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]]) {