diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 89722959799d..0fc9b9d394f3 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -30,6 +30,15 @@ export function applyJournalDispatchRow( } else { delete submission.rejection } + if ( + row.state === 'rejected' && + typeof row.rejectedByStartKey === 'string' && + row.rejectedByStartKey + ) { + submission.rejectedByStartKey = row.rejectedByStartKey + } else { + delete submission.rejectedByStartKey + } submission.resolvedAt = row.state === 'pending' ? null : row.ts if (row.state === 'pending') { submission.handedOverAt = row.ts diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index c252a57d8a57..7048d0d4210d 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -1,8 +1,8 @@ -import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from './journal-store' +import type { JournalDispatchRejectionInput } from './journal-store-contracts' /** Settles every submission a process fact left unanswerable. Doubt is never * proof of non-delivery, so nothing here ever becomes re-deliverable. A queued @@ -41,7 +41,7 @@ export async function markJournalPendingSubmissionsUnknown( export async function rejectJournalPendingSubmissions( journal: AgentSessionJournal, fence: number, - rejection: AgentJournalDispatchRejection + rejection: JournalDispatchRejectionInput ): Promise { const unwritten = journal .submissions() @@ -67,7 +67,7 @@ export async function rejectJournalPendingSubmissions( export async function rejectJournalQueuedSubmissions( journal: AgentSessionJournal, fence: number, - rejection: AgentJournalDispatchRejection, + rejection: JournalDispatchRejectionInput, which: (submission: AgentJournalSubmission) => boolean = () => true ): Promise { const queued = journal diff --git a/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts index 3332e53b0b3e..296256af3f29 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer-rejection-fact.test.ts @@ -10,7 +10,7 @@ import { createJournalReducerState, type JournalReducerState } from './journal-reducer' -import type { JournalRow } from './journal-row-schema' +import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' const EPOCH = 'epoch-1' @@ -97,4 +97,53 @@ describe('a rejected dispatch', () => { expect(malformed).not.toHaveProperty('rejection') expect(malformed && classifyDispatchRejection(malformed)).toMatchObject({ kind: 'queueFull' }) }) + + // Set only by a failed start's writer; a host from before it, or any other writer, writes none. + describe('the start that rejected it', () => { + const rejectedBy = (rejectedByStartKey: unknown, state = 'rejected'): JournalRow => { + const row: JournalRow = { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: state === 'rejected' ? 'rejected' : 'unknown', + providerItemId: null, + reason: "Codex couldn't start.", + rejection: { kind: 'startFailed' }, + ...base(2) + } + // A row read from disk carries whatever the host that wrote it did. + return Object.assign(row, { rejectedByStartKey }) + } + + it('copies it onto a rejected submission, beside the fact it leaves alone', () => { + const settled = fold([submission, rejectedBy('generation-2')]).submissions.get('cm_1') + expect(settled).toMatchObject({ + dispatchState: 'rejected', + rejection: { kind: 'startFailed' }, + rejectedByStartKey: 'generation-2' + }) + const unnamed = fold([submission, rejectedBy(undefined)]).submissions.get('cm_1') + expect(unnamed).not.toHaveProperty('rejectedByStartKey') + // Identity, not the situation: the verdict is the same whichever start it names. + expect(settled && classifyDispatchRejection(settled)).toEqual( + unnamed && classifyDispatchRejection(unnamed) + ) + const doubt = fold([submission, rejectedBy('generation-2', 'unknown')]).submissions.get( + 'cm_1' + ) + expect(doubt).not.toHaveProperty('rejectedByStartKey') + }) + + it('drops a malformed one when read, never the row', () => { + for (const malformed of ['', 7, { generation: 'generation-2' }]) { + const parsed = parseJournalRow(serializeJournalRow(rejectedBy(malformed))) + expect(parsed.ok).toBe(true) + const settled = parsed.ok ? fold([submission, parsed.row]).submissions.get('cm_1') : null + expect(settled).toMatchObject({ + dispatchState: 'rejected', + rejection: { kind: 'startFailed' } + }) + expect(settled).not.toHaveProperty('rejectedByStartKey') + } + }) + }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 46d41ecb7cfe..6f9bda6b9408 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -109,6 +109,9 @@ export function journalDispatchRowBuilder( providerItemId, reason: boundedDispatchReason(input), ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), + ...(input.state === 'rejected' && input.rejectedByStartKey + ? { rejectedByStartKey: input.rejectedByStartKey } + : {}), ...journalRowBase(state().epoch, seq, input.fence, ts), ...(input.recovered ? { recovered: input.recovered } : {}), ...(input.state === 'pending' ? { turnScope: input.turnScope } : {}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 1c7ea36eaefd..f4b590a7ab81 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -110,6 +110,9 @@ export type JournalDispatchRow = JournalRowBase & { /** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is * dropped when read, never the row. */ rejection?: AgentSessionFailureFact + /** On `rejected` by a failed start: that start's key. Older readers keep the key and ignore it; + * anything but a non-empty string is dropped when read, never the row. */ + rejectedByStartKey?: string } /** An item mutation may name its own producer, because one batch can CREATE diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 29f0db6bed63..f8cf1927fd94 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -40,10 +40,16 @@ export type ResolveDispatchInput = { | { state: 'pending'; turnScope: AgentJournalTurnScope } /** `reason` is what released clients print, `rejection` what newer ones read: both from * `agentSessionFailureWords`, never written by hand. */ - | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | ({ state: 'rejected' } & JournalDispatchRejectionInput) | { state: 'unknown'; reason?: string | null } ) +/** A rejection, and the failed start that wrote it when it is one: only that start's writer + * names it, so its messages pair with its row by the row's own key. */ +export type JournalDispatchRejectionInput = AgentJournalDispatchRejection & { + rejectedByStartKey?: string +} + export type JournalAppendResult = { cursor: AgentJournalCursor itemId: string diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 5b046ab421f2..fbad993914cc 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -1,7 +1,6 @@ // Append-only journal store for one agent session. It owns the chat's fold and write queue, and no // connection: every statement goes through the host's one journal database. -import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import { randomUUID } from 'node:crypto' import type { AgentJournalAcceptanceReceipt, @@ -48,6 +47,7 @@ import { import type { AgentSessionJournalOptions, JournalAppendResult, + JournalDispatchRejectionInput, JournalItemAppendOptions, JournalItemLinkageVisitor, JournalLifecycleBatchInput, @@ -347,7 +347,7 @@ export class AgentSessionJournal { /** Reject unanswered sends after an owner that never proved its start ended: none was written. */ async rejectPendingSubmissions( fence: number, - rejection: AgentJournalDispatchRejection + rejection: JournalDispatchRejectionInput ): Promise { return rejectJournalPendingSubmissions(this, fence, rejection) } @@ -355,7 +355,7 @@ export class AgentSessionJournal { /** Reject sends accepted but never handed over, optionally only those `which` names. */ async rejectQueuedSubmissions( fence: number, - rejection: AgentJournalDispatchRejection, + rejection: JournalDispatchRejectionInput, which?: (submission: AgentJournalSubmission) => boolean ): Promise { return rejectJournalQueuedSubmissions(this, fence, rejection, which) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index b7371ee40f36..49c3d9a252d8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -336,11 +336,13 @@ describe('a start the chat needed and did not get', () => { refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } } expect(await errorFailures()).toEqual([failure]) + // No child was published, so the start is keyed by the oldest message it was for. for (const id of [first, second]) { expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', reason: rows[0], - rejection: failure + rejection: failure, + rejectedByStartKey: first }) } @@ -444,6 +446,7 @@ describe('a start the chat needed and did not get', () => { await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) expect(await errorRows()).toEqual([row.text]) expect(await errorFailures()).toEqual([row.failure]) + expect(await submission(id)).toMatchObject({ rejectedByStartKey: id }) const framedRows = events.flatMap((event) => event.type === 'batch' || event.type === 'snapshot' ? (event.type === 'batch' ? event.batch.items : event.page.items).filter( @@ -535,6 +538,8 @@ describe('what an earlier host process left behind', () => { surface: 'rejection' }) }) + // No start rejected it, so it names none. + expect(await submission('queued')).not.toHaveProperty('rejectedByStartKey') expect(dispatch).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index cb83dba870a1..166e5268cc1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -25,6 +25,7 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemIdentity, type AgentJournalMessageItem, + type AgentJournalStatusItem, type AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' @@ -41,7 +42,6 @@ import type { StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' -import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { conversationCommandBlocked } from './structured-conversation-command-admission' export const STRUCTURED_AGENT_SESSION_COMPACT_COMMAND = 'compact' @@ -157,12 +157,14 @@ export type StructuredAgentSessionCommandHandoverContext = { now: () => number } -/** Refuses the command, or opens its turn and sends it. */ +/** Refuses the command, or opens its turn and sends it. Returns the cause when the child's start + * failed at the handover, leaving the command pending for the caller to record that start's + * failure for it and everything queued behind it. */ export async function handOverStructuredAgentSessionCommand( ctx: StructuredAgentSessionCommandHandoverContext, submission: AgentJournalSubmission, body: AgentJournalMessageItem -): Promise { +): Promise<{ error: unknown } | null> { const { clientMessageId } = submission // Provider frames already received decide whether a turn is running. await ctx.flushStreamedEvents() @@ -174,7 +176,7 @@ export async function handOverStructuredAgentSessionCommand( ...agentSessionFailureWords(blocked, { ...ctx.failureTextContext, surface: 'rejection' }), fence: ctx.fence }) - return + return null } const turn = structuredAgentSessionCommandTurn(clientMessageId) await ctx.journal.resolveDispatch({ @@ -204,20 +206,16 @@ export async function handOverStructuredAgentSessionCommand( command: { clientMessageId, ...turn, running } }) } catch (error) { - // A child that had not proven its start took nothing, so the command provably did not run. Any - // other throw is a lost reply: the command may have run. - const unsent = - ctx.providerChildPhase?.() === 'starting' - ? { - state: 'rejected' as const, - ...structuredAgentSessionStartFailure({ error }, ctx.failureTextContext) - } - : { - state: 'unknown' as const, - reason: error instanceof Error ? error.message : String(error) - } - await settleUnsentCommand(ctx, clientMessageId, unsent) - return + // A child that had not proven its start took nothing: its start failed. Any other throw is a + // lost reply: the command may have run. + if (ctx.providerChildPhase?.() === 'starting') { + return { error } + } + await settleUnsentCommand(ctx, clientMessageId, { + state: 'unknown', + reason: error instanceof Error ? error.message : String(error) + }) + return null } if (admission.state === 'rejected') { // The provider refused the compaction itself: its row reads as the compaction failing. @@ -231,6 +229,7 @@ export async function handOverStructuredAgentSessionCommand( // An unknown write leaves the turn to the provider's end or the child's: it may have run. await ctx.journal.resolveDispatch({ clientMessageId, ...admission, fence: ctx.fence }) } + return null } /** Where the turn a handed-over submission runs in starts counting: its handover, so time spent @@ -256,28 +255,52 @@ async function settleUnsentCommand( | { state: 'unknown'; reason: string }, /** What the result row reports, when it is not the rejection's own fact. */ rowFailure?: AgentSessionFailureFact +): Promise { + await ctx.journal.resolveDispatch({ clientMessageId, ...unsent, fence: ctx.fence }) + await endUnsentCommandTurn( + ctx, + clientMessageId, + unsent.state === 'rejected' + ? { + kind: 'status', + ...agentSessionFailureWords(rowFailure ?? unsent.rejection, { + ...ctx.failureTextContext, + surface: 'row' + }), + tone: 'error' + } + : null + ) +} + +/** A command whose start failed at its handover, once that start's writer has rejected it: its + * turn ends failed, and the start's row is its one row. */ +export async function endStructuredAgentSessionCommandStartFailure( + ctx: Pick, + clientMessageId: string +): Promise { + await endUnsentCommandTurn(ctx, clientMessageId, 'failed') +} + +/** Ends the command's turn if it still runs: failed when the command was refused (`result` is its + * row, if it has one of its own), else unverifiable. */ +async function endUnsentCommandTurn( + ctx: Pick, + clientMessageId: string, + result: AgentJournalStatusItem | 'failed' | null ): Promise { const turn = structuredAgentSessionCommandTurn(clientMessageId) const running = readAgentJournalTurn(ctx.journal.itemBody(turn.itemId) ?? undefined) - await ctx.journal.resolveDispatch({ clientMessageId, ...unsent, fence: ctx.fence }) if (running?.state !== 'running') { return } - const refused = unsent.state === 'rejected' const mutations: JournalLifecycleMutationInput[] = [ - ...(refused + ...(result && result !== 'failed' ? [ { kind: 'item' as const, identity: turn.resultIdentity, - body: { - kind: 'status' as const, - ...agentSessionFailureWords(rowFailure ?? unsent.rejection, { - ...ctx.failureTextContext, - surface: 'row' - }), - tone: 'error' as const - }, + body: result, turnScope: { kind: 'turn' as const, turnItemId: turn.itemId } } ] @@ -287,7 +310,7 @@ async function settleUnsentCommand( identity: turn.identity, body: agentJournalTurnBody({ ...running, - ...(refused + ...(result ? { state: 'completed', outcome: 'failure', completedAt: ctx.now() } : { state: 'unverifiable' }) }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 6677a50b4470..757373eedea8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -11,6 +11,12 @@ import { providerDiagnostic } from '../../../shared/agent-session-failure' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import { + recordStructuredAgentSessionStartFailure, + structuredAgentSessionStartFailureRow +} from './structured-agent-session-start-failure-row' import { captureUnfinishedStructuredAgentSessionWork, settleStructuredAgentSessionDeadGeneration, @@ -22,6 +28,7 @@ const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'The agent stopped while this response was in progress. You can continue in this conversation.' const SESSION = 'session-dead-generation' +const START_ROW = `orca:${encodeURIComponent('start-failure:generation-1')}` const THREAD = 'thread-1' let root: string let journal: AgentSessionJournal @@ -318,18 +325,187 @@ describe('dead structured-session generation settlement', () => { exitedDuringStartup: { generation: 'generation-1' } }) - // The sentence is Orca's; the stderr the exit carried rides as a log detail only. + // The sentence is Orca's; the stderr the exit carried rides as a log detail only. The message + // names the start, the row's own key; the row's fact is only the failure. + const reason = 'The agent stopped before it finished starting. Send your message to try again.' + const rejection = { + kind: 'providerStartFailed', + detail: { text: 'code 1\nnot signed in', audience: 'log' } + } + expect(journal.submissions()).toEqual([ + expect.objectContaining({ + clientMessageId: 'client-held', + dispatchState: 'rejected', + reason, + rejection, + rejectedByStartKey: 'generation-1' + }) + ]) + expect(journal.itemBody(START_ROW)).toEqual({ + kind: 'status', + text: reason, + tone: 'error', + failure: rejection + }) + }) + + // An earlier report of the same start wrote its row first; its words are the start's. + it("rejects a send the start left pending in the start's row's words, leaving the row", async () => { + const first = agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { + surface: 'rejection' + }) + const row = structuredAgentSessionStartFailureRow('generation-1', first) + await journal.appendLifecycleBatch({ + settlementId: 'start-failure:generation-1', + fence: 7, + recovered: true, + mutations: [row] + }) + const written = journal.itemBody(START_ROW) + await journal.appendSubmission({ + clientMessageId: 'client-held', + payloadFingerprint: 'fingerprint', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello?' }] }, + fence: 7 + }) + + await settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `provider-exit:${SESSION}:7:generation-1`, + pendingSubmissionReason: 'provider_closed_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_000 }, + exitFailure: agentSessionFailureFact('providerExited'), + exitedDuringStartup: { generation: 'generation-1' } + }) + expect(journal.submissions()).toEqual([ expect.objectContaining({ clientMessageId: 'client-held', dispatchState: 'rejected', - reason: 'The agent stopped before it finished starting. Send your message to try again.', - rejection: { - kind: 'providerStartFailed', - detail: { text: 'code 1\nnot signed in', audience: 'log' } - } + reason: first.reason, + rejection: { kind: 'notSignedIn' }, + rejectedByStartKey: 'generation-1' }) ]) + expect(journal.itemBody(START_ROW)).toEqual(written) + }) + + // The exit and the delivery loop each report one start: the exit what the child was handed, the + // loop what is still queued. Whichever reports first writes the start's row; both reject in it. + describe('a start that failed with one message handed over and one still queued', () => { + const START_KEY = 'generation-1' + const loopFailure = structuredAgentSessionStartFailure({ + failure: agentSessionFailureFact('hostStopped') + }) + + async function handedOverAndQueued(): Promise { + for (const clientMessageId of ['client-handed', 'client-queued']) { + await journal.appendSubmission({ + clientMessageId, + payloadFingerprint: 'fingerprint', + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: clientMessageId }] + }, + fence: 7, + handoverRecorded: true + }) + } + await journal.resolveDispatch({ + clientMessageId: 'client-handed', + state: 'pending', + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + + function settleExit(): Promise { + return settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `provider-exit:${SESSION}:7:${START_KEY}`, + pendingSubmissionReason: 'provider_closed_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_000 }, + exitFailure: agentSessionFailureFact('notSignedIn'), + exitedDuringStartup: { generation: START_KEY } + }) + } + + function reportFromLoop(): Promise { + return recordStructuredAgentSessionStartFailure( + { journal, fence: 7 }, + { startKey: START_KEY, ...loopFailure } + ) + } + + function startRows(): unknown[] { + return journal + .snapshot() + .items.filter((item) => + item.itemId.startsWith(`orca:${encodeURIComponent('start-failure:')}`) + ) + } + + it.each([ + ['the exit first', [settleExit, reportFromLoop]], + ['the delivery loop first', [reportFromLoop, settleExit]] + ] as const)('writes one row and rejects both in its words, %s', async (_order, reports) => { + await handedOverAndQueued() + + for (const report of reports) { + await report() + } + + expect(startRows()).toHaveLength(1) + const row = journal.itemBody(START_ROW) + if (row?.kind !== 'status') { + throw new Error('no start row') + } + expect(journal.submissions()).toEqual( + ['client-handed', 'client-queued'].map((clientMessageId) => + expect.objectContaining({ + clientMessageId, + dispatchState: 'rejected', + reason: row.text, + rejection: row.failure, + rejectedByStartKey: START_KEY + }) + ) + ) + }) + + // Its messages were never handed to this child, so the start is the loop's to report. + it('leaves the row to the delivery loop when the exit rejected nothing it was handed', async () => { + await journal.appendSubmission({ + clientMessageId: 'client-queued', + payloadFingerprint: 'fingerprint', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello?' }] }, + fence: 7, + handoverRecorded: true + }) + + // As the exit of a start always settles: a failed start says why. + await settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `provider-exit:${SESSION}:7:${START_KEY}`, + pendingSubmissionReason: 'provider_closed_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_000 }, + showUnexpectedExitOutcome: true, + exitFailure: agentSessionFailureFact('notSignedIn'), + exitedDuringStartup: { generation: START_KEY } + }) + + expect(startRows()).toEqual([]) + expect(journal.submissions()).toEqual([ + expect.objectContaining({ clientMessageId: 'client-queued', dispatchState: 'pending' }) + ]) + }) }) it("keeps a subagent's settled rows the subagent's, in one batch and after a reopen", async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 72caac40844d..d5afbf29a632 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -21,8 +21,9 @@ import { } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { - hasStructuredAgentSessionStartFailureRow, - structuredAgentSessionStartFailureRow + structuredAgentSessionStartFailureRejection, + structuredAgentSessionStartFailureRow, + structuredAgentSessionStartFailureRowItemId } from './structured-agent-session-start-failure-row' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { @@ -102,6 +103,14 @@ export function unfinishedStructuredAgentSessionWorkWasInterrupted( return outcomeItems.some((item) => !isCleanlySettled(currentItems.get(item.itemId))) } +function startFailureRowBody( + journal: DeadGenerationJournal, + startKey: string +): AgentJournalItemBody | undefined { + const itemId = structuredAgentSessionStartFailureRowItemId(startKey) + return journal.snapshot().items.find((item) => item.itemId === itemId)?.body +} + export async function settleStructuredAgentSessionDeadGeneration(input: { journal: DeadGenerationJournal sessionId: string @@ -115,8 +124,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { /** Who a failed start's sentence names. */ failureTextContext?: AgentSessionFailureWordsContext /** The provider never finished starting: the start that failed, keyed by the child's - * generation. Its row is the one the delivery loop writes for the same start. */ - exitedDuringStartup?: { generation: string | null } + * generation, as the delivery loop keys the same start's one row. */ + exitedDuringStartup?: { generation: string } onError?: (sessionId: string, error: unknown) => void }): Promise { try { @@ -129,24 +138,33 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { // child that never proved its start accepted nothing either — input is written only after it // initializes — so every send it was handed is rejected with the child's own diagnostic. A // proven child's handed-over sends stay in doubt. - const startupFailure = input.exitedDuringStartup - ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) - : null - await (startupFailure - ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) + const startKey = input.exitedDuringStartup?.generation ?? null + const startupFailure = + startKey === null + ? null + : structuredAgentSessionStartFailureRejection( + startFailureRowBody(input.journal, startKey), + startKey, + structuredAgentSessionStartFailure( + { exit: input.exitFailure }, + input.failureTextContext + ) + ) + const rejected = await (startupFailure + ? input.journal.rejectPendingSubmissions(input.fence, startupFailure.words) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] - if (showUnexpectedExitOutcome && input.exitedDuringStartup && startupFailure) { - const startKey = input.exitedDuringStartup.generation ?? input.settlementId - // A start a message waited on is the delivery loop's to record, before or after this exit, - // in the words it rejected the message with; this row is for a command, goal or rewind start. - // A row already written stays: rejected is terminal, so its words are not reworded. - const recordedByDeliveryLoop = - input.journal.submissions?.().some(isQueuedAgentJournalSubmission) || - hasStructuredAgentSessionStartFailureRow(items, startKey) - if (!recordedByDeliveryLoop) { - mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure)) + if (showUnexpectedExitOutcome && startKey !== null && startupFailure) { + // A row already written stays: rejected is terminal, so its words are not reworded. A start + // that rejected what it was handed writes its row now, beside those messages; the delivery + // loop keys the same start by the same generation, so its later report rejects what is queued + // in the row's words. A start that was handed nothing leaves the row to the loop's report. + // With nothing queued, this is a command, goal or rewind start, and the row is this + // settlement's. + const queued = input.journal.submissions?.().some(isQueuedAgentJournalSubmission) + if (!startupFailure.written && (!queued || rejected.length > 0)) { + mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure.words)) } } else if (showUnexpectedExitOutcome) { // The turn the exit ended, and an error so no fold ever hides why it stopped. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 373e2ae3c36e..d9f00c14a55f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -37,7 +37,10 @@ import { } from './structured-agent-session-start-failure-row' import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' -import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' +import { + endStructuredAgentSessionCommandStartFailure, + structuredAgentSessionCommandRunning +} from './structured-agent-session-command-turn' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -209,7 +212,7 @@ export class StructuredAgentSessionDeliveryLoop { if (!next) { return this.stop(sessionId) } - await handOverSubmission( + const unstarted = await handOverSubmission( { sessionId, journal: session.journal, @@ -223,10 +226,22 @@ export class StructuredAgentSessionDeliveryLoop { }, next ) + if (unstarted) { + // The start failed for this message and every one behind it, not for this one alone. + return this.fail( + sessionId, + { startKey: awaitedChild.generation, cause: unstarted }, + next.clientMessageId + ) + } return 'continue' } - private async fail(sessionId: string, failure: StartFailure): Promise<'stop'> { + private async fail( + sessionId: string, + failure: StartFailure, + handedOver?: string + ): Promise<'stop'> { const session = this.deps.sessions.get(sessionId) if (session) { await recordStructuredAgentSessionStartFailure( @@ -237,8 +252,20 @@ export class StructuredAgentSessionDeliveryLoop { failure.cause, this.deps.failureTextContext(sessionId) ) - } + }, + handedOver ) + if (handedOver) { + // A command opens its own turn at handover; the start's row answers it, so the turn ends. + await endStructuredAgentSessionCommandStartFailure( + { + journal: session.journal, + fence: this.deps.conversationFence(sessionId), + now: this.deps.now + }, + handedOver + ) + } } return this.stop(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 75061435a1fe..bd27642d6a8e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -416,7 +416,8 @@ describe('a published child that dies while it proves its start', () => { expect(await submission(id)).toMatchObject({ dispatchState: 'rejected', reason: TEXT, - rejection: START_FAILURE + rejection: START_FAILURE, + rejectedByStartKey: child.acquisitionGeneration }) } expect(rejectedIn(events, second)).toBe(true) @@ -459,7 +460,10 @@ describe('a start another operation made that dies while a sent message waits on await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) await settleLoop() - expect((await submission(id))?.reason).toBe(TEXT) + expect(await submission(id)).toMatchObject({ + reason: TEXT, + rejectedByStartKey: operationChild.acquisitionGeneration + }) expect(await statusRows()).toEqual([ { itemId: `orca:${encodeURIComponent(`start-failure:${operationChild.acquisitionGeneration}`)}`, @@ -545,7 +549,8 @@ describe('a child that ends before its message is handed over', () => { } expect(await submission(id)).toMatchObject({ reason: 'Codex stopped before this message was sent.', - rejection: failure + rejection: failure, + rejectedByStartKey: 'generation-2' }) expect(await statusRows()).toEqual([ { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error', failure } @@ -731,7 +736,8 @@ describe('how a stopped child ends the start its loop was waiting on', () => { const text = 'Codex never finished starting, so Orca stopped it.' expect(await submission(second)).toMatchObject({ reason: text, - rejection: { kind: 'hostStopped' } + rejection: { kind: 'hostStopped' }, + rejectedByStartKey: 'generation-2' }) expect(await statusRows()).toEqual([ { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.test.ts new file mode 100644 index 000000000000..8be97fc7332c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.test.ts @@ -0,0 +1,79 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import { + recordStructuredAgentSessionStartFailure, + structuredAgentSessionStartFailureRow, + structuredAgentSessionStartFailureRowItemId +} from './structured-agent-session-start-failure-row' + +const START_KEY = 'generation-1' +let root: string +let journal: AgentSessionJournal +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-start-failure-row-')) + journal = await journals.open({ + identity: { + sessionId: 'session-start-failure', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + stateDirectory: root, + now: () => 1_000 + }) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +// A second report of a start whose row is written: the short notice beside that row must not sit +// under a different reason, so the messages take the row's words. +it("rejects a later report's messages in the start's row's words, leaving the row", async () => { + const first = agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { + surface: 'rejection' + }) + await journal.appendLifecycleBatch({ + settlementId: `start-failure:${START_KEY}`, + fence: 7, + recovered: true, + mutations: [structuredAgentSessionStartFailureRow(START_KEY, first)] + }) + const row = journal.itemBody(structuredAgentSessionStartFailureRowItemId(START_KEY)) + await journal.appendSubmission({ + clientMessageId: 'client-queued', + payloadFingerprint: 'fingerprint', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello?' }] }, + fence: 7, + handoverRecorded: true + }) + + const later = structuredAgentSessionStartFailure({ exit: undefined }) + expect(later.reason).not.toBe(first.reason) + await recordStructuredAgentSessionStartFailure( + { journal, fence: 7 }, + { startKey: START_KEY, ...later } + ) + + expect(journal.submissions()).toEqual([ + expect.objectContaining({ + clientMessageId: 'client-queued', + dispatchState: 'rejected', + reason: first.reason, + rejection: { kind: 'notSignedIn' }, + rejectedByStartKey: START_KEY + }) + ]) + expect(journal.itemBody(structuredAgentSessionStartFailureRowItemId(START_KEY))).toEqual(row) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 12fa05710f00..fcb76ba06ed5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -1,8 +1,16 @@ +import { + isSubmissionRejectionFact, + readAgentSessionFailureFact +} from '../../../shared/agent-session-failure' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { JournalDispatchRejectionInput } from '../agent-session-journal/journal-store-contracts' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionStartFailureWords } from './structured-agent-session-failure-text' @@ -31,40 +39,76 @@ export function structuredAgentSessionStartFailureRow( } } -/** Whether the start's row is already written. Its words are the ones its rejected messages carry, - * and rejected is terminal, so the exit's later report of the same start must not reword it. */ -export function hasStructuredAgentSessionStartFailureRow( - items: readonly { itemId: string }[], - startKey: string -): boolean { - const itemId = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)) - return items.some((item) => item.itemId === itemId) +export function structuredAgentSessionStartFailureRowItemId(startKey: string): string { + return agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)) } /** - * A start the delivery loop needed and did not get: the start's row, and every queued message - * rejected with the same words. Writes nothing when nothing is still queued: a start whose - * messages Stop withdrew did not fail anyone. + * What a failed start rejects its messages with, naming the start on each. Once its row is written, + * the row's words win: a later report of the same start never puts other words beside it, and never + * rewrites the row (`written`). Beside a written row whose fact this build cannot read, the + * messages keep their own words and name no start. + */ +export function structuredAgentSessionStartFailureRejection( + row: AgentJournalItemBody | null | undefined, + startKey: string, + words: StructuredAgentSessionStartFailureWords +): { words: JournalDispatchRejectionInput; written: boolean } { + if (!row) { + return { words: { ...words, rejectedByStartKey: startKey }, written: false } + } + if (row.kind !== 'status' || !row.failure) { + return { words, written: true } + } + const fact = readAgentSessionFailureFact(row.failure) + if (!fact || !isSubmissionRejectionFact(fact)) { + return { words, written: true } + } + return { + words: { reason: row.text, rejection: fact, rejectedByStartKey: startKey }, + written: true + } +} + +/** + * A start the delivery loop needed and did not get: the start's row, and every message it was for + * rejected with the same words — each queued one, and `handedOver`, the one handed to a child that + * could not take it. Writes nothing when neither is left: a start whose messages Stop withdrew did + * not fail anyone. */ export async function recordStructuredAgentSessionStartFailure( session: Pick & { fence: number }, - failure: StructuredAgentSessionStartFailure + failure: StructuredAgentSessionStartFailure, + handedOver?: string ): Promise { - const oldest = oldestQueuedSubmission(session) + const oldest = handedOver ?? oldestQueuedSubmission(session)?.clientMessageId if (!oldest) { return } - const startKey = failure.startKey ?? oldest.clientMessageId - await session.journal.appendLifecycleBatch({ - settlementId: `start-failure:${startKey}`, - fence: session.fence, - recovered: true, - mutations: [structuredAgentSessionStartFailureRow(startKey, failure)] - }) - await session.journal.rejectQueuedSubmissions(session.fence, { - reason: failure.reason, - rejection: failure.rejection - }) + const startKey = failure.startKey ?? oldest + // Each message it rejects names this start, the row's own key, so a reader pairs them by identity. + const { words, written } = structuredAgentSessionStartFailureRejection( + session.journal.itemBody(structuredAgentSessionStartFailureRowItemId(startKey)), + startKey, + { reason: failure.reason, rejection: failure.rejection } + ) + if (!written) { + await session.journal.appendLifecycleBatch({ + settlementId: `start-failure:${startKey}`, + fence: session.fence, + recovered: true, + mutations: [structuredAgentSessionStartFailureRow(startKey, words)] + }) + } + if (handedOver) { + await session.journal.resolveDispatch({ + clientMessageId: handedOver, + state: 'rejected', + ...words, + fence: session.fence + }) + } + await session.journal.rejectQueuedSubmissions(session.fence, words) } export function oldestQueuedSubmission( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index e5527d1a5afb..f0e9491f5920 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -1,7 +1,9 @@ // A start a queued message waited on can be seen failing twice: by the delivery loop, when the -// adapter settles the start without proving it, and by the exit settlement, when the child's exit -// lands. The chat gets one row for that start, the loop's, in the words the message was rejected -// with — whichever of the two reports first. +// adapter settles the start without proving it or cannot take the message it was handed, and by +// the exit settlement, when the child's exit lands. Both key the start by the child's generation, so +// the chat gets one row for it, in the words every message it was for was rejected with. The exit +// writes it only when it rejected a message handed to the child and no row is there yet; else the +// loop does. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -35,6 +37,7 @@ const ADAPTER_FAILURE_TEXT = 'Codex is not signed in for the selected account. Sign in, then send your message again.' // The exit's reason is Orca's log text; the row says only that the start stopped. const EXIT_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' +const COMPACT_EXIT_TEXT = 'Codex stopped before it finished starting. Run /compact again.' // The first child (generation-1) is lost at setup; the send starts generation-2. const START_ROW = agentJournalItemKey({ provider: 'orca', @@ -51,6 +54,8 @@ let host: StructuredAgentSessionHost let generation = 0 let settleStart: (failure: SubmissionRejectionFact | undefined) => void = () => {} let awaitStarted = vi.fn<() => Promise>() +let dispatch = vi.fn<() => Promise<{ state: 'admitted' }>>() +let compact = vi.fn<() => Promise<{ state: 'admitted' }>>() let frames: AgentSessionSubscribeEvent[] = [] function exitBeforeProof(): Promise { @@ -87,6 +92,27 @@ async function sendQueued(text: string): Promise { return sent.ok ? sent.value.clientMessageId : '' } +/** A /compact sent through the command RPC, whose answer waits for its handover. */ +async function sendCompact(): Promise<{ id: string; answered: Promise }> { + const fields = { command: 'compact' } + const answered = host.conversationCommand(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId: SESSION, + fields + }) + }, + command: 'compact' + }) + await eventually(() => expect(awaitStarted).toHaveBeenCalledOnce()) + const waiting = (await host.journalSnapshot(SESSION)).submissions.at(-1) + return { id: waiting?.clientMessageId ?? '', answered } +} + async function submission(clientMessageId: string) { return (await host.journalSnapshot(SESSION)).submissions.find( (entry) => entry.clientMessageId === clientMessageId @@ -118,6 +144,8 @@ beforeEach(async () => { awaitStarted = vi.fn( () => new Promise((resolve) => (settleStart = resolve)) ) + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) + compact = vi.fn(async () => ({ state: 'admitted' as const })) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, @@ -137,7 +165,8 @@ beforeEach(async () => { awaitStarted, releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + dispatch, + compact, cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) @@ -194,3 +223,140 @@ describe('a queued message whose start fails and whose child then exits', () => expect(publishedStartRows()).toEqual([EXIT_TEXT]) }) }) + +// The adapter's session is gone before the host has seen the exit: the start settles with no +// reason, and the child the host still holds as starting cannot take the message handed to it. +describe('a start whose child cannot take the message it was handed', () => { + it('rejects every message it was for with the one row the exit then leaves alone', async () => { + const first = await sendQueued('first') + const second = await sendQueued('second') + dispatch.mockImplementation(() => { + throw new Error(`no live claude stream-json session for ${SESSION}`) + }) + awaitStarted.mockImplementation(async () => undefined) + + settleStart(undefined) + await eventually(async () => { + expect(await submission(first)).toMatchObject({ dispatchState: 'rejected' }) + expect(await submission(second)).toMatchObject({ dispatchState: 'rejected' }) + }) + await exitBeforeProof() + await host.flushStreamedEvents(SESSION) + + const row = (await host.journalSnapshot(SESSION)).items.find( + (item) => item.itemId === START_ROW + ) + const firstRejected = await submission(first) + const secondRejected = await submission(second) + // Nothing saw the provider stop when the handover failed; each message names the start the row + // is keyed by. + expect(firstRejected).toMatchObject({ + rejection: { kind: 'startFailed' }, + rejectedByStartKey: 'generation-2' + }) + expect(row?.body).toEqual({ + kind: 'status', + text: firstRejected?.reason, + tone: 'error', + failure: firstRejected?.rejection + }) + expect(secondRejected).toMatchObject({ + reason: firstRejected?.reason, + rejection: firstRejected?.rejection, + rejectedByStartKey: 'generation-2' + }) + expect(publishedStartRows()).toEqual([firstRejected?.reason]) + // One start failed, so one handover tried it. + expect(dispatch).toHaveBeenCalledOnce() + }) +}) + +// A /compact the start was carrying is handed over like any message, as a command: a child that +// cannot take it failed its start for the command and for every message waiting behind it. +describe('a waiting /compact whose child cannot take it', () => { + function resultRows(items: { itemId: string }[]): string[] { + return items.flatMap((item) => (item.itemId.includes('command-result:') ? [item.itemId] : [])) + } + + it('rejects it and the message behind it naming the one start row, whichever report lands first', async () => { + const { id: waiting, answered } = await sendCompact() + const behind = await sendQueued('behind') + compact.mockImplementation(() => { + throw new Error(`no live claude stream-json session for ${SESSION}`) + }) + awaitStarted.mockImplementation(async () => undefined) + + settleStart(undefined) + await eventually(async () => + expect(await submission(waiting)).toMatchObject({ dispatchState: 'rejected' }) + ) + expect(await submission(waiting)).toMatchObject({ rejectedByStartKey: 'generation-2' }) + await eventually(async () => + expect(await submission(behind)).toMatchObject({ dispatchState: 'rejected' }) + ) + await exitBeforeProof() + await host.flushStreamedEvents(SESSION) + + const { items } = await host.journalSnapshot(SESSION) + const rejected = await submission(waiting) + expect(rejected).toMatchObject({ + rejection: { kind: 'startFailed' }, + rejectedByStartKey: 'generation-2' + }) + expect(await submission(behind)).toMatchObject({ + reason: rejected?.reason, + rejectedByStartKey: 'generation-2' + }) + expect(await startRows()).toEqual([rejected?.reason]) + expect(publishedStartRows()).toEqual([rejected?.reason]) + // The start's row is the command's one row: no result row repeats it. + expect(resultRows(items)).toEqual([]) + // The command's turn ended with its start, not left running to hold the queue. + expect(items.some((item) => item.body.kind === 'turn' && item.body.state === 'running')).toBe( + false + ) + expect(compact).toHaveBeenCalledOnce() + await answered + }) + + it('leaves the row to the loop when the exit lands while the command still waits', async () => { + const { id: waiting, answered } = await sendCompact() + + await exitBeforeProof() + settleStart(undefined) + await eventually(async () => + expect(await submission(waiting)).toMatchObject({ dispatchState: 'rejected' }) + ) + await host.flushStreamedEvents(SESSION) + + const { items } = await host.journalSnapshot(SESSION) + expect(await submission(waiting)).toMatchObject({ + reason: COMPACT_EXIT_TEXT, + rejectedByStartKey: 'generation-2' + }) + expect(publishedStartRows()).toEqual([COMPACT_EXIT_TEXT]) + expect(resultRows(items)).toEqual([]) + expect(compact).not.toHaveBeenCalled() + await answered + }) + + it("keeps the exit's row when the child exits after it took the command", async () => { + const { id: waiting, answered } = await sendCompact() + awaitStarted.mockImplementation(async () => undefined) + + settleStart(undefined) + await eventually(() => expect(compact).toHaveBeenCalledOnce()) + await answered + await exitBeforeProof() + await host.flushStreamedEvents(SESSION) + + const { items } = await host.journalSnapshot(SESSION) + const rejected = await submission(waiting) + expect(rejected).toMatchObject({ + dispatchState: 'rejected', + rejectedByStartKey: 'generation-2' + }) + expect(publishedStartRows()).toEqual([rejected?.reason]) + expect(resultRows(items)).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 13aa9d72aea0..51a5448103ca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -29,7 +29,6 @@ import type { StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' -import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { handOverStructuredAgentSessionCommand, @@ -80,13 +79,13 @@ function invalid( /** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown` * rather than as a rejection — unless the child had not proven its start. Such a child has * accepted nothing (input is written only after it initializes), so a dispatch it could not - * take is provably unwritten and is rejected with the cause the adapter gave. */ + * take is provably unwritten: its start failed, with the cause the adapter gave. */ async function dispatchSafely( ctx: AgentSessionHandoverContext, clientMessageId: string, body: AgentJournalMessageItem, requestedAt: number -): Promise { +): Promise { try { return await ctx.adapter.dispatch({ sessionId: ctx.sessionId, @@ -97,10 +96,7 @@ async function dispatchSafely( }) } catch (error) { if (ctx.providerChildPhase?.() === 'starting') { - return { - state: 'rejected', - ...structuredAgentSessionStartFailure({ error }, ctx.failureTextContext) - } + return { state: 'startFailed', cause: { error } } } return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } } @@ -170,11 +166,13 @@ export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverC /** * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash * after it leaves a message in doubt, never one that reads as queued and so provably unwritten. + * Returns the cause when the child's start failed at the handover, leaving the message pending for + * the caller to record that start's failure for it and everything queued behind it. */ export async function handOverSubmission( ctx: AgentSessionHandoverContext, submission: AgentJournalSubmission -): Promise { +): Promise<{ error: unknown } | null> { const { clientMessageId } = submission const body = ctx.journal.itemBody(agentJournalSubmissionKey(clientMessageId)) if (body?.kind !== 'message') { @@ -184,11 +182,10 @@ export async function handOverSubmission( ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { surface: 'rejection' }), fence: ctx.fence }) - return + return null } if (body.command) { - await handOverStructuredAgentSessionCommand(ctx, submission, body) - return + return handOverStructuredAgentSessionCommand(ctx, submission, body) } // The message joins the turn running at handover, a steer, or opens its own. await ctx.journal.resolveDispatch({ @@ -205,9 +202,12 @@ export async function handOverSubmission( body, structuredAgentSessionHandoverOrigin(ctx.journal, submission) ) + if (outcome.state === 'startFailed') { + return outcome.cause + } // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { - return + return null } try { await ctx.journal.resolveDispatch( @@ -243,6 +243,7 @@ export async function handOverSubmission( } throw error } + return null } function requireSubmission( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx index 6f529c1d3c9f..839efb18863e 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx @@ -37,13 +37,14 @@ afterEach(() => { }) const SESSION_ID = 'start-failure-session' +const START_KEY = 'generation-1' const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed' } const START_FAILED_REASON = 'Claude stopped before it finished starting. Send your message to try again.' function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem { return { - itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('generation-1')), + itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(START_KEY)), revision: 1, sequence: 1, observedAt: 1, @@ -55,7 +56,12 @@ function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem } } -function rejected(clientMessageId: string, reason: string, rejection: AgentSessionFailureFact) { +function rejected( + clientMessageId: string, + reason: string, + rejection: AgentSessionFailureFact, + rejectedByStartKey?: string +) { return { outbox: { clientMessageId, @@ -77,7 +83,8 @@ function rejected(clientMessageId: string, reason: string, rejection: AgentSessi reason, rejection, submittedAt: 1, - resolvedAt: 1 + resolvedAt: 1, + ...(rejectedByStartKey ? { rejectedByStartKey } : {}) } } } @@ -118,8 +125,8 @@ it("says only 'not sent', with its Retry, on each message the failed start's row mocks.journalItems = [startFailureRow(START_FAILED)] renderPane([ - rejected('first', START_FAILED_REASON, START_FAILED), - rejected('second', START_FAILED_REASON, START_FAILED) + rejected('first', START_FAILED_REASON, START_FAILED, START_KEY), + rejected('second', START_FAILED_REASON, START_FAILED, START_KEY) ]) for (const id of ['first', 'second']) { @@ -138,7 +145,7 @@ it('keeps the full notice on a message rejected for a reason no start-failure ro } renderPane([ - rejected('stated', START_FAILED_REASON, START_FAILED), + rejected('stated', START_FAILED_REASON, START_FAILED, START_KEY), rejected( 'other', 'The provider did not accept this message: Image type .bmp.', @@ -154,8 +161,25 @@ it('keeps the full notice on a message rejected for a reason no start-failure ro ).toBeTruthy() }) +// A host from before starts were named records the same failure with no start beside it; another +// start's row does not explain a message it did not reject. +it('keeps the full notice on a rejection that names no start, or another, beside an equal start row', async () => { + mocks.journalItems = [startFailureRow(START_FAILED)] + + renderPane([ + rejected('older', START_FAILED_REASON, START_FAILED), + rejected('elsewhere', START_FAILED_REASON, START_FAILED, 'generation-0') + ]) + + for (const id of ['older', 'elsewhere']) { + expect( + within(await notice(id)).getByText('Claude stopped before it finished starting.') + ).toBeTruthy() + } +}) + it("keeps the start failure's own words when its row is not loaded", async () => { - renderPane([rejected('first', START_FAILED_REASON, START_FAILED)]) + renderPane([rejected('first', START_FAILED_REASON, START_FAILED, START_KEY)]) expect( within(await notice('first')).getByText('Claude stopped before it finished starting.') diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 463a63e20938..6312b5e8bb79 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -28,7 +28,7 @@ import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' -import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' +import { useStructuredAgentSessionStartFailureKeys } from './use-structured-agent-session-start-failure-keys' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' @@ -123,7 +123,7 @@ export function NativeChatStructuredSession( // Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else. const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected') const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS - const startFailures = useStructuredAgentSessionStartFailureFacts( + const startFailureKeys = useStructuredAgentSessionStartFailureKeys( controller.journalItems, hasRejected ) @@ -135,7 +135,7 @@ export function NativeChatStructuredSession( agentLabel, retryDelivery, rejectionRows, - startFailures + startFailureKeys ), [ controller.outbox, @@ -143,7 +143,7 @@ export function NativeChatStructuredSession( agentLabel, retryDelivery, rejectionRows, - startFailures + startFailureKeys ] ) const viewState = selectNativeChatViewState(session, { readRetries: true }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 6ccc98b45267..742a0037cfe5 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -16,7 +16,7 @@ import { import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' import { structuredAgentSessionDeliveryNotices, - structuredAgentSessionStartFailureFacts + structuredAgentSessionStartFailureKeys } from './structured-agent-session-delivery-notices' function entry( @@ -39,7 +39,7 @@ function texts( outbox: StructuredAgentSessionOutboxEntry[], blocked: string | null = null, submissions: readonly AgentJournalSubmission[] = [], - startFailures: readonly AgentSessionFailureFact[] = [] + startFailureKeys: readonly string[] = [] ): Record { const notices = structuredAgentSessionDeliveryNotices( outbox, @@ -47,7 +47,7 @@ function texts( 'Claude', () => {}, submissions, - startFailures + startFailureKeys ) return Object.fromEntries([...notices].map(([id, notice]) => [id, notice.text])) } @@ -335,7 +335,8 @@ describe('the notice on each message that did not go through', () => { expect(texts([entry('queued'), entry('sending', { state: 'dispatching' })])).toEqual({}) }) - // Matched on the typed fact of a row found by its identity, never on either sentence. + // Matched on the start the host recorded as rejecting the message and a loaded row keyed by it, + // never on either sentence or on the failure the two state. describe('a message rejected by a start whose row already says why', () => { const startFailed: AgentSessionFailureFact = { kind: 'startFailed', @@ -350,7 +351,11 @@ describe('the notice on each message that did not go through', () => { rejection: { kind: fact.kind } } }) - const recorded = (id: string, fact: AgentSessionFailureFact): AgentJournalSubmission => ({ + const recorded = ( + id: string, + fact: AgentSessionFailureFact, + rejectedByStartKey?: string + ): AgentJournalSubmission => ({ clientMessageId: id, fence: 1, payloadFingerprint: id, @@ -359,7 +364,8 @@ describe('the notice on each message that did not go through', () => { reason: 'Written by the host.', rejection: fact, submittedAt: 1, - resolvedAt: 1 + resolvedAt: 1, + ...(rejectedByStartKey ? { rejectedByStartKey } : {}) }) const statusRow = (itemId: string, fact: AgentSessionFailureFact): AgentJournalRenderItem => ({ itemId, @@ -372,15 +378,17 @@ describe('the notice on each message that did not go through', () => { ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) } }) - const startRowKey = agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen')) + const startRowKey = (startKey: string) => + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)) + const shown = "Claude couldn't start. Start a new chat to continue." it('reads only the start-failure rows', () => { expect( - structuredAgentSessionStartFailureFacts([ - statusRow(startRowKey, startFailed), + structuredAgentSessionStartFailureKeys([ + statusRow(startRowKey('gen'), startFailed), statusRow(agentJournalSubmissionKey('exit-row'), { kind: 'providerExited' }) ]) - ).toEqual([startFailed]) + ).toEqual(['gen']) }) it('says only that each was not sent, and words any other rejection in full', () => { @@ -388,7 +396,9 @@ describe('the notice on each message that did not go through', () => { kind: 'startFailed', refusal: { code: 'agent_session_conflict', details: { reason: 'claimConflicted' } } } - const facts = structuredAgentSessionStartFailureFacts([statusRow(startRowKey, startFailed)]) + const keys = structuredAgentSessionStartFailureKeys([ + statusRow(startRowKey('gen'), startFailed) + ]) expect( texts( [ @@ -398,11 +408,11 @@ describe('the notice on each message that did not go through', () => { ], null, [ - recorded('first', startFailed), - recorded('second', startFailed), + recorded('first', startFailed, 'gen'), + recorded('second', startFailed, 'gen'), recorded('other', otherRefusal) ], - facts + keys ) ).toEqual({ [agentJournalSubmissionKey('first')]: 'Your message was not sent.', @@ -412,13 +422,28 @@ describe('the notice on each message that did not go through', () => { }) }) - it('keeps the full notice when the rejection is not loaded, or no start row states it', () => { - const shown = "Claude couldn't start. Start a new chat to continue." - expect(texts([rejected('first', startFailed)], null, [], [startFailed])).toEqual({ + // A rejection no start named — from a host before starts were named, or another writer — may + // state the same failure as a loaded start's row; the row does not explain it. + it('keeps the full notice on an equal failure that names no start, or another start', () => { + expect( + texts( + [rejected('unnamed', startFailed), rejected('elsewhere', startFailed)], + null, + [recorded('unnamed', startFailed), recorded('elsewhere', startFailed, 'earlier')], + ['gen'] + ) + ).toEqual({ + [agentJournalSubmissionKey('unnamed')]: shown, + [agentJournalSubmissionKey('elsewhere')]: shown + }) + }) + + it('keeps the full notice when the rejection is not loaded, or its start row is not', () => { + expect(texts([rejected('first', startFailed)], null, [], ['gen'])).toEqual({ [agentJournalSubmissionKey('first')]: 'Written by the host.' }) expect( - texts([rejected('first', startFailed)], null, [recorded('first', startFailed)], []) + texts([rejected('first', startFailed)], null, [recorded('first', startFailed, 'gen')], []) ).toEqual({ [agentJournalSubmissionKey('first')]: shown }) }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index cccc3d7de3d3..4da803795943 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -7,21 +7,18 @@ // words and gets its Retry once the queue moves. // // A message the host recorded and then rejected is worded from the journal's own fact, found by id; -// the message keeps only a smaller copy, read when its submission is not loaded. A rejection that -// is a failed start's, the fact its loaded row states, says only that it was not sent: the row -// already says why. +// the message keeps only a smaller copy, read when its submission is not loaded. A submission the +// host records as rejected by a failed start whose row is loaded says only that it was not sent: the +// row already says why. -import { - readAgentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../../shared/agent-session-failure' +import { readAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import type { AgentJournalRenderItem, AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' -import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key' +import { structuredAgentSessionStartFailureRowStartKey } from '../../../../shared/structured-agent-session-start-failure-row-key' import { admitStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry @@ -32,45 +29,28 @@ import { translate } from '@/i18n/i18n' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' -/** The facts the chat's loaded start-failure rows state. */ -export function structuredAgentSessionStartFailureFacts( +/** The starts the chat's loaded start-failure rows are for. */ +export function structuredAgentSessionStartFailureKeys( items: readonly AgentJournalRenderItem[] -): AgentSessionFailureFact[] { - const facts: AgentSessionFailureFact[] = [] +): string[] { + const keys: string[] = [] for (const item of items) { - if (item.body.kind === 'status' && isStructuredAgentSessionStartFailureRow(item.itemId)) { - const fact = readAgentSessionFailureFact(item.body.failure) - if (fact) { - facts.push(fact) - } + const startKey = + item.body.kind === 'status' + ? structuredAgentSessionStartFailureRowStartKey(item.itemId) + : null + if (startKey) { + keys.push(startKey) } } - return facts -} - -/** Whether two facts are one failure: a start's row and the messages it rejected share one. */ -export function sameAgentSessionFailureFact( - a: AgentSessionFailureFact, - b: AgentSessionFailureFact -): boolean { - return ( - a.kind === b.kind && - a.detail?.text === b.detail?.text && - a.detail?.audience === b.detail?.audience && - a.refusal?.code === b.refusal?.code && - a.refusal?.details?.reason === b.refusal?.details?.reason && - a.attachment?.reason === b.attachment?.reason && - a.attachment?.limit === b.attachment?.limit && - a.retry?.error === b.retry?.error && - a.retry?.status === b.retry?.status - ) + return keys } function deliveryNoticeText( entry: StructuredAgentSessionOutboxEntry, context: AgentSessionFailureWordsContext, recorded: AgentJournalSubmission | undefined, - startFailures: readonly AgentSessionFailureFact[] + startFailureKeys: readonly string[] ): string { // A send attempted before a Stop and then interrupted may already be with the host. const attemptedAcrossStop = @@ -87,14 +67,12 @@ function deliveryNoticeText( 'Message was not sent.' ) } - const fact = readAgentSessionFailureFact(recorded?.rejection) - if ( - entry.state === 'rejected' && - fact && - startFailures.some((stated) => sameAgentSessionFailureFact(stated, fact)) - ) { + // Only the start's own writer names it, so an equal failure from elsewhere keeps its words. + const startKey = recorded?.rejectedByStartKey + if (entry.state === 'rejected' && startKey && startFailureKeys.includes(startKey)) { return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) } + const fact = readAgentSessionFailureFact(recorded?.rejection) return agentSessionWriteNoticeText( structuredAgentSessionAttemptFailureParts(entry.lastFailure, context, fact) ) @@ -109,8 +87,8 @@ export function structuredAgentSessionDeliveryNotices( retry: (clientMessageId: string) => void, /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps. */ submissions: readonly AgentJournalSubmission[], - /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ - startFailures: readonly AgentSessionFailureFact[] + /** The starts the loaded start-failure rows are for, from `structuredAgentSessionStartFailureKeys`. */ + startFailureKeys: readonly string[] ): ReadonlyMap { const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedClientMessageId) const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null @@ -128,7 +106,7 @@ export function structuredAgentSessionDeliveryNotices( entry, { agentName, retryControl }, rejected.get(entry.clientMessageId), - startFailures + startFailureKeys ) notices.set( agentJournalSubmissionKey(entry.clientMessageId), diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts deleted file mode 100644 index 83f20103de11..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect, useMemo, useRef } from 'react' -import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' -import { - sameAgentSessionFailureFact, - structuredAgentSessionStartFailureFacts -} from './structured-agent-session-delivery-notices' - -const NO_FACTS: readonly AgentSessionFailureFact[] = [] - -/** What the loaded start-failure rows state, read only while `enabled`. Held while unchanged, so a - * streaming turn does not rebuild every row's delivery notice. */ -export function useStructuredAgentSessionStartFailureFacts( - items: readonly AgentJournalRenderItem[], - enabled: boolean -): readonly AgentSessionFailureFact[] { - const facts = useMemo( - () => (enabled ? structuredAgentSessionStartFailureFacts(items) : NO_FACTS), - [enabled, items] - ) - const previousRef = useRef(NO_FACTS) - const previous = previousRef.current - const stable = - previous.length === facts.length && - previous.every((fact, index) => sameAgentSessionFailureFact(fact, facts[index])) - ? previous - : facts - // Written after commit, so render stays pure. - useEffect(() => { - previousRef.current = stable - }, [stable]) - return stable -} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.test.ts similarity index 54% rename from src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts rename to src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.test.ts index 97ef023dffb7..0d3982d24fcf 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.test.ts @@ -2,14 +2,13 @@ import { renderHook } from '@testing-library/react' import { expect, it } from 'vitest' -import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { structuredAgentSessionStartFailureRowIdentity } from '../../../../shared/structured-agent-session-start-failure-row-key' -import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' +import { useStructuredAgentSessionStartFailureKeys } from './use-structured-agent-session-start-failure-keys' -function startFailureRow(startKey: string, fact: AgentSessionFailureFact): AgentJournalRenderItem { +function startFailureRow(startKey: string): AgentJournalRenderItem { return { itemId: agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity(startKey)), revision: 1, @@ -18,25 +17,28 @@ function startFailureRow(startKey: string, fact: AgentSessionFailureFact): Agent body: { kind: 'status', tone: 'error', - ...agentSessionFailureWords(fact, { agentName: 'Claude', surface: 'row' }) + ...agentSessionFailureWords( + { kind: 'providerStartFailed' }, + { agentName: 'Claude', surface: 'row' } + ) } } } -// A streaming turn hands over a new item list on every delta; the facts must not follow it. -it('holds the same facts while the start rows state nothing new', () => { - const row = startFailureRow('gen-1', { kind: 'providerStartFailed' }) +// A streaming turn hands over a new item list on every delta; the keys must not follow it. +it('holds the same keys while the start rows name no new start', () => { + const row = startFailureRow('gen-1') const { result, rerender } = renderHook( - ({ items }) => useStructuredAgentSessionStartFailureFacts(items, true), + ({ items }) => useStructuredAgentSessionStartFailureKeys(items, true), { initialProps: { items: [row] } } ) const first = result.current - expect(first).toEqual([{ kind: 'providerStartFailed' }]) + expect(first).toEqual(['gen-1']) - rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] }) + rerender({ items: [row, startFailureRow('other-start')] }) expect(result.current).not.toBe(first) const second = result.current - rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] }) + rerender({ items: [row, startFailureRow('other-start')] }) expect(result.current).toBe(second) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.ts new file mode 100644 index 000000000000..31ad438b924d --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-keys.ts @@ -0,0 +1,28 @@ +import { useEffect, useMemo, useRef } from 'react' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { structuredAgentSessionStartFailureKeys } from './structured-agent-session-delivery-notices' + +const NO_KEYS: readonly string[] = [] + +/** The starts the loaded start-failure rows are for, read only while `enabled`. Held while + * unchanged, so a streaming turn does not rebuild every row's delivery notice. */ +export function useStructuredAgentSessionStartFailureKeys( + items: readonly AgentJournalRenderItem[], + enabled: boolean +): readonly string[] { + const keys = useMemo( + () => (enabled ? structuredAgentSessionStartFailureKeys(items) : NO_KEYS), + [enabled, items] + ) + const previousRef = useRef(NO_KEYS) + const previous = previousRef.current + const stable = + previous.length === keys.length && previous.every((key, index) => key === keys[index]) + ? previous + : keys + // Written after commit, so render stays pure. + useEffect(() => { + previousRef.current = stable + }, [stable]) + return stable +} diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index 594d6a04b11c..79ff5ce860ea 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -372,6 +372,27 @@ describe('typed failure facts', () => { expect(refused({ code: 'agent_session_conflict', cause: 'claimConflicted' })).toBe(true) }) + // Older builds read with a schema that lists no such key; this one lists it as any value. Either + // way the start that rejected a message, well-formed or not, never makes it inadmissible. + it('admits the start that rejected a message, whatever a host wrote for it', () => { + for (const rejectedByStartKey of ['generation-2', '', 7, { generation: 'generation-2' }]) { + expect( + isAdmissibleAgentJournalSubmission({ + clientMessageId: 'cm-1', + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'rejected', + providerItemId: null, + reason: "Codex couldn't start.", + submittedAt: 1, + resolvedAt: 2, + rejection: { kind: 'startFailed' }, + rejectedByStartKey + }) + ).toBe(true) + } + }) + it('keeps a kind or audience a newer host writes admissible', () => { expect( isAdmissibleAgentJournalItemBody({ diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 035bb7d1d01c..b42cc9587ae4 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -209,6 +209,21 @@ const FailureFact = z.object({ refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional() }) +/** A turn's record, as the turn body and a status row's `turnLifecycle` both carry it. */ +const TurnRecordFields = { + turnId: z.string(), + state: z.string().min(1), + // Open like `state`: a verdict a newer build writes must not turn the row + // malformed. `readAgentJournalTurnOutcome` is where an unplaceable one + // becomes unknown rather than an arm a caller would act on. + outcome: z.string().min(1).optional(), + userItemId: z.string().min(1).optional(), + startedAt: z.number().finite().positive().optional(), + requestedAt: z.number().finite().positive().optional(), + completedAt: z.number().finite().positive().optional(), + durationMs: z.number().finite().nonnegative().optional() +} + export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ MessageBody, z.object({ @@ -248,35 +263,14 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ text: z.string(), presentation: z.string().optional(), tone: z.string().optional(), - turnLifecycle: z - .object({ - turnId: z.string(), - state: z.string().min(1), - outcome: z.string().min(1).optional(), - userItemId: z.string().min(1).optional(), - startedAt: z.number().finite().positive().optional(), - requestedAt: z.number().finite().positive().optional(), - completedAt: z.number().finite().positive().optional(), - durationMs: z.number().finite().nonnegative().optional() - }) - .optional(), + turnLifecycle: z.object(TurnRecordFields).optional(), providerFrame: ProviderFrame.optional(), threadGoal: ThreadGoalState.optional(), failure: FailureFact.optional() }), z.object({ kind: z.literal('turn'), - turnId: z.string(), - state: z.string().min(1), - // Open like `state`: a verdict a newer build writes must not turn the row - // malformed. `readAgentJournalTurnOutcome` is where an unplaceable one - // becomes unknown rather than an arm a caller would act on. - outcome: z.string().min(1).optional(), - userItemId: z.string().min(1).optional(), - startedAt: z.number().finite().positive().optional(), - requestedAt: z.number().finite().positive().optional(), - completedAt: z.number().finite().positive().optional(), - durationMs: z.number().finite().nonnegative().optional(), + ...TurnRecordFields, contextUsage: AgentSessionContextUsageSchema.optional(), providerTurnId: z.string().min(1).optional() }) @@ -329,6 +323,8 @@ export const AgentJournalSubmissionSchema = z.object({ handoverRecorded: z.literal(true).optional(), handedOverAt: z.number().optional(), rejection: FailureFact.optional(), + // Any value: a malformed one is dropped by the host's reader, never the submission. + rejectedByStartKey: z.unknown().optional(), // Listed, or the parse strips it: this schema drops unknown keys. queuedMessageId: z.string().min(1).optional() }) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 548b263bbfec..fa4772a34e80 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -408,6 +408,9 @@ export type AgentJournalSubmission = { reason: string | null /** On `rejected`, why, typed; absent on rows from older hosts. */ rejection?: UnreadAgentSessionFailureFact + /** On `rejected` by a failed start: that start's key, the one its `start-failure:` row is keyed + * by. Set only by the start's own writer; absent on every other rejection and on older hosts. */ + rejectedByStartKey?: string submittedAt: number resolvedAt: number | null /** Set when crash reconciliation resolved the dispatch, not the provider. A live diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index c905584fd80f..1dc52d9f7046 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -341,6 +341,24 @@ describe('what a refusal shows the user', () => { }) }) + // The pane reads the start from the journal's submission; the message's saved copy never holds it. + it('keeps no start on the saved copy of a message a failed start rejected', () => { + const result = rejectedWith("Claude couldn't start.", { rejection: { kind: 'startFailed' } }) + if (!result.ok || !('submission' in result.value)) { + throw new Error('expected rejected submission fixture') + } + const [saved] = reconcileStructuredAgentSessionOutbox( + [{ ...entry, state: 'dispatching' }], + [{ ...result.value.submission, rejectedByStartKey: 'generation-2' }] + ) + + expect(saved?.lastFailure).toEqual({ + kind: 'rejected', + reason: "Claude couldn't start.", + rejection: { kind: 'startFailed' } + }) + }) + it('keeps a failed request as a fact, not a transport error string', () => { const disposition = disposeStructuredAgentSessionSendFailure({ entries: [entry], diff --git a/src/shared/structured-agent-session-start-failure-row-key.ts b/src/shared/structured-agent-session-start-failure-row-key.ts index 6dc4a4817b14..f4e523f4853e 100644 --- a/src/shared/structured-agent-session-start-failure-row-key.ts +++ b/src/shared/structured-agent-session-start-failure-row-key.ts @@ -11,7 +11,11 @@ export function structuredAgentSessionStartFailureRowIdentity( return { provider: 'orca', clientMessageId: `${START_FAILURE_ROW}${startKey}` } } -export function isStructuredAgentSessionStartFailureRow(itemId: string): boolean { +/** The start a start-failure row is for; null for any other row. */ +export function structuredAgentSessionStartFailureRowStartKey(itemId: string): string | null { const identity = parseAgentJournalItemKey(itemId) - return identity?.provider === 'orca' && identity.clientMessageId.startsWith(START_FAILURE_ROW) + if (identity?.provider !== 'orca' || !identity.clientMessageId.startsWith(START_FAILURE_ROW)) { + return null + } + return identity.clientMessageId.slice(START_FAILURE_ROW.length) || null }