fix(native-chat): read an older host's restart answer as recovered when sending, too

Hosts from before the recovered marker publish only the restart reason. The
working-state rule already read that reason as recovered; the send answer now
uses the same predicate, so both agree on those hosts.
This commit is contained in:
Brennan Benson
2026-10-03 22:46:54 -07:00
parent d9e060543f
commit 87f2c0f92a
4 changed files with 91 additions and 4 deletions
@@ -411,6 +411,33 @@ describe('ambiguous operation refusals', () => {
expect(disposition.entries).toMatchObject([{ clientMessageId: 'fresh-id', state: 'rejected' }])
})
it("parks an older host's restart answer that carries the reason but not the recovered marker", () => {
const result = rejectedWith(null)
if (!result.ok || !('submission' in result.value)) {
throw new Error('expected a send result')
}
result.value.submission = {
...result.value.submission,
dispatchState: 'unknown',
reason: 'host_restarted_before_acknowledgement'
}
const disposition = disposeStructuredAgentSessionSendResult({
entries: [entry],
entry,
result,
createOperationId: () => 'unused'
})
expect(disposition.entries).toMatchObject([
{
clientMessageId: entry.clientMessageId,
state: 'unconfirmed',
retryAfterUnknownSubmittedAt: -1
}
])
})
it('parks a recovered missing submission without polling forever', () => {
const result = rejectedWith(null)
if (!result.ok || !('submission' in result.value)) {
@@ -22,6 +22,7 @@ import {
import type { AgentSessionFailureFact } from './agent-session-failure'
import type { AgentSessionFailureWordsContext } from './agent-session-failure-words'
import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection'
import { isRecoveredStructuredAgentSessionSubmission } from './structured-agent-session-unanswered-dispatch'
import {
classifyStructuredAgentSessionSendFailure,
requeueStructuredAgentSessionSendRefusal,
@@ -277,7 +278,7 @@ export function disposeStructuredAgentSessionSendResult(
error: null
}
}
if (submission.dispatchState === 'unknown' && submission.recovered) {
if (isRecoveredStructuredAgentSessionSubmission(submission)) {
return {
entries: input.entries.map((candidate) =>
candidate.clientMessageId === input.entry.clientMessageId
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import type { AgentJournalSubmission } from './agent-session-journal-types'
import {
isRecoveredStructuredAgentSessionSubmission,
isUnansweredStructuredAgentSessionDispatch
} from './structured-agent-session-unanswered-dispatch'
function submission(patch: Partial<AgentJournalSubmission>): AgentJournalSubmission {
return {
clientMessageId: 'client-1',
fence: 1,
payloadFingerprint: 'fingerprint',
dispatchState: 'unknown',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: 1,
...patch
}
}
describe('a send whose outcome the host lost for good', () => {
it('is one the host marked recovered', () => {
const row = submission({ recovered: true, reason: 'provider_exited_before_acknowledgement' })
expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(true)
expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(false)
})
it("is an older host's restart row, which carries the reason without the marker", () => {
const row = submission({ reason: 'host_restarted_before_acknowledgement' })
expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(true)
expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(false)
})
it('is never a live unknown, which something still running may answer', () => {
const row = submission({ reason: 'provider_ack_ambiguous' })
expect(isRecoveredStructuredAgentSessionSubmission(row)).toBe(false)
expect(isUnansweredStructuredAgentSessionDispatch(row)).toBe(true)
})
it('is never a settled send, whatever its reason says', () => {
expect(
isRecoveredStructuredAgentSessionSubmission(
submission({ dispatchState: 'rejected', reason: 'host_restarted_before_acknowledgement' })
)
).toBe(false)
})
})
@@ -1,6 +1,19 @@
import type { AgentJournalSubmission } from './agent-session-journal-types'
import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission'
/** A send whose outcome the host lost for good when the process that sent it went away (a restart,
* a provider exit, an idle release): nothing still running can answer it. */
export function isRecoveredStructuredAgentSessionSubmission(
submission: Pick<AgentJournalSubmission, 'dispatchState' | 'recovered' | 'reason'>
): boolean {
return (
submission.dispatchState === 'unknown' &&
(submission.recovered === true ||
// Older hosts publish the recovery reason but omit the optional marker.
submission.reason === 'host_restarted_before_acknowledgement')
)
}
/** One send the provider has neither opened a turn for nor refused; the rule is explained on
* `hasUnansweredStructuredAgentSessionDispatch`, which asks it of every send. */
export function isUnansweredStructuredAgentSessionDispatch(
@@ -15,9 +28,7 @@ export function isUnansweredStructuredAgentSessionDispatch(
(currentFence == null || submission.fence >= currentFence) &&
(submission.dispatchState === 'pending' ||
(submission.dispatchState === 'unknown' &&
submission.recovered !== true &&
// Older hosts publish the recovery reason but omit the optional marker.
submission.reason !== 'host_restarted_before_acknowledgement'))
!isRecoveredStructuredAgentSessionSubmission(submission)))
)
}