From f61e6b59c9dbbbc7a1a417c3eedc4cf2bd321a3e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:44:20 -0700 Subject: [PATCH] fix(native-chat): a message the host keeps refusing past its window comes back A host that refuses a send before looking up its id (a host with structured chats turned off) refuses every resend the same way. Since a message past the host's window is no longer handed back on wake, such a message was resent forever and held up everything queued behind it. Now, past the host's window for the id, an answer the host itself gave that settles nothing (a refusal returned or thrown, or a call turned away) hands the message back with "couldn't confirm" words, unless the journal shows its row. Only a lost connection keeps it resending, since that says nothing about the host. Derived from that answer and the id's own time; nothing new is stored. Also corrects the host-window docstring, which still said an answer that never comes ends it. --- ...tructured-agent-session-outbox-dispatch.ts | 4 +- ...nt-session-outbox.offline-journal.test.tsx | 43 ++++++++++++++++++ ...tured-agent-session-draft-hand-off.test.ts | 2 +- ...ed-agent-session-outbox-settlement.test.ts | 45 ++++++++++++++++++- ...uctured-agent-session-outbox-settlement.ts | 32 ++++++++++++- ...gent-session-outbox.queue-delivery.test.ts | 2 +- 6 files changed, 122 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts index 89d69cc6f5f..6f35a1e04fa 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -13,6 +13,7 @@ import { import { applyStructuredAgentSessionOutboxSettlement, settleStructuredAgentSessionSendAnswer, + structuredAgentSessionEntryOutlivedHostWindow, type StructuredAgentSessionSendAnswer, type StructuredAgentSessionOutboxSettlement, type StructuredAgentSessionSettledOutbox @@ -279,7 +280,8 @@ export async function sendStructuredAgentSessionOutboxEntry(args: { const settlement = settleStructuredAgentSessionSendAnswer(answer, next.clientMessageId, { firstAttempt: onlyAttempt, answersProve, - journalHasRow: args.journalHasRow?.(next.clientMessageId) ?? false + journalHasRow: args.journalHasRow?.(next.clientMessageId) ?? false, + outlivedHostWindow: structuredAgentSessionEntryOutlivedHostWindow(next, Date.now()) }) settle(settlement) return settlement diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.offline-journal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.offline-journal.test.tsx index d80302620a3..fdfcf7bab9e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.offline-journal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.offline-journal.test.tsx @@ -21,6 +21,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) import { createBrowserUuid } from '@/lib/browser-uuid' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { useStructuredAgentSessionConversationStop } from './use-structured-agent-session-conversation-stop' import type { @@ -149,4 +150,46 @@ describe('while the journal is not live', () => { "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed." ) }) + + // A host that refuses before looking the id up refuses it every time: past its window that + // answer settles nothing, so the message comes back to be checked instead of blocking the queue. + it('a message past the window the host keeps refusing comes back after the next attempt', async () => { + seedSent('host refuses', Date.now() - AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS - 60_000) + // As the client receives a refusal the host threw: an RPC error carrying it. + mocks.call.mockImplementation(async () => { + throw new RuntimeRpcCallError({ + id: 'rpc-1', + ok: false, + error: { + code: 'runtime_error', + message: 'structured_agent_session_unsupported', + data: { + refusal: { + code: 'structured_agent_session_unsupported', + details: { reason: 'hostDisabled' } + } + } + } + }) + }) + mountChat(asWriteAs(async () => LOST)) + + await vi.waitFor(() => expect(readNativeChatDraftCache(SCOPE)).toBe('host refuses'), { + timeout: 3000 + }) + expect(mocks.call).toHaveBeenCalledOnce() + expect(readOutbox('session-1')).toEqual([]) + }) + + it('a message past the window that only meets a lost connection keeps being sent', async () => { + seedSent('no contact', Date.now() - AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS - 60_000) + mocks.call.mockRejectedValue(new Error('socket closed')) + mountChat(asWriteAs(async () => LOST)) + + await vi.waitFor(() => expect(mocks.call.mock.calls.length).toBeGreaterThanOrEqual(2), { + timeout: 5000 + }) + expect(readOutbox('session-1')).toHaveLength(1) + expect(readNativeChatDraftCache(SCOPE)).toBe('') + }, 10000) }) diff --git a/src/shared/structured-agent-session-draft-hand-off.test.ts b/src/shared/structured-agent-session-draft-hand-off.test.ts index 0058a80f4b0..304f8b93270 100644 --- a/src/shared/structured-agent-session-draft-hand-off.test.ts +++ b/src/shared/structured-agent-session-draft-hand-off.test.ts @@ -63,7 +63,7 @@ describe('a queued draft handed off under a fresh submission id', () => { } }, 'draft', - { firstAttempt: false, answersProve: true, journalHasRow: false } + { firstAttempt: false, answersProve: true, journalHasRow: false, outlivedHostWindow: false } ) ).toEqual({ kind: 'recorded' }) }) diff --git a/src/shared/structured-agent-session-outbox-settlement.test.ts b/src/shared/structured-agent-session-outbox-settlement.test.ts index e8abc6cc996..06e3be725d5 100644 --- a/src/shared/structured-agent-session-outbox-settlement.test.ts +++ b/src/shared/structured-agent-session-outbox-settlement.test.ts @@ -90,7 +90,8 @@ function refused( const FIRST: StructuredAgentSessionOutboxSettlementContext = { firstAttempt: true, answersProve: true, - journalHasRow: false + journalHasRow: false, + outlivedHostWindow: false } const RESEND_PROVING: StructuredAgentSessionOutboxSettlementContext = { ...FIRST, @@ -617,6 +618,48 @@ describe('applying a settlement', () => { }) }) +describe("past the host's window, a host answer that settles nothing", () => { + const PAST = { ...RESEND_OLD_HOST, outlivedHostWindow: true } + const hostAnswers: [string, StructuredAgentSessionSendAnswer][] = [ + ['a refusal it returned', refused('agent_session_journal_unreadable')], + [ + 'a refusal it threw', + { + kind: 'thrown', + refusal: { kind: 'refused', code: 'structured_agent_session_unsupported' }, + rpcCode: undefined + } + ], + ['a call it turned away', { kind: 'thrown', refusal: undefined, rpcCode: 'method_not_found' }] + ] + + it.each(hostAnswers)( + '%s: comes back to check the chat, or is the row it shows', + (_label, answer) => { + expect(settleStructuredAgentSessionSendAnswer(answer, ID, RESEND_OLD_HOST).kind).toBe( + 'unanswered' + ) + expect(settleStructuredAgentSessionSendAnswer(answer, ID, PAST)).toEqual({ + kind: 'returned', + words: ['sendOutcomeLost'] + }) + expect( + settleStructuredAgentSessionSendAnswer(answer, ID, { ...PAST, journalHasRow: true }) + ).toEqual({ kind: 'recorded' }) + } + ) + + it('but a lost connection is no answer from the host, so it still goes again', () => { + expect( + settleStructuredAgentSessionSendAnswer( + { kind: 'thrown', refusal: undefined, rpcCode: 'runtime_timeout' }, + ID, + PAST + ) + ).toEqual({ kind: 'unanswered' }) + }) +}) + describe('the host window bounds every entry', () => { const pastWindow = MADE_AT + AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS + 1 const reading = { diff --git a/src/shared/structured-agent-session-outbox-settlement.ts b/src/shared/structured-agent-session-outbox-settlement.ts index da2f43c237f..8ff77776c98 100644 --- a/src/shared/structured-agent-session-outbox-settlement.ts +++ b/src/shared/structured-agent-session-outbox-settlement.ts @@ -57,6 +57,9 @@ export type StructuredAgentSessionOutboxSettlementContext = { answersProve: boolean /** The loaded journal holds a row for this id. */ journalHasRow: boolean + /** The host's window for this id has closed, by the id's own time + * (`structuredAgentSessionEntryOutlivedHostWindow`). */ + outlivedHostWindow: boolean } /** Words for a message Orca can no longer settle with the host: an earlier attempt may already be @@ -135,10 +138,34 @@ function thrownSettlement( } /** The host's answer to one attempt, as one of the three ends. */ +/** Whether the host itself answered: a refusal it returned or threw, or a code it turned the call + * away with. Anything else is the transport, which says nothing about the host. */ +function answeredByHost(answer: StructuredAgentSessionSendAnswer): boolean { + return ( + answer.kind === 'result' || + answer.refusal !== undefined || + agentSessionRpcErrorFailure(answer.rpcCode).kind === 'refused' + ) +} + +/** The host's answer to one attempt, as one of the three ends. Past the host's window for the id, + * an answer the host gave that settles nothing never will (a host that refuses before looking the + * id up refuses it every time), so the journal decides; lost contact still only goes again. */ export function settleStructuredAgentSessionSendAnswer( answer: StructuredAgentSessionSendAnswer, clientMessageId: string, context: StructuredAgentSessionOutboxSettlementContext +): StructuredAgentSessionOutboxSettlement { + const settlement = settleAnswer(answer, clientMessageId, context) + return settlement.kind === 'unanswered' && context.outlivedHostWindow && answeredByHost(answer) + ? settledByJournal(context) + : settlement +} + +function settleAnswer( + answer: StructuredAgentSessionSendAnswer, + clientMessageId: string, + context: StructuredAgentSessionOutboxSettlementContext ): StructuredAgentSessionOutboxSettlement { if (answer.kind === 'thrown') { return thrownSettlement(answer, context) @@ -177,8 +204,9 @@ export function structuredAgentSessionEntryHostWindowEndsAt( return madeAt === null ? null : madeAt + AGENT_SESSION_MAX_OPERATION_REPLAY_AGE_MS } -/** Whether the host can no longer settle this id, so a host that keeps failing the request, or an - * answer that never comes, can't hold the entry forever. */ +/** Whether the host can no longer settle this id. Past it, a host answer that settles nothing + * hands the entry back for the journal to decide, and a live journal settles one only an owed + * answer would; lost contact alone keeps it, since it says nothing about the host. */ export function structuredAgentSessionEntryOutlivedHostWindow( entry: Pick, now: number diff --git a/src/shared/structured-agent-session-outbox.queue-delivery.test.ts b/src/shared/structured-agent-session-outbox.queue-delivery.test.ts index 18dcade0de3..a347f8c443a 100644 --- a/src/shared/structured-agent-session-outbox.queue-delivery.test.ts +++ b/src/shared/structured-agent-session-outbox.queue-delivery.test.ts @@ -147,7 +147,7 @@ describe('outbox queue delivery', () => { } }, 'client-1', - { firstAttempt: true, answersProve: false, journalHasRow: false } + { firstAttempt: true, answersProve: false, journalHasRow: false, outlivedHostWindow: false } ) const stopped = stopStructuredAgentSessionOutbox([staged], [], 'client-1', 'stop-1').entries for (const entries of [stopped, [staged]]) {