From d4981a0e900098dafaad56d794e88502105b92b5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:11:56 -0700 Subject: [PATCH 001/182] test(native-chat): a Stop over a card sent now into the running turn keeps it paused Red on main: Codex's turn end withdraws the steered hand-off and the queue sends the card again as a new host turn, with no pause recorded. --- ...-session-codex-turn-end-settlement.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 990c33850efe..ce23a8df5918 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -222,6 +222,80 @@ describe('a Codex send its turn ended without taking it', () => { }) }) +describe('a queued card sent now into the turn a Stop ends', () => { + async function queueCard(text: string): Promise { + const body = hostTestMessage(text) + const delivery = 'queue-if-active' as const + const queued = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body, delivery }), + body, + delivery, + userSend: true + }) + if (!queued.ok || !('queued' in queued.value)) { + throw new Error(`not queued: ${JSON.stringify(queued)}`) + } + return queued.value.queued.messageId + } + + async function handoffs(messageId: string): Promise { + return (await settled()).submissions.filter((entry) => entry.queuedMessageId === messageId) + } + + /** Each hand-off of the card, as who sent it and how it settled. */ + async function sends(messageId: string) { + return (await handoffs(messageId)).map((entry) => ({ + origin: entry.origin, + verdict: verdictOf([entry], entry.clientMessageId) + })) + } + + async function queue() { + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return { + pause: page.page.queuePause ?? null, + cards: (page.page.queuedMessages ?? []).map(({ messageId, state }) => ({ messageId, state })) + } + } + + it('comes back as a paused waiting card, and nothing sends it again', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + const cardId = await queueCard('and check the tests') + const sentNow = await host.queuedMessageSend(CALLER, { + envelope: envelope('agentSession.queuedMessageSend', { messageId: cardId }), + messageId: cardId + }) + expect(sentNow).toMatchObject({ ok: true }) + // Steered into the running turn, with no echo yet. + await vi.waitFor(() => expect(answers).toBe(2)) + const [steered] = await handoffs(cardId) + expect(steered?.dispatchState).toBe('pending') + + await stop('turn-1') + + await vi.waitFor(async () => { + const [withdrawn] = await handoffs(cardId) + expect(verdictOf([withdrawn!], withdrawn!.clientMessageId)).toBe('withdrawn') + }) + await vi.waitFor( + async () => + expect({ ...(await queue()), sends: await sends(cardId) }).toEqual({ + pause: { reason: 'stopped' }, + cards: [{ messageId: cardId, state: 'waiting' }], + sends: [{ origin: 'client', verdict: 'withdrawn' }] + }), + { timeout: 5_000 } + ) + expect(answers).toBe(2) + }) +}) + describe('a Stop sent after Codex answered a cold send, before it opened the turn', () => { it('waits for the turn to open, then stops it and withdraws the send', async () => { const sent = await send('look around') From 943bbe4dbb9e1550f4703ae21bd3ea2ab67c7ba1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:28:09 -0700 Subject: [PATCH 002/182] fix(native-chat): a Stop's queue pause holds a card whose hand-off is still unanswered A card sent now into the running turn was still pending when Stop judged the pause, so nothing was recorded; the interrupt then withdrew the hand-off, the card went back to waiting unpaused, and the queue sent it again as a new turn. The pause now counts a hand-off that may still return to waiting, judged with the appended row applied, so a withdrawal lands under the pause and an acceptance retires it in that same write. Codex and Claude both hit it. --- .../journal-queued-messages.ts | 18 ++++--- .../queued-message-pause-table.ts | 14 ++--- .../queued-message-settlement.ts | 29 +++++++--- .../queued-message-store.test.ts | 34 ++++++++++++ ...d-agent-session-claude-queued-stop.test.ts | 54 ++++++++++++++++++- ...ed-agent-session-queued-pause-lift.test.ts | 23 ++++++++ .../structured-agent-session-queued-stop.ts | 9 ++-- 7 files changed, 154 insertions(+), 27 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 96b45c9d17cb..05dde59051f3 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -13,6 +13,7 @@ import { import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' +import type { JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' import { @@ -37,7 +38,7 @@ import { import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' import { - owedBackToWaiting, + mayReturnToWaiting, queuedMessageSettlementOwed, settleOwedQueuedMessages, settleQueuedMessagesForRow @@ -173,10 +174,10 @@ export class JournalQueuedMessages { } /** What `queuePauseHoldsBack` judges a pause by, from this journal's submissions. */ - private pauseScope() { + private pauseScope(row?: JournalRow) { return { sessionId: this.deps.sessionId, - owedToWaiting: owedBackToWaiting(this.deps.state().submissions) + mayReturnToWaiting: mayReturnToWaiting(this.deps.state().submissions, row) } } @@ -252,14 +253,15 @@ export class JournalQueuedMessages { row, now: this.deps.now() }) - this.changeRevision += retireQueuePauseIfNothingHeld(db, this.pauseScope()) + this.changeRevision += retireQueuePauseIfNothingHeld(db, this.pauseScope(row)) } /** The in-transaction consume for `appendSubmission`; a false compare-and-set * throws so the whole append — draft transition AND submission row — rolls back. */ consumeInTransaction( db: Database.Database, - input: JournalSubmissionConsume & { consumedAs: string } + input: JournalSubmissionConsume & { consumedAs: string }, + row: JournalRow ): void { const { db: own } = this.deps.database() if (own !== db) { @@ -274,7 +276,7 @@ export class JournalQueuedMessages { if (!consumed) { throw new QueuedMessageNotConsumableError(input.messageId, input.expect) } - retireQueuePauseIfNothingHeld(db, this.pauseScope()) + retireQueuePauseIfNothingHeld(db, this.pauseScope(row)) this.changeRevision++ } @@ -348,8 +350,8 @@ export function queuedMessageConsumeHook( queuedMessages: JournalQueuedMessages, consumedAs: string, consume: JournalSubmissionConsume -): (db: Database.Database) => void { - return (db) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }) +): JournalRowTransactionHook { + return (db, row) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }, row) } export class QueuedMessageNotConsumableError extends Error { diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts b/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts index 41387165de04..f7dba2b2fca4 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts @@ -82,7 +82,7 @@ export function clearQueuePause( type QueueCardState = { state: string; holdReason: string | null } /** A card a pause holds back: waiting, with no hold of its own, wherever it sits. - * While one exists — or a hand-off still owed a return to waiting + * While one exists — or a hand-off that may still return to waiting * (`queuePauseHoldsBack`) — the pause is KEPT: a Stop records it, and it is * retired only once none remains, so deleting a returned card that blocks such * cards leaves them paused rather than sending them unasked. */ @@ -108,12 +108,12 @@ export function hasResumableQueuedMessage(rows: readonly QueueCardState[]): bool /** What a queue pause holds back, judged inside the caller's transaction: a waiting * card with no hold of its own (`isPausableQueuedMessage`, in SQL), or a dispatched - * one whose settlement back to waiting is still owed — its hook was skipped, so the - * row has not caught up with its rejected submission, which only the journal's - * submissions can tell (`owedToWaiting`). */ + * one its hand-off may still send back to waiting — unanswered, so the Stop that + * recorded the pause may yet withdraw it, or withdrawn with that return still owed. + * Only the journal's submissions can tell (`mayReturnToWaiting`). */ export function queuePauseHoldsBack( db: Database.Database, - input: { sessionId: string; owedToWaiting: (consumedRef: string) => boolean } + input: { sessionId: string; mayReturnToWaiting: (consumedRef: string) => boolean } ): boolean { const pausable = db .prepare( @@ -136,7 +136,7 @@ export function queuePauseHoldsBack( row !== null && 'consumed_as' in row && typeof row.consumed_as === 'string' && - input.owedToWaiting(row.consumed_as) + input.mayReturnToWaiting(row.consumed_as) ) } @@ -145,7 +145,7 @@ export function queuePauseHoldsBack( * it can never outlive them and catch a card typed long after. */ export function retireQueuePauseIfNothingHeld( db: Database.Database, - input: { sessionId: string; owedToWaiting: (consumedRef: string) => boolean } + input: { sessionId: string; mayReturnToWaiting: (consumedRef: string) => boolean } ): number { // Runs on every appended journal row: with no pause recorded there is nothing to judge. const recorded = db diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts index 75e622567809..fed516db95c7 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -8,6 +8,7 @@ import type Database from '../../sqlite/sync-database' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { consumedSubmissionWasRejected, + journalDispatchRowApplies, journalDispatchRowNewlyRejects, rejectedDraftSettlement } from './journal-dispatch-settlement' @@ -36,16 +37,30 @@ export function queuedMessageSettlementOwed( ) } -/** A dispatched draft's consumed submission settled so that the draft is owed a - * return to waiting (a withdrawal, not a refusal): what a queue pause must still - * count as a card it holds back while that settlement is owed. */ -export function owedBackToWaiting(submissions: Submissions): (consumedRef: string) => boolean { +/** A dispatched draft's hand-off can still send it back to waiting: unanswered, so a Stop may + * yet withdraw it, or withdrawn with that return still owed. What a queue pause holds back + * besides waiting cards. `row`, the journal row this transaction appends, counts as applied, + * so the write that settles the last such hand-off is the one that retires the pause. */ +export function mayReturnToWaiting( + submissions: Submissions, + row?: JournalRow +): (consumedRef: string) => boolean { return (consumedRef) => { + if (row?.kind === 'submission' && row.clientMessageId === consumedRef) { + return true + } const submission = submissions.get(consumedRef) + const current = + row?.kind === 'dispatch' && + row.clientMessageId === consumedRef && + journalDispatchRowApplies(submission) + ? { dispatchState: row.state, reason: row.reason, rejection: row.rejection } + : submission + if (current?.dispatchState === 'pending') { + return true + } return ( - submission !== undefined && - consumedSubmissionWasRejected(submission) && - rejectedDraftSettlement(submission).state === 'waiting' + consumedSubmissionWasRejected(current) && rejectedDraftSettlement(current).state === 'waiting' ) } } diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index 0e42a22be4d9..053210c015de 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -732,6 +732,40 @@ describe("the queue's Stop fact", () => { expect(journal.queuedMessages.pause()).not.toBeNull() }) + it('holds a card sent now while its hand-off is unanswered; the write accepting it retires the pause', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + // Send-now: the last waiting card is handed off, which a Stop may yet withdraw. + await consumeDraft(journal, 'draft-1') + expect(journal.queuedMessages.pause()).not.toBeNull() + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, + fence: 0 + }) + expect(journal.queuedMessages.pause()).toBeNull() + await journal.close() + journal = await open() + expect(journal.queuedMessages.pause()).toBeNull() + }) + + it("records over a hand-off still unanswered, so the Stop's withdrawal lands it back under the pause", async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...STOP_WITHDRAWAL, + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + expect(journal.queuedMessages.pause()).not.toBeNull() + }) + it('a hand-off whose return to waiting is still owed (its hook skipped) keeps the pause', async () => { const journal = await open() await queueDraft(journal, 'draft-sent') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index f78be712f7cf..27d1a7951697 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -111,7 +111,7 @@ function eventually(assertion: () => unknown): Promise { } function envelope( - method: 'agentSession.send' | 'agentSession.cancel', + method: 'agentSession.send' | 'agentSession.cancel' | 'agentSession.queuedMessageSend', fields: Record ) { return { @@ -316,3 +316,55 @@ it('withdraws a host-queued follow-up but leaves a newer turn running when the S ) expect(rows).toContain('The provider had already finished this turn.') }, 15_000) + +it('a card sent now into the running turn comes back paused when Stop withdraws it, and is not sent again', async () => { + const connection = claude.connections[0]! + const turnId = await openFirstTurn(connection) + const body = hostTestMessage('And then this.') + const delivery = 'queue-if-active' as const + const queuedSend = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body, delivery }), + body, + delivery, + userSend: true + }) + if (!queuedSend.ok || !('queued' in queuedSend.value)) { + throw new Error('expected a queued receipt') + } + const cardId = queuedSend.value.queued.messageId + const sentNow = await host.queuedMessageSend(CALLER, { + envelope: envelope('agentSession.queuedMessageSend', { messageId: cardId }), + messageId: cardId + }) + expect(sentNow).toMatchObject({ ok: true }) + // Folded into the running turn: Claude holds it until that turn ends. + await eventually(() => expect(connection.sent).toHaveLength(2)) + queued.push(String(connection.sent.at(-1)!.uuid)) + const sends = async () => + (await host.journalSnapshot(SESSION)).submissions + .filter((entry) => entry.queuedMessageId === cardId) + .map((entry) => ({ origin: entry.origin, state: entry.dispatchState, reason: entry.reason })) + await eventually(async () => expect((await sends())[0]?.state).toBe('pending')) + + expect(await stop(turnId)).toMatchObject({ ok: true, value: { cancelled: true } }) + connection.handlers.onMessage?.({ + type: 'result', + subtype: 'error_during_execution', + session_id: PROVIDER_SESSION_ID, + uuid: 'interrupted-result' + }) + + await eventually(async () => { + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect({ + pause: page.ok ? (page.page.queuePause ?? null) : 'history refused', + cards: page.ok ? (page.page.queuedMessages ?? []).map((card) => card.state) : [], + sends: await sends() + }).toEqual({ + pause: { reason: 'stopped' }, + cards: ['waiting'], + sends: [{ origin: 'client', state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }] + }) + }) + expect(connection.sent).toHaveLength(2) +}, 15_000) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 1349a6a178e0..42230f826a30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -231,6 +231,29 @@ describe('a pause is over the cards it paused', () => { await eventually(async () => expect(await rig.handoff(correction)).toBeDefined()) }) + it('a Stop over a card sent now into the turn holds it until the agent takes it; then a card typed later drains', async () => { + const working = await rig.workingSend() + const sentId = await queuedDraft('sent now into the turn') + await rig.sendNow(sentId) + await handedOver(sentId) + await rig.stop() + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + // Recorded over the unanswered hand-off, which the Stop may yet withdraw; nothing to publish. + expect(journal?.queuedMessages.pause()).toMatchObject({ reason: 'stopped' }) + expect(await rig.queuePause()).toBeNull() + await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') + expect(journal?.queuedMessages.pause()).toBeNull() + await rig.settleAccepted(working, 'stopped') + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await eventually(async () => + expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() + ) + const later = await queuedDraft('typed during the mail turn') + await rig.settleAccepted(mail.id, 'mail') + await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) + }) + it('deleting the last paused card ends the pause, so a card typed later is not held by it', async () => { const working = await rig.workingSend() const only = await queuedDraft('paused, then deleted') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index 16a9b4aeaf77..4d5369364b24 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -25,10 +25,11 @@ export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMes * once it withdrew something — and only over cards it then holds back. The Stop * calls `tookEffect` there. A Stop that throws before then changed nothing and * recorded nothing, so there is nothing to undo; one that fails after it keeps - * the pause, since the interrupt may have landed. A draft whose hand-off the - * Stop withdrew is back to waiting in its own place, under this same pause. The - * drain cannot slip a draft in between: it runs on the same serialized lane as - * the Stop. + * the pause, since the interrupt may have landed. A hand-off still unanswered + * counts as held, so a draft the interrupt withdraws later — at the provider's turn + * end — is back to waiting in its own place, under this same pause; accepted + * instead, the pause retires in that write. The drain cannot slip a draft in + * between: it runs on the same serialized lane as the Stop. */ export async function runStopWithQueuePause( ctx: AgentSessionTurnContext, From 071e2c0f7e91d40092a027d65712cc104d17ddb7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:31:41 -0700 Subject: [PATCH 003/182] test(native-chat): the Claude re-send case fails on its diff, inside the test's budget --- .../structured-agent-session-claude-queued-stop.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index 27d1a7951697..bd960b179ba6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -354,7 +354,8 @@ it('a card sent now into the running turn comes back paused when Stop withdraws uuid: 'interrupted-result' }) - await eventually(async () => { + // Inside the test's budget, so a re-send fails on this diff rather than the timeout. + await vi.waitFor(async () => { const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect({ pause: page.ok ? (page.page.queuePause ?? null) : 'history refused', @@ -365,6 +366,6 @@ it('a card sent now into the running turn comes back paused when Stop withdraws cards: ['waiting'], sends: [{ origin: 'client', state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }] }) - }) + }, 5_000) expect(connection.sent).toHaveLength(2) }, 15_000) From 7de5fca5ad8c5eaab369a1e206282c17b1771de6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:10:55 -0700 Subject: [PATCH 004/182] test(native-chat): a pause held by an unanswered hand-off ends on every path that ends it The provider's answer, the provider dying, the chat closing, a restart, a withdrawal still owed at open, and a /clear (refused until the hand-off ends, then carrying every waiting card paused 'cleared'); each ends with the queue sending again. --- ...session-queued-message-rig.test-fixture.ts | 11 +- ...t-session-queued-pause-handoff-end.test.ts | 194 ++++++++++++++++++ 2 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 9aed0d4acb84..c645b6ea4e47 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -35,7 +35,9 @@ export function eventually(assertion: () => void | Promise): Promise export type QueuedMessageTestRig = Awaited> -export async function createQueuedMessageTestRig() { +/** `restartable`: a child started for a chat whose chain already names a thread resumes it, so a + * chat whose child closed or died can start another. */ +export async function createQueuedMessageTestRig(options: { restartable?: true } = {}) { const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) resetHostTestOperationIds() // Admitted: the message is written and unanswered, so the session owes work @@ -56,8 +58,11 @@ export async function createQueuedMessageTestRig() { const host = new StructuredAgentSessionHost({ store, adapter: { - acquire: async ({ fence, spawnToken, events: sink }) => { + acquire: async ({ identity, fence, spawnToken, events: sink }) => { events = sink + const resumes = + options.restartable === true && + (store.getRecord(identity.sessionId)?.providerHandleChain.length ?? 0) > 0 return { process: { hostId: 'local', @@ -69,7 +74,7 @@ export async function createQueuedMessageTestRig() { link: { linkId: `link-${fence}`, handle: { provider: 'codex' as const, threadId: THREAD }, - origin: 'created' as const, + origin: resumes ? ('resumed' as const) : ('created' as const), mintedAtFence: fence, observedAt: NOW } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts new file mode 100644 index 000000000000..e83c7c5a32e5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts @@ -0,0 +1,194 @@ +// A Stop's queue pause kept alive only by a card's unanswered hand-off ends on every path that +// ends that hand-off, so no lost answer pauses a chat's queue for good: the provider answering +// it, the chat closing, a restart, the provider dying without answering, and a /clear. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(() => rig.dispose()) + +async function queuedDraft(text: string, sessionId = HOST_TEST_SESSION): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + expect( + (await rig.drafts(sessionId)).some((card) => card.messageId === queued.value.queued.messageId) + ) + return queued.value.queued.messageId +} + +/** The stored Stop fact, read from the open conversation (opened by a history read if closed). */ +async function stopFact(sessionId = HOST_TEST_SESSION) { + await rig.drafts(sessionId) + const journal = rig.host.collaboratorsForTests().sessions.get(sessionId)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + return journal.queuedMessages.pause() +} + +/** A card sent now into the running turn, then Stop, with the provider answering neither: the + * pause is recorded over that unanswered hand-off alone, and is not shown. */ +async function stopOverUnansweredHandOff(): Promise<{ working: string; sentId: string }> { + const working = await rig.workingSend() + const sentId = await queuedDraft('sent now into the turn') + await rig.sendNow(sentId) + await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(await stopFact()).toMatchObject({ reason: 'stopped' }) + expect(await rig.queuePause()).toBeNull() + return { working, sentId } +} + +/** The queue sends on its own again: a card typed during a later mail turn drains after it. */ +async function expectQueueDrains(): Promise { + expect(await stopFact()).toBeNull() + expect(await rig.queuePause()).toBeNull() + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await eventually(async () => expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined()) + const later = await queuedDraft('typed during the mail turn') + await rig.settleAccepted(mail.id, 'mail') + await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) +} + +/** The provider's answer that the Stop took the card back before it reached the agent. */ +async function withdraw(draftId: string): Promise { + await rig.host.settleLateDispatch({ + sessionId: HOST_TEST_SESSION, + clientMessageId: await rig.handoffId(draftId), + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) +} + +function clear() { + const fields = { command: 'clear' as const } + return rig.host.conversationCommand(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) +} + +function providerExited() { + return rig.host.handleAdapterEvent({ + type: 'ended', + sessionId: HOST_TEST_SESSION, + reason: 'provider exited', + cause: 'unexpected-exit', + fence: 1, + acquisitionGeneration: 'generation-1' + }) +} + +describe('a pause held only by an unanswered hand-off', () => { + it('the provider withdrawing it leaves the card waiting under the pause; Resume sends it', async () => { + const { working, sentId } = await stopOverUnansweredHandOff() + await withdraw(sentId) + await rig.settleAccepted(working, 'stopped') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.drafts()).toEqual([{ messageId: sentId, state: 'waiting' }]) + expect(await rig.resume()).toMatchObject({ ok: true }) + await eventually(async () => expect((await rig.handoff(sentId))?.origin).toBe('host')) + }) + + it('ends when the provider dies without answering it', async () => { + const { sentId } = await stopOverUnansweredHandOff() + await providerExited() + // The dead generation's settlement: in doubt, never back to waiting. + await eventually(async () => expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown')) + await expectQueueDrains() + }) + + it('ends when the chat closes', async () => { + const { sentId } = await stopOverUnansweredHandOff() + await rig.host.close(HOST_TEST_SESSION) + expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown') + await expectQueueDrains() + }) + + it('ends when the host restarts under it', async () => { + const { sentId } = await stopOverUnansweredHandOff() + await rig.restartHostProcess() + expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown') + await expectQueueDrains() + }) + + it('a withdrawal the restart finds still owed is settled at open, under the pause; Resume sends it', async () => { + const { working, sentId } = await stopOverUnansweredHandOff() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementationOnce(() => { + throw new Error('bookkeeping failed') + }) + try { + await withdraw(sentId) + } finally { + hook.mockRestore() + warn.mockRestore() + } + await rig.settleAccepted(working, 'stopped') + await rig.restartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: sentId, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.resume()).toMatchObject({ ok: true }) + await eventually(async () => expect((await rig.handoff(sentId))?.origin).toBe('host')) + }) + + it("waits for it before a /clear, which then carries every waiting card paused 'cleared'", async () => { + const { working, sentId } = await stopOverUnansweredHandOff() + const carriedId = await queuedDraft('waiting behind the stop') + await rig.settleAccepted(working, 'stopped') + // An unanswered send blocks /clear: the hand-off ends first, by one of the paths above. + expect(await clear()).toMatchObject({ + ok: false, + refusal: { details: { reason: 'messagesUnsettled' } } + }) + await withdraw(sentId) + const cleared = await clear() + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error(`expected a replacement session: ${JSON.stringify(cleared)}`) + } + // The superseded chat keeps no pause over the cards it gave away. + expect(await stopFact()).toBeNull() + expect(await rig.drafts(replacementId)).toEqual([ + { messageId: sentId, state: 'waiting' }, + { messageId: carriedId, state: 'waiting' } + ]) + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + const resumed = await rig.host.queuedMessagesResume(QUEUED_RIG_CALLER, { + envelope: rig.envelope( + {}, + 'agentSession.queuedMessagesResume', + hostTestOperationId(), + replacementId + ) + }) + expect(resumed).toMatchObject({ ok: true }) + await eventually(async () => + expect( + (await rig.host.journalSnapshot(replacementId)).submissions.some( + (entry) => entry.queuedMessageId === sentId + ) + ).toBe(true) + ) + }) +}) From 6ce9464a1cc6b565f292c9b8a9bd1bae709c30e3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:24:26 -0700 Subject: [PATCH 005/182] fix(native-chat): narrow the pause's settled hand-off, and assert the queued receipt's card --- .../agent-session-journal/queued-message-settlement.ts | 4 +++- ...ructured-agent-session-queued-pause-handoff-end.test.ts | 7 +++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts index fed516db95c7..3813534a7c6c 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -60,7 +60,9 @@ export function mayReturnToWaiting( return true } return ( - consumedSubmissionWasRejected(current) && rejectedDraftSettlement(current).state === 'waiting' + current !== undefined && + consumedSubmissionWasRejected(current) && + rejectedDraftSettlement(current).state === 'waiting' ) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts index e83c7c5a32e5..5e8c7dd027b4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts @@ -27,10 +27,9 @@ async function queuedDraft(text: string, sessionId = HOST_TEST_SESSION): Promise if (!queued.ok || !('queued' in queued.value)) { throw new Error('expected a queued receipt') } - expect( - (await rig.drafts(sessionId)).some((card) => card.messageId === queued.value.queued.messageId) - ) - return queued.value.queued.messageId + const { messageId } = queued.value.queued + expect((await rig.drafts(sessionId)).some((card) => card.messageId === messageId)).toBe(true) + return messageId } /** The stored Stop fact, read from the open conversation (opened by a history read if closed). */ From 2af16ab1326745fda4eae052a3dad8b5e18191ca Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:38:08 -0700 Subject: [PATCH 006/182] refactor(native-chat): derive the queue's pause from Stop and Resume journal rows Stop now appends one journal row where it takes effect, before the interrupt, whatever the queue holds; Resume appends its own. The pause is a pure function of the fold: the latest Stop with no later Resume and no later accepted turn a person asked for. A /clear's carried cards name their source, which is the replacement's 'cleared' pause. Host-origin turns never lift either. One predicate decides which cards a pause holds; by default every waiting card without a hold of its own, including one queued after the Stop. The drain's consume re-judges it inside its own transaction. The rows are tombstones of an id no item takes, carrying the mark: a released host reads an unknown row kind as corruption and truncates the journal there. Deletes the stored pause (recordPause, the retire hook on every appended row, the settle-before-record step, mayReturnToWaiting and its row overlay) and the tests that only proved it retires. The queued_message_pauses table stays in the schema, unread and unwritten, for downgrade safety. --- .../journal-epoch-controller.ts | 3 + .../journal-epoch-replacement.test.ts | 1 + .../journal-epoch-replacement.ts | 15 + .../agent-session-journal/journal-open.ts | 5 +- .../journal-queue-pause-row.ts | 37 +++ .../journal-queued-messages.ts | 126 +++----- .../agent-session-journal/journal-reducer.ts | 16 +- .../journal-row-schema.ts | 13 + .../journal-store-collaborators.ts | 4 + .../journal-store-contracts.ts | 3 + .../agent-session-journal/journal-store.ts | 8 +- .../queued-message-delivered-echo.test.ts | 8 - .../queued-message-holds.ts | 5 +- .../queued-message-pause-table.ts | 161 ---------- .../queued-message-pause.test.ts | 279 ++++++++++++++++++ .../queued-message-pause.ts | 115 ++++++++ .../queued-message-schema.ts | 6 +- .../queued-message-settlement.ts | 34 +-- .../queued-message-store.test.ts | 166 ----------- .../queued-message-table.ts | 18 +- .../agent-session-journal-batch.ts | 9 +- ...session-queued-message-rig.test-fixture.ts | 91 +++--- ...ured-agent-session-queued-messages.test.ts | 17 +- ...tructured-agent-session-queued-messages.ts | 23 +- ...ructured-agent-session-queued-mutations.ts | 14 +- ...t-session-queued-pause-handoff-end.test.ts | 193 ------------ ...ed-agent-session-queued-pause-lift.test.ts | 70 ++--- .../structured-agent-session-queued-pause.ts | 92 ++---- ...ctured-agent-session-queued-publication.ts | 15 +- ...ured-agent-session-queued-stop-row.test.ts | 240 +++++++++++++++ .../structured-agent-session-queued-stop.ts | 67 ++--- .../structured-agent-session-queued-wiring.ts | 6 +- ...ent-session-queued-withdrawn-draft.test.ts | 27 +- 33 files changed, 983 insertions(+), 904 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-pause-table.ts create mode 100644 src/main/native-chat/agent-session-journal/queued-message-pause.test.ts create mode 100644 src/main/native-chat/agent-session-journal/queued-message-pause.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts index 52a5478c381a..2c8b2766c0f2 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts @@ -20,6 +20,8 @@ export class JournalEpochController { readOnly: () => boolean setReadOnly: (readOnly: boolean) => void highestFence: () => number + /** Whether the live epoch's latest Stop still pauses the queue. */ + queueStopped: () => boolean cursor: () => AgentJournalCursor adopt: (loaded: JournalLoad) => void } @@ -67,6 +69,7 @@ export class JournalEpochController { reason, fence, items, + queueStopped: this.deps.queueStopped(), now: this.deps.now, mintEpoch: this.deps.mintEpoch, onPublished: this.deps.adopt diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 646254b4932d..3c893545c4e0 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -67,6 +67,7 @@ function replace(input: { reason: 'legacy_import', fence: 1, items: input.items, + queueStopped: false, now, mintEpoch: () => `epoch-${clock}`, onPublished: input.onPublished ?? (() => undefined) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 8d94c5b2b5b0..1097b5ab5cc7 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -16,6 +16,7 @@ import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' +import { buildJournalQueuePauseRow } from './journal-queue-pause-row' import { deleteJournalEpochRows, insertJournalRow, @@ -40,6 +41,9 @@ export function replaceJournalEpoch(input: { reason: AgentJournalEpochReason fence: number items: readonly JournalReplacementItem[] + /** The superseded epoch's latest Stop still paused the queue: the new epoch restates it, or + * the rewind would release cards the person stopped. */ + queueStopped: boolean now: () => number mintEpoch: () => string /** Called the instant the transaction commits, before any fallible follow-up. */ @@ -70,6 +74,17 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } + if (input.queueStopped) { + const row = buildJournalQueuePauseRow({ + state, + mark: 'stopped', + seq: state.lastSequence + 1, + fence: input.fence, + ts: input.now() + }) + applyJournalRow(state, row) + rows.push(row) + } const { sessionId } = input.identity input.database.transaction((db) => { diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts index ae753839c40d..8654ad569aa4 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -19,7 +19,7 @@ import { } from './journal-row-table' import { JOURNAL_REPAIR_DISCLOSURE_ITEM_ID } from './journal-repair-disclosure' import { pendingJournalRepairSequence } from './journal-repair-marker' -import { parseJournalRow, type JournalRow } from './journal-row-schema' +import { isJournalQueuePauseRow, parseJournalRow, type JournalRow } from './journal-row-schema' /** Every epoch row is sequence 1, and no compaction moves that floor. */ const FIRST_JOURNAL_SEQUENCE = 1 @@ -120,7 +120,8 @@ export function startJournalRowFold(input: JournalRowFoldInput): { } applyJournalRow(state, row) const disclosure = row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID - if (!disclosure) { + // A Stop or Resume is no history, so it never reads as a rebuilt or provider-backed epoch. + if (!disclosure && !isJournalQueuePauseRow(row)) { repairHasContent ||= repairedFrom !== null && row.seq >= repairedFrom providerHasContent ||= row.seq >= FIRST_JOURNAL_SEQUENCE + 1 } diff --git a/src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts b/src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts new file mode 100644 index 000000000000..4be07ce6b8b0 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts @@ -0,0 +1,37 @@ +// The row a person's Stop or Resume appends: a tombstone of an id no item ever takes, carrying +// the mark (`journal-row-schema.ts` says why not a row kind of its own). + +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { JournalReducerState } from './journal-reducer' +import { journalRowBase } from './journal-row-builders' +import type { JournalQueuePauseMark, JournalTombstoneRow } from './journal-row-schema' + +/** The one id the queue's Stop and Resume rows name; no item ever takes it. */ +const JOURNAL_QUEUE_PAUSE_ITEM_ID = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'queue-pause' +}) + +export function buildJournalQueuePauseRow(input: { + state: JournalReducerState + mark: JournalQueuePauseMark + seq: number + fence: number + ts: number +}): JournalTombstoneRow { + return { + kind: 'tombstone', + itemId: JOURNAL_QUEUE_PAUSE_ITEM_ID, + revision: 1, + queuePause: input.mark, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + +export function journalQueuePauseRowBuilder( + state: () => JournalReducerState, + mark: JournalQueuePauseMark, + fence: number +): (seq: number, ts: number) => JournalTombstoneRow { + return (seq, ts) => buildJournalQueuePauseRow({ state: state(), mark, seq, fence, ts }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 05dde59051f3..34281b1b849e 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -16,15 +16,7 @@ import type { JournalRow } from './journal-row-schema' import type { JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' -import { - clearQueuePause, - queuePauseHoldsBack, - readQueuePause, - recordQueuePause, - retireQueuePauseIfNothingHeld, - type QueuePauseFact, - type QueuePauseReason -} from './queued-message-pause-table' +import { deriveQueuePause, queuePauseHolds, type DerivedQueuePause } from './queued-message-pause' import { consumeQueuedMessageInTransaction, getQueuedMessage, @@ -38,7 +30,6 @@ import { import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' import { - mayReturnToWaiting, queuedMessageSettlementOwed, settleOwedQueuedMessages, settleQueuedMessagesForRow @@ -56,6 +47,8 @@ export type JournalQueuedMessagesDeps = { database: () => JournalHostDatabase readOnly: () => boolean state: () => JournalReducerState + /** Whether this handle found the row at `sequence` on disk when it opened. */ + wroteBeforeOpen: (sequence: number) => boolean /** The journal's own commit notification. Every standalone draft-table * transaction that changed rows fires it after COMMIT, so a draft or hold * change publishes and wakes the drain through the same path a journal row @@ -68,7 +61,6 @@ export class JournalQueuedMessages { /** Bumped on every draft-table write, so publication memos recompute only when they must. */ private changeRevision = 0 private listed: { revision: number; rows: readonly QueuedMessageRow[] } | null = null - private paused: { revision: number; fact: QueuePauseFact | null } | null = null constructor(private readonly deps: JournalQueuedMessagesDeps) {} @@ -76,12 +68,6 @@ export class JournalQueuedMessages { return this.changeRevision } - /** The submission row of the latest accepted turn a person asked for; 0 when none. What - * ends the queue's pause, read from the reducer in O(1). */ - latestPersonTurnSequence(): number { - return this.deps.state().latestPersonTurnSequence - } - /** A journal transaction rolled back: nothing read inside it may stay cached. */ invalidate(): void { this.changeRevision++ @@ -108,33 +94,26 @@ export class JournalQueuedMessages { return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp) } - /** `pausedBy`: the queue is paused in the SAME transaction as this card lands - * (a /clear's carry), so the drain never sees it unpaused and no pause fact - * exists without a card under it. */ + /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. */ insert(input: { messageId: string body: AgentJournalMessageItem fingerprint: string hostInstance: string - pausedBy?: QueuePauseReason + carriedFrom?: string }): Promise { - const { pausedBy, ...draft } = input const { sessionId } = this.deps let inserted = false return this.transact( (db) => { - const existing = getQueuedMessage(db, sessionId, draft.messageId) + const existing = getQueuedMessage(db, sessionId, input.messageId) if (existing) { // One id, one draft: admission replays a recorded operation before it // gets here, so an existing row is the same accept landing twice. return existing } inserted = true - const row = insertQueuedMessage(db, { ...draft, sessionId, now: this.deps.now() }) - if (pausedBy) { - recordQueuePause(db, { sessionId, fact: this.pauseFact(pausedBy) }) - } - return row + return insertQueuedMessage(db, { ...input, sessionId, now: this.deps.now() }) }, () => inserted ) @@ -150,53 +129,37 @@ export class JournalQueuedMessages { ).then(() => undefined) } - /** Where the user's last Stop took effect, if it is still recorded; cached per revision. */ - pause(): QueuePauseFact | null { - if (this.paused?.revision !== this.changeRevision) { - this.paused = { - revision: this.changeRevision, - fact: readQueuePause(this.deps.database().db, this.deps.sessionId) - } - } - return this.paused.fact + /** The queue's pause, derived from the fold and the cards (`queued-message-pause.ts`). */ + pause(hostInstance: string): DerivedQueuePause | null { + return this.derivePause(this.list(), hostInstance) } - /** A Stop took effect here: the queue is paused from this position on — if, judged in - * the same transaction, it holds back a card at all. Returns whether it recorded. */ - recordPause(reason: QueuePauseReason): Promise { - const fact = this.pauseFact(reason) - return this.transact( - (db) => - queuePauseHoldsBack(db, this.pauseScope()) && - (recordQueuePause(db, { sessionId: this.deps.sessionId, fact }), true), - (recorded) => recorded - ) - } - - /** What `queuePauseHoldsBack` judges a pause by, from this journal's submissions. */ - private pauseScope(row?: JournalRow) { - return { - sessionId: this.deps.sessionId, - mayReturnToWaiting: mayReturnToWaiting(this.deps.state().submissions, row) - } + private derivePause( + cards: readonly QueuedMessageRow[], + hostInstance: string + ): DerivedQueuePause | null { + const state = this.deps.state() + return deriveQueuePause({ + marks: state.queuePauseMarks, + latestPersonTurnSequence: state.latestPersonTurnSequence, + cards, + hostInstance, + restartEnded: this.restartEnded() + }) } - private pauseFact(reason: QueuePauseReason): QueuePauseFact { - const { epoch, lastSequence: sequence } = this.deps.state() - return { reason, epoch, sequence, recordedAt: this.deps.now() } + /** A person's turn started since this handle opened, which ends a restart's pause. */ + restartEnded(): boolean { + const latest = this.deps.state().latestPersonTurnSequence + return latest > 0 && !this.deps.wroteBeforeOpen(latest) } - /** Ends the queue's pause: `stop` retires that Stop fact (never a later one), - * `adoptInto` adopts a restart's rows into this host instance. Returns whether - * anything changed. */ - liftPause(input: { stop: QueuePauseFact | null; adoptInto: string | null }): Promise { + /** Adopts waiting rows another host instance wrote into this one, ending a restart's pause. + * Returns whether anything changed. */ + adopt(hostInstance: string): Promise { const { sessionId } = this.deps return this.transact( - (db) => - (input.stop ? clearQueuePause(db, { sessionId, fact: input.stop }) : 0) + - (input.adoptInto === null - ? 0 - : adoptQueuedMessages(db, { sessionId, hostInstance: input.adoptInto })), + (db) => adoptQueuedMessages(db, { sessionId, hostInstance }), (changed) => changed > 0 ).then((changed) => changed > 0) } @@ -230,12 +193,8 @@ export class JournalQueuedMessages { ): Promise { return this.deps.serialize(async () => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - const { result, retired } = this.deps.database().transaction((db) => ({ - result: run(db), - // Any draft write may take the last card a pause holds back. - retired: retireQueuePauseIfNothingHeld(db, this.pauseScope()) - })) - if (changed(result) || retired > 0) { + const result = this.deps.database().transaction(run) + if (changed(result)) { this.changeRevision++ this.deps.committed() } @@ -253,21 +212,29 @@ export class JournalQueuedMessages { row, now: this.deps.now() }) - this.changeRevision += retireQueuePauseIfNothingHeld(db, this.pauseScope(row)) } /** The in-transaction consume for `appendSubmission`; a false compare-and-set * throws so the whole append — draft transition AND submission row — rolls back. */ consumeInTransaction( db: Database.Database, - input: JournalSubmissionConsume & { consumedAs: string }, - row: JournalRow + input: JournalSubmissionConsume & { consumedAs: string } ): void { const { db: own } = this.deps.database() if (own !== db) { // Same handle only: a second connection could not join the transaction. throw new AgentSessionJournalError('journal_closed', 'consume crossed database handles') } + if (input.yieldsToPause) { + // Judged here, on the same queue as every journal append, so a Stop row that landed + // after the drain chose this card still holds it. + const cards = listQueuedMessages(db, this.deps.sessionId) + const pause = this.derivePause(cards, input.yieldsToPause.hostInstance) + const card = cards.find((entry) => entry.messageId === input.messageId) + if (pause && card && queuePauseHolds(pause, card)) { + throw new QueuedMessageNotConsumableError(input.messageId, input.expect) + } + } const consumed = consumeQueuedMessageInTransaction(db, { ...input, sessionId: this.deps.sessionId, @@ -276,7 +243,6 @@ export class JournalQueuedMessages { if (!consumed) { throw new QueuedMessageNotConsumableError(input.messageId, input.expect) } - retireQueuePauseIfNothingHeld(db, this.pauseScope(row)) this.changeRevision++ } @@ -319,11 +285,11 @@ export class JournalQueuedMessages { * Open-time reconciliation, a re-derivation behind the stored fact: owed * settlements apply exactly as the live hook would have (covers consume → * crash → downgrade → upgrade, where the old build rejected the leftover with - * no hook), then retention runs; a pause left holding back nothing retires. + * no hook), then retention runs. */ repairAndPrune(): Promise { // No draft, no work, and no write: a chat whose first-use copy is still owed stays uncopied. - if (this.deps.readOnly() || (this.list().length === 0 && this.pause() === null)) { + if (this.deps.readOnly() || this.list().length === 0) { return Promise.resolve() } const { sessionId } = this.deps @@ -351,7 +317,7 @@ export function queuedMessageConsumeHook( consumedAs: string, consume: JournalSubmissionConsume ): JournalRowTransactionHook { - return (db, row) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }, row) + return (db) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }) } export class QueuedMessageNotConsumableError extends Error { diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index ed12c4a6cba8..0af2eccf11dd 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -26,10 +26,15 @@ import { structuredAgentSessionPayloadFingerprint } from '../../../shared/struct import { JournalDerivedTurnScope } from './journal-derived-turn-scope' import { removeJournalItem, statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import { journalItemRevisionIsStale } from './journal-item-revision' -import type { JournalRow } from './journal-row-schema' +import { isJournalQueuePauseRow, type JournalRow } from './journal-row-schema' import { acceptSubmissionFromProviderItem, applyJournalSubmission } from './journal-submission-fold' import { applyJournalDispatchRow } from './journal-dispatch-reducer' import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' +import { + createJournalQueuePauseMarks, + foldJournalQueuePauseMark, + type JournalQueuePauseMarks +} from './queued-message-pause' export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096 @@ -57,6 +62,8 @@ export type JournalReducerState = { /** The submission row of the latest turn a person asked for (`origin: 'client'`) that the * provider accepted; 0 when none. Kept as it folds so the queue's pause reads it in O(1). */ latestPersonTurnSequence: number + /** The queue's latest Stop and Resume rows, what its pause is derived from. */ + queuePauseMarks: JournalQueuePauseMarks } export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState { @@ -75,7 +82,8 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou aliases: new Map(), appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), - latestPersonTurnSequence: 0 + latestPersonTurnSequence: 0, + queuePauseMarks: createJournalQueuePauseMarks() } } @@ -101,6 +109,10 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo ) return } + if (isJournalQueuePauseRow(row)) { + foldJournalQueuePauseMark(state.queuePauseMarks, row) + return + } if (row.kind === 'tombstone') { removeJournalItem(state, resolveItemId(state, row.itemId), row.revision) return 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..67c8aa8ca111 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 @@ -72,6 +72,19 @@ export type JournalTombstoneRow = JournalRowBase & { kind: 'tombstone' itemId: string revision: number + /** Present: not a removal but the queue's Stop or Resume (`queued-message-pause.ts`), on an id + * no item ever takes. A new row kind would make a released host truncate the journal from + * that row; this one it reads as removing nothing. */ + queuePause?: JournalQueuePauseMark +} + +export type JournalQueuePauseMark = 'stopped' | 'resumed' + +/** A Stop or Resume row. Any value counts, so a newer build's mark never removes an item. */ +export function isJournalQueuePauseRow( + row: JournalRow +): row is JournalTombstoneRow & { queuePause: JournalQueuePauseMark } { + return row.kind === 'tombstone' && row.queuePause !== undefined } /** The write-ahead row. Durable BEFORE the adapter dispatches anything; it diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 16d3795cc6ab..5e90baf7a873 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -13,6 +13,7 @@ import { JournalItemAppender } from './journal-item-appender' import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalLoad } from './journal-open' import { JournalQueuedMessages } from './journal-queued-messages' +import { journalQueueStopHolds } from './queued-message-pause' import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-writer' import { restoreJournalStore } from './journal-store-restore' @@ -70,6 +71,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal readOnly: host.readOnly, setReadOnly: host.setReadOnly, highestFence: () => host.state().highestFence, + queueStopped: () => + journalQueueStopHolds(host.state().queuePauseMarks, host.state().latestPersonTurnSequence), cursor: host.cursor, adopt: host.adopt }) @@ -80,6 +83,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal database: host.database, readOnly: host.readOnly, state: host.state, + wroteBeforeOpen: (sequence) => host.journal().wroteBeforeOpen(sequence), committed: host.notifyCommitted }) return { 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..85e6b99f9029 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 @@ -94,6 +94,9 @@ export type JournalSubmissionConsume = { /** The host process handing it off, stamped on the draft so a hand-off withdrawn back to * waiting belongs to the process that sent it, not the one that first wrote the card. */ hostInstance?: string + /** The queue's own send: refused in the consume's transaction while the queue's pause, as + * this host instance derives it, holds the card. Send-now omits it. */ + yieldsToPause?: { hostInstance: string } } export type JournalItemAppendInput = { 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..1c26eb031f55 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -58,7 +58,8 @@ import type { ResolveDispatchInput } from './journal-store-contracts' import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' -import type { AgentJournalEpochReason } from './journal-row-schema' +import { journalQueuePauseRowBuilder } from './journal-queue-pause-row' +import type { AgentJournalEpochReason, JournalQueuePauseMark } from './journal-row-schema' import type { JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' @@ -307,6 +308,11 @@ export class AgentSessionJournal { ) } + /** A person's Stop or Resume: the rows the queue's pause is derived from. */ + appendQueuePauseMark(mark: JournalQueuePauseMark, fence: number): Promise { + return this.rowWriter.append(journalQueuePauseRowBuilder(() => this.state, mark, fence)) + } + appendLifecycleBatch(input: JournalLifecycleBatchInput): Promise { return this.lifecycleBatchAppender.append(input) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts index 5b21c36f3af5..4ce62c6192b2 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts @@ -139,14 +139,6 @@ describe("a waiting draft whose 'never delivered' claim an echo disproves", () = expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') }) - it('retires the pause in the per-row hook when that echo withdraws the last card it holds back', async () => { - const journal = await withdrawnDraft('did it land?') - expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) - await echo(journal, 'echo-1', 'did it land?') - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - expect(journal.queuedMessages.pause()).toBeNull() - }) - it('stays waiting for an echo of some other text', async () => { const journal = await withdrawnDraft('did it land?') await echo(journal, 'echo-1', 'something else') diff --git a/src/main/native-chat/agent-session-journal/queued-message-holds.ts b/src/main/native-chat/agent-session-journal/queued-message-holds.ts index 665466d60e76..00b90a832c1d 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-holds.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-holds.ts @@ -1,7 +1,6 @@ // Per-draft holds: what keeps one card from auto-sending, stored on its row. A -// Stop or a restart pauses the whole queue instead (`queued-message-pause-table.ts`, -// and the host instance each row records); a per-draft hold is only a -// conversion that failed, which an explicit Send releases. +// Stop or a restart pauses the queue instead (`queued-message-pause.ts`, derived); +// a per-draft hold is only a conversion that failed, which an explicit Send releases. import type Database from '../../sqlite/sync-database' import type { QueuedMessageHoldReason } from './queued-message-table' diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts b/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts deleted file mode 100644 index f7dba2b2fca4..000000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-pause-table.ts +++ /dev/null @@ -1,161 +0,0 @@ -// The queue-level pause fact: where in the journal the user's last Stop (or a -// /clear, which starts its replacement paused) took effect. The pause itself is never stored — it is derived from this fact and -// the journal rows after it (a user-requested turn that started ends it); the -// fact only records the one event the journal's closed row kinds cannot carry. -// An explicit Resume retires it. - -import type Database from '../../sqlite/sync-database' - -export type QueuePauseReason = 'stopped' | 'cleared' - -export type QueuePauseFact = { - reason: QueuePauseReason - /** The journal position the Stop took effect at: rows after it are later. */ - epoch: string - sequence: number - recordedAt: number -} - -export function readQueuePause(db: Database.Database, sessionId: string): QueuePauseFact | null { - const row: unknown = db - .prepare( - 'SELECT reason, epoch, sequence, recorded_at FROM queued_message_pauses WHERE session_id = ?' - ) - .get(sessionId) - if ( - typeof row !== 'object' || - row === null || - !('reason' in row) || - (row.reason !== 'stopped' && row.reason !== 'cleared') || - !('epoch' in row) || - typeof row.epoch !== 'string' || - !('sequence' in row) || - typeof row.sequence !== 'number' || - !('recorded_at' in row) || - typeof row.recorded_at !== 'number' - ) { - // A reason this build cannot place reads as no pause rather than a wrong one. - return null - } - return { - reason: row.reason, - epoch: row.epoch, - sequence: row.sequence, - recordedAt: row.recorded_at - } -} - -/** The latest Stop replaces an earlier one: only the last interruption decides. */ -export function recordQueuePause( - db: Database.Database, - input: { sessionId: string; fact: QueuePauseFact } -): void { - db.prepare( - `INSERT INTO queued_message_pauses (session_id, reason, epoch, sequence, recorded_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT (session_id) DO UPDATE SET - reason = excluded.reason, epoch = excluded.epoch, - sequence = excluded.sequence, recorded_at = excluded.recorded_at` - ).run( - input.sessionId, - input.fact.reason, - input.fact.epoch, - input.fact.sequence, - input.fact.recordedAt - ) -} - -/** Compare-and-clear: only the fact the caller judged, so a Stop recorded since stands. */ -export function clearQueuePause( - db: Database.Database, - input: { sessionId: string; fact: Pick } -): number { - return Number( - db - .prepare( - 'DELETE FROM queued_message_pauses WHERE session_id = ? AND epoch = ? AND sequence = ?' - ) - .run(input.sessionId, input.fact.epoch, input.fact.sequence).changes ?? 0 - ) -} - -type QueueCardState = { state: string; holdReason: string | null } - -/** A card a pause holds back: waiting, with no hold of its own, wherever it sits. - * While one exists — or a hand-off that may still return to waiting - * (`queuePauseHoldsBack`) — the pause is KEPT: a Stop records it, and it is - * retired only once none remains, so deleting a returned card that blocks such - * cards leaves them paused rather than sending them unasked. */ -export function isPausableQueuedMessage(row: QueueCardState): boolean { - return row.state === 'waiting' && row.holdReason === null -} - -/** Whether Resume would send anything: a pausable card not behind a returned one, - * which blocks everything after it until the user acts, exactly as the drain - * reads it. Only then is the kept pause PUBLISHED, so its header never offers a - * Resume that sends nothing. */ -export function hasResumableQueuedMessage(rows: readonly QueueCardState[]): boolean { - for (const row of rows) { - if (row.state === 'returned') { - return false - } - if (isPausableQueuedMessage(row)) { - return true - } - } - return false -} - -/** What a queue pause holds back, judged inside the caller's transaction: a waiting - * card with no hold of its own (`isPausableQueuedMessage`, in SQL), or a dispatched - * one its hand-off may still send back to waiting — unanswered, so the Stop that - * recorded the pause may yet withdraw it, or withdrawn with that return still owed. - * Only the journal's submissions can tell (`mayReturnToWaiting`). */ -export function queuePauseHoldsBack( - db: Database.Database, - input: { sessionId: string; mayReturnToWaiting: (consumedRef: string) => boolean } -): boolean { - const pausable = db - .prepare( - `SELECT 1 FROM queued_messages - WHERE session_id = ? AND state = 'waiting' AND hold_reason IS NULL LIMIT 1` - ) - .get(input.sessionId) - if (pausable !== undefined) { - return true - } - return db - .prepare( - `SELECT consumed_as FROM queued_messages - WHERE session_id = ? AND state = 'dispatched' AND consumed_as IS NOT NULL` - ) - .all(input.sessionId) - .some( - (row) => - typeof row === 'object' && - row !== null && - 'consumed_as' in row && - typeof row.consumed_as === 'string' && - input.mayReturnToWaiting(row.consumed_as) - ) -} - -/** A pause is over the cards it paused: once it holds back none (`queuePauseHoldsBack`), - * the fact goes too, in the same transaction as the write that took the last one, so - * it can never outlive them and catch a card typed long after. */ -export function retireQueuePauseIfNothingHeld( - db: Database.Database, - input: { sessionId: string; mayReturnToWaiting: (consumedRef: string) => boolean } -): number { - // Runs on every appended journal row: with no pause recorded there is nothing to judge. - const recorded = db - .prepare('SELECT 1 FROM queued_message_pauses WHERE session_id = ?') - .get(input.sessionId) - if (recorded === undefined || queuePauseHoldsBack(db, input)) { - return 0 - } - return Number( - db.prepare('DELETE FROM queued_message_pauses WHERE session_id = ?').run(input.sessionId) - .changes ?? 0 - ) -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts new file mode 100644 index 000000000000..e0b5de53eb62 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -0,0 +1,279 @@ +// The queue's pause is a pure function of the journal: a Stop and a Resume are rows, a person's +// accepted turn is a row, and a /clear's carried card names its source. Nothing is stored beside +// them, so nothing has to retire. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import Database from '../../sqlite/sync-database' +import { journalDatabasePath } from './journal-host-database' +import { + createTrackedJournalOpener, + liveTestJournalRows +} from './journal-host-database-test-support' +import { QueuedMessageNotConsumableError } from './journal-queued-messages' +import { applyJournalRow, createJournalReducerState } from './journal-reducer' +import { parseJournalRow } from './journal-row-schema' +import type { AgentSessionJournal } from './journal-store' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-p', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} +const HOST = 'proc-1' + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +function open(): Promise { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) +} + +function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom?: string) { + return journal.queuedMessages.insert({ + messageId, + body: message(messageId), + fingerprint: `fp-${messageId}`, + hostInstance: HOST, + ...(carriedFrom ? { carriedFrom } : {}) + }) +} + +/** A turn sent, then accepted by the provider; `origin` says who asked for it. */ +async function turn( + journal: AgentSessionJournal, + id: string, + origin: 'client' | 'host', + accept = true +): Promise { + await journal.appendSubmission({ + clientMessageId: id, + origin, + payloadFingerprint: `fp-${id}`, + body: message(id), + fence: 0, + handoverRecorded: true + }) + if (accept) { + await acceptTurn(journal, id) + } +} + +function acceptTurn(journal: AgentSessionJournal, id: string) { + return journal.resolveDispatch({ + clientMessageId: id, + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: `echo-${id}` }, + fence: 0 + }) +} + +function reason(journal: AgentSessionJournal): string | null { + return journal.queuedMessages.pause(HOST)?.reason ?? null +} + +function storedPauses(): number { + const db = new Database(journalDatabasePath(root), { readonly: true }) + try { + const row: unknown = db.prepare('SELECT COUNT(*) AS n FROM queued_message_pauses').get() + return typeof row === 'object' && row !== null && 'n' in row ? Number(row.n) : -1 + } finally { + db.close() + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queue-pause-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe("the queue's pause, derived from the journal", () => { + it('a Stop row pauses the queue, even with no card yet, survives reopen, and stores nothing', async () => { + let journal = await open() + await journal.appendQueuePauseMark('stopped', 0) + expect(reason(journal)).toBe('stopped') + await queueDraft(journal, 'draft-1') + await journal.close() + journal = await open() + expect(reason(journal)).toBe('stopped') + expect(storedPauses()).toBe(0) + }) + + it("only a person's turn sent after the Stop and accepted lifts it; host turns never do", async () => { + const journal = await open() + await turn(journal, 'before-stop', 'client', false) + await journal.appendQueuePauseMark('stopped', 0) + // Sent before the Stop: its acceptance now does not end a Stop that came after it. + await acceptTurn(journal, 'before-stop') + await turn(journal, 'mail', 'host') + expect(reason(journal)).toBe('stopped') + await turn(journal, 'typed', 'client', false) + expect(reason(journal)).toBe('stopped') + await acceptTurn(journal, 'typed') + expect(reason(journal)).toBeNull() + }) + + it('a later Stop is the latest, and a Resume row lifts it', async () => { + const journal = await open() + await journal.appendQueuePauseMark('stopped', 0) + await turn(journal, 'typed', 'client') + expect(reason(journal)).toBeNull() + await journal.appendQueuePauseMark('stopped', 0) + expect(reason(journal)).toBe('stopped') + await journal.appendQueuePauseMark('resumed', 0) + expect(reason(journal)).toBeNull() + await journal.appendQueuePauseMark('stopped', 0) + expect(reason(journal)).toBe('stopped') + expect(storedPauses()).toBe(0) + }) + + it("a card /clear carried in pauses the replacement 'cleared' until a Resume there", async () => { + let journal = await open() + await queueDraft(journal, 'carried', 'source-session') + expect(reason(journal)).toBe('cleared') + await journal.close() + journal = await open() + expect(reason(journal)).toBe('cleared') + await journal.appendQueuePauseMark('resumed', 0) + expect(reason(journal)).toBeNull() + expect(storedPauses()).toBe(0) + }) + + it("a person's accepted turn on the replacement lifts 'cleared'; a host turn does not", async () => { + const journal = await open() + await queueDraft(journal, 'carried', 'source-session') + await turn(journal, 'launch', 'host') + expect(reason(journal)).toBe('cleared') + await turn(journal, 'typed', 'client') + expect(reason(journal)).toBeNull() + }) + + it('rides a tombstone of an id no item takes, so an older build reads it and changes nothing', async () => { + const journal = await open() + await turn(journal, 'typed', 'client') + await journal.appendQueuePauseMark('stopped', 0) + const db = new Database(journalDatabasePath(root), { readonly: true }) + const stored = liveTestJournalRows(db, IDENTITY.sessionId) + db.close() + const parsed = parseJournalRow(stored.at(-1)?.rowJson ?? '') + expect(parsed).toMatchObject({ ok: true, row: { kind: 'tombstone', queuePause: 'stopped' } }) + if (!parsed.ok || parsed.row.kind !== 'tombstone') { + throw new Error('expected a tombstone row') + } + // An older build ignores the unknown key: the row is an ordinary removal of nothing. + const { queuePause: _ignored, ...asOlderBuildReadsIt } = parsed.row + const state = createJournalReducerState(IDENTITY.sessionId, parsed.row.epoch) + for (const row of stored.slice(0, -1)) { + const earlier = parseJournalRow(row.rowJson) + if (earlier.ok) { + applyJournalRow(state, earlier.row) + } + } + const items = [...state.items.keys()] + const submissions = [...state.submissions.keys()] + applyJournalRow(state, asOlderBuildReadsIt) + expect([...state.items.keys()]).toEqual(items) + expect([...state.submissions.keys()]).toEqual(submissions) + }) + + it("the queue's own consume is refused in its transaction while the pause holds the card; Send-now is not", async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.appendQueuePauseMark('stopped', 0) + const consume = (id: string, automatic: boolean) => + journal.appendSubmission( + { + clientMessageId: id, + origin: automatic ? 'host' : 'client', + payloadFingerprint: 'fp-draft-1', + body: message('draft-1'), + fence: 0, + handoverRecorded: true + }, + { + messageId: 'draft-1', + expect: 'waiting', + settledByOp: null, + hostInstance: HOST, + ...(automatic ? { yieldsToPause: { hostInstance: HOST } } : {}) + } + ) + await expect(consume('drain-1', true)).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) + expect(journal.submission('drain-1')).toBeUndefined() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + await consume('send-now-1', false) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + }) + + it("a rewind's epoch replacement restates a Stop still pausing, and not one a person ended", async () => { + const journal = await open() + await journal.appendQueuePauseMark('stopped', 0) + await journal.replaceEpochItems('handle_forked', 0, []) + expect(reason(journal)).toBe('stopped') + await turn(journal, 'typed', 'client') + await journal.replaceEpochItems('handle_forked', 0, []) + expect(reason(journal)).toBeNull() + }) +}) + +describe("a restart's pause", () => { + it("adopting a restart's rows moves them into this instance and clears an older build's stored 'stopped' hold; send_failed stays", async () => { + const journal = await open() + await journal.queuedMessages.insert({ + messageId: 'draft-restart', + body: message('written before the restart'), + fingerprint: 'fp-draft-restart', + hostInstance: 'proc-0' + }) + expect(reason(journal)).toBe('restarted') + await queueDraft(journal, 'draft-legacy') + await queueDraft(journal, 'draft-failed') + await journal.queuedMessages.hold({ messageIds: ['draft-failed'], reason: 'send_failed' }) + const db = new Database(journalDatabasePath(root)) + db.prepare("UPDATE queued_messages SET hold_reason = 'stopped' WHERE message_id = ?").run( + 'draft-legacy' + ) + db.close() + journal.queuedMessages.invalidate() + expect(await journal.queuedMessages.adopt(HOST)).toBe(true) + expect(reason(journal)).toBeNull() + expect( + journal.queuedMessages.list().map((row) => [row.messageId, row.hostInstance, row.holdReason]) + ).toEqual([ + ['draft-restart', HOST, null], + ['draft-legacy', HOST, null], + ['draft-failed', HOST, 'send_failed'] + ]) + }) + + it('an adoption with nothing to adopt changes nothing and fires no commit notification', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + const revision = journal.queuedMessages.revision() + expect(await journal.queuedMessages.adopt(HOST)).toBe(false) + expect(journal.queuedMessages.revision()).toBe(revision) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts new file mode 100644 index 000000000000..ef0cd9950a9e --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -0,0 +1,115 @@ +// The queue's pause: a pure function of the journal fold and the cards, never a stored flag, so +// nothing has to retire it. Paused when: +// - 'stopped': the latest Stop row has no later Resume row, and no turn a person asked for was +// sent after it and accepted. A later Stop is simply the latest. +// - 'cleared': a card /clear carried into this conversation waits, and no person's turn or +// Resume has happened here since. +// - 'restarted': a waiting card was written by another host process, and no person's turn has +// started since this conversation opened. +// A person's turn is an accepted submission of origin `client`. Orchestration mail, a restart +// continuation, a launch prompt and the queue's own drain are `host` and never lift it. + +import type { JournalQueuePauseMark, JournalTombstoneRow } from './journal-row-schema' + +export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' + +/** The latest Stop and Resume rows, folded by the reducer; 0 when none. */ +export type JournalQueuePauseMarks = { + stoppedSequence: number + stoppedAt: number + resumedSequence: number +} + +export type DerivedQueuePause = { + reason: QueuePauseReason + /** Host clock the pause began at; a card created later is newer than it. */ + since: number +} + +type QueueCard = { + state: string + holdReason: string | null + createdAt: number + hostInstance: string + carriedFrom: string | null +} + +export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { + return { stoppedSequence: 0, stoppedAt: 0, resumedSequence: 0 } +} + +export function foldJournalQueuePauseMark( + marks: JournalQueuePauseMarks, + row: JournalTombstoneRow & { queuePause: JournalQueuePauseMark } +): void { + if (row.queuePause === 'stopped') { + marks.stoppedSequence = row.seq + marks.stoppedAt = row.ts + } else if (row.queuePause === 'resumed') { + marks.resumedSequence = row.seq + } +} + +/** The latest Stop still pauses: nothing a person did since, and no Resume, ended it. */ +export function journalQueueStopHolds( + marks: JournalQueuePauseMarks, + latestPersonTurnSequence: number +): boolean { + return marks.stoppedSequence > Math.max(latestPersonTurnSequence, marks.resumedSequence) +} + +export function deriveQueuePause(input: { + marks: JournalQueuePauseMarks + latestPersonTurnSequence: number + cards: readonly QueueCard[] + hostInstance: string + /** A person's turn started since this conversation opened. */ + restartEnded: boolean +}): DerivedQueuePause | null { + const { marks, latestPersonTurnSequence } = input + if (journalQueueStopHolds(marks, latestPersonTurnSequence)) { + return { reason: 'stopped', since: marks.stoppedAt } + } + const waiting = input.cards.filter((card) => card.state === 'waiting') + const carried = waiting.filter((card) => card.carriedFrom !== null) + if (carried.length > 0 && latestPersonTurnSequence === 0 && marks.resumedSequence === 0) { + return { reason: 'cleared', since: Math.max(...carried.map((card) => card.createdAt)) } + } + if (!input.restartEnded && waiting.some((card) => card.hostInstance !== input.hostInstance)) { + // The process that wrote a card is gone: every card waits, whenever it was written. + return { reason: 'restarted', since: Number.POSITIVE_INFINITY } + } + return null +} + +// Open product decision: whether a pause also holds a card queued AFTER it began (for example +// typed while orchestration mail runs after a Stop). true: every waiting card waits for the person. +// false: only cards that existed when it began, or came back from it, wait; newer ones drain. +const PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT = true + +/** THE rule for which cards a pause holds: the drain, its consume, and publication all read it. */ +export function queuePauseHolds(pause: DerivedQueuePause, card: QueueCard): boolean { + return ( + card.state === 'waiting' && + card.holdReason === null && + (PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || card.createdAt <= pause.since) + ) +} + +/** Whether Resume would send anything: a card the pause holds, not behind a returned card, which + * blocks everything after it until the user acts. Only then is the pause PUBLISHED, so its + * header never offers a Resume that sends nothing. */ +export function queuePauseHoldsResumableCard( + pause: DerivedQueuePause, + cards: readonly QueueCard[] +): boolean { + for (const card of cards) { + if (card.state === 'returned') { + return false + } + if (queuePauseHolds(pause, card)) { + return true + } + } + return false +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts index 31806e6b71a7..34230fe51cf0 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-schema.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -9,7 +9,8 @@ const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ ['returned_rejection', 'TEXT'], ['settled_at', 'INTEGER'], ['settled_by_op', 'TEXT'], - ['consumed_as', 'TEXT'] + ['consumed_as', 'TEXT'], + ['carried_from', 'TEXT'] ] /** @@ -37,6 +38,7 @@ CREATE TABLE IF NOT EXISTS queued_messages ( settled_at INTEGER, settled_by_op TEXT, consumed_as TEXT, + carried_from TEXT, PRIMARY KEY (session_id, message_id) ); `) @@ -61,6 +63,8 @@ CREATE TABLE IF NOT EXISTS queued_messages ( db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS queued_messages_consumed_as ON queued_messages (session_id, consumed_as) WHERE consumed_as IS NOT NULL; +-- Unread and unwritten: the queue's pause is derived from the journal. Kept for downgrade +-- safety; dropped in a later migration. CREATE TABLE IF NOT EXISTS queued_message_pauses ( session_id TEXT PRIMARY KEY, reason TEXT NOT NULL, diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts index 3813534a7c6c..8d2e9ec0acce 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -8,9 +8,7 @@ import type Database from '../../sqlite/sync-database' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { consumedSubmissionWasRejected, - journalDispatchRowApplies, - journalDispatchRowNewlyRejects, - rejectedDraftSettlement + journalDispatchRowNewlyRejects } from './journal-dispatch-settlement' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' @@ -37,36 +35,6 @@ export function queuedMessageSettlementOwed( ) } -/** A dispatched draft's hand-off can still send it back to waiting: unanswered, so a Stop may - * yet withdraw it, or withdrawn with that return still owed. What a queue pause holds back - * besides waiting cards. `row`, the journal row this transaction appends, counts as applied, - * so the write that settles the last such hand-off is the one that retires the pause. */ -export function mayReturnToWaiting( - submissions: Submissions, - row?: JournalRow -): (consumedRef: string) => boolean { - return (consumedRef) => { - if (row?.kind === 'submission' && row.clientMessageId === consumedRef) { - return true - } - const submission = submissions.get(consumedRef) - const current = - row?.kind === 'dispatch' && - row.clientMessageId === consumedRef && - journalDispatchRowApplies(submission) - ? { dispatchState: row.state, reason: row.reason, rejection: row.rejection } - : submission - if (current?.dispatchState === 'pending') { - return true - } - return ( - current !== undefined && - consumedSubmissionWasRejected(current) && - rejectedDraftSettlement(current).state === 'waiting' - ) - } -} - /** Applies each owed settlement, and withdraws each waiting draft an applied echo proves * delivered (`draftsDeliveredByAppliedEcho`); returns how many drafts changed. */ export function settleOwedQueuedMessages( diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index 053210c015de..c3910e466854 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -685,172 +685,6 @@ describe('holds', () => { }) }) -describe("the queue's Stop fact", () => { - it('records where the Stop took effect, survives reopen, and the latest Stop replaces an earlier one', async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await journal.queuedMessages.recordPause('stopped') - const first = journal.queuedMessages.pause() - expect(first).toMatchObject({ reason: 'stopped', sequence: journal.cursor().sequence }) - await journal.appendItem( - { provider: 'orca', clientMessageId: 'later' }, - { kind: 'status', text: 'later' }, - { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.queuedMessages.recordPause('stopped') - expect(journal.queuedMessages.pause()?.sequence).toBe((first?.sequence ?? 0) + 1) - await journal.close() - journal = await open() - expect(journal.queuedMessages.pause()?.sequence).toBe((first?.sequence ?? 0) + 1) - // The pause is the queue's, never a row's. - expect(journal.queuedMessages.get('draft-1')?.holdReason).toBeNull() - }) - - it("a /clear's replacement records its pause as 'cleared', read back the same way", async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await journal.queuedMessages.recordPause('cleared') - await journal.close() - journal = await open() - expect(journal.queuedMessages.pause()).toMatchObject({ reason: 'cleared' }) - }) - - it('retires in the write that takes the last card it holds back: a hold of its own', async () => { - const journal = await open() - await queueDraft(journal, 'draft-held') - expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) - await journal.queuedMessages.hold({ messageIds: ['draft-held'], reason: 'send_failed' }) - expect(journal.queuedMessages.pause()).toBeNull() - }) - - it('records nothing over a queue with no card it holds back, judged in its own transaction', async () => { - const journal = await open() - expect(await journal.queuedMessages.recordPause('stopped')).toBe(false) - expect(journal.queuedMessages.pause()).toBeNull() - await queueDraft(journal, 'draft-1') - expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) - expect(journal.queuedMessages.pause()).not.toBeNull() - }) - - it('holds a card sent now while its hand-off is unanswered; the write accepting it retires the pause', async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) - // Send-now: the last waiting card is handed off, which a Stop may yet withdraw. - await consumeDraft(journal, 'draft-1') - expect(journal.queuedMessages.pause()).not.toBeNull() - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'accepted', - providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, - fence: 0 - }) - expect(journal.queuedMessages.pause()).toBeNull() - await journal.close() - journal = await open() - expect(journal.queuedMessages.pause()).toBeNull() - }) - - it("records over a hand-off still unanswered, so the Stop's withdrawal lands it back under the pause", async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...STOP_WITHDRAWAL, - fence: 0 - }) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - expect(journal.queuedMessages.pause()).not.toBeNull() - }) - - it('a hand-off whose return to waiting is still owed (its hook skipped) keeps the pause', async () => { - const journal = await open() - await queueDraft(journal, 'draft-sent') - await queueDraft(journal, 'draft-other') - await consumeDraft(journal, 'draft-sent') - expect(await journal.queuedMessages.recordPause('stopped')).toBe(true) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const hook = vi - .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') - .mockImplementationOnce(() => { - throw new Error('bookkeeping failed') - }) - try { - await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) - } finally { - hook.mockRestore() - warn.mockRestore() - } - expect(journal.queuedMessages.get('draft-sent')?.state).toBe('dispatched') - // The only waiting card goes; the owed one is still a card the pause holds back. - await journal.queuedMessages.withdraw({ messageIds: ['draft-other'], settledByOp: 'c\u0000op' }) - expect(journal.queuedMessages.pause()).not.toBeNull() - await journal.queuedMessages.settleOwed() - expect(journal.queuedMessages.get('draft-sent')?.state).toBe('waiting') - expect(journal.queuedMessages.pause()).not.toBeNull() - }) - - it('lifting retires only the Stop fact it judged, never one recorded since', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await journal.queuedMessages.recordPause('stopped') - const judged = journal.queuedMessages.pause() - await journal.appendItem( - { provider: 'orca', clientMessageId: 'later' }, - { kind: 'status', text: 'later' }, - { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.queuedMessages.recordPause('stopped') - expect(await journal.queuedMessages.liftPause({ stop: judged, adoptInto: null })).toBe(false) - expect(journal.queuedMessages.pause()).not.toBeNull() - expect( - await journal.queuedMessages.liftPause({ - stop: journal.queuedMessages.pause(), - adoptInto: null - }) - ).toBe(true) - expect(journal.queuedMessages.pause()).toBeNull() - }) - - it("adopting a restart's rows moves them into this instance and clears an older build's stored 'stopped' hold; send_failed stays", async () => { - const journal = await open() - await journal.queuedMessages.insert({ - messageId: 'draft-restart', - body: message('written before the restart'), - fingerprint: 'fp-draft-restart', - hostInstance: 'proc-0' - }) - await queueDraft(journal, 'draft-legacy') - await queueDraft(journal, 'draft-failed') - await journal.queuedMessages.hold({ messageIds: ['draft-failed'], reason: 'send_failed' }) - const db = new Database(journalDatabasePath(root)) - db.prepare("UPDATE queued_messages SET hold_reason = 'stopped' WHERE message_id = ?").run( - 'draft-legacy' - ) - db.close() - journal.queuedMessages.invalidate() - expect(await journal.queuedMessages.liftPause({ stop: null, adoptInto: 'proc-1' })).toBe(true) - expect( - journal.queuedMessages.list().map((row) => [row.messageId, row.hostInstance, row.holdReason]) - ).toEqual([ - ['draft-restart', 'proc-1', null], - ['draft-legacy', 'proc-1', null], - ['draft-failed', 'proc-1', 'send_failed'] - ]) - }) - - it('a lift with nothing to lift changes nothing and fires no commit notification', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - const revision = journal.queuedMessages.revision() - expect(await journal.queuedMessages.liftPause({ stop: null, adoptInto: 'proc-1' })).toBe(false) - expect(journal.queuedMessages.revision()).toBe(revision) - }) -}) - describe('the commit listener', () => { it('draft-table writes fire it exactly when rows changed, so no caller publishes by hand', async () => { const journal = await open() diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index ffc9b240987e..8b812e33481b 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -50,10 +50,13 @@ export type QueuedMessageRow = { * card, cleared when a withdrawal sends it back to waiting. Host-only; the published link is * the submission's `queuedMessageId`. */ consumedAs: string | null + /** The conversation /clear carried this card from; null for a card written here. What the + * replacement's 'cleared' pause is derived from. */ + carriedFrom: string | null } const COLUMNS = - 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as' + 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from' export function insertQueuedMessage( db: Database.Database, @@ -63,6 +66,7 @@ export function insertQueuedMessage( body: AgentJournalMessageItem fingerprint: string hostInstance: string + carriedFrom?: string now: number } ): QueuedMessageRow { @@ -73,7 +77,7 @@ export function insertQueuedMessage( const position = Number(highest?.p ?? 0) + 1 db.prepare( `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?)` ).run( input.sessionId, input.messageId, @@ -81,7 +85,8 @@ export function insertQueuedMessage( JSON.stringify(input.body), input.fingerprint, input.now, - input.hostInstance + input.hostInstance, + input.carriedFrom ?? null ) return { sessionId: input.sessionId, @@ -97,7 +102,8 @@ export function insertQueuedMessage( returnedRejection: null, settledAt: null, settledByOp: null, - consumedAs: null + consumedAs: null, + carriedFrom: input.carriedFrom ?? null } } @@ -276,6 +282,7 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { settled_at: number | null settled_by_op: string | null consumed_as: string | null + carried_from: string | null } let body: AgentJournalMessageItem try { @@ -309,7 +316,8 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { returnedRejection: storedRejection(record.returned_rejection), settledAt: record.settled_at, settledByOp: record.settled_by_op, - consumedAs: record.consumed_as + consumedAs: record.consumed_as, + carriedFrom: record.carried_from } } diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts index b70a18a83861..55782a3756f6 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -14,7 +14,10 @@ import type { } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournalBatch } from '../../../shared/agent-session-wire' import { findSequenceGap } from '../agent-session-journal/journal-cursor' -import type { JournalRow } from '../agent-session-journal/journal-row-schema' +import { + isJournalQueuePauseRow, + type JournalRow +} from '../agent-session-journal/journal-row-schema' export type JournalBatchProjection = | { ok: true; batch: AgentSessionJournalBatch } @@ -50,6 +53,10 @@ export function projectJournalBatch(input: { } continue } + if (isJournalQueuePauseRow(row)) { + // Host-only: the queue pause it feeds is published beside the list, not as an item. + continue + } if (row.kind === 'item' || row.kind === 'tombstone') { touchedItemIds.add( input.canonicalItemId?.(row.itemId) ?? aliases.get(row.itemId) ?? row.itemId diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index c645b6ea4e47..c267a0028800 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -53,47 +53,52 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } state: 'accepted' as const, providerIdentity: null })) + const cancelTurn: Mock = vi.fn(async () => ({ + cancelled: true + })) let events: StructuredAgentSessionEventSink | undefined const store = await openTestAgentSessionRecordStore(root) - const host = new StructuredAgentSessionHost({ - store, - adapter: { - acquire: async ({ identity, fence, spawnToken, events: sink }) => { - events = sink - const resumes = - options.restartable === true && - (store.getRecord(identity.sessionId)?.providerHandleChain.length ?? 0) > 0 - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${fence}`, - handle: { provider: 'codex' as const, threadId: THREAD }, - origin: resumes ? ('resumed' as const) : ('created' as const), - mintedAtFence: fence, - observedAt: NOW + const makeHost = () => + new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async ({ identity, fence, spawnToken, events: sink }) => { + events = sink + const resumes = + options.restartable === true && + (store.getRecord(identity.sessionId)?.providerHandleChain.length ?? 0) > 0 + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: resumes ? ('resumed' as const) : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + } } - } + }, + dispatch, + awaitStarted, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + compact, + cancelTurn, + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) }, - dispatch, - awaitStarted, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - compact, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - now: () => NOW - }) + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + let host = makeHost() expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) @@ -254,6 +259,12 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } rotateStructuredAgentSessionHostInstanceForTests() } + /** A host process that dies with no close: a new host opens the same state directory. */ + function crashRestartHostProcess(): void { + rotateStructuredAgentSessionHostInstanceForTests() + host = makeHost() + } + /** The queue's published pause: null when it sends on its own. */ async function queuePause(sessionId = SESSION): Promise { const page = await host.history({ sessionId, direction: 'tail' }) @@ -277,8 +288,11 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } return { root, store, - host, + get host() { + return host + }, dispatch, + cancelTurn, awaitStarted, compact, finishCompact, @@ -295,6 +309,7 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } settleAccepted, settleRejected, restartHostProcess, + crashRestartHostProcess, queuePause, resume, dispose diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index aa9d31cbf105..d3847af8b728 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -14,7 +14,10 @@ import { import { ConversationCommandParams } from '../../../shared/rpc-contract/structured-agent-session-params' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' -import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' +import { + rotateStructuredAgentSessionHostInstanceForTests, + structuredQueuePause +} from './structured-agent-session-queued-pause' import { createQueuedMessageTestRig, eventually, @@ -512,19 +515,23 @@ describe('Stop and Delete', () => { }) }) - it('a Stop whose pause record fails still interrupts; only the pause is lost, and it is reported', async () => { + it('a Stop whose row fails to write still interrupts; only the pause is lost, and it is reported', async () => { await workingSend() const queued = await send('kept by the stop', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { throw new Error('expected a queued receipt') } const record = vi - .spyOn(JournalQueuedMessages.prototype, 'recordPause') + .spyOn(AgentSessionJournal.prototype, 'appendQueuePauseMark') .mockRejectedValueOnce(new Error('disk full')) const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) try { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(warned).toHaveBeenCalledWith(expect.stringContaining('queue pause'), expect.anything()) + expect(rig.cancelTurn).toHaveBeenCalledTimes(1) + expect(warned).toHaveBeenCalledWith( + expect.stringContaining("Stop's queue row"), + expect.anything() + ) } finally { record.mockRestore() warned.mockRestore() @@ -674,7 +681,7 @@ describe('/clear', () => { } expect(await drafts(replacementId)).toHaveLength(0) const journal = host.collaboratorsForTests().sessions.get(replacementId)?.journal - expect(journal?.queuedMessages.pause()).toBeNull() + expect(journal && structuredQueuePause(journal)).toBeNull() }) it('a returned card carries over as a plain waiting draft on the paused replacement', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index b6d9d2a90beb..bd7b4b0bb03f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -30,6 +30,7 @@ import { structuredAgentSessionHostInstance, structuredQueuePause } from './structured-agent-session-queued-pause' +import { queuePauseHolds } from '../agent-session-journal/queued-message-pause' /** Budget at accept, in the send schema's own unit (`Buffer.byteLength` of the * serialized blocks); refused readably rather than trimmed. */ @@ -57,23 +58,28 @@ export function pendingPromptExists(journal: Pick + journal: Pick ): QueuedMessageRow | null { const rows = journal.queuedMessages.list() // Nothing waiting costs no pause derivation: this runs on every journal publish. - if (!rows.some((row) => row.state === 'waiting') || structuredQueuePause(journal) !== null) { + if (!rows.some((row) => row.state === 'waiting')) { return null } + const pause = structuredQueuePause(journal) for (const row of rows) { if (row.state === 'returned') { // A returned card blocks everything after it until the user acts. return null } - if (row.state === 'waiting' && row.holdReason === null) { + if ( + row.state === 'waiting' && + row.holdReason === null && + !(pause && queuePauseHolds(pause, row)) + ) { return row } } @@ -358,12 +364,13 @@ export class StructuredAgentSessionQueuedMessageDrain { messageId: next.messageId, expect: 'waiting', settledByOp: null, - hostInstance: structuredAgentSessionHostInstance() + hostInstance: structuredAgentSessionHostInstance(), + yieldsToPause: { hostInstance: structuredAgentSessionHostInstance() } } ) } catch (error) { if (error instanceof QueuedMessageNotConsumableError) { - // Lost a race with a Send-now or Delete; their transition stands. + // Lost a race with a Send-now, a Delete or a Stop; their transition stands. return } // Pre-consume failure: the draft stays waiting, held with the marker on diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 9da974a44a43..ee7fb2c76478 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -80,9 +80,9 @@ export async function withdrawQueuedMessagesForOperation( * cards stay visible where the user now is. The replacement's queue starts * paused ('cleared'), lifted exactly like a Stop's: the cards were written for the context /clear just * discarded, so they wait for the user's next turn there, or Resume, rather than - * sending into the fresh context unasked. Each card lands with the pause in one - * transaction, so the drain never sees a carried card unpaused and no pause is - * left over an empty queue if an insert fails. Runs after the + * sending into the fresh context unasked. Each card records the conversation it + * came from, which IS that pause, so the drain never sees a carried card unpaused + * and no pause outlives the cards. Runs after the * replacement's attach succeeded and before the clear commits. Each insert is * idempotent on (session, message), so a retried clear replays it safely; the source rows are then tombstoned. Bookkeeping around the clear: * a failure leaves the cards on the superseded source — whose supersession @@ -117,7 +117,7 @@ export async function carryQueuedMessagesToClearReplacement( body: row.body, fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), - pausedBy: 'cleared' + carriedFrom: ctx.sessionId }) } await withdrawQueuedMessagesForOperation(ctx.journal, { @@ -321,11 +321,11 @@ export function resumeStructuredAgentQueue( method: 'agentSession.queuedMessagesResume', fields: {}, conversationWrite: true, - // The lift notifies through the journal's commit listener, which publishes the - // cleared pause and wakes the drain. + // The Resume row notifies through the journal's commit listener, which publishes the + // lifted pause and wakes the drain. run: async (ctx) => ({ ok: true, - value: { resumed: await resumeStructuredQueue(ctx.journal) } + value: { resumed: await resumeStructuredQueue(ctx.journal, ctx.fence) } }), // Like Stop's replay: the Resume already ran, so this one lifts nothing. replay: () => ({ resumed: false }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts deleted file mode 100644 index 5e8c7dd027b4..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-handoff-end.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -// A Stop's queue pause kept alive only by a card's unanswered hand-off ends on every path that -// ends that hand-off, so no lost answer pauses a chat's queue for good: the provider answering -// it, the chat closing, a restart, the provider dying without answering, and a /clear. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' -import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' -import { - createQueuedMessageTestRig, - eventually, - QUEUED_RIG_CALLER, - type QueuedMessageTestRig -} from './structured-agent-session-queued-message-rig.test-fixture' - -let rig: QueuedMessageTestRig - -beforeEach(async () => { - rig = await createQueuedMessageTestRig({ restartable: true }) -}) - -afterEach(() => rig.dispose()) - -async function queuedDraft(text: string, sessionId = HOST_TEST_SESSION): Promise { - const queued = await rig.send(text, 'queue-if-active').result - if (!queued.ok || !('queued' in queued.value)) { - throw new Error('expected a queued receipt') - } - const { messageId } = queued.value.queued - expect((await rig.drafts(sessionId)).some((card) => card.messageId === messageId)).toBe(true) - return messageId -} - -/** The stored Stop fact, read from the open conversation (opened by a history read if closed). */ -async function stopFact(sessionId = HOST_TEST_SESSION) { - await rig.drafts(sessionId) - const journal = rig.host.collaboratorsForTests().sessions.get(sessionId)?.journal - if (!journal) { - throw new Error('expected the conversation open') - } - return journal.queuedMessages.pause() -} - -/** A card sent now into the running turn, then Stop, with the provider answering neither: the - * pause is recorded over that unanswered hand-off alone, and is not shown. */ -async function stopOverUnansweredHandOff(): Promise<{ working: string; sentId: string }> { - const working = await rig.workingSend() - const sentId = await queuedDraft('sent now into the turn') - await rig.sendNow(sentId) - await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) - expect(await rig.stop()).toMatchObject({ ok: true }) - expect(await stopFact()).toMatchObject({ reason: 'stopped' }) - expect(await rig.queuePause()).toBeNull() - return { working, sentId } -} - -/** The queue sends on its own again: a card typed during a later mail turn drains after it. */ -async function expectQueueDrains(): Promise { - expect(await stopFact()).toBeNull() - expect(await rig.queuePause()).toBeNull() - const mail = rig.send('coordinator mail', undefined, { internal: true }) - await mail.result - await eventually(async () => expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined()) - const later = await queuedDraft('typed during the mail turn') - await rig.settleAccepted(mail.id, 'mail') - await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) -} - -/** The provider's answer that the Stop took the card back before it reached the agent. */ -async function withdraw(draftId: string): Promise { - await rig.host.settleLateDispatch({ - sessionId: HOST_TEST_SESSION, - clientMessageId: await rig.handoffId(draftId), - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) - }) -} - -function clear() { - const fields = { command: 'clear' as const } - return rig.host.conversationCommand(QUEUED_RIG_CALLER, { - envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), - ...fields - }) -} - -function providerExited() { - return rig.host.handleAdapterEvent({ - type: 'ended', - sessionId: HOST_TEST_SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: 1, - acquisitionGeneration: 'generation-1' - }) -} - -describe('a pause held only by an unanswered hand-off', () => { - it('the provider withdrawing it leaves the card waiting under the pause; Resume sends it', async () => { - const { working, sentId } = await stopOverUnansweredHandOff() - await withdraw(sentId) - await rig.settleAccepted(working, 'stopped') - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - expect(await rig.drafts()).toEqual([{ messageId: sentId, state: 'waiting' }]) - expect(await rig.resume()).toMatchObject({ ok: true }) - await eventually(async () => expect((await rig.handoff(sentId))?.origin).toBe('host')) - }) - - it('ends when the provider dies without answering it', async () => { - const { sentId } = await stopOverUnansweredHandOff() - await providerExited() - // The dead generation's settlement: in doubt, never back to waiting. - await eventually(async () => expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown')) - await expectQueueDrains() - }) - - it('ends when the chat closes', async () => { - const { sentId } = await stopOverUnansweredHandOff() - await rig.host.close(HOST_TEST_SESSION) - expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown') - await expectQueueDrains() - }) - - it('ends when the host restarts under it', async () => { - const { sentId } = await stopOverUnansweredHandOff() - await rig.restartHostProcess() - expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown') - await expectQueueDrains() - }) - - it('a withdrawal the restart finds still owed is settled at open, under the pause; Resume sends it', async () => { - const { working, sentId } = await stopOverUnansweredHandOff() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const hook = vi - .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') - .mockImplementationOnce(() => { - throw new Error('bookkeeping failed') - }) - try { - await withdraw(sentId) - } finally { - hook.mockRestore() - warn.mockRestore() - } - await rig.settleAccepted(working, 'stopped') - await rig.restartHostProcess() - expect(await rig.drafts()).toEqual([{ messageId: sentId, state: 'waiting' }]) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - expect(await rig.resume()).toMatchObject({ ok: true }) - await eventually(async () => expect((await rig.handoff(sentId))?.origin).toBe('host')) - }) - - it("waits for it before a /clear, which then carries every waiting card paused 'cleared'", async () => { - const { working, sentId } = await stopOverUnansweredHandOff() - const carriedId = await queuedDraft('waiting behind the stop') - await rig.settleAccepted(working, 'stopped') - // An unanswered send blocks /clear: the hand-off ends first, by one of the paths above. - expect(await clear()).toMatchObject({ - ok: false, - refusal: { details: { reason: 'messagesUnsettled' } } - }) - await withdraw(sentId) - const cleared = await clear() - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined - if (!replacementId) { - throw new Error(`expected a replacement session: ${JSON.stringify(cleared)}`) - } - // The superseded chat keeps no pause over the cards it gave away. - expect(await stopFact()).toBeNull() - expect(await rig.drafts(replacementId)).toEqual([ - { messageId: sentId, state: 'waiting' }, - { messageId: carriedId, state: 'waiting' } - ]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) - const resumed = await rig.host.queuedMessagesResume(QUEUED_RIG_CALLER, { - envelope: rig.envelope( - {}, - 'agentSession.queuedMessagesResume', - hostTestOperationId(), - replacementId - ) - }) - expect(resumed).toMatchObject({ ok: true }) - await eventually(async () => - expect( - (await rig.host.journalSnapshot(replacementId)).submissions.some( - (entry) => entry.queuedMessageId === sentId - ) - ).toBe(true) - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 42230f826a30..65cb937eaf79 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -16,6 +16,7 @@ import { type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' import { sameQueuePause } from './structured-agent-session-queued-publication' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionHostInstance, structuredQueuePause @@ -188,61 +189,40 @@ describe('the pause read', () => { } const scan = vi.spyOn(journal, 'submissions') // Read on every publish, per subscriber: it must not walk the submissions. - expect(structuredQueuePause(journal)).toEqual({ reason: 'stopped' }) + expect(structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) expect(scan).not.toHaveBeenCalled() scan.mockRestore() - const before = journal.queuedMessages.latestPersonTurnSequence() const mail = rig.send('coordinator mail', undefined, { internal: true }) await mail.result await rig.settleAccepted(mail.id, 'mail') - // Orca's own turn moves nothing; a person's does, and lifts the pause. - expect(journal.queuedMessages.latestPersonTurnSequence()).toBe(before) + // Orca's own turn lifts nothing; a person's does. + expect(structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) const next = rig.send('user starts a new turn') await next.result await rig.settleAccepted(next.id, 'next') - expect(journal.queuedMessages.latestPersonTurnSequence()).toBe( - journal.submission(next.id)?.acceptedSequence - ) + expect(structuredQueuePause(journal)).toBeNull() await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) }) -describe('a pause is over the cards it paused', () => { - it('a Stop over an empty queue pauses nothing: a card typed during a later mail turn drains', async () => { - const working = await rig.workingSend() - await rig.stop() - await rig.settleAccepted(working, 'stopped') - const mail = rig.send('coordinator mail', undefined, { internal: true }) - await mail.result - await eventually(async () => - expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() - ) - const followUp = await queuedDraft('typed during the mail turn') - await rig.settleAccepted(mail.id, 'mail') - await eventually(async () => expect(await rig.handoff(followUp)).toBeDefined()) - expect(await rig.queuePause()).toBeNull() - }) - - it('a Stop over an empty queue pauses nothing: a correction typed before the turn ends drains', async () => { +describe('a Stop pauses the queue whatever it held', () => { + it('a Stop over an empty queue holds a correction typed before the turn ends', async () => { const working = await rig.workingSend() await rig.stop() const correction = await queuedDraft('typed right after the stop') await rig.settleAccepted(working, 'stopped') - await eventually(async () => expect(await rig.handoff(correction)).toBeDefined()) + await expectPaused(correction) }) - it('a Stop over a card sent now into the turn holds it until the agent takes it; then a card typed later drains', async () => { + it('a card sent now before the Stop and taken by the agent anyway lifts nothing: it was sent first', async () => { const working = await rig.workingSend() const sentId = await queuedDraft('sent now into the turn') await rig.sendNow(sentId) await handedOver(sentId) await rig.stop() - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - // Recorded over the unanswered hand-off, which the Stop may yet withdraw; nothing to publish. - expect(journal?.queuedMessages.pause()).toMatchObject({ reason: 'stopped' }) + // Nothing waits, so nothing is published; the pause is still derived. expect(await rig.queuePause()).toBeNull() await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') - expect(journal?.queuedMessages.pause()).toBeNull() await rig.settleAccepted(working, 'stopped') const mail = rig.send('coordinator mail', undefined, { internal: true }) await mail.result @@ -251,29 +231,27 @@ describe('a pause is over the cards it paused', () => { ) const later = await queuedDraft('typed during the mail turn') await rig.settleAccepted(mail.id, 'mail') - await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) + await expectPaused(later) }) - it('deleting the last paused card ends the pause, so a card typed later is not held by it', async () => { + it("deleting the last paused card hides the pause; a person's next turn is what ends it", async () => { const working = await rig.workingSend() const only = await queuedDraft('paused, then deleted') await rig.stop() await rig.settleAccepted(working, 'stopped') expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) expect(await rig.deleteQueued(only)).toMatchObject({ ok: true, value: { deleted: true } }) - const mail = rig.send('coordinator mail', undefined, { internal: true }) - await mail.result - await eventually(async () => - expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() - ) - const later = await queuedDraft('typed during the mail turn') - await rig.settleAccepted(mail.id, 'mail') + expect(await rig.queuePause()).toBeNull() + const next = await rig.workingSend() + const later = await queuedDraft('typed during the next turn') + // That send is a person's turn after the Stop: once it starts, the queue sends again. + await rig.settleAccepted(next, 'next') await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) }) }) describe('a pause only over cards Resume could send', () => { - it('a Stop that leaves only a returned card publishes no pause and keeps no fact', async () => { + it('a Stop that leaves only a returned card publishes no pause', async () => { const working = await rig.workingSend() const draftId = await queuedDraft('refused before the stop') await rig.settleAccepted(working, 'a') @@ -288,7 +266,7 @@ describe('a pause only over cards Resume could send', () => { await rig.settleAccepted(next, 'stopped') expect(await rig.queuePause()).toBeNull() const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - expect(journal?.queuedMessages.pause()).toBeNull() + expect(journal && structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) }) it('a returned card blocking the paused cards hides the pause but keeps it; deleting that card shows it again, and only Resume sends', async () => { @@ -313,7 +291,7 @@ describe('a pause only over cards Resume could send', () => { } // Resume would send nothing past the returned card, so no header offers it; the pause stays. expect(await rig.queuePause()).toBeNull() - expect(journal.queuedMessages.pause()).toMatchObject({ reason: 'stopped' }) + expect(structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) // Deleting the blocking card shows the pause again: the card behind it does not send unasked. expect(await rig.deleteQueued(refusedId)).toMatchObject({ ok: true, value: { deleted: true } }) await expectPaused(behindId) @@ -401,10 +379,10 @@ describe('a card handed off after a restart', () => { structuredAgentSessionHostInstance() ) // With the Stop's pause gone, nothing else holds it: no restart happened since it was sent. - await journal.queuedMessages.liftPause({ - stop: journal.queuedMessages.pause(), - adoptInto: null - }) + await journal.appendQueuePauseMark( + 'resumed', + structuredAgentSessionConversationFence(rig.store, HOST_TEST_SESSION) + ) expect(structuredQueuePause(journal)).toBeNull() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts index 1428de75fe63..4e7d3a606a82 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -1,20 +1,10 @@ -// Whether the queue is paused, and why — DERIVED, never stored as a flag. The -// queue is paused when: -// - 'stopped': the user's last Stop took effect (its recorded journal -// position) and no turn a person asked for has started since — or -// 'cleared', the same for a /clear's replacement, whose carried cards -// start paused; or -// - 'restarted': a waiting draft was written by another host process and no -// turn a person asked for has started since this conversation opened. -// A person's turn is a submission whose recorded origin is `client` (a send over -// the client send RPC, or a card they sent now) that the provider accepted. -// Orchestration mail, a restart continuation, a host-sent launch prompt and the -// queue's own drain are `host` and never lift it. An explicit Resume lifts any. +// Whether the queue is paused, and why — derived from the journal and the cards +// (`queued-message-pause.ts`), never stored. Stop and Resume are journal rows; an +// explicit Resume lifts any pause. import { randomUUID } from 'node:crypto' -import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { QueuePauseFact } from '../agent-session-journal/queued-message-pause-table' +import type { DerivedQueuePause } from '../agent-session-journal/queued-message-pause' /** A per-process id, minted once per host process like the runtime's own * `runtimeId` (`orca-runtime-runtime-id.ts`); a draft written by another @@ -31,78 +21,48 @@ export function rotateStructuredAgentSessionHostInstanceForTests(): string { return hostInstance } -type PauseJournal = Pick - -// Both read the reducer's latest accepted person turn (its submission row), so a -// derivation on every publish costs no scan of the submissions. - -function stopEnded(journal: PauseJournal, stop: QueuePauseFact): boolean { - const latest = journal.queuedMessages.latestPersonTurnSequence() - // Sent after the Stop: a send made before it no longer lifts it, even if its turn starts later. - return stop.epoch !== journal.cursor().epoch ? latest > 0 : latest > stop.sequence -} - -function restartPending(journal: PauseJournal): boolean { - return journal.queuedMessages - .list() - .some((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) -} - -function restartEnded(journal: PauseJournal): boolean { - const latest = journal.queuedMessages.latestPersonTurnSequence() - return latest > 0 && !journal.wroteBeforeOpen(latest) -} +type PauseJournal = Pick /** The queue's pause, derived; null when the queue sends on its own. */ -export function structuredQueuePause(journal: PauseJournal): AgentSessionQueuePause | null { - const stop = journal.queuedMessages.pause() - if (stop && !stopEnded(journal, stop)) { - return { reason: stop.reason } - } - if (restartPending(journal) && !restartEnded(journal)) { - return { reason: 'restarted' } - } - return null +export function structuredQueuePause(journal: PauseJournal): DerivedQueuePause | null { + return journal.queuedMessages.pause(hostInstance) } /** - * Every journal publish: retire what a person's started turn already ended — the - * Stop fact it superseded, and a restart's rows, adopted into this instance. The - * derivation already reads them as lifted; the write keeps that answer when the - * handle reopens (the restart's "since this conversation opened" moves) and spares - * later derivations the submission scan. Bookkeeping: a failure is reported. + * Every journal publish: a restart's rows are adopted into this instance once a person's turn + * started. The derivation already reads them as lifted; the write keeps that answer when the + * handle reopens (its "since this conversation opened" moves). Bookkeeping: a failure is reported. */ -export async function retireEndedQueuePause( +export async function adoptEndedRestartPause( sessionId: string, journal: PauseJournal ): Promise { try { - const stop = journal.queuedMessages.pause() - const retireStop = stop !== null && stopEnded(journal, stop) ? stop : null - const adopt = restartPending(journal) && restartEnded(journal) - if (retireStop === null && !adopt) { - return + const { queuedMessages } = journal + const restarted = queuedMessages + .list() + .some((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) + if (restarted && queuedMessages.restartEnded()) { + await queuedMessages.adopt(hostInstance) } - await journal.queuedMessages.liftPause({ - stop: retireStop, - adoptInto: adopt ? hostInstance : null - }) } catch (error) { - console.warn("[agent-session] a started turn's queue-pause retirement skipped:", { + console.warn("[agent-session] a started turn's restart-pause adoption skipped:", { sessionId, error: error instanceof Error ? error.message : String(error) }) } } -/** Resume: ends whichever pause holds the queue. Returns whether it was paused. */ -export async function resumeStructuredQueue(journal: PauseJournal): Promise { +/** Resume: a journal row that ends a Stop's or a /clear's pause, and adoption of a restart's + * rows. Returns whether the queue was paused. */ +export async function resumeStructuredQueue( + journal: Pick, + fence: number +): Promise { if (structuredQueuePause(journal) === null) { return false } - await journal.queuedMessages.liftPause({ - stop: journal.queuedMessages.pause(), - adoptInto: hostInstance - }) + await journal.appendQueuePauseMark('resumed', fence) + await journal.queuedMessages.adopt(hostInstance) return true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index 10b1f75fc3e3..17bd71a57e0e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -9,7 +9,7 @@ import { type AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { hasResumableQueuedMessage } from '../agent-session-journal/queued-message-pause-table' +import { queuePauseHoldsResumableCard } from '../agent-session-journal/queued-message-pause' import { structuredQueuePause } from './structured-agent-session-queued-pause' export type QueuePublication = { @@ -81,11 +81,14 @@ export function sameQueuePause( export function readQueuePublication(journal: AgentSessionJournal): QueuePublication { const queuedMessages = readPublishedQueuedMessages(journal) - // Read per emit: the pause also turns on submissions (a person's turn starting). - // Kept over any card it holds back, but shown only over one Resume would send, so its - // header never offers to send nothing; deleting a blocking returned card shows it again. - const pausable = hasResumableQueuedMessage(journal.queuedMessages.list()) - const queuePause = pausable ? structuredQueuePause(journal) : null + // Read per emit: the pause also turns on submissions (a person's turn starting). Shown only + // over a card Resume would send, so its header never offers to send nothing; deleting a + // blocking returned card shows it again. + const pause = structuredQueuePause(journal) + const queuePause = + pause && queuePauseHoldsResumableCard(pause, journal.queuedMessages.list()) + ? { reason: pause.reason } + : null const previous = publications.get(journal) if ( previous && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts new file mode 100644 index 000000000000..4471c206d0f5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -0,0 +1,240 @@ +// Stop writes one journal row before it interrupts, and the queue's pause is derived from it: +// through the real host, a card queued after a Stop is held (the default of an open decision), a +// withdrawn card comes back under it, a crash keeps it, and no stored pause is ever written. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import Database from '../../sqlite/sync-database' +import { journalDatabasePath } from '../agent-session-journal/journal-host-database' +import { + HOST_TEST_SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { structuredQueuePause } from './structured-agent-session-queued-pause' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(() => rig.dispose()) + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +function journal(sessionId = HOST_TEST_SESSION) { + const open = rig.host.collaboratorsForTests().sessions.get(sessionId)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +/** No drain step converts the cards, and the published pause names `reason`. */ +async function expectHeld(reason: string, ...draftIds: string[]): Promise { + await new Promise((resolve) => setTimeout(resolve, 250)) + for (const draftId of draftIds) { + expect(await rig.handoff(draftId)).toBeUndefined() + } + expect(await rig.queuePause()).toEqual({ reason }) +} + +async function mailTurn(): Promise { + const mail = rig.send('coordinator mail', undefined, { internal: true }) + await mail.result + await eventually(async () => expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined()) + return mail.id +} + +function withdraw(clientMessageId: string) { + return rig.host.settleLateDispatch({ + sessionId: HOST_TEST_SESSION, + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) +} + +function storedPauses(): number { + const db = new Database(journalDatabasePath(rig.root), { readonly: true }) + try { + const row: unknown = db.prepare('SELECT COUNT(*) AS n FROM queued_message_pauses').get() + return typeof row === 'object' && row !== null && 'n' in row ? Number(row.n) : -1 + } finally { + db.close() + } +} + +describe("Stop's row", () => { + it('is written before the interrupt reaches the agent', async () => { + await rig.workingSend() + let pausedAtInterrupt: unknown = 'not interrupted' + rig.cancelTurn.mockImplementationOnce(async () => { + pausedAtInterrupt = structuredQueuePause(journal())?.reason ?? null + return { cancelled: true } + }) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(pausedAtInterrupt).toBe('stopped') + }) + + it('over an EMPTY queue still pauses: a card typed during a later mail turn waits for the person', async () => { + const working = await rig.workingSend() + await rig.stop() + await rig.settleAccepted(working, 'stopped') + const mail = await mailTurn() + const typed = await queuedDraft('typed during the mail turn') + await rig.settleAccepted(mail, 'mail') + await expectHeld('stopped', typed) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) + }) + + it("a person's accepted turn lifts it; a host turn and a later Stop do not", async () => { + const working = await rig.workingSend() + const first = await queuedDraft('first') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + await rig.settleAccepted(await mailTurn(), 'mail') + await expectHeld('stopped', first) + const person = rig.send('the person asks for a turn') + await person.result + // A later Stop supersedes: the send made before it no longer lifts anything. + await rig.stop() + await rig.settleAccepted(person.id, 'person') + await expectHeld('stopped', first) + const after = rig.send('asked after the second Stop') + await after.result + await rig.settleAccepted(after.id, 'after') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + }) + + it('a card the Stop withdrew comes back once, under the pause, and is never sent again on its own', async () => { + const working = await rig.workingSend() + const sentId = await queuedDraft('sent now into the turn') + await rig.sendNow(sentId) + await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) + const handoffId = await rig.handoffId(sentId) + await rig.stop() + await withdraw(handoffId) + await withdraw(handoffId) + await rig.settleAccepted(working, 'stopped') + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.drafts()).toEqual([{ messageId: sentId, state: 'waiting' }]) + const sends = (await rig.host.journalSnapshot(HOST_TEST_SESSION)).submissions.filter( + (entry) => entry.queuedMessageId === sentId + ) + expect(sends.map((entry) => [entry.origin, entry.dispatchState])).toEqual([ + ['client', 'rejected'] + ]) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect((await rig.handoff(sentId))?.origin).toBe('host')) + }) + + it('survives a host crash: the next open marks the hand-off unknown, the pause stays, Resume sends', async () => { + await rig.workingSend() + const sentId = await queuedDraft('sent now into the turn') + const waiting = await queuedDraft('waiting behind it') + await rig.sendNow(sentId) + await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) + await rig.stop() + // The process dies with no close; a new host opens the same state directory. + rig.crashRestartHostProcess() + expect((await rig.handoff(sentId))?.dispatchState).toBe('unknown') + await expectHeld('stopped', waiting) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(waiting)).toBeDefined()) + }) +}) + +describe("a /clear's carried cards", () => { + function clear() { + const fields = { command: 'clear' as const } + return rig.host.conversationCommand(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + } + + it("wait 'cleared' on the replacement until a person's turn there", async () => { + const working = await rig.workingSend() + const carried = await queuedDraft('written for the old context') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + const cleared = await clear() + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error(`expected a replacement session: ${JSON.stringify(cleared)}`) + } + expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + // Idle there, so the person's send goes straight out rather than queueing. + const text = hostTestMessage('hi') + const person = rig.host.send(QUEUED_RIG_CALLER, { + envelope: rig.envelope( + { body: text }, + 'agentSession.send', + hostTestOperationId(), + replacementId + ), + body: text, + userSend: true + }) + const sent = await person + if (!sent.ok || !('submission' in sent.value)) { + throw new Error(`expected an immediate send: ${JSON.stringify(sent)}`) + } + await eventually(async () => + expect( + (await rig.host.journalSnapshot(replacementId)).submissions.find( + (entry) => entry.clientMessageId === sent.value.clientMessageId + )?.handedOverAt + ).toBeDefined() + ) + await rig.host.settleLateDispatch({ + sessionId: replacementId, + clientMessageId: sent.value.clientMessageId, + providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-hi', ordinal: 0 } + }) + expect(journal(replacementId).queuedMessages.list()[0]?.messageId).toBe(carried) + expect(structuredQueuePause(journal(replacementId))).toBeNull() + }) +}) + +describe('no stored pause', () => { + it('is read or written across Stop, Resume and /clear', async () => { + const working = await rig.workingSend() + const draftId = await queuedDraft('paused') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + await expectHeld('stopped', draftId) + expect(storedPauses()).toBe(0) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + expect(storedPauses()).toBe(0) + await eventually(async () => expect((await rig.handoff(draftId))?.handedOverAt).toBeDefined()) + await rig.stop() + await queuedDraft('carried') + await rig.settleAccepted(await rig.handoffId(draftId), 'drained') + const fields = { command: 'clear' as const } + const cleared = await rig.host.conversationCommand(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + expect(replacementId && (await rig.queuePause(replacementId))).toEqual({ reason: 'cleared' }) + expect(storedPauses()).toBe(0) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index 4d5369364b24..c58c11041802 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -1,10 +1,10 @@ // Stop's pause on the queue. A Stop never withdraws a draft and no text ever -// travels back over the wire: it records WHERE in the journal it took effect, -// and the queue is paused from there (`structured-agent-session-queued-pause.ts` -// derives it) until a turn a person asked for starts, or they Resume. The cards -// stay published, and Send-now sends one card without lifting the pause for the -// rest until that card's turn starts. The record is bookkeeping: a failure is -// reported and never gates the interrupt. +// travels back over the wire: it appends ONE journal row where it took effect, and +// the queue's pause is derived from that row (`queued-message-pause.ts`) until a +// turn a person asked for is sent after it, or they Resume. The cards stay +// published, and Send-now sends one card without lifting the pause for the rest +// until that card's turn starts. The row is bookkeeping: a failure is reported +// and never gates the interrupt. import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -20,48 +20,27 @@ export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMes } /** - * Runs a Stop and records its queue pause at the point it takes effect — after - * it withdrew the queued sends, as it reaches the agent, or, reaching no agent, - * once it withdrew something — and only over cards it then holds back. The Stop - * calls `tookEffect` there. A Stop that throws before then changed nothing and - * recorded nothing, so there is nothing to undo; one that fails after it keeps - * the pause, since the interrupt may have landed. A hand-off still unanswered - * counts as held, so a draft the interrupt withdraws later — at the provider's turn - * end — is back to waiting in its own place, under this same pause; accepted - * instead, the pause retires in that write. The drain cannot slip a draft in - * between: it runs on the same serialized lane as the Stop. + * Runs a Stop, which calls `tookEffect` where it takes effect — after it withdrew the + * queued sends, before it reaches the agent, or, reaching no agent, once it withdrew + * something. That writes the Stop's row, whatever the queue holds, so a card its + * interrupt later withdraws comes back to waiting under the pause. A Stop that throws + * before then changed nothing and wrote nothing. The drain cannot slip a card in + * between: the Stop runs on the drain's serialized lane, and the consume re-judges the + * pause in its own transaction. */ export async function runStopWithQueuePause( ctx: AgentSessionTurnContext, stop: (tookEffect: () => Promise) => Promise> ): Promise> { - let attempted = false - return stop(async () => { - if (attempted) { - return - } - attempted = true - const { queuedMessages } = ctx.journal - // A hand-off this Stop's withdrawal sent back may not have caught up yet (its hook - // was skipped): heal it first, as the drain would, so the pause sees it waiting. - try { - if (queuedMessages.settlementOwed()) { - await queuedMessages.settleOwed() + return stop(() => + ctx.journal.appendQueuePauseMark('stopped', ctx.fence).then( + () => undefined, + (error: unknown) => { + console.warn("[agent-session] Stop's queue row skipped:", { + sessionId: ctx.sessionId, + error: error instanceof Error ? error.message : String(error) + }) } - } catch (error) { - report(ctx, 'owed settlement', error) - } - // Recorded only over a card it holds back — judged in its own transaction, which - // still counts an owed return to waiting if that heal failed. - await queuedMessages - .recordPause('stopped') - .catch((error: unknown) => report(ctx, 'queue pause', error)) - }) -} - -function report(ctx: AgentSessionTurnContext, step: string, error: unknown): void { - console.warn(`[agent-session] Stop's ${step} skipped:`, { - sessionId: ctx.sessionId, - error: error instanceof Error ? error.message : String(error) - }) + ) + ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts index 2b193d5cb82f..17956e2df9bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -9,7 +9,7 @@ import type { } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { StructuredAgentSessionQueuedMessageDrain } from './structured-agent-session-queued-messages' -import { retireEndedQueuePause } from './structured-agent-session-queued-pause' +import { adoptEndedRestartPause } from './structured-agent-session-queued-pause' import { deleteQueuedStructuredAgentMessage, resumeStructuredAgentQueue, @@ -39,12 +39,12 @@ export function wireStructuredAgentSessionQueuedMessages( drain, /** Every journal publish: turn, submission, prompt, command and Stop * settlements are all commits, and each re-derives the drain's gates — - * and retires a queue pause a person's started turn already ended. */ + * and adopts a restart's cards once a person's turn started. */ onJournalActivity: (sessionId: string) => { sessions.touch(sessionId) const journal = sessions.get(sessionId)?.journal if (journal && !journal.isReadOnly) { - void retireEndedQueuePause(sessionId, journal) + void adoptEndedRestartPause(sessionId, journal) } drain.schedule(sessionId) }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts index 403cb08a4053..7ae2baada74c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts @@ -170,7 +170,8 @@ async function stopWithSkippedSettlement(): Promise<{ a: string; working: string it("a Stop whose withdrawal's settlement was skipped still pauses the card it sent back", async () => { const { a } = await stopWithSkippedSettlement() - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // The drain heals the skipped return; the Stop's row already pauses whatever comes back. + await eventually(async () => expect(await rig.queuePause()).toEqual({ reason: 'stopped' })) await new Promise((resolve) => setTimeout(resolve, 250)) // Healed back to waiting, but the Stop's pause holds it: it does not send. expect(await rig.drafts()).toEqual([{ messageId: a, state: 'waiting' }]) @@ -183,27 +184,3 @@ it("a Stop whose withdrawal's settlement was skipped still pauses the card it se ).toHaveLength(2) ) }) - -it('the pause is recorded even when healing the skipped settlement fails, since the card is still owed', async () => { - const heal = vi - .spyOn(JournalQueuedMessages.prototype, 'settleOwed') - .mockRejectedValueOnce(new Error('disk full')) - try { - const { a } = await stopWithSkippedSettlement() - const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal - expect(journal?.queuedMessages.pause()).toMatchObject({ reason: 'stopped' }) - // The drain heals it later; the pause recorded over the owed card holds it then. - await eventually(async () => - expect(await rig.drafts()).toEqual([{ messageId: a, state: 'waiting' }]) - ) - await new Promise((resolve) => setTimeout(resolve, 250)) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - expect( - (await rig.host.journalSnapshot(SESSION)).submissions.filter( - (entry) => entry.queuedMessageId === a - ) - ).toHaveLength(1) - } finally { - heal.mockRestore() - } -}) From 438240a4023b073cc61dd32809f963c695fbdfc2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:58:12 -0700 Subject: [PATCH 007/182] fix(native-chat): a card queued after a Stop sends normally, never ahead of held ones A Stop's pause now holds only the cards queued before its row, plus a steer it withdrew, which returns to its own place. Each card records the journal position it was queued at, and the one hold rule compares that with the Stop row. A card queued after the Stop is a new instruction: it sends as usual, but the drain still stops at the first held card, so it never overtakes them. /clear's pause holds the cards it carried. Holding every card again is a one-line switch in that rule. --- .../journal-queued-messages.ts | 9 ++- .../queued-message-pause.test.ts | 71 +++++++++++++++++++ .../queued-message-pause.ts | 49 ++++++++----- .../queued-message-schema.ts | 6 +- .../queued-message-table.ts | 28 ++++++-- ...tructured-agent-session-queued-messages.ts | 16 ++--- ...ed-agent-session-queued-pause-lift.test.ts | 12 ++-- ...ured-agent-session-queued-stop-row.test.ts | 34 +++++++-- 8 files changed, 180 insertions(+), 45 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 34281b1b849e..77d8c3ca4e45 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -113,7 +113,13 @@ export class JournalQueuedMessages { return existing } inserted = true - return insertQueuedMessage(db, { ...input, sessionId, now: this.deps.now() }) + const { epoch, lastSequence } = this.deps.state() + return insertQueuedMessage(db, { + ...input, + sessionId, + queuedAt: { epoch, sequence: lastSequence }, + now: this.deps.now() + }) }, () => inserted ) @@ -140,6 +146,7 @@ export class JournalQueuedMessages { ): DerivedQueuePause | null { const state = this.deps.state() return deriveQueuePause({ + epoch: state.epoch, marks: state.queuePauseMarks, latestPersonTurnSequence: state.latestPersonTurnSequence, cards, diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index e0b5de53eb62..2269786a4ccd 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -20,6 +20,9 @@ import { QueuedMessageNotConsumableError } from './journal-queued-messages' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { parseJournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' +import { queuePauseHolds } from './queued-message-pause' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-p', @@ -90,6 +93,15 @@ function reason(journal: AgentSessionJournal): string | null { return journal.queuedMessages.pause(HOST)?.reason ?? null } +/** Each card, and whether the queue's pause holds it. */ +function held(journal: AgentSessionJournal): [string, boolean][] { + const pause = journal.queuedMessages.pause(HOST) + return journal.queuedMessages + .list() + .filter((card) => card.state === 'waiting') + .map((card) => [card.messageId, pause !== null && queuePauseHolds(pause, card)]) +} + function storedPauses(): number { const db = new Database(journalDatabasePath(root), { readonly: true }) try { @@ -239,6 +251,65 @@ describe("the queue's pause, derived from the journal", () => { }) }) +describe('which cards a pause holds', () => { + it('only cards queued before the Stop row: one queued after it is a new instruction', async () => { + let journal = await open() + await queueDraft(journal, 'before') + await journal.appendQueuePauseMark('stopped', 0) + await queueDraft(journal, 'after') + expect(held(journal)).toEqual([ + ['before', true], + ['after', false] + ]) + await journal.close() + journal = await open() + expect(held(journal)).toEqual([ + ['before', true], + ['after', false] + ]) + }) + + it('a steer the Stop withdrew comes back in its own place, and is held', async () => { + const journal = await open() + await queueDraft(journal, 'steered') + await journal.appendSubmission( + { + clientMessageId: 'send-now-1', + origin: 'client', + payloadFingerprint: 'fp-steered', + body: message('steered'), + fence: 0, + handoverRecorded: true + }, + { messageId: 'steered', expect: 'waiting', settledByOp: null, hostInstance: HOST } + ) + await journal.appendQueuePauseMark('stopped', 0) + await queueDraft(journal, 'after') + await journal.resolveDispatch({ + clientMessageId: 'send-now-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), + fence: 0 + }) + expect(held(journal)).toEqual([ + ['steered', true], + ['after', false] + ]) + }) + + it("'cleared' holds the carried cards, not one typed after them", async () => { + const journal = await open() + await queueDraft(journal, 'carried-1', 'source-session') + await queueDraft(journal, 'carried-2', 'source-session') + await queueDraft(journal, 'typed-here') + expect(held(journal)).toEqual([ + ['carried-1', true], + ['carried-2', true], + ['typed-here', false] + ]) + }) +}) + describe("a restart's pause", () => { it("adopting a restart's rows moves them into this instance and clears an older build's stored 'stopped' hold; send_failed stays", async () => { const journal = await open() diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index ef0cd9950a9e..dcb38d8e46a4 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -1,5 +1,5 @@ // The queue's pause: a pure function of the journal fold and the cards, never a stored flag, so -// nothing has to retire it. Paused when: +// nothing has to retire it. Which cards it holds is `queuePauseHolds`. Paused when: // - 'stopped': the latest Stop row has no later Resume row, and no turn a person asked for was // sent after it and accepted. A later Stop is simply the latest. // - 'cleared': a card /clear carried into this conversation waits, and no person's turn or @@ -9,6 +9,7 @@ // A person's turn is an accepted submission of origin `client`. Orchestration mail, a restart // continuation, a launch prompt and the queue's own drain are `host` and never lift it. +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalQueuePauseMark, JournalTombstoneRow } from './journal-row-schema' export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' @@ -16,26 +17,25 @@ export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' /** The latest Stop and Resume rows, folded by the reducer; 0 when none. */ export type JournalQueuePauseMarks = { stoppedSequence: number - stoppedAt: number resumedSequence: number } export type DerivedQueuePause = { reason: QueuePauseReason - /** Host clock the pause began at; a card created later is newer than it. */ - since: number + /** Where a Stop's pause began: a card queued at or after it is newer. Null for the others. */ + since: AgentJournalCursor | null } type QueueCard = { state: string holdReason: string | null - createdAt: number hostInstance: string carriedFrom: string | null + queuedAt: AgentJournalCursor | null } export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { - return { stoppedSequence: 0, stoppedAt: 0, resumedSequence: 0 } + return { stoppedSequence: 0, resumedSequence: 0 } } export function foldJournalQueuePauseMark( @@ -44,7 +44,6 @@ export function foldJournalQueuePauseMark( ): void { if (row.queuePause === 'stopped') { marks.stoppedSequence = row.seq - marks.stoppedAt = row.ts } else if (row.queuePause === 'resumed') { marks.resumedSequence = row.seq } @@ -59,6 +58,8 @@ export function journalQueueStopHolds( } export function deriveQueuePause(input: { + /** The journal's epoch: sequences compare only within one. */ + epoch: string marks: JournalQueuePauseMarks latestPersonTurnSequence: number cards: readonly QueueCard[] @@ -66,33 +67,49 @@ export function deriveQueuePause(input: { /** A person's turn started since this conversation opened. */ restartEnded: boolean }): DerivedQueuePause | null { - const { marks, latestPersonTurnSequence } = input + const { epoch, marks, latestPersonTurnSequence } = input if (journalQueueStopHolds(marks, latestPersonTurnSequence)) { - return { reason: 'stopped', since: marks.stoppedAt } + return { reason: 'stopped', since: { epoch, sequence: marks.stoppedSequence } } } const waiting = input.cards.filter((card) => card.state === 'waiting') const carried = waiting.filter((card) => card.carriedFrom !== null) if (carried.length > 0 && latestPersonTurnSequence === 0 && marks.resumedSequence === 0) { - return { reason: 'cleared', since: Math.max(...carried.map((card) => card.createdAt)) } + return { reason: 'cleared', since: null } } if (!input.restartEnded && waiting.some((card) => card.hostInstance !== input.hostInstance)) { // The process that wrote a card is gone: every card waits, whenever it was written. - return { reason: 'restarted', since: Number.POSITIVE_INFINITY } + return { reason: 'restarted', since: null } } return null } -// Open product decision: whether a pause also holds a card queued AFTER it began (for example -// typed while orchestration mail runs after a Stop). true: every waiting card waits for the person. -// false: only cards that existed when it began, or came back from it, wait; newer ones drain. -const PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT = true +/** Queued before the pause began: for /clear, a card it carried; for a restart, every card. For a + * Stop, a card queued before its row; one from another epoch (before a rewind) or from a build + * that recorded no position counts as before. A withdrawn steer keeps its position, so is held. */ +function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { + if (pause.reason === 'cleared') { + return card.carriedFrom !== null + } + const { since } = pause + return ( + since === null || + card.queuedAt === null || + card.queuedAt.epoch !== since.epoch || + card.queuedAt.sequence < since.sequence + ) +} + +// Product decision: a card queued AFTER a Stop is a new instruction and is not held; only cards +// queued before it, and a steer it withdrew, wait. It still never jumps ahead of a held card: the +// drain stops at the first one. true instead holds every waiting card, whenever it was queued. +const PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT = false /** THE rule for which cards a pause holds: the drain, its consume, and publication all read it. */ export function queuePauseHolds(pause: DerivedQueuePause, card: QueueCard): boolean { return ( card.state === 'waiting' && card.holdReason === null && - (PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || card.createdAt <= pause.since) + (PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || queuedBeforePause(pause, card)) ) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts index 34230fe51cf0..690db5eed7f0 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-schema.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -10,7 +10,9 @@ const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ ['settled_at', 'INTEGER'], ['settled_by_op', 'TEXT'], ['consumed_as', 'TEXT'], - ['carried_from', 'TEXT'] + ['carried_from', 'TEXT'], + ['queued_epoch', 'TEXT'], + ['queued_sequence', 'INTEGER'] ] /** @@ -39,6 +41,8 @@ CREATE TABLE IF NOT EXISTS queued_messages ( settled_by_op TEXT, consumed_as TEXT, carried_from TEXT, + queued_epoch TEXT, + queued_sequence INTEGER, PRIMARY KEY (session_id, message_id) ); `) diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index 8b812e33481b..a2bd9c3e05ab 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -9,7 +9,10 @@ import type Database from '../../sqlite/sync-database' import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalCursor, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import { rejectedDraftSettlement } from './journal-dispatch-settlement' import { readStoredRejectionFact } from './journal-dispatch-reducer' @@ -53,10 +56,13 @@ export type QueuedMessageRow = { /** The conversation /clear carried this card from; null for a card written here. What the * replacement's 'cleared' pause is derived from. */ carriedFrom: string | null + /** Where the journal stood when it was queued: a Stop's pause holds only cards queued before + * it. Null on rows from builds before it was recorded, which read as queued before any Stop. */ + queuedAt: AgentJournalCursor | null } const COLUMNS = - 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from' + 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence' export function insertQueuedMessage( db: Database.Database, @@ -67,6 +73,7 @@ export function insertQueuedMessage( fingerprint: string hostInstance: string carriedFrom?: string + queuedAt: AgentJournalCursor now: number } ): QueuedMessageRow { @@ -77,7 +84,7 @@ export function insertQueuedMessage( const position = Number(highest?.p ?? 0) + 1 db.prepare( `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)` ).run( input.sessionId, input.messageId, @@ -86,7 +93,9 @@ export function insertQueuedMessage( input.fingerprint, input.now, input.hostInstance, - input.carriedFrom ?? null + input.carriedFrom ?? null, + input.queuedAt.epoch, + input.queuedAt.sequence ) return { sessionId: input.sessionId, @@ -103,7 +112,8 @@ export function insertQueuedMessage( settledAt: null, settledByOp: null, consumedAs: null, - carriedFrom: input.carriedFrom ?? null + carriedFrom: input.carriedFrom ?? null, + queuedAt: input.queuedAt } } @@ -283,6 +293,8 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { settled_by_op: string | null consumed_as: string | null carried_from: string | null + queued_epoch: string | null + queued_sequence: number | null } let body: AgentJournalMessageItem try { @@ -317,7 +329,11 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { settledAt: record.settled_at, settledByOp: record.settled_by_op, consumedAs: record.consumed_as, - carriedFrom: record.carried_from + carriedFrom: record.carried_from, + queuedAt: + record.queued_epoch !== null && typeof record.queued_sequence === 'number' + ? { epoch: record.queued_epoch, sequence: record.queued_sequence } + : null } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index bd7b4b0bb03f..c6e7853bd0a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -58,9 +58,9 @@ export function pendingPromptExists(journal: Pick ): QueuedMessageRow | null { @@ -71,15 +71,11 @@ function oldestActionableQueuedMessage( } const pause = structuredQueuePause(journal) for (const row of rows) { - if (row.state === 'returned') { - // A returned card blocks everything after it until the user acts. + if (row.state === 'returned' || (pause && queuePauseHolds(pause, row))) { + // A returned or paused card blocks everything after it until the user acts. return null } - if ( - row.state === 'waiting' && - row.holdReason === null && - !(pause && queuePauseHolds(pause, row)) - ) { + if (row.state === 'waiting' && row.holdReason === null) { return row } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 65cb937eaf79..09738da3d1d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -205,16 +205,16 @@ describe('the pause read', () => { }) }) -describe('a Stop pauses the queue whatever it held', () => { - it('a Stop over an empty queue holds a correction typed before the turn ends', async () => { +describe('a card queued after a Stop is a new instruction', () => { + it('a correction typed after a Stop over an empty queue sends when the stopped turn ends', async () => { const working = await rig.workingSend() await rig.stop() const correction = await queuedDraft('typed right after the stop') await rig.settleAccepted(working, 'stopped') - await expectPaused(correction) + await eventually(async () => expect(await rig.handoff(correction)).toBeDefined()) }) - it('a card sent now before the Stop and taken by the agent anyway lifts nothing: it was sent first', async () => { + it('a card sent now before the Stop and taken anyway lifts nothing, and holds nothing typed later', async () => { const working = await rig.workingSend() const sentId = await queuedDraft('sent now into the turn') await rig.sendNow(sentId) @@ -231,7 +231,9 @@ describe('a Stop pauses the queue whatever it held', () => { ) const later = await queuedDraft('typed during the mail turn') await rig.settleAccepted(mail.id, 'mail') - await expectPaused(later) + await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + expect(journal && structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) }) it("deleting the last paused card hides the pause; a person's next turn is what ends it", async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index 4471c206d0f5..c171f873fce0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -1,6 +1,7 @@ // Stop writes one journal row before it interrupts, and the queue's pause is derived from it: -// through the real host, a card queued after a Stop is held (the default of an open decision), a -// withdrawn card comes back under it, a crash keeps it, and no stored pause is ever written. +// through the real host, the cards queued before a Stop wait, a card queued after it sends +// normally but never ahead of them, a withdrawn card comes back under it, a crash keeps it, and +// no stored pause is ever written. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -91,18 +92,39 @@ describe("Stop's row", () => { expect(pausedAtInterrupt).toBe('stopped') }) - it('over an EMPTY queue still pauses: a card typed during a later mail turn waits for the person', async () => { + it('over an EMPTY queue holds nothing: a card queued during a later mail turn sends normally', async () => { const working = await rig.workingSend() await rig.stop() await rig.settleAccepted(working, 'stopped') const mail = await mailTurn() const typed = await queuedDraft('typed during the mail turn') + expect(await rig.queuePause()).toBeNull() await rig.settleAccepted(mail, 'mail') - await expectHeld('stopped', typed) - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) }) + it('a card queued after the Stop waits behind the cards it holds, then all send in order', async () => { + const working = await rig.workingSend() + const held = await queuedDraft('queued before the stop') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + const mail = await mailTurn() + const later = await queuedDraft('queued during the mail turn') + await rig.settleAccepted(mail, 'mail') + // The queue never reorders: the newer card waits behind the held one, with no caption of its own. + await expectHeld('stopped', held, later) + expect(await rig.drafts()).toEqual([ + { messageId: held, state: 'waiting' }, + { messageId: later, state: 'waiting' } + ]) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) + expect(await rig.handoff(later)).toBeUndefined() + await eventually(async () => expect((await rig.handoff(held))?.handedOverAt).toBeDefined()) + await rig.settleAccepted(await rig.handoffId(held), 'held') + await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) + }) + it("a person's accepted turn lifts it; a host turn and a later Stop do not", async () => { const working = await rig.workingSend() const first = await queuedDraft('first') @@ -225,8 +247,8 @@ describe('no stored pause', () => { expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) expect(storedPauses()).toBe(0) await eventually(async () => expect((await rig.handoff(draftId))?.handedOverAt).toBeDefined()) - await rig.stop() await queuedDraft('carried') + await rig.stop() await rig.settleAccepted(await rig.handoffId(draftId), 'drained') const fields = { command: 'clear' as const } const cleared = await rig.host.conversationCommand(QUEUED_RIG_CALLER, { From 6de6b3238e1c6162642dca542eaf71d296ff5f95 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:03:49 -0700 Subject: [PATCH 008/182] fix(native-chat): the queue's own send re-checks the no-overtake rule in its transaction The drain's pick and its consume now read one function, nextSendableQueuedCard, so a Stop row that lands between them holds a newer card behind an older held one exactly as the pick would. Notes why Stop and Resume ride a tombstone row. --- .../journal-queued-messages.ts | 13 +++++---- .../agent-session-journal/journal-store.ts | 4 ++- .../queued-message-pause.test.ts | 27 +++++++++++++++++++ .../queued-message-pause.ts | 18 +++++++++++++ ...tructured-agent-session-queued-messages.ts | 14 ++-------- 5 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 77d8c3ca4e45..48a88360be21 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -16,7 +16,11 @@ import type { JournalRow } from './journal-row-schema' import type { JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' -import { deriveQueuePause, queuePauseHolds, type DerivedQueuePause } from './queued-message-pause' +import { + deriveQueuePause, + nextSendableQueuedCard, + type DerivedQueuePause +} from './queued-message-pause' import { consumeQueuedMessageInTransaction, getQueuedMessage, @@ -233,12 +237,11 @@ export class JournalQueuedMessages { throw new AgentSessionJournalError('journal_closed', 'consume crossed database handles') } if (input.yieldsToPause) { - // Judged here, on the same queue as every journal append, so a Stop row that landed - // after the drain chose this card still holds it. + // Judged here, on the same queue as every journal append, by the drain's own rule, so a + // Stop row that landed after the drain chose this card still holds it. const cards = listQueuedMessages(db, this.deps.sessionId) const pause = this.derivePause(cards, input.yieldsToPause.hostInstance) - const card = cards.find((entry) => entry.messageId === input.messageId) - if (pause && card && queuePauseHolds(pause, card)) { + if (nextSendableQueuedCard(pause, cards)?.messageId !== input.messageId) { throw new QueuedMessageNotConsumableError(input.messageId, input.expect) } } 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 1c26eb031f55..90fa5366f3b7 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -308,7 +308,9 @@ export class AgentSessionJournal { ) } - /** A person's Stop or Resume: the rows the queue's pause is derived from. */ + /** A person's Stop or Resume: the rows the queue's pause is derived from. Carried by a + * tombstone because a released host deletes the journal from the first row kind it does not + * know (`journal-open.ts`, `journal-store-open.ts`); an unknown key it ignores. */ appendQueuePauseMark(mark: JournalQueuePauseMark, fence: number): Promise { return this.rowWriter.append(journalQueuePauseRowBuilder(() => this.state, mark, fence)) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 2269786a4ccd..2333fc663c90 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -297,6 +297,33 @@ describe('which cards a pause holds', () => { ]) }) + it("the queue's own consume refuses a newer card while an older one is held: nothing overtakes", async () => { + const journal = await open() + await queueDraft(journal, 'held') + await journal.appendQueuePauseMark('stopped', 0) + await queueDraft(journal, 'newer') + await expect( + journal.appendSubmission( + { + clientMessageId: 'drain-newer', + origin: 'host', + payloadFingerprint: 'fp-newer', + body: message('newer'), + fence: 0, + handoverRecorded: true + }, + { + messageId: 'newer', + expect: 'waiting', + settledByOp: null, + hostInstance: HOST, + yieldsToPause: { hostInstance: HOST } + } + ) + ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) + expect(journal.queuedMessages.get('newer')?.state).toBe('waiting') + }) + it("'cleared' holds the carried cards, not one typed after them", async () => { const journal = await open() await queueDraft(journal, 'carried-1', 'source-session') diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index dcb38d8e46a4..f5f9c1247f3d 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -113,6 +113,24 @@ export function queuePauseHolds(pause: DerivedQueuePause, card: QueueCard): bool ) } +/** The card the queue sends next: the oldest waiting one with no hold of its own, unless a + * returned card or one the pause holds comes first. The queue never reorders, so a newer card + * never overtakes a held one. The drain's pick and its consume both read this. */ +export function nextSendableQueuedCard( + pause: DerivedQueuePause | null, + cards: readonly T[] +): T | null { + for (const card of cards) { + if (card.state === 'returned' || (pause && queuePauseHolds(pause, card))) { + return null + } + if (card.state === 'waiting' && card.holdReason === null) { + return card + } + } + return null +} + /** Whether Resume would send anything: a card the pause holds, not behind a returned card, which * blocks everything after it until the user acts. Only then is the pause PUBLISHED, so its * header never offers a Resume that sends nothing. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index c6e7853bd0a9..f630e749866c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -30,7 +30,7 @@ import { structuredAgentSessionHostInstance, structuredQueuePause } from './structured-agent-session-queued-pause' -import { queuePauseHolds } from '../agent-session-journal/queued-message-pause' +import { nextSendableQueuedCard } from '../agent-session-journal/queued-message-pause' /** Budget at accept, in the send schema's own unit (`Buffer.byteLength` of the * serialized blocks); refused readably rather than trimmed. */ @@ -69,17 +69,7 @@ function oldestActionableQueuedMessage( if (!rows.some((row) => row.state === 'waiting')) { return null } - const pause = structuredQueuePause(journal) - for (const row of rows) { - if (row.state === 'returned' || (pause && queuePauseHolds(pause, row))) { - // A returned or paused card blocks everything after it until the user acts. - return null - } - if (row.state === 'waiting' && row.holdReason === null) { - return row - } - } - return null + return nextSendableQueuedCard(structuredQueuePause(journal), rows) } /** From d8b9e480bc2b128dc267e2159e67c832c4a74f0e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:21:02 -0700 Subject: [PATCH 009/182] fix(native-chat): stop creating the unused queue pause table The queue's pause is derived from journal rows, so nothing reads or writes queued_message_pauses. It was still created on every open "for downgrade safety", but an older build creates it itself when it opens the database, so the table only sat empty in every new database. The tests now pin that no pause table exists. --- .../queued-message-pause.test.ts | 14 ++++++++------ .../agent-session-journal/queued-message-schema.ts | 9 --------- ...tructured-agent-session-queued-stop-row.test.ts | 14 ++++++++------ 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 2333fc663c90..7678b4aef133 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -102,11 +102,13 @@ function held(journal: AgentSessionJournal): [string, boolean][] { .map((card) => [card.messageId, pause !== null && queuePauseHolds(pause, card)]) } -function storedPauses(): number { +/** Tables that could store a pause: none, the journal rows are the only record. */ +function pauseTables(): number { const db = new Database(journalDatabasePath(root), { readonly: true }) try { - const row: unknown = db.prepare('SELECT COUNT(*) AS n FROM queued_message_pauses').get() - return typeof row === 'object' && row !== null && 'n' in row ? Number(row.n) : -1 + return db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%pause%'") + .all().length } finally { db.close() } @@ -131,7 +133,7 @@ describe("the queue's pause, derived from the journal", () => { await journal.close() journal = await open() expect(reason(journal)).toBe('stopped') - expect(storedPauses()).toBe(0) + expect(pauseTables()).toBe(0) }) it("only a person's turn sent after the Stop and accepted lifts it; host turns never do", async () => { @@ -159,7 +161,7 @@ describe("the queue's pause, derived from the journal", () => { expect(reason(journal)).toBeNull() await journal.appendQueuePauseMark('stopped', 0) expect(reason(journal)).toBe('stopped') - expect(storedPauses()).toBe(0) + expect(pauseTables()).toBe(0) }) it("a card /clear carried in pauses the replacement 'cleared' until a Resume there", async () => { @@ -171,7 +173,7 @@ describe("the queue's pause, derived from the journal", () => { expect(reason(journal)).toBe('cleared') await journal.appendQueuePauseMark('resumed', 0) expect(reason(journal)).toBeNull() - expect(storedPauses()).toBe(0) + expect(pauseTables()).toBe(0) }) it("a person's accepted turn on the replacement lifts 'cleared'; a host turn does not", async () => { diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts index 690db5eed7f0..3f9d0122fda2 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-schema.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -67,14 +67,5 @@ CREATE TABLE IF NOT EXISTS queued_messages ( db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS queued_messages_consumed_as ON queued_messages (session_id, consumed_as) WHERE consumed_as IS NOT NULL; --- Unread and unwritten: the queue's pause is derived from the journal. Kept for downgrade --- safety; dropped in a later migration. -CREATE TABLE IF NOT EXISTS queued_message_pauses ( - session_id TEXT PRIMARY KEY, - reason TEXT NOT NULL, - epoch TEXT NOT NULL, - sequence INTEGER NOT NULL, - recorded_at INTEGER NOT NULL -); `) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index c171f873fce0..0cf2ce728705 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -70,11 +70,13 @@ function withdraw(clientMessageId: string) { }) } -function storedPauses(): number { +/** Tables that could store a pause: none, the journal rows are the only record. */ +function pauseTables(): number { const db = new Database(journalDatabasePath(rig.root), { readonly: true }) try { - const row: unknown = db.prepare('SELECT COUNT(*) AS n FROM queued_message_pauses').get() - return typeof row === 'object' && row !== null && 'n' in row ? Number(row.n) : -1 + return db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%pause%'") + .all().length } finally { db.close() } @@ -243,9 +245,9 @@ describe('no stored pause', () => { await rig.stop() await rig.settleAccepted(working, 'stopped') await expectHeld('stopped', draftId) - expect(storedPauses()).toBe(0) + expect(pauseTables()).toBe(0) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - expect(storedPauses()).toBe(0) + expect(pauseTables()).toBe(0) await eventually(async () => expect((await rig.handoff(draftId))?.handedOverAt).toBeDefined()) await queuedDraft('carried') await rig.stop() @@ -257,6 +259,6 @@ describe('no stored pause', () => { }) const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined expect(replacementId && (await rig.queuePause(replacementId))).toEqual({ reason: 'cleared' }) - expect(storedPauses()).toBe(0) + expect(pauseTables()).toBe(0) }) }) From 44e5184ec698c13976d7f734d2497deff29a0397 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:22:17 -0700 Subject: [PATCH 010/182] fix(native-chat): a Stop's pause never hides the restart pause A Stop holds only the cards queued before it. The pause derivation still returned the Stop alone whenever it was in force, so the restart pause was never considered: a card queued after the Stop, written by a host process that has since exited, sent by itself after Orca restarted, with no pause header and no Resume. A /clear pause that held nothing could hide it the same way. Every pause in force is now derived. A card is held if any of them holds it, and it names the first that does. The drain's pick, the consume transaction's re-check and the published header all read that one rule; the header names the pause holding the first card Resume would send. --- .../journal-queued-messages.ts | 18 ++--- .../queued-message-pause.test.ts | 79 +++++++++++++++++-- .../queued-message-pause.ts | 65 ++++++++------- ...ured-agent-session-queued-messages.test.ts | 4 +- ...tructured-agent-session-queued-messages.ts | 4 +- ...ed-agent-session-queued-pause-lift.test.ts | 16 ++-- .../structured-agent-session-queued-pause.ts | 8 +- ...ctured-agent-session-queued-publication.ts | 11 +-- ...ured-agent-session-queued-stop-row.test.ts | 38 +++++++-- 9 files changed, 172 insertions(+), 71 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 48a88360be21..a565c007b017 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -17,7 +17,7 @@ import type { JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' import { - deriveQueuePause, + deriveQueuePauses, nextSendableQueuedCard, type DerivedQueuePause } from './queued-message-pause' @@ -139,17 +139,17 @@ export class JournalQueuedMessages { ).then(() => undefined) } - /** The queue's pause, derived from the fold and the cards (`queued-message-pause.ts`). */ - pause(hostInstance: string): DerivedQueuePause | null { - return this.derivePause(this.list(), hostInstance) + /** The queue's pauses in force, derived from the fold and the cards (`queued-message-pause.ts`). */ + pauses(hostInstance: string): DerivedQueuePause[] { + return this.derivePauses(this.list(), hostInstance) } - private derivePause( + private derivePauses( cards: readonly QueuedMessageRow[], hostInstance: string - ): DerivedQueuePause | null { + ): DerivedQueuePause[] { const state = this.deps.state() - return deriveQueuePause({ + return deriveQueuePauses({ epoch: state.epoch, marks: state.queuePauseMarks, latestPersonTurnSequence: state.latestPersonTurnSequence, @@ -240,8 +240,8 @@ export class JournalQueuedMessages { // Judged here, on the same queue as every journal append, by the drain's own rule, so a // Stop row that landed after the drain chose this card still holds it. const cards = listQueuedMessages(db, this.deps.sessionId) - const pause = this.derivePause(cards, input.yieldsToPause.hostInstance) - if (nextSendableQueuedCard(pause, cards)?.messageId !== input.messageId) { + const pauses = this.derivePauses(cards, input.yieldsToPause.hostInstance) + if (nextSendableQueuedCard(pauses, cards)?.messageId !== input.messageId) { throw new QueuedMessageNotConsumableError(input.messageId, input.expect) } } diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 7678b4aef133..75861bcf8a11 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -20,7 +20,12 @@ import { QueuedMessageNotConsumableError } from './journal-queued-messages' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { parseJournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' -import { queuePauseHolds } from './queued-message-pause' +import { + deriveQueuePauses, + nextSendableQueuedCard, + queuePauseHolding, + resumableQueuePause +} from './queued-message-pause' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' @@ -90,16 +95,16 @@ function acceptTurn(journal: AgentSessionJournal, id: string) { } function reason(journal: AgentSessionJournal): string | null { - return journal.queuedMessages.pause(HOST)?.reason ?? null + return journal.queuedMessages.pauses(HOST)[0]?.reason ?? null } -/** Each card, and whether the queue's pause holds it. */ +/** Each card, and whether a pause in force holds it. */ function held(journal: AgentSessionJournal): [string, boolean][] { - const pause = journal.queuedMessages.pause(HOST) + const pauses = journal.queuedMessages.pauses(HOST) return journal.queuedMessages .list() .filter((card) => card.state === 'waiting') - .map((card) => [card.messageId, pause !== null && queuePauseHolds(pause, card)]) + .map((card) => [card.messageId, queuePauseHolding(pauses, card) !== undefined]) } /** Tables that could store a pause: none, the journal rows are the only record. */ @@ -377,3 +382,67 @@ describe("a restart's pause", () => { expect(journal.queuedMessages.revision()).toBe(revision) }) }) + +describe('several pauses in force', () => { + type Card = Parameters[1] & { messageId: string } + const DEAD = 'proc-0' + + function card(messageId: string, queuedAfter: number, fields: Partial = {}): Card { + const queuedAt = { epoch: 'epoch-1', sequence: queuedAfter + 1 } + const base = { state: 'waiting', holdReason: null, hostInstance: HOST, carriedFrom: null } + return { messageId, ...base, queuedAt, ...fields } + } + + /** A Stop at sequence 5 unless `stopped` is 0; no person's turn or Resume since. */ + function pausesOver(cards: readonly Card[], stopped = 5) { + return deriveQueuePauses({ + epoch: 'epoch-1', + marks: { stoppedSequence: stopped, resumedSequence: 0 }, + latestPersonTurnSequence: 0, + cards, + hostInstance: HOST, + restartEnded: false + }) + } + + function holding(cards: readonly Card[], stopped?: number): [string, string | null][] { + const pauses = pausesOver(cards, stopped) + return cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null]) + } + + it("a Stop that holds nothing never hides a restart's: a dead process's card queued after it waits", () => { + const after = card('after', 5, { hostInstance: DEAD }) + expect(pausesOver([after]).map((pause) => pause.reason)).toEqual(['stopped', 'restarted']) + expect(holding([after])).toEqual([['after', 'restarted']]) + expect(nextSendableQueuedCard(pausesOver([after]), [after])).toBeNull() + expect(resumableQueuePause(pausesOver([after]), [after])?.reason).toBe('restarted') + // Written by this process, nothing holds it: a card queued after a Stop sends normally. + const live = card('after', 5) + expect(nextSendableQueuedCard(pausesOver([live]), [live])).toBe(live) + }) + + it("a card names the first pause holding it, and the header names the first held card's", () => { + const cards = [ + card('before', 3, { hostInstance: DEAD }), + card('after', 5, { hostInstance: DEAD }) + ] + expect(holding(cards)).toEqual([ + ['before', 'stopped'], + ['after', 'restarted'] + ]) + expect(resumableQueuePause(pausesOver(cards), cards)?.reason).toBe('stopped') + }) + + it("a /clear's pause that holds nothing never hides a restart's", () => { + const cards = [ + card('carried', 1, { carriedFrom: 'source-session', holdReason: 'send_failed' }), + card('typed', 2, { hostInstance: DEAD }) + ] + expect(pausesOver(cards, 0).map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) + expect(holding(cards, 0)).toEqual([ + ['carried', null], + ['typed', 'restarted'] + ]) + expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index f5f9c1247f3d..ea20622ff0bb 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -1,5 +1,5 @@ -// The queue's pause: a pure function of the journal fold and the cards, never a stored flag, so -// nothing has to retire it. Which cards it holds is `queuePauseHolds`. Paused when: +// The queue's pauses: a pure function of the journal fold and the cards, never a stored flag, so +// nothing has to retire them. Each holds its own cards (`queuePauseHolding`). In force when: // - 'stopped': the latest Stop row has no later Resume row, and no turn a person asked for was // sent after it and accepted. A later Stop is simply the latest. // - 'cleared': a card /clear carried into this conversation waits, and no person's turn or @@ -57,7 +57,8 @@ export function journalQueueStopHolds( return marks.stoppedSequence > Math.max(latestPersonTurnSequence, marks.resumedSequence) } -export function deriveQueuePause(input: { +/** Every pause in force, in the order a card held by several names its reason. */ +export function deriveQueuePauses(input: { /** The journal's epoch: sequences compare only within one. */ epoch: string marks: JournalQueuePauseMarks @@ -66,21 +67,22 @@ export function deriveQueuePause(input: { hostInstance: string /** A person's turn started since this conversation opened. */ restartEnded: boolean -}): DerivedQueuePause | null { +}): DerivedQueuePause[] { const { epoch, marks, latestPersonTurnSequence } = input + const pauses: DerivedQueuePause[] = [] if (journalQueueStopHolds(marks, latestPersonTurnSequence)) { - return { reason: 'stopped', since: { epoch, sequence: marks.stoppedSequence } } + pauses.push({ reason: 'stopped', since: { epoch, sequence: marks.stoppedSequence } }) } const waiting = input.cards.filter((card) => card.state === 'waiting') const carried = waiting.filter((card) => card.carriedFrom !== null) if (carried.length > 0 && latestPersonTurnSequence === 0 && marks.resumedSequence === 0) { - return { reason: 'cleared', since: null } + pauses.push({ reason: 'cleared', since: null }) } if (!input.restartEnded && waiting.some((card) => card.hostInstance !== input.hostInstance)) { // The process that wrote a card is gone: every card waits, whenever it was written. - return { reason: 'restarted', since: null } + pauses.push({ reason: 'restarted', since: null }) } - return null + return pauses } /** Queued before the pause began: for /clear, a card it carried; for a restart, every card. For a @@ -104,24 +106,28 @@ function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { // drain stops at the first one. true instead holds every waiting card, whenever it was queued. const PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT = false -/** THE rule for which cards a pause holds: the drain, its consume, and publication all read it. */ -export function queuePauseHolds(pause: DerivedQueuePause, card: QueueCard): boolean { - return ( - card.state === 'waiting' && - card.holdReason === null && - (PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || queuedBeforePause(pause, card)) - ) +/** THE rule for which cards are held: by ANY pause in force, named by the first that holds it, so + * a Stop's that holds nothing never hides a restart's. The drain, its consume, and publication + * all read it. */ +export function queuePauseHolding( + pauses: readonly DerivedQueuePause[], + card: QueueCard +): DerivedQueuePause | undefined { + if (card.state !== 'waiting' || card.holdReason !== null) { + return undefined + } + return pauses.find((pause) => PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || queuedBeforePause(pause, card)) } /** The card the queue sends next: the oldest waiting one with no hold of its own, unless a - * returned card or one the pause holds comes first. The queue never reorders, so a newer card - * never overtakes a held one. The drain's pick and its consume both read this. */ + * returned card or a held one comes first. The queue never reorders, so a newer card never + * overtakes a held one. The drain's pick and its consume both read this. */ export function nextSendableQueuedCard( - pause: DerivedQueuePause | null, + pauses: readonly DerivedQueuePause[], cards: readonly T[] ): T | null { for (const card of cards) { - if (card.state === 'returned' || (pause && queuePauseHolds(pause, card))) { + if (card.state === 'returned' || queuePauseHolding(pauses, card)) { return null } if (card.state === 'waiting' && card.holdReason === null) { @@ -131,20 +137,21 @@ export function nextSendableQueuedCard( return null } -/** Whether Resume would send anything: a card the pause holds, not behind a returned card, which - * blocks everything after it until the user acts. Only then is the pause PUBLISHED, so its - * header never offers a Resume that sends nothing. */ -export function queuePauseHoldsResumableCard( - pause: DerivedQueuePause, +/** The pause to PUBLISH: the one holding the first card Resume would send, not behind a returned + * card, which blocks everything after it until the user acts. None otherwise, so its header + * never offers a Resume that sends nothing. */ +export function resumableQueuePause( + pauses: readonly DerivedQueuePause[], cards: readonly QueueCard[] -): boolean { +): DerivedQueuePause | null { for (const card of cards) { if (card.state === 'returned') { - return false + return null } - if (queuePauseHolds(pause, card)) { - return true + const holding = queuePauseHolding(pauses, card) + if (holding) { + return holding } } - return false + return null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index d3847af8b728..4453e060df08 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -16,7 +16,7 @@ import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' import { rotateStructuredAgentSessionHostInstanceForTests, - structuredQueuePause + structuredQueuePauses } from './structured-agent-session-queued-pause' import { createQueuedMessageTestRig, @@ -681,7 +681,7 @@ describe('/clear', () => { } expect(await drafts(replacementId)).toHaveLength(0) const journal = host.collaboratorsForTests().sessions.get(replacementId)?.journal - expect(journal && structuredQueuePause(journal)).toBeNull() + expect(journal && structuredQueuePauses(journal)).toEqual([]) }) it('a returned card carries over as a plain waiting draft on the paused replacement', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index f630e749866c..5aeca39b74df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -28,7 +28,7 @@ import type { QueuedMessageRow } from '../agent-session-journal/queued-message-t import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionHostInstance, - structuredQueuePause + structuredQueuePauses } from './structured-agent-session-queued-pause' import { nextSendableQueuedCard } from '../agent-session-journal/queued-message-pause' @@ -69,7 +69,7 @@ function oldestActionableQueuedMessage( if (!rows.some((row) => row.state === 'waiting')) { return null } - return nextSendableQueuedCard(structuredQueuePause(journal), rows) + return nextSendableQueuedCard(structuredQueuePauses(journal), rows) } /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 09738da3d1d3..243da1182a21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -19,7 +19,7 @@ import { sameQueuePause } from './structured-agent-session-queued-publication' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionHostInstance, - structuredQueuePause + structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig @@ -189,18 +189,18 @@ describe('the pause read', () => { } const scan = vi.spyOn(journal, 'submissions') // Read on every publish, per subscriber: it must not walk the submissions. - expect(structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) + expect(structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) expect(scan).not.toHaveBeenCalled() scan.mockRestore() const mail = rig.send('coordinator mail', undefined, { internal: true }) await mail.result await rig.settleAccepted(mail.id, 'mail') // Orca's own turn lifts nothing; a person's does. - expect(structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) + expect(structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) const next = rig.send('user starts a new turn') await next.result await rig.settleAccepted(next.id, 'next') - expect(structuredQueuePause(journal)).toBeNull() + expect(structuredQueuePauses(journal)).toEqual([]) await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) }) @@ -233,7 +233,7 @@ describe('a card queued after a Stop is a new instruction', () => { await rig.settleAccepted(mail.id, 'mail') await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - expect(journal && structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) + expect(journal && structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) }) it("deleting the last paused card hides the pause; a person's next turn is what ends it", async () => { @@ -268,7 +268,7 @@ describe('a pause only over cards Resume could send', () => { await rig.settleAccepted(next, 'stopped') expect(await rig.queuePause()).toBeNull() const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - expect(journal && structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) + expect(journal && structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) }) it('a returned card blocking the paused cards hides the pause but keeps it; deleting that card shows it again, and only Resume sends', async () => { @@ -293,7 +293,7 @@ describe('a pause only over cards Resume could send', () => { } // Resume would send nothing past the returned card, so no header offers it; the pause stays. expect(await rig.queuePause()).toBeNull() - expect(structuredQueuePause(journal)).toMatchObject({ reason: 'stopped' }) + expect(structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) // Deleting the blocking card shows the pause again: the card behind it does not send unasked. expect(await rig.deleteQueued(refusedId)).toMatchObject({ ok: true, value: { deleted: true } }) await expectPaused(behindId) @@ -385,7 +385,7 @@ describe('a card handed off after a restart', () => { 'resumed', structuredAgentSessionConversationFence(rig.store, HOST_TEST_SESSION) ) - expect(structuredQueuePause(journal)).toBeNull() + expect(structuredQueuePauses(journal)).toEqual([]) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts index 4e7d3a606a82..7355655476c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -23,9 +23,9 @@ export function rotateStructuredAgentSessionHostInstanceForTests(): string { type PauseJournal = Pick -/** The queue's pause, derived; null when the queue sends on its own. */ -export function structuredQueuePause(journal: PauseJournal): DerivedQueuePause | null { - return journal.queuedMessages.pause(hostInstance) +/** The queue's pauses in force, derived; none when the queue sends on its own. */ +export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[] { + return journal.queuedMessages.pauses(hostInstance) } /** @@ -59,7 +59,7 @@ export async function resumeStructuredQueue( journal: Pick, fence: number ): Promise { - if (structuredQueuePause(journal) === null) { + if (structuredQueuePauses(journal).length === 0) { return false } await journal.appendQueuePauseMark('resumed', fence) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index 17bd71a57e0e..1fa46616da18 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -9,8 +9,8 @@ import { type AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { queuePauseHoldsResumableCard } from '../agent-session-journal/queued-message-pause' -import { structuredQueuePause } from './structured-agent-session-queued-pause' +import { resumableQueuePause } from '../agent-session-journal/queued-message-pause' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' export type QueuePublication = { queuedMessages: AgentSessionQueuedMessage[] @@ -84,11 +84,8 @@ export function readQueuePublication(journal: AgentSessionJournal): QueuePublica // Read per emit: the pause also turns on submissions (a person's turn starting). Shown only // over a card Resume would send, so its header never offers to send nothing; deleting a // blocking returned card shows it again. - const pause = structuredQueuePause(journal) - const queuePause = - pause && queuePauseHoldsResumableCard(pause, journal.queuedMessages.list()) - ? { reason: pause.reason } - : null + const pause = resumableQueuePause(structuredQueuePauses(journal), journal.queuedMessages.list()) + const queuePause = pause ? { reason: pause.reason } : null const previous = publications.get(journal) if ( previous && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index 0cf2ce728705..e3b8682bd9c8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -1,7 +1,7 @@ // Stop writes one journal row before it interrupts, and the queue's pause is derived from it: // through the real host, the cards queued before a Stop wait, a card queued after it sends -// normally but never ahead of them, a withdrawn card comes back under it, a crash keeps it, and -// no stored pause is ever written. +// normally but never ahead of them, a withdrawn card comes back under it, a crash keeps it, it +// never hides a restart's pause, and no stored pause is ever written. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -19,7 +19,7 @@ import { QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' -import { structuredQueuePause } from './structured-agent-session-queued-pause' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig @@ -87,7 +87,7 @@ describe("Stop's row", () => { await rig.workingSend() let pausedAtInterrupt: unknown = 'not interrupted' rig.cancelTurn.mockImplementationOnce(async () => { - pausedAtInterrupt = structuredQueuePause(journal())?.reason ?? null + pausedAtInterrupt = structuredQueuePauses(journal())[0]?.reason ?? null return { cancelled: true } }) expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -185,6 +185,34 @@ describe("Stop's row", () => { }) }) +describe("a Stop never hides a restart's pause", () => { + it('a card queued after a Stop over an empty queue, before a restart, waits restarted', async () => { + const working = await rig.workingSend() + await rig.stop() + await rig.settleAccepted(working, 'stopped') + const mail = await mailTurn() + const typed = await queuedDraft('typed during the mail turn') + await rig.restartHostProcess() + await rig.settleAccepted(mail, 'mail') + await expectHeld('restarted', typed) + }) + + it("a card queued during the queue's own send after a Stop, before a restart, waits restarted", async () => { + const working = await rig.workingSend() + await rig.stop() + const correction = await queuedDraft('typed while the interrupt lands') + await rig.settleAccepted(working, 'stopped') + // The queue's own send is not a person's turn: the Stop stays in force, holding nothing. + await eventually(async () => + expect((await rig.handoff(correction))?.handedOverAt).toBeDefined() + ) + const typed = await queuedDraft('typed during that send') + await rig.restartHostProcess() + await rig.settleAccepted(await rig.handoffId(correction), 'correction') + await expectHeld('restarted', typed) + }) +}) + describe("a /clear's carried cards", () => { function clear() { const fields = { command: 'clear' as const } @@ -234,7 +262,7 @@ describe("a /clear's carried cards", () => { providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-hi', ordinal: 0 } }) expect(journal(replacementId).queuedMessages.list()[0]?.messageId).toBe(carried) - expect(structuredQueuePause(journal(replacementId))).toBeNull() + expect(structuredQueuePauses(journal(replacementId))).toEqual([]) }) }) From a90bd87b8bc6be7c92fe3559694839d574c8ac9d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:26:35 -0700 Subject: [PATCH 011/182] test(native-chat): pin the Stop's no-resend, lift and held-card rules - The Claude and Codex Stop-withdraws-a-steer tests checked "not sent again" at one instant, before a queue ignoring the pause re-sends. They now wait for the stopped turn to end and re-check after a quiet window. - The deleted-card test read a card queued after the Stop, which sends whether or not a person's turn lifts it; it now reads the Stop's pause before and after that turn. - Unit cases pin that a Stop holds a card with no recorded position and one queued before a rewind. --- .../queued-message-pause.test.ts | 16 +++++++++++++++- ...ured-agent-session-claude-queued-stop.test.ts | 8 +++++++- ...tured-agent-session-queued-pause-lift.test.ts | 10 +++++++++- ...ent-session-codex-turn-end-settlement.test.ts | 6 +++++- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 75861bcf8a11..3a893c137001 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -383,7 +383,7 @@ describe("a restart's pause", () => { }) }) -describe('several pauses in force', () => { +describe('which cards the pauses in force hold', () => { type Card = Parameters[1] & { messageId: string } const DEAD = 'proc-0' @@ -410,6 +410,20 @@ describe('several pauses in force', () => { return cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null]) } + it('a Stop holds a card with no recorded position, and one queued before a rewind', () => { + const cards = [ + card('unrecorded', 5, { queuedAt: null }), + // Later in its own epoch than the Stop is in this one: only the epoch says it came first. + card('before-rewind', 5, { queuedAt: { epoch: 'epoch-0', sequence: 9 } }), + card('after', 5) + ] + expect(holding(cards)).toEqual([ + ['unrecorded', 'stopped'], + ['before-rewind', 'stopped'], + ['after', null] + ]) + }) + it("a Stop that holds nothing never hides a restart's: a dead process's card queued after it waits", () => { const after = card('after', 5, { hostInstance: DEAD }) expect(pausesOver([after]).map((pause) => pause.reason)).toEqual(['stopped', 'restarted']) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index bd960b179ba6..043f093a1bc0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -367,5 +367,11 @@ it('a card sent now into the running turn comes back paused when Stop withdraws sends: [{ origin: 'client', state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }] }) }, 5_000) + // A drain ignoring the pause re-sends only after the stopped turn ends: watch past that. + await eventually(async () => expect(await liveTurnId()).toBeNull()) + await new Promise((resolve) => setTimeout(resolve, 2_500)) expect(connection.sent).toHaveLength(2) -}, 15_000) + expect(await sends()).toEqual([ + { origin: 'client', state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED } + ]) +}, 20_000) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 243da1182a21..b15f27b9596f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -244,10 +244,18 @@ describe('a card queued after a Stop is a new instruction', () => { expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) expect(await rig.deleteQueued(only)).toMatchObject({ ok: true, value: { deleted: true } }) expect(await rig.queuePause()).toBeNull() + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + // Hidden, not ended: with nothing to hold, no card can show the lift, so read the Stop itself. + expect(structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) const next = await rig.workingSend() const later = await queuedDraft('typed during the next turn') - // That send is a person's turn after the Stop: once it starts, the queue sends again. + expect(structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) + // That send is a person's turn after the Stop: once it starts, the Stop is over. await rig.settleAccepted(next, 'next') + expect(structuredQueuePauses(journal)).toEqual([]) await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) }) }) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index ce23a8df5918..4af11020cdd7 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -292,8 +292,12 @@ describe('a queued card sent now into the turn a Stop ends', () => { }), { timeout: 5_000 } ) + // A drain ignoring the pause re-sends only after the stopped turn ends: watch past that. + await vi.waitFor(() => expect(turns.turnId).toBeNull()) + await new Promise((resolve) => setTimeout(resolve, 2_500)) expect(answers).toBe(2) - }) + expect(await sends(cardId)).toEqual([{ origin: 'client', verdict: 'withdrawn' }]) + }, 20_000) }) describe('a Stop sent after Codex answered a cold send, before it opened the turn', () => { From 99589e93c27825efe71b6ea00149a44fe2799e80 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:37:09 -0700 Subject: [PATCH 012/182] refactor(native-chat): a Stop writes one Stop event with its reason, turn and caller The Stop row that paused the queue becomes the general Stop event { reason, turnId?, at, caller? }, whose reason is the host's existing stop cause. It still rides a tombstone of a host-only id (a released host deletes the journal from the first unknown row kind), and Resume keeps its own marker on its own id. Only a person's Stop (reason user-stop) pauses the queue. --- .../journal-epoch-controller.ts | 7 ++- .../journal-epoch-replacement.test.ts | 2 +- .../journal-epoch-replacement.ts | 26 +++++---- .../agent-session-journal/journal-open.ts | 4 +- .../journal-queue-pause-row.ts | 37 ------------ .../agent-session-journal/journal-reducer.ts | 6 +- .../journal-row-schema.ts | 34 +++++++---- .../journal-stop-and-resume-rows.ts | 58 +++++++++++++++++++ .../journal-store-collaborators.ts | 9 ++- .../agent-session-journal/journal-store.ts | 23 +++++--- .../queued-message-pause.test.ts | 51 ++++++++++------ .../queued-message-pause.ts | 53 ++++++++++++----- .../agent-session-journal-batch.ts | 6 +- ...structured-agent-session-host-mutations.ts | 15 +++-- ...ured-agent-session-queued-messages.test.ts | 6 +- ...ed-agent-session-queued-pause-lift.test.ts | 3 +- .../structured-agent-session-queued-pause.ts | 8 +-- ...ured-agent-session-queued-stop-row.test.ts | 4 +- .../structured-agent-session-queued-stop.ts | 42 ++++++++------ .../structured-agent-session-stop-cause.ts | 5 +- 20 files changed, 249 insertions(+), 150 deletions(-) delete mode 100644 src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts index 2c8b2766c0f2..51d3a4b2fd53 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts @@ -4,6 +4,7 @@ import type { } from '../../../shared/agent-session-journal-types' import type { JournalHostDatabase } from './journal-host-database' import { replaceJournalEpoch, type JournalReplacementItem } from './journal-epoch-replacement' +import type { JournalQueuePauseRestatement } from './queued-message-pause' import { publishNewEpoch } from './journal-epoch-rollover' import type { JournalLoad } from './journal-open' import type { AgentJournalEpochReason } from './journal-row-schema' @@ -20,8 +21,8 @@ export class JournalEpochController { readOnly: () => boolean setReadOnly: (readOnly: boolean) => void highestFence: () => number - /** Whether the live epoch's latest Stop still pauses the queue. */ - queueStopped: () => boolean + /** What of the live epoch's Stop and Resume a replacement restates. */ + queuePauseRestatement: () => JournalQueuePauseRestatement cursor: () => AgentJournalCursor adopt: (loaded: JournalLoad) => void } @@ -69,7 +70,7 @@ export class JournalEpochController { reason, fence, items, - queueStopped: this.deps.queueStopped(), + queuePause: this.deps.queuePauseRestatement(), now: this.deps.now, mintEpoch: this.deps.mintEpoch, onPublished: this.deps.adopt diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 3c893545c4e0..2992459a787a 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -67,7 +67,7 @@ function replace(input: { reason: 'legacy_import', fence: 1, items: input.items, - queueStopped: false, + queuePause: { lifted: false, liveStop: null }, now, mintEpoch: () => `epoch-${clock}`, onPublished: input.onPublished ?? (() => undefined) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 1097b5ab5cc7..3f0c7a1e7796 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -16,7 +16,8 @@ import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' -import { buildJournalQueuePauseRow } from './journal-queue-pause-row' +import { buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' +import type { JournalQueuePauseRestatement } from './queued-message-pause' import { deleteJournalEpochRows, insertJournalRow, @@ -41,9 +42,9 @@ export function replaceJournalEpoch(input: { reason: AgentJournalEpochReason fence: number items: readonly JournalReplacementItem[] - /** The superseded epoch's latest Stop still paused the queue: the new epoch restates it, or - * the rewind would release cards the person stopped. */ - queueStopped: boolean + /** Restated in the new epoch, or the rewind would release cards the person stopped, or bring + * back a /clear pause they already lifted. */ + queuePause: JournalQueuePauseRestatement now: () => number mintEpoch: () => string /** Called the instant the transaction commits, before any fallible follow-up. */ @@ -74,14 +75,15 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } - if (input.queueStopped) { - const row = buildJournalQueuePauseRow({ - state, - mark: 'stopped', - seq: state.lastSequence + 1, - fence: input.fence, - ts: input.now() - }) + const { lifted, liveStop } = input.queuePause + const place = () => ({ state, seq: state.lastSequence + 1, fence: input.fence, ts: input.now() }) + if (lifted) { + const row = buildJournalQueueResumeRow(place()) + applyJournalRow(state, row) + rows.push(row) + } + if (liveStop) { + const row = buildJournalStopEventRow({ ...place(), event: liveStop }) applyJournalRow(state, row) rows.push(row) } diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts index 8654ad569aa4..8f947272dbc7 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -19,7 +19,7 @@ import { } from './journal-row-table' import { JOURNAL_REPAIR_DISCLOSURE_ITEM_ID } from './journal-repair-disclosure' import { pendingJournalRepairSequence } from './journal-repair-marker' -import { isJournalQueuePauseRow, parseJournalRow, type JournalRow } from './journal-row-schema' +import { isJournalStopOrResumeRow, parseJournalRow, type JournalRow } from './journal-row-schema' /** Every epoch row is sequence 1, and no compaction moves that floor. */ const FIRST_JOURNAL_SEQUENCE = 1 @@ -121,7 +121,7 @@ export function startJournalRowFold(input: JournalRowFoldInput): { applyJournalRow(state, row) const disclosure = row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID // A Stop or Resume is no history, so it never reads as a rebuilt or provider-backed epoch. - if (!disclosure && !isJournalQueuePauseRow(row)) { + if (!disclosure && !isJournalStopOrResumeRow(row)) { repairHasContent ||= repairedFrom !== null && row.seq >= repairedFrom providerHasContent ||= row.seq >= FIRST_JOURNAL_SEQUENCE + 1 } diff --git a/src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts b/src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts deleted file mode 100644 index 4be07ce6b8b0..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-queue-pause-row.ts +++ /dev/null @@ -1,37 +0,0 @@ -// The row a person's Stop or Resume appends: a tombstone of an id no item ever takes, carrying -// the mark (`journal-row-schema.ts` says why not a row kind of its own). - -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { JournalReducerState } from './journal-reducer' -import { journalRowBase } from './journal-row-builders' -import type { JournalQueuePauseMark, JournalTombstoneRow } from './journal-row-schema' - -/** The one id the queue's Stop and Resume rows name; no item ever takes it. */ -const JOURNAL_QUEUE_PAUSE_ITEM_ID = agentJournalItemKey({ - provider: 'orca', - clientMessageId: 'queue-pause' -}) - -export function buildJournalQueuePauseRow(input: { - state: JournalReducerState - mark: JournalQueuePauseMark - seq: number - fence: number - ts: number -}): JournalTombstoneRow { - return { - kind: 'tombstone', - itemId: JOURNAL_QUEUE_PAUSE_ITEM_ID, - revision: 1, - queuePause: input.mark, - ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) - } -} - -export function journalQueuePauseRowBuilder( - state: () => JournalReducerState, - mark: JournalQueuePauseMark, - fence: number -): (seq: number, ts: number) => JournalTombstoneRow { - return (seq, ts) => buildJournalQueuePauseRow({ state: state(), mark, seq, fence, ts }) -} diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index 0af2eccf11dd..a7d6f150e70c 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -26,7 +26,7 @@ import { structuredAgentSessionPayloadFingerprint } from '../../../shared/struct import { JournalDerivedTurnScope } from './journal-derived-turn-scope' import { removeJournalItem, statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import { journalItemRevisionIsStale } from './journal-item-revision' -import { isJournalQueuePauseRow, type JournalRow } from './journal-row-schema' +import { isJournalStopOrResumeRow, type JournalRow } from './journal-row-schema' import { acceptSubmissionFromProviderItem, applyJournalSubmission } from './journal-submission-fold' import { applyJournalDispatchRow } from './journal-dispatch-reducer' import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' @@ -62,7 +62,7 @@ export type JournalReducerState = { /** The submission row of the latest turn a person asked for (`origin: 'client'`) that the * provider accepted; 0 when none. Kept as it folds so the queue's pause reads it in O(1). */ latestPersonTurnSequence: number - /** The queue's latest Stop and Resume rows, what its pause is derived from. */ + /** The latest person's Stop event and Resume, what the queue's pause is derived from. */ queuePauseMarks: JournalQueuePauseMarks } @@ -109,7 +109,7 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo ) return } - if (isJournalQueuePauseRow(row)) { + if (isJournalStopOrResumeRow(row)) { foldJournalQueuePauseMark(state.queuePauseMarks, row) return } 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 67c8aa8ca111..ddda8809689c 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 @@ -20,6 +20,7 @@ import { isAdmissibleAgentJournalMessageBody } from '../../../shared/agent-session-journal-schemas' import { isAdmissibleAgentSessionContextUsage } from '../../../shared/agent-session-context-usage-schema' +import type { StructuredAgentSessionStopCause } from '../agent-session-wire/structured-agent-session-stop-cause' /** Producer linkage rides the row BASE rather than the body: the two nested * prompt shapes are `.strict()`, so an unknown key on a body would make the @@ -72,19 +73,32 @@ export type JournalTombstoneRow = JournalRowBase & { kind: 'tombstone' itemId: string revision: number - /** Present: not a removal but the queue's Stop or Resume (`queued-message-pause.ts`), on an id - * no item ever takes. A new row kind would make a released host truncate the journal from - * that row; this one it reads as removing nothing. */ - queuePause?: JournalQueuePauseMark + /** Present: not a removal but a Stop's event, on an id no item ever takes. */ + stopEvent?: JournalStopEvent + /** Present: not a removal but a person's Resume of the queue, on an id no item ever takes. */ + queueResume?: true } -export type JournalQueuePauseMark = 'stopped' | 'resumed' +/** One Stop that took effect. Temporary carrier: a tombstone's extra key, because a released host + * deletes the journal from the first row kind it does not know (`journal-open.ts` then + * `journal-store-open.ts`) but ignores an unknown key; a row kind of its own once released hosts + * skip unknown kinds instead. */ +export type JournalStopEvent = { + /** Persisted: never rename an arm. Only `user-stop` pauses the queue. */ + reason: StructuredAgentSessionStopCause + /** The turn the Stop named, else the one running when it took effect. */ + turnId?: string + /** When it took effect; a rewind's restatement keeps it. */ + at: number + /** Who asked (`StructuredAgentSessionCaller.callerKey`). */ + caller?: string +} -/** A Stop or Resume row. Any value counts, so a newer build's mark never removes an item. */ -export function isJournalQueuePauseRow( - row: JournalRow -): row is JournalTombstoneRow & { queuePause: JournalQueuePauseMark } { - return row.kind === 'tombstone' && row.queuePause !== undefined +/** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ +export function isJournalStopOrResumeRow(row: JournalRow): row is JournalTombstoneRow { + return ( + row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) + ) } /** The write-ahead row. Durable BEFORE the adapter dispatches anything; it diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts new file mode 100644 index 000000000000..e763af8b2e6c --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts @@ -0,0 +1,58 @@ +// The rows a Stop's event and a person's Resume append: tombstones of ids no item ever takes, +// each carrying its record as an extra key (`journal-row-schema.ts` says why not a row kind). + +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { JournalReducerState } from './journal-reducer' +import { journalRowBase } from './journal-row-builders' +import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' + +/** One id per mark kind; no item ever takes either. */ +const JOURNAL_STOP_EVENT_ITEM_ID = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'stop-event' +}) +const JOURNAL_QUEUE_RESUME_ITEM_ID = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'queue-resume' +}) + +type RowPlace = { state: JournalReducerState; seq: number; fence: number; ts: number } + +export function buildJournalStopEventRow( + input: RowPlace & { event: JournalStopEvent } +): JournalTombstoneRow { + return { + kind: 'tombstone', + itemId: JOURNAL_STOP_EVENT_ITEM_ID, + revision: 1, + stopEvent: input.event, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + +export function buildJournalQueueResumeRow(input: RowPlace): JournalTombstoneRow { + return { + kind: 'tombstone', + itemId: JOURNAL_QUEUE_RESUME_ITEM_ID, + revision: 1, + queueResume: true, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + +/** A Stop taking effect now: its event's time is its row's. */ +export function journalStopEventRowBuilder( + state: () => JournalReducerState, + event: Omit, + fence: number +): (seq: number, ts: number) => JournalTombstoneRow { + return (seq, ts) => + buildJournalStopEventRow({ state: state(), seq, fence, ts, event: { ...event, at: ts } }) +} + +export function journalQueueResumeRowBuilder( + state: () => JournalReducerState, + fence: number +): (seq: number, ts: number) => JournalTombstoneRow { + return (seq, ts) => buildJournalQueueResumeRow({ state: state(), seq, fence, ts }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 5e90baf7a873..647b64e0f004 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -13,7 +13,7 @@ import { JournalItemAppender } from './journal-item-appender' import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalLoad } from './journal-open' import { JournalQueuedMessages } from './journal-queued-messages' -import { journalQueueStopHolds } from './queued-message-pause' +import { journalQueuePauseRestatement } from './queued-message-pause' import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-writer' import { restoreJournalStore } from './journal-store-restore' @@ -71,8 +71,11 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal readOnly: host.readOnly, setReadOnly: host.setReadOnly, highestFence: () => host.state().highestFence, - queueStopped: () => - journalQueueStopHolds(host.state().queuePauseMarks, host.state().latestPersonTurnSequence), + queuePauseRestatement: () => + journalQueuePauseRestatement( + host.state().queuePauseMarks, + host.state().latestPersonTurnSequence + ), cursor: host.cursor, adopt: host.adopt }) 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 90fa5366f3b7..2e37ca7692b8 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -58,8 +58,11 @@ import type { ResolveDispatchInput } from './journal-store-contracts' import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' -import { journalQueuePauseRowBuilder } from './journal-queue-pause-row' -import type { AgentJournalEpochReason, JournalQueuePauseMark } from './journal-row-schema' +import { + journalQueueResumeRowBuilder, + journalStopEventRowBuilder +} from './journal-stop-and-resume-rows' +import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' import type { JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' @@ -308,11 +311,17 @@ export class AgentSessionJournal { ) } - /** A person's Stop or Resume: the rows the queue's pause is derived from. Carried by a - * tombstone because a released host deletes the journal from the first row kind it does not - * know (`journal-open.ts`, `journal-store-open.ts`); an unknown key it ignores. */ - appendQueuePauseMark(mark: JournalQueuePauseMark, fence: number): Promise { - return this.rowWriter.append(journalQueuePauseRowBuilder(() => this.state, mark, fence)) + /** A Stop that took effect, timed by its row (`JournalStopEvent`). */ + appendStopEvent( + event: Omit, + fence: number + ): Promise { + return this.rowWriter.append(journalStopEventRowBuilder(() => this.state, event, fence)) + } + + /** A person's Resume of the queue. */ + appendQueueResume(fence: number): Promise { + return this.rowWriter.append(journalQueueResumeRowBuilder(() => this.state, fence)) } appendLifecycleBatch(input: JournalLifecycleBatchInput): Promise { diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 3a893c137001..5986787d25ad 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -94,6 +94,11 @@ function acceptTurn(journal: AgentSessionJournal, id: string) { }) } +/** A person's Stop taking effect. */ +function userStop(journal: AgentSessionJournal) { + return journal.appendStopEvent({ reason: 'user-stop' }, 0) +} + function reason(journal: AgentSessionJournal): string | null { return journal.queuedMessages.pauses(HOST)[0]?.reason ?? null } @@ -130,9 +135,9 @@ afterEach(async () => { }) describe("the queue's pause, derived from the journal", () => { - it('a Stop row pauses the queue, even with no card yet, survives reopen, and stores nothing', async () => { + it("a person's Stop event pauses the queue, even with no card yet, survives reopen, and stores nothing", async () => { let journal = await open() - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) expect(reason(journal)).toBe('stopped') await queueDraft(journal, 'draft-1') await journal.close() @@ -144,7 +149,7 @@ describe("the queue's pause, derived from the journal", () => { it("only a person's turn sent after the Stop and accepted lifts it; host turns never do", async () => { const journal = await open() await turn(journal, 'before-stop', 'client', false) - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) // Sent before the Stop: its acceptance now does not end a Stop that came after it. await acceptTurn(journal, 'before-stop') await turn(journal, 'mail', 'host') @@ -157,14 +162,14 @@ describe("the queue's pause, derived from the journal", () => { it('a later Stop is the latest, and a Resume row lifts it', async () => { const journal = await open() - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) await turn(journal, 'typed', 'client') expect(reason(journal)).toBeNull() - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) expect(reason(journal)).toBe('stopped') - await journal.appendQueuePauseMark('resumed', 0) + await journal.appendQueueResume(0) expect(reason(journal)).toBeNull() - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) expect(reason(journal)).toBe('stopped') expect(pauseTables()).toBe(0) }) @@ -176,7 +181,7 @@ describe("the queue's pause, derived from the journal", () => { await journal.close() journal = await open() expect(reason(journal)).toBe('cleared') - await journal.appendQueuePauseMark('resumed', 0) + await journal.appendQueueResume(0) expect(reason(journal)).toBeNull() expect(pauseTables()).toBe(0) }) @@ -193,17 +198,24 @@ describe("the queue's pause, derived from the journal", () => { it('rides a tombstone of an id no item takes, so an older build reads it and changes nothing', async () => { const journal = await open() await turn(journal, 'typed', 'client') - await journal.appendQueuePauseMark('stopped', 0) + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 0) const db = new Database(journalDatabasePath(root), { readonly: true }) const stored = liveTestJournalRows(db, IDENTITY.sessionId) db.close() const parsed = parseJournalRow(stored.at(-1)?.rowJson ?? '') - expect(parsed).toMatchObject({ ok: true, row: { kind: 'tombstone', queuePause: 'stopped' } }) + expect(parsed).toMatchObject({ + ok: true, + row: { + kind: 'tombstone', + stopEvent: { reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' } + } + }) if (!parsed.ok || parsed.row.kind !== 'tombstone') { throw new Error('expected a tombstone row') } + expect(parsed.row.stopEvent?.at).toBe(parsed.row.ts) // An older build ignores the unknown key: the row is an ordinary removal of nothing. - const { queuePause: _ignored, ...asOlderBuildReadsIt } = parsed.row + const { stopEvent: _ignored, ...asOlderBuildReadsIt } = parsed.row const state = createJournalReducerState(IDENTITY.sessionId, parsed.row.epoch) for (const row of stored.slice(0, -1)) { const earlier = parseJournalRow(row.rowJson) @@ -221,7 +233,7 @@ describe("the queue's pause, derived from the journal", () => { it("the queue's own consume is refused in its transaction while the pause holds the card; Send-now is not", async () => { const journal = await open() await queueDraft(journal, 'draft-1') - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) const consume = (id: string, automatic: boolean) => journal.appendSubmission( { @@ -249,7 +261,7 @@ describe("the queue's pause, derived from the journal", () => { it("a rewind's epoch replacement restates a Stop still pausing, and not one a person ended", async () => { const journal = await open() - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) await journal.replaceEpochItems('handle_forked', 0, []) expect(reason(journal)).toBe('stopped') await turn(journal, 'typed', 'client') @@ -259,10 +271,10 @@ describe("the queue's pause, derived from the journal", () => { }) describe('which cards a pause holds', () => { - it('only cards queued before the Stop row: one queued after it is a new instruction', async () => { + it('only cards queued before the Stop event: one queued after it is a new instruction', async () => { let journal = await open() await queueDraft(journal, 'before') - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) await queueDraft(journal, 'after') expect(held(journal)).toEqual([ ['before', true], @@ -290,7 +302,7 @@ describe('which cards a pause holds', () => { }, { messageId: 'steered', expect: 'waiting', settledByOp: null, hostInstance: HOST } ) - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) await queueDraft(journal, 'after') await journal.resolveDispatch({ clientMessageId: 'send-now-1', @@ -307,7 +319,7 @@ describe('which cards a pause holds', () => { it("the queue's own consume refuses a newer card while an older one is held: nothing overtakes", async () => { const journal = await open() await queueDraft(journal, 'held') - await journal.appendQueuePauseMark('stopped', 0) + await userStop(journal) await queueDraft(journal, 'newer') await expect( journal.appendSubmission( @@ -397,7 +409,10 @@ describe('which cards the pauses in force hold', () => { function pausesOver(cards: readonly Card[], stopped = 5) { return deriveQueuePauses({ epoch: 'epoch-1', - marks: { stoppedSequence: stopped, resumedSequence: 0 }, + marks: { + userStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, + resumedSequence: 0 + }, latestPersonTurnSequence: 0, cards, hostInstance: HOST, diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index ea20622ff0bb..25de034054a2 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -1,7 +1,7 @@ // The queue's pauses: a pure function of the journal fold and the cards, never a stored flag, so // nothing has to retire them. Each holds its own cards (`queuePauseHolding`). In force when: -// - 'stopped': the latest Stop row has no later Resume row, and no turn a person asked for was -// sent after it and accepted. A later Stop is simply the latest. +// - 'stopped': the latest person's Stop event (reason `user-stop`) has no later Resume row, and +// no turn a person asked for was sent after it and accepted. A later Stop is simply the latest. // - 'cleared': a card /clear carried into this conversation waits, and no person's turn or // Resume has happened here since. // - 'restarted': a waiting card was written by another host process, and no person's turn has @@ -10,13 +10,14 @@ // continuation, a launch prompt and the queue's own drain are `host` and never lift it. import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' -import type { JournalQueuePauseMark, JournalTombstoneRow } from './journal-row-schema' +import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' -/** The latest Stop and Resume rows, folded by the reducer; 0 when none. */ +/** The latest person's Stop event and Resume row, folded by the reducer. */ export type JournalQueuePauseMarks = { - stoppedSequence: number + userStop: { sequence: number; event: JournalStopEvent } | null + /** 0 when none. */ resumedSequence: number } @@ -35,26 +36,50 @@ type QueueCard = { } export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { - return { stoppedSequence: 0, resumedSequence: 0 } + return { userStop: null, resumedSequence: 0 } } export function foldJournalQueuePauseMark( marks: JournalQueuePauseMarks, - row: JournalTombstoneRow & { queuePause: JournalQueuePauseMark } + row: JournalTombstoneRow ): void { - if (row.queuePause === 'stopped') { - marks.stoppedSequence = row.seq - } else if (row.queuePause === 'resumed') { + // Only a person's Stop pauses; any other reason, or a newer build's shape, is left alone. + if (row.stopEvent?.reason === 'user-stop') { + marks.userStop = { sequence: row.seq, event: row.stopEvent } + } else if (row.queueResume !== undefined) { marks.resumedSequence = row.seq } } -/** The latest Stop still pauses: nothing a person did since, and no Resume, ended it. */ +/** The latest person's Stop still pauses: nothing a person did since, and no Resume, ended it. */ export function journalQueueStopHolds( marks: JournalQueuePauseMarks, latestPersonTurnSequence: number ): boolean { - return marks.stoppedSequence > Math.max(latestPersonTurnSequence, marks.resumedSequence) + return ( + marks.userStop !== null && + marks.userStop.sequence > Math.max(latestPersonTurnSequence, marks.resumedSequence) + ) +} + +/** What a rewind's new epoch restates so its pauses read as they did: a lift of /clear's pause (a + * person's turn or a Resume happened), then the Stop still in force, in that order so the lift + * never ends the Stop. */ +export type JournalQueuePauseRestatement = { + lifted: boolean + liveStop: JournalStopEvent | null +} + +export function journalQueuePauseRestatement( + marks: JournalQueuePauseMarks, + latestPersonTurnSequence: number +): JournalQueuePauseRestatement { + return { + lifted: latestPersonTurnSequence > 0 || marks.resumedSequence > 0, + liveStop: journalQueueStopHolds(marks, latestPersonTurnSequence) + ? (marks.userStop?.event ?? null) + : null + } } /** Every pause in force, in the order a card held by several names its reason. */ @@ -70,8 +95,8 @@ export function deriveQueuePauses(input: { }): DerivedQueuePause[] { const { epoch, marks, latestPersonTurnSequence } = input const pauses: DerivedQueuePause[] = [] - if (journalQueueStopHolds(marks, latestPersonTurnSequence)) { - pauses.push({ reason: 'stopped', since: { epoch, sequence: marks.stoppedSequence } }) + if (marks.userStop && journalQueueStopHolds(marks, latestPersonTurnSequence)) { + pauses.push({ reason: 'stopped', since: { epoch, sequence: marks.userStop.sequence } }) } const waiting = input.cards.filter((card) => card.state === 'waiting') const carried = waiting.filter((card) => card.carriedFrom !== null) diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts index 55782a3756f6..42af5f830420 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -15,7 +15,7 @@ import type { import type { AgentSessionJournalBatch } from '../../../shared/agent-session-wire' import { findSequenceGap } from '../agent-session-journal/journal-cursor' import { - isJournalQueuePauseRow, + isJournalStopOrResumeRow, type JournalRow } from '../agent-session-journal/journal-row-schema' @@ -53,8 +53,8 @@ export function projectJournalBatch(input: { } continue } - if (isJournalQueuePauseRow(row)) { - // Host-only: the queue pause it feeds is published beside the list, not as an item. + if (isJournalStopOrResumeRow(row)) { + // Host-only: no item; the queue pause it feeds is published beside the list. continue } if (row.kind === 'item' || row.kind === 'tombstone') { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 3055bb2d8789..2c0e37b8d286 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -46,7 +46,7 @@ import { type MutationPlan } from './structured-agent-session-mutation-plans' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' -import { runStopWithQueuePause } from './structured-agent-session-queued-stop' +import { runRecordedStop } from './structured-agent-session-queued-stop' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -167,17 +167,21 @@ export function cancelStructuredAgentSessionTurn( ...params, stopChild: () => context.stopAgent(params.envelope.sessionId) }) + const stopEvent = { + reason: 'user-stop' as const, + caller: caller.callerKey, + ...(params.turnId !== undefined ? { turnId: params.turnId } : {}) + } return mutateStructuredAgentSession( context, caller, params.envelope, { ...plan, - // Stop's queue step, the same for every client: once the Stop takes effect - // the queue is paused. The cards stay published; nothing is withdrawn and no - // text ever rides the answer. + // The same for every client: once the Stop takes effect its event is written, and the + // queue's pause follows from it. The cards stay published; no text rides the answer. run: (ctx) => - runStopWithQueuePause(ctx, async (tookEffect) => { + runRecordedStop(ctx, stopEvent, async (tookEffect) => { // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. const withdrawn = await ctx.journal.rejectQueuedSubmissions( ctx.fence, @@ -201,6 +205,7 @@ export function cancelStructuredAgentSessionTurn( } return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } } + // Before the interrupt, and before anything after it that ends the child. await tookEffect() return performCancel( { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index e2b90f05c403..fabc8d0611b5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -515,21 +515,21 @@ describe('Stop and Delete', () => { }) }) - it('a Stop whose row fails to write still interrupts; only the pause is lost, and it is reported', async () => { + it('a Stop whose event fails to write still interrupts; only the pause is lost, and it is reported', async () => { await workingSend() const queued = await send('kept by the stop', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { throw new Error('expected a queued receipt') } const record = vi - .spyOn(AgentSessionJournal.prototype, 'appendQueuePauseMark') + .spyOn(AgentSessionJournal.prototype, 'appendStopEvent') .mockRejectedValueOnce(new Error('disk full')) const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) try { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(rig.cancelTurn).toHaveBeenCalledTimes(1) expect(warned).toHaveBeenCalledWith( - expect.stringContaining("Stop's queue row"), + expect.stringContaining("Stop's event row"), expect.anything() ) } finally { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 39add2c2dd82..0760a55c264c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -389,8 +389,7 @@ describe('a card handed off after a restart', () => { structuredAgentSessionHostInstance() ) // With the Stop's pause gone, nothing else holds it: no restart happened since it was sent. - await journal.appendQueuePauseMark( - 'resumed', + await journal.appendQueueResume( structuredAgentSessionConversationFence(rig.store, HOST_TEST_SESSION) ) expect(structuredQueuePauses(journal)).toEqual([]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts index 7355655476c3..982d7cee9ed9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -1,6 +1,6 @@ // Whether the queue is paused, and why — derived from the journal and the cards -// (`queued-message-pause.ts`), never stored. Stop and Resume are journal rows; an -// explicit Resume lifts any pause. +// (`queued-message-pause.ts`), never stored. A Stop's event and a Resume are journal +// rows; an explicit Resume lifts any pause. import { randomUUID } from 'node:crypto' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -56,13 +56,13 @@ export async function adoptEndedRestartPause( /** Resume: a journal row that ends a Stop's or a /clear's pause, and adoption of a restart's * rows. Returns whether the queue was paused. */ export async function resumeStructuredQueue( - journal: Pick, + journal: Pick, fence: number ): Promise { if (structuredQueuePauses(journal).length === 0) { return false } - await journal.appendQueuePauseMark('resumed', fence) + await journal.appendQueueResume(fence) await journal.queuedMessages.adopt(hostInstance) return true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index e3b8682bd9c8..aa2c93ba15e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -1,4 +1,4 @@ -// Stop writes one journal row before it interrupts, and the queue's pause is derived from it: +// Stop writes one event row before it interrupts, and the queue's pause is derived from it: // through the real host, the cards queued before a Stop wait, a card queued after it sends // normally but never ahead of them, a withdrawn card comes back under it, a crash keeps it, it // never hides a restart's pause, and no stored pause is ever written. @@ -82,7 +82,7 @@ function pauseTables(): number { } } -describe("Stop's row", () => { +describe("Stop's event", () => { it('is written before the interrupt reaches the agent', async () => { await rig.workingSend() let pausedAtInterrupt: unknown = 'not interrupted' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index c58c11041802..7c1640fb3108 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -1,11 +1,12 @@ -// Stop's pause on the queue. A Stop never withdraws a draft and no text ever -// travels back over the wire: it appends ONE journal row where it took effect, and -// the queue's pause is derived from that row (`queued-message-pause.ts`) until a -// turn a person asked for is sent after it, or they Resume. The cards stay -// published, and Send-now sends one card without lifting the pause for the rest -// until that card's turn starts. The row is bookkeeping: a failure is reported -// and never gates the interrupt. +// A Stop's event and the queue. A Stop never withdraws a draft and no text ever +// travels back over the wire: where it takes effect it appends ONE Stop event +// (`JournalStopEvent`), and the queue's pause is derived from it +// (`queued-message-pause.ts`) until a turn a person asked for is sent after it, or +// they Resume. The cards stay published, and Send-now sends one card without lifting +// the pause for the rest until that card's turn starts. The event is bookkeeping: a +// failure is reported and never gates the interrupt. +import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { isUnsettledQueuedMessage, @@ -20,27 +21,30 @@ export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMes } /** - * Runs a Stop, which calls `tookEffect` where it takes effect — after it withdrew the - * queued sends, before it reaches the agent, or, reaching no agent, once it withdrew - * something. That writes the Stop's row, whatever the queue holds, so a card its - * interrupt later withdraws comes back to waiting under the pause. A Stop that throws - * before then changed nothing and wrote nothing. The drain cannot slip a card in - * between: the Stop runs on the drain's serialized lane, and the consume re-judges the - * pause in its own transaction. + * Runs a Stop, which calls `tookEffect` where it takes effect: after it withdrew the queued + * sends, and BEFORE the interrupt or anything that ends the child (the at-start stop, a running + * command's stop, a kill after the interrupt), or, reaching no agent, once it withdrew something. + * That writes the Stop's event, whatever the queue holds, so a card its interrupt later withdraws + * comes back to waiting under the pause, and whatever ends the child finds the event already + * written. A Stop that throws before then, or stops nothing, changed nothing and writes nothing. + * The drain cannot slip a card in between: the Stop runs on the drain's serialized lane. */ -export async function runStopWithQueuePause( +export async function runRecordedStop( ctx: AgentSessionTurnContext, + /** `turnId` absent: the turn running when the Stop takes effect, if any. */ + event: Omit, stop: (tookEffect: () => Promise) => Promise> ): Promise> { - return stop(() => - ctx.journal.appendQueuePauseMark('stopped', ctx.fence).then( + return stop(() => { + const turnId = event.turnId ?? ctx.journal.activeTurnId() ?? undefined + return ctx.journal.appendStopEvent({ ...event, ...(turnId ? { turnId } : {}) }, ctx.fence).then( () => undefined, (error: unknown) => { - console.warn("[agent-session] Stop's queue row skipped:", { + console.warn("[agent-session] Stop's event row skipped:", { sessionId: ctx.sessionId, error: error instanceof Error ? error.message : String(error) }) } ) - ) + }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts index 8b3078df8dfb..c145d6dc9c28 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts @@ -1,4 +1,4 @@ -/** Why a provider child ended. In memory only: never journaled, persisted or sent. */ +/** Why a provider child ended. In memory only, except the stop arms a Stop event journals. */ export type StructuredAgentSessionChildEndCause = | 'user-stop' /** The user closed this chat: its tab, its launch, or a `/clear` that replaces it. */ @@ -8,7 +8,8 @@ export type StructuredAgentSessionChildEndCause = | 'attach-failed' | 'evict' -/** Why the host asked a child to stop. The adapter carries it onto the `ended` it settles with. */ +/** Why the host asked a child to stop. The adapter carries it onto the `ended` it settles with, + * and a Stop event persists it (`JournalStopEvent.reason`), so never rename an arm. */ export type StructuredAgentSessionStopCause = Extract< StructuredAgentSessionChildEndCause, 'user-stop' | 'user-close' | 'host-stop' | 'evict' From e0087eef8f43b72d786de77b868e0b6cdc72c51e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:37:40 -0700 Subject: [PATCH 013/182] test(native-chat): a rewind keeps a lifted /clear pause lifted and restates the same Stop event --- .../queued-message-pause.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 5986787d25ad..89d83425f089 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -112,6 +112,21 @@ function held(journal: AgentSessionJournal): [string, boolean][] { .map((card) => [card.messageId, queuePauseHolding(pauses, card) !== undefined]) } +/** The Stop events stored in the live epoch, oldest first. */ +function stopEvents(): unknown[] { + const db = new Database(journalDatabasePath(root), { readonly: true }) + try { + return liveTestJournalRows(db, IDENTITY.sessionId).flatMap((stored) => { + const parsed = parseJournalRow(stored.rowJson) + return parsed.ok && parsed.row.kind === 'tombstone' && parsed.row.stopEvent + ? [parsed.row.stopEvent] + : [] + }) + } finally { + db.close() + } +} + /** Tables that could store a pause: none, the journal rows are the only record. */ function pauseTables(): number { const db = new Database(journalDatabasePath(root), { readonly: true }) @@ -268,6 +283,40 @@ describe("the queue's pause, derived from the journal", () => { await journal.replaceEpochItems('handle_forked', 0, []) expect(reason(journal)).toBeNull() }) + + it('the restated Stop is the same event: its reason, turn, caller and time', async () => { + const journal = await open() + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-7', caller: 'phone' }, 0) + const stopped = stopEvents() + await journal.replaceEpochItems('handle_forked', 0, []) + expect(stopEvents()).toEqual(stopped) + expect(stopped).toEqual([ + { reason: 'user-stop', turnId: 'turn-7', caller: 'phone', at: expect.any(Number) } + ]) + }) + + it.each([ + ["a person's turn", (journal: AgentSessionJournal) => turn(journal, 'typed', 'client')], + ['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)] + ])("a /clear pause %s already lifted stays lifted across a rewind", async (_name, lift) => { + const journal = await open() + await queueDraft(journal, 'carried', 'source-session') + await lift(journal) + expect(reason(journal)).toBeNull() + await journal.replaceEpochItems('handle_forked', 0, []) + expect(reason(journal)).toBeNull() + }) + + it('a lifted /clear pause and a later Stop are both restated, the Stop still in force', async () => { + const journal = await open() + await queueDraft(journal, 'carried', 'source-session') + await turn(journal, 'typed', 'client') + await userStop(journal) + await journal.replaceEpochItems('handle_forked', 0, []) + expect(journal.queuedMessages.pauses(HOST).map((pause) => pause.reason)).toEqual(['stopped']) + await journal.appendQueueResume(0) + expect(reason(journal)).toBeNull() + }) }) describe('which cards a pause holds', () => { From e44789976eb48c8a70b2c6fc867e9e9762406f32 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:38:41 -0700 Subject: [PATCH 014/182] test(native-chat): pin that Stop and Resume rows never reach apps or count as history --- .../journal-stop-and-resume-rows.test.ts | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts new file mode 100644 index 000000000000..a65dc4b6cf60 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts @@ -0,0 +1,114 @@ +// A Stop's event and a Resume ride tombstones, but are no history: an app never receives them, +// and they never count as the content that makes a rebuilt or provider-backed epoch usable. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { projectJournalBatch } from '../agent-session-wire/agent-session-journal-batch' +import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { startJournalRowFold } from './journal-open' +import { createJournalReducerState } from './journal-reducer' +import { buildJournalItemRow, journalRowBase } from './journal-row-builders' +import { + serializeJournalRow, + type AgentJournalEpochReason, + type JournalRow +} from './journal-row-schema' +import { buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-s', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} +const EPOCH = 'epoch-1' + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-stop-event-rows-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +/** One epoch's rows, folded as an open folds them. */ +function fold(rows: readonly JournalRow[], repairedFrom: number | null = null) { + const folding = startJournalRowFold({ sessionId: IDENTITY.sessionId, epoch: EPOCH, repairedFrom }) + for (const row of rows) { + folding.add({ seq: row.seq, rowJson: serializeJournalRow(row) }) + } + return folding.finish() +} + +/** An epoch row, then `after` built at the next sequences. */ +function epochWith( + reason: AgentJournalEpochReason, + after: readonly ('stop' | 'resume' | 'item')[] +): JournalRow[] { + const state = createJournalReducerState(IDENTITY.sessionId, EPOCH) + const rows: JournalRow[] = [ + { kind: 'epoch', reason, providerHandle: IDENTITY.providerHandle, ...journalRowBase(EPOCH, 1, 1, 1) } + ] + for (const kind of after) { + const place = { state, seq: rows.length + 1, fence: 1, ts: rows.length + 1 } + rows.push( + kind === 'stop' + ? buildJournalStopEventRow({ ...place, event: { reason: 'user-stop', at: place.ts } }) + : kind === 'resume' + ? buildJournalQueueResumeRow(place) + : buildJournalItemRow({ + ...place, + identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + body: { kind: 'status', text: 'history' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + } + return rows +} + +describe("a Stop's event and a Resume", () => { + it('never reach an app: the batch projection skips them', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'status', text: 'history' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const cursor = journal.cursor() + await journal.appendStopEvent({ reason: 'user-stop' }, 1) + await journal.appendQueueResume(1) + const since = journal.readSince(cursor) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + expect(since.rows).toHaveLength(2) + const projected = projectJournalBatch({ + rows: since.rows, + snapshot: journal.snapshot(), + afterSequence: cursor.sequence + }) + expect(projected).toMatchObject({ ok: true, batch: { items: [], removedItemIds: [] } }) + }) + + it('are no provider history: an unreconcilable epoch holding only them still reads corrupt', () => { + expect(fold(epochWith('unreconcilable_prefix', ['stop', 'resume'])).corrupt).toBe(true) + expect(fold(epochWith('unreconcilable_prefix', ['stop', 'item'])).corrupt).toBe(false) + }) + + it('are no rebuilt history: a pending repair followed only by them still reads corrupt', () => { + expect(fold(epochWith('handle_forked', ['stop', 'resume']), 2).corrupt).toBe(true) + expect(fold(epochWith('handle_forked', ['stop', 'item']), 2).corrupt).toBe(false) + }) +}) From 60500d8a0478a00d887f9d63ebf0610fec22a5f6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:39:02 -0700 Subject: [PATCH 015/182] test(native-chat): only a person's Stop event pauses the queue --- .../queued-message-pause.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 89d83425f089..5fa65441fb98 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -284,6 +284,18 @@ describe("the queue's pause, derived from the journal", () => { expect(reason(journal)).toBeNull() }) + it("only a person's Stop pauses: a host's, a close's or an unknown reason holds nothing", async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + for (const reason of ['user-close', 'host-stop', 'evict'] as const) { + await journal.appendStopEvent({ reason }, 0) + } + // A newer build's reason this one does not know. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately outside the type, as a newer build could write it. + await journal.appendStopEvent({ reason: 'future-reason' as 'evict' }, 0) + expect(reason(journal)).toBeNull() + }) + it('the restated Stop is the same event: its reason, turn, caller and time', async () => { const journal = await open() await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-7', caller: 'phone' }, 0) From 839f6f47664490db13206a1f60db9d2e81f90a92 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:40:36 -0700 Subject: [PATCH 016/182] test(native-chat): pin that a Stop's event precedes the interrupt and the at-start stop Through the real host: the event names the turn and who asked and is in the journal when the interrupt reaches the agent; at an agent still starting it is there before the start is ended and holds a card queued before it; an idle Stop writes one only when it withdrew a send; and the queue's claim re-judges a pause that landed after its pick. --- ...session-queued-message-rig.test-fixture.ts | 13 +- ...tructured-agent-session-stop-event.test.ts | 141 ++++++++++++++++++ 2 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 9cbef23a08e4..c4fc82f5cc99 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -36,8 +36,10 @@ export function eventually(assertion: () => void | Promise): Promise export type QueuedMessageTestRig = Awaited> /** `restartable`: a child started for a chat whose chain already names a thread resumes it, so a - * chat whose child closed or died can start another. */ -export async function createQueuedMessageTestRig(options: { restartable?: true } = {}) { + * chat whose child closed or died can start another. `starting`: every child stays starting. */ +export async function createQueuedMessageTestRig( + options: { restartable?: true; starting?: true } = {} +) { const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) resetHostTestOperationIds() // Admitted: the message is written and unanswered, so the session owes work @@ -56,6 +58,9 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } const cancelTurn: Mock = vi.fn(async () => ({ cancelled: true })) + const closeSession: Mock> = vi.fn( + async () => true + ) let events: StructuredAgentSessionEventSink | undefined const store = await openTestAgentSessionRecordStore(root) const makeHost = () => @@ -75,6 +80,7 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } spawnToken }, acquisitionGeneration: 'generation-1', + ...(options.starting ? { providerChildPhase: 'starting' as const } : {}), link: { linkId: `link-${fence}`, handle: { provider: 'codex' as const, threadId: THREAD }, @@ -86,7 +92,7 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } }, dispatch, awaitStarted, - closeSession: vi.fn(async () => true), + closeSession, releaseAcquisition: vi.fn(async () => true), compact, cancelTurn, @@ -293,6 +299,7 @@ export async function createQueuedMessageTestRig(options: { restartable?: true } }, dispatch, cancelTurn, + closeSession, awaitStarted, compact, finishCompact, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts new file mode 100644 index 000000000000..2023bff82952 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -0,0 +1,141 @@ +// A Stop's event, through the real host: written in the Stop's serialized step once it takes +// effect, before the interrupt and before anything that ends the child, naming the turn and who +// asked; never by a Stop that stopped nothing. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' +import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +/** Every Stop event in the live epoch, oldest first. */ +function stopEvents(): JournalStopEvent[] { + const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + return since.rows.flatMap((row) => + row.kind === 'tombstone' && row.stopEvent ? [row.stopEvent] : [] + ) +} + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) + } + return queued.value.queued.messageId +} + +/** Holds every start until the returned release. */ +function holdStart(): () => void { + let release: () => void = () => undefined + rig.awaitStarted.mockImplementation( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + return () => release() +} + +describe("a Stop's event", () => { + it('reaches the journal before the interrupt, naming the turn it stopped and who asked', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + let atInterrupt: JournalStopEvent[] = [] + rig.cancelTurn.mockImplementationOnce(async () => { + atInterrupt = stopEvents() + return { cancelled: true } + }) + const fields = { turnId: 'turn-named' } + const stopped = await rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), + ...fields + }) + expect(stopped).toMatchObject({ ok: true }) + expect(rig.cancelTurn).toHaveBeenCalledTimes(1) + expect(atInterrupt).toEqual([ + { + reason: 'user-stop', + turnId: 'turn-named', + caller: QUEUED_RIG_CALLER.callerKey, + at: expect.any(Number) + } + ]) + }) + + it('at an agent still starting, reaches the journal before the start is ended, and holds a card queued before it', async () => { + rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) + const release = holdStart() + rig.send('work on this') + const held = await queuedDraft('queued while it starts') + let atEnd: JournalStopEvent[] | undefined + rig.closeSession.mockImplementationOnce(async () => { + atEnd = stopEvents() + return true + }) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + release() + expect(rig.closeSession).toHaveBeenCalledTimes(1) + expect(atEnd).toEqual([ + { reason: 'user-stop', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } + ]) + expect(rig.cancelTurn).not.toHaveBeenCalled() + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(held)).toBeUndefined() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + + it('is written by an idle Stop only when it withdrew a send', async () => { + rig = await createQueuedMessageTestRig() + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(stopEvents()).toEqual([]) + const release = holdStart() + const waiting = rig.send('waits for the start') + await eventually(async () => expect(await rig.submission(waiting.id)).toBeDefined()) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + release() + expect(rig.cancelTurn).not.toHaveBeenCalled() + expect(stopEvents()).toEqual([ + { reason: 'user-stop', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } + ]) + }) + + it("holds a card when it lands between the queue's pick and its claim", async () => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + const draftId = await queuedDraft('picked by the drain') + const open = journal() + const appendSubmission = open.appendSubmission.bind(open) + let injected = false + // Stop and the drain share one serialized lane, so this interleaving is forced: a pause + // written after the drain chose the card must still hold it in the claim's transaction. + vi.spyOn(open, 'appendSubmission').mockImplementation(async (input, consume) => { + if (consume?.yieldsToPause && !injected) { + injected = true + await open.appendStopEvent({ reason: 'user-stop' }, input.fence) + } + return appendSubmission(input, consume) + }) + await rig.settleAccepted(working, 'working') + await eventually(() => expect(injected).toBe(true)) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) +}) From d5b48ef269a2b296906ff06a51e9dab26459cbf2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:42:15 -0700 Subject: [PATCH 017/182] test(native-chat): a card held at a starting agent is checked before the Stop's timing Also says precisely what the claim's in-transaction pause check defends against: the Stop and the drain share one serialized lane. --- .../agent-session-journal/journal-queued-messages.ts | 5 +++-- .../structured-agent-session-stop-event.test.ts | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index a565c007b017..fd50b974aada 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -237,8 +237,9 @@ export class JournalQueuedMessages { throw new AgentSessionJournalError('journal_closed', 'consume crossed database handles') } if (input.yieldsToPause) { - // Judged here, on the same queue as every journal append, by the drain's own rule, so a - // Stop row that landed after the drain chose this card still holds it. + // Judged again here, by the drain's own rule. Today a Stop cannot land between the drain's + // pick and this claim (both run on the session's serialized lane, held across the send), so + // this guards any pause-relevant row written off that lane from overtaking a held card. const cards = listQueuedMessages(db, this.deps.sessionId) const pauses = this.derivePauses(cards, input.yieldsToPause.hostInstance) if (nextSendableQueuedCard(pauses, cards)?.messageId !== input.messageId) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index 2023bff82952..b38a3a45af68 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -90,14 +90,14 @@ describe("a Stop's event", () => { }) expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) release() + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(held)).toBeUndefined() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(rig.cancelTurn).not.toHaveBeenCalled() expect(rig.closeSession).toHaveBeenCalledTimes(1) expect(atEnd).toEqual([ { reason: 'user-stop', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } ]) - expect(rig.cancelTurn).not.toHaveBeenCalled() - await new Promise((resolve) => setTimeout(resolve, 250)) - expect(await rig.handoff(held)).toBeUndefined() - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) it('is written by an idle Stop only when it withdrew a send', async () => { @@ -125,7 +125,7 @@ describe("a Stop's event", () => { // Stop and the drain share one serialized lane, so this interleaving is forced: a pause // written after the drain chose the card must still hold it in the claim's transaction. vi.spyOn(open, 'appendSubmission').mockImplementation(async (input, consume) => { - if (consume?.yieldsToPause && !injected) { + if (input.origin === 'host' && consume?.messageId === draftId && !injected) { injected = true await open.appendStopEvent({ reason: 'user-stop' }, input.fence) } From 5689a9d0708eeae78342f2d6eae1fa177e02fc58 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:43:37 -0700 Subject: [PATCH 018/182] test(native-chat): a released build keeps and folds a journal holding Stop events Replays this build's rows from the released build's own journal database: every row is kept, the history after the Stop still folds, and an older client is sent only removed ids no item uses. --- ...-session-stop-event-downgrade.unit.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts new file mode 100644 index 000000000000..35927a52bbd0 --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -0,0 +1,124 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../src/shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// A release that knows neither the Stop event nor the Resume marker: an unknown row kind would +// make it delete the journal from that row on, so both ride a tombstone it already reads. +const BASELINE_REF = 'v1.4.218' +const JOURNAL = 'src/main/native-chat/agent-session-journal' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-downgrade', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +type OldReplay = { + state: { items: Map } + readOnly: boolean + corrupt: boolean + malformedRows: number + truncateFrom?: number +} + +test("an older build keeps every row around a Stop's event and a Resume, and folds the rows after them", async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-stop-event-downgrade-')) + const journals = createTrackedJournalOpener() + try { + // This build: history, a person's Stop, a Resume, then more history. + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + const append = (ordinal: number, text: string) => + journal.appendItem( + item(ordinal), + { kind: 'status', text }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await append(0, 'before the Stop') + const beforeMarks = journal.cursor() + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) + await journal.appendQueueResume(1) + const afterMarks = journal.cursor() + await append(1, 'after the Stop') + const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const rows: JournalRow[] = since.rows + + // The older build, after a downgrade, replays the same rows from its own database. + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const [database, table, open, reducer, batch] = await Promise.all( + [ + `${JOURNAL}/journal-database.ts`, + `${JOURNAL}/journal-row-table.ts`, + `${JOURNAL}/journal-open.ts`, + `${JOURNAL}/journal-reducer.ts`, + 'src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts' + ].map((path) => importReleaseCheckoutModule(checkout, path)) + ) + /* oxlint-disable typescript/consistent-type-assertions -- SAFETY: the pinned release exports these functions with the signatures called below; a missing or changed one fails the test. */ + const openJournalDatabase = database.openJournalDatabase as (path: string) => { + db: { close: () => void } + } + const upsertJournalSessionRow = table.upsertJournalSessionRow as ( + ...args: [unknown, string, string, number] + ) => void + const insertJournalRow = table.insertJournalRow as (...args: [unknown, string, JournalRow]) => void + const replayJournal = open.replayJournal as ( + ...args: [unknown, boolean, string] + ) => OldReplay | null + const renderJournalState = reducer.renderJournalState as (state: unknown) => unknown + const projectJournalBatch = batch.projectJournalBatch as (input: { + rows: readonly JournalRow[] + snapshot: unknown + afterSequence: number + }) => { ok: boolean; batch?: { items: unknown[]; removedItemIds: string[] } } + /* oxlint-enable typescript/consistent-type-assertions */ + + const { db } = openJournalDatabase(join(directory, 'older-build-journal.sqlite')) + try { + upsertJournalSessionRow(db, IDENTITY.sessionId, journal.epoch, 1) + for (const row of rows) { + insertJournalRow(db, IDENTITY.sessionId, row) + } + const replayed = replayJournal(db, false, IDENTITY.sessionId) + expect(replayed).toMatchObject({ readOnly: false, corrupt: false, malformedRows: 0 }) + expect(replayed?.truncateFrom).toBeUndefined() + expect([...(replayed?.state.items.keys() ?? [])]).toHaveLength(2) + + // An older client is sent only ids no item uses, removed. + const projected = projectJournalBatch({ + rows: rows.filter( + (row) => row.seq > beforeMarks.sequence && row.seq <= afterMarks.sequence + ), + snapshot: renderJournalState(replayed?.state), + afterSequence: beforeMarks.sequence + }) + expect(projected.ok).toBe(true) + expect(projected.batch?.items).toEqual([]) + expect(projected.batch?.removedItemIds).toHaveLength(2) + const liveIds = new Set(journal.snapshot().items.map((entry) => entry.itemId)) + expect(projected.batch?.removedItemIds.some((id) => liveIds.has(id))).toBe(false) + } finally { + db.close() + } + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}) From 8c5523e1392dc0bef4ad05d2ca5fd83cc8a54cd6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:43:47 -0700 Subject: [PATCH 019/182] style(native-chat): format the Stop event changes --- .../journal-epoch-replacement.ts | 5 ++++- .../agent-session-journal/journal-row-schema.ts | 4 +--- .../journal-stop-and-resume-rows.test.ts | 12 ++++++++++-- .../agent-session-journal/journal-store.ts | 5 +---- .../queued-message-pause.test.ts | 2 +- .../agent-session-stop-event-downgrade.unit.test.ts | 4 +++- 6 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 3f0c7a1e7796..70efc8912837 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -16,7 +16,10 @@ import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' -import { buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' +import { + buildJournalQueueResumeRow, + buildJournalStopEventRow +} from './journal-stop-and-resume-rows' import type { JournalQueuePauseRestatement } from './queued-message-pause' import { deleteJournalEpochRows, 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 ddda8809689c..df04dd1e22eb 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 @@ -96,9 +96,7 @@ export type JournalStopEvent = { /** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ export function isJournalStopOrResumeRow(row: JournalRow): row is JournalTombstoneRow { - return ( - row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) - ) + return row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) } /** The write-ahead row. Durable BEFORE the adapter dispatches anything; it diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts index a65dc4b6cf60..8eee42a70d03 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts @@ -19,7 +19,10 @@ import { type AgentJournalEpochReason, type JournalRow } from './journal-row-schema' -import { buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' +import { + buildJournalQueueResumeRow, + buildJournalStopEventRow +} from './journal-stop-and-resume-rows' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-s', @@ -58,7 +61,12 @@ function epochWith( ): JournalRow[] { const state = createJournalReducerState(IDENTITY.sessionId, EPOCH) const rows: JournalRow[] = [ - { kind: 'epoch', reason, providerHandle: IDENTITY.providerHandle, ...journalRowBase(EPOCH, 1, 1, 1) } + { + kind: 'epoch', + reason, + providerHandle: IDENTITY.providerHandle, + ...journalRowBase(EPOCH, 1, 1, 1) + } ] for (const kind of after) { const place = { state, seq: rows.length + 1, fence: 1, ts: rows.length + 1 } 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 2e37ca7692b8..08113e3bc7db 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -312,10 +312,7 @@ export class AgentSessionJournal { } /** A Stop that took effect, timed by its row (`JournalStopEvent`). */ - appendStopEvent( - event: Omit, - fence: number - ): Promise { + appendStopEvent(event: Omit, fence: number): Promise { return this.rowWriter.append(journalStopEventRowBuilder(() => this.state, event, fence)) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 5fa65441fb98..6808a5893c30 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -310,7 +310,7 @@ describe("the queue's pause, derived from the journal", () => { it.each([ ["a person's turn", (journal: AgentSessionJournal) => turn(journal, 'typed', 'client')], ['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)] - ])("a /clear pause %s already lifted stays lifted across a rewind", async (_name, lift) => { + ])('a /clear pause %s already lifted stays lifted across a rewind', async (_name, lift) => { const journal = await open() await queueDraft(journal, 'carried', 'source-session') await lift(journal) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 35927a52bbd0..18dc1a32b9a5 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -78,7 +78,9 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol const upsertJournalSessionRow = table.upsertJournalSessionRow as ( ...args: [unknown, string, string, number] ) => void - const insertJournalRow = table.insertJournalRow as (...args: [unknown, string, JournalRow]) => void + const insertJournalRow = table.insertJournalRow as ( + ...args: [unknown, string, JournalRow] + ) => void const replayJournal = open.replayJournal as ( ...args: [unknown, boolean, string] ) => OldReplay | null From 5c38ea648ac2fd275df627ec15d92da6d31f9143 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:44:23 -0700 Subject: [PATCH 020/182] test(native-chat): type the released build's exports through one checked helper --- ...-session-stop-event-downgrade.unit.test.ts | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 18dc1a32b9a5..c4a5761c367e 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -28,6 +28,16 @@ function item(ordinal: number): AgentJournalItemIdentity { return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } } +/** A function the pinned release exports, typed as the caller calls it. */ +function releaseExport(module: Record, name: string): T { + const value = module[name] + if (typeof value !== 'function') { + throw new Error(`the pinned release exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a function the pinned release exports; each caller names the signature it calls, and a changed one fails the test. + return value as T +} + type OldReplay = { state: { items: Map } readOnly: boolean @@ -71,26 +81,31 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol 'src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts' ].map((path) => importReleaseCheckoutModule(checkout, path)) ) - /* oxlint-disable typescript/consistent-type-assertions -- SAFETY: the pinned release exports these functions with the signatures called below; a missing or changed one fails the test. */ - const openJournalDatabase = database.openJournalDatabase as (path: string) => { - db: { close: () => void } - } - const upsertJournalSessionRow = table.upsertJournalSessionRow as ( - ...args: [unknown, string, string, number] - ) => void - const insertJournalRow = table.insertJournalRow as ( - ...args: [unknown, string, JournalRow] - ) => void - const replayJournal = open.replayJournal as ( - ...args: [unknown, boolean, string] - ) => OldReplay | null - const renderJournalState = reducer.renderJournalState as (state: unknown) => unknown - const projectJournalBatch = batch.projectJournalBatch as (input: { - rows: readonly JournalRow[] - snapshot: unknown - afterSequence: number - }) => { ok: boolean; batch?: { items: unknown[]; removedItemIds: string[] } } - /* oxlint-enable typescript/consistent-type-assertions */ + const openJournalDatabase = releaseExport<(path: string) => { db: { close: () => void } }>( + database, + 'openJournalDatabase' + ) + const upsertJournalSessionRow = releaseExport< + (...args: [unknown, string, string, number]) => void + >(table, 'upsertJournalSessionRow') + const insertJournalRow = releaseExport<(...args: [unknown, string, JournalRow]) => void>( + table, + 'insertJournalRow' + ) + const replayJournal = releaseExport<(...args: [unknown, boolean, string]) => OldReplay | null>( + open, + 'replayJournal' + ) + const renderJournalState = releaseExport<(state: unknown) => unknown>( + reducer, + 'renderJournalState' + ) + const projectJournalBatch = releaseExport< + (input: { rows: readonly JournalRow[]; snapshot: unknown; afterSequence: number }) => { + ok: boolean + batch?: { items: unknown[]; removedItemIds: string[] } + } + >(batch, 'projectJournalBatch') const { db } = openJournalDatabase(join(directory, 'older-build-journal.sqlite')) try { From 26e9419f8bf957bf6576bae01337997cee9dca1f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:54:55 -0700 Subject: [PATCH 021/182] fix(native-chat): the Stop/Resume row guard narrows to those tombstones only --- .../agent-session-journal/journal-row-schema.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 df04dd1e22eb..14d36b4a1926 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 @@ -95,7 +95,14 @@ export type JournalStopEvent = { } /** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ -export function isJournalStopOrResumeRow(row: JournalRow): row is JournalTombstoneRow { +/** A tombstone that carries a Stop event or a Resume mark instead of removing an item. */ +export type JournalStopOrResumeRow = JournalTombstoneRow & + ( + | { stopEvent: NonNullable } + | { queueResume: NonNullable } + ) + +export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrResumeRow { return row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) } From 806765bb1218741de6bbed3128d0c38748354843 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:19:21 -0700 Subject: [PATCH 022/182] test(native-chat): run the Stop-event downgrade test in CI, and cover a writable downgrade The Stop-event downgrade test ran in no CI lane: unit shards exclude the cross-version folder, and the cross-version lane runs a fixed file list that did not name it. It is now on that list. Its only case replayed the rows into a release's own fresh database, because that release cannot open the current host database. A second case opens the journal this build wrote with a main build that shares the database: it opens writable, keeps every row, appends, and this build then reopens it with the person's Stop still pausing the queue. --- .github/workflows/pr.yml | 1 + ...-session-stop-event-downgrade.unit.test.ts | 85 ++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c2b35b7bce1f..76d17ce616df 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -862,6 +862,7 @@ jobs: tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts + tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts managed_hook_node18: diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index c4a5761c367e..0a9b5ea5f35d 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -7,7 +7,12 @@ import { type AgentJournalItemIdentity, type AgentSessionJournalIdentity } from '../../../src/shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import Database from '../../../src/main/sqlite/sync-database' +import { journalDatabasePath } from '../../../src/main/native-chat/agent-session-journal/journal-host-database' +import { + createTrackedJournalOpener, + liveTestJournalRows +} from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' @@ -15,6 +20,9 @@ import { importReleaseCheckoutModule, materializeReleaseCheckout } from './relea // make it delete the journal from that row on, so both ride a tombstone it already reads. const BASELINE_REF = 'v1.4.218' const JOURNAL = 'src/main/native-chat/agent-session-journal' +// A main build that shares this one's host database and schema version, so a downgrade to it opens +// the journal writable. No release tag has that database yet; move to the first one that does. +const WRITABLE_BASELINE_REF = '3727100cc9dbcea6201f8a3e506676a3c4b53b18' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-downgrade', @@ -139,3 +147,78 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol rmSync(directory, { recursive: true, force: true }) } }) + +type OlderJournal = { + isReadOnly: boolean + cursor: () => { epoch: string; sequence: number } + snapshot: () => { items: { itemId: string }[] } + appendItem: (...args: [AgentJournalItemIdentity, unknown, unknown]) => Promise +} + +type OlderOpener = { + open: (options: { + identity: AgentSessionJournalIdentity + stateDirectory: string + }) => Promise + closeAll: () => Promise +} + +function storedRows(directory: string): string[] { + const db = new Database(journalDatabasePath(directory), { readonly: true }) + try { + return liveTestJournalRows(db, IDENTITY.sessionId).map((row) => row.rowJson) + } finally { + db.close() + } +} + +test("an older build opens this build's journal writable and appends to it; the pause survives the round trip", async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-stop-event-writable-downgrade-')) + const journals = createTrackedJournalOpener() + const itemIds = (journal: Pick) => + journal.snapshot().items.map((entry) => entry.itemId) + try { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + await journal.appendItem(item(0), { kind: 'status', text: 'before the Stop' }, scope) + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) + await journal.appendItem(item(1), { kind: 'status', text: 'after the Stop' }, scope) + const wrote = { cursor: journal.cursor(), items: itemIds(journal) } + expect(journal.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ + 'stopped' + ]) + await journals.closeAll() + const rowsBefore = storedRows(directory) + + const checkout = await materializeReleaseCheckout(WRITABLE_BASELINE_REF) + const support = await importReleaseCheckoutModule( + checkout, + `${JOURNAL}/journal-host-database-test-support.ts` + ) + const older = releaseExport<() => OlderOpener>(support, 'createTrackedJournalOpener')() + try { + const downgraded = await older.open({ identity: IDENTITY, stateDirectory: directory }) + expect(downgraded.isReadOnly).toBe(false) + expect(downgraded.cursor()).toEqual(wrote.cursor) + expect(itemIds(downgraded)).toEqual(wrote.items) + await downgraded.appendItem(item(2), { kind: 'status', text: 'the older build' }, scope) + } finally { + await older.closeAll() + } + const rowsAfter = storedRows(directory) + expect(rowsAfter.slice(0, rowsBefore.length)).toEqual(rowsBefore) + expect(rowsAfter).toHaveLength(rowsBefore.length + 1) + + // Upgraded again: the older build's row folds, and the person's Stop still pauses the queue. + const upgraded = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + expect(upgraded.isReadOnly).toBe(false) + expect(upgraded.cursor().sequence).toBe(wrote.cursor.sequence + 1) + expect(itemIds(upgraded)).toEqual([...wrote.items, 'codex:thread-1:turn-1:2']) + expect(upgraded.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ + 'stopped' + ]) + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}, 120_000) From 8ec0fbf0e8ad9b8d931efac2ed2c0f74cd0b61f4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:25:56 -0700 Subject: [PATCH 023/182] fix(native-chat): a Stop that stops nothing new writes no Stop event A Stop reaching a running agent wrote a Stop event on every press. Two presses before the first interrupt landed wrote two events, so a card queued between them counted as before the latest Stop and was held, though a card queued after a Stop should send normally. A Stop naming a turn that had already ended, as a phone sends late, also wrote an event for a turn it never stopped. It now writes one only when it withdrew a queued send, or stops something no event records yet: not a turn the journal no longer runs, and not the live turn a Stop still in force already names, unless a card was handed over into it since, which this Stop's interrupt sends back and must hold. The interrupt and the "already finished" note are unchanged. A Stop at a starting agent still always writes. --- .../journal-queued-messages.ts | 10 +++- .../queued-message-pause.ts | 12 +++-- ...structured-agent-session-host-mutations.ts | 6 ++- .../structured-agent-session-queued-stop.ts | 34 +++++++++++++- ...tructured-agent-session-stop-event.test.ts | 46 +++++++++++++++++++ 5 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index fd50b974aada..e2fd2c7e5a15 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -18,8 +18,10 @@ import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' import { deriveQueuePauses, + journalUserStopInForce, nextSendableQueuedCard, - type DerivedQueuePause + type DerivedQueuePause, + type JournalQueuePauseMarks } from './queued-message-pause' import { consumeQueuedMessageInTransaction, @@ -144,6 +146,12 @@ export class JournalQueuedMessages { return this.derivePauses(this.list(), hostInstance) } + /** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ + userStopInForce(): JournalQueuePauseMarks['userStop'] { + const state = this.deps.state() + return journalUserStopInForce(state.queuePauseMarks, state.latestPersonTurnSequence) + } + private derivePauses( cards: readonly QueuedMessageRow[], hostInstance: string diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 25de034054a2..18f5c2635f4d 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -62,6 +62,14 @@ export function journalQueueStopHolds( ) } +/** The latest person's Stop while it still pauses, with where it was written; else null. */ +export function journalUserStopInForce( + marks: JournalQueuePauseMarks, + latestPersonTurnSequence: number +): JournalQueuePauseMarks['userStop'] { + return journalQueueStopHolds(marks, latestPersonTurnSequence) ? marks.userStop : null +} + /** What a rewind's new epoch restates so its pauses read as they did: a lift of /clear's pause (a * person's turn or a Resume happened), then the Stop still in force, in that order so the lift * never ends the Stop. */ @@ -76,9 +84,7 @@ export function journalQueuePauseRestatement( ): JournalQueuePauseRestatement { return { lifted: latestPersonTurnSequence > 0 || marks.resumedSequence > 0, - liveStop: journalQueueStopHolds(marks, latestPersonTurnSequence) - ? (marks.userStop?.event ?? null) - : null + liveStop: journalUserStopInForce(marks, latestPersonTurnSequence)?.event ?? null } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 2c0e37b8d286..e85b0f615da0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -46,7 +46,7 @@ import { type MutationPlan } from './structured-agent-session-mutation-plans' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' -import { runRecordedStop } from './structured-agent-session-queued-stop' +import { runRecordedStop, stopReachesUnrecordedWork } from './structured-agent-session-queued-stop' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -206,7 +206,9 @@ export function cancelStructuredAgentSessionTurn( return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } } // Before the interrupt, and before anything after it that ends the child. - await tookEffect() + if (withdrawn.length > 0 || (await stopReachesUnrecordedWork(ctx, params.turnId))) { + await tookEffect() + } return performCancel( { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index 7c1640fb3108..d5a83d3680d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -13,6 +13,7 @@ import { type QueuedMessageRow } from '../agent-session-journal/queued-message-table' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import { isMainAgentWorkingOnceFlushed } from './structured-agent-session-turns-cancel' /** The one unsettled-card predicate /clear's carry and the budget share: * waiting or returned. Pending/unknown/accepted deliveries stay outside it. */ @@ -26,7 +27,8 @@ export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMes * command's stop, a kill after the interrupt), or, reaching no agent, once it withdrew something. * That writes the Stop's event, whatever the queue holds, so a card its interrupt later withdraws * comes back to waiting under the pause, and whatever ends the child finds the event already - * written. A Stop that throws before then, or stops nothing, changed nothing and writes nothing. + * written. A Stop that throws before then, or stops nothing (`stopReachesUnrecordedWork`), changed + * nothing and writes nothing. * The drain cannot slip a card in between: the Stop runs on the drain's serialized lane. */ export async function runRecordedStop( @@ -48,3 +50,33 @@ export async function runRecordedStop( ) }) } + +/** + * Whether a Stop reaching a running agent stops anything no Stop event records yet. Not when it + * names a turn already over (a late Stop from a phone), nor when it repeats the Stop still in force + * on the same turn with nothing handed over since: a card queued between the presses then sends + * normally, as after one Stop. + */ +export async function stopReachesUnrecordedWork( + ctx: Pick, + namedTurnId: string | undefined +): Promise { + const live = ctx.journal.activeTurnId() + // No turn published yet while the agent works: the named one may still be opening. + if ( + namedTurnId !== undefined && + namedTurnId !== live && + (live !== null || !(await isMainAgentWorkingOnceFlushed(ctx))) + ) { + return false + } + const inForce = ctx.journal.queuedMessages.userStopInForce() + return ( + inForce === null || + (inForce.event.turnId ?? null) !== live || + // A steer handed over after that Stop comes back to waiting, so this Stop must hold it. + ctx.journal + .pendingSubmissions() + .some((entry) => (entry.acceptedSequence ?? Infinity) > inForce.sequence) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index b38a3a45af68..d6f22bf7adf4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -3,6 +3,8 @@ // asked; never by a Stop that stopped nothing. import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' import { @@ -115,6 +117,50 @@ describe("a Stop's event", () => { ]) }) + it('a second press while the first interrupt lands writes nothing: a card queued between them sends normally', async () => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + const between = await queuedDraft('queued between the presses') + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(stopEvents()).toHaveLength(1) + await rig.settleAccepted(working, 'stopped') + await eventually(async () => expect(await rig.handoff(between)).toBeDefined()) + }) + + it('a second press after a card was sent into the turn writes again, and holds that card', async () => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + await rig.stop() + const steered = await queuedDraft('sent into the turn between the presses') + await rig.sendNow(steered) + await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) + await rig.stop() + expect(stopEvents()).toHaveLength(2) + await rig.host.settleLateDispatch({ + sessionId: HOST_TEST_SESSION, + clientMessageId: await rig.handoffId(steered), + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) + await rig.settleAccepted(working, 'stopped') + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.drafts()).toEqual([{ messageId: steered, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + + it('names a turn already over, as a late Stop from a phone does: writes nothing', async () => { + rig = await createQueuedMessageTestRig() + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false }) + const fields = { turnId: 'turn-already-over' } + const stopped = await rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), + ...fields + }) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(stopEvents()).toEqual([]) + }) + it("holds a card when it lands between the queue's pick and its claim", async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() From 722e9640a0701b8d02eb67499691dc5a303b3ea9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:25:56 -0700 Subject: [PATCH 024/182] test(native-chat): pin that a later host, eviction or close Stop never lifts a person's Stop --- .../queued-message-pause.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 6808a5893c30..d9d4185e9480 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -296,6 +296,17 @@ describe("the queue's pause, derived from the journal", () => { expect(reason(journal)).toBeNull() }) + it("a later Stop by the host, an eviction or a close never lifts a person's Stop", async () => { + const journal = await open() + await queueDraft(journal, 'held') + await journal.appendStopEvent({ reason: 'user-stop' }, 0) + for (const reason of ['host-stop', 'evict', 'user-close'] as const) { + await journal.appendStopEvent({ reason }, 0) + } + expect(reason(journal)).toBe('stopped') + expect(held(journal)).toEqual([['held', true]]) + }) + it('the restated Stop is the same event: its reason, turn, caller and time', async () => { const journal = await open() await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-7', caller: 'phone' }, 0) From 74adc162d771cd7579714c85f83a2e65e3c1fb81 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:25:56 -0700 Subject: [PATCH 025/182] chore(native-chat): put each Stop-row doc on its own declaration, and say only user-stop is journaled --- .../native-chat/agent-session-journal/journal-row-schema.ts | 2 +- .../agent-session-wire/structured-agent-session-stop-cause.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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 14d36b4a1926..e65d5919b39b 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 @@ -94,7 +94,6 @@ export type JournalStopEvent = { caller?: string } -/** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ /** A tombstone that carries a Stop event or a Resume mark instead of removing an item. */ export type JournalStopOrResumeRow = JournalTombstoneRow & ( @@ -102,6 +101,7 @@ export type JournalStopOrResumeRow = JournalTombstoneRow & | { queueResume: NonNullable } ) +/** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrResumeRow { return row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts index c145d6dc9c28..58a36546a046 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts @@ -1,4 +1,5 @@ -/** Why a provider child ended. In memory only, except the stop arms a Stop event journals. */ +/** Why a provider child ended. In memory only, except `user-stop`, the one arm a Stop event + * journals so far. */ export type StructuredAgentSessionChildEndCause = | 'user-stop' /** The user closed this chat: its tab, its launch, or a `/clear` that replaces it. */ From 4750645a5a71f2385da7763a455051233d6aeba1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:29:06 -0700 Subject: [PATCH 026/182] fix(native-chat): any later Stop event ends a person's Stop pause A person's Stop paused the queue until their next accepted turn or Resume, and a later Stop of another reason (the host stopping the agent, an eviction, a close) was ignored. Now the pause is the latest Stop event's: a later Stop of any reason ends a person's pause, and only a person's Stop pauses. The fold keeps the latest Stop event whatever its reason. An eviction of a resting chat writes no Stop event (a Stop that stops nothing writes nothing), so it cannot release held cards; a test pins that no event means no lift. --- .../journal-queued-messages.ts | 2 +- .../queued-message-pause.test.ts | 22 +++++++++---- .../queued-message-pause.ts | 32 ++++++++++--------- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index e2fd2c7e5a15..b8fcafc0b6c0 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -147,7 +147,7 @@ export class JournalQueuedMessages { } /** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ - userStopInForce(): JournalQueuePauseMarks['userStop'] { + userStopInForce(): JournalQueuePauseMarks['latestStop'] { const state = this.deps.state() return journalUserStopInForce(state.queuePauseMarks, state.latestPersonTurnSequence) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index d9d4185e9480..ebea1552f7dc 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -296,13 +296,23 @@ describe("the queue's pause, derived from the journal", () => { expect(reason(journal)).toBeNull() }) - it("a later Stop by the host, an eviction or a close never lifts a person's Stop", async () => { + it("a later Stop of any reason ends a person's Stop, without pausing itself", async () => { const journal = await open() - await queueDraft(journal, 'held') - await journal.appendStopEvent({ reason: 'user-stop' }, 0) - for (const reason of ['host-stop', 'evict', 'user-close'] as const) { - await journal.appendStopEvent({ reason }, 0) + await queueDraft(journal, 'draft-1') + for (const later of ['host-stop', 'evict', 'user-close'] as const) { + await userStop(journal) + expect(held(journal)).toEqual([['draft-1', true]]) + await journal.appendStopEvent({ reason: later }, 0) + expect(reason(journal)).toBeNull() + expect(held(journal)).toEqual([['draft-1', false]]) } + }) + + it("no Stop event after a person's Stop means no lift: an idle eviction writes none", async () => { + const journal = await open() + await queueDraft(journal, 'held') + await userStop(journal) + // An eviction writer must write only when it ends a running turn, or it would lift this pause. expect(reason(journal)).toBe('stopped') expect(held(journal)).toEqual([['held', true]]) }) @@ -482,7 +492,7 @@ describe('which cards the pauses in force hold', () => { return deriveQueuePauses({ epoch: 'epoch-1', marks: { - userStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, + latestStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, resumedSequence: 0 }, latestPersonTurnSequence: 0, diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 18f5c2635f4d..012b99aa203b 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -1,7 +1,8 @@ // The queue's pauses: a pure function of the journal fold and the cards, never a stored flag, so // nothing has to retire them. Each holds its own cards (`queuePauseHolding`). In force when: -// - 'stopped': the latest person's Stop event (reason `user-stop`) has no later Resume row, and -// no turn a person asked for was sent after it and accepted. A later Stop is simply the latest. +// - 'stopped': the latest Stop event is a person's (reason `user-stop`), with no later Resume row +// and no turn a person asked for sent after it and accepted. A later Stop of any reason +// supersedes it; only a person's pauses. // - 'cleared': a card /clear carried into this conversation waits, and no person's turn or // Resume has happened here since. // - 'restarted': a waiting card was written by another host process, and no person's turn has @@ -14,9 +15,9 @@ import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' -/** The latest person's Stop event and Resume row, folded by the reducer. */ +/** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ export type JournalQueuePauseMarks = { - userStop: { sequence: number; event: JournalStopEvent } | null + latestStop: { sequence: number; event: JournalStopEvent } | null /** 0 when none. */ resumedSequence: number } @@ -36,29 +37,29 @@ type QueueCard = { } export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { - return { userStop: null, resumedSequence: 0 } + return { latestStop: null, resumedSequence: 0 } } export function foldJournalQueuePauseMark( marks: JournalQueuePauseMarks, row: JournalTombstoneRow ): void { - // Only a person's Stop pauses; any other reason, or a newer build's shape, is left alone. - if (row.stopEvent?.reason === 'user-stop') { - marks.userStop = { sequence: row.seq, event: row.stopEvent } + if (row.stopEvent !== undefined) { + marks.latestStop = { sequence: row.seq, event: row.stopEvent } } else if (row.queueResume !== undefined) { marks.resumedSequence = row.seq } } -/** The latest person's Stop still pauses: nothing a person did since, and no Resume, ended it. */ +/** A person's Stop still pauses: it is the latest Stop, and nothing a person did since, and no + * Resume, ended it. A later Stop for any other reason ends it without pausing itself. */ export function journalQueueStopHolds( marks: JournalQueuePauseMarks, latestPersonTurnSequence: number ): boolean { return ( - marks.userStop !== null && - marks.userStop.sequence > Math.max(latestPersonTurnSequence, marks.resumedSequence) + marks.latestStop?.event.reason === 'user-stop' && + marks.latestStop.sequence > Math.max(latestPersonTurnSequence, marks.resumedSequence) ) } @@ -66,8 +67,8 @@ export function journalQueueStopHolds( export function journalUserStopInForce( marks: JournalQueuePauseMarks, latestPersonTurnSequence: number -): JournalQueuePauseMarks['userStop'] { - return journalQueueStopHolds(marks, latestPersonTurnSequence) ? marks.userStop : null +): JournalQueuePauseMarks['latestStop'] { + return journalQueueStopHolds(marks, latestPersonTurnSequence) ? marks.latestStop : null } /** What a rewind's new epoch restates so its pauses read as they did: a lift of /clear's pause (a @@ -101,8 +102,9 @@ export function deriveQueuePauses(input: { }): DerivedQueuePause[] { const { epoch, marks, latestPersonTurnSequence } = input const pauses: DerivedQueuePause[] = [] - if (marks.userStop && journalQueueStopHolds(marks, latestPersonTurnSequence)) { - pauses.push({ reason: 'stopped', since: { epoch, sequence: marks.userStop.sequence } }) + const stop = journalUserStopInForce(marks, latestPersonTurnSequence) + if (stop) { + pauses.push({ reason: 'stopped', since: { epoch, sequence: stop.sequence } }) } const waiting = input.cards.filter((card) => card.state === 'waiting') const carried = waiting.filter((card) => card.carriedFrom !== null) From c0d96898dc848bf692af42cb4565ac4f8f9c1427 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:49:47 -0700 Subject: [PATCH 027/182] fix(native-chat): a second Stop press is a repeat even when the first came before the turn showed A Stop pressed before the agent's turn shows in the journal (before Claude's echo, or before Codex opens the turn) records no turn. A second press once the turn showed compared that missing turn with the live one, wrote a second Stop event, and held a card queued between the presses. A repeat is now judged by what was sent since the Stop in force: with nothing sent after it (a refused send aside), a Stop that named no turn, or named the live one, is repeated and writes nothing. Anything sent since and not refused, including a send whose fate is unknown, makes the new press write, since its interrupt may send that card back to waiting. Tests: the two-press case across the turn showing; a steer between the presses settled unknown; and a Stop naming a turn that ended while the next card is sent but shows no turn yet, which writes and holds that card. The fold test that claimed an eviction path is renamed. --- .../queued-message-pause.test.ts | 2 +- .../structured-agent-session-queued-stop.ts | 26 ++++--- ...tructured-agent-session-stop-event.test.ts | 75 +++++++++++++++++-- 3 files changed, 87 insertions(+), 16 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index ebea1552f7dc..7208755b4193 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -308,7 +308,7 @@ describe("the queue's pause, derived from the journal", () => { } }) - it("no Stop event after a person's Stop means no lift: an idle eviction writes none", async () => { + it("a person's Stop with no later Stop event is not lifted", async () => { const journal = await open() await queueDraft(journal, 'held') await userStop(journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index d5a83d3680d4..72df6fe2ac6a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -54,8 +54,8 @@ export async function runRecordedStop( /** * Whether a Stop reaching a running agent stops anything no Stop event records yet. Not when it * names a turn already over (a late Stop from a phone), nor when it repeats the Stop still in force - * on the same turn with nothing handed over since: a card queued between the presses then sends - * normally, as after one Stop. + * with nothing sent since, on the same turn or one that opened after a Stop pressed before any + * turn showed: a card queued between the presses then sends normally, as after one Stop. */ export async function stopReachesUnrecordedWork( ctx: Pick, @@ -71,12 +71,18 @@ export async function stopReachesUnrecordedWork( return false } const inForce = ctx.journal.queuedMessages.userStopInForce() - return ( - inForce === null || - (inForce.event.turnId ?? null) !== live || - // A steer handed over after that Stop comes back to waiting, so this Stop must hold it. - ctx.journal - .pendingSubmissions() - .some((entry) => (entry.acceptedSequence ?? Infinity) > inForce.sequence) - ) + if (inForce === null) { + return true + } + // Sent after that Stop and not refused, even if its fate is unknown: this interrupt may send it + // back to waiting, so this Stop must hold it. A send with no sequence is an older host's. + const sentSince = ctx.journal + .submissions() + .some( + (entry) => + entry.dispatchState !== 'rejected' && + entry.acceptedSequence !== undefined && + entry.acceptedSequence > inForce.sequence + ) + return sentSince || (inForce.event.turnId !== undefined && inForce.event.turnId !== live) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index d6f22bf7adf4..88c2e6d74363 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' import { @@ -45,6 +46,24 @@ async function queuedDraft(text: string): Promise { return queued.value.queued.messageId } +/** The provider's turn row for the working send, which may land after a Stop. */ +async function turnRow(turnId: string, state: 'running' | 'interrupted'): Promise { + await journal().appendItem( + { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, + { kind: 'turn', turnId, state, startedAt: 1 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +function withdraw(clientMessageId: string) { + return rig.host.settleLateDispatch({ + sessionId: HOST_TEST_SESSION, + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) +} + /** Holds every start until the returned release. */ function holdStart(): () => void { let release: () => void = () => undefined @@ -128,21 +147,46 @@ describe("a Stop's event", () => { await eventually(async () => expect(await rig.handoff(between)).toBeDefined()) }) - it('a second press after a card was sent into the turn writes again, and holds that card', async () => { + it('a second press once the turn shows, after a first before it did, writes nothing: a card queued between sends', async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() await rig.stop() + const between = await queuedDraft('queued between the presses') + await turnRow('turn-1', 'running') + await rig.stop() + expect(stopEvents()).toHaveLength(1) + await rig.settleAccepted(working, 'stopped') + await turnRow('turn-1', 'interrupted') + await eventually(async () => expect(await rig.handoff(between)).toBeDefined()) + }) + + it('a second press after a card sent into the turn settled unknown writes again', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + await rig.stop() const steered = await queuedDraft('sent into the turn between the presses') await rig.sendNow(steered) await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) - await rig.stop() - expect(stopEvents()).toHaveLength(2) await rig.host.settleLateDispatch({ sessionId: HOST_TEST_SESSION, clientMessageId: await rig.handoffId(steered), - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + state: 'unknown', + reason: 'the provider never answered' }) + await rig.stop() + expect(stopEvents()).toHaveLength(2) + }) + + it('a second press after a card was sent into the turn writes again, and holds that card', async () => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + await rig.stop() + const steered = await queuedDraft('sent into the turn between the presses') + await rig.sendNow(steered) + await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) + await rig.stop() + expect(stopEvents()).toHaveLength(2) + await withdraw(await rig.handoffId(steered)) await rig.settleAccepted(working, 'stopped') await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.drafts()).toEqual([{ messageId: steered, state: 'waiting' }]) @@ -161,6 +205,27 @@ describe("a Stop's event", () => { expect(stopEvents()).toEqual([]) }) + it('names a turn that ended while the next card is sent but shows no turn yet: writes, and holds that card', async () => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + await turnRow('turn-1', 'running') + const next = await queuedDraft('sent when turn-1 ends') + await rig.settleAccepted(working, 'working') + await turnRow('turn-1', 'interrupted') + await eventually(async () => expect((await rig.handoff(next))?.handedOverAt).toBeDefined()) + expect(journal().activeTurnId()).toBeNull() + const fields = { turnId: 'turn-1' } + await rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), + ...fields + }) + expect(stopEvents()).toHaveLength(1) + await withdraw(await rig.handoffId(next)) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.drafts()).toEqual([{ messageId: next, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + it("holds a card when it lands between the queue's pick and its claim", async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() From 989f90bdf19edf5b0216dbb3a02678ab8d18a522 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:04:37 -0700 Subject: [PATCH 028/182] fix(native-chat): the queue's pause ignores a Stop or Resume row holding a value no build writes A Stop or Resume row's value is read from disk with no shape check, and the pause fold stored whatever it found. A stored `stopEvent: null` would then throw on every pause check for that chat: the queue's pick, its send, and every queue update to clients. No build writes such a row, so this is hardening. The fold now reads a Stop only when it is an object with a string reason and a finite time, and a Resume only when it is `true`. Anything else is ignored: it pauses nothing and ends nothing. The row is still not treated as malformed, which could cut the history short. --- .../queued-message-pause.test.ts | 51 ++++++++++++++++++- .../queued-message-pause.ts | 17 ++++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 7208755b4193..b8e45e0ef590 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -14,7 +14,8 @@ import Database from '../../sqlite/sync-database' import { journalDatabasePath } from './journal-host-database' import { createTrackedJournalOpener, - liveTestJournalRows + liveTestJournalRows, + updateTestJournalRowJson } from './journal-host-database-test-support' import { QueuedMessageNotConsumableError } from './journal-queued-messages' import { applyJournalRow, createJournalReducerState } from './journal-reducer' @@ -127,6 +128,23 @@ function stopEvents(): unknown[] { } } +/** Rewrites one key of each row at a sequence, as a corrupt write would leave it. */ +function rewriteRows(keys: Record): void { + const db = new Database(journalDatabasePath(root)) + try { + for (const stored of liveTestJournalRows(db, IDENTITY.sessionId)) { + const rewrite = keys[stored.seq] + if (rewrite) { + const row: Record = JSON.parse(stored.rowJson) + row[rewrite[0]] = rewrite[1] + updateTestJournalRowJson(db, IDENTITY.sessionId, stored.seq, JSON.stringify(row)) + } + } + } finally { + db.close() + } +} + /** Tables that could store a pause: none, the journal rows are the only record. */ function pauseTables(): number { const db = new Database(journalDatabasePath(root), { readonly: true }) @@ -245,6 +263,37 @@ describe("the queue's pause, derived from the journal", () => { expect([...state.submissions.keys()]).toEqual(submissions) }) + it('a Stop or Resume row holding a value no build writes is ignored on read, never trusted', async () => { + let journal = await open() + await queueDraft(journal, 'held') + /** Appends a row, then reopens with one of its keys rewritten as a corrupt write would. */ + const corrupted = async ( + append: Promise<{ sequence: number }>, + key: string, + value: unknown + ) => { + const { sequence } = await append + await journal.close() + rewriteRows({ [sequence]: [key, value] }) + journal = await open() + } + const malformed = [null, 'stopped', { at: 1 }] + // Pauses nothing as the latest Stop row. + for (const value of malformed) { + await corrupted(userStop(journal), 'stopEvent', value) + expect(reason(journal)).toBeNull() + } + // Ends nothing after a person's Stop: neither as a later Stop nor as a Resume. + await userStop(journal) + for (const value of malformed) { + await corrupted(userStop(journal), 'stopEvent', value) + expect(reason(journal)).toBe('stopped') + } + await corrupted(journal.appendQueueResume(0), 'queueResume', 'yes') + expect(reason(journal)).toBe('stopped') + expect(held(journal)).toEqual([['held', true]]) + }) + it("the queue's own consume is refused in its transaction while the pause holds the card; Send-now is not", async () => { const journal = await open() await queueDraft(journal, 'draft-1') diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 012b99aa203b..9039760ba43c 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -40,13 +40,26 @@ export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { return { latestStop: null, resumedSequence: 0 } } +/** The keys are read from disk unchecked: a value no build writes (a corrupt row) is ignored. */ +function isReadableStopEvent(value: unknown): value is JournalStopEvent { + return ( + typeof value === 'object' && + value !== null && + 'reason' in value && + typeof value.reason === 'string' && + 'at' in value && + typeof value.at === 'number' && + Number.isFinite(value.at) + ) +} + export function foldJournalQueuePauseMark( marks: JournalQueuePauseMarks, row: JournalTombstoneRow ): void { - if (row.stopEvent !== undefined) { + if (isReadableStopEvent(row.stopEvent)) { marks.latestStop = { sequence: row.seq, event: row.stopEvent } - } else if (row.queueResume !== undefined) { + } else if (row.queueResume === true) { marks.resumedSequence = row.seq } } From be2be9facd963b4e09081c95e8249073615f4e52 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:24:08 -0700 Subject: [PATCH 029/182] fix(native-chat): a Stop still reads as yours after Orca restarts before the turn ends Every stop that ends work now writes the Stop's event before it ends the child: a person's close of the chat, an eviction (worktree teardown, orchestration stop, tab cleanup) and the idle sweep's stop of a start that never landed. A stop that ends nothing writes nothing, and quit writes none: its resume marker records why. The turn-end write reads the latest Stop event where every turn row is built, so the adapter's settle, the host's fallback and the relaunch's settle all agree: a turn a person's Stop or close named, ending with no verdict of its own after that Stop, ends as their cancellation. A relaunch's probe-bounded end is no earlier than a Stop that found the turn running. When the provider refuses the interrupt and the turn runs on, a refusal row answers the Stop, so a later crash still reads Failed; pressing Stop again after a refusal is a new Stop. --- .../journal-lifecycle-batch-partition.ts | 15 +- .../agent-session-journal/journal-reducer.ts | 7 +- .../journal-row-builders.ts | 14 +- .../journal-row-schema.ts | 19 +- .../journal-stop-and-resume-rows.ts | 26 +- .../journal-stop-marks.ts | 25 ++ .../journal-stop-turn-end.ts | 110 +++++++++ .../journal-store-collaborators.ts | 3 + .../agent-session-journal/journal-store.ts | 3 + ...gent-session-dead-generation-settlement.ts | 7 +- .../structured-agent-session-host-lifetime.ts | 52 +++- .../structured-agent-session-idle-sweep.ts | 9 +- ...session-queued-message-rig.test-fixture.ts | 10 +- ...ed-agent-session-queued-pause-lift.test.ts | 10 +- .../structured-agent-session-queued-stop.ts | 3 +- ...d-agent-session-stale-turn-verdict.test.ts | 1 + ...ctured-agent-session-stale-turn-verdict.ts | 31 ++- ...d-agent-session-stop-event-entries.test.ts | 224 ++++++++++++++++++ ...d-agent-session-stop-event-restart.test.ts | 186 +++++++++++++++ .../structured-agent-session-turns-cancel.ts | 25 ++ ...ured-agent-session-unexpected-exit.test.ts | 1 + ...-session-stop-event-downgrade.unit.test.ts | 10 +- 22 files changed, 755 insertions(+), 36 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-marks.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts index 11fb69863f26..2074e4d77c13 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts @@ -71,9 +71,22 @@ function serializedLifecycleBatchFits( return Buffer.byteLength(JSON.stringify(row), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES } +/** Sized as a Stop may write it (`turnEndAfterStop`), so the chunk built from it still fits. */ +function sizedAsStopped(mutation: JournalLifecycleMutationInput): JournalLifecycleMutationInput { + if ( + mutation.kind !== 'item' || + mutation.body.kind !== 'turn' || + mutation.body.state !== 'interrupted' || + mutation.body.outcome !== undefined + ) { + return mutation + } + return { ...mutation, body: { ...mutation.body, outcome: 'cancellation' } } +} + function toLifecycleMutationRow(mutation: JournalLifecycleMutationInput): JournalLifecycleMutation { return journalLifecycleMutationRow( - mutation, + sizedAsStopped(mutation), agentJournalItemKey(mutation.identity), Number.MAX_SAFE_INTEGER ) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index a7d6f150e70c..fec7c50672cb 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -35,6 +35,7 @@ import { foldJournalQueuePauseMark, type JournalQueuePauseMarks } from './queued-message-pause' +import { foldJournalStopRefusal, type JournalStopRefusalMark } from './journal-stop-turn-end' export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096 @@ -64,6 +65,8 @@ export type JournalReducerState = { latestPersonTurnSequence: number /** The latest person's Stop event and Resume, what the queue's pause is derived from. */ queuePauseMarks: JournalQueuePauseMarks + /** The latest refusal of a Stop, what keeps a refused Stop from ending its turn. */ + latestStopRefusal: JournalStopRefusalMark | null } export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState { @@ -83,7 +86,8 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), latestPersonTurnSequence: 0, - queuePauseMarks: createJournalQueuePauseMarks() + queuePauseMarks: createJournalQueuePauseMarks(), + latestStopRefusal: null } } @@ -111,6 +115,7 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } if (isJournalStopOrResumeRow(row)) { foldJournalQueuePauseMark(state.queuePauseMarks, row) + foldJournalStopRefusal(state, row) return } if (row.kind === 'tombstone') { 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..0865df3a41fa 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 @@ -28,6 +28,7 @@ import { } from './journal-row-schema' import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import { assertSubmissionIdUnused } from './journal-write-guards' +import { turnEndAfterStop } from './journal-stop-turn-end' import type { ResolveDispatchInput } from './journal-store-contracts' type RowBuilder = (seq: number, ts: number) => T @@ -200,7 +201,13 @@ export function journalLifecycleBatchRowBuilder( current.tombstones.get(resolved) ?? 0 )) + 1 revisions.set(resolved, revision) - return journalLifecycleMutationRow(mutation, itemId, revision) + return journalLifecycleMutationRow( + mutation.kind === 'item' + ? { ...mutation, body: turnEndAfterStop(current, resolved, mutation.body) } + : mutation, + itemId, + revision + ) }) const row: JournalLifecycleBatchRow = { kind: 'lifecycle-batch', @@ -252,12 +259,13 @@ export function buildJournalItemRow(input: { input.state.items.get(resolved)?.revision ?? 0, input.state.tombstones.get(resolved) ?? 0 ) + 1 + const body = turnEndAfterStop(input.state, resolved, input.body) return { kind: 'item', itemId, revision, - body: input.body, - ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [input.body]), + body, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [body]), ...(input.recovered ? { recovered: input.recovered } : {}), turnScope: input.turnScope, ...agentJournalLinkageFields(input.linkage) 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 e65d5919b39b..e4bd35bd40d7 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 @@ -77,6 +77,8 @@ export type JournalTombstoneRow = JournalRowBase & { stopEvent?: JournalStopEvent /** Present: not a removal but a person's Resume of the queue, on an id no item ever takes. */ queueResume?: true + /** Present: not a removal but the provider's refusal of a Stop, on an id no item ever takes. */ + stopRefusal?: JournalStopRefusal } /** One Stop that took effect. Temporary carrier: a tombstone's extra key, because a released host @@ -94,16 +96,29 @@ export type JournalStopEvent = { caller?: string } +/** The provider refused a Stop's interrupt and its turn ran on, so that Stop never ends the turn + * (`journal-stop-turn-end.ts`). Names the Stop it answers by that event's `turnId` and `at`. Same + * Temporary carrier as `JournalStopEvent`. */ +export type JournalStopRefusal = { + turnId?: string + stopAt: number +} + /** A tombstone that carries a Stop event or a Resume mark instead of removing an item. */ export type JournalStopOrResumeRow = JournalTombstoneRow & ( | { stopEvent: NonNullable } | { queueResume: NonNullable } + | { stopRefusal: NonNullable } ) -/** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ +/** A Stop's event, its refusal or a Resume. Any value counts, so a newer build's mark never + * removes an item. */ export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrResumeRow { - return row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) + return ( + row.kind === 'tombstone' && + (row.stopEvent !== undefined || row.queueResume !== undefined || row.stopRefusal !== undefined) + ) } /** The write-ahead row. Durable BEFORE the adapter dispatches anything; it diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts index e763af8b2e6c..079b2281c7c6 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts @@ -1,10 +1,14 @@ -// The rows a Stop's event and a person's Resume append: tombstones of ids no item ever takes, +// The rows a Stop's event, its refusal and a person's Resume append: tombstones of ids no item ever takes, // each carrying its record as an extra key (`journal-row-schema.ts` says why not a row kind). import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { JournalReducerState } from './journal-reducer' import { journalRowBase } from './journal-row-builders' -import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' +import type { + JournalStopEvent, + JournalStopRefusal, + JournalTombstoneRow +} from './journal-row-schema' /** One id per mark kind; no item ever takes either. */ const JOURNAL_STOP_EVENT_ITEM_ID = agentJournalItemKey({ @@ -15,6 +19,10 @@ const JOURNAL_QUEUE_RESUME_ITEM_ID = agentJournalItemKey({ provider: 'orca', clientMessageId: 'queue-resume' }) +const JOURNAL_STOP_REFUSAL_ITEM_ID = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'stop-refusal' +}) type RowPlace = { state: JournalReducerState; seq: number; fence: number; ts: number } @@ -56,3 +64,17 @@ export function journalQueueResumeRowBuilder( ): (seq: number, ts: number) => JournalTombstoneRow { return (seq, ts) => buildJournalQueueResumeRow({ state: state(), seq, fence, ts }) } + +export function journalStopRefusalRowBuilder( + state: () => JournalReducerState, + refusal: JournalStopRefusal, + fence: number +): (seq: number, ts: number) => JournalTombstoneRow { + return (seq, ts) => ({ + kind: 'tombstone', + itemId: JOURNAL_STOP_REFUSAL_ITEM_ID, + revision: 1, + stopRefusal: refusal, + ...journalRowBase(state().epoch, seq, fence, ts) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts new file mode 100644 index 000000000000..2d9bb308e2ff --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -0,0 +1,25 @@ +// What the journal answers about its Stops beyond the queue's pause: the latest Stop event and +// whether a refusal answered it, which the turn-end rule reads (`journal-stop-turn-end.ts`). + +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow, JournalStopRefusal } from './journal-row-schema' +import { journalStopRefusalRowBuilder } from './journal-stop-and-resume-rows' +import { journalLatestStop, type JournalLatestStop } from './journal-stop-turn-end' + +export class JournalStopMarks { + constructor( + private readonly deps: { + state: () => JournalReducerState + enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise + } + ) {} + + latest(): JournalLatestStop | null { + return journalLatestStop(this.deps.state()) + } + + /** The provider refused a Stop's interrupt and its turn runs on (`JournalStopRefusal`). */ + async appendRefusal(refusal: JournalStopRefusal, fence: number): Promise { + await this.deps.enqueue(journalStopRefusalRowBuilder(this.deps.state, refusal, fence)) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts new file mode 100644 index 000000000000..016fe47d7f9e --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -0,0 +1,110 @@ +// What a Stop decides about the turn it named: the one rule every turn-end write passes through. +// +// A turn a person's Stop or close of this chat named, ending with no verdict of its own after that +// Stop's event and with no refusal answering it, ends as their cancellation. A host stop, an +// eviction, a refused Stop and no Stop at all leave the end as written. It runs where each row is +// built, inside the journal's serialized write, so it reads every Stop folded before the end: the +// adapter's settle, the host's fallback and a relaunch's settle all write through it, and every +// client folds the row it wrote. + +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { JournalReducerState } from './journal-reducer' +import type { + JournalStopEvent, + JournalStopRefusal, + JournalTombstoneRow +} from './journal-row-schema' + +export type JournalStopRefusalMark = { sequence: number; refusal: JournalStopRefusal } + +export type JournalLatestStop = { + sequence: number + event: JournalStopEvent + /** A refusal written after it answers it: its turn ran on. */ + refused: boolean +} + +/** The keys are read from disk unchecked: a value no build writes (a corrupt row) is ignored. */ +export function foldJournalStopRefusal( + state: Pick, + row: JournalTombstoneRow +): void { + const refusal: unknown = row.stopRefusal + if ( + typeof refusal === 'object' && + refusal !== null && + 'stopAt' in refusal && + typeof refusal.stopAt === 'number' && + (!('turnId' in refusal) || typeof refusal.turnId === 'string') + ) { + state.latestStopRefusal = { + sequence: row.seq, + refusal: { + stopAt: refusal.stopAt, + ...('turnId' in refusal && typeof refusal.turnId === 'string' + ? { turnId: refusal.turnId } + : {}) + } + } + } +} + +export function journalLatestStop( + state: Pick +): JournalLatestStop | null { + const stop = state.queuePauseMarks.latestStop + if (!stop) { + return null + } + const answer = state.latestStopRefusal + return { + ...stop, + refused: + answer !== null && + answer.sequence > stop.sequence && + answer.refusal.stopAt === stop.event.at && + answer.refusal.turnId === stop.event.turnId + } +} + +/** Only a person's own Stop, or their close of this chat, makes a cut turn their cancellation. */ +function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { + switch (reason) { + case 'user-stop': + case 'user-close': + return true + case 'host-stop': + case 'evict': + return false + } +} + +/** + * The body to write for item `itemId`: unchanged unless it ends, with no verdict of its own and no + * earlier than the latest Stop event, a person's, which named it while it was still open (running, + * or unproven) and which no refusal answered. A provider's own verdict always stands. + */ +export function turnEndAfterStop( + state: Pick, + itemId: string, + body: AgentJournalItemBody +): AgentJournalItemBody { + if (body.kind !== 'turn' || body.state !== 'interrupted' || body.outcome !== undefined) { + return body + } + const previous = readAgentJournalTurn(state.items.get(itemId)?.body) + // An end already written stands: the Stop came after it. + if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { + return body + } + const stop = journalLatestStop(state) + return stop && + !stop.refused && + stop.event.turnId === body.turnId && + stopIsAPersons(stop.event.reason) && + // An exit the provider saw before the Stop was news, whenever its end is written. + (body.completedAt === undefined || body.completedAt >= stop.event.at) + ? { ...body, outcome: 'cancellation' } + : body +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 647b64e0f004..5351427f3023 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -13,6 +13,7 @@ import { JournalItemAppender } from './journal-item-appender' import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalLoad } from './journal-open' import { JournalQueuedMessages } from './journal-queued-messages' +import { JournalStopMarks } from './journal-stop-marks' import { journalQueuePauseRestatement } from './queued-message-pause' import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-writer' @@ -56,6 +57,7 @@ export type JournalStoreCollaborators = { itemAppender: JournalItemAppender lifecycleBatchAppender: JournalLifecycleBatchAppender queuedMessages: JournalQueuedMessages + stopMarks: JournalStopMarks /** Restores the store's state from disk. Owned here because it needs the same * collaborators the constructor just built. */ restore: () => Promise @@ -92,6 +94,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal return { epochController, queuedMessages, + stopMarks: new JournalStopMarks({ state: host.state, enqueue: host.enqueue }), // Behind the stored fact: settles drafts whose consumed submission the loaded journal shows // refused (a downgrade wrote no hook), then prunes. Bookkeeping, never failing the open. restore: () => 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 08113e3bc7db..3de15cc3df60 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -70,6 +70,7 @@ import { createJournalStoreCollaborators } from './journal-store-collaborators' import { journalStoreLoadedFields } from './journal-store-open' import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' +import type { JournalStopMarks } from './journal-stop-marks' export { AgentSessionJournalError } from './journal-write-guards' @@ -93,6 +94,7 @@ export class AgentSessionJournal { private readonly restore: () => Promise /** Draft rows queued while the agent works; never reducer input or owed work. */ readonly queuedMessages: JournalQueuedMessages + readonly stopMarks: JournalStopMarks constructor(options: AgentSessionJournalOptions) { this.identity = options.identity @@ -141,6 +143,7 @@ export class AgentSessionJournal { this.itemAppender = collaborators.itemAppender this.lifecycleBatchAppender = collaborators.lifecycleBatchAppender this.queuedMessages = collaborators.queuedMessages + this.stopMarks = collaborators.stopMarks this.restore = collaborators.restore } 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..6bf0c7346e2f 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 @@ -28,6 +28,7 @@ import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-re import { provenUnverifiableTurnRevisions, runningTurnLifecycleRevisions, + stopFoundTurnLiveAt, turnVerdictFromDeathEvidence, type StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' @@ -216,7 +217,11 @@ export async function settleStaleStructuredAgentSessionState(input: { const items = journal.snapshot().items // Each turn is judged by the evidence only if it names that turn's owner. const verdictFor = (item: AgentJournalRenderItem) => - turnVerdictFromDeathEvidence(input.deathEvidence, journal.itemFence(item.itemId)) + turnVerdictFromDeathEvidence( + input.deathEvidence, + journal.itemFence(item.itemId), + stopFoundTurnLiveAt(journal, item) + ) // Per attempt: a retry re-partitions only what is left, and a reused chunk id would skip it. const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 719540da5da8..03639dc8c2b7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -32,6 +32,7 @@ import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-ses import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import { turnVerdictForChildEnd } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' export type StructuredAgentSessionLifetimeContext = { deps: StructuredAgentSessionHostDeps @@ -87,6 +88,47 @@ function owedProviderChildWindDown( : session.owesProviderChildWindDown } +/** How a stop ends the child: its cause, the host's text for it, and whether it is quit, whose + * resume marker's trigger already records why, so it writes no Stop event. */ +export type StructuredAgentSessionStopEnding = { + cause: StructuredAgentSessionStopCause + reason?: string + quit?: true +} + +/** + * Writes this stop's event (`JournalStopEvent`) when it ends a running turn or a start: a stop + * that ends nothing writes nothing. A person's Stop wrote its own before it reached here, and quit + * writes none. Issued before the kill and never awaited by it: bookkeeping, reported on failure. + */ +function recordStopEvent( + context: StructuredAgentSessionLifetimeContext, + sessionId: string, + session: StructuredAgentSessionHostSession, + ending: StructuredAgentSessionStopEnding +): Promise { + const { child, journal } = session + const turnId = journal.activeTurnId() + if ( + ending.cause === 'user-stop' || + ending.quit || + !child || + (child.phase !== 'starting' && + !isStructuredAgentSessionMainAgentWorking(turnId, journal.submissions(), child.fence)) + ) { + return Promise.resolve() + } + return journal + .appendStopEvent( + { reason: ending.cause, ...(turnId !== null ? { turnId } : {}) }, + structuredAgentSessionConversationFence(context.deps.store, sessionId) + ) + .then( + () => undefined, + (error: unknown) => context.deps.onEventSinkError?.({ sessionId, error }) + ) +} + /** * The agent goes to rest; the conversation stays. Runs the eviction steps under a deadline. A step * that fails — or runs out of time — aborts the rest and leaves the wind-down owed, so the next @@ -97,12 +139,13 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, sessionId: string, // Required: an omitted cause must not default to the user's cancellation. - ending: { cause: StructuredAgentSessionStopCause; reason?: string } + ending: StructuredAgentSessionStopEnding ): Promise { const session = context.sessions.get(sessionId) if (!session) { return } + const recorded = recordStopEvent(context, sessionId, session, ending) // A retry finishes the stop that ended the child, so the turn that stop cut keeps its cause. const cause = session.child ? ending.cause @@ -143,6 +186,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( acknowledgeRelease: () => context.deps.adapter.acknowledgeSessionRelease?.(sessionId), discardSink: () => context.runtimeState.discardEventSink(sessionId), settleWork: async () => { + // Folded before the fallback's end is built, so the end reads it (`turnEndAfterStop`). + await recorded const fence = owed?.fence ?? structuredAgentSessionConversationFence(context.deps.store, sessionId) const settled = await settleStructuredAgentSessionDeadGeneration({ @@ -250,7 +295,10 @@ export async function evictOwnedStructuredAgentSessions( ownedSessionIds.map(async (sessionId) => { try { await context.serialize(sessionId, () => - stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { cause: 'evict' }) + stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { + cause: 'evict', + quit: true + }) ) retainOnFailure.delete(sessionId) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index cdf412a7bcd5..92e0eebc3939 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -122,11 +122,10 @@ export class StructuredAgentSessionIdleSweep { } if (session.child) { // A start that has been quiet this long is not coming: the host stops it, with its reason. - if (session.child.phase === 'starting') { - await this.deps.stopStartingAgent(sessionId) - return - } - await this.deps.stopAgent(sessionId) + // The close below keeps a conversation whose delivery loop still owes that start's rows. + await (session.child.phase === 'starting' + ? this.deps.stopStartingAgent(sessionId) + : this.deps.stopAgent(sessionId)) } await this.deps.closeConversation(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index c4fc82f5cc99..1354db74e87c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -38,7 +38,12 @@ export type QueuedMessageTestRig = Awaited 'spawn-1', - now: () => NOW + now: () => NOW, + ...(options.idleSweep ? { idleSweep: options.idleSweep } : {}) }) let host = makeHost() expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 0760a55c264c..f2ad9a18fa41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -91,13 +91,11 @@ describe("a Stop's queue pause", () => { it('survives a restart, and a send made after the Stop still ends it when its turn starts there', async () => { const draftId = await stoppedDraft() - const inFlight = await handedOverUserSend('sent before the restart') - // Derived from the journal, not remembered: a restart forgets nothing it needs. - await rig.restartHostProcess() + // Derived from the journal, not remembered: a restart forgets nothing it needs. Quit writes no + // Stop event, so the process dying is that restart; `restartHostProcess` closes with an + // eviction, whose event would end the pause itself. + rig.crashRestartHostProcess() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - await rig.settleAccepted(inFlight, 'after-restart') - // The Stop's pause is over; the restart's own lasts until a turn asked for since it. - await eventually(async () => expect(await rig.queuePause()).toEqual({ reason: 'restarted' })) const next = rig.send('sent after the restart') await next.result await rig.settleAccepted(next.id, 'next') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index 72df6fe2ac6a..ac21829b87a1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -71,7 +71,8 @@ export async function stopReachesUnrecordedWork( return false } const inForce = ctx.journal.queuedMessages.userStopInForce() - if (inForce === null) { + // A refused Stop ended nothing, so pressing again is a new Stop. + if (inForce === null || ctx.journal.stopMarks.latest()?.refused) { return true } // Sent after that Stop and not refused, even if its fate is unknown: this interrupt may send it diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 71c5df2692d6..8a9deaad48c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -276,6 +276,7 @@ describe('stale session state on a cold acquire', () => { const journal = { snapshot: () => ({ items }), itemFence: () => 1, + stopMarks: { latest: () => null }, cursor: () => ({ epoch: 'epoch-1', sequence: 8 }), appendLifecycleBatch } as unknown as AgentSessionJournal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index d08f536daef4..45f636eccf77 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -36,7 +36,9 @@ export const UNVERIFIABLE_TURN_VERDICT: StructuredAgentSessionTurnVerdict = { export function turnVerdictFromDeathEvidence( evidence: AgentSessionDeathEvidence | null | undefined, /** Fence of the owner that wrote the turn. */ - turnFence: number | undefined + turnFence: number | undefined, + /** When a Stop event found the turn running (`stopFoundTurnLiveAt`): a later proof of life. */ + liveAt?: number ): StructuredAgentSessionTurnVerdict { if (!evidence) { return UNVERIFIABLE_TURN_VERDICT @@ -54,11 +56,20 @@ export function turnVerdictFromDeathEvidence( return { state: 'interrupted', completedAt: evidence.observedAt } } // A probe finds a dead child long after it died; its last renewal bounds the end, so the turn never - // counts the time Orca was down. Timeline rows don't: a send can land there after the death. - return { - state: 'interrupted', - completedAt: Math.min(evidence.lastProvenAliveAt ?? evidence.observedAt, evidence.observedAt) - } + // counts the time Orca was down. Timeline rows don't: a send can land there after the death. A + // Stop that found the turn running is a later renewal, so the end reads after that Stop. + const lastAlive = Math.max(evidence.lastProvenAliveAt ?? evidence.observedAt, liveAt ?? 0) + return { state: 'interrupted', completedAt: Math.min(lastAlive, evidence.observedAt) } +} + +/** When the latest Stop event found `item`'s turn running: E1 writes one only for a live turn. */ +export function stopFoundTurnLiveAt( + journal: Pick, + item: AgentJournalRenderItem +): number | undefined { + const stop = journal.stopMarks.latest() + const turnId = readAgentJournalTurn(item.body)?.turnId + return stop && turnId !== undefined && stop.event.turnId === turnId ? stop.event.at : undefined } /** @@ -119,7 +130,7 @@ export function runningTurnLifecycleRevisions( export function provenUnverifiableTurnRevisions( items: readonly AgentJournalRenderItem[], evidence: AgentSessionDeathEvidence | null | undefined, - journal: Pick + journal: Pick ): JournalLifecycleMutationInput[] { const ownerFence = evidence?.ownerFence if (ownerFence === undefined) { @@ -128,7 +139,11 @@ export function provenUnverifiableTurnRevisions( return items.flatMap((item) => { const turn = readAgentJournalTurn(item.body) return turn?.state === 'unverifiable' && journal.itemFence(item.itemId) === ownerFence - ? turnLifecycleRevision(item, turn, turnVerdictFromDeathEvidence(evidence, ownerFence)) + ? turnLifecycleRevision( + item, + turn, + turnVerdictFromDeathEvidence(evidence, ownerFence, stopFoundTurnLiveAt(journal, item)) + ) : [] }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts new file mode 100644 index 000000000000..a0a1dd5dc511 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -0,0 +1,224 @@ +// Every way a host stop or close begins writes the Stop's event with its reason, before it ends the +// child, and only when it ends work: a running turn, or a start. A stop that ends nothing writes +// nothing, quit writes nothing (its resume marker records why), and any later Stop event ends a +// person's Stop pause. + +import { afterEach, describe, expect, it } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +/** Swept only when a test ticks it. */ +const MANUAL_IDLE_SWEEP = { idleMs: 0, intervalMs: 60 * 60 * 1000 } + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +/** Every Stop event in the live epoch, oldest first. */ +function stopEvents(): JournalStopEvent[] { + const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + return since.rows.flatMap((row) => + row.kind === 'tombstone' && row.stopEvent ? [row.stopEvent] : [] + ) +} + +/** The Stop events as the provider's close finds them, or null when no close ran. */ +function stopEventsAtClose(): { events: JournalStopEvent[] | null } { + const seen: { events: JournalStopEvent[] | null } = { events: null } + rig.closeSession.mockImplementationOnce(async () => { + seen.events = stopEvents() + return true + }) + return seen +} + +async function runningTurn(turnId = 'turn-1'): Promise { + const working = await rig.workingSend() + await journal().appendItem( + { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, + { kind: 'turn', turnId, state: 'running', startedAt: 1 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return working +} + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) + } + return queued.value.queued.messageId +} + +function idleSweep() { + return rig.host.collaboratorsForTests().lifetime.idleSweep +} + +/** Holds every start until the returned release. */ +function holdStart(): () => void { + let release: () => void = () => undefined + rig.awaitStarted.mockImplementation( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + return () => release() +} + +describe('every Stop entry writes its event, with its reason, before it ends the child', () => { + it.each([ + // A person closing this chat: its tab, its launch, or a /clear that replaces it. + ['user-close' as const], + // A worktree teardown, an orchestration stop, a discarded half-started worker, a tab cleanup. + ['evict' as const] + ])('a %s close of a running turn', async (cause) => { + rig = await createQueuedMessageTestRig() + await runningTurn() + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, cause) + + expect(atClose.events).toEqual([{ reason: cause, turnId: 'turn-1', at: expect.any(Number) }]) + }) + + it("a person's Stop of a running turn", async () => { + rig = await createQueuedMessageTestRig() + await runningTurn() + let atInterrupt: JournalStopEvent[] = [] + rig.cancelTurn.mockImplementationOnce(async () => { + atInterrupt = stopEvents() + return { cancelled: true } + }) + + await rig.stop() + + expect(atInterrupt).toEqual([ + expect.objectContaining({ reason: 'user-stop', turnId: 'turn-1', at: expect.any(Number) }) + ]) + }) + + it('the idle sweep stopping a start that never landed', async () => { + rig = await createQueuedMessageTestRig({ + starting: true, + restartable: true, + idleSweep: MANUAL_IDLE_SWEEP + }) + holdStart() + rig.send('work on this') + await eventually(async () => + expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( + 'starting' + ) + ) + const atClose = stopEventsAtClose() + + await idleSweep().tick() + + expect(atClose.events).toEqual([{ reason: 'host-stop', at: expect.any(Number) }]) + }) + + it('writes nothing when it ends nothing: a close of a chat at rest', async () => { + rig = await createQueuedMessageTestRig() + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, 'user-close') + + expect(atClose.events).toEqual([]) + }) + + it("writes nothing at quit, whose resume marker's trigger records why", async () => { + rig = await createQueuedMessageTestRig() + await runningTurn() + const atClose = stopEventsAtClose() + + await rig.host.flushAllStreamedEvents({ trigger: 'quit' }) + + expect(atClose.events).toEqual([]) + }) +}) + +describe("a person's Stop pause and the Stop events after it", () => { + it('holds through an idle eviction of the chat at rest, which writes nothing', async () => { + rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) + const working = await rig.workingSend() + const held = await queuedDraft('queued behind the turn') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'stopped') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + const atClose = stopEventsAtClose() + + await idleSweep().tick() + + expect(rig.closeSession).toHaveBeenCalledTimes(1) + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.handoff(held)).toBeUndefined() + }) + + it('ends when a host eviction ends a running turn: that Stop event is later', async () => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + await queuedDraft('queued behind the turn') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'stopped') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // Orchestration mail starts a turn the host sent, which lifts nothing. + await rig.send('mail for the lead', undefined, { internal: true }).result + await journal().appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-mail', ordinal: 999 }, + { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: 1 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, 'evict') + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop', 'evict']) + expect(await rig.queuePause()).not.toEqual({ reason: 'stopped' }) + }) + + it('ends when the host stops a start that never landed: that Stop event is later', async () => { + rig = await createQueuedMessageTestRig({ + starting: true, + restartable: true, + idleSweep: MANUAL_IDLE_SWEEP + }) + const working = await rig.workingSend() + const held = await queuedDraft('queued behind the turn') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'stopped') + // The agent at rest goes, writing nothing; mail then starts a new child, which never lands. + await idleSweep().tick() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + holdStart() + rig.send('mail for the lead', undefined, { internal: true }) + await eventually(async () => + expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( + 'starting' + ) + ) + const atClose = stopEventsAtClose() + + await idleSweep().tick() + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop', 'host-stop']) + expect(await rig.handoff(held)).toBeUndefined() + expect(await rig.queuePause()).not.toEqual({ reason: 'stopped' }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts new file mode 100644 index 000000000000..5dc82d8efd73 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -0,0 +1,186 @@ +// A Stop that took effect still decides its turn after Orca restarts before the turn's end was +// written: the relaunch's settle reads the Stop's event, so the turn reads "Interrupted after N" +// with the muted mark, not "Failed". A turn nobody stopped, and one whose Stop the provider refused +// while the turn ran on, still read as the news they are. + +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' +import { agentTurnVerdict } from '../../../shared/agent-turn-outcome' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { formatNativeChatTurnStatusLabel } from '../../../shared/native-chat-turn-status' +import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +const TURN = 'turn-1' +/** The provider rows a turn lives on: the Claude lane's, and Codex's. */ +const CLAUDE_TURN: AgentJournalItemIdentity = { + provider: 'claude', + sessionId: 'provider-session-1', + uuid: 'uuid-turn' +} +const CODEX_TURN: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: TURN, + ordinal: 999 +} + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +/** A send the provider is working on, with its turn running since half a minute ago. */ +async function runningTurn(identity: AgentJournalItemIdentity): Promise { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + await journal().appendItem( + identity, + { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() - 30_000 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +/** Orca dies with the turn's end unwritten; the relaunch reopens the chat from disk and proves + * the old child gone (its last renewal came before the Stop), then settles what it left. */ +async function restartAndSettle(proof: 'pid-absent' | 'unproven' = 'pid-absent'): Promise { + rig.crashRestartHostProcess() + await rig.host.journalSnapshot(HOST_TEST_SESSION) + const now = Date.now() + const deathEvidence: AgentSessionDeathEvidence | null = + proof === 'pid-absent' + ? { + kind: 'pid-absent', + observedAt: now + 60_000, + ownerFence: 1, + lastProvenAliveAt: now - 20_000 + } + : null + await settleStaleStructuredAgentSessionState({ + journal: journal(), + sessionId: HOST_TEST_SESSION, + fence: 2, + acquisitionGeneration: 'generation-2', + deathEvidence + }) +} + +/** What the chat's turn bar and the session's mark read. */ +function settled() { + const { items } = journal().snapshot() + const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) + const [timing] = [...selectStructuredAgentSettledTurns(items).values()] + const verdict = turn + ? agentTurnVerdict({ state: turn.state, outcome: turn.outcome ?? null }) + : null + return { + turn, + label: timing ? formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...timing }) : null, + mark: verdict + ? agentVerdictDisplayMark({ state: 'done', mainAgent: { state: 'done', outcome: verdict } }) + : null + } +} + +describe('a restart between a Stop and its turn end', () => { + it.each([ + ['a Claude Stop before its result arrives', CLAUDE_TURN], + ['a Codex Stop before turn/completed', CODEX_TURN] + ])('reads Interrupted after N, marked interrupted: %s', async (_label, identity) => { + await runningTurn(identity) + // The provider took the interrupt; its end never arrived. + expect(await rig.stop()).toMatchObject({ ok: true }) + + await restartAndSettle() + + const { turn, label, mark } = settled() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(label).toMatch(/^Interrupted after /) + expect(mark).toBe('interrupted') + }) + + it('reads Interrupted after N, marked interrupted: a close of the chat that died midway', async () => { + await runningTurn(CODEX_TURN) + // The host dies inside the close: the provider's close never answers, and nothing settles. + rig.closeSession.mockImplementationOnce(() => Promise.reject(new Error('host died'))) + await expect(rig.host.close(HOST_TEST_SESSION, 'user-close')).rejects.toThrow() + expect(journal().stopMarks.latest()?.event).toMatchObject({ + reason: 'user-close', + turnId: TURN + }) + + await restartAndSettle() + + const { turn, label, mark } = settled() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(label).toMatch(/^Interrupted after /) + expect(mark).toBe('interrupted') + }) + + it('reads Failed after N, marked failed, when nobody stopped it', async () => { + await runningTurn(CODEX_TURN) + + await restartAndSettle() + + const { turn, label, mark } = settled() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + expect(label).toMatch(/^Failed after /) + expect(mark).toBe('failed') + }) + + it("reads Couldn't confirm when the relaunch cannot prove the child gone, Stop or not", async () => { + // The Stop says whose end it was, never that the turn ended. + await runningTurn(CODEX_TURN) + expect(await rig.stop()).toMatchObject({ ok: true }) + + await restartAndSettle('unproven') + + const { turn, mark } = settled() + expect(turn).toMatchObject({ state: 'unverifiable' }) + expect(mark).toBe('unconfirmed') + }) + + it('reads Failed after N when the provider refused the Stop and the turn ran on until the crash', async () => { + await runningTurn(CODEX_TURN) + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + await eventually(async () => expect(journal().stopMarks.latest()?.refused).toBe(true)) + + await restartAndSettle() + + const { turn, label, mark } = settled() + expect(turn).not.toHaveProperty('outcome') + expect(label).toMatch(/^Failed after /) + expect(mark).toBe('failed') + }) + + it('a Stop pressed again after a refusal is a new Stop, which its turn end reads', async () => { + await runningTurn(CODEX_TURN) + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) + await rig.stop() + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + await restartAndSettle() + + expect(settled().turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index eb3784b0a1d7..fb97d87341db 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -30,6 +30,26 @@ export async function isMainAgentWorkingOnceFlushed( ) } +/** The provider refused the interrupt and `turnId` runs on: its Stop event must not end it + * (`turnEndAfterStop`). Only where nothing then ends the child. Bookkeeping: reported, never thrown. */ +async function recordStopRefusal( + ctx: Pick, + turnId: string | null +): Promise { + const stop = ctx.journal.stopMarks.latest() + if (!stop || stop.refused || turnId === null || stop.event.turnId !== turnId) { + return + } + await ctx.journal.stopMarks + .appendRefusal({ turnId, stopAt: stop.event.at }, ctx.fence) + .catch((error: unknown) => { + console.warn("[agent-session] a refused Stop's answer row skipped:", { + sessionId: ctx.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + }) +} + export async function performCancel( ctx: AgentSessionTurnContext, input: { @@ -52,6 +72,7 @@ export async function performCancel( } } let cancelled = false + let refused = false let note: AgentJournalStatusItem | null = { kind: 'status', text: 'Cancellation requested.' } // The turn the Stop names, read before the cancel settles it: the note reports on that turn. const turnScope = ctx.journal.liveTurnScope() @@ -102,6 +123,7 @@ export async function performCancel( note = null } else if (!cancelled) { // Sent only while the chat reads working, so a Stop that ended nothing must say why. + refused = true const detail = outcome.refusal?.detail note = { kind: 'status', @@ -126,6 +148,9 @@ export async function performCancel( cancelled = true note = { kind: 'status', text: 'Cancellation requested.' } } + if (refused && !cancelled && !input.scope) { + await recordStopRefusal(ctx, liveTurnId) + } if (cancelled && input.prompt) { await ctx.flushStreamedEvents() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 2cb38a466345..f8a17510f430 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -98,6 +98,7 @@ describe('provider-exit settlement', () => { items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), itemFence: () => 7, + stopMarks: { latest: () => null }, appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) } diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 0a9b5ea5f35d..88abffc1fb4e 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -54,7 +54,7 @@ type OldReplay = { truncateFrom?: number } -test("an older build keeps every row around a Stop's event and a Resume, and folds the rows after them", async () => { +test("an older build keeps every row around a Stop's event, its refusal and a Resume, and folds the rows after them", async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-stop-event-downgrade-')) const journals = createTrackedJournalOpener() try { @@ -69,6 +69,9 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol await append(0, 'before the Stop') const beforeMarks = journal.cursor() await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) + const stopAt = journal.stopMarks.latest()?.event.at ?? 0 + await journal.stopMarks.appendRefusal({ turnId: 'turn-1', stopAt }, 1) + await journal.appendStopEvent({ reason: 'user-close', turnId: 'turn-1' }, 1) await journal.appendQueueResume(1) const afterMarks = journal.cursor() await append(1, 'after the Stop') @@ -136,7 +139,7 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol }) expect(projected.ok).toBe(true) expect(projected.batch?.items).toEqual([]) - expect(projected.batch?.removedItemIds).toHaveLength(2) + expect(projected.batch?.removedItemIds).toHaveLength(3) const liveIds = new Set(journal.snapshot().items.map((entry) => entry.itemId)) expect(projected.batch?.removedItemIds.some((id) => liveIds.has(id))).toBe(false) } finally { @@ -182,6 +185,8 @@ test("an older build opens this build's journal writable and appends to it; the const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } await journal.appendItem(item(0), { kind: 'status', text: 'before the Stop' }, scope) await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) + const stopAt = journal.stopMarks.latest()?.event.at ?? 0 + await journal.stopMarks.appendRefusal({ turnId: 'turn-1', stopAt }, 1) await journal.appendItem(item(1), { kind: 'status', text: 'after the Stop' }, scope) const wrote = { cursor: journal.cursor(), items: itemIds(journal) } expect(journal.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ @@ -217,6 +222,7 @@ test("an older build opens this build's journal writable and appends to it; the expect(upgraded.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ 'stopped' ]) + expect(upgraded.stopMarks.latest()).toMatchObject({ event: { at: stopAt }, refused: true }) } finally { await journals.closeAll() rmSync(directory, { recursive: true, force: true }) From cc32533d335abe4f436d92505ded8985e438ee93 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:44:53 -0700 Subject: [PATCH 030/182] refactor(native-chat): a stop no longer carries its cause; the turn's end reads the Stop event The cause of a stop was threaded in memory from each entry through the host's stop step, the adapter router and each adapter's close onto the `ended` it settled with, and Claude kept a per-turn copy of a Stop it sent. All of that is gone: adapters settle a turn they cut as interrupted with no verdict, the host's fallback does the same, and the one rule where a turn row is built (`turnEndAfterStop`) reads the journal's latest Stop event to say whether it was a person's. - `closeSession` / `disposeSession` take no cause; `ended` has no `stopCause`. - Claude reads an error result after a person's Stop as their cancellation from the journal's Stop event (through the event sink), not from a per-turn slot, and a refused interrupt is the host's refusal row, not `withdrawTurnStop`. - An owed wind-down keeps no cause: its retry's fallback reads the Stop event. - The mutation context's Stop passes no cause: its step already wrote the event, and the delivery loop's child-end reason is read back from it. - A Stop pressed before its turn showed applies to the turn that opens under it, unless a send a person made since was accepted. --- .../claude-api-retry-idle-sweep.test.ts | 2 +- ....test.ts => claude-close-turn-end.test.ts} | 31 +++---- .../claude-journal-translator-contract.ts | 6 -- src/main/claude/claude-open-turn.ts | 24 ------ src/main/claude/claude-result-journaling.ts | 14 +++- src/main/claude/claude-result-outcome.ts | 11 ++- .../claude-structured-control-actions.test.ts | 2 - .../claude-structured-control-actions.ts | 15 +--- .../claude-structured-in-turn-stop.test.ts | 36 +++++++-- .../claude-structured-journal-translation.ts | 14 +--- .../claude-structured-prompt-ownership.ts | 15 +--- .../claude-structured-provider-fallback.ts | 5 +- .../claude-structured-session-adapter.ts | 22 ++--- .../claude/claude-structured-session-close.ts | 5 -- .../claude/claude-structured-session-state.ts | 7 +- src/main/claude/claude-turn-lifecycle-item.ts | 5 +- src/main/claude/claude-turn-outcome.test.ts | 47 ++++++----- src/main/claude/claude-turn-ownership.test.ts | 2 - ...e.test.ts => codex-close-turn-end.test.ts} | 81 ++++++------------- .../codex-provider-retry-idle-sweep.test.ts | 2 +- .../codex-structured-journal-translation.ts | 20 ++--- .../codex/codex-structured-session-adapter.ts | 9 +-- .../codex/codex-structured-session-close.ts | 16 +--- .../codex/codex-structured-session-state.ts | 7 +- .../codex-structured-session-teardown.ts | 6 +- .../journal-stop-turn-end.ts | 48 ++++++++--- ...tured-agent-session-adapter-router.test.ts | 2 +- ...structured-agent-session-adapter-router.ts | 18 ++--- .../structured-agent-session-adapter.ts | 7 +- ...ctured-agent-session-close-verdict.test.ts | 44 +++++----- ...d-agent-session-conversation-close.test.ts | 4 +- ...red-agent-session-conversation-lifetime.ts | 10 +-- ...ructured-agent-session-event-sink-queue.ts | 4 + .../structured-agent-session-event-sink.ts | 4 + .../structured-agent-session-eviction.test.ts | 2 +- .../structured-agent-session-eviction.ts | 9 +-- .../structured-agent-session-host-lifetime.ts | 52 +++++++----- .../structured-agent-session-host-types.ts | 10 +-- .../structured-agent-session-host.ts | 3 +- ...tructured-agent-session-idle-sweep.test.ts | 18 ++--- ...ed-agent-session-option-settlement.test.ts | 2 +- ...gent-session-provider-child-record.test.ts | 2 +- ...agent-session-recovered-turn-clock.test.ts | 18 +---- ...ctured-agent-session-stale-turn-verdict.ts | 51 +----------- .../structured-agent-session-stop-cause.ts | 8 +- ...red-agent-session-surface-lifetime.test.ts | 4 +- 46 files changed, 285 insertions(+), 439 deletions(-) rename src/main/claude/{claude-close-stop-cause.test.ts => claude-close-turn-end.test.ts} (69%) rename src/main/codex/{codex-close-stop-cause.test.ts => codex-close-turn-end.test.ts} (59%) diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index bac1da60fe73..7f3511930292 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -123,7 +123,7 @@ describe('a Claude retrying a refused request', () => { // Once the frames stop, the same clock does let the sweep close it. clock += STRUCTURED_AGENT_SESSION_IDLE_MS await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(closeSession).toHaveBeenCalledWith(SESSION) expect(host.hasSession(SESSION)).toBe(false) }) }) diff --git a/src/main/claude/claude-close-stop-cause.test.ts b/src/main/claude/claude-close-turn-end.test.ts similarity index 69% rename from src/main/claude/claude-close-stop-cause.test.ts rename to src/main/claude/claude-close-turn-end.test.ts index ba93c53a3d85..d115afe61a28 100644 --- a/src/main/claude/claude-close-stop-cause.test.ts +++ b/src/main/claude/claude-close-turn-end.test.ts @@ -1,6 +1,6 @@ -// Closing a Claude child settles its open turn in the adapter, with the cause the host handed the -// close. Only a stop the user aimed at this chat reads as their cancellation; an exit the adapter -// saw before the close settles as that exit. +// Closing a Claude child settles its open turn in the adapter as interrupted, with no verdict: the +// close names no cause. Whether the end was a person's is the journal's Stop event to say; an exit +// the adapter saw before the close settles as that exit. import { describe, expect, it } from 'vitest' import type { @@ -53,33 +53,24 @@ async function childInsideTurn() { return { adapter, connection, events, turns } } -describe('a Claude close settles the open turn with the host-named cause', () => { - it("records the user's close of this chat as their cancellation", async () => { +describe('a Claude close settles the open turn with no verdict of its own', () => { + // Whose end it was is the journal's Stop event to say (`turnEndAfterStop`), never the close's. + it('ends it interrupted, with no outcome', async () => { const { adapter, events, turns } = await childInsideTurn() - await expect(adapter.closeSession('session-1', 'user-close')).resolves.toBe(true) - - expect(turns.at(-1)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - stopCause: 'user-close' - }) - }) - - it('leaves an eviction as news', async () => { - const { adapter, turns } = await childInsideTurn() - - await adapter.closeSession('session-1', 'evict') + await expect(adapter.closeSession('session-1')).resolves.toBe(true) expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) expect(turns.at(-1)).not.toHaveProperty('outcome') + expect(events.find((event) => event.type === 'ended')).not.toHaveProperty('stopCause') }) - it('leaves a crash it saw before the user closed the chat as news', async () => { + it('settles a crash it saw before the close once, as that exit', async () => { const { adapter, connection, events, turns } = await childInsideTurn() - // The child dies on its own first; the user's close arrives while that exit is still settling. + // The child dies on its own first; the close arrives while that exit is still settling. connection.handlers.onExit?.(new Error('provider exited')) - await adapter.closeSession('session-1', 'user-close') + await adapter.closeSession('session-1') await tick() const settled = turns.filter((turn) => turn.state !== 'running') diff --git a/src/main/claude/claude-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts index ed508b30846e..45feeb84364a 100644 --- a/src/main/claude/claude-journal-translator-contract.ts +++ b/src/main/claude/claude-journal-translator-contract.ts @@ -2,7 +2,6 @@ import type { AgentSessionContextReport } from '../../shared/agent-session-context-usage' import type { StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeChildToolQueries } from './claude-child-tool-queries' import type { ClaudeContextReportPart, ClaudeContextReportTarget } from './claude-context-facts' import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts' @@ -15,11 +14,6 @@ export type ClaudeJournalTranslator = { /** The open turn's provider id — the same id its journal row carries, and the one * a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */ readonly currentTurnId: string | null - /** Orca is stopping this turn; its error end reads as the user's cancellation when they asked. - * False when the turn is no longer open. */ - recordTurnStop: (turnId: string, cause: StructuredAgentSessionStopCause) => boolean - /** The provider refused the stop. */ - withdrawTurnStop: (turnId: string) => void /** The open turn's id while it is a conversation command's. */ readonly commandTurnId: string | null /** Makes the host's command turn the open one until the command's result ends it. */ diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 5aca644143b3..f946e4ee9cfb 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -12,7 +12,6 @@ import { type AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeCurrentTurnIdentity, claudeTurnLifecycleItem, @@ -33,9 +32,6 @@ export type ClaudeOpenTurnDeps = { export class ClaudeOpenTurn { private current: ClaudeCurrentTurn | null = null - /** The stop Orca sent, held against the turn it was sent to: it reads only while that turn is open. */ - private sentStop: { turn: ClaudeCurrentTurn; cause: StructuredAgentSessionStopCause } | null = - null /** Provider output may not reopen a turn after the session ended or a turn * failed: nothing would ever close the turn it opened, and the row would read * working for the life of the session. Only an accepted send lifts it. */ @@ -90,26 +86,6 @@ export class ClaudeOpenTurn { return this.current !== null } - get stop(): StructuredAgentSessionStopCause | null { - return this.current && this.sentStop?.turn === this.current ? this.sentStop.cause : null - } - - /** Orca is stopping `turnId`. False when that turn is no longer the open one. */ - recordStop(turnId: string, cause: StructuredAgentSessionStopCause): boolean { - if (this.current?.turnId !== turnId) { - return false - } - this.sentStop = { turn: this.current, cause } - return true - } - - /** The provider refused the stop, so the turn goes on as if none was sent. */ - withdrawStop(turnId: string): void { - if (this.current?.turnId === turnId) { - this.sentStop = null - } - } - /** Whether a turn is open inside a provider request cycle that has already * done work — the state in which the CLI folds an arriving send into it. A * cycle's first send is its opener, never a fold. */ diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 1fe79b626064..2609b4e919c8 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -15,6 +15,7 @@ import { } from './claude-structured-provider-fallback' import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' import { claudeFrameParentRef, isRootClaudeFrame } from './claude-turn-opening' +import { stopIsTurnCancellation } from '../native-chat/agent-session-journal/journal-stop-turn-end' export type ClaudeResultJournalContext = Pick< ClaudeMessageJournalContext, @@ -56,15 +57,20 @@ export function journalClaudeResult( } // Read before the settle below closes it: the result reports that turn's end. const endedTurnScope = turn.turnScope - // The stop Orca sent that turn: after the user's own, an error end is their cancellation. - const stop = settlesTurn ? turn.stop : null + // Read before the settle below closes the turn: a person's Stop of it makes an error end theirs. + const turnId = settlesTurn ? turn.id : null + const stoppedByPerson = + turnId !== null && stopIsTurnCancellation(sink.journalLatestStop?.() ?? null, turnId) if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) // The turn is over however it ended, so a foreground child still // reported as working will never be settled by an event. subagents.settleTurn(turn.groupKey) - context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt, stop)) + context.settle( + message, + commandEnd ?? claudeTurnEndForResult(message, observedAt, stoppedByPerson) + ) // The turn is over. A block still awaiting its final keeps the text the // flush above journaled, but its live state goes: an interrupted turn // would otherwise retain that text for the life of the session. @@ -72,7 +78,7 @@ export function journalClaudeResult( streamedText.settle() } const kind = claudeProviderFrameKind(message) - const failure = claudeResultFailure(message, stop) + const failure = claudeResultFailure(message, stoppedByPerson) if (failure || !isSettledClaudeResultKind(kind)) { providerFallback.append( kind, diff --git a/src/main/claude/claude-result-outcome.ts b/src/main/claude/claude-result-outcome.ts index cdbf5a529bfc..aa5e0ca6fa23 100644 --- a/src/main/claude/claude-result-outcome.ts +++ b/src/main/claude/claude-result-outcome.ts @@ -6,8 +6,6 @@ // the user's or the provider's. import type { AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { stopIsTheUsers } from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import { claudeText } from './claude-structured-item-translation' /** The SDK reports the user's stop as an error result, so `is_error` alone cannot @@ -15,16 +13,17 @@ import { claudeText } from './claude-structured-item-translation' const CLAUDE_ABORTED_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools']) /** A success-subtype result still carries `is_error` for an API error, so the flag - * is what decides, never the subtype. `stop` is the stop Orca sent for this turn: an error - * end after the user's own is their cancellation, since older CLIs name no reason. */ + * is what decides, never the subtype. `stoppedByPerson`: a person's Stop event names this turn + * (`stopIsTurnCancellation`), so an error end is their cancellation, since older CLIs name no + * reason. */ export function claudeResultOutcome( message: Record, - stop: StructuredAgentSessionStopCause | null = null + stoppedByPerson = false ): AgentJournalTurnOutcome { if (message.is_error !== true) { return 'success' } - if (stop !== null && stopIsTheUsers(stop)) { + if (stoppedByPerson) { return 'cancellation' } const reason = claudeText(message.terminal_reason) diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index e4aa15352c3f..4ba08d3b642c 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -249,8 +249,6 @@ describe('answerClaudePrompt', () => { resolve: resolvePrompt }, currentTurnId: null, - recordTurnStop: () => true, - withdrawTurnStop: () => {}, commandTurnId: null, beginCommand: vi.fn(), forgetCommand: vi.fn(), diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 412439a0be95..54629df0fcda 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -4,7 +4,6 @@ import { ClaudeControlRequestError } from './claude-stream-json-connection' import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import type { ClaudeSession } from './claude-structured-session-state' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' @@ -26,8 +25,7 @@ export async function cancelClaudeTurn( session: ClaudeSession, timeoutMs: number | undefined, isCurrent: ClaudeTurnCancellationGuard = () => true, - onDispatchSettledLate?: ClaudeLateDispatchSettlement, - stopped?: { turnId: string; cause: StructuredAgentSessionStopCause } + onDispatchSettledLate?: ClaudeLateDispatchSettlement ): Promise<{ cancelled: boolean }> { // The SDK interrupt is session-scoped. Re-check the caller's turn/fence // immediately before issuing it so a delayed request cannot stop a later turn. @@ -35,10 +33,6 @@ export async function cancelClaudeTurn( return { cancelled: false } } const cancelQueued = supportsClaudeQueuedInterruptCancellation(session) - // Recorded before the interrupt goes out, so the result it provokes finds it. - if (stopped) { - session.translator?.recordTurnStop(stopped.turnId, stopped.cause) - } try { const receipt = await session.connection.interrupt({ ...(cancelQueued ? { cancelQueued: true } : {}), @@ -58,11 +52,8 @@ export async function cancelClaudeTurn( return { cancelled: true } } catch (error) { if (error instanceof ClaudeControlRequestError) { - // The CLI refused, so the turn runs on and its own end means what it says. Any other error - // leaves the interrupt's effect unknown, and the stop the user asked for stands. - if (stopped) { - session.translator?.withdrawTurnStop(stopped.turnId) - } + // The CLI refused, so the turn runs on: the host answers the Stop's event with a refusal. + // Any other error leaves the interrupt's effect unknown, and the Stop stands. return { cancelled: false } } throw error diff --git a/src/main/claude/claude-structured-in-turn-stop.test.ts b/src/main/claude/claude-structured-in-turn-stop.test.ts index c5bbc51d8bc7..4575179dca08 100644 --- a/src/main/claude/claude-structured-in-turn-stop.test.ts +++ b/src/main/claude/claude-structured-in-turn-stop.test.ts @@ -1,10 +1,12 @@ // A user's Stop inside a live Claude chat interrupts the turn and keeps the session. The turn's end -// then comes from the CLI's result frame, which CLIs before 2.1.91 send with no terminal_reason. +// then comes from the CLI's result frame, which CLIs before 2.1.91 send with no terminal_reason; the +// host's Stop event, written before the interrupt, says whose end it was. import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { JournalLatestStop } from '../native-chat/agent-session-journal/journal-stop-turn-end' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { PROVIDER_SESSION_ID, @@ -28,8 +30,11 @@ async function runningChat(claude: ReturnType): Promise<{ bodies: Map connection: FakeConnection turnId: string + /** What the host's Stop writes before the interrupt, and its refusal answer after one. */ + stopEvent: (turnId: string, refused?: boolean) => void }> { const bodies = new Map() + let latestStop: JournalLatestStop | null = null const adapter = adapterFor(claude) await adapter.acquire({ identity: identityFor(), @@ -38,7 +43,8 @@ async function runningChat(claude: ReturnType): Promise<{ events: { appendItem: (identity, body) => bodies.set(agentJournalItemKey(identity), body), appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)), - publish: vi.fn() + publish: vi.fn(), + journalLatestStop: () => latestStop } }) await adapter.dispatch({ @@ -56,7 +62,14 @@ async function runningChat(claude: ReturnType): Promise<{ if (!turnId) { throw new Error('expected a running turn') } - return { adapter, bodies, connection, turnId } + const stopEvent = (stoppedTurnId: string, refused = false) => { + latestStop = { + sequence: 9, + event: { reason: 'user-stop', turnId: stoppedTurnId, at: 1 }, + refused + } + } + return { adapter, bodies, connection, turnId, stopEvent } } function settled(bodies: Map, turnId: string) { @@ -80,7 +93,8 @@ describe("a user's Stop inside a live Claude chat", () => { } } }) - const { adapter, bodies, turnId } = await runningChat(claude) + const { adapter, bodies, turnId, stopEvent } = await runningChat(claude) + stopEvent(turnId) await expect(adapter.cancelTurn({ sessionId: 'session-1', turnId, fence: 7 })).resolves.toEqual( { cancelled: true } @@ -103,7 +117,9 @@ describe("a user's Stop inside a live Claude chat", () => { } } }) - const { adapter, bodies, turnId } = await runningChat(claude) + const { adapter, bodies, turnId, stopEvent } = await runningChat(claude) + // The host names the open turn on the Stop's event. + stopEvent(turnId) await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ cancelled: true @@ -127,6 +143,8 @@ describe("a user's Stop inside a live Claude chat", () => { it('keeps a Stop naming no turn off the turn after it', async () => { const claude = fakeClaude({ + // Each turn is its own uuid, as Claude's are: the Stop names the first. + replayUuids: ['user-uuid-0', 'user-uuid-1'], routes: { interrupt: () => { claude.connections[0]!.handlers.onMessage?.(CUT_SHORT) @@ -134,7 +152,8 @@ describe("a user's Stop inside a live Claude chat", () => { } } }) - const { adapter, bodies, connection } = await runningChat(claude) + const { adapter, bodies, connection, turnId, stopEvent } = await runningChat(claude) + stopEvent(turnId) await adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) await adapter.dispatch({ @@ -168,11 +187,14 @@ describe("a user's Stop inside a live Claude chat", () => { } } }) - const { adapter, bodies, connection, turnId } = await runningChat(claude) + const { adapter, bodies, connection, turnId, stopEvent } = await runningChat(claude) + stopEvent(turnId) await expect( adapter.cancelTurn({ sessionId: 'session-1', ...(named ? { turnId } : {}), fence: 7 }) ).resolves.toEqual({ cancelled: false }) + // The host answers the refused interrupt on the Stop's event. + stopEvent(turnId, true) connection.handlers.onMessage?.(CUT_SHORT) expect(settled(bodies, turnId)).toMatchObject({ state: 'completed', outcome: 'failure' }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index dcf3a3a67209..c036b7d67405 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -1,7 +1,3 @@ -import { - childEndCauseOfEndedEvent, - turnVerdictForChildEnd -} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' @@ -189,11 +185,9 @@ export function createClaudeJournalTranslator( streamedText.flush() subagents.settleSession() backgroundTasks.settleSession() - // The host saw the child end, so the turn's end is observed, not lost; its verdict is only - // what the host's own cause says, a user's stop of this chat or else news. - turn.settle( - turnVerdictForChildEnd(childEndCauseOfEndedEvent(event), event.observedAt ?? Date.now()) - ) + // The host saw the child end, so the turn's end is observed, not lost. Whether it was a + // person's Stop is the journal's Stop event to say (`turnEndAfterStop`), else it is news. + turn.settle({ state: 'interrupted', completedAt: event.observedAt ?? Date.now() }) // A frame that arrives after the child is gone must not open a turn no // event can close. turn.suppressReopen() @@ -282,8 +276,6 @@ export function createClaudeJournalTranslator( get currentTurnId() { return turn.id }, - recordTurnStop: (turnId, cause) => turn.recordStop(turnId, cause), - withdrawTurnStop: (turnId) => turn.withdrawStop(turnId), get commandTurnId() { return turn.command ? turn.id : null }, diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 1014455324ea..78c63af80752 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -75,16 +75,7 @@ function cancelClaudeConversation( session.fence === request.fence && session.acquisitionGeneration === acquisitionGeneration && (claudeLiveTurnId(session, request) !== null || session.dispatchWaiters.length > 0) - // A turn is cancelled only at a client's request, so the stop is the user's: on the named turn, - // else on whatever turn is open. - const stoppedTurnId = request.turnId ?? session.translator?.currentTurnId ?? null - return cancelClaudeTurn( - session, - timeoutMs, - isCurrent, - onDispatchSettledLate, - stoppedTurnId === null ? undefined : { turnId: stoppedTurnId, cause: 'user-stop' } - ) + return cancelClaudeTurn(session, timeoutMs, isCurrent, onDispatchSettledLate) } export async function cancelClaudeStructuredTurn(input: { @@ -175,7 +166,6 @@ export async function cancelClaudeStructuredTurn(input: { : compactionOwnsTurn() || (ownsRequestedTurn() && dispatchAdmissionAllowsCancellation())) let interruptConfirmed = false try { - // A turn is cancelled only at a client's request, so the stop is the user's. const result = await cancelClaudeTurn( session, timeoutMs, @@ -187,8 +177,7 @@ export async function cancelClaudeStructuredTurn(input: { } return current }, - input.onDispatchSettledLate, - { turnId: requestedTurnId, cause: 'user-stop' } + input.onDispatchSettledLate ) if (result.cancelled && claim && cancellationObserved) { interruptConfirmed = true diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index 0da42846fe54..c73f22105999 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -16,7 +16,6 @@ import { type ClaudeMessageEnvelope } from './claude-structured-item-translation' import { claudeResultOutcome } from './claude-result-outcome' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeRowStamp } from './claude-provisional-row-corrections' import { CLAUDE_API_RETRY_FRAME_KIND, @@ -53,11 +52,11 @@ export function isSettledClaudeResultKind(kind: string): boolean { */ export function claudeResultFailure( message: Record, - stop: StructuredAgentSessionStopCause | null = null + stoppedByPerson = false ): { text: string | null } | null { // A cancellation is not a fault and earns no error row; the outcome classifier // owns that distinction so this reader cannot drift from the turn's verdict. - if (claudeResultOutcome(message, stop) !== 'failure') { + if (claudeResultOutcome(message, stoppedByPerson) !== 'failure') { return null } const result = claudeText(message.result)?.trim() diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 43576cc44e96..0ca9ca2a3b9e 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -3,8 +3,7 @@ import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, StructuredAgentSessionAcquireInput, - StructuredAgentSessionAdapter, - StructuredAgentSessionStopCause + StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' @@ -283,30 +282,21 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) }) - closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => + closeSession = (sessionId: string): Promise => // After the close, not before: releasing an exit still settling settles it on the way. - this.closeSessionProcess(sessionId, cause).finally(() => - this.settledExitErrors.delete(sessionId) - ) + this.closeSessionProcess(sessionId).finally(() => this.settledExitErrors.delete(sessionId)) - private closeSessionProcess( - sessionId: string, - cause: StructuredAgentSessionStopCause | undefined - ): Promise { + private closeSessionProcess(sessionId: string): Promise { // An exit seen first settles as that exit, whoever asked for the close after it. if (this.exits.has(sessionId)) { return this.releaseAcquisition({ sessionId }) } - return this.afterClose(sessionId, () => this.closeProviderSession(sessionId, cause)) + return this.afterClose(sessionId, () => this.closeProviderSession(sessionId)) } - private closeProviderSession = ( - sessionId: string, - stopCause?: StructuredAgentSessionStopCause - ): Promise => + private closeProviderSession = (sessionId: string): Promise => closeClaudeSession({ sessionId, - ...(stopCause ? { stopCause } : {}), sessions: this.sessions, acquisitions: this.acquisitions, ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 91a0df1e2b19..f49b6a8d88f9 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -6,7 +6,6 @@ import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { cancelClaudeAcquisitionAttempt } from './claude-structured-session-state' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRootExitObservedError, @@ -78,8 +77,6 @@ export function settleClaudeExitedSession(session: ClaudeSession): void { type CloseClaudePublishedSessionInput = { sessions: Map sessionId: string - /** Who asked for the close; the translator settles the open turn with it. */ - stopCause?: StructuredAgentSessionStopCause persistHandle?: (handle: { sessionId: string providerSessionId: string @@ -138,7 +135,6 @@ async function finalizeClaudePublishedSession( type: 'ended', sessionId: input.sessionId, reason: 'claude session closed', - ...(input.stopCause ? { stopCause: input.stopCause } : {}), observedAt: Date.now() } as const let callbackError: unknown @@ -246,7 +242,6 @@ export function closeClaudePublishedSessionForDeps( export async function closeClaudeSession(input: { sessionId: string - stopCause?: StructuredAgentSessionStopCause sessions: Map acquisitions: ClaudeAcquisitionRegistry persistHandle?: (handle: { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index fd51d2987e02..0d43dbb99ec8 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -5,10 +5,7 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { - StructuredAgentSessionStartedEvent, - StructuredAgentSessionStopCause -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection @@ -76,8 +73,6 @@ export type ClaudeStructuredSessionEvent = failure?: SubmissionRejectionFact /** Present for first-hand child exits so the host can fence recovery. */ cause?: 'unexpected-exit' | 'requested-close' - /** Who asked for a close; the translator settles the open turn with it. */ - stopCause?: StructuredAgentSessionStopCause fence?: number acquisitionGeneration?: string /** Host clock when the end was observed. */ diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index da3af8823093..63cf708f1d47 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -5,7 +5,6 @@ import type { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeResultOutcome } from './claude-result-outcome' import type { ClaudeCommandTurn } from './claude-command-turn' @@ -46,9 +45,9 @@ export type ClaudeTurnEnd = { export function claudeTurnEndForResult( message: Record, completedAt: number, - stop: StructuredAgentSessionStopCause | null = null + stoppedByPerson = false ): ClaudeTurnEnd { - const outcome = claudeResultOutcome(message, stop) + const outcome = claudeResultOutcome(message, stoppedByPerson) const durationMs = message.duration_ms return { state: outcome === 'cancellation' ? 'interrupted' : 'completed', diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts index c8de34cee080..97fdf36de938 100644 --- a/src/main/claude/claude-turn-outcome.test.ts +++ b/src/main/claude/claude-turn-outcome.test.ts @@ -12,6 +12,8 @@ import { import { describeNativeChatTurnStatus } from '../../shared/native-chat-turn-status' import { selectStructuredAgentSettledTurns } from '../../shared/structured-agent-session-turn-timing' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { JournalStopEvent } from '../native-chat/agent-session-journal/journal-row-schema' +import type { JournalLatestStop } from '../native-chat/agent-session-journal/journal-stop-turn-end' import { claudeResultOutcome } from './claude-result-outcome' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { claudeResultFailure } from './claude-structured-provider-fallback' @@ -233,10 +235,24 @@ describe('a turn end the host inferred', () => { }) }) -describe("a user's Stop inside a live turn", () => { +describe("a person's Stop inside a live turn", () => { // Claude CLIs before 2.1.91 send no terminal_reason, and later ones may omit it. const cutShort = { type: 'result', subtype: 'error_during_execution', is_error: true } + /** A sink whose journal's latest Stop event is `stop`, as the host's would answer. */ + function sinkWithStop(stop: JournalLatestStop | null) { + const state = sinkState() + return { ...state, sink: { ...state.sink, journalLatestStop: () => stop } } + } + + function stopOf( + turnId: string, + reason: JournalStopEvent['reason'] = 'user-stop', + refused = false + ): JournalLatestStop { + return { sequence: 9, event: { reason, turnId, at: 1 }, refused } + } + function settledTurn(items: ReturnType['items'], turnId: string) { return items .map((item) => readAgentJournalTurn(item.body)) @@ -252,11 +268,10 @@ describe("a user's Stop inside a live turn", () => { } it('reads an error result with no terminal reason as the cancellation it asked for', () => { - const state = sinkState() + const state = sinkWithStop(stopOf('user-1')) const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - expect(translator.recordTurnStop('user-1', 'user-stop')).toBe(true) translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) expect(settledTurn(state.items, 'user-1')).toMatchObject({ @@ -273,7 +288,7 @@ describe("a user's Stop inside a live turn", () => { { ...cutShort, terminal_reason: 'api_error', result: 'API Error' } ] ])('keeps a result with %s and no Stop a failure', (_label, frame) => { - const state = sinkState() + const state = sinkWithStop(null) const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) @@ -287,11 +302,10 @@ describe("a user's Stop inside a live turn", () => { }) it('keeps a turn that finished during the Stop a success', () => { - const state = sinkState() + const state = sinkWithStop(stopOf('user-1')) const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - translator.recordTurnStop('user-1', 'user-stop') translator.handle({ type: 'message', sessionId: 'orca-session', @@ -302,15 +316,12 @@ describe("a user's Stop inside a live turn", () => { }) it('does not carry a Stop onto the next turn', () => { - const state = sinkState() + // The Stop named user-1, which a newer send superseded before its result. + const state = sinkWithStop(stopOf('user-1')) const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - translator.recordTurnStop('user-1', 'user-stop') - // Superseded before its result: the Stop was for user-1 only. translator.handle(userTurn('user-2')) translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) - // A late Stop names a turn that already ended. - expect(translator.recordTurnStop('user-2', 'user-stop')).toBe(false) translator.handle(userTurn('user-3')) translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) @@ -319,10 +330,9 @@ describe("a user's Stop inside a live turn", () => { }) it('ends the Stop with its turn, so a result after the turn settled reads on its own', () => { - const state = sinkState() + const state = sinkWithStop(stopOf('user-1')) const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - translator.recordTurnStop('user-1', 'user-stop') translator.handle({ type: 'message', sessionId: 'orca-session', @@ -334,24 +344,21 @@ describe("a user's Stop inside a live turn", () => { expect(providerRows(state.items)).toBe(1) }) - it('forgets a Stop the CLI refused', () => { - const state = sinkState() + it('reads a Stop the CLI refused as no Stop', () => { + const state = sinkWithStop(stopOf('user-1', 'user-stop', true)) const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - translator.recordTurnStop('user-1', 'user-stop') - translator.withdrawTurnStop('user-1') translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'failure' }) }) - it('does not read a host stop as the user asking', () => { - const state = sinkState() + it.each(['host-stop', 'evict'] as const)('does not read a %s as the person asking', (reason) => { + const state = sinkWithStop(stopOf('user-1', reason)) const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - translator.recordTurnStop('user-1', 'host-stop') translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'failure' }) diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts index a7255aab558a..6bfcb32cb863 100644 --- a/src/main/claude/claude-turn-ownership.test.ts +++ b/src/main/claude/claude-turn-ownership.test.ts @@ -95,8 +95,6 @@ function sessionHoldingTurn(turnId: string | null): ReturnType true, - withdrawTurnStop: () => {}, commandTurnId: null, beginCommand: vi.fn(), forgetCommand: vi.fn(), diff --git a/src/main/codex/codex-close-stop-cause.test.ts b/src/main/codex/codex-close-turn-end.test.ts similarity index 59% rename from src/main/codex/codex-close-stop-cause.test.ts rename to src/main/codex/codex-close-turn-end.test.ts index 69618f22dd87..1e3475301bf0 100644 --- a/src/main/codex/codex-close-stop-cause.test.ts +++ b/src/main/codex/codex-close-turn-end.test.ts @@ -1,10 +1,9 @@ -// Closing a Codex child settles its open turn in the adapter, with the cause the host handed the -// close. Only a stop the user aimed at this chat reads as their cancellation. +// Closing a Codex child settles its open turn in the adapter as interrupted, with no verdict: the +// close names no cause. Whether the end was a person's is the journal's Stop event to say. import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' @@ -75,48 +74,26 @@ function sessionWithRunningTurn() { return { sessions: new Map([['session-1', session]]), session, turnBodies } } -describe('a Codex close settles the open turn with the host-named cause', () => { - it.each(['user-close', 'user-stop'] satisfies StructuredAgentSessionStopCause[])( - "records the user's %s as their cancellation", - async (stopCause) => { - const { sessions, turnBodies } = sessionWithRunningTurn() - const onEvent = vi.fn() - - await expect( - closeCodexPublishedSession(sessions, 'session-1', onEvent, { stopCause }) - ).resolves.toBe(true) - - expect(turnBodies).toEqual([ - expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', outcome: 'cancellation' }) - ]) - expect(onEvent).toHaveBeenCalledWith( - expect.objectContaining({ type: 'ended', cause: 'requested-close', stopCause }) - ) - } - ) - - it.each([['evict'], ['host-stop'], [undefined]] as const)( - 'leaves a close for %s as news', - async (stopCause) => { - const { sessions, turnBodies } = sessionWithRunningTurn() +describe('a Codex close settles the open turn with no verdict of its own', () => { + // Whose end it was is the journal's Stop event to say (`turnEndAfterStop`), never the close's. + it('ends it interrupted at the exit, with no outcome', async () => { + const { sessions, turnBodies } = sessionWithRunningTurn() + const onEvent = vi.fn() - await closeCodexPublishedSession( - sessions, - 'session-1', - undefined, - stopCause ? { stopCause } : {} - ) + await expect(closeCodexPublishedSession(sessions, 'session-1', onEvent)).resolves.toBe(true) - expect(turnBodies).toEqual([ - expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) - ]) - expect(turnBodies[0]).not.toHaveProperty('outcome') - } - ) + expect(turnBodies).toEqual([ + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) + ]) + expect(turnBodies[0]).not.toHaveProperty('outcome') + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ended', cause: 'requested-close' }) + ) + }) - it('leaves a crash it saw before the user closed the chat as news', async () => { + it('settles a crash it saw before the close once, as that exit', async () => { const { sessions, session, turnBodies } = sessionWithRunningTurn() - // The child died on its own first; the user's close then finds it already ended. + // The child died on its own first; the close then finds it already ended. handleCodexSessionExit({ sessions, sessionId: 'session-1', @@ -124,7 +101,7 @@ describe('a Codex close settles the open turn with the host-named cause', () => error: new Error('app-server exited') }) - await closeCodexPublishedSession(sessions, 'session-1', undefined, { stopCause: 'user-close' }) + await closeCodexPublishedSession(sessions, 'session-1') expect(turnBodies).toEqual([ expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) @@ -132,18 +109,7 @@ describe('a Codex close settles the open turn with the host-named cause', () => expect(turnBodies[0]).not.toHaveProperty('outcome') }) - it('never carries a user cause onto a close forced after a sink failure', async () => { - const { sessions, turnBodies } = sessionWithRunningTurn() - - await closeCodexPublishedSession(sessions, 'session-1', undefined, { - requestedClose: false, - stopCause: 'user-close' - }) - - expect(turnBodies[0]).not.toHaveProperty('outcome') - }) - - it("carries the host's cause from the adapter's close to the ended batch", async () => { + it("settles the same through the adapter's close", async () => { const { sessions, turnBodies } = sessionWithRunningTurn() const teardown = new CodexStructuredSessionTeardown({ sessions, @@ -151,8 +117,11 @@ describe('a Codex close settles the open turn with the host-named cause', () => forgetNotificationRetries: () => {} }) - await expect(teardown.close('session-1', 'user-close')).resolves.toBe(true) + await expect(teardown.close('session-1')).resolves.toBe(true) - expect(turnBodies[0]).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(turnBodies).toEqual([ + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) + ]) + expect(turnBodies[0]).not.toHaveProperty('outcome') }) }) diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index b1e8b8b6510c..8836c3157117 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -132,7 +132,7 @@ describe('a Codex reconnecting a dropped stream', () => { // Once the frames stop, the same clock does let the sweep close it. clock += STRUCTURED_AGENT_SESSION_IDLE_MS await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(closeSession).toHaveBeenCalledWith(SESSION) expect(host.hasSession(SESSION)).toBe(false) }) }) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 7200e5bc76ac..20fd8bf58df3 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -1,7 +1,3 @@ -import { - childEndCauseOfEndedEvent, - turnVerdictForChildEnd -} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import { createCodexProviderActivityReader } from '../native-chat/agent-session-wire/provider-frame-activity' import { CODEX_TOKEN_USAGE_METHOD } from './codex-subagent-activity' import { @@ -162,18 +158,14 @@ export function createCodexJournalTranslator( currentTurnIds: activeTurns.byThread, primaryThreadId: deps.primaryThreadId?.() ?? null, ordinals: items.ordinals, - // The host saw the child go, not what Codex made of the turn: the verdict is only what - // the host's own cause says, a user's stop of this chat or else news. + // The host saw the child go, not what Codex made of the turn: whether it was a person's + // Stop is the journal's Stop event to say (`turnEndAfterStop`), else it is news. settledTurnLifecycle: (threadId, turnId) => turnBoundaries.ownsRecord(threadId, turnId) - ? turnBoundaries.settled( - threadId, - turnId, - turnVerdictForChildEnd( - childEndCauseOfEndedEvent(event), - event.observedAt ?? deps.now?.() ?? Date.now() - ) - ) + ? turnBoundaries.settled(threadId, turnId, { + state: 'interrupted', + completedAt: event.observedAt ?? deps.now?.() ?? Date.now() + }) : null, attributionFor }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 2ab884d7cd1e..e0b1a93b395a 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -9,8 +9,7 @@ import type { AgentSessionDispatchOutcome, StructuredAgentSessionAcquireInput, StructuredAgentSessionAdapter, - StructuredAgentSessionSetOptionInput, - StructuredAgentSessionStopCause + StructuredAgentSessionSetOptionInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' @@ -286,11 +285,9 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap identity: AgentSessionJournalIdentity }): Promise => this.sessions.get(input.identity.sessionId)?.historyPath ?? null - closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => - this.teardown.close(sessionId, cause) + closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) - disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => - this.teardown.close(sessionId, cause) + disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) closeAll = (): Promise => this.teardown.closeAll() releaseAcquisition = (input: { sessionId: string }): Promise => this.teardown.close(input.sessionId) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 0a017f37f191..5bf973308b10 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -8,10 +8,7 @@ import { type CodexStructuredSessionAdapterDeps, type CodexStructuredSessionEvent } from './codex-structured-session-state' -import type { - StructuredAgentSessionEndedEvent, - StructuredAgentSessionStopCause -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' export function handleCodexSessionExit(input: { sessions: Map @@ -43,9 +40,6 @@ export function handleCodexSessionExit(input: { ? agentSessionFailureFact('hostFault') : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), cause: session.requestedClose ? 'requested-close' : 'unexpected-exit', - ...(session.requestedClose && session.closeStopCause - ? { stopCause: session.closeStopCause } - : {}), fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, observedAt: session.exitObservedAt @@ -80,8 +74,6 @@ export async function closeCodexPublishedSession( options?: { allowFailedSettlement?: boolean requestedClose?: boolean - /** Who asked for a requested close; the translator settles the open turn with it. */ - stopCause?: StructuredAgentSessionStopCause expectedFence?: number expectedAcquisitionGeneration?: string unexpectedReason?: Error @@ -101,7 +93,6 @@ export async function closeCodexPublishedSession( // Sink-failure recovery force-closes the child but must preserve the // observed-exit cause so host lease settlement runs as an unexpected death. session.requestedClose = options?.requestedClose ?? true - session.closeStopCause = options?.stopCause // Keep the session indexed until the child exit is observed. A timeout or // failed kill must leave the live connection available for a safe retry. const exited = await session.connection.close() @@ -133,8 +124,7 @@ export async function closeCodexSession( sessionId: string, sessions: Map, acquisitions: CodexAcquisitionRegistry, - onEvent?: (event: CodexStructuredSessionEvent) => void, - stopCause?: StructuredAgentSessionStopCause + onEvent?: (event: CodexStructuredSessionEvent) => void ): Promise { const attempt = acquisitions.get(sessionId) if (!(await cancelCodexAcquisitionAttempt(attempt))) { @@ -143,7 +133,7 @@ export async function closeCodexSession( if (attempt) { acquisitions.deleteIfCurrent(sessionId, attempt) } - return closeCodexPublishedSession(sessions, sessionId, onEvent, stopCause ? { stopCause } : {}) + return closeCodexPublishedSession(sessions, sessionId, onEvent) } export async function closeAllCodexSessions( diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 78864fe90448..fcfb120fe821 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -19,10 +19,7 @@ import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' -import type { - StructuredAgentSessionEndedEvent, - StructuredAgentSessionStopCause -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import type { AgentModelCatalogSessionAccess, @@ -114,8 +111,6 @@ export type CodexSession = { /** First observed child exit survives rejected settlement admission. */ exitObservedAt?: number requestedClose: boolean - /** Who asked for the requested close in flight, carried onto its `ended`. */ - closeStopCause?: StructuredAgentSessionStopCause fence: number acquisitionGeneration: string threadId: string diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index f302e15b3671..dcd38eb82fe2 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -5,7 +5,6 @@ // proven stopped — a refused close leaves the session indexed for a retry. import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' -import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { closeAllCodexSessions, closeCodexPublishedSession, @@ -31,13 +30,12 @@ export type CodexStructuredSessionTeardownDeps = { export class CodexStructuredSessionTeardown { constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {} - close = async (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => { + close = async (sessionId: string): Promise => { const closed = await closeCodexSession( sessionId, this.deps.sessions, this.deps.acquisitions, - this.deps.onEvent, - cause + this.deps.onEvent ) return this.settled(sessionId, closed) } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 016fe47d7f9e..1f03dd9e2388 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -80,31 +80,59 @@ function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { } } +/** Whether `stop` makes the end of turn `turnId` a person's cancellation: a person's Stop or close + * that named that turn (`opened`: or named none, and the turn opened under it), and that no + * refusal answered. */ +export function stopIsTurnCancellation( + stop: JournalLatestStop | null, + turnId: string, + opened?: { createdAt: number | null; latestPersonTurnSequence: number } +): boolean { + if (stop === null || stop.refused || !stopIsAPersons(stop.event.reason)) { + return false + } + if (stop.event.turnId !== undefined || !opened) { + return stop.event.turnId === turnId + } + // Pressed before the turn showed: the turn its row opened after the Stop, unless a send a + // person made since was accepted, whose turn it would be. + return ( + (opened.createdAt === null || opened.createdAt > stop.sequence) && + opened.latestPersonTurnSequence < stop.sequence + ) +} + /** * The body to write for item `itemId`: unchanged unless it ends, with no verdict of its own and no * earlier than the latest Stop event, a person's, which named it while it was still open (running, * or unproven) and which no refusal answered. A provider's own verdict always stands. */ export function turnEndAfterStop( - state: Pick, + state: Pick< + JournalReducerState, + 'items' | 'queuePauseMarks' | 'latestStopRefusal' | 'latestPersonTurnSequence' + >, itemId: string, body: AgentJournalItemBody ): AgentJournalItemBody { if (body.kind !== 'turn' || body.state !== 'interrupted' || body.outcome !== undefined) { return body } - const previous = readAgentJournalTurn(state.items.get(itemId)?.body) + const existing = state.items.get(itemId) + const previous = readAgentJournalTurn(existing?.body) // An end already written stands: the Stop came after it. if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { return body } const stop = journalLatestStop(state) - return stop && - !stop.refused && - stop.event.turnId === body.turnId && - stopIsAPersons(stop.event.reason) && - // An exit the provider saw before the Stop was news, whenever its end is written. - (body.completedAt === undefined || body.completedAt >= stop.event.at) - ? { ...body, outcome: 'cancellation' } - : body + const opened = { + createdAt: existing?.sequence ?? null, + latestPersonTurnSequence: state.latestPersonTurnSequence + } + if (!stop || !stopIsTurnCancellation(stop, body.turnId, opened)) { + return body + } + // An exit the provider saw before the Stop was news, whenever its end is written. + const endedAfterStop = body.completedAt === undefined || body.completedAt >= stop.event.at + return endedAfterStop ? { ...body, outcome: 'cancellation' } : body } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index fb6c001b320a..cc8decab8ffb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -162,7 +162,7 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => await expect(stopSession('session-1')).resolves.toBe(true) // A host with no cause to name passes none; the adapter settles its turn as news. - expect(closeSession).toHaveBeenCalledWith('session-1', undefined) + expect(closeSession).toHaveBeenCalledWith('session-1') } ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 72291de48245..9a759ba0411b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -4,10 +4,7 @@ import type { AgentSessionAccountHome, AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionStopCause -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' type RoutedAgent = 'claude' | 'codex' type SessionRoute = { adapter: StructuredAgentSessionAdapter; state: 'live' | 'stopped' } @@ -151,21 +148,20 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi accountHome: AgentSessionAccountHome }) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null) - closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => - this.stopSession(sessionId, (adapter) => adapter.closeSession, cause) + closeSession = (sessionId: string): Promise => + this.stopSession(sessionId, (adapter) => adapter.closeSession) forceCloseSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => adapter.forceCloseSession ?? adapter.closeSession) - disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => - this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession, cause) + disposeSession = (sessionId: string): Promise => + this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession) private async stopSession( sessionId: string, selectStop: ( adapter: StructuredAgentSessionAdapter - ) => NonNullable | undefined, - cause?: StructuredAgentSessionStopCause + ) => NonNullable | undefined ): Promise { const route = this.routes.get(sessionId) if (!route) { @@ -178,7 +174,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return true } const stop = selectStop(route.adapter) - const stopped = await stop?.call(route.adapter, sessionId, cause) + const stopped = await stop?.call(route.adapter, sessionId) if (stopped === true) { route.state = 'stopped' return true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 6d19c8523bb7..f12735032984 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -38,7 +38,6 @@ import type { ProviderDiagnostic, SubmissionRejectionFact } from '../../../shared/agent-session-failure' -import type { StructuredAgentSessionStopCause } from './structured-agent-session-stop-cause' export type { StructuredAgentSessionChildEndCause, StructuredAgentSessionStopCause @@ -191,8 +190,6 @@ export type StructuredAgentSessionEndedEvent = { * Orca fault. Absent reads as a provider exit with nothing to add. */ failure?: SubmissionRejectionFact cause: 'unexpected-exit' | 'requested-close' - /** With `requested-close`: who asked for it. Absent when the host named no cause. */ - stopCause?: StructuredAgentSessionStopCause fence: number acquisitionGeneration: string /** Host receipt of the child exit: the end time of a turn it interrupted. */ @@ -382,11 +379,11 @@ export type StructuredAgentSessionAdapter = { /** Gracefully stops the structured owner after its event stream is drained. */ /** Returns true only after the provider child exit is proven. A root-exit or processless verdict * is thrown only once the session is finalized; read it through `stopAgentSessionProviderRoot`. */ - closeSession?(sessionId: string, cause?: StructuredAgentSessionStopCause): Promise + closeSession?(sessionId: string): Promise /** Stops a provider after a sink failure; the resulting exit is recovered as unexpected. */ forceCloseSession?(sessionId: string): Promise /** Stops a provider child for teardown without requiring a future-resume cursor. */ - disposeSession?(sessionId: string, cause?: StructuredAgentSessionStopCause): Promise + disposeSession?(sessionId: string): Promise /** Host acknowledgement that the proven-dead child, lease and journal owner are released. */ acknowledgeSessionRelease?(sessionId: string): void } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 83d80437de3c..1a42f5803c86 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -1,7 +1,8 @@ // A turn the host cuts short by closing its provider is the user's cancellation only when the user // closed this chat. A quit, an idle eviction or a teardown aimed elsewhere leaves it news: the user -// needs to learn it did not finish. The adapter settles its own open turn with the cause the host -// hands its close, and the host's fallback settles any turn no adapter did, through one mapping. +// needs to learn it did not finish. The adapter settles its own open turn interrupted, the host's +// fallback settles any turn no adapter did, and both ends read the close's Stop event where the +// row is built: no cause travels with the close. import { beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -15,28 +16,26 @@ import { selectStructuredAgentSettledTurns } from '../../../shared/structured-ag import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import type { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { adapter, attach, + CALLER, + envelope, hostTestState, replaceHostTestState } from './structured-agent-session-host-test-harness' import { + hostTestMessage, HOST_TEST_NOW, HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' -import { - childEndCauseOfEndedEvent, - turnVerdictForChildEnd -} from './structured-agent-session-stale-turn-verdict' const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 } let host: StructuredAgentSessionHost -/** What the provider writes on the open turn as it exits: settled through the mapping with the - * cause its close was handed, as both adapters do, or its own verdict; null writes nothing. */ +/** What the provider writes on the open turn as it exits: interrupted at the exit it saw, as both + * adapters do, or its own verdict; null writes nothing. */ let providerEnd: | 'mapped' | Pick @@ -54,22 +53,16 @@ beforeEach(() => { store: state.store, adapter: { ...adapter(), - closeSession: async (_sessionId, cause) => { + closeSession: async () => { closeCalls += 1 const events = state.acquire.mock.calls.at(-1)?.[0].events if (providerEnd === null) { return true } + // An exit it saw first ended before the close's Stop; a close it made ends after it. const end = providerEnd === 'mapped' - ? turnVerdictForChildEnd( - childEndCauseOfEndedEvent({ - type: 'ended', - cause: exitObservedFirst ? 'unexpected-exit' : 'requested-close', - ...(cause ? { stopCause: cause } : {}) - }), - 1_500 - ) + ? { state: 'interrupted' as const, completedAt: exitObservedFirst ? 1_500 : Date.now() } : providerEnd events?.appendItem( CUT_TURN, @@ -178,9 +171,15 @@ describe('a turn cut short by closing its provider', () => { }) it("records the user's close on a turn whose start landed only as the provider stopped", async () => { - // A send echo still in flight when the close arrives: the journal has no turn yet. + // A send still unanswered when the close arrives: the journal has no turn yet. await attach() + hostTestState().dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const body = hostTestMessage('long job') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) await host.flushStreamedEvents(SESSION) + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() + ) await host.close(SESSION, 'user-close') @@ -331,10 +330,3 @@ describe('a turn cut short by closing its provider', () => { } ) }) - -it('requires every stop to name its cause', () => { - type StopArgs = Parameters - // @ts-expect-error a stop that names no cause must not compile, or it would default to one - const omitted: StopArgs = [host['lifetimeContext'](), SESSION] - expect(omitted).toHaveLength(2) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 5fd28ff3927a..f5607777ecb2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -138,7 +138,7 @@ describe('closing the handle', () => { await foundRestTestChat(rig) await rig.host.close(SESSION, 'evict') - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) // The stop says not-running; the row belongs to the tab, so nothing forgets it. expect(rig.sink.forget).not.toHaveBeenCalled() expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) @@ -261,7 +261,7 @@ describe('a start that never finishes (P2-15)', () => { rig.clock.now += IDLE_MS + 1 await sweepOnce(rig.host) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'host-stop') + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) await vi.waitFor(() => expect(readerSaw(reader.events).submissions).toContainEqual( expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 1d06a61f88ca..b82fd5b305d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -16,9 +16,9 @@ import { closeStructuredAgentSessionConversationUnderSerialize, stopStructuredAgentSessionAgentUnderSerialize, type StructuredAgentSessionCloseCause, - type StructuredAgentSessionLifetimeContext + type StructuredAgentSessionLifetimeContext, + type StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' -import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' @@ -48,8 +48,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { session.journal.whenImported().catch((error: unknown) => { throw readRefusals.refusal(sessionId, error) }) - const stopAgent = (sessionId: string, cause: StructuredAgentSessionStopCause) => - stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) + const stopAgent = (sessionId: string, ending: StructuredAgentSessionStopEnding) => + stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, ending) const closeConversation = (sessionId: string): Promise => closeStructuredAgentSessionConversationUnderSerialize( @@ -77,7 +77,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { }, providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. - stopAgent: (sessionId) => stopAgent(sessionId, 'evict'), + stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict' }), // A host stop: the delivery loop waiting on this child writes the one error row and rejects // what is queued with it, both worded from the hostStopped fact. stopStartingAgent: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 2d0766a4bce8..f406cf9ecc89 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -8,6 +8,7 @@ import type { StructuredAgentSessionSinkState, StructuredAgentSessionSinkWatermarks } from './structured-agent-session-event-sink' +import type { JournalLatestStop } from '../agent-session-journal/journal-stop-turn-end' export type StructuredAgentSessionSinkOperation = { sequence: number @@ -67,6 +68,9 @@ export class StructuredAgentSessionSinkQueue { journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null + journalLatestStop = (): JournalLatestStop | null => + this.target?.journal.stopMarks.latest() ?? null + bindReadingControl(control: StructuredAgentSessionReadingControl): () => void { this.readingControl = control if (this.backpressured) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 121fb865db4a..400c86c885a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -8,6 +8,7 @@ import type { import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { JournalLatestStop } from '../agent-session-journal/journal-stop-turn-end' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' import { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' @@ -135,6 +136,8 @@ export type StructuredAgentSessionEventSink = { journalEpoch?(): string | null /** The bound journal's producer linkage; null until bound. */ journalLinkage?(): StructuredAgentSessionLinkageJournal | null + /** The bound journal's latest Stop event (`JournalStopMarks.latest`); null until bound. */ + journalLatestStop?(): JournalLatestStop | null appendLifecycleBatch?( settlementId: string, mutations: readonly JournalLifecycleMutationInput[], @@ -277,6 +280,7 @@ export function createDeferredStructuredAgentSessionEventSink( ...resolvedAppend, journalEpoch: queue.journalEpoch, journalLinkage: queue.journalLinkage, + journalLatestStop: queue.journalLatestStop, appendLifecycleBatch: (settlementId, mutations, options = {}) => { const admission = appendLifecycleBatch(settlementId, mutations, options) if (!admission.accepted) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index 38349f95115e..8fc810de2a4d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -76,7 +76,7 @@ describe('structured agent session eviction', () => { await evictStructuredAgentSession(ctx) - expect(disposeSession).toHaveBeenCalledWith('session-1', undefined) + expect(disposeSession).toHaveBeenCalledWith('session-1') expect(closeSession).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index fc45a2c77154..226781117bdf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -19,8 +19,7 @@ import { stopAgentSessionProviderRoot, - type StructuredAgentSessionAdapter, - type StructuredAgentSessionStopCause + type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { StructuredAgentSessionStopVerdict } from './structured-agent-session-host-types' @@ -28,8 +27,6 @@ import { withTimeout } from '../../../shared/promise-timeout-fallback' export type StructuredAgentSessionEvictionContext = { sessionId: string - /** Why the host stops the child; the adapter settles the turn it cuts with it. */ - stopCause?: StructuredAgentSessionStopCause hasProviderChild?: boolean eventSink: DeferredStructuredAgentSessionEventSink adapter: StructuredAgentSessionAdapter @@ -89,9 +86,7 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe // An adapter with no close has nothing to stop; anything else must PROVE the exit. const stop = context.adapter.disposeSession ?? context.adapter.closeSession const rootGone = stop - ? await stopAgentSessionProviderRoot(() => - stop.call(context.adapter, context.sessionId, context.stopCause) - ) + ? await stopAgentSessionProviderRoot(() => stop.call(context.adapter, context.sessionId)) : true if (!rootGone) { throw new Error('provider child exit was not proven') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 03639dc8c2b7..63ff659702f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -30,7 +30,6 @@ import { } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' -import { turnVerdictForChildEnd } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' @@ -88,18 +87,33 @@ function owedProviderChildWindDown( : session.owesProviderChildWindDown } -/** How a stop ends the child: its cause, the host's text for it, and whether it is quit, whose - * resume marker's trigger already records why, so it writes no Stop event. */ -export type StructuredAgentSessionStopEnding = { - cause: StructuredAgentSessionStopCause - reason?: string - quit?: true +/** How a stop ends the child. A person's Stop wrote its event in its own step (`recorded`); any + * other stop names the reason its event records, with the host's text for it. Quit writes none: + * its resume marker's trigger records why. */ +export type StructuredAgentSessionStopEnding = + | { recorded: true } + | { + cause: Exclude + reason?: string + quit?: true + } + +/** Why the child this stop ends ended, for the delivery loop (`lastEndedChild`): a person's Stop's + * reason is its event's, which its own step wrote before it got here. */ +function childEndCause( + session: StructuredAgentSessionHostSession, + ending: StructuredAgentSessionStopEnding +): StructuredAgentSessionStopCause { + // A failed write of that event leaves the Stop no less the person's. + return 'recorded' in ending + ? (session.journal.stopMarks.latest()?.event.reason ?? 'user-stop') + : ending.cause } /** * Writes this stop's event (`JournalStopEvent`) when it ends a running turn or a start: a stop - * that ends nothing writes nothing. A person's Stop wrote its own before it reached here, and quit - * writes none. Issued before the kill and never awaited by it: bookkeeping, reported on failure. + * that ends nothing writes nothing. Issued before the kill and never awaited by it: bookkeeping, + * reported on failure. */ function recordStopEvent( context: StructuredAgentSessionLifetimeContext, @@ -110,7 +124,7 @@ function recordStopEvent( const { child, journal } = session const turnId = journal.activeTurnId() if ( - ending.cause === 'user-stop' || + 'recorded' in ending || ending.quit || !child || (child.phase !== 'starting' && @@ -138,7 +152,6 @@ function recordStopEvent( export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, sessionId: string, - // Required: an omitted cause must not default to the user's cancellation. ending: StructuredAgentSessionStopEnding ): Promise { const session = context.sessions.get(sessionId) @@ -146,15 +159,11 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( return } const recorded = recordStopEvent(context, sessionId, session, ending) - // A retry finishes the stop that ended the child, so the turn that stop cut keeps its cause. - const cause = session.child - ? ending.cause - : (session.owesProviderChildWindDown?.cause ?? ending.cause) // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, // so a step that aborts after that point would otherwise leave the retry reading "no child // here" and skipping the settlement and the lease release it still owes. const owed = owedProviderChildWindDown(session) - session.owesProviderChildWindDown = owed ? { ...owed, cause } : undefined + session.owesProviderChildWindDown = owed const stopping = session.child let settlementError: unknown const eviction: StructuredAgentSessionEvictionContext = { @@ -164,8 +173,6 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( owesProviderChildWindDown: owed !== undefined, eventSink: context.runtimeState.eventSinkFor(sessionId), adapter: context.deps.adapter, - // The adapter settles its own open turn with this, so who asked travels with the stop. - stopCause: cause, ...(context.restartWitness ? { beforeProviderChildStop: () => context.restartWitness?.beforeStop(sessionId) } : {}), @@ -175,8 +182,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( endProviderChild(session, { generation: stopping.generation, fence: stopping.fence, - cause, - reason: ending.reason ?? null, + cause: childEndCause(session, ending), + reason: ('reason' in ending ? ending.reason : undefined) ?? null, duringStartup: stopping.phase === 'starting', ...verdict }) @@ -196,8 +203,9 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( fence, settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', - // Only a turn no adapter settled: one with no close, or whose settle threw. - verdict: turnVerdictForChildEnd(cause, context.now()), + // Only a turn no adapter settled: one with no close, or whose settle threw. Whether it was + // a person's Stop is its event's to say (`turnEndAfterStop`). + verdict: { state: 'interrupted', completedAt: context.now() }, showUnexpectedExitOutcome: false, onError: (id, error) => { settlementError = error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index c2ec18332882..cfb1ab885e06 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -11,8 +11,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import type { StructuredAgentSessionAdapter, StructuredAgentSessionChildEndCause, - StructuredAgentSessionProviderChildPhase, - StructuredAgentSessionStopCause + StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' @@ -37,11 +36,6 @@ export type StructuredAgentSessionProviderChildIdentity = { readonly fence: number } -/** A wind-down still owed, with the cause of the stop that owes it: a retry finishes that stop. */ -export type StructuredAgentSessionOwedWindDown = StructuredAgentSessionProviderChildIdentity & { - readonly cause: StructuredAgentSessionStopCause -} - /** The provider process behind a conversation. Written only in * `structured-agent-session-provider-child`. */ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & { @@ -89,7 +83,7 @@ export type StructuredAgentSessionHostSession = { /** The wind-down this host still owes for a child it started: settling that generation's work * and handing the lease back. Outlives `child`, which ends the moment the adapter proves the * exit — an eviction that aborts after that point must still finish it on the next close. */ - owesProviderChildWindDown?: StructuredAgentSessionOwedWindDown + owesProviderChildWindDown?: StructuredAgentSessionProviderChildIdentity lastEndedChild?: StructuredAgentSessionEndedChild } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 62603707ded9..e06fc0561269 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -280,7 +280,8 @@ export class StructuredAgentSessionHost { ensureAgent: (sessionId) => ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), - stopAgent: (sessionId) => this.lifetime.stopAgent(sessionId, 'user-stop'), + // A person's Stop: its own step wrote the Stop's event first. + stopAgent: (sessionId) => this.lifetime.stopAgent(sessionId, { recorded: true }), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 533904d5fa0f..1345adc574f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -54,7 +54,7 @@ describe('the idle sweep', () => { await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) await vi.waitFor(() => expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released')) await vi.waitFor(() => expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledOnce()) expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledWith(SESSION) @@ -122,7 +122,7 @@ describe('the idle sweep', () => { expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.adapter.backgroundTaskState.mockReturnValue(undefined) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) // A finished child reads done before the lead's wake-up turn writes its first row; stopping the @@ -143,7 +143,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) // Owed work is read every tick, not once a window: work that ends just before a window would @@ -165,7 +165,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) it('stops an agent whose roster holds only children that went idle or finished', async () => { @@ -179,7 +179,7 @@ describe('the idle sweep', () => { }) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) it('never stops an agent while its lead turn runs, however quiet (P2-10)', async () => { @@ -202,7 +202,7 @@ describe('the idle sweep', () => { await rig.host.subscribe({ id: 'on-screen', sessionId: SESSION, emit: reader.emit }) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) expect(reader.events.some((event) => event.type === 'end')).toBe(false) }) @@ -221,7 +221,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) it('never stops a worker whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => { @@ -237,7 +237,7 @@ describe('the idle sweep', () => { expect(hasOpenDispatch).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION })) open = false rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) // A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row @@ -254,7 +254,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) it('keeps a child an unanswered prompt waits on (P2-22 i)', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index aa7b8a17295f..58572980aafd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -214,7 +214,7 @@ describe('structured session options and close', () => { await host.close(SESSION, 'evict') - expect(closeNativeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(closeNativeSession).toHaveBeenCalledWith(SESSION) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null, 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 230709e5d213..9d24d7e24c0a 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 @@ -646,7 +646,7 @@ describe('a quit with a message still queued', () => { starting.resolve() await quit - expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(closeSession).toHaveBeenCalledWith(SESSION) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) expect(dispatch).not.toHaveBeenCalled() expect(await afterRelaunch(id)).toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index 538fb0b9dbc6..ddaae1474f4f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -26,11 +26,7 @@ import { settleStaleStructuredAgentSessionState, settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' -import { - childEndCauseOfEndedEvent, - turnVerdictForChildEnd, - type StructuredAgentSessionTurnVerdict -} from './structured-agent-session-stale-turn-verdict' +import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' @@ -195,7 +191,7 @@ describe('a turn recovery settled after its host went away', () => { ], [ 'quitting Orca', - // A quit evicts the child, and its adapter settles the open turn through the one mapping. + // A quit evicts the child, writing no Stop event, and its adapter settles the open turn. (journal: AgentSessionJournal) => journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 9 }, @@ -203,14 +199,8 @@ describe('a turn recovery settled after its host went away', () => { kind: 'turn', turnId: 'turn-1', startedAt: TURN_STARTED, - ...turnVerdictForChildEnd( - childEndCauseOfEndedEvent({ - type: 'ended', - cause: 'requested-close', - stopCause: 'evict' - }), - EXIT_OBSERVED - ) + state: 'interrupted', + completedAt: EXIT_OBSERVED }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index 45f636eccf77..9002cff14a72 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -17,17 +17,12 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' -import type { - StructuredAgentSessionChildEndCause, - StructuredAgentSessionEndedEvent -} from './structured-agent-session-adapter' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export type StructuredAgentSessionTurnVerdict = - /** `cancellation` only for a stop the user aimed at this chat: every other cut is news. */ - | { state: 'interrupted'; completedAt: number; outcome?: 'cancellation' } - | { state: 'unverifiable' } + /** Whose end it was is the Stop event's to say, where the row is built (`turnEndAfterStop`). */ + { state: 'interrupted'; completedAt: number } | { state: 'unverifiable' } export const UNVERIFIABLE_TURN_VERDICT: StructuredAgentSessionTurnVerdict = { state: 'unverifiable' @@ -72,45 +67,6 @@ export function stopFoundTurnLiveAt( return stop && turnId !== undefined && stop.event.turnId === turnId ? stop.event.at : undefined } -/** - * The one mapping from why a provider child ended to what the turn it cut reads as. Each adapter - * settles its own open turn through it on `ended`, and the host's fallback settles through it any - * turn no adapter did. Only a stop the user aimed at this chat is their cancellation. - */ -export function turnVerdictForChildEnd( - cause: StructuredAgentSessionChildEndCause, - completedAt: number -): Extract { - return stopIsTheUsers(cause) - ? { state: 'interrupted', completedAt, outcome: 'cancellation' } - : { state: 'interrupted', completedAt } -} - -/** Whether the user asked for this end. Only then is a cut turn their cancellation. */ -export function stopIsTheUsers(cause: StructuredAgentSessionChildEndCause): boolean { - switch (cause) { - case 'user-stop': - case 'user-close': - return true - case 'host-stop': - case 'evict': - case 'exit': - case 'attach-failed': - return false - } -} - -/** Why the child an `ended` event reports ended: who asked for a close, else an exit it had. A - * requested close with no cause named is the host's own. */ -export function childEndCauseOfEndedEvent( - event: { type: 'ended' } & Partial> -): StructuredAgentSessionChildEndCause { - if (event.cause === 'unexpected-exit') { - return 'exit' - } - return event.stopCause ?? 'host-stop' -} - /** Revises every still-running lifecycle item in place, keeping its identity and start. */ export function runningTurnLifecycleRevisions( items: readonly AgentJournalRenderItem[], @@ -187,7 +143,6 @@ function settledLifecycle( return { ...kept, state: verdict.state, - completedAt: Math.max(verdict.completedAt, began), - ...(verdict.outcome ? { outcome: verdict.outcome } : {}) + completedAt: Math.max(verdict.completedAt, began) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts index 58a36546a046..36002750cd57 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts @@ -1,5 +1,5 @@ -/** Why a provider child ended. In memory only, except `user-stop`, the one arm a Stop event - * journals so far. */ +/** Why a provider child ended, for the delivery loop (`lastEndedChild`). A stop's arms are also + * its Stop event's reason (`JournalStopEvent`); the others are in memory only. */ export type StructuredAgentSessionChildEndCause = | 'user-stop' /** The user closed this chat: its tab, its launch, or a `/clear` that replaces it. */ @@ -9,8 +9,8 @@ export type StructuredAgentSessionChildEndCause = | 'attach-failed' | 'evict' -/** Why the host asked a child to stop. The adapter carries it onto the `ended` it settles with, - * and a Stop event persists it (`JournalStopEvent.reason`), so never rename an arm. */ +/** Why a child was asked to stop. A Stop event persists it (`JournalStopEvent.reason`), which + * is what a turn's end reads, so never rename an arm. */ export type StructuredAgentSessionStopCause = Extract< StructuredAgentSessionChildEndCause, 'user-stop' | 'user-close' | 'host-stop' | 'evict' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 871c05a6e337..7d7d4861cd0b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -132,7 +132,7 @@ function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): voi /** The sweep stops the child first and closes the conversation last. */ function waitForEviction(): Promise { return vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(closeSession).toHaveBeenCalledWith(SESSION) expect(host.hasSession(SESSION)).toBe(false) }) } @@ -247,7 +247,7 @@ describe('a chat that closes', () => { await host.close(SESSION, 'evict') - expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(closeSession).toHaveBeenCalledWith(SESSION) expect(host.hasSession(SESSION)).toBe(false) expect(hostErrors).toEqual([]) // The record and its journal stay; only the process and the claim on it go. From 93e2f55646bd4fdf44d69a5113c2821b1e6fbbac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:49:24 -0700 Subject: [PATCH 031/182] test(native-chat): a turn a later send opened is no Stop's that named no turn --- ...d-agent-session-stop-event-restart.test.ts | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 5dc82d8efd73..987c0fd69105 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -61,19 +61,16 @@ async function runningTurn(identity: AgentJournalItemIdentity): Promise { /** Orca dies with the turn's end unwritten; the relaunch reopens the chat from disk and proves * the old child gone (its last renewal came before the Stop), then settles what it left. */ -async function restartAndSettle(proof: 'pid-absent' | 'unproven' = 'pid-absent'): Promise { +async function restartAndSettle( + proof: 'pid-absent' | 'exit-observed' | 'unproven' = 'pid-absent' +): Promise { rig.crashRestartHostProcess() await rig.host.journalSnapshot(HOST_TEST_SESSION) const now = Date.now() const deathEvidence: AgentSessionDeathEvidence | null = - proof === 'pid-absent' - ? { - kind: 'pid-absent', - observedAt: now + 60_000, - ownerFence: 1, - lastProvenAliveAt: now - 20_000 - } - : null + proof === 'unproven' + ? null + : { kind: proof, observedAt: now + 60_000, ownerFence: 1, lastProvenAliveAt: now - 20_000 } await settleStaleStructuredAgentSessionState({ journal: journal(), sessionId: HOST_TEST_SESSION, @@ -173,6 +170,29 @@ describe('a restart between a Stop and its turn end', () => { expect(mark).toBe('failed') }) + it('reads a turn a send made after a Stop pressed before any turn showed as no Stop of its', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + // Pressed before the turn showed: the Stop names no turn. + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() + await rig.settleAccepted(stopped, 'stopped') + const next = rig.send('sent after the Stop') + await next.result + await rig.settleAccepted(next.id, 'next') + await journal().appendItem( + CODEX_TURN, + { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() - 30_000 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + // Its exit is proven after the Stop, so only whose turn it is decides. + await restartAndSettle('exit-observed') + + expect(settled().turn).toMatchObject({ state: 'interrupted' }) + expect(settled().turn).not.toHaveProperty('outcome') + }) + it('a Stop pressed again after a refusal is a new Stop, which its turn end reads', async () => { await runningTurn(CODEX_TURN) rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) From 4be2b8b2ba78f8161cb57f0ba7326474911e8c46 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:56:51 -0700 Subject: [PATCH 032/182] test(native-chat): the restart test's death proof carries its detail --- .../structured-agent-session-stop-event-restart.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 987c0fd69105..0ec9e7dcc2d8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -70,7 +70,13 @@ async function restartAndSettle( const deathEvidence: AgentSessionDeathEvidence | null = proof === 'unproven' ? null - : { kind: proof, observedAt: now + 60_000, ownerFence: 1, lastProvenAliveAt: now - 20_000 } + : { + kind: proof, + detail: 'the relaunch proved the old child gone', + observedAt: now + 60_000, + ownerFence: 1, + lastProvenAliveAt: now - 20_000 + } await settleStaleStructuredAgentSessionState({ journal: journal(), sessionId: HOST_TEST_SESSION, From d388b9cdd222af9df8ecc56b8b88d4dc22ba48dc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:51:11 -0700 Subject: [PATCH 033/182] refactor(native-chat): a refused Stop leaves no record; a Stop only ever ends the turn it names The stop-refused mark is gone: its tombstone kind, its fold, the clock-keyed match that tied it to a Stop, and the exception that let a second press after a refusal write a new Stop. A Stop that stops nothing writes nothing. A Codex refusal names a turn that is no longer its active one, and the Stop names that turn, so the turn running instead never reads as the person's by its id alone. --- .../claude-structured-in-turn-stop.test.ts | 9 +- src/main/claude/claude-turn-outcome.test.ts | 3 +- .../agent-session-journal/journal-reducer.ts | 7 +- .../journal-row-schema.ts | 19 +--- .../journal-stop-and-resume-rows.ts | 26 +----- .../journal-stop-marks.ts | 22 ++--- .../journal-stop-turn-end.ts | 82 +++-------------- .../journal-store-collaborators.ts | 2 +- .../structured-agent-session-queued-stop.ts | 3 +- ...d-agent-session-stop-event-restart.test.ts | 87 ++++++++++++++----- .../structured-agent-session-turns-cancel.ts | 26 ------ ...-session-stop-event-downgrade.unit.test.ts | 9 +- 12 files changed, 95 insertions(+), 200 deletions(-) diff --git a/src/main/claude/claude-structured-in-turn-stop.test.ts b/src/main/claude/claude-structured-in-turn-stop.test.ts index 3962b97dbf40..427ec1aaf480 100644 --- a/src/main/claude/claude-structured-in-turn-stop.test.ts +++ b/src/main/claude/claude-structured-in-turn-stop.test.ts @@ -30,8 +30,7 @@ async function runningChat(claude: ReturnType): Promise<{ bodies: Map connection: FakeConnection turnId: string - /** What the host's Stop writes before the interrupt. Its child's end follows, so no refusal - * ever answers it. */ + /** What the host's Stop writes before the interrupt. */ stopEvent: (turnId: string) => void }> { const bodies = new Map() @@ -64,11 +63,7 @@ async function runningChat(claude: ReturnType): Promise<{ throw new Error('expected a running turn') } const stopEvent = (stoppedTurnId: string) => { - latestStop = { - sequence: 9, - event: { reason: 'user-stop', turnId: stoppedTurnId, at: 1 }, - refused: false - } + latestStop = { sequence: 9, event: { reason: 'user-stop', turnId: stoppedTurnId, at: 1 } } } return { adapter, bodies, connection, turnId, stopEvent } } diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts index c099a704dc93..ba064e3b4852 100644 --- a/src/main/claude/claude-turn-outcome.test.ts +++ b/src/main/claude/claude-turn-outcome.test.ts @@ -245,12 +245,11 @@ describe("a person's Stop inside a live turn", () => { return { ...state, sink: { ...state.sink, journalLatestStop: () => stop } } } - // A Claude Stop ends the child, so no refusal ever answers its event. function stopOf( turnId: string, reason: JournalStopEvent['reason'] = 'user-stop' ): JournalLatestStop { - return { sequence: 9, event: { reason, turnId, at: 1 }, refused: false } + return { sequence: 9, event: { reason, turnId, at: 1 } } } function settledTurn(items: ReturnType['items'], turnId: string) { diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index fec7c50672cb..a7d6f150e70c 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -35,7 +35,6 @@ import { foldJournalQueuePauseMark, type JournalQueuePauseMarks } from './queued-message-pause' -import { foldJournalStopRefusal, type JournalStopRefusalMark } from './journal-stop-turn-end' export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096 @@ -65,8 +64,6 @@ export type JournalReducerState = { latestPersonTurnSequence: number /** The latest person's Stop event and Resume, what the queue's pause is derived from. */ queuePauseMarks: JournalQueuePauseMarks - /** The latest refusal of a Stop, what keeps a refused Stop from ending its turn. */ - latestStopRefusal: JournalStopRefusalMark | null } export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState { @@ -86,8 +83,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), latestPersonTurnSequence: 0, - queuePauseMarks: createJournalQueuePauseMarks(), - latestStopRefusal: null + queuePauseMarks: createJournalQueuePauseMarks() } } @@ -115,7 +111,6 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } if (isJournalStopOrResumeRow(row)) { foldJournalQueuePauseMark(state.queuePauseMarks, row) - foldJournalStopRefusal(state, row) return } if (row.kind === 'tombstone') { 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 e4bd35bd40d7..e65d5919b39b 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 @@ -77,8 +77,6 @@ export type JournalTombstoneRow = JournalRowBase & { stopEvent?: JournalStopEvent /** Present: not a removal but a person's Resume of the queue, on an id no item ever takes. */ queueResume?: true - /** Present: not a removal but the provider's refusal of a Stop, on an id no item ever takes. */ - stopRefusal?: JournalStopRefusal } /** One Stop that took effect. Temporary carrier: a tombstone's extra key, because a released host @@ -96,29 +94,16 @@ export type JournalStopEvent = { caller?: string } -/** The provider refused a Stop's interrupt and its turn ran on, so that Stop never ends the turn - * (`journal-stop-turn-end.ts`). Names the Stop it answers by that event's `turnId` and `at`. Same - * Temporary carrier as `JournalStopEvent`. */ -export type JournalStopRefusal = { - turnId?: string - stopAt: number -} - /** A tombstone that carries a Stop event or a Resume mark instead of removing an item. */ export type JournalStopOrResumeRow = JournalTombstoneRow & ( | { stopEvent: NonNullable } | { queueResume: NonNullable } - | { stopRefusal: NonNullable } ) -/** A Stop's event, its refusal or a Resume. Any value counts, so a newer build's mark never - * removes an item. */ +/** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrResumeRow { - return ( - row.kind === 'tombstone' && - (row.stopEvent !== undefined || row.queueResume !== undefined || row.stopRefusal !== undefined) - ) + return row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) } /** The write-ahead row. Durable BEFORE the adapter dispatches anything; it diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts index 079b2281c7c6..e763af8b2e6c 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts @@ -1,14 +1,10 @@ -// The rows a Stop's event, its refusal and a person's Resume append: tombstones of ids no item ever takes, +// The rows a Stop's event and a person's Resume append: tombstones of ids no item ever takes, // each carrying its record as an extra key (`journal-row-schema.ts` says why not a row kind). import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { JournalReducerState } from './journal-reducer' import { journalRowBase } from './journal-row-builders' -import type { - JournalStopEvent, - JournalStopRefusal, - JournalTombstoneRow -} from './journal-row-schema' +import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' /** One id per mark kind; no item ever takes either. */ const JOURNAL_STOP_EVENT_ITEM_ID = agentJournalItemKey({ @@ -19,10 +15,6 @@ const JOURNAL_QUEUE_RESUME_ITEM_ID = agentJournalItemKey({ provider: 'orca', clientMessageId: 'queue-resume' }) -const JOURNAL_STOP_REFUSAL_ITEM_ID = agentJournalItemKey({ - provider: 'orca', - clientMessageId: 'stop-refusal' -}) type RowPlace = { state: JournalReducerState; seq: number; fence: number; ts: number } @@ -64,17 +56,3 @@ export function journalQueueResumeRowBuilder( ): (seq: number, ts: number) => JournalTombstoneRow { return (seq, ts) => buildJournalQueueResumeRow({ state: state(), seq, fence, ts }) } - -export function journalStopRefusalRowBuilder( - state: () => JournalReducerState, - refusal: JournalStopRefusal, - fence: number -): (seq: number, ts: number) => JournalTombstoneRow { - return (seq, ts) => ({ - kind: 'tombstone', - itemId: JOURNAL_STOP_REFUSAL_ITEM_ID, - revision: 1, - stopRefusal: refusal, - ...journalRowBase(state().epoch, seq, fence, ts) - }) -} diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts index 2d9bb308e2ff..87a87f26e194 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -1,25 +1,13 @@ -// What the journal answers about its Stops beyond the queue's pause: the latest Stop event and -// whether a refusal answered it, which the turn-end rule reads (`journal-stop-turn-end.ts`). +// What the journal answers about its Stops beyond the queue's pause: the latest Stop event, which +// the turn-end rule reads (`journal-stop-turn-end.ts`). import type { JournalReducerState } from './journal-reducer' -import type { JournalRow, JournalStopRefusal } from './journal-row-schema' -import { journalStopRefusalRowBuilder } from './journal-stop-and-resume-rows' -import { journalLatestStop, type JournalLatestStop } from './journal-stop-turn-end' +import type { JournalLatestStop } from './journal-stop-turn-end' export class JournalStopMarks { - constructor( - private readonly deps: { - state: () => JournalReducerState - enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise - } - ) {} + constructor(private readonly deps: { state: () => JournalReducerState }) {} latest(): JournalLatestStop | null { - return journalLatestStop(this.deps.state()) - } - - /** The provider refused a Stop's interrupt and its turn runs on (`JournalStopRefusal`). */ - async appendRefusal(refusal: JournalStopRefusal, fence: number): Promise { - await this.deps.enqueue(journalStopRefusalRowBuilder(this.deps.state, refusal, fence)) + return this.deps.state().queuePauseMarks.latestStop } } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 1f03dd9e2388..2d08bb502b6d 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -1,72 +1,18 @@ // What a Stop decides about the turn it named: the one rule every turn-end write passes through. // // A turn a person's Stop or close of this chat named, ending with no verdict of its own after that -// Stop's event and with no refusal answering it, ends as their cancellation. A host stop, an -// eviction, a refused Stop and no Stop at all leave the end as written. It runs where each row is -// built, inside the journal's serialized write, so it reads every Stop folded before the end: the -// adapter's settle, the host's fallback and a relaunch's settle all write through it, and every -// client folds the row it wrote. +// Stop's event, ends as their cancellation. A host stop, an eviction and no Stop at all leave the +// end as written. It runs where each row is built, inside the journal's serialized write, so it +// reads every Stop folded before the end: the adapter's settle, the host's fallback and a +// relaunch's settle all write through it, and every client folds the row it wrote. import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalReducerState } from './journal-reducer' -import type { - JournalStopEvent, - JournalStopRefusal, - JournalTombstoneRow -} from './journal-row-schema' +import type { JournalStopEvent } from './journal-row-schema' +import type { JournalQueuePauseMarks } from './queued-message-pause' -export type JournalStopRefusalMark = { sequence: number; refusal: JournalStopRefusal } - -export type JournalLatestStop = { - sequence: number - event: JournalStopEvent - /** A refusal written after it answers it: its turn ran on. */ - refused: boolean -} - -/** The keys are read from disk unchecked: a value no build writes (a corrupt row) is ignored. */ -export function foldJournalStopRefusal( - state: Pick, - row: JournalTombstoneRow -): void { - const refusal: unknown = row.stopRefusal - if ( - typeof refusal === 'object' && - refusal !== null && - 'stopAt' in refusal && - typeof refusal.stopAt === 'number' && - (!('turnId' in refusal) || typeof refusal.turnId === 'string') - ) { - state.latestStopRefusal = { - sequence: row.seq, - refusal: { - stopAt: refusal.stopAt, - ...('turnId' in refusal && typeof refusal.turnId === 'string' - ? { turnId: refusal.turnId } - : {}) - } - } - } -} - -export function journalLatestStop( - state: Pick -): JournalLatestStop | null { - const stop = state.queuePauseMarks.latestStop - if (!stop) { - return null - } - const answer = state.latestStopRefusal - return { - ...stop, - refused: - answer !== null && - answer.sequence > stop.sequence && - answer.refusal.stopAt === stop.event.at && - answer.refusal.turnId === stop.event.turnId - } -} +export type JournalLatestStop = NonNullable /** Only a person's own Stop, or their close of this chat, makes a cut turn their cancellation. */ function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { @@ -81,14 +27,13 @@ function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { } /** Whether `stop` makes the end of turn `turnId` a person's cancellation: a person's Stop or close - * that named that turn (`opened`: or named none, and the turn opened under it), and that no - * refusal answered. */ + * that named that turn (`opened`: or named none, and the turn opened under it). */ export function stopIsTurnCancellation( stop: JournalLatestStop | null, turnId: string, opened?: { createdAt: number | null; latestPersonTurnSequence: number } ): boolean { - if (stop === null || stop.refused || !stopIsAPersons(stop.event.reason)) { + if (stop === null || !stopIsAPersons(stop.event.reason)) { return false } if (stop.event.turnId !== undefined || !opened) { @@ -105,13 +50,10 @@ export function stopIsTurnCancellation( /** * The body to write for item `itemId`: unchanged unless it ends, with no verdict of its own and no * earlier than the latest Stop event, a person's, which named it while it was still open (running, - * or unproven) and which no refusal answered. A provider's own verdict always stands. + * or unproven). A provider's own verdict always stands. */ export function turnEndAfterStop( - state: Pick< - JournalReducerState, - 'items' | 'queuePauseMarks' | 'latestStopRefusal' | 'latestPersonTurnSequence' - >, + state: Pick, itemId: string, body: AgentJournalItemBody ): AgentJournalItemBody { @@ -124,7 +66,7 @@ export function turnEndAfterStop( if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { return body } - const stop = journalLatestStop(state) + const stop = state.queuePauseMarks.latestStop const opened = { createdAt: existing?.sequence ?? null, latestPersonTurnSequence: state.latestPersonTurnSequence diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 5351427f3023..b938efa3c5c9 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -94,7 +94,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal return { epochController, queuedMessages, - stopMarks: new JournalStopMarks({ state: host.state, enqueue: host.enqueue }), + stopMarks: new JournalStopMarks({ state: host.state }), // Behind the stored fact: settles drafts whose consumed submission the loaded journal shows // refused (a downgrade wrote no hook), then prunes. Bookkeeping, never failing the open. restore: () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index d7240c249e69..e66c2a9b9765 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -77,8 +77,7 @@ export async function stopReachesUnrecordedWork( return false } const inForce = ctx.journal.queuedMessages.userStopInForce() - // A refused Stop ended nothing, so pressing again is a new Stop. - if (inForce === null || ctx.journal.stopMarks.latest()?.refused) { + if (inForce === null) { return true } // Sent after that Stop and not refused, even if its fate is unknown: this interrupt may send it diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 4c09f76504df..d0beae7df677 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -1,7 +1,7 @@ // A Stop that took effect still decides its turn after Orca restarts before the turn's end was // written: the relaunch's settle reads the Stop's event, so the turn reads "Interrupted after N" -// with the muted mark, not "Failed". A turn nobody stopped, and one whose Stop the provider refused -// while the turn ran on, still read as the news they are. +// with the muted mark, not "Failed". A turn nobody stopped, and one a Stop never named, still read +// as the news they are. import { afterEach, describe, expect, it } from 'vitest' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' @@ -15,10 +15,10 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { formatNativeChatTurnStatusLabel } from '../../../shared/native-chat-turn-status' import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' -import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' import { + QUEUED_RIG_CALLER, createQueuedMessageTestRig, - eventually, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' @@ -35,6 +35,12 @@ const CODEX_TURN: AgentJournalItemIdentity = { turnId: TURN, ordinal: 999 } +const NEXT_TURN = 'turn-2' +const CODEX_NEXT_TURN: AgentJournalItemIdentity = { + ...CODEX_TURN, + turnId: NEXT_TURN, + ordinal: 1000 +} let rig: QueuedMessageTestRig @@ -89,10 +95,12 @@ async function restartAndSettle( }) } -/** What the chat's turn bar and the session's mark read. */ -function settled() { +/** What the chat's turn bar and the session's mark read, for `turnId` or else the first turn. */ +function settled(turnId?: string) { const { items } = journal().snapshot() - const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) + const turn = items + .map((item) => readAgentJournalTurn(item.body)) + .find((entry) => entry && (turnId === undefined || entry.turnId === turnId)) const [timing] = [...selectStructuredAgentSettledTurns(items).values()] const verdict = turn ? agentTurnVerdict({ state: turn.state, outcome: turn.outcome ?? null }) @@ -165,21 +173,55 @@ describe('a restart between a Stop and its turn end', () => { expect(mark).toBe('unconfirmed') }) - it('reads Failed after N when the provider refused the Stop and the turn ran on until the crash', async () => { + // Codex refuses a Stop naming a turn that is no longer its active one ("expected active turn id + // X but found Y"). The Stop names X, so Y's end is never the person's, by its turn id alone. + it('reads Failed for the turn running when the provider refused a Stop naming the one before it', async () => { await runningTurn(CODEX_TURN) - rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) - await eventually(async () => expect(journal().stopMarks.latest()?.refused).toBe(true)) + rig.cancelTurn.mockResolvedValueOnce({ + cancelled: false, + refusal: { + detail: { + text: `expected active turn id ${TURN} but found ${NEXT_TURN}`, + audience: 'person' + } + } + }) + const fields = { turnId: TURN } + expect( + await rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), + ...fields + }) + ).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(journal().stopMarks.latest()?.event).toMatchObject({ reason: 'user-stop', turnId: TURN }) + // The journal catches up: X had finished, and Y runs on until the crash. + await journal().appendItem( + CODEX_TURN, + { + kind: 'turn', + turnId: TURN, + state: 'completed', + outcome: 'success', + completedAt: Date.now() + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal().appendItem( + CODEX_NEXT_TURN, + { kind: 'turn', turnId: NEXT_TURN, state: 'running', startedAt: Date.now() - 10_000 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) - await restartAndSettle() + // Its exit is proven after the Stop, so only the turn the Stop named decides. + await restartAndSettle('exit-observed') - const { turn, label, mark } = settled() + const { turn, mark } = settled(NEXT_TURN) + expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect(label).toMatch(/^Failed after /) expect(mark).toBe('failed') }) - // Claude's Stop ends its child whatever the interrupt answered, so only Codex writes a refusal. + // Claude's Stop ends its child whatever the interrupt answered. it('reads Interrupted after N when the provider refused a Stop that ends its child', async () => { await runningTurn(CLAUDE_TURN, { stopEndsSession: true }) rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) @@ -187,7 +229,6 @@ describe('a restart between a Stop and its turn end', () => { // The Stop's next step on the session's lane ends the child. await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) expect(rig.closeSession).toHaveBeenCalled() - expect(journal().stopMarks.latest()).toMatchObject({ refused: false }) await restartAndSettle() @@ -217,14 +258,18 @@ describe('a restart between a Stop and its turn end', () => { expect(settled().turn).not.toHaveProperty('outcome') }) - it('a Stop pressed again after a refusal is a new Stop, which its turn end reads', async () => { + // A refusal is no record: the first Stop stays the one in force, so pressing again repeats it. + it('writes nothing for a Stop pressed again after the provider refused one', async () => { await runningTurn(CODEX_TURN) - rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) + rig.cancelTurn.mockResolvedValueOnce({ + cancelled: false, + refusal: { detail: { text: 'no active turn to interrupt', audience: 'person' } } + }) await rig.stop() - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + const first = journal().stopMarks.latest() - await restartAndSettle() + await rig.stop() - expect(settled().turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(journal().stopMarks.latest()).toEqual(first) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index d92ac0bf583c..cdb70ff41d45 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -59,27 +59,6 @@ export async function endStoppedStructuredAgentSession( } } -/** The provider refused the interrupt and `turnId` runs on: its Stop event must not end it - * (`turnEndAfterStop`). Only where nothing then ends the child, so never for a provider whose Stop - * ends its session (Claude). Bookkeeping: reported, never thrown. */ -async function recordStopRefusal( - ctx: Pick, - turnId: string | null -): Promise { - const stop = ctx.journal.stopMarks.latest() - if (!stop || stop.refused || turnId === null || stop.event.turnId !== turnId) { - return - } - await ctx.journal.stopMarks - .appendRefusal({ turnId, stopAt: stop.event.at }, ctx.fence) - .catch((error: unknown) => { - console.warn("[agent-session] a refused Stop's answer row skipped:", { - sessionId: ctx.sessionId, - error: error instanceof Error ? error.message : String(error) - }) - }) -} - export async function performCancel( ctx: AgentSessionTurnContext, input: { @@ -107,7 +86,6 @@ export async function performCancel( } } let cancelled = false - let refused = false let note: AgentJournalStatusItem | null = { kind: 'status', text: 'Cancellation requested.' } // The turn the Stop names, read before the cancel settles it: the note reports on that turn. const turnScope = ctx.journal.liveTurnScope() @@ -165,7 +143,6 @@ export async function performCancel( note = null } else if (!cancelled) { // Sent only while the chat reads working, so a Stop that ended nothing must say why. - refused = true const detail = outcome.refusal?.detail note = { kind: 'status', @@ -204,9 +181,6 @@ export async function performCancel( cancelled = true note = { kind: 'status', text: 'Cancellation requested.' } } - if (refused && !cancelled && !input.scope) { - await recordStopRefusal(ctx, liveTurnId) - } if (cancelled && input.prompt) { await ctx.flushStreamedEvents() } diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 88abffc1fb4e..20b64c7f6dbb 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -54,7 +54,7 @@ type OldReplay = { truncateFrom?: number } -test("an older build keeps every row around a Stop's event, its refusal and a Resume, and folds the rows after them", async () => { +test("an older build keeps every row around a Stop's event and a Resume, and folds the rows after them", async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-stop-event-downgrade-')) const journals = createTrackedJournalOpener() try { @@ -69,8 +69,6 @@ test("an older build keeps every row around a Stop's event, its refusal and a Re await append(0, 'before the Stop') const beforeMarks = journal.cursor() await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) - const stopAt = journal.stopMarks.latest()?.event.at ?? 0 - await journal.stopMarks.appendRefusal({ turnId: 'turn-1', stopAt }, 1) await journal.appendStopEvent({ reason: 'user-close', turnId: 'turn-1' }, 1) await journal.appendQueueResume(1) const afterMarks = journal.cursor() @@ -139,7 +137,7 @@ test("an older build keeps every row around a Stop's event, its refusal and a Re }) expect(projected.ok).toBe(true) expect(projected.batch?.items).toEqual([]) - expect(projected.batch?.removedItemIds).toHaveLength(3) + expect(projected.batch?.removedItemIds).toHaveLength(2) const liveIds = new Set(journal.snapshot().items.map((entry) => entry.itemId)) expect(projected.batch?.removedItemIds.some((id) => liveIds.has(id))).toBe(false) } finally { @@ -185,8 +183,6 @@ test("an older build opens this build's journal writable and appends to it; the const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } await journal.appendItem(item(0), { kind: 'status', text: 'before the Stop' }, scope) await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) - const stopAt = journal.stopMarks.latest()?.event.at ?? 0 - await journal.stopMarks.appendRefusal({ turnId: 'turn-1', stopAt }, 1) await journal.appendItem(item(1), { kind: 'status', text: 'after the Stop' }, scope) const wrote = { cursor: journal.cursor(), items: itemIds(journal) } expect(journal.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ @@ -222,7 +218,6 @@ test("an older build opens this build's journal writable and appends to it; the expect(upgraded.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ 'stopped' ]) - expect(upgraded.stopMarks.latest()).toMatchObject({ event: { at: stopAt }, refused: true }) } finally { await journals.closeAll() rmSync(directory, { recursive: true, force: true }) From f85a98403785d82666b7425479ed936ee9a7a2be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:54:49 -0700 Subject: [PATCH 034/182] fix(native-chat): a Stop pressed before any turn showed stops only the turn opened next A Stop that named no turn read as the person's cancellation for every later turn that opened after it, until a send a person made was accepted. The queue's drain, orchestration mail and a restart continuation send as the host, so a turn they opened long after, cut by a crash, read "Interrupted" as if the person had stopped it. The Stop now applies only to the first turn opened after it. --- .../journal-stop-turn-end.ts | 58 +++++++++++++------ ...d-agent-session-stop-event-restart.test.ts | 36 ++++++++++++ 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 2d08bb502b6d..266abbb7fd9c 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -26,12 +26,17 @@ function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { } } +type TurnEndState = Pick< + JournalReducerState, + 'items' | 'queuePauseMarks' | 'latestPersonTurnSequence' +> + /** Whether `stop` makes the end of turn `turnId` a person's cancellation: a person's Stop or close - * that named that turn (`opened`: or named none, and the turn opened under it). */ + * that named that turn (`opened`: or named none, and stopped the turn item `itemId` opened). */ export function stopIsTurnCancellation( stop: JournalLatestStop | null, turnId: string, - opened?: { createdAt: number | null; latestPersonTurnSequence: number } + opened?: { state: TurnEndState; itemId: string } ): boolean { if (stop === null || !stopIsAPersons(stop.event.reason)) { return false @@ -39,39 +44,56 @@ export function stopIsTurnCancellation( if (stop.event.turnId !== undefined || !opened) { return stop.event.turnId === turnId } - // Pressed before the turn showed: the turn its row opened after the Stop, unless a send a - // person made since was accepted, whose turn it would be. - return ( - (opened.createdAt === null || opened.createdAt > stop.sequence) && - opened.latestPersonTurnSequence < stop.sequence - ) + return turnlessStopStopped(opened.state, stop, opened.itemId) +} + +/** Pressed before any turn showed, a Stop stopped the first turn opened after it, and no later + * one: unless a send a person made since was accepted, whose turn that is. */ +function turnlessStopStopped( + state: TurnEndState, + stop: JournalLatestStop, + itemId: string +): boolean { + const createdAt = state.items.get(itemId)?.sequence ?? null + if ( + (createdAt !== null && createdAt <= stop.sequence) || + state.latestPersonTurnSequence >= stop.sequence + ) { + return false + } + for (const [otherId, item] of state.items) { + if ( + otherId !== itemId && + item.body.kind === 'turn' && + item.sequence > stop.sequence && + (createdAt === null || item.sequence < createdAt) + ) { + return false + } + } + return true } /** * The body to write for item `itemId`: unchanged unless it ends, with no verdict of its own and no - * earlier than the latest Stop event, a person's, which named it while it was still open (running, - * or unproven). A provider's own verdict always stands. + * earlier than the latest Stop event, a person's, which named it, or stopped it before it showed, + * while it was still open (running, or unproven). A provider's own verdict always stands. */ export function turnEndAfterStop( - state: Pick, + state: TurnEndState, itemId: string, body: AgentJournalItemBody ): AgentJournalItemBody { if (body.kind !== 'turn' || body.state !== 'interrupted' || body.outcome !== undefined) { return body } - const existing = state.items.get(itemId) - const previous = readAgentJournalTurn(existing?.body) + const previous = readAgentJournalTurn(state.items.get(itemId)?.body) // An end already written stands: the Stop came after it. if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { return body } const stop = state.queuePauseMarks.latestStop - const opened = { - createdAt: existing?.sequence ?? null, - latestPersonTurnSequence: state.latestPersonTurnSequence - } - if (!stop || !stopIsTurnCancellation(stop, body.turnId, opened)) { + if (!stop || !stopIsTurnCancellation(stop, body.turnId, { state, itemId })) { return body } // An exit the provider saw before the Stop was news, whenever its end is written. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index d0beae7df677..1d1554d9174b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -258,6 +258,42 @@ describe('a restart between a Stop and its turn end', () => { expect(settled().turn).not.toHaveProperty('outcome') }) + // A Stop pressed before any turn showed stopped the turn its send was about to open, and no other. + it('reads a turn a host send opened after the turnless Stop ended its own turn as no Stop of its', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() + await rig.settleAccepted(stopped, 'stopped') + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + await journal().appendItem( + CODEX_TURN, + { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() }, + scope + ) + await journal().appendItem( + CODEX_TURN, + { kind: 'turn', turnId: TURN, state: 'interrupted', completedAt: Date.now() + 1 }, + scope + ) + expect(settled(TURN).turn).toMatchObject({ outcome: 'cancellation' }) + // The queue's drain sends as the host, so no person's send voids the Stop. + const drained = rig.send('drained after the Stop', undefined, { internal: true }) + await drained.result + await rig.settleAccepted(drained.id, 'drained') + await journal().appendItem( + CODEX_NEXT_TURN, + { kind: 'turn', turnId: NEXT_TURN, state: 'running', startedAt: Date.now() }, + scope + ) + + // Its exit is proven after the Stop, so only which turn the Stop stopped decides. + await restartAndSettle('exit-observed') + + expect(settled(NEXT_TURN).turn).toMatchObject({ state: 'interrupted' }) + expect(settled(NEXT_TURN).turn).not.toHaveProperty('outcome') + }) + // A refusal is no record: the first Stop stays the one in force, so pressing again repeats it. it('writes nothing for a Stop pressed again after the provider refused one', async () => { await runningTurn(CODEX_TURN) From 2ba34a95f9b2cc225448d6878643477e4e55eef4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:59:28 -0700 Subject: [PATCH 035/182] fix(native-chat): an older Claude's error end after a Stop pressed before its echo reads Interrupted Claude CLIs before 2.1.91 end an interrupted turn with an error result that names no reason. The translator judged whether a person's Stop explained it by its own copy of the Stop rule, which ignored a Stop that named no turn, so a Stop pressed before Claude echoed the send read "Failed". The translator now writes such an end as interrupted with no verdict and no error row whenever a person's Stop may name the turn, and the journal's one rule decides as it writes the end. --- src/main/claude/claude-result-journaling.ts | 16 ++++------ src/main/claude/claude-result-outcome.ts | 16 +++++----- .../claude-structured-in-turn-stop.test.ts | 27 ++++++++-------- .../claude-structured-provider-fallback.ts | 4 +-- src/main/claude/claude-turn-lifecycle-item.ts | 9 +++--- src/main/claude/claude-turn-outcome.test.ts | 32 +++++++++++-------- .../journal-stop-turn-end.ts | 32 ++++++++++++------- ...t-session-claude-stop-ends-session.test.ts | 22 +++++++++++++ 8 files changed, 96 insertions(+), 62 deletions(-) diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 2609b4e919c8..4ea153b82754 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -15,7 +15,7 @@ import { } from './claude-structured-provider-fallback' import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' import { claudeFrameParentRef, isRootClaudeFrame } from './claude-turn-opening' -import { stopIsTurnCancellation } from '../native-chat/agent-session-journal/journal-stop-turn-end' +import { personStopMayNameTurn } from '../native-chat/agent-session-journal/journal-stop-turn-end' export type ClaudeResultJournalContext = Pick< ClaudeMessageJournalContext, @@ -57,20 +57,18 @@ export function journalClaudeResult( } // Read before the settle below closes it: the result reports that turn's end. const endedTurnScope = turn.turnScope - // Read before the settle below closes the turn: a person's Stop of it makes an error end theirs. + // Read before the settle below closes the turn: an error end a person's Stop may have caused is + // the journal's to decide as it writes the end (`turnEndAfterStop`). const turnId = settlesTurn ? turn.id : null - const stoppedByPerson = - turnId !== null && stopIsTurnCancellation(sink.journalLatestStop?.() ?? null, turnId) + const leftToStop = + turnId !== null && personStopMayNameTurn(sink.journalLatestStop?.() ?? null, turnId) if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) // The turn is over however it ended, so a foreground child still // reported as working will never be settled by an event. subagents.settleTurn(turn.groupKey) - context.settle( - message, - commandEnd ?? claudeTurnEndForResult(message, observedAt, stoppedByPerson) - ) + context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt, leftToStop)) // The turn is over. A block still awaiting its final keeps the text the // flush above journaled, but its live state goes: an interrupted turn // would otherwise retain that text for the life of the session. @@ -78,7 +76,7 @@ export function journalClaudeResult( streamedText.settle() } const kind = claudeProviderFrameKind(message) - const failure = claudeResultFailure(message, stoppedByPerson) + const failure = claudeResultFailure(message, leftToStop) if (failure || !isSettledClaudeResultKind(kind)) { providerFallback.append( kind, diff --git a/src/main/claude/claude-result-outcome.ts b/src/main/claude/claude-result-outcome.ts index aa5e0ca6fa23..e7cc73737686 100644 --- a/src/main/claude/claude-result-outcome.ts +++ b/src/main/claude/claude-result-outcome.ts @@ -13,19 +13,19 @@ import { claudeText } from './claude-structured-item-translation' const CLAUDE_ABORTED_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools']) /** A success-subtype result still carries `is_error` for an API error, so the flag - * is what decides, never the subtype. `stoppedByPerson`: a person's Stop event names this turn - * (`stopIsTurnCancellation`), so an error end is their cancellation, since older CLIs name no - * reason. */ + * is what decides, never the subtype. `leftToStop`: a person's Stop may name this turn + * (`personStopMayNameTurn`), so an error end with no abort reason gives no verdict (undefined): + * older CLIs name no reason, and the journal's Stop rule decides as the end is written. */ export function claudeResultOutcome( message: Record, - stoppedByPerson = false -): AgentJournalTurnOutcome { + leftToStop = false +): AgentJournalTurnOutcome | undefined { if (message.is_error !== true) { return 'success' } - if (stoppedByPerson) { + const reason = claudeText(message.terminal_reason) + if (reason !== null && CLAUDE_ABORTED_TERMINAL_REASONS.has(reason)) { return 'cancellation' } - const reason = claudeText(message.terminal_reason) - return reason !== null && CLAUDE_ABORTED_TERMINAL_REASONS.has(reason) ? 'cancellation' : 'failure' + return leftToStop ? undefined : 'failure' } diff --git a/src/main/claude/claude-structured-in-turn-stop.test.ts b/src/main/claude/claude-structured-in-turn-stop.test.ts index 427ec1aaf480..abadd8c3043c 100644 --- a/src/main/claude/claude-structured-in-turn-stop.test.ts +++ b/src/main/claude/claude-structured-in-turn-stop.test.ts @@ -1,6 +1,8 @@ // A user's Stop inside a live Claude chat interrupts the turn before the host ends its child. The // turn's end then comes from the CLI's result frame, which CLIs before 2.1.91 send with no -// terminal_reason; the host's Stop event, written before the interrupt, says whose end it was. +// terminal_reason. The translator then writes an interrupted end with no verdict and no error row, +// and the host's Stop event, written before the interrupt, decides whose end it was as the journal +// writes it (`turnEndAfterStop`). import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' @@ -79,7 +81,7 @@ function providerRows(bodies: Map): string[] { } describe("a user's Stop inside a live Claude chat", () => { - it('records the turn the interrupt cut as their cancellation when the CLI names no reason', async () => { + it('leaves the turn the interrupt cut to the Stop when the CLI names no reason', async () => { const claude = fakeClaude({ routes: { // The CLI aborts the turn, then acknowledges the interrupt. @@ -96,15 +98,13 @@ describe("a user's Stop inside a live Claude chat", () => { { cancelled: true } ) - expect(settled(bodies, turnId)).toMatchObject({ - state: 'interrupted', - outcome: 'cancellation' - }) + expect(settled(bodies, turnId)).toMatchObject({ state: 'interrupted' }) + expect(settled(bodies, turnId)).not.toHaveProperty('outcome') expect(providerRows(bodies)).toEqual([]) }) // The chat's Stop button names no turn: it stops whatever the conversation has open. - it('records the open turn a Stop naming no turn cut as their cancellation', async () => { + it('leaves the open turn a Stop naming no turn cut to that Stop', async () => { const claude = fakeClaude({ routes: { interrupt: () => { @@ -121,10 +121,8 @@ describe("a user's Stop inside a live Claude chat", () => { cancelled: true }) - expect(settled(bodies, turnId)).toMatchObject({ - state: 'interrupted', - outcome: 'cancellation' - }) + expect(settled(bodies, turnId)).toMatchObject({ state: 'interrupted' }) + expect(settled(bodies, turnId)).not.toHaveProperty('outcome') expect(providerRows(bodies)).toEqual([]) }) @@ -170,12 +168,12 @@ describe("a user's Stop inside a live Claude chat", () => { expect(settled(bodies, nextTurnId)).toMatchObject({ state: 'completed', outcome: 'failure' }) }) - // The Stop ends the child next, so the turn it was asked for reads Interrupted however it ends. + // The Stop ends the child next, so the turn it was asked for is the Stop's however it ends. it.each([ ['naming the turn', true], ['naming no turn', false] ] as const)( - 'reads a turn the CLI refused to interrupt as the user stopping it, %s', + 'leaves a turn the CLI refused to interrupt to the Stop, %s', async (_label, named) => { const claude = fakeClaude({ routes: { @@ -192,7 +190,8 @@ describe("a user's Stop inside a live Claude chat", () => { ).resolves.toEqual({ cancelled: false }) connection.handlers.onMessage?.(CUT_SHORT) - expect(settled(bodies, turnId)).toMatchObject({ outcome: 'cancellation' }) + expect(settled(bodies, turnId)).toMatchObject({ state: 'interrupted' }) + expect(settled(bodies, turnId)).not.toHaveProperty('outcome') expect(providerRows(bodies)).toHaveLength(0) } ) diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index c73f22105999..0fa645ea648d 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -52,11 +52,11 @@ export function isSettledClaudeResultKind(kind: string): boolean { */ export function claudeResultFailure( message: Record, - stoppedByPerson = false + leftToStop = false ): { text: string | null } | null { // A cancellation is not a fault and earns no error row; the outcome classifier // owns that distinction so this reader cannot drift from the turn's verdict. - if (claudeResultOutcome(message, stoppedByPerson) !== 'failure') { + if (claudeResultOutcome(message, leftToStop) !== 'failure') { return null } const result = claudeText(message.result)?.trim() diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index 63cf708f1d47..e816be7f2b29 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -45,14 +45,15 @@ export type ClaudeTurnEnd = { export function claudeTurnEndForResult( message: Record, completedAt: number, - stoppedByPerson = false + leftToStop = false ): ClaudeTurnEnd { - const outcome = claudeResultOutcome(message, stoppedByPerson) + const outcome = claudeResultOutcome(message, leftToStop) const durationMs = message.duration_ms return { - state: outcome === 'cancellation' ? 'interrupted' : 'completed', + // No verdict: an interrupted end, which a person's Stop of it makes their cancellation. + state: outcome === undefined || outcome === 'cancellation' ? 'interrupted' : 'completed', completedAt, - outcome, + ...(outcome !== undefined ? { outcome } : {}), ...(typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0 ? { durationMs } : {}) diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts index ba064e3b4852..ff18ac3a5571 100644 --- a/src/main/claude/claude-turn-outcome.test.ts +++ b/src/main/claude/claude-turn-outcome.test.ts @@ -266,19 +266,25 @@ describe("a person's Stop inside a live turn", () => { ).length } - it('reads an error result with no terminal reason as the cancellation it asked for', () => { - const state = sinkWithStop(stopOf('user-1')) - const translator = createClaudeJournalTranslator({ sink: state.sink }) - translator.handle(userTurn('user-1')) - - translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) - - expect(settledTurn(state.items, 'user-1')).toMatchObject({ - state: 'interrupted', - outcome: 'cancellation' - }) - expect(providerRows(state.items)).toBe(0) - }) + // Whose end it was is the journal's Stop rule to say as it writes the end (`turnEndAfterStop`). + it.each([ + ['naming the turn', stopOf('user-1')], + ['naming no turn', { sequence: 9, event: { reason: 'user-stop' as const, at: 1 } }] + ])( + "leaves an error result with no terminal reason after a person's Stop %s to that Stop", + (_label, stop) => { + const state = sinkWithStop(stop) + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + const turn = settledTurn(state.items, 'user-1') + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + expect(providerRows(state.items)).toBe(0) + } + ) it.each([ ['no terminal reason', cutShort], diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 266abbb7fd9c..ac15fe04f0b3 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -31,20 +31,28 @@ type TurnEndState = Pick< 'items' | 'queuePauseMarks' | 'latestPersonTurnSequence' > +/** Whether the latest Stop is a person's that names turn `turnId` or names none: an end that gives + * no verdict of its own is then theirs to decide, here, where its row is built. */ +export function personStopMayNameTurn(stop: JournalLatestStop | null, turnId: string): boolean { + return ( + stop !== null && + stopIsAPersons(stop.event.reason) && + (stop.event.turnId === undefined || stop.event.turnId === turnId) + ) +} + /** Whether `stop` makes the end of turn `turnId` a person's cancellation: a person's Stop or close - * that named that turn (`opened`: or named none, and stopped the turn item `itemId` opened). */ -export function stopIsTurnCancellation( - stop: JournalLatestStop | null, + * that named that turn, or named none and stopped the turn item `itemId` opened. */ +function stopIsTurnCancellation( + stop: JournalLatestStop, turnId: string, - opened?: { state: TurnEndState; itemId: string } + state: TurnEndState, + itemId: string ): boolean { - if (stop === null || !stopIsAPersons(stop.event.reason)) { - return false - } - if (stop.event.turnId !== undefined || !opened) { - return stop.event.turnId === turnId - } - return turnlessStopStopped(opened.state, stop, opened.itemId) + return ( + personStopMayNameTurn(stop, turnId) && + (stop.event.turnId !== undefined || turnlessStopStopped(state, stop, itemId)) + ) } /** Pressed before any turn showed, a Stop stopped the first turn opened after it, and no later @@ -93,7 +101,7 @@ export function turnEndAfterStop( return body } const stop = state.queuePauseMarks.latestStop - if (!stop || !stopIsTurnCancellation(stop, body.turnId, { state, itemId })) { + if (!stop || !stopIsTurnCancellation(stop, body.turnId, state, itemId)) { return body } // An exit the provider saw before the Stop was news, whenever its end is written. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index eed9878e004b..2b02ff6266c2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -362,6 +362,28 @@ it('reads a Stop pressed before Claude echoed the send as interrupted, not as a expect(await agentStatus()).toMatchObject({ mainAgent: { outcome: 'cancellation' } }) }) +// CLIs before 2.1.91 end an interrupted turn with an error result that names no reason. +it("reads an older CLI's error end after a Stop pressed before the echo as interrupted, not failed", async () => { + const connection = claude.connections[0]! + const { terminal_reason: _reason, ...olderResult } = INTERRUPTED_RESULT + claude.routes.interrupt = () => { + setTimeout(() => { + const written = connection.sent.find((message) => message.type === 'user')! + frame(connection, { ...written, uuid: written.uuid }) + frame(connection, olderResult) + frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + }, 5) + return { still_queued: [], cancelled: [] } + } + await sendUnechoed(connection) + + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(await turnOutcome()).toBe('cancellation') + expect(await agentStatus()).toMatchObject({ mainAgent: { outcome: 'cancellation' } }) +}) + it('ends the child once the grace runs out when Claude says nothing after a Stop before the echo', async () => { const connection = claude.connections[0]! const eventsAtClose = stopEventsAtClose(connection) From 4fdd9adf5c6071f929df085ce6aee503cdedbbfd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:03:47 -0700 Subject: [PATCH 036/182] fix(native-chat): a person's Stop and /clear each name why they end the agent The host's mutation path ended the agent with one "recorded" ending for every caller, which read back the reason of whatever Stop event the journal held last, however old. /clear writes no Stop event, so its end took an unrelated earlier reason. Each caller now names its own: the chat's Stop `user-stop`, whose event its own step wrote, and /clear `user-close`, the user replacing this chat. --- .../structured-agent-session-chat-stop.ts | 8 ++++--- .../structured-agent-session-host-lifetime.ts | 22 +++++-------------- .../structured-agent-session-host.ts | 3 +-- ...ructured-agent-session-mutation-context.ts | 6 +++-- .../structured-conversation-command.test.ts | 19 ++++++++++++++++ .../structured-conversation-command.ts | 3 ++- 6 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 952ea4a214eb..22f91b3ba405 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -51,6 +51,8 @@ export function mutateWithChatStop( let windDown: StructuredAgentSessionStopWindDown | undefined const named = turnId !== undefined ? { turnId } : {} const stopEvent = { reason: 'user-stop' as const, caller: caller.callerKey, ...named } + // Its own step wrote the Stop's event first. + const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) // The same for every client: once the Stop takes effect its event is written, and the queue's // pause follows from it. The cards stay published; no text rides the answer. const stop = (ctx: AgentSessionTurnContext): Promise => @@ -64,7 +66,7 @@ export function mutateWithChatStop( if (child?.phase === 'starting') { // A start that may never land is the one thing here Stop has to end; the chat stays. await tookEffect() - await context.stopAgent(ctx.sessionId) + await stopChild() return { ok: true, value: { ...named, cancelled: true } } } // A Stop naming no turn ends nothing more unless the session reads working, by the rule @@ -86,7 +88,7 @@ export function mutateWithChatStop( { clientOperationId: envelope.clientOperationId, ...named, - stopChild: () => context.stopAgent(sessionId), + stopChild, endSession: (owed) => { windDown = owed }, @@ -111,7 +113,7 @@ export function mutateWithChatStop( await endStoppedStructuredAgentSession( { sessionId, adapter: context.deps.adapter }, windDown, - () => context.stopAgent(sessionId), + stopChild, (error) => context.deps.onEventSinkError?.({ sessionId, error }) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 63ff659702f0..4ec95bf170d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -87,29 +87,17 @@ function owedProviderChildWindDown( : session.owesProviderChildWindDown } -/** How a stop ends the child. A person's Stop wrote its event in its own step (`recorded`); any - * other stop names the reason its event records, with the host's text for it. Quit writes none: - * its resume marker's trigger records why. */ +/** How a stop ends the child, and why (`lastEndedChild`). A person's Stop wrote its event in its + * own step (`recorded` names its reason); any other stop names the reason its event records, with + * the host's text for it. Quit writes none: its resume marker's trigger records why. */ export type StructuredAgentSessionStopEnding = - | { recorded: true } + | { recorded: 'user-stop' } | { cause: Exclude reason?: string quit?: true } -/** Why the child this stop ends ended, for the delivery loop (`lastEndedChild`): a person's Stop's - * reason is its event's, which its own step wrote before it got here. */ -function childEndCause( - session: StructuredAgentSessionHostSession, - ending: StructuredAgentSessionStopEnding -): StructuredAgentSessionStopCause { - // A failed write of that event leaves the Stop no less the person's. - return 'recorded' in ending - ? (session.journal.stopMarks.latest()?.event.reason ?? 'user-stop') - : ending.cause -} - /** * Writes this stop's event (`JournalStopEvent`) when it ends a running turn or a start: a stop * that ends nothing writes nothing. Issued before the kill and never awaited by it: bookkeeping, @@ -182,7 +170,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( endProviderChild(session, { generation: stopping.generation, fence: stopping.fence, - cause: childEndCause(session, ending), + cause: 'recorded' in ending ? ending.recorded : ending.cause, reason: ('reason' in ending ? ending.reason : undefined) ?? null, duringStartup: stopping.phase === 'starting', ...verdict diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 366805935052..405580406ed3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -283,8 +283,7 @@ export class StructuredAgentSessionHost { ensureAgent: (sessionId) => ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), - // A person's Stop: its own step wrote the Stop's event first. - stopAgent: (sessionId) => this.lifetime.stopAgent(sessionId, { recorded: true }), + stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 73b47f1fe6e2..520a1dbfa13a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -12,6 +12,7 @@ import { type AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' import type { MutationPlan } from './structured-agent-session-mutation-plans' +import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -34,8 +35,9 @@ export type StructuredAgentSessionMutationContext = { ensureAgent: (sessionId: string) => Promise /** A message was accepted: the session's delivery loop hands it over. */ wakeDelivery: (sessionId: string) => void - /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. */ - stopAgent: (sessionId: string) => Promise + /** Stops the session's provider child, keeping its conversation; inside the caller's serialize. + * Each caller names why (`ending`). */ + stopAgent: (sessionId: string, ending: StructuredAgentSessionStopEnding) => Promise /** Only for gate inputs living in the RECORD store, which can settle with no * journal commit (a conversation command). Draft-table changes need no call: * the draft store notifies through the journal's own commit listener. */ diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 336e120d8baf..70513003b868 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -348,6 +348,25 @@ describe('/clear starts nothing', () => { expect(atCommit).toEqual({ child: null, claim: 'released' }) }) + // Its own cause, never the reason of whatever Stop the journal holds last. + it("ends a running source's agent as the user closing the chat", async () => { + const session = host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)! + await session.journal.appendStopEvent( + { reason: 'host-stop' }, + store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence + ) + const commit = store.commitConversationClear + let endedAs: string | undefined + vi.spyOn(store, 'commitConversationClear').mockImplementationOnce(async (clear) => { + endedAs = session.lastEndedChild?.cause + return commit(clear) + }) + + await clearCommits() + + expect(endedAs).toBe('user-close') + }) + it('founds one record per /clear through a chain of clears, starting neither', async () => { const first = await clearCommits() const second = await host.conversationCommand(caller, { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 4a825d6081d5..efdf0ad4676f 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -104,7 +104,8 @@ export function runStructuredConversationCommand( } // Stopped before the marker, so nothing the old agent does can land after the clear. The // stop releases the lease, which moves its fence: the marker is written at the new one. - await context.stopAgent(sessionId) + // A /clear replaces this chat: the user closing it. + await context.stopAgent(sessionId, { cause: 'user-close' }) const fence = store.getRecord(sessionId)!.lease.runtimeFence const completed = { command, From 98ee326cff4b3bb7214e4a8540b3e99357107eda Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:17:44 -0700 Subject: [PATCH 037/182] fix(native-chat): a host stop judges whether it ends work after the provider's rows land A close, eviction or host stop decided whether it ended a running turn from the journal as it stood, while the provider's own rows (the turn its echo opened) could still be in the session's event sink. A close landing in that gap wrote no Stop event, so the turn it cut read as news. It now reads after the sink drains, as a person's Stop does, through the same check; a drain that fails or takes over a second reads working. --- ...ctured-agent-session-close-verdict.test.ts | 21 ++++---- .../structured-agent-session-host-lifetime.ts | 54 +++++++++++++------ .../structured-agent-session-turns-cancel.ts | 15 ++++-- 3 files changed, 61 insertions(+), 29 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 1a42f5803c86..89878aee78d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -19,13 +19,10 @@ import { StructuredAgentSessionHost } from './structured-agent-session-host' import { adapter, attach, - CALLER, - envelope, hostTestState, replaceHostTestState } from './structured-agent-session-host-test-harness' import { - hostTestMessage, HOST_TEST_NOW, HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD @@ -171,14 +168,20 @@ describe('a turn cut short by closing its provider', () => { }) it("records the user's close on a turn whose start landed only as the provider stopped", async () => { - // A send still unanswered when the close arrives: the journal has no turn yet. + // The provider opened the turn, but its rows are still in the event sink when the close + // arrives: the journal has no turn yet. await attach() - hostTestState().dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('long job') - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) await host.flushStreamedEvents(SESSION) - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() + const events = hostTestState().acquire.mock.calls[0]?.[0].events + events?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'cut-turn', ordinal: 0 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + events?.appendItem( + CUT_TURN, + { kind: 'turn', turnId: 'cut-turn', state: 'running', startedAt: 1_000, requestedAt: 1_000 }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.close(SESSION, 'user-close') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 4ec95bf170d2..18391bb7f6e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -31,7 +31,7 @@ import { import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' -import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { isMainAgentWorkingOnceFlushed } from './structured-agent-session-turns-cancel' export type StructuredAgentSessionLifetimeContext = { deps: StructuredAgentSessionHostDeps @@ -98,29 +98,50 @@ export type StructuredAgentSessionStopEnding = quit?: true } +/** How long a host stop waits for the session's sink before it judges whether the stop ends work. */ +const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000 + /** - * Writes this stop's event (`JournalStopEvent`) when it ends a running turn or a start: a stop - * that ends nothing writes nothing. Issued before the kill and never awaited by it: bookkeeping, - * reported on failure. + * Whether this stop ends work its event must record: a start, or a running turn or unanswered send + * read once the sink drained what the provider already said (`isMainAgentWorkingOnceFlushed`). A + * person's Stop wrote its own event, and quit writes none. */ +async function stopEndsWork( + context: StructuredAgentSessionLifetimeContext, + sessionId: string, + session: StructuredAgentSessionHostSession, + ending: StructuredAgentSessionStopEnding +): Promise { + const { child, journal } = session + if ('recorded' in ending || ending.quit || !child) { + return false + } + return ( + child.phase === 'starting' || + isMainAgentWorkingOnceFlushed( + { + journal, + fence: child.fence, + flushStreamedEvents: () => context.runtimeState.flushEventSink(sessionId) + }, + STOP_EVENT_DRAIN_TIMEOUT_MS + ) + ) +} + +/** Writes this stop's event (`JournalStopEvent`). Issued before the kill and never awaited by it: + * bookkeeping, reported on failure. */ function recordStopEvent( context: StructuredAgentSessionLifetimeContext, sessionId: string, session: StructuredAgentSessionHostSession, ending: StructuredAgentSessionStopEnding ): Promise { - const { child, journal } = session - const turnId = journal.activeTurnId() - if ( - 'recorded' in ending || - ending.quit || - !child || - (child.phase !== 'starting' && - !isStructuredAgentSessionMainAgentWorking(turnId, journal.submissions(), child.fence)) - ) { + if ('recorded' in ending) { return Promise.resolve() } - return journal + const turnId = session.journal.activeTurnId() + return session.journal .appendStopEvent( { reason: ending.cause, ...(turnId !== null ? { turnId } : {}) }, structuredAgentSessionConversationFence(context.deps.store, sessionId) @@ -146,7 +167,10 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( if (!session) { return } - const recorded = recordStopEvent(context, sessionId, session, ending) + // Judged before the kill: a stop that ends nothing writes nothing. + const recorded = (await stopEndsWork(context, sessionId, session, ending)) + ? recordStopEvent(context, sessionId, session, ending) + : Promise.resolve() // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, // so a step that aborts after that point would otherwise leave the retry reading "no child // here" and skipping the settlement and the lease release it still owes. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index cdb70ff41d45..b90b4ad8645e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -13,16 +13,21 @@ import { } from './structured-agent-session-command-turn' import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { withTimeout } from '../../../shared/promise-timeout-fallback' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' /** Claude's echo accepts a send one sink write before its turn row lands, so read after the drain. - * A failed drain reads working: bookkeeping never talks a Stop out of stopping. */ + * A failed drain, or one still running after `timeoutMs`, reads working: bookkeeping never talks a + * Stop out of stopping. */ export async function isMainAgentWorkingOnceFlushed( - ctx: Pick + ctx: Pick, + timeoutMs?: number ): Promise { - try { - await ctx.flushStreamedEvents() - } catch { + const flushed = ctx.flushStreamedEvents().then( + () => true, + () => false + ) + if (!(await (timeoutMs === undefined ? flushed : withTimeout(flushed, timeoutMs, false)))) { return true } return isStructuredAgentSessionMainAgentWorking( From d0e4cfc107b20ff99ee465e5d0114360ce272a98 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:28:10 -0700 Subject: [PATCH 038/182] fix(native-chat): a Claude Stop naming a turn that just ended still marks the follow-up it cuts A phone names the turn it last saw. When that turn had ended and a follow-up was still unechoed, Claude's Stop interrupted the follow-up and ended the child, but the Stop's event named the ended turn, so the follow-up's turn the child's end cut read "Failed" under "Cancellation requested.". A Stop that ends the provider's session ends whatever is in flight, so its event now names the live turn or none, and a Stop that names none binds the turn opened next. Codex keeps naming only the turn the Stop names. The Claude Stop turn-end tests move to their own file, since the session-ending Stop suite is at its line budget. --- .../structured-agent-session-chat-stop.ts | 85 ++++---- ...t-session-claude-stop-ends-session.test.ts | 22 -- ...agent-session-claude-stop-turn-end.test.ts | 200 ++++++++++++++++++ 3 files changed, 247 insertions(+), 60 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 22f91b3ba405..3a9602dc6026 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -50,52 +50,61 @@ export function mutateWithChatStop( // Set by the Stop's step only when its provider's session ends; a replay leaves it unset. let windDown: StructuredAgentSessionStopWindDown | undefined const named = turnId !== undefined ? { turnId } : {} - const stopEvent = { reason: 'user-stop' as const, caller: caller.callerKey, ...named } // Its own step wrote the Stop's event first. const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) // The same for every client: once the Stop takes effect its event is written, and the queue's // pause follows from it. The cards stay published; no text rides the answer. const stop = (ctx: AgentSessionTurnContext): Promise => - runRecordedStop(ctx, stopEvent, async (tookEffect) => { - // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. - const withdrawn = await ctx.journal.rejectQueuedSubmissions( - ctx.fence, - agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) - ) - const child = context.sessions.get(ctx.sessionId)?.child - if (child?.phase === 'starting') { - // A start that may never land is the one thing here Stop has to end; the chat stays. - await tookEffect() - await stopChild() - return { ok: true, value: { ...named, cancelled: true } } - } - // A Stop naming no turn ends nothing more unless the session reads working, by the rule - // every session list and the chat's own Stop read it. - const inFlight = turnId !== undefined || (await isMainAgentWorkingOnceFlushed(ctx)) - const record = context.deps.store.getRecord(ctx.sessionId) - if (!child || !inFlight) { - if (withdrawn.length > 0) { + runRecordedStop( + ctx, + { + reason: 'user-stop', + caller: caller.callerKey, + // A Stop that ends the provider's session ends whatever is in flight, so its event names + // the live turn, or none (the turn opened next), never a named turn that already ended. + ...(ctx.adapter.stopEndsSession?.(ctx.sessionId) === true ? {} : named) + }, + async (tookEffect) => { + // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. + const withdrawn = await ctx.journal.rejectQueuedSubmissions( + ctx.fence, + agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + ) + const child = context.sessions.get(ctx.sessionId)?.child + if (child?.phase === 'starting') { + // A start that may never land is the one thing here Stop has to end; the chat stays. await tookEffect() + await stopChild() + return { ok: true, value: { ...named, cancelled: true } } } - return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } - } - // Awaited until journal appends are synchronous; then issued here, and a `finally` awaits it. - if (withdrawn.length > 0 || (await stopReachesUnrecordedWork(ctx, turnId))) { - await tookEffect() - } - return performCancel( - { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, - { - clientOperationId: envelope.clientOperationId, - ...named, - stopChild, - endSession: (owed) => { - windDown = owed - }, - withdrewQueued: withdrawn.length > 0 + // A Stop naming no turn ends nothing more unless the session reads working, by the rule + // every session list and the chat's own Stop read it. + const inFlight = turnId !== undefined || (await isMainAgentWorkingOnceFlushed(ctx)) + const record = context.deps.store.getRecord(ctx.sessionId) + if (!child || !inFlight) { + if (withdrawn.length > 0) { + await tookEffect() + } + return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } } - ) - }) + // Awaited until journal appends are synchronous; then issued here, and a `finally` awaits it. + if (withdrawn.length > 0 || (await stopReachesUnrecordedWork(ctx, turnId))) { + await tookEffect() + } + return performCancel( + { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, + { + clientOperationId: envelope.clientOperationId, + ...named, + stopChild, + endSession: (owed) => { + windDown = owed + }, + withdrewQueued: withdrawn.length > 0 + } + ) + } + ) const result = mutateStructuredAgentSession( context, caller, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index 2b02ff6266c2..eed9878e004b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -362,28 +362,6 @@ it('reads a Stop pressed before Claude echoed the send as interrupted, not as a expect(await agentStatus()).toMatchObject({ mainAgent: { outcome: 'cancellation' } }) }) -// CLIs before 2.1.91 end an interrupted turn with an error result that names no reason. -it("reads an older CLI's error end after a Stop pressed before the echo as interrupted, not failed", async () => { - const connection = claude.connections[0]! - const { terminal_reason: _reason, ...olderResult } = INTERRUPTED_RESULT - claude.routes.interrupt = () => { - setTimeout(() => { - const written = connection.sent.find((message) => message.type === 'user')! - frame(connection, { ...written, uuid: written.uuid }) - frame(connection, olderResult) - frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) - }, 5) - return { still_queued: [], cancelled: [] } - } - await sendUnechoed(connection) - - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) - await laneDrained() - - expect(await turnOutcome()).toBe('cancellation') - expect(await agentStatus()).toMatchObject({ mainAgent: { outcome: 'cancellation' } }) -}) - it('ends the child once the grace runs out when Claude says nothing after a Stop before the echo', async () => { const connection = claude.connections[0]! const eventsAtClose = stopEventsAtClose(connection) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts new file mode 100644 index 000000000000..a9cfcaea9fc5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -0,0 +1,200 @@ +// Which turn a Claude Stop's event makes the person's cancellation, on the shipping adapter: the +// Stop ends the child, so whatever its event binds is what the child's end cut. Older CLIs end an +// interrupted turn with an error result that names no reason, and the journal's Stop rule decides +// it as it writes the end. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +// As Claude Code 2.1.280 advertises them on a turn's system/init frame. +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-stop-turn-end-')) + resetHostTestOperationIds() + claude = fakeClaude({ replayUuid: null }) + const lifecycle: Promise[] = [] + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) + await Promise.all(lifecycle) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function eventually(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function envelope(method: 'agentSession.send' | 'agentSession.cancel', fields: object) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields: { ...fields } + }) + } +} + +/** A send Claude takes but has not echoed yet. */ +async function sendUnechoed(connection: FakeConnection, text: string): Promise { + const body = hostTestMessage(text) + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await eventually(() => + expect(connection.sent.some((message) => JSON.stringify(message).includes(text))).toBe(true) + ) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +/** Claude echoes its latest send, which opens that send's turn. */ +function echoLatest(connection: FakeConnection): void { + const written = connection.sent.findLast((message) => message.type === 'user')! + frame(connection, { ...written, uuid: written.uuid }) +} + +function stop(turnId?: string) { + const fields = turnId === undefined ? {} : { turnId } + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) +} + +/** Resolves once everything queued on the session's lane so far has run: a Stop's second step. */ +function laneDrained(): Promise { + return host['tasks'].serialize(SESSION, async () => {}) +} + +async function lastTurn() { + await host.flushStreamedEvents(SESSION) + const { items } = await host.journalSnapshot(SESSION) + return readAgentJournalTurn(items.findLast((item) => item.body.kind === 'turn')?.body) +} + +it("reads an older CLI's error end after a Stop pressed before the echo as interrupted, not failed", async () => { + const connection = claude.connections[0]! + claude.routes.interrupt = () => { + setTimeout(() => { + echoLatest(connection) + frame(connection, { type: 'result', subtype: 'error_during_execution', is_error: true }) + frame(connection, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + }, 5) + return { still_queued: [], cancelled: [] } + } + await sendUnechoed(connection, 'Write a long reply.') + + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(await lastTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) +}) + +// A phone names the turn it last saw. The Stop ends the child, which ends whatever is in flight, +// so its event names no ended turn: it binds the turn the follow-up's echo opens. +it('reads a follow-up the child end cut as interrupted when the Stop named the turn before it', async () => { + const connection = claude.connections[0]! + await sendUnechoed(connection, 'Write a long reply.') + frame(connection, { type: 'system', subtype: 'init', uuid: 'init-1', capabilities: CAPABILITIES }) + echoLatest(connection) + const ended = await eventually(async () => { + const turnId = activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + expect(turnId).not.toBeNull() + return turnId! + }) + frame(connection, { type: 'result', subtype: 'success', is_error: false, uuid: 'ended-result' }) + await eventually(async () => + expect(activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items)).toBe( + null + ) + ) + await sendUnechoed(connection, 'Follow up.') + // Claude takes the interrupt; the follow-up's echo opens its turn, which outlives the grace. + claude.routes.interrupt = () => { + setTimeout(() => echoLatest(connection), 5) + return { still_queued: [], cancelled: [] } + } + + await expect(stop(ended)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + + expect(connection.closed).toBe(true) + const cut = await lastTurn() + expect(cut?.turnId).not.toBe(ended) + expect(cut).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) +}, 15_000) From 771ad35d74cb310e7d338dbf8811a0bd2dfb1069 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:38:44 -0700 Subject: [PATCH 039/182] fix(native-chat): the idle sweep reads working by the same rule as a stop's event The sweep judged a chat resting while a send whose reply was lost was still unanswered, but the stop's event writer counts that send as work. So the sweep evicted it and wrote an evict event, which ends a person's Stop pause and let the cards behind it drain on their own. The sweep's owed work now reads the main agent working the way every session list and the event writer do. --- .../structured-agent-session-idle-sweep.ts | 11 ++++++--- ...d-agent-session-stop-event-entries.test.ts | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index b57898626a81..2cbc1c3b8147 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -9,7 +9,7 @@ // Owed work is derived on every tick, never stored, so there is nothing to disagree with it. import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -139,11 +139,16 @@ export class StructuredAgentSessionIdleSweep { } /** Work the running child still owes. Scoped to the child: with none, nothing here can pin the - * handle, and a leftover prompt or turn row is only history. */ + * handle, and a leftover prompt or turn row is only history. Working reads as a stop's event + * does (`isStructuredAgentSessionMainAgentWorking`), so a chat swept as resting writes none. */ private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { const items = session.journal.snapshot().items return ( - activeStructuredAgentSessionTurnId(items) !== null || + isStructuredAgentSessionMainAgentWorking( + session.journal.activeTurnId(), + session.journal.submissions(), + session.child?.fence + ) || this.queuedOrDelivering(sessionId, session) || agentChildWorkLiveness(this.deps.childWork(sessionId)) !== null || this.deps.hasOpenDispatch(sessionId) || diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index a0a1dd5dc511..97558d95c2f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -170,6 +170,29 @@ describe("a person's Stop pause and the Stop events after it", () => { expect(await rig.handoff(held)).toBeUndefined() }) + // The sweep and the Stop event's writer read working by one rule: a send whose reply was lost + // may still be running, so neither calls the chat resting. + it('holds through an idle tick while a send with a lost reply is unanswered: nothing is evicted', async () => { + rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) + const working = await rig.workingSend() + await queuedDraft('queued behind the turn') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'stopped') + rig.dispatch.mockRejectedValueOnce(new Error('reply lost')) + const lost = rig.send('sent as the reply was lost') + await lost.result + await eventually(async () => + expect(await rig.submission(lost.id)).toMatchObject({ dispatchState: 'unknown' }) + ) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + + await idleSweep().tick() + + expect(rig.closeSession).not.toHaveBeenCalled() + expect(stopEvents().map((event) => event.reason)).toEqual(['user-stop']) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + it('ends when a host eviction ends a running turn: that Stop event is later', async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() From a51a1ee36cebf4c67ed0e0960e621c26700c1c72 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:47:07 -0700 Subject: [PATCH 040/182] test(native-chat): an aborted eviction's injected drain failure lands on the eviction's own drain A host stop now drains the session's sink once to judge whether it ends work, so the tests that fail the eviction's drain-published step skip that first drain. --- ...red-agent-session-surface-lifetime.test.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 7d7d4861cd0b..aebdfd8a9c40 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -142,12 +142,14 @@ function waitOutSeveralSweeps(): Promise { return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) } -/** Fails the next eviction at `drain-published`, which leaves the session indexed for a retry. */ -function failNextDrain(): void { - vi.spyOn(host['runtimeState'].eventSinkFor(SESSION), 'drained').mockResolvedValueOnce({ - ok: false, - error: new Error('drain barrier lost') - }) +/** Fails the next eviction at `drain-published`, which leaves the session indexed for a retry. The + * stop drains once before it, to judge whether it ends work. */ +function failEvictionDrain(): void { + const sink = host['runtimeState'].eventSinkFor(SESSION) + const drained = sink.drained.bind(sink) + vi.spyOn(sink, 'drained') + .mockImplementationOnce(drained) + .mockResolvedValueOnce({ ok: false, error: new Error('drain barrier lost') }) } /** The submissions as they stood when the session was forgotten; its journal is gone after that. */ @@ -343,10 +345,7 @@ describe('a chat that closes', () => { expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) const session = host['sessions'].get(SESSION) expect(session).toBeDefined() - vi.spyOn(host['runtimeState'].eventSinkFor(SESSION), 'drained').mockResolvedValueOnce({ - ok: false, - error: new Error('drain barrier lost') - }) + failEvictionDrain() const settled = captureSettledSubmissions() await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' }) @@ -818,7 +817,7 @@ describe('a quit over an eviction that never got its retry', () => { await attach() await sendPending('pending across an abandoned eviction') const settled = captureSettledSubmissions() - failNextDrain() + failEvictionDrain() await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' }) expect(host['sessions'].get(SESSION)?.child).toBeNull() From eb27559bcbbc9689b262305ba7469217ad35cf43 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:48:23 -0700 Subject: [PATCH 041/182] fix(native-chat): the idle sweep's rest writes no Stop event; it evicts a send that never echoes The previous commit made the sweep count an unanswered send as owed work, which pins a chat whose admitted send Codex never echoes forever, and the sweep exists to retire exactly that. That rule returns. The sweep stops only an agent it judged resting, so its eviction now writes no Stop event, whatever send it retires: a person's Stop pause holds through it. --- .../structured-agent-session-conversation-lifetime.ts | 2 +- .../structured-agent-session-host-lifetime.ts | 7 +++++-- .../structured-agent-session-idle-sweep.ts | 11 +++-------- ...tructured-agent-session-stop-event-entries.test.ts | 11 ++++++----- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 2ea8820e7580..43100a17e265 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -80,7 +80,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { }, providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. - stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict' }), + stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict', resting: true }), // A host stop: the delivery loop waiting on this child writes the one error row and rejects // what is queued with it, both worded from the hostStopped fact. stopStartingAgent: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 18391bb7f6e2..653c806cf89c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -96,6 +96,9 @@ export type StructuredAgentSessionStopEnding = cause: Exclude reason?: string quit?: true + /** The idle sweep judged the agent resting (`owesWork`): a send it retires unanswered is + * no work its event records. */ + resting?: true } /** How long a host stop waits for the session's sink before it judges whether the stop ends work. */ @@ -104,7 +107,7 @@ const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000 /** * Whether this stop ends work its event must record: a start, or a running turn or unanswered send * read once the sink drained what the provider already said (`isMainAgentWorkingOnceFlushed`). A - * person's Stop wrote its own event, and quit writes none. + * person's Stop wrote its own event, and quit and the idle sweep's rest write none. */ async function stopEndsWork( context: StructuredAgentSessionLifetimeContext, @@ -113,7 +116,7 @@ async function stopEndsWork( ending: StructuredAgentSessionStopEnding ): Promise { const { child, journal } = session - if ('recorded' in ending || ending.quit || !child) { + if ('recorded' in ending || ending.quit || ending.resting || !child) { return false } return ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 2cbc1c3b8147..b57898626a81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -9,7 +9,7 @@ // Owed work is derived on every tick, never stored, so there is nothing to disagree with it. import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' -import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -139,16 +139,11 @@ export class StructuredAgentSessionIdleSweep { } /** Work the running child still owes. Scoped to the child: with none, nothing here can pin the - * handle, and a leftover prompt or turn row is only history. Working reads as a stop's event - * does (`isStructuredAgentSessionMainAgentWorking`), so a chat swept as resting writes none. */ + * handle, and a leftover prompt or turn row is only history. */ private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { const items = session.journal.snapshot().items return ( - isStructuredAgentSessionMainAgentWorking( - session.journal.activeTurnId(), - session.journal.submissions(), - session.child?.fence - ) || + activeStructuredAgentSessionTurnId(items) !== null || this.queuedOrDelivering(sessionId, session) || agentChildWorkLiveness(this.deps.childWork(sessionId)) !== null || this.deps.hasOpenDispatch(sessionId) || diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 97558d95c2f2..acc72441a008 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -170,9 +170,9 @@ describe("a person's Stop pause and the Stop events after it", () => { expect(await rig.handoff(held)).toBeUndefined() }) - // The sweep and the Stop event's writer read working by one rule: a send whose reply was lost - // may still be running, so neither calls the chat resting. - it('holds through an idle tick while a send with a lost reply is unanswered: nothing is evicted', async () => { + // The sweep rests an agent with no turn running even while a send whose reply was lost waits, + // so that send cannot pin it forever; that rest ends no work, so it writes no event. + it('holds through an idle eviction that retires a send whose reply was lost', async () => { rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) const working = await rig.workingSend() await queuedDraft('queued behind the turn') @@ -185,11 +185,12 @@ describe("a person's Stop pause and the Stop events after it", () => { expect(await rig.submission(lost.id)).toMatchObject({ dispatchState: 'unknown' }) ) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + const atClose = stopEventsAtClose() await idleSweep().tick() - expect(rig.closeSession).not.toHaveBeenCalled() - expect(stopEvents().map((event) => event.reason)).toEqual(['user-stop']) + expect(rig.closeSession).toHaveBeenCalledTimes(1) + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) From 693583d6f3a7433da8c384ddbfaffa5b09083303 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:50:33 -0700 Subject: [PATCH 042/182] fix(native-chat): stopping a start that carries no send writes no Stop event A host stop, eviction or close of a starting child wrote a Stop event whatever the start carried. A start with a send already reads working, so the clause only mattered for a start with none, which ends no turn and no send: its event only lifted a person's Stop pause and bumped the idle clock, which is why the idle sweep had been changed to close the conversation in the same pass. The clause goes and the sweep is #24072's again. The child's end still reads host-stop, as before. --- .../structured-agent-session-host-lifetime.ts | 24 +++++++++---------- .../structured-agent-session-idle-sweep.ts | 9 +++---- ...d-agent-session-stop-event-entries.test.ts | 21 ++++++++++++++-- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 653c806cf89c..0fdd577daf6d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -105,9 +105,10 @@ export type StructuredAgentSessionStopEnding = const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000 /** - * Whether this stop ends work its event must record: a start, or a running turn or unanswered send - * read once the sink drained what the provider already said (`isMainAgentWorkingOnceFlushed`). A - * person's Stop wrote its own event, and quit and the idle sweep's rest write none. + * Whether this stop ends work its event must record: a running turn or an unanswered send, a start's + * own included, read once the sink drained what the provider already said + * (`isMainAgentWorkingOnceFlushed`). A start that carries no send ends nothing. A person's Stop + * wrote its own event, and quit and the idle sweep's rest write none. */ async function stopEndsWork( context: StructuredAgentSessionLifetimeContext, @@ -119,16 +120,13 @@ async function stopEndsWork( if ('recorded' in ending || ending.quit || ending.resting || !child) { return false } - return ( - child.phase === 'starting' || - isMainAgentWorkingOnceFlushed( - { - journal, - fence: child.fence, - flushStreamedEvents: () => context.runtimeState.flushEventSink(sessionId) - }, - STOP_EVENT_DRAIN_TIMEOUT_MS - ) + return isMainAgentWorkingOnceFlushed( + { + journal, + fence: child.fence, + flushStreamedEvents: () => context.runtimeState.flushEventSink(sessionId) + }, + STOP_EVENT_DRAIN_TIMEOUT_MS ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index b57898626a81..14cc0e56030d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -123,10 +123,11 @@ export class StructuredAgentSessionIdleSweep { } if (session.child) { // A start that has been quiet this long is not coming: the host stops it, with its reason. - // The close below keeps a conversation whose delivery loop still owes that start's rows. - await (session.child.phase === 'starting' - ? this.deps.stopStartingAgent(sessionId) - : this.deps.stopAgent(sessionId)) + if (session.child.phase === 'starting') { + await this.deps.stopStartingAgent(sessionId) + return + } + await this.deps.stopAgent(sessionId) } await this.deps.closeConversation(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index acc72441a008..5748a7f2c4e0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -1,5 +1,5 @@ // Every way a host stop or close begins writes the Stop's event with its reason, before it ends the -// child, and only when it ends work: a running turn, or a start. A stop that ends nothing writes +// child, and only when it ends work: a running turn or a send. A stop that ends nothing writes // nothing, quit writes nothing (its resume marker records why), and any later Stop event ends a // person's Stop pause. @@ -112,7 +112,7 @@ describe('every Stop entry writes its event, with its reason, before it ends the ]) }) - it('the idle sweep stopping a start that never landed', async () => { + it('the idle sweep stopping a start, with its send, that never landed', async () => { rig = await createQueuedMessageTestRig({ starting: true, restartable: true, @@ -194,6 +194,23 @@ describe("a person's Stop pause and the Stop events after it", () => { expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) + // A start that carries no send ends no turn and no send, so stopping it writes nothing. + it('holds through the sweep stopping a start that carries no send, which writes nothing', async () => { + rig = await createQueuedMessageTestRig({ starting: true, idleSweep: MANUAL_IDLE_SWEEP }) + expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( + 'starting' + ) + // A person's Stop of an earlier turn still pauses the queue. + await journal().appendStopEvent({ reason: 'user-stop', caller: 'client-1' }, 1) + expect(journal().queuedMessages.userStopInForce()).not.toBeNull() + const atClose = stopEventsAtClose() + + await idleSweep().tick() + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) + expect(journal().queuedMessages.userStopInForce()).not.toBeNull() + }) + it('ends when a host eviction ends a running turn: that Stop event is later', async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() From 3b8c6e74e4917f37515fcec45ff5be27b965db6b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:56:50 -0700 Subject: [PATCH 043/182] test(native-chat): a Stop's pause across a restart is tested with a restart that writes no event The rig's restart closes the chat with an eviction, which now writes a Stop event when work runs and so ends a person's Stop pause. "A Stop never hides a restart's pause" then passed with no Stop pause left to hide anything. Those tests, and the pause-lift test whose dropped assertion returns, restart as a process that dies with no close, which like a quit writes no Stop event, and assert that both the Stop's and the restart's pauses are in force first. --- ...nt-session-queued-message-rig.test-fixture.ts | 4 +++- ...tured-agent-session-queued-pause-lift.test.ts | 9 ++++++--- ...uctured-agent-session-queued-stop-row.test.ts | 16 ++++++++++++++-- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index f39c2d8cb195..83fa0c39c8ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -268,7 +268,9 @@ export async function createQueuedMessageTestRig( } /** A host-process restart, as the queue sees it: the conversation closes, and - * opens afresh under a new instance id while its rows survive. */ + * opens afresh under a new instance id while its rows survive. The close is an eviction, whose + * Stop event ends a person's Stop pause if work runs; a quit writes none, so a test of that + * pause across a restart uses `crashRestartHostProcess`. */ async function restartHostProcess(): Promise { await host.close(SESSION, 'evict') rotateStructuredAgentSessionHostInstanceForTests() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index f2ad9a18fa41..d5aa5e497d45 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -91,11 +91,14 @@ describe("a Stop's queue pause", () => { it('survives a restart, and a send made after the Stop still ends it when its turn starts there', async () => { const draftId = await stoppedDraft() - // Derived from the journal, not remembered: a restart forgets nothing it needs. Quit writes no - // Stop event, so the process dying is that restart; `restartHostProcess` closes with an - // eviction, whose event would end the pause itself. + const inFlight = await handedOverUserSend('sent before the restart') + // Derived from the journal, not remembered: a restart forgets nothing it needs. The process + // dies with no close, as a quit writes no Stop event to end the pause either. rig.crashRestartHostProcess() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + await rig.settleAccepted(inFlight, 'after-restart') + // The Stop's pause is over; the restart's own lasts until a turn asked for since it. + await eventually(async () => expect(await rig.queuePause()).toEqual({ reason: 'restarted' })) const next = rig.send('sent after the restart') await next.result await rig.settleAccepted(next.id, 'next') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index aa2c93ba15e7..d1db51426c50 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -192,8 +192,15 @@ describe("a Stop never hides a restart's pause", () => { await rig.settleAccepted(working, 'stopped') const mail = await mailTurn() const typed = await queuedDraft('typed during the mail turn') - await rig.restartHostProcess() + // The process dies with no close: a quit writes no Stop event either, so the Stop's pause stays. + rig.crashRestartHostProcess() await rig.settleAccepted(mail, 'mail') + // The new host opens the conversation for its first reader. + await rig.queuePause() + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([ + 'stopped', + 'restarted' + ]) await expectHeld('restarted', typed) }) @@ -207,8 +214,13 @@ describe("a Stop never hides a restart's pause", () => { expect((await rig.handoff(correction))?.handedOverAt).toBeDefined() ) const typed = await queuedDraft('typed during that send') - await rig.restartHostProcess() + rig.crashRestartHostProcess() await rig.settleAccepted(await rig.handoffId(correction), 'correction') + await rig.queuePause() + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([ + 'stopped', + 'restarted' + ]) await expectHeld('restarted', typed) }) }) From 37ed748f8daa73ae6823207830824b5348722ada Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:02:34 -0700 Subject: [PATCH 044/182] fix(native-chat): a host stop of a turn a person's Stop is still ending keeps that Stop's reason An eviction or host stop that landed while a person's Stop or close was already ending the same turn wrote a newer Stop event, and the turn's end reads only the latest, so the person's Stop of that turn read as news. A host reason now writes nothing while a person's Stop still decides what runs: the live turn it names or bound, or, with none, the turn a send opens next. The person's own close still writes. The E2 tests now open and end the stopped send's own turn, as Codex does, so the mail turn after it is not the turnless Stop's. --- .../journal-stop-marks.ts | 9 +++- .../journal-stop-turn-end.ts | 25 +++++++++-- .../structured-agent-session-host-lifetime.ts | 7 +++- ...d-agent-session-stop-event-entries.test.ts | 42 +++++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts index 87a87f26e194..27cfa28e536d 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -1,8 +1,8 @@ // What the journal answers about its Stops beyond the queue's pause: the latest Stop event, which -// the turn-end rule reads (`journal-stop-turn-end.ts`). +// the turn-end rule reads (`journal-stop-turn-end.ts`), and whether a person's still decides. import type { JournalReducerState } from './journal-reducer' -import type { JournalLatestStop } from './journal-stop-turn-end' +import { personStopInForce, type JournalLatestStop } from './journal-stop-turn-end' export class JournalStopMarks { constructor(private readonly deps: { state: () => JournalReducerState }) {} @@ -10,4 +10,9 @@ export class JournalStopMarks { latest(): JournalLatestStop | null { return this.deps.state().queuePauseMarks.latestStop } + + /** `personStopInForce`: a person's Stop already decides what runs now. */ + personStopInForce(turnId: string | null): boolean { + return personStopInForce(this.deps.state(), turnId) + } } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index ac15fe04f0b3..9fed007679f6 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -47,7 +47,7 @@ function stopIsTurnCancellation( stop: JournalLatestStop, turnId: string, state: TurnEndState, - itemId: string + itemId: string | null ): boolean { return ( personStopMayNameTurn(stop, turnId) && @@ -56,13 +56,14 @@ function stopIsTurnCancellation( } /** Pressed before any turn showed, a Stop stopped the first turn opened after it, and no later - * one: unless a send a person made since was accepted, whose turn that is. */ + * one: unless a send a person made since was accepted, whose turn that is. `itemId` null: a turn + * not yet opened. */ function turnlessStopStopped( state: TurnEndState, stop: JournalLatestStop, - itemId: string + itemId: string | null ): boolean { - const createdAt = state.items.get(itemId)?.sequence ?? null + const createdAt = itemId === null ? null : (state.items.get(itemId)?.sequence ?? null) if ( (createdAt !== null && createdAt <= stop.sequence) || state.latestPersonTurnSequence >= stop.sequence @@ -82,6 +83,22 @@ function turnlessStopStopped( return true } +/** Whether a person's Stop already decides the end of what runs now: the live turn `turnId`, or + * with none, the turn a send opens next. A host stop of that work must not supersede it. */ +export function personStopInForce(state: TurnEndState, turnId: string | null): boolean { + const stop = state.queuePauseMarks.latestStop + if (!stop || !stopIsAPersons(stop.event.reason)) { + return false + } + if (turnId === null) { + return stop.event.turnId === undefined && turnlessStopStopped(state, stop, null) + } + const itemId = [...state.items].find( + ([, item]) => readAgentJournalTurn(item.body)?.turnId === turnId + )?.[0] + return stopIsTurnCancellation(stop, turnId, state, itemId ?? null) +} + /** * The body to write for item `itemId`: unchanged unless it ends, with no verdict of its own and no * earlier than the latest Stop event, a person's, which named it, or stopped it before it showed, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 0fdd577daf6d..d3e5c728feb4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -120,7 +120,7 @@ async function stopEndsWork( if ('recorded' in ending || ending.quit || ending.resting || !child) { return false } - return isMainAgentWorkingOnceFlushed( + const working = await isMainAgentWorkingOnceFlushed( { journal, fence: child.fence, @@ -128,6 +128,11 @@ async function stopEndsWork( }, STOP_EVENT_DRAIN_TIMEOUT_MS ) + // A host stop of work a person's Stop is already ending must not supersede that Stop's reason. + return ( + working && + (ending.cause === 'user-close' || !journal.stopMarks.personStopInForce(journal.activeTurnId())) + ) } /** Writes this stop's event (`JournalStopEvent`). Issued before the kill and never awaited by it: diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 5748a7f2c4e0..26f191a7f90c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' import { @@ -59,6 +60,28 @@ async function runningTurn(turnId = 'turn-1'): Promise { return working } +/** The stopped send's turn opens after its turnless Stop and ends cut: the turn that Stop bound. */ +async function stoppedTurnEnded(): Promise { + const fence = rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 + const scope = { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const identity = { + provider: 'codex' as const, + threadId: 'thread-1', + turnId: 'turn-stopped', + ordinal: 998 + } + await journal().appendItem( + identity, + { kind: 'turn', turnId: 'turn-stopped', state: 'running', startedAt: 1 }, + scope + ) + await journal().appendItem( + identity, + { kind: 'turn', turnId: 'turn-stopped', state: 'interrupted', completedAt: Date.now() }, + scope + ) +} + async function queuedDraft(text: string): Promise { const queued = await rig.send(text, 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { @@ -141,6 +164,23 @@ describe('every Stop entry writes its event, with its reason, before it ends the expect(atClose.events).toEqual([]) }) + // The person's reason survives to the turn's end. + it("writes nothing when the host evicts a turn a person's Stop is still ending", async () => { + rig = await createQueuedMessageTestRig() + await runningTurn() + expect(await rig.stop()).toMatchObject({ ok: true }) + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, 'evict') + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) + const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION) + expect(items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + }) + it("writes nothing at quit, whose resume marker's trigger records why", async () => { rig = await createQueuedMessageTestRig() await runningTurn() @@ -217,6 +257,7 @@ describe("a person's Stop pause and the Stop events after it", () => { await queuedDraft('queued behind the turn') expect(await rig.stop()).toMatchObject({ ok: true }) await rig.settleAccepted(working, 'stopped') + await stoppedTurnEnded() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) // Orchestration mail starts a turn the host sent, which lifts nothing. await rig.send('mail for the lead', undefined, { internal: true }).result @@ -244,6 +285,7 @@ describe("a person's Stop pause and the Stop events after it", () => { const held = await queuedDraft('queued behind the turn') expect(await rig.stop()).toMatchObject({ ok: true }) await rig.settleAccepted(working, 'stopped') + await stoppedTurnEnded() // The agent at rest goes, writing nothing; mail then starts a new child, which never lands. await idleSweep().tick() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) From 69b4dec08aa64dd117e75f97cdae457cdddd383c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:09:35 -0700 Subject: [PATCH 045/182] fix(native-chat): an older Claude's error on a later turn keeps its error text after a Stop The translator left an error result that names no reason to the journal's Stop rule whenever a person's Stop named the turn or none, but the rule binds a Stop naming no turn only to the turn opened next. So a real error on a later turn read "Failed" with its error text dropped. The translator now asks the journal's rule itself (`personStopDecidesTurn`, the one core `turnEndAfterStop` and a host stop's in-force check share), so the two cannot disagree. --- src/main/claude/claude-result-journaling.ts | 8 +- src/main/claude/claude-result-outcome.ts | 6 +- .../claude-structured-in-turn-stop.test.ts | 12 ++- src/main/claude/claude-turn-outcome.test.ts | 19 ++++- .../journal-stop-marks.ts | 8 +- .../journal-stop-turn-end.ts | 85 +++++++++++-------- ...agent-session-claude-stop-turn-end.test.ts | 42 +++++++++ ...ructured-agent-session-event-sink-queue.ts | 5 +- .../structured-agent-session-event-sink.ts | 8 +- .../structured-agent-session-host-lifetime.ts | 2 +- 10 files changed, 133 insertions(+), 62 deletions(-) diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 4ea153b82754..1473886b9eac 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -15,7 +15,6 @@ import { } from './claude-structured-provider-fallback' import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' import { claudeFrameParentRef, isRootClaudeFrame } from './claude-turn-opening' -import { personStopMayNameTurn } from '../native-chat/agent-session-journal/journal-stop-turn-end' export type ClaudeResultJournalContext = Pick< ClaudeMessageJournalContext, @@ -57,11 +56,10 @@ export function journalClaudeResult( } // Read before the settle below closes it: the result reports that turn's end. const endedTurnScope = turn.turnScope - // Read before the settle below closes the turn: an error end a person's Stop may have caused is - // the journal's to decide as it writes the end (`turnEndAfterStop`). + // Read before the settle below closes the turn: an error end the journal's Stop rule makes a + // person's cancellation is theirs to decide as the end is written (`turnEndAfterStop`). const turnId = settlesTurn ? turn.id : null - const leftToStop = - turnId !== null && personStopMayNameTurn(sink.journalLatestStop?.() ?? null, turnId) + const leftToStop = turnId !== null && sink.journalStopDecidesTurn?.(turnId, observedAt) === true if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) diff --git a/src/main/claude/claude-result-outcome.ts b/src/main/claude/claude-result-outcome.ts index e7cc73737686..de3cadad3387 100644 --- a/src/main/claude/claude-result-outcome.ts +++ b/src/main/claude/claude-result-outcome.ts @@ -13,9 +13,9 @@ import { claudeText } from './claude-structured-item-translation' const CLAUDE_ABORTED_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools']) /** A success-subtype result still carries `is_error` for an API error, so the flag - * is what decides, never the subtype. `leftToStop`: a person's Stop may name this turn - * (`personStopMayNameTurn`), so an error end with no abort reason gives no verdict (undefined): - * older CLIs name no reason, and the journal's Stop rule decides as the end is written. */ + * is what decides, never the subtype. `leftToStop`: the journal's Stop rule makes this turn's end + * a person's cancellation (`personStopDecidesTurn`), so an error end with no abort reason gives no + * verdict (undefined): older CLIs name no reason, and that rule writes it with the end. */ export function claudeResultOutcome( message: Record, leftToStop = false diff --git a/src/main/claude/claude-structured-in-turn-stop.test.ts b/src/main/claude/claude-structured-in-turn-stop.test.ts index abadd8c3043c..93e8e9ee6d6a 100644 --- a/src/main/claude/claude-structured-in-turn-stop.test.ts +++ b/src/main/claude/claude-structured-in-turn-stop.test.ts @@ -8,7 +8,8 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import type { JournalLatestStop } from '../native-chat/agent-session-journal/journal-stop-turn-end' +import { personStopDecidesTurn } from '../native-chat/agent-session-journal/journal-stop-turn-end' +import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' import { ClaudeControlRequestError } from './claude-stream-json-connection' import { PROVIDER_SESSION_ID, @@ -36,7 +37,7 @@ async function runningChat(claude: ReturnType): Promise<{ stopEvent: (turnId: string) => void }> { const bodies = new Map() - let latestStop: JournalLatestStop | null = null + const journal = createJournalReducerState('session-1', 'epoch-1') const adapter = adapterFor(claude) await adapter.acquire({ identity: identityFor(), @@ -46,7 +47,7 @@ async function runningChat(claude: ReturnType): Promise<{ appendItem: (identity, body) => bodies.set(agentJournalItemKey(identity), body), appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)), publish: vi.fn(), - journalLatestStop: () => latestStop + journalStopDecidesTurn: (turnId, endedAt) => personStopDecidesTurn(journal, turnId, endedAt) } }) await adapter.dispatch({ @@ -65,7 +66,10 @@ async function runningChat(claude: ReturnType): Promise<{ throw new Error('expected a running turn') } const stopEvent = (stoppedTurnId: string) => { - latestStop = { sequence: 9, event: { reason: 'user-stop', turnId: stoppedTurnId, at: 1 } } + journal.queuePauseMarks.latestStop = { + sequence: 9, + event: { reason: 'user-stop', turnId: stoppedTurnId, at: 1 } + } } return { adapter, bodies, connection, turnId, stopEvent } } diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts index ff18ac3a5571..d5bc5366606c 100644 --- a/src/main/claude/claude-turn-outcome.test.ts +++ b/src/main/claude/claude-turn-outcome.test.ts @@ -13,7 +13,11 @@ import { describeNativeChatTurnStatus } from '../../shared/native-chat-turn-stat import { selectStructuredAgentSettledTurns } from '../../shared/structured-agent-session-turn-timing' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { JournalStopEvent } from '../native-chat/agent-session-journal/journal-row-schema' -import type { JournalLatestStop } from '../native-chat/agent-session-journal/journal-stop-turn-end' +import { + personStopDecidesTurn, + type JournalLatestStop +} from '../native-chat/agent-session-journal/journal-stop-turn-end' +import { createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' import { claudeResultOutcome } from './claude-result-outcome' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { claudeResultFailure } from './claude-structured-provider-fallback' @@ -239,10 +243,19 @@ describe("a person's Stop inside a live turn", () => { // Claude CLIs before 2.1.91 send no terminal_reason, and later ones may omit it. const cutShort = { type: 'result', subtype: 'error_during_execution', is_error: true } - /** A sink whose journal's latest Stop event is `stop`, as the host's would answer. */ + /** A sink whose journal's latest Stop event is `stop`, answering by the journal's own rule. */ function sinkWithStop(stop: JournalLatestStop | null) { const state = sinkState() - return { ...state, sink: { ...state.sink, journalLatestStop: () => stop } } + const journal = createJournalReducerState('orca-session', 'epoch-1') + journal.queuePauseMarks.latestStop = stop + return { + ...state, + sink: { + ...state.sink, + journalStopDecidesTurn: (turnId: string, endedAt: number) => + personStopDecidesTurn(journal, turnId, endedAt) + } + } } function stopOf( diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts index 27cfa28e536d..0094c30e07c8 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -2,7 +2,7 @@ // the turn-end rule reads (`journal-stop-turn-end.ts`), and whether a person's still decides. import type { JournalReducerState } from './journal-reducer' -import { personStopInForce, type JournalLatestStop } from './journal-stop-turn-end' +import { personStopDecidesTurn, type JournalLatestStop } from './journal-stop-turn-end' export class JournalStopMarks { constructor(private readonly deps: { state: () => JournalReducerState }) {} @@ -11,8 +11,8 @@ export class JournalStopMarks { return this.deps.state().queuePauseMarks.latestStop } - /** `personStopInForce`: a person's Stop already decides what runs now. */ - personStopInForce(turnId: string | null): boolean { - return personStopInForce(this.deps.state(), turnId) + /** `personStopDecidesTurn`: a person's Stop decides how turn `turnId` ends. */ + personStopDecides(turnId: string | null, endedAt?: number): boolean { + return personStopDecidesTurn(this.deps.state(), turnId, endedAt) } } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 9fed007679f6..b0975d0d6073 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -31,28 +31,20 @@ type TurnEndState = Pick< 'items' | 'queuePauseMarks' | 'latestPersonTurnSequence' > -/** Whether the latest Stop is a person's that names turn `turnId` or names none: an end that gives - * no verdict of its own is then theirs to decide, here, where its row is built. */ -export function personStopMayNameTurn(stop: JournalLatestStop | null, turnId: string): boolean { - return ( - stop !== null && - stopIsAPersons(stop.event.reason) && - (stop.event.turnId === undefined || stop.event.turnId === turnId) - ) -} - -/** Whether `stop` makes the end of turn `turnId` a person's cancellation: a person's Stop or close - * that named that turn, or named none and stopped the turn item `itemId` opened. */ +/** Whether `stop`, a person's, makes the end of turn `turnId` theirs: it named that turn, or named + * none and stopped the turn item `itemId` opened. */ function stopIsTurnCancellation( stop: JournalLatestStop, turnId: string, state: TurnEndState, itemId: string | null ): boolean { - return ( - personStopMayNameTurn(stop, turnId) && - (stop.event.turnId !== undefined || turnlessStopStopped(state, stop, itemId)) - ) + if (!stopIsAPersons(stop.event.reason)) { + return false + } + return stop.event.turnId !== undefined + ? stop.event.turnId === turnId + : turnlessStopStopped(state, stop, itemId) } /** Pressed before any turn showed, a Stop stopped the first turn opened after it, and no later @@ -83,20 +75,47 @@ function turnlessStopStopped( return true } -/** Whether a person's Stop already decides the end of what runs now: the live turn `turnId`, or - * with none, the turn a send opens next. A host stop of that work must not supersede it. */ -export function personStopInForce(state: TurnEndState, turnId: string | null): boolean { +/** THE rule: whether the latest Stop makes turn `turnId` (item `itemId`, null if not yet opened), + * ending at `endedAt` with no verdict of its own, a person's cancellation. An exit the provider saw + * before the Stop was news, whenever its end is written. */ +function stopEndsTurnAsCancellation( + state: TurnEndState, + turnId: string, + itemId: string | null, + endedAt: number | undefined +): boolean { const stop = state.queuePauseMarks.latestStop - if (!stop || !stopIsAPersons(stop.event.reason)) { - return false - } - if (turnId === null) { - return stop.event.turnId === undefined && turnlessStopStopped(state, stop, null) + return ( + stop !== null && + stopIsTurnCancellation(stop, turnId, state, itemId) && + (endedAt === undefined || endedAt >= stop.event.at) + ) +} + +/** + * Whether a person's Stop decides the end of turn `turnId` (null: the turn a send opens next), + * by `turnEndAfterStop`'s rule: ending at `endedAt` it is their cancellation, and still running it + * is theirs to end. For a writer that must choose before the end is written: a host stop must not + * supersede it, and a Claude error result naming no reason leaves its verdict to it. + */ +export function personStopDecidesTurn( + state: TurnEndState, + turnId: string | null, + endedAt?: number +): boolean { + if (turnId !== null) { + const itemId = [...state.items].find( + ([, item]) => readAgentJournalTurn(item.body)?.turnId === turnId + )?.[0] + return stopEndsTurnAsCancellation(state, turnId, itemId ?? null, endedAt) } - const itemId = [...state.items].find( - ([, item]) => readAgentJournalTurn(item.body)?.turnId === turnId - )?.[0] - return stopIsTurnCancellation(stop, turnId, state, itemId ?? null) + const stop = state.queuePauseMarks.latestStop + return ( + stop !== null && + stop.event.turnId === undefined && + stopIsAPersons(stop.event.reason) && + turnlessStopStopped(state, stop, null) + ) } /** @@ -117,11 +136,7 @@ export function turnEndAfterStop( if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { return body } - const stop = state.queuePauseMarks.latestStop - if (!stop || !stopIsTurnCancellation(stop, body.turnId, state, itemId)) { - return body - } - // An exit the provider saw before the Stop was news, whenever its end is written. - const endedAfterStop = body.completedAt === undefined || body.completedAt >= stop.event.at - return endedAfterStop ? { ...body, outcome: 'cancellation' } : body + return stopEndsTurnAsCancellation(state, body.turnId, itemId, body.completedAt) + ? { ...body, outcome: 'cancellation' } + : body } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts index a9cfcaea9fc5..1c46f0ffc70c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -198,3 +198,45 @@ it('reads a follow-up the child end cut as interrupted when the Stop named the t expect(cut?.turnId).not.toBe(ended) expect(cut).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) }, 15_000) + +// The Stop bound only the turn it stopped: a later turn's error end is the provider's own. +it("keeps an older CLI's error end on a later turn a failure, with its error text, after a turnless Stop", async () => { + const stopped = claude.connections[0]! + claude.routes.interrupt = () => { + setTimeout(() => { + echoLatest(stopped) + frame(stopped, { type: 'result', subtype: 'error_during_execution', is_error: true }) + frame(stopped, { type: 'system', subtype: 'session_state_changed', state: 'idle' }) + }, 5) + return { still_queued: [], cancelled: [] } + } + await sendUnechoed(stopped, 'Write a long reply.') + await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await laneDrained() + expect(await lastTurn()).toMatchObject({ outcome: 'cancellation' }) + + // The next send starts a new child on the same conversation. + const body = hostTestMessage('Carry on.') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + const resumed = await eventually(() => { + const started = claude.connections.at(-1)! + expect(started).not.toBe(stopped) + expect(started.sent.some((message) => JSON.stringify(message).includes('Carry on.'))).toBe(true) + return started + }) + echoLatest(resumed) + frame(resumed, { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + result: 'API Error: overloaded' + }) + + await eventually(async () => + expect(await lastTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + ) + const { items } = await host.journalSnapshot(SESSION) + expect(JSON.stringify(items.map((item) => item.body))).toContain('API Error: overloaded') +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index f406cf9ecc89..4f2491f7b617 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -8,7 +8,6 @@ import type { StructuredAgentSessionSinkState, StructuredAgentSessionSinkWatermarks } from './structured-agent-session-event-sink' -import type { JournalLatestStop } from '../agent-session-journal/journal-stop-turn-end' export type StructuredAgentSessionSinkOperation = { sequence: number @@ -68,8 +67,8 @@ export class StructuredAgentSessionSinkQueue { journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null - journalLatestStop = (): JournalLatestStop | null => - this.target?.journal.stopMarks.latest() ?? null + journalStopDecidesTurn = (turnId: string, endedAt: number): boolean => + this.target?.journal.stopMarks.personStopDecides(turnId, endedAt) ?? false bindReadingControl(control: StructuredAgentSessionReadingControl): () => void { this.readingControl = control diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 400c86c885a0..cd15160df88d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -8,7 +8,6 @@ import type { import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import type { JournalLatestStop } from '../agent-session-journal/journal-stop-turn-end' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' import { StructuredAgentSessionSinkQueue } from './structured-agent-session-event-sink-queue' import { structuredAgentSessionJournalAppendOptions } from './structured-agent-session-journal-append-options' @@ -136,8 +135,9 @@ export type StructuredAgentSessionEventSink = { journalEpoch?(): string | null /** The bound journal's producer linkage; null until bound. */ journalLinkage?(): StructuredAgentSessionLinkageJournal | null - /** The bound journal's latest Stop event (`JournalStopMarks.latest`); null until bound. */ - journalLatestStop?(): JournalLatestStop | null + /** Whether the bound journal's Stop rule makes turn `turnId`, ending at `endedAt` with no verdict + * of its own, a person's cancellation (`personStopDecidesTurn`); false until bound. */ + journalStopDecidesTurn?(turnId: string, endedAt: number): boolean appendLifecycleBatch?( settlementId: string, mutations: readonly JournalLifecycleMutationInput[], @@ -280,7 +280,7 @@ export function createDeferredStructuredAgentSessionEventSink( ...resolvedAppend, journalEpoch: queue.journalEpoch, journalLinkage: queue.journalLinkage, - journalLatestStop: queue.journalLatestStop, + journalStopDecidesTurn: queue.journalStopDecidesTurn, appendLifecycleBatch: (settlementId, mutations, options = {}) => { const admission = appendLifecycleBatch(settlementId, mutations, options) if (!admission.accepted) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index d3e5c728feb4..8145be82eebd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -131,7 +131,7 @@ async function stopEndsWork( // A host stop of work a person's Stop is already ending must not supersede that Stop's reason. return ( working && - (ending.cause === 'user-close' || !journal.stopMarks.personStopInForce(journal.activeTurnId())) + (ending.cause === 'user-close' || !journal.stopMarks.personStopDecides(journal.activeTurnId())) ) } From 5ead1f6bccd9875104a223282050476cff9f5a97 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:47:03 -0700 Subject: [PATCH 046/182] fix(native-chat): a Stop of a start that never landed binds no later turn, whatever sent it A person's Stop pressed while the agent starts names no turn, and the send it stopped is cancelled before it opens one. The Stop then bound the next turn anything opened (orchestration mail, a restart continuation, the queue's drain, all of which send as the host), so a host eviction of that turn wrote nothing and its crash or close read as the person's cancellation. A Stop that named no turn now binds only a turn no send journaled after it opened: any send since, of any origin and not refused, opens its own. The E2 test's mail send is accepted as Codex accepts it, instead of opening the stopped send's own turn first. --- .../journal-stop-turn-end.ts | 45 +++--- ...d-agent-session-stop-event-binding.test.ts | 128 ++++++++++++++++++ ...d-agent-session-stop-event-entries.test.ts | 28 +--- 3 files changed, 154 insertions(+), 47 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index b0975d0d6073..821ceb781382 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -26,10 +26,7 @@ function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { } } -type TurnEndState = Pick< - JournalReducerState, - 'items' | 'queuePauseMarks' | 'latestPersonTurnSequence' -> +type TurnEndState = Pick /** Whether `stop`, a person's, makes the end of turn `turnId` theirs: it named that turn, or named * none and stopped the turn item `itemId` opened. */ @@ -48,20 +45,26 @@ function stopIsTurnCancellation( } /** Pressed before any turn showed, a Stop stopped the first turn opened after it, and no later - * one: unless a send a person made since was accepted, whose turn that is. `itemId` null: a turn - * not yet opened. */ + * one: unless a send journaled since, of any origin, was not refused, whose turn that is. A Stop + * whose stopped send never opens a turn so binds nothing. `itemId` null: a turn not yet opened. */ function turnlessStopStopped( state: TurnEndState, stop: JournalLatestStop, itemId: string | null ): boolean { const createdAt = itemId === null ? null : (state.items.get(itemId)?.sequence ?? null) - if ( - (createdAt !== null && createdAt <= stop.sequence) || - state.latestPersonTurnSequence >= stop.sequence - ) { + if (createdAt !== null && createdAt <= stop.sequence) { return false } + for (const submission of state.submissions.values()) { + if ( + submission.dispatchState !== 'rejected' && + submission.acceptedSequence !== undefined && + submission.acceptedSequence > stop.sequence + ) { + return false + } + } for (const [otherId, item] of state.items) { if ( otherId !== itemId && @@ -103,19 +106,17 @@ export function personStopDecidesTurn( turnId: string | null, endedAt?: number ): boolean { - if (turnId !== null) { - const itemId = [...state.items].find( - ([, item]) => readAgentJournalTurn(item.body)?.turnId === turnId - )?.[0] - return stopEndsTurnAsCancellation(state, turnId, itemId ?? null, endedAt) - } const stop = state.queuePauseMarks.latestStop - return ( - stop !== null && - stop.event.turnId === undefined && - stopIsAPersons(stop.event.reason) && - turnlessStopStopped(state, stop, null) - ) + if (stop === null || !stopIsAPersons(stop.event.reason)) { + return false + } + if (turnId === null) { + return stop.event.turnId === undefined && turnlessStopStopped(state, stop, null) + } + const itemId = [...state.items].find( + ([, item]) => readAgentJournalTurn(item.body)?.turnId === turnId + )?.[0] + return stopEndsTurnAsCancellation(state, turnId, itemId ?? null, endedAt) } /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts new file mode 100644 index 000000000000..c7cef759d542 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -0,0 +1,128 @@ +// Which turn a person's Stop that named no turn binds: only the one its stopped send opens. A Stop of +// a start that never landed stopped a send that opens no turn, and a send journaled after the Stop +// opens its own; neither is the Stop's, whatever sent it. + +import { afterEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +const MAIL_TURN: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-mail', + ordinal: 999 +} + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +function stopEvents(): JournalStopEvent[] { + const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + return since.rows.flatMap((row) => + row.kind === 'tombstone' && row.stopEvent ? [row.stopEvent] : [] + ) +} + +function childPhase() { + return rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase +} + +function fence(): number { + return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 +} + +function mailTurn() { + return journal() + .snapshot() + .items.map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-mail') +} + +/** A person's Stop of a start that never landed, whose send opens no turn; then orchestration mail + * starts a new child, which lands and runs the mail's turn. */ +async function mailTurnAfterStopOfStart(): Promise { + rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) + let release: () => void = () => undefined + rig.awaitStarted.mockImplementation( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + rig.send('work on this') + await eventually(() => expect(childPhase()).toBe('starting')) + expect(await rig.stop()).toMatchObject({ ok: true }) + release() + expect(stopEvents()).toEqual([expect.objectContaining({ reason: 'user-stop' })]) + expect(stopEvents()[0]).not.toHaveProperty('turnId') + await eventually(() => expect(childPhase()).toBeUndefined()) + rig.awaitStarted.mockImplementation(async () => undefined) + await rig.send('mail for the worker', undefined, { internal: true }).result + await eventually(() => expect(rig.dispatch).toHaveBeenCalled()) + await journal().appendItem( + MAIL_TURN, + { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: Date.now() }, + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +describe('a Stop of a start that never landed binds no later turn', () => { + it("writes the host's event when it evicts the mail turn, which reads as news", async () => { + await mailTurnAfterStopOfStart() + let atClose: JournalStopEvent[] = [] + rig.closeSession.mockImplementationOnce(async () => { + atClose = stopEvents() + return true + }) + + await rig.host.close(HOST_TEST_SESSION, 'evict') + + expect(atClose.map((event) => event.reason)).toEqual(['user-stop', 'evict']) + await rig.host.journalSnapshot(HOST_TEST_SESSION) + expect(mailTurn()).toMatchObject({ state: 'interrupted' }) + expect(mailTurn()).not.toHaveProperty('outcome') + }) + + it('settles a crash of the mail turn on relaunch as news', async () => { + await mailTurnAfterStopOfStart() + const owner = fence() + rig.crashRestartHostProcess() + await rig.host.journalSnapshot(HOST_TEST_SESSION) + + await settleStaleStructuredAgentSessionState({ + journal: journal(), + sessionId: HOST_TEST_SESSION, + fence: owner + 1, + acquisitionGeneration: 'generation-2', + deathEvidence: { + kind: 'exit-observed', + detail: 'the relaunch proved the old child gone', + observedAt: Date.now() + 60_000, + ownerFence: owner + } + }) + + expect(mailTurn()).toMatchObject({ state: 'interrupted' }) + expect(mailTurn()).not.toHaveProperty('outcome') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 26f191a7f90c..bccc020f7cbe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -60,28 +60,6 @@ async function runningTurn(turnId = 'turn-1'): Promise { return working } -/** The stopped send's turn opens after its turnless Stop and ends cut: the turn that Stop bound. */ -async function stoppedTurnEnded(): Promise { - const fence = rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 - const scope = { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - const identity = { - provider: 'codex' as const, - threadId: 'thread-1', - turnId: 'turn-stopped', - ordinal: 998 - } - await journal().appendItem( - identity, - { kind: 'turn', turnId: 'turn-stopped', state: 'running', startedAt: 1 }, - scope - ) - await journal().appendItem( - identity, - { kind: 'turn', turnId: 'turn-stopped', state: 'interrupted', completedAt: Date.now() }, - scope - ) -} - async function queuedDraft(text: string): Promise { const queued = await rig.send(text, 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { @@ -257,10 +235,11 @@ describe("a person's Stop pause and the Stop events after it", () => { await queuedDraft('queued behind the turn') expect(await rig.stop()).toMatchObject({ ok: true }) await rig.settleAccepted(working, 'stopped') - await stoppedTurnEnded() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) // Orchestration mail starts a turn the host sent, which lifts nothing. - await rig.send('mail for the lead', undefined, { internal: true }).result + const mail = rig.send('mail for the lead', undefined, { internal: true }) + await mail.result + await rig.settleAccepted(mail.id, 'mail') await journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-mail', ordinal: 999 }, { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: 1 }, @@ -285,7 +264,6 @@ describe("a person's Stop pause and the Stop events after it", () => { const held = await queuedDraft('queued behind the turn') expect(await rig.stop()).toMatchObject({ ok: true }) await rig.settleAccepted(working, 'stopped') - await stoppedTurnEnded() // The agent at rest goes, writing nothing; mail then starts a new child, which never lands. await idleSweep().tick() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) From aa7f3a7b9d3e7874f50e93b53c60cd6e699310f9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:47:16 -0700 Subject: [PATCH 047/182] test(native-chat): a rewind's restated turnless Stop binds no turn opened after the rewind A Codex rewind restates a person's Stop still in force after the turns it keeps, at a new sequence, so by sequence alone it would bind the next turn opened after the rewind. A send journaled after the restated row voids that binding (the previous commit), which this pins. --- ...d-agent-session-stop-event-binding.test.ts | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index c7cef759d542..2962041b8be9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -1,6 +1,6 @@ // Which turn a person's Stop that named no turn binds: only the one its stopped send opens. A Stop of // a start that never landed stopped a send that opens no turn, and a send journaled after the Stop -// opens its own; neither is the Stop's, whatever sent it. +// opens its own; neither is the Stop's, whatever sent it, through a rewind too. import { afterEach, describe, expect, it } from 'vitest' import { @@ -126,3 +126,45 @@ describe('a Stop of a start that never landed binds no later turn', () => { expect(mailTurn()).not.toHaveProperty('outcome') }) }) + +describe('a rewind that restates a turnless Stop', () => { + // The rewind writes the Stop still in force after the turns it keeps, so it follows the turn it + // already bound; the mail after the rewind is still its own. + it('binds no turn opened after the rewind', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + await rig.settleAccepted(stopped, 'stopped') + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const stoppedTurn = { ...MAIL_TURN, turnId: 'turn-stopped', ordinal: 998 } + const ended = { kind: 'turn' as const, turnId: 'turn-stopped', state: 'interrupted' as const } + await journal().appendItem( + stoppedTurn, + { kind: 'turn', turnId: 'turn-stopped', state: 'running', startedAt: Date.now() }, + scope + ) + await journal().appendItem(stoppedTurn, { ...ended, completedAt: Date.now() + 1 }, scope) + + await journal().replaceEpochItems('handle_forked', 1, [ + { + identity: stoppedTurn, + body: { ...ended, completedAt: Date.now() + 1, outcome: 'cancellation' } + } + ]) + await rig.send('mail after the rewind', undefined, { internal: true }).result + await journal().appendItem( + MAIL_TURN, + { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: Date.now() }, + scope + ) + await journal().appendItem( + MAIL_TURN, + { kind: 'turn', turnId: 'turn-mail', state: 'interrupted', completedAt: Date.now() + 5 }, + scope + ) + + expect(mailTurn()).toMatchObject({ state: 'interrupted' }) + expect(mailTurn()).not.toHaveProperty('outcome') + }) +}) From a5960f76c27be348d7088d4314c6903e3b2f317b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:49:07 -0700 Subject: [PATCH 048/182] fix(native-chat): a relaunch settles a person's stopped turn with no "stopped while in progress" row After a restart, a turn a person's Stop ended reads "Interrupted after N" with the muted mark, but the relaunch still added the error row saying the provider stopped mid-response, which a live Stop never writes. The settle now skips that row when every turn it interrupts is the person's Stop's by the journal's one rule; a crash nobody stopped keeps it. --- ...gent-session-dead-generation-settlement.ts | 9 +++++--- ...ctured-agent-session-stale-turn-verdict.ts | 16 ++++++++++++++ ...d-agent-session-stop-event-restart.test.ts | 21 ++++++++++++++----- 3 files changed, 38 insertions(+), 8 deletions(-) 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 6bf0c7346e2f..3e2831c1109e 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 @@ -26,6 +26,7 @@ import { } from './structured-agent-session-start-failure-row' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { + endedByPersonsStop, provenUnverifiableTurnRevisions, runningTurnLifecycleRevisions, stopFoundTurnLiveAt, @@ -239,15 +240,17 @@ export async function settleStaleStructuredAgentSessionState(input: { } } const proven = provenUnverifiableTurnRevisions(items, input.deathEvidence, journal) - mutations.push( + const turnEnds = [ ...items.flatMap((item) => runningTurnLifecycleRevisions([item], verdictFor(item))), ...proven - ) + ] + mutations.push(...turnEnds) const evidence = input.deathEvidence if ( evidence && (proven.length > 0 || - items.some((item) => isInProgressItem(item) && verdictFor(item).state === 'interrupted')) + items.some((item) => isInProgressItem(item) && verdictFor(item).state === 'interrupted')) && + !endedByPersonsStop(journal, turnEnds) ) { mutations.unshift({ kind: 'item', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index 9002cff14a72..d7cd404e468c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -67,6 +67,22 @@ export function stopFoundTurnLiveAt( return stop && turnId !== undefined && stop.event.turnId === turnId ? stop.event.at : undefined } +/** Every turn this settle interrupts is a person's Stop's to end (`turnEndAfterStop`), so it reads + * as theirs, muted, with no row saying the provider stopped: as a live Stop writes none. */ +export function endedByPersonsStop( + journal: Pick, + turnEnds: readonly JournalLifecycleMutationInput[] +): boolean { + const interrupted = turnEnds.flatMap((mutation) => { + const turn = mutation.kind === 'item' ? readAgentJournalTurn(mutation.body) : undefined + return turn?.state === 'interrupted' ? [turn] : [] + }) + return ( + interrupted.length > 0 && + interrupted.every((turn) => journal.stopMarks.personStopDecides(turn.turnId, turn.completedAt)) + ) +} + /** Revises every still-running lifecycle item in place, keeping its identity and start. */ export function runningTurnLifecycleRevisions( items: readonly AgentJournalRenderItem[], diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 1d1554d9174b..85ec7a619995 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -95,7 +95,8 @@ async function restartAndSettle( }) } -/** What the chat's turn bar and the session's mark read, for `turnId` or else the first turn. */ +/** What the chat's turn bar and the session's mark read, for `turnId` or else the first turn, and + * the error rows beside it. */ function settled(turnId?: string) { const { items } = journal().snapshot() const turn = items @@ -110,7 +111,10 @@ function settled(turnId?: string) { label: timing ? formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...timing }) : null, mark: verdict ? agentVerdictDisplayMark({ state: 'done', mainAgent: { state: 'done', outcome: verdict } }) - : null + : null, + errorRows: items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) } } @@ -125,10 +129,12 @@ describe('a restart between a Stop and its turn end', () => { await restartAndSettle() - const { turn, label, mark } = settled() + const { turn, label, mark, errorRows } = settled() expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) expect(label).toMatch(/^Interrupted after /) expect(mark).toBe('interrupted') + // A live Stop writes no row saying the provider stopped; nor does its relaunch. + expect(errorRows).toEqual([]) }) it('reads Interrupted after N, marked interrupted: a close of the chat that died midway', async () => { @@ -143,10 +149,12 @@ describe('a restart between a Stop and its turn end', () => { await restartAndSettle() - const { turn, label, mark } = settled() + const { turn, label, mark, errorRows } = settled() expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) expect(label).toMatch(/^Interrupted after /) expect(mark).toBe('interrupted') + // A live Stop writes no row saying the provider stopped; nor does its relaunch. + expect(errorRows).toEqual([]) }) it('reads Failed after N, marked failed, when nobody stopped it', async () => { @@ -154,11 +162,14 @@ describe('a restart between a Stop and its turn end', () => { await restartAndSettle() - const { turn, label, mark } = settled() + const { turn, label, mark, errorRows } = settled() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') expect(label).toMatch(/^Failed after /) expect(mark).toBe('failed') + expect(errorRows).toEqual([ + expect.stringContaining('stopped while this response was in progress') + ]) }) it("reads Couldn't confirm when the relaunch cannot prove the child gone, Stop or not", async () => { From 78369ee9ca61ef56faafd17f111d849ef350affd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:50:57 -0700 Subject: [PATCH 049/182] test(native-chat): the unexpected-exit settle's journal fake answers whether a person's Stop decides a turn --- .../structured-agent-session-unexpected-exit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index f8a17510f430..db5915e585cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -98,7 +98,7 @@ describe('provider-exit settlement', () => { items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), itemFence: () => 7, - stopMarks: { latest: () => null }, + stopMarks: { latest: () => null, personStopDecides: () => false }, appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) } From e9c8af84024a1f157cf11dd55a4f650c996056b7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:57:56 -0700 Subject: [PATCH 050/182] fix(native-chat): a host stop whose sink drain fails reads the journal as it stands A host stop drains the session's sink before judging whether it ends work, and a failed or slow drain read as working. So an eviction of an agent at rest wrote a Stop event that ended nothing, which lifts a person's Stop pause, and a close wrote a person's event naming no turn. The drain is now best effort: the stop goes ahead either way and only its record is at stake, so a failed or slow drain leaves the journal's read as it stands. A person's Stop keeps its own rule. --- .../structured-agent-session-host-lifetime.ts | 27 +++++++++++-------- ...d-agent-session-stop-event-entries.test.ts | 26 +++++++++++++++++- .../structured-agent-session-turns-cancel.ts | 15 ++++------- 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 8145be82eebd..054e51d4974a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -31,7 +31,8 @@ import { import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' -import { isMainAgentWorkingOnceFlushed } from './structured-agent-session-turns-cancel' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { withTimeout } from '../../../shared/promise-timeout-fallback' export type StructuredAgentSessionLifetimeContext = { deps: StructuredAgentSessionHostDeps @@ -106,9 +107,9 @@ const STOP_EVENT_DRAIN_TIMEOUT_MS = 1_000 /** * Whether this stop ends work its event must record: a running turn or an unanswered send, a start's - * own included, read once the sink drained what the provider already said - * (`isMainAgentWorkingOnceFlushed`). A start that carries no send ends nothing. A person's Stop - * wrote its own event, and quit and the idle sweep's rest write none. + * own included, read once the sink drained what the provider already said. A start that carries + * no send ends nothing. A person's Stop wrote its own event, and quit and the idle sweep's rest + * write none. */ async function stopEndsWork( context: StructuredAgentSessionLifetimeContext, @@ -120,13 +121,17 @@ async function stopEndsWork( if ('recorded' in ending || ending.quit || ending.resting || !child) { return false } - const working = await isMainAgentWorkingOnceFlushed( - { - journal, - fence: child.fence, - flushStreamedEvents: () => context.runtimeState.flushEventSink(sessionId) - }, - STOP_EVENT_DRAIN_TIMEOUT_MS + // Best effort: the stop goes ahead either way, so a failed or slow drain leaves the journal's + // read as it stands rather than calling the agent working. + await withTimeout( + context.runtimeState.flushEventSink(sessionId).catch(() => undefined), + STOP_EVENT_DRAIN_TIMEOUT_MS, + undefined + ) + const working = isStructuredAgentSessionMainAgentWorking( + journal.activeTurnId(), + journal.submissions(), + child.fence ) // A host stop of work a person's Stop is already ending must not supersede that Stop's reason. return ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index bccc020f7cbe..1e2e707ac1cb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -3,7 +3,7 @@ // nothing, quit writes nothing (its resume marker records why), and any later Stop event ends a // person's Stop pause. -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' @@ -159,6 +159,30 @@ describe('every Stop entry writes its event, with its reason, before it ends the }) }) + // The drain is best effort: when it fails, the journal as it stands says the agent rests. + it('writes nothing when the drain fails as it evicts a chat at rest', async () => { + rig = await createQueuedMessageTestRig() + const working = await runningTurn() + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'stopped') + await journal().appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 }, + { kind: 'turn', turnId: 'turn-1', state: 'interrupted', completedAt: Date.now() }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + expect(journal().activeTurnId()).toBeNull() + const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) + const drained = sink.drained.bind(sink) + vi.spyOn(sink, 'drained') + .mockResolvedValueOnce({ ok: false, error: new Error('drain lost once') }) + .mockImplementation(drained) + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, 'evict') + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) + }) + it("writes nothing at quit, whose resume marker's trigger records why", async () => { rig = await createQueuedMessageTestRig() await runningTurn() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index b90b4ad8645e..cdb70ff41d45 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -13,21 +13,16 @@ import { } from './structured-agent-session-command-turn' import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' -import { withTimeout } from '../../../shared/promise-timeout-fallback' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' /** Claude's echo accepts a send one sink write before its turn row lands, so read after the drain. - * A failed drain, or one still running after `timeoutMs`, reads working: bookkeeping never talks a - * Stop out of stopping. */ + * A failed drain reads working: bookkeeping never talks a Stop out of stopping. */ export async function isMainAgentWorkingOnceFlushed( - ctx: Pick, - timeoutMs?: number + ctx: Pick ): Promise { - const flushed = ctx.flushStreamedEvents().then( - () => true, - () => false - ) - if (!(await (timeoutMs === undefined ? flushed : withTimeout(flushed, timeoutMs, false)))) { + try { + await ctx.flushStreamedEvents() + } catch { return true } return isStructuredAgentSessionMainAgentWorking( From 8550aa1a8daa85acb6b86a1367e11dc26fa7817d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:07:41 -0700 Subject: [PATCH 051/182] fix(native-chat): a Stop that named no turn applies only to a turn a send it stopped opened A person's Stop pressed before any turn showed names no turn. It bound the first turn opened after it, then (5ead1f6bccd) any turn opened by no later send, so a turn the host started for a card the Stop held, or for orchestration mail, read as the person's cancellation, and a host eviction of it wrote no Stop event when its send had been abandoned by the close first. The rule is now the concept itself: a Stop naming no turn applies to a turn opened by a send it stopped, one already handed to the agent at the Stop's position. Nothing new is stored. The turn's row names the send that opened it (Codex: the submission's key; Claude: the echo, which the journal aliases to the submission), and a handed-over send's item sits at its handover, so the target set is derived from the journal. A card the Stop held is handed over after it, so it is no target; a Stop of a start whose send never opens a turn binds nothing; a rewind keeps no submissions, so a restated Stop binds no turn opened after it. With no turn running, a host stop defers to the person's Stop only while every unanswered send is one it stopped. Claude's translator, which asks before its echo row lands, passes the send its echo acknowledged. --- src/main/claude/claude-message-journaling.ts | 5 +- src/main/claude/claude-open-turn.ts | 5 + .../claude/claude-replay-turn-resolution.ts | 20 +- src/main/claude/claude-result-journaling.ts | 4 +- ...aude-structured-dispatch-admission.test.ts | 11 +- .../claude-structured-journal-translation.ts | 9 +- .../claude-structured-session-acquisition.ts | 1 + .../claude/claude-structured-session-state.ts | 2 + src/main/claude/claude-turn-lifecycle-item.ts | 2 + src/main/claude/claude-turn-opening.ts | 5 +- src/main/claude/claude-turn-outcome.test.ts | 30 +-- .../journal-stop-marks.ts | 4 +- .../journal-stop-turn-end.ts | 150 +++++++------ ...ructured-agent-session-event-sink-queue.ts | 4 +- .../structured-agent-session-event-sink.ts | 5 +- ...d-agent-session-stop-event-binding.test.ts | 205 +++++++++++++----- ...d-agent-session-stop-event-entries.test.ts | 4 +- ...d-agent-session-stop-event-restart.test.ts | 23 +- 18 files changed, 329 insertions(+), 160 deletions(-) diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index 9d75db1fca9f..0ade48d58125 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -63,7 +63,9 @@ export function journalClaudeMessage( startsTurn: boolean, observedAt: number, /** Host clock on the submission that produced this send, when known. */ - requestedAt?: number + requestedAt?: number, + /** The submission this send echo acknowledged. */ + openedBy?: string ): boolean { const envelope = readClaudeMessageEnvelope(message) if (!envelope) { @@ -176,6 +178,7 @@ export function journalClaudeMessage( startsTurn, observedAt, ...(requestedAt === undefined ? {} : { requestedAt }), + ...(openedBy === undefined ? {} : { openedBy }), userItemId: agentJournalItemKey(identity) }) if (sendEchoTurn) { diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index f946e4ee9cfb..f6edf49d56b2 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -60,6 +60,11 @@ export class ClaudeOpenTurn { } /** The open turn's row, where a fact about the running turn lands. */ + /** The submission that opened the open turn, when known (`ClaudeCurrentTurn.openedBy`). */ + get openedBy(): string | null { + return this.current?.openedBy ?? null + } + get identity(): AgentJournalItemIdentity | null { return this.current ? claudeCurrentTurnIdentity(this.current) : null } diff --git a/src/main/claude/claude-replay-turn-resolution.ts b/src/main/claude/claude-replay-turn-resolution.ts index fe2bb1523f95..c8887b4f3f16 100644 --- a/src/main/claude/claude-replay-turn-resolution.ts +++ b/src/main/claude/claude-replay-turn-resolution.ts @@ -27,7 +27,11 @@ import { claudeDispatchContentKey } from './claude-structured-dispatch-content' /** Settles a provider-proven late outcome; replay rows independently reconcile acceptance. */ export type ClaudeLateDispatchSettlement = (input: ClaudeLateDispatchOutcome) => void -export type ClaudeReplayTurnOrigin = { requestedAt: number | null } +export type ClaudeReplayTurnOrigin = { + requestedAt: number | null + /** The submission this replay acknowledged, which opens the turn; null for a provider-control turn. */ + clientMessageId: string | null +} export function resolveClaudeReplayTurn( session: ClaudeSession, @@ -89,7 +93,9 @@ export function resolveClaudeReplayTurn( if (exact) { const foldReceipt = isUserReplay && claudeReplayIsFoldReceipt(session, exact, uuid) settleWaiter(session, exact, uuid, onSettledLate) - return isUserReplay && !foldReceipt ? { requestedAt: exact.requestedAt } : null + return isUserReplay && !foldReceipt + ? { requestedAt: exact.requestedAt, clientMessageId: exact.clientMessageId } + : null } const retired = session.retiredDispatchWaiters.find( (candidate) => candidate.sentUuid === userMessageUuid @@ -111,7 +117,9 @@ export function resolveClaudeReplayTurn( if (exact) { const foldReceipt = isUserReplay && claudeReplayIsFoldReceipt(session, exact, uuid) settleWaiter(session, exact, uuid, onSettledLate) - return isUserReplay && !foldReceipt ? { requestedAt: exact.requestedAt } : null + return isUserReplay && !foldReceipt + ? { requestedAt: exact.requestedAt, clientMessageId: exact.clientMessageId } + : null } const retired = session.retiredDispatchWaiters.find((candidate) => candidate.sentUuid === uuid) if (retired) { @@ -133,7 +141,7 @@ export function resolveClaudeReplayTurn( if (compatible.length === 1) { const [candidate] = compatible settleWaiter(session, candidate!, uuid, onSettledLate) - return { requestedAt: candidate!.requestedAt } + return { requestedAt: candidate!.requestedAt, clientMessageId: candidate!.clientMessageId } } } else if (!session.replayContentFallbackBlocked && session.dispatchWaiters.length === 0) { const lateCompatible = session.retiredDispatchWaiters.filter( @@ -164,7 +172,9 @@ export function resolveClaudeReplayTurn( const waiter = uuid ? session.dispatchWaiters.shift() : undefined if (waiter && uuid) { settleWaiter(session, waiter, uuid, onSettledLate) - return isUserReplay ? { requestedAt: waiter.requestedAt } : null + return isUserReplay + ? { requestedAt: waiter.requestedAt, clientMessageId: waiter.clientMessageId } + : null } return null } diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 1473886b9eac..a4226067b98e 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -59,7 +59,9 @@ export function journalClaudeResult( // Read before the settle below closes the turn: an error end the journal's Stop rule makes a // person's cancellation is theirs to decide as the end is written (`turnEndAfterStop`). const turnId = settlesTurn ? turn.id : null - const leftToStop = turnId !== null && sink.journalStopDecidesTurn?.(turnId, observedAt) === true + const leftToStop = + turnId !== null && + sink.journalStopDecidesTurn?.(turnId, observedAt, turn.openedBy ?? undefined) === true if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) diff --git a/src/main/claude/claude-structured-dispatch-admission.test.ts b/src/main/claude/claude-structured-dispatch-admission.test.ts index c4578ca38d8d..a7501b39d88f 100644 --- a/src/main/claude/claude-structured-dispatch-admission.test.ts +++ b/src/main/claude/claude-structured-dispatch-admission.test.ts @@ -16,7 +16,7 @@ function resolveClaudeReplayWaiter(...args: Parameters { - it('opens queued exact replays with the origin owned by each send', async () => { + it('opens queued exact replays with the origin and the send owned by each send', async () => { const session = sessionFor() await dispatchClaudeTurn(session, { clientMessageId: 'client-a', @@ -25,7 +25,8 @@ describe('Claude structured dispatch admission', () => { }) const aUuid = session.dispatchWaiters[0]!.sentUuid expect(resolveClaudeReplayTurn(session, userReplayFrame(aUuid, 'a'))).toEqual({ - requestedAt: 100 + requestedAt: 100, + clientMessageId: 'client-a' }) await dispatchClaudeTurn(session, { @@ -41,10 +42,12 @@ describe('Claude structured dispatch admission', () => { const [b, c] = session.dispatchWaiters expect(resolveClaudeReplayTurn(session, userReplayFrame(b!.sentUuid, 'b'))).toEqual({ - requestedAt: 200 + requestedAt: 200, + clientMessageId: 'client-b' }) expect(resolveClaudeReplayTurn(session, userReplayFrame(c!.sentUuid, 'c'))).toEqual({ - requestedAt: 300 + requestedAt: 300, + clientMessageId: 'client-c' }) }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index c036b7d67405..f47b71b51180 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -175,8 +175,10 @@ export function createClaudeJournalTranslator( message: Record, startsTurn: boolean, observedAt: number, - requestedAt?: number - ): boolean => journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt) + requestedAt?: number, + openedBy?: string + ): boolean => + journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt, openedBy) return { handle: (event) => { @@ -244,7 +246,8 @@ export function createClaudeJournalTranslator( event.message, event.startsTurn === true, event.observedAt ?? Date.now(), - event.requestedAt + event.requestedAt, + event.clientMessageId ) ) { providerFallback.append( diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 92f84f6c4896..404787620e95 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -145,6 +145,7 @@ export async function acquireClaudeSession({ message, ...(startsTurn ? { startsTurn: true } : {}), ...(requestedAt === null || requestedAt === undefined ? {} : { requestedAt }), + ...(turnOrigin?.clientMessageId ? { clientMessageId: turnOrigin.clientMessageId } : {}), ...observedAt }) ) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index ec7813ef33b8..d61df04ac75c 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -47,6 +47,8 @@ export type ClaudeStructuredSessionEvent = /** Submission instant of the dispatch this replay acknowledged; the origin * of the turn it opens. Absent when the host cannot name a send. */ requestedAt?: number + /** The submission this replay acknowledged, which opens the turn. */ + clientMessageId?: string /** Host clock at receipt; stamped on turn boundaries only. */ observedAt?: number } diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index e816be7f2b29..1d0db5fa21f4 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -19,6 +19,8 @@ export type ClaudeCurrentTurn = { /** Provider key of the user echo, or the lifecycle row itself when provider * output opened a turn with no user row to receive its timing. */ userItemId: string + /** The submission whose send opened the turn, while its echo has yet to land in the journal. */ + openedBy?: string /** Present when the turn is the host's record of a conversation command. */ command?: ClaudeCommandTurn } diff --git a/src/main/claude/claude-turn-opening.ts b/src/main/claude/claude-turn-opening.ts index 1406928b37ed..b5e13a39c71c 100644 --- a/src/main/claude/claude-turn-opening.ts +++ b/src/main/claude/claude-turn-opening.ts @@ -30,6 +30,8 @@ export type ClaudeSendEchoTurnInput = { requestedAt?: number /** Provider key of the user row this turn is anchored to. */ userItemId: string + /** The submission this echo acknowledged. */ + openedBy?: string } /** The turn a replayed send echo opens, or null when this frame is not one. */ @@ -46,7 +48,8 @@ export function claudeTurnOpenedBySendEcho( turnId: envelope.uuid, startedAt: input.observedAt, ...(input.requestedAt === undefined ? {} : { requestedAt: input.requestedAt }), - userItemId: input.userItemId + userItemId: input.userItemId, + ...(input.openedBy === undefined ? {} : { openedBy: input.openedBy }) } : null } diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts index d5bc5366606c..de88da76635f 100644 --- a/src/main/claude/claude-turn-outcome.test.ts +++ b/src/main/claude/claude-turn-outcome.test.ts @@ -280,24 +280,18 @@ describe("a person's Stop inside a live turn", () => { } // Whose end it was is the journal's Stop rule to say as it writes the end (`turnEndAfterStop`). - it.each([ - ['naming the turn', stopOf('user-1')], - ['naming no turn', { sequence: 9, event: { reason: 'user-stop' as const, at: 1 } }] - ])( - "leaves an error result with no terminal reason after a person's Stop %s to that Stop", - (_label, stop) => { - const state = sinkWithStop(stop) - const translator = createClaudeJournalTranslator({ sink: state.sink }) - translator.handle(userTurn('user-1')) - - translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) - - const turn = settledTurn(state.items, 'user-1') - expect(turn).toMatchObject({ state: 'interrupted' }) - expect(turn).not.toHaveProperty('outcome') - expect(providerRows(state.items)).toBe(0) - } - ) + it("leaves an error result with no terminal reason after a person's Stop of the turn to that Stop", () => { + const state = sinkWithStop(stopOf('user-1')) + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + const turn = settledTurn(state.items, 'user-1') + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + expect(providerRows(state.items)).toBe(0) + }) it.each([ ['no terminal reason', cutShort], diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts index 0094c30e07c8..cdcf7048bc6f 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -12,7 +12,7 @@ export class JournalStopMarks { } /** `personStopDecidesTurn`: a person's Stop decides how turn `turnId` ends. */ - personStopDecides(turnId: string | null, endedAt?: number): boolean { - return personStopDecidesTurn(this.deps.state(), turnId, endedAt) + personStopDecides(turnId: string | null, endedAt?: number, openedBy?: string): boolean { + return personStopDecidesTurn(this.deps.state(), turnId, endedAt, openedBy) } } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 821ceb781382..dfa448c1c81c 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -1,13 +1,22 @@ // What a Stop decides about the turn it named: the one rule every turn-end write passes through. // -// A turn a person's Stop or close of this chat named, ending with no verdict of its own after that -// Stop's event, ends as their cancellation. A host stop, an eviction and no Stop at all leave the -// end as written. It runs where each row is built, inside the journal's serialized write, so it -// reads every Stop folded before the end: the adapter's settle, the host's fallback and a -// relaunch's settle all write through it, and every client folds the row it wrote. +// A turn a person's Stop or close of this chat named, or, when it named none, a turn opened by a +// send it stopped, ending with no verdict of its own after that Stop's event, ends as their +// cancellation. A host stop, an eviction and no Stop at all leave the end as written. It runs +// where each row is built, inside the journal's serialized write, so it reads every Stop folded +// before the end: the adapter's settle, the host's fallback and a relaunch's settle all write +// through it, and every client folds the row it wrote. -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { + agentJournalSubmissionKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch' import type { JournalReducerState } from './journal-reducer' import type { JournalStopEvent } from './journal-row-schema' import type { JournalQueuePauseMarks } from './queued-message-pause' @@ -26,103 +35,121 @@ function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { } } -type TurnEndState = Pick +type TurnEndState = Pick< + JournalReducerState, + 'items' | 'queuePauseMarks' | 'submissions' | 'aliases' +> -/** Whether `stop`, a person's, makes the end of turn `turnId` theirs: it named that turn, or named - * none and stopped the turn item `itemId` opened. */ -function stopIsTurnCancellation( - stop: JournalLatestStop, - turnId: string, +/** The send whose journal item `userItemId` (a turn's opener, by its own or its provider key) is. */ +function openingSubmission( state: TurnEndState, - itemId: string | null + userItemId: string | undefined +): AgentJournalSubmission | undefined { + if (userItemId === undefined) { + return undefined + } + const identity = parseAgentJournalItemKey(state.aliases.get(userItemId) ?? userItemId) + return identity?.provider === 'orca' && 'clientMessageId' in identity + ? state.submissions.get(identity.clientMessageId) + : undefined +} + +/** A send a Stop that named no turn stopped: one already handed to the agent at the Stop's + * position, whose turn had not opened. A card the Stop held, or anything sent after it, is not. */ +function isStopTarget( + state: TurnEndState, + stop: JournalLatestStop, + submission: AgentJournalSubmission ): boolean { - if (!stopIsAPersons(stop.event.reason)) { + if ( + submission.dispatchState === 'rejected' || + (submission.handoverRecorded === true && submission.handedOverAt === undefined) + ) { return false } - return stop.event.turnId !== undefined - ? stop.event.turnId === turnId - : turnlessStopStopped(state, stop, itemId) + // A handed-over send's item sits at its handover (`placeHandedOverMessage`). + const handedOver = state.items.get(agentJournalSubmissionKey(submission.clientMessageId)) + return handedOver !== undefined && handedOver.sequence < stop.sequence } -/** Pressed before any turn showed, a Stop stopped the first turn opened after it, and no later - * one: unless a send journaled since, of any origin, was not refused, whose turn that is. A Stop - * whose stopped send never opens a turn so binds nothing. `itemId` null: a turn not yet opened. */ -function turnlessStopStopped( +/** Whether `stop`, a person's, makes the end of turn `turnId` theirs: it named that turn, or named + * none and stopped the send that opened it (`userItemId`). */ +function stopIsTurnCancellation( state: TurnEndState, stop: JournalLatestStop, - itemId: string | null + turnId: string, + userItemId: string | undefined ): boolean { - const createdAt = itemId === null ? null : (state.items.get(itemId)?.sequence ?? null) - if (createdAt !== null && createdAt <= stop.sequence) { + if (!stopIsAPersons(stop.event.reason)) { return false } - for (const submission of state.submissions.values()) { - if ( - submission.dispatchState !== 'rejected' && - submission.acceptedSequence !== undefined && - submission.acceptedSequence > stop.sequence - ) { - return false - } - } - for (const [otherId, item] of state.items) { - if ( - otherId !== itemId && - item.body.kind === 'turn' && - item.sequence > stop.sequence && - (createdAt === null || item.sequence < createdAt) - ) { - return false - } + if (stop.event.turnId !== undefined) { + return stop.event.turnId === turnId } - return true + const opener = openingSubmission(state, userItemId) + return opener !== undefined && isStopTarget(state, stop, opener) } -/** THE rule: whether the latest Stop makes turn `turnId` (item `itemId`, null if not yet opened), - * ending at `endedAt` with no verdict of its own, a person's cancellation. An exit the provider saw - * before the Stop was news, whenever its end is written. */ +/** THE rule: whether the latest Stop makes turn `turnId`, opened by `userItemId` and ending at + * `endedAt` with no verdict of its own, a person's cancellation. An exit the provider saw before + * the Stop was news, whenever its end is written. */ function stopEndsTurnAsCancellation( state: TurnEndState, turnId: string, - itemId: string | null, + userItemId: string | undefined, endedAt: number | undefined ): boolean { const stop = state.queuePauseMarks.latestStop return ( stop !== null && - stopIsTurnCancellation(stop, turnId, state, itemId) && + stopIsTurnCancellation(state, stop, turnId, userItemId) && (endedAt === undefined || endedAt >= stop.event.at) ) } +/** With no turn running, the work in flight is the person's Stop's: every send still unanswered is + * one it stopped. */ +function unansweredSendsAreStopTargets(state: TurnEndState, stop: JournalLatestStop): boolean { + const unanswered = [...state.submissions.values()].filter((submission) => + isUnansweredStructuredAgentSessionDispatch(submission) + ) + return ( + unanswered.length > 0 && unanswered.every((submission) => isStopTarget(state, stop, submission)) + ) +} + /** - * Whether a person's Stop decides the end of turn `turnId` (null: the turn a send opens next), - * by `turnEndAfterStop`'s rule: ending at `endedAt` it is their cancellation, and still running it - * is theirs to end. For a writer that must choose before the end is written: a host stop must not - * supersede it, and a Claude error result naming no reason leaves its verdict to it. + * Whether a person's Stop decides the end of turn `turnId` (null: the sends in flight with no turn + * running), by `turnEndAfterStop`'s rule: ending at `endedAt` it is their cancellation, and still + * running it is theirs to end. For a writer that must choose before the end is written: a host + * stop must not supersede it, and a Claude error result naming no reason leaves its verdict to it. */ export function personStopDecidesTurn( state: TurnEndState, turnId: string | null, - endedAt?: number + endedAt?: number, + /** The submission that opened the turn, for one whose rows have yet to land. */ + openedBy?: string ): boolean { const stop = state.queuePauseMarks.latestStop if (stop === null || !stopIsAPersons(stop.event.reason)) { return false } if (turnId === null) { - return stop.event.turnId === undefined && turnlessStopStopped(state, stop, null) + return stop.event.turnId === undefined && unansweredSendsAreStopTargets(state, stop) } - const itemId = [...state.items].find( - ([, item]) => readAgentJournalTurn(item.body)?.turnId === turnId - )?.[0] - return stopEndsTurnAsCancellation(state, turnId, itemId ?? null, endedAt) + const turn = [...state.items.values()] + .map((item) => readAgentJournalTurn(item.body)) + .find((candidate) => candidate?.turnId === turnId) + const userItemId = + turn?.userItemId ?? (openedBy === undefined ? undefined : agentJournalSubmissionKey(openedBy)) + return stopEndsTurnAsCancellation(state, turnId, userItemId, endedAt) } /** * The body to write for item `itemId`: unchanged unless it ends, with no verdict of its own and no - * earlier than the latest Stop event, a person's, which named it, or stopped it before it showed, - * while it was still open (running, or unproven). A provider's own verdict always stands. + * earlier than the latest Stop event, a person's, which named it, or stopped the send that opened + * it, while it was still open (running, or unproven). A provider's own verdict always stands. */ export function turnEndAfterStop( state: TurnEndState, @@ -137,7 +164,8 @@ export function turnEndAfterStop( if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { return body } - return stopEndsTurnAsCancellation(state, body.turnId, itemId, body.completedAt) + const userItemId = body.userItemId ?? previous?.userItemId + return stopEndsTurnAsCancellation(state, body.turnId, userItemId, body.completedAt) ? { ...body, outcome: 'cancellation' } : body } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 4f2491f7b617..fe8d6108aae1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -67,8 +67,8 @@ export class StructuredAgentSessionSinkQueue { journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null - journalStopDecidesTurn = (turnId: string, endedAt: number): boolean => - this.target?.journal.stopMarks.personStopDecides(turnId, endedAt) ?? false + journalStopDecidesTurn = (turnId: string, endedAt: number, openedBy?: string): boolean => + this.target?.journal.stopMarks.personStopDecides(turnId, endedAt, openedBy) ?? false bindReadingControl(control: StructuredAgentSessionReadingControl): () => void { this.readingControl = control diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index cd15160df88d..90cc2334690f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -136,8 +136,9 @@ export type StructuredAgentSessionEventSink = { /** The bound journal's producer linkage; null until bound. */ journalLinkage?(): StructuredAgentSessionLinkageJournal | null /** Whether the bound journal's Stop rule makes turn `turnId`, ending at `endedAt` with no verdict - * of its own, a person's cancellation (`personStopDecidesTurn`); false until bound. */ - journalStopDecidesTurn?(turnId: string, endedAt: number): boolean + * of its own, a person's cancellation (`personStopDecidesTurn`); false until bound. `openedBy`: + * the submission that opened it, for a turn whose rows have yet to land. */ + journalStopDecidesTurn?(turnId: string, endedAt: number, openedBy?: string): boolean appendLifecycleBatch?( settlementId: string, mutations: readonly JournalLifecycleMutationInput[], diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 2962041b8be9..7bfe85b3765d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -1,8 +1,10 @@ -// Which turn a person's Stop that named no turn binds: only the one its stopped send opens. A Stop of -// a start that never landed stopped a send that opens no turn, and a send journaled after the Stop -// opens its own; neither is the Stop's, whatever sent it, through a rewind too. +// Which turn a person's Stop that named no turn binds: only a turn a send it stopped opens. A Stop +// of a start that never landed stopped a send that opens no turn; a card it held, which Resume +// releases, and anything sent after it open their own; a rewind keeps the binding. Turn rows name +// the send that opened them, as Codex writes them. import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemIdentity @@ -21,10 +23,10 @@ let rig: QueuedMessageTestRig afterEach(() => rig.dispose()) -const MAIL_TURN: AgentJournalItemIdentity = { +const LATER_TURN: AgentJournalItemIdentity = { provider: 'codex', threadId: 'thread-1', - turnId: 'turn-mail', + turnId: 'turn-later', ordinal: 999 } @@ -54,16 +56,39 @@ function fence(): number { return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 } -function mailTurn() { - return journal() - .snapshot() - .items.map((item) => readAgentJournalTurn(item.body)) - .find((turn) => turn?.turnId === 'turn-mail') +async function laterTurn() { + const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION) + return items + .map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-later') } -/** A person's Stop of a start that never landed, whose send opens no turn; then orchestration mail - * starts a new child, which lands and runs the mail's turn. */ -async function mailTurnAfterStopOfStart(): Promise { +/** The turn send `clientMessageId` opens, running, named by its row as Codex writes it. */ +async function turnOpenedBy(clientMessageId: string, state: 'running' | 'interrupted' = 'running') { + await journal().appendItem( + LATER_TURN, + { + kind: 'turn', + turnId: 'turn-later', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(clientMessageId), + ...(state === 'running' ? { state } : { state, completedAt: Date.now() + 5 }) + }, + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) + } + return queued.value.queued.messageId +} + +/** A person's Stop of a start that never landed, whose send opens no turn. `held`: a card queued + * behind the start, which the Stop holds. */ +async function stopOfStart(options: { held?: true } = {}): Promise { rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) let release: () => void = () => undefined rig.awaitStarted.mockImplementation( @@ -71,40 +96,64 @@ async function mailTurnAfterStopOfStart(): Promise { ) rig.send('work on this') await eventually(() => expect(childPhase()).toBe('starting')) + const held = options.held ? await queuedDraft('queued behind the start') : undefined expect(await rig.stop()).toMatchObject({ ok: true }) release() expect(stopEvents()).toEqual([expect.objectContaining({ reason: 'user-stop' })]) expect(stopEvents()[0]).not.toHaveProperty('turnId') await eventually(() => expect(childPhase()).toBeUndefined()) rig.awaitStarted.mockImplementation(async () => undefined) - await rig.send('mail for the worker', undefined, { internal: true }).result + return held +} + +/** Orchestration mail after the Stop starts a new child and its turn runs. */ +async function mailTurn(): Promise { + const mail = rig.send('mail for the worker', undefined, { internal: true }) + await mail.result await eventually(() => expect(rig.dispatch).toHaveBeenCalled()) - await journal().appendItem( - MAIL_TURN, - { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: Date.now() }, - { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) + await turnOpenedBy(mail.id) +} + +/** The host evicts the chat; the Stop events as its provider close finds them. */ +async function evictedAt(): Promise { + let atClose: JournalStopEvent[] = [] + rig.closeSession.mockImplementationOnce(async () => { + atClose = stopEvents() + return true + }) + await rig.host.close(HOST_TEST_SESSION, 'evict') + return atClose.map((event) => event.reason) +} + +async function expectNews(): Promise { + const turn = await laterTurn() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') } describe('a Stop of a start that never landed binds no later turn', () => { - it("writes the host's event when it evicts the mail turn, which reads as news", async () => { - await mailTurnAfterStopOfStart() - let atClose: JournalStopEvent[] = [] - rig.closeSession.mockImplementationOnce(async () => { - atClose = stopEvents() - return true - }) + it("writes the host's event when it evicts a mail turn, which reads as news", async () => { + await stopOfStart() + await mailTurn() - await rig.host.close(HOST_TEST_SESSION, 'evict') + expect(await evictedAt()).toEqual(['user-stop', 'evict']) + await expectNews() + }) - expect(atClose.map((event) => event.reason)).toEqual(['user-stop', 'evict']) - await rig.host.journalSnapshot(HOST_TEST_SESSION) - expect(mailTurn()).toMatchObject({ state: 'interrupted' }) - expect(mailTurn()).not.toHaveProperty('outcome') + it('reads a mail turn the child end cut, with no verdict of its own, as news', async () => { + await stopOfStart() + const mail = rig.send('mail for the worker', undefined, { internal: true }) + await mail.result + await turnOpenedBy(mail.id) + + await turnOpenedBy(mail.id, 'interrupted') + + await expectNews() }) - it('settles a crash of the mail turn on relaunch as news', async () => { - await mailTurnAfterStopOfStart() + it('settles a crash of a mail turn on relaunch as news', async () => { + await stopOfStart() + await mailTurn() const owner = fence() rig.crashRestartHostProcess() await rig.host.journalSnapshot(HOST_TEST_SESSION) @@ -122,14 +171,67 @@ describe('a Stop of a start that never landed binds no later turn', () => { } }) - expect(mailTurn()).toMatchObject({ state: 'interrupted' }) - expect(mailTurn()).not.toHaveProperty('outcome') + await expectNews() + }) + + it("writes the host's event when it evicts the turn of a card the Stop held, which Resume sent", async () => { + const held = (await stopOfStart({ held: true }))! + expect(await rig.resume()).toMatchObject({ ok: true }) + await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) + await turnOpenedBy(await rig.handoffId(held)) + + expect(await evictedAt()).toEqual(['user-stop', 'evict']) + await expectNews() + }) +}) + +describe('a Stop pressed before its send opened a turn binds only that turn', () => { + /** The send the Stop stopped is handed over and unopened; a card waits behind it. */ + async function stopBeforeTheTurnShowed(): Promise<{ stopped: string; held: string }> { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + const held = await queuedDraft('queued behind the turn') + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + return { stopped, held } + } + + it("binds the stopped send's own turn", async () => { + const { stopped } = await stopBeforeTheTurnShowed() + await rig.settleAccepted(stopped, 'stopped') + + await turnOpenedBy(stopped, 'interrupted') + + expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + it("writes the host's event when it evicts the turn of the card Resume sent", async () => { + const { stopped, held } = await stopBeforeTheTurnShowed() + await rig.settleAccepted(stopped, 'stopped') + expect(await rig.resume()).toMatchObject({ ok: true }) + await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) + await turnOpenedBy(await rig.handoffId(held)) + + expect(await evictedAt()).toEqual(['user-stop', 'evict']) + await expectNews() + }) + + it('reads a mail turn the child end cut as news', async () => { + const { stopped } = await stopBeforeTheTurnShowed() + await rig.settleAccepted(stopped, 'stopped') + const mail = rig.send('mail for the lead', undefined, { internal: true }) + await mail.result + await turnOpenedBy(mail.id) + + await turnOpenedBy(mail.id, 'interrupted') + + await expectNews() }) }) describe('a rewind that restates a turnless Stop', () => { - // The rewind writes the Stop still in force after the turns it keeps, so it follows the turn it - // already bound; the mail after the rewind is still its own. + // The rewind writes the Stop still in force after the turns it keeps, at a new position; the + // mail after the rewind is still its own. it('binds no turn opened after the rewind', async () => { rig = await createQueuedMessageTestRig() const stopped = await rig.workingSend() @@ -137,11 +239,16 @@ describe('a rewind that restates a turnless Stop', () => { expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') await rig.settleAccepted(stopped, 'stopped') const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - const stoppedTurn = { ...MAIL_TURN, turnId: 'turn-stopped', ordinal: 998 } - const ended = { kind: 'turn' as const, turnId: 'turn-stopped', state: 'interrupted' as const } + const stoppedTurn = { ...LATER_TURN, turnId: 'turn-stopped', ordinal: 998 } + const ended = { + kind: 'turn' as const, + turnId: 'turn-stopped', + state: 'interrupted' as const, + userItemId: agentJournalSubmissionKey(stopped) + } await journal().appendItem( stoppedTurn, - { kind: 'turn', turnId: 'turn-stopped', state: 'running', startedAt: Date.now() }, + { ...ended, state: 'running', startedAt: Date.now() }, scope ) await journal().appendItem(stoppedTurn, { ...ended, completedAt: Date.now() + 1 }, scope) @@ -152,19 +259,11 @@ describe('a rewind that restates a turnless Stop', () => { body: { ...ended, completedAt: Date.now() + 1, outcome: 'cancellation' } } ]) - await rig.send('mail after the rewind', undefined, { internal: true }).result - await journal().appendItem( - MAIL_TURN, - { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: Date.now() }, - scope - ) - await journal().appendItem( - MAIL_TURN, - { kind: 'turn', turnId: 'turn-mail', state: 'interrupted', completedAt: Date.now() + 5 }, - scope - ) + const mail = rig.send('mail after the rewind', undefined, { internal: true }) + await mail.result + await turnOpenedBy(mail.id) + await turnOpenedBy(mail.id, 'interrupted') - expect(mailTurn()).toMatchObject({ state: 'interrupted' }) - expect(mailTurn()).not.toHaveProperty('outcome') + await expectNews() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 1e2e707ac1cb..0a77eebbff22 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -261,9 +261,7 @@ describe("a person's Stop pause and the Stop events after it", () => { await rig.settleAccepted(working, 'stopped') expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) // Orchestration mail starts a turn the host sent, which lifts nothing. - const mail = rig.send('mail for the lead', undefined, { internal: true }) - await mail.result - await rig.settleAccepted(mail.id, 'mail') + await rig.send('mail for the lead', undefined, { internal: true }).result await journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-mail', ordinal: 999 }, { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: 1 }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 85ec7a619995..18d28db12407 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -9,6 +9,7 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' import { agentTurnVerdict } from '../../../shared/agent-turn-outcome' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' @@ -277,24 +278,38 @@ describe('a restart between a Stop and its turn end', () => { expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() await rig.settleAccepted(stopped, 'stopped') const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + // Codex names the send that opened a turn on its row. + const opener = agentJournalSubmissionKey(stopped) await journal().appendItem( CODEX_TURN, - { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() }, + { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now(), userItemId: opener }, scope ) await journal().appendItem( CODEX_TURN, - { kind: 'turn', turnId: TURN, state: 'interrupted', completedAt: Date.now() + 1 }, + { + kind: 'turn', + turnId: TURN, + state: 'interrupted', + completedAt: Date.now() + 1, + userItemId: opener + }, scope ) expect(settled(TURN).turn).toMatchObject({ outcome: 'cancellation' }) - // The queue's drain sends as the host, so no person's send voids the Stop. + // A send after the Stop, the queue's drain here, opens its own turn. const drained = rig.send('drained after the Stop', undefined, { internal: true }) await drained.result await rig.settleAccepted(drained.id, 'drained') await journal().appendItem( CODEX_NEXT_TURN, - { kind: 'turn', turnId: NEXT_TURN, state: 'running', startedAt: Date.now() }, + { + kind: 'turn', + turnId: NEXT_TURN, + state: 'running', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(drained.id) + }, scope ) From ee548614a2d4cad687e6a3f331c05a15daf8e714 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:31:06 -0700 Subject: [PATCH 052/182] fix(native-chat): a host stop whose sink drain runs long reads the agent working; a failed one reads the journal A drain past its bound may still hold the turn's row, while the echo's acceptance has already landed, so the journal as it stands read nothing running: a person's close of that turn wrote no Stop event and the turn read as news. The two drain outcomes now differ: one that failed has nothing more to deliver, so the journal's read holds (as before); one still running reads working. --- .../structured-agent-session-host-lifetime.ts | 25 +++++++------ ...d-agent-session-stop-event-entries.test.ts | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 054e51d4974a..263a1f5d91ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -121,18 +121,23 @@ async function stopEndsWork( if ('recorded' in ending || ending.quit || ending.resting || !child) { return false } - // Best effort: the stop goes ahead either way, so a failed or slow drain leaves the journal's - // read as it stands rather than calling the agent working. - await withTimeout( - context.runtimeState.flushEventSink(sessionId).catch(() => undefined), + // A failed drain has nothing more to deliver, so the journal's read as it stands holds. One + // still running past its bound may hold the turn's row, so the agent reads working. + const drain = await withTimeout( + context.runtimeState.flushEventSink(sessionId).then( + () => 'drained' as const, + () => 'failed' as const + ), STOP_EVENT_DRAIN_TIMEOUT_MS, - undefined - ) - const working = isStructuredAgentSessionMainAgentWorking( - journal.activeTurnId(), - journal.submissions(), - child.fence + 'slow' as const ) + const working = + drain === 'slow' || + isStructuredAgentSessionMainAgentWorking( + journal.activeTurnId(), + journal.submissions(), + child.fence + ) // A host stop of work a person's Stop is already ending must not supersede that Stop's reason. return ( working && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 0a77eebbff22..db71694dbb7c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' @@ -159,6 +160,40 @@ describe('every Stop entry writes its event, with its reason, before it ends the }) }) + // A drain still running after its bound may hold the turn's row: the agent reads working. + it("writes a person's close while the running turn's row waits behind a slow sink", async () => { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + const open = journal() + const append = open.appendItem.bind(open) + let held = false + vi.spyOn(open, 'appendItem').mockImplementation(async (...args: Parameters) => { + if (!held && args[1].kind === 'turn' && args[1].state === 'running') { + held = true + await new Promise((resolve) => setTimeout(resolve, 1_500)) + } + return append(...args) + }) + rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION).sink.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 }, + { + kind: 'turn', + turnId: 'turn-1', + state: 'running', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(sent) + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + // The echo's acceptance lands straight in the journal, ahead of the turn row. + await rig.settleAccepted(sent, 'turn-1') + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, 'user-close') + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-close']) + }, 20_000) + // The drain is best effort: when it fails, the journal as it stands says the agent rests. it('writes nothing when the drain fails as it evicts a chat at rest', async () => { rig = await createQueuedMessageTestRig() From bd16848e4addb679a620ecaee5df8c45859baae0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:31:40 -0700 Subject: [PATCH 053/182] test(native-chat): a host stop with no turn running defers only while every unanswered send is the Stop's The branch had no test. An eviction with only the stopped send unanswered writes nothing; one with a send made after the Stop still unanswered writes its event. --- ...d-agent-session-stop-event-binding.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 7bfe85b3765d..a284bd9b12f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -229,6 +229,31 @@ describe('a Stop pressed before its send opened a turn binds only that turn', () }) }) +describe('a host stop with no turn running after a Stop that named none', () => { + it('writes nothing while every unanswered send is one the Stop stopped', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + + expect(await evictedAt()).toEqual(['user-stop']) + }) + + it("writes the host's event when a send after the Stop is unanswered too", async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(stopped, 'stopped') + const mail = rig.send('mail for the lead', undefined, { internal: true }) + await mail.result + await eventually(async () => + expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() + ) + + expect(await evictedAt()).toEqual(['user-stop', 'evict']) + }) +}) + describe('a rewind that restates a turnless Stop', () => { // The rewind writes the Stop still in force after the turns it keeps, at a new position; the // mail after the rewind is still its own. From ec0533b527658275aba9fcc45dc6bac58f4bc319 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:36:41 -0700 Subject: [PATCH 054/182] fix(native-chat): a slow sink drain reads working only while an accepted send's turn row is due The previous commit read every drain past its bound as working, so a host eviction or stop of an agent at rest during a sink backlog wrote a Stop event that ended nothing and lifted a person's Stop pause. A slow drain now reads working only when the latest send the agent accepted has opened no turn the journal holds, the race it was for; otherwise the journal's read holds. --- .../journal-stop-marks.ts | 11 ++++- .../journal-stop-turn-end.ts | 29 ++++++++++++ .../structured-agent-session-host-lifetime.ts | 5 +- ...d-agent-session-stop-event-entries.test.ts | 47 +++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts index cdcf7048bc6f..e79958a952c7 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -2,7 +2,11 @@ // the turn-end rule reads (`journal-stop-turn-end.ts`), and whether a person's still decides. import type { JournalReducerState } from './journal-reducer' -import { personStopDecidesTurn, type JournalLatestStop } from './journal-stop-turn-end' +import { + latestAcceptedSendUnopened, + personStopDecidesTurn, + type JournalLatestStop +} from './journal-stop-turn-end' export class JournalStopMarks { constructor(private readonly deps: { state: () => JournalReducerState }) {} @@ -11,6 +15,11 @@ export class JournalStopMarks { return this.deps.state().queuePauseMarks.latestStop } + /** `latestAcceptedSendUnopened`: the latest accepted send's turn row may still be on its way. */ + latestAcceptedSendUnopened(): boolean { + return latestAcceptedSendUnopened(this.deps.state()) + } + /** `personStopDecidesTurn`: a person's Stop decides how turn `turnId` ends. */ personStopDecides(turnId: string | null, endedAt?: number, openedBy?: string): boolean { return personStopDecidesTurn(this.deps.state(), turnId, endedAt, openedBy) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index dfa448c1c81c..df7e3f0118c8 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -54,6 +54,35 @@ function openingSubmission( : undefined } +/** Whether the latest send the agent accepted has opened no turn the journal holds: its turn's row + * may still be on its way. */ +export function latestAcceptedSendUnopened(state: TurnEndState): boolean { + let latest: { submission: AgentJournalSubmission; sequence: number } | undefined + for (const submission of state.submissions.values()) { + const sequence = state.items.get( + agentJournalSubmissionKey(submission.clientMessageId) + )?.sequence + if ( + submission.dispatchState === 'accepted' && + sequence !== undefined && + sequence >= (latest?.sequence ?? -1) + ) { + latest = { submission, sequence } + } + } + if (!latest) { + return false + } + for (const item of state.items.values()) { + if ( + openingSubmission(state, readAgentJournalTurn(item.body)?.userItemId) === latest.submission + ) { + return false + } + } + return true +} + /** A send a Stop that named no turn stopped: one already handed to the agent at the Stop's * position, whose turn had not opened. A card the Stop held, or anything sent after it, is not. */ function isStopTarget( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 263a1f5d91ae..95e5591c4306 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -122,7 +122,8 @@ async function stopEndsWork( return false } // A failed drain has nothing more to deliver, so the journal's read as it stands holds. One - // still running past its bound may hold the turn's row, so the agent reads working. + // still running past its bound may hold the turn row of a send already accepted: that reads + // working. const drain = await withTimeout( context.runtimeState.flushEventSink(sessionId).then( () => 'drained' as const, @@ -132,7 +133,7 @@ async function stopEndsWork( 'slow' as const ) const working = - drain === 'slow' || + (drain === 'slow' && journal.stopMarks.latestAcceptedSendUnopened()) || isStructuredAgentSessionMainAgentWorking( journal.activeTurnId(), journal.submissions(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index db71694dbb7c..e24d92bf6c3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -194,6 +194,53 @@ describe('every Stop entry writes its event, with its reason, before it ends the expect(atClose.events?.map((event) => event.reason)).toEqual(['user-close']) }, 20_000) + // A slow drain counts as working only for a send accepted with no turn row yet. + it('writes nothing when the drain runs long as it evicts a chat at rest', async () => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + const opener = agentJournalSubmissionKey(working) + const identity = { + provider: 'codex' as const, + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 999 + } + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + await journal().appendItem( + identity, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1, userItemId: opener }, + scope + ) + await queuedDraft('queued behind the turn') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'turn-1') + await journal().appendItem( + identity, + { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + completedAt: Date.now(), + userItemId: opener + }, + scope + ) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) + const drained = sink.drained.bind(sink) + vi.spyOn(sink, 'drained') + .mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 1_500)) + ) + .mockImplementation(drained) + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, 'evict') + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }, 20_000) + // The drain is best effort: when it fails, the journal as it stands says the agent rests. it('writes nothing when the drain fails as it evicts a chat at rest', async () => { rig = await createQueuedMessageTestRig() From 3fba742bdd17ab7ae97b1ebd7e1eeb15b47e4ae5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:37:04 -0700 Subject: [PATCH 055/182] test(native-chat): the host-stop control keeps the stopped send unanswered beside the later one With both unanswered, the host writes only because not every unanswered send is the Stop's; a rule that deferred when any one was would pass the old control. --- .../structured-agent-session-stop-event-binding.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index a284bd9b12f9..2654b20ac730 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -239,11 +239,10 @@ describe('a host stop with no turn running after a Stop that named none', () => expect(await evictedAt()).toEqual(['user-stop']) }) - it("writes the host's event when a send after the Stop is unanswered too", async () => { + it("writes the host's event when a send after the Stop is unanswered beside the stopped one", async () => { rig = await createQueuedMessageTestRig() - const stopped = await rig.workingSend() + await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(stopped, 'stopped') const mail = rig.send('mail for the lead', undefined, { internal: true }) await mail.result await eventually(async () => From 6a1f65bc96537eca10f836d0ab0d3fc1cd574e4e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:41:50 -0700 Subject: [PATCH 056/182] fix(native-chat): a steer is no send owed a turn when a slow drain judges a host stop A slow drain reads working when the latest accepted send has opened no turn yet. A Codex steer or a Claude fold is accepted into the running turn and never opens one, so a chat at rest whose last send was a steer still read working, and an eviction lifted a person's Stop pause. Sends delivered into a running turn, whose item carries that turn's scope, are skipped. --- .../journal-stop-turn-end.ts | 16 +-- ...d-agent-session-stop-event-entries.test.ts | 107 +++++++++++------- 2 files changed, 72 insertions(+), 51 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index df7e3f0118c8..4c28a0422a6d 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -54,20 +54,20 @@ function openingSubmission( : undefined } -/** Whether the latest send the agent accepted has opened no turn the journal holds: its turn's row - * may still be on its way. */ +/** Whether the latest send the agent accepted to open a turn has opened none the journal holds: its + * turn's row may still be on its way. A send delivered into a running turn (a steer, a fold) + * opens none, and its item carries that turn's scope (`placeHandedOverMessage`). */ export function latestAcceptedSendUnopened(state: TurnEndState): boolean { let latest: { submission: AgentJournalSubmission; sequence: number } | undefined for (const submission of state.submissions.values()) { - const sequence = state.items.get( - agentJournalSubmissionKey(submission.clientMessageId) - )?.sequence + const item = state.items.get(agentJournalSubmissionKey(submission.clientMessageId)) if ( submission.dispatchState === 'accepted' && - sequence !== undefined && - sequence >= (latest?.sequence ?? -1) + item !== undefined && + item.turnScope?.kind !== 'turn' && + item.sequence >= (latest?.sequence ?? -1) ) { - latest = { submission, sequence } + latest = { submission, sequence: item.sequence } } } if (!latest) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index e24d92bf6c3b..a40633381454 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -195,51 +195,72 @@ describe('every Stop entry writes its event, with its reason, before it ends the }, 20_000) // A slow drain counts as working only for a send accepted with no turn row yet. - it('writes nothing when the drain runs long as it evicts a chat at rest', async () => { - rig = await createQueuedMessageTestRig() - const working = await rig.workingSend() - const opener = agentJournalSubmissionKey(working) - const identity = { - provider: 'codex' as const, - threadId: 'thread-1', - turnId: 'turn-1', - ordinal: 999 - } - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - await journal().appendItem( - identity, - { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1, userItemId: opener }, - scope - ) - await queuedDraft('queued behind the turn') - expect(await rig.stop()).toMatchObject({ ok: true }) - await rig.settleAccepted(working, 'turn-1') - await journal().appendItem( - identity, - { - kind: 'turn', + // A steer delivered into the running turn opens no turn of its own, so it is never owed one. + it.each([ + ['', false], + [', its last send a steer into the stopped turn', true] + ])( + 'writes nothing when the drain runs long as it evicts a chat at rest%s', + async (_label, steered) => { + rig = await createQueuedMessageTestRig() + const working = await rig.workingSend() + const opener = agentJournalSubmissionKey(working) + const identity = { + provider: 'codex' as const, + threadId: 'thread-1', turnId: 'turn-1', - state: 'interrupted', - completedAt: Date.now(), - userItemId: opener - }, - scope - ) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) - const drained = sink.drained.bind(sink) - vi.spyOn(sink, 'drained') - .mockImplementationOnce( - () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 1_500)) + ordinal: 999 + } + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + await journal().appendItem( + identity, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1, userItemId: opener }, + scope ) - .mockImplementation(drained) - const atClose = stopEventsAtClose() - - await rig.host.close(HOST_TEST_SESSION, 'evict') - - expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - }, 20_000) + if (steered) { + const steer = rig.send('steer the running turn') + await steer.result + await eventually(async () => + expect((await rig.submission(steer.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(steer.id, 'turn-1') + expect( + journal() + .snapshot() + .items.find((item) => item.itemId === agentJournalSubmissionKey(steer.id))?.turnScope + ).toMatchObject({ kind: 'turn' }) + } + await queuedDraft('queued behind the turn') + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'turn-1') + await journal().appendItem( + identity, + { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + completedAt: Date.now(), + userItemId: opener + }, + scope + ) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) + const drained = sink.drained.bind(sink) + vi.spyOn(sink, 'drained') + .mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 1_500)) + ) + .mockImplementation(drained) + const atClose = stopEventsAtClose() + + await rig.host.close(HOST_TEST_SESSION, 'evict') + + expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }, + 20_000 + ) // The drain is best effort: when it fails, the journal as it stands says the agent rests. it('writes nothing when the drain fails as it evicts a chat at rest', async () => { From e644e58e7fc3de82e016a8e19947f44b90d6230b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:13:38 -0700 Subject: [PATCH 057/182] feat(native-chat): a chat reads Stopping from the person's Stop until the work it stopped ends The host derives it on each journal publish from the Stop's event, the live turn and the Stop's own answer, and publishes it as an optional field on the session status and the main agent's row. Clients present it: the chat's tail line and Stop control, the sidebar row, worktree ps and the phone's row. The chat and the phone also read their own Stop press until its request answers. --- .../session/MobileNativeChatTurnStatus.tsx | 12 +- mobile/src/session/MobileNativeChatView.tsx | 17 +- .../use-mobile-structured-agent-session.ts | 14 +- .../use-mobile-structured-stop-press.ts | 32 +++ mobile/src/worktree/agent-row-display.ts | 13 +- pnpm-lock.yaml | 19 ++ .../first-work-branch-rename.test.ts | 2 + .../server/server-ingest-structured.ts | 3 +- .../claude-background-wake-retention.test.ts | 4 +- .../structured-agent-session-command-turn.ts | 21 +- ...agent-session-status-feed-stopping.test.ts | 102 +++++++++ .../structured-agent-session-status-feed.ts | 11 +- ...agent-session-status-journal-projection.ts | 6 +- ...d-agent-session-status-summary-equality.ts | 1 + .../structured-agent-session-stopping.test.ts | 202 ++++++++++++++++++ .../structured-agent-session-stopping.ts | 44 ++++ ...ructured-agent-session-rpc.test-fixture.ts | 1 + ...ime-worktree-agent-rows-structured.test.ts | 10 + .../native-chat/NativeChatComposer.test.tsx | 18 ++ .../native-chat/NativeChatComposer.tsx | 4 +- .../NativeChatComposerActions.test.tsx | 23 ++ .../native-chat/NativeChatComposerActions.tsx | 7 +- .../native-chat/NativeChatComposerField.tsx | 4 + .../native-chat/NativeChatMessageList.tsx | 4 + ...iveChatStructuredSession.stopping.test.tsx | 116 ++++++++++ ...tiveChatStructuredSession.test-harness.tsx | 5 + .../NativeChatStructuredSession.tsx | 7 +- .../NativeChatTurnActivityLine.test.tsx | 20 ++ .../NativeChatTurnActivityLine.tsx | 38 +++- ...tructuredAgentSessionStatusBridge.test.tsx | 3 +- .../StructuredAgentSessionStatusBridge.tsx | 14 +- .../native-chat/native-chat-composer-types.ts | 2 + ...ructured-agent-session-stop-press.test.tsx | 66 ++++++ ...use-structured-agent-session-stop-press.ts | 32 +++ .../use-structured-agent-session.ts | 11 +- ...ee-card-compact-agent-row-stopping.test.ts | 51 +++++ .../worktree-card-compact-agent-row.tsx | 5 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + ...gent-session-status-feed-lifecycle.test.ts | 21 ++ .../structured-agent-session-status-feed.ts | 4 +- src/shared/agent-lead-status-fold.ts | 2 + src/shared/agent-session-wire.ts | 4 + src/shared/agent-status-types.ts | 1 + src/shared/main-agent-status.ts | 10 +- src/shared/native-chat-turn-status.test.ts | 11 + src/shared/native-chat-turn-status.ts | 20 +- ...uctured-agent-session-agent-status.test.ts | 11 + .../structured-agent-session-agent-status.ts | 5 +- 53 files changed, 979 insertions(+), 60 deletions(-) create mode 100644 mobile/src/session/use-mobile-structured-stop-press.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts diff --git a/mobile/src/session/MobileNativeChatTurnStatus.tsx b/mobile/src/session/MobileNativeChatTurnStatus.tsx index a39e404c6397..e2370407161c 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.tsx +++ b/mobile/src/session/MobileNativeChatTurnStatus.tsx @@ -76,15 +76,17 @@ export function MobileNativeChatTurnStatus({ ) } -/** The live turn's tail line: a spinner beside what the provider says it is doing, - * else "Thinking", else "Working…". The clock stays in the turn bar. Desktop - * parity: `NativeChatTurnActivityLine`. */ +/** The live turn's tail line: a spinner beside "Stopping…" once the person's Stop is ending the + * turn, else what the provider says it is doing, else "Thinking", else "Working…". The clock + * stays in the turn bar. Desktop parity: `NativeChatTurnActivityLine`. */ export function MobileNativeChatTurnActivity({ thinking, - activityText + activityText, + stopping = false }: { thinking: boolean activityText?: string | null + stopping?: boolean }): React.JSX.Element { return ( - {formatNativeChatActiveTurnLabel({ activityText, thinking })} + {formatNativeChatActiveTurnLabel({ activityText, thinking, stopping })} ) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 469abba53880..24218a1ec51a 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -13,9 +13,10 @@ import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-ha import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import type { - NativeChatLiveTurnIndicator, - NativeChatSettledTurns +import { + NATIVE_CHAT_TURN_STATUS_COPY, + type NativeChatLiveTurnIndicator, + type NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import { colors } from '../theme/mobile-theme' @@ -266,6 +267,7 @@ export function MobileNativeChatView({ // Per-turn status rows: one live indicator while the turn runs, then a settled // "Worked for N" row. The structured lane owns them; the bridge lane keeps its // three-dot indicator. + const stopping = agentWorking === true && turnIndicator?.stopping === true const turns = useMobileNativeChatTurnDisclosure({ messages: data, enabled: structuredActivityUi, @@ -300,6 +302,7 @@ export function MobileNativeChatView({ ) : null @@ -406,14 +409,18 @@ export function MobileNativeChatView({ {canStop ? ( + // A second Stop while one is ending the turn sends nothing. [styles.stopButton, pressed && styles.pressed]} onPress={onStop} + disabled={stopping} hitSlop={8} - accessibilityLabel="Stop the agent" + accessibilityLabel={stopping ? NATIVE_CHAT_TURN_STATUS_COPY.stopping : 'Stop the agent'} > - Stop + + {stopping ? NATIVE_CHAT_TURN_STATUS_COPY.stopping : 'Stop'} + ) : null} diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 8b7e15514019..f2ff6cf443f2 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -20,6 +20,7 @@ import type { MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatSession } from './use-mobile-native-chat-session' import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status' import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state' +import { useMobileStructuredStopPress } from './use-mobile-structured-stop-press' import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses' import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' @@ -168,7 +169,14 @@ export function useMobileStructuredAgentSession(args: { const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state.items) - const turnIndicator = useMemo(() => ({ thinking, activityText }), [thinking, activityText]) + const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) + // The phone reads no session status, so only its own press says "Stopping…". + const stopPress = useMobileStructuredStopPress(sessionKey) + const stopping = isWorking && stopPress.pressed + const turnIndicator = useMemo( + () => ({ thinking, activityText, stopping }), + [thinking, activityText, stopping] + ) const status = state.status === 'idle' ? 'idle' : state.status const approvalPrompt = useMemo( () => state.items.find(pendingStructuredApproval) ?? null, @@ -219,13 +227,13 @@ export function useMobileStructuredAgentSession(args: { loadingEarlier: loadingOlder, loadEarlier }, - isWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), + isWorking, turnId, turnIndicator, ...turnTiming, sendWithOutcome, cancel: () => { - void requestCancel() + void stopPress.track(() => requestCancel()) }, cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => requestCancel(prompt ?? pendingStructuredPromptIdentity(stateRef.current.items)), diff --git a/mobile/src/session/use-mobile-structured-stop-press.ts b/mobile/src/session/use-mobile-structured-stop-press.ts new file mode 100644 index 000000000000..daa4be6c1d90 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-stop-press.ts @@ -0,0 +1,32 @@ +import { useCallback, useRef, useState } from 'react' + +/** + * The person's own Stop press, read as "Stopping…" until its request answers, so the control + * flips on tap. Presentation only: never stored or sent. Desktop parity: + * `useStructuredAgentSessionStopPress`. + */ +export function useMobileStructuredStopPress(sessionKey: string): { + pressed: boolean + track: (stop: () => Promise) => Promise +} { + const [press, setPress] = useState<{ sessionKey: string; token: symbol } | null>(null) + const latest = useRef(null) + const track = useCallback( + async (stop: () => Promise): Promise => { + const token = Symbol('stop-press') + latest.current = token + setPress({ sessionKey, token }) + try { + return await stop() + } finally { + // Answered or failed alike: a failed request keeps its own banner. + if (latest.current === token) { + latest.current = null + setPress(null) + } + } + }, + [sessionKey] + ) + return { pressed: press?.sessionKey === sessionKey, track } +} diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index 87b07abfbfb7..0ab164d079aa 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -123,10 +123,17 @@ export function agentStateLabel(state: AgentDotState): string { } } -// Primary row text: prefer the agent's last message, then the user prompt, then -// a human-readable state label so a row is never blank. Matches the desktop -// DashboardAgentRow displayLabel fallback chain. +// Mirrors NATIVE_CHAT_TURN_STATUS_COPY.stopping, which this file cannot import as a value (see +// AGENT_STATUS_STALE_AFTER_MS above). +const STOPPING_LABEL = 'Stopping…' + +// Primary row text: "Stopping…" while the host says a person's Stop is ending the turn, else the +// agent's last message, then the user prompt, then a human-readable state label so a row is never +// blank. Matches the desktop DashboardAgentRow displayLabel fallback chain. export function agentDisplayLabel(row: RuntimeWorktreeAgentRow, now: number): string { + if (row.state === 'working' && row.mainAgent?.stopping === true) { + return STOPPING_LABEL + } const message = row.lastAssistantMessage?.trim() if (message) { return message diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17332ca0bb19..ed9826860897 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ importers: .: configDependencies: {} packageManagerDependencies: + '@pnpm/exe': + specifier: 12.0.0 + version: 12.0.0 pnpm: specifier: 12.0.0 version: 12.0.0 @@ -56,6 +59,11 @@ packages: cpu: [x64] os: [win32] + '@pnpm/exe@12.0.0': + resolution: {integrity: sha512-405vw2qYPPghNxoPRt2cvkGtGNU5gnInDcIe5TztQxe73yUZLqFJN/jCJFGs1xbVaIWDXEVuQ71P8npFZeatZQ==} + engines: {node: '>=18.*'} + hasBin: true + pnpm@12.0.0: resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} engines: {node: '>=18.*'} @@ -87,6 +95,17 @@ snapshots: '@pnpm/exe.win32-x64@12.0.0': optional: true + '@pnpm/exe@12.0.0': + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + pnpm@12.0.0: optionalDependencies: '@pnpm/exe.darwin-arm64': 12.0.0 diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 63b6165ce3aa..0d6792d4d442 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -105,6 +105,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { let sequence = 0 const journal = { snapshot: () => ({ items }), + stopMarks: { latest: () => null }, lastActivityAt: () => 1, isReadOnly: false, cursor: () => ({ epoch: 1, sequence: (sequence += 1) }) @@ -193,6 +194,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { const journal = { isReadOnly: false, lastActivityAt: () => 1, + stopMarks: { latest: () => null }, cursor: () => ({ epoch: 1, sequence: 1 }), snapshot: () => ({ items: [ diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index 6255c57b5ca9..e455f77782e8 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -49,7 +49,8 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng const agentStatus = structuredAgentSessionAgentStatus({ status: summary.status, childWork: this.canonicalStatusStore.getChildren(parsed), - turnOutcome: summary.turnOutcome + turnOutcome: summary.turnOutcome, + ...(summary.stopping ? { stopping: summary.stopping } : {}) }) const { state, workingMode } = agentStatus // The main agent's own clock keeps continuity the same way the combined row's does below, dated diff --git a/src/main/claude/claude-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts index 313f9c5de590..56bb45b56ceb 100644 --- a/src/main/claude/claude-background-wake-retention.test.ts +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -32,6 +32,8 @@ async function wiredSession() { cursor: () => ({ epoch: 'epoch-1', sequence: ++sequence }), isReadOnly: false, lastActivityAt: () => 1, + // No Stop was ever pressed here. + stopMarks: { latest: () => null }, snapshot: () => ({ items: [...run.journalItems.values()] .sort((a, b) => a.sequence - b.sequence) @@ -44,7 +46,7 @@ async function wiredSession() { [ parent.sessionId, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the cursor, read-only flag, activity clock and snapshot served here. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the cursor, read-only flag, activity clock, Stop marks and snapshot served here. journal: journal as unknown as Journal, params: { location: { 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 25b6addb3c58..3ccf446fa503 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 @@ -127,6 +127,12 @@ export function structuredAgentSessionStopNoteIdentity( return { provider: 'orca', clientMessageId: `${STOP_NOTE_PREFIX}${clientOperationId}` } } +/** Whether journal item `itemId` is a Stop's note. */ +export function isStructuredAgentSessionStopNote(itemId: string): boolean { + const identity = parseAgentJournalItemKey(itemId) + return identity?.provider === 'orca' && identity.clientMessageId.startsWith(STOP_NOTE_PREFIX) +} + /** Whether an earlier Stop already asked the running command `turnId` names to end. Read from the * journal, so nothing is held that could outlive the command. */ export function structuredAgentSessionCommandWasStopped( @@ -134,15 +140,14 @@ export function structuredAgentSessionCommandWasStopped( turnId: string ): boolean { const { itemId } = structuredAgentSessionCommandTurn(turnId.slice('compact:'.length)) - return journal.snapshot().items.some((item) => { - const identity = parseAgentJournalItemKey(item.itemId) - return ( - item.turnScope?.kind === 'turn' && - item.turnScope.turnItemId === itemId && - identity?.provider === 'orca' && - identity.clientMessageId.startsWith(STOP_NOTE_PREFIX) + return journal + .snapshot() + .items.some( + (item) => + item.turnScope?.kind === 'turn' && + item.turnScope.turnItemId === itemId && + isStructuredAgentSessionStopNote(item.itemId) ) - }) } export type StructuredAgentSessionCommandHandoverContext = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts new file mode 100644 index 000000000000..c0244c86c2d2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts @@ -0,0 +1,102 @@ +// The status feed's Stopping is live state, like the child it rides beside: when the host lets go of +// a session, nothing there is ending the work, so the retained summary no longer says Stopping. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' + +const SESSION = 'status-session' + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-status-feed-stopping-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe("the status feed's Stopping", () => { + it('publishes it while the stopped turn runs, and drops it when the host lets go', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + stateDirectory: join(root, SESSION) + }) + const fence = agentSessionRecordFixture().lease.runtimeFence + const published: AgentSessionStatusSummary[] = [] + const session = indexedStatusFeedSession({ + journal, + child: { phase: 'ready', generation: 'child-1', fence } + }) + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([[SESSION, session]]), + statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), + getRecord: () => agentSessionRecordFixture(), + now: () => 1_000 + }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1_000 }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + feed.publish(SESSION) + expect(published.at(-1)).toMatchObject({ status: 'working' }) + expect(published.at(-1)).not.toHaveProperty('stopping') + + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, fence) + feed.publish(SESSION) + expect(published.at(-1)).toMatchObject({ status: 'working', stopping: true }) + + feed.revokeLive(SESSION) + expect(published.at(-1)).toMatchObject({ status: 'working' }) + expect(published.at(-1)).not.toHaveProperty('stopping') + }) + + it('never says Stopping for a stop that was not a person’s', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + stateDirectory: join(root, SESSION) + }) + const fence = agentSessionRecordFixture().lease.runtimeFence + const published: AgentSessionStatusSummary[] = [] + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), + statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), + getRecord: () => agentSessionRecordFixture(), + now: () => 1_000 + }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1_000 }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + await journal.appendStopEvent({ reason: 'host-stop', turnId: 'turn-1' }, fence) + feed.publish(SESSION) + + expect(published.at(-1)).toMatchObject({ status: 'working' }) + expect(published.at(-1)).not.toHaveProperty('stopping') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 4f717d2c31be..fffad22dac70 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -25,6 +25,7 @@ import { structuredAgentSessionProviderSessionMetadata } from './structured-agen import { structuredStatusChildWork } from './structured-agent-session-status-child-work' import { StructuredAgentSessionJournalProjections, + type StructuredAgentSessionJournalProjection, type StructuredAgentSessionStatusState } from './structured-agent-session-status-journal-projection' import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' @@ -156,7 +157,8 @@ export class StructuredAgentSessionStatusFeed { } } - /** Revoke live execution authority while retaining the last projection for reload history. */ + /** Revoke live execution authority while retaining the last projection for reload history. A + * Stop still ending work is live state too: with the host gone, nothing here is ending it. */ revokeLive(sessionId: string): void { const previous = this.published.get(sessionId) if (!previous) { @@ -166,6 +168,7 @@ export class StructuredAgentSessionStatusFeed { hostExecutionOwned: _hostExecutionOwned, hostExecutionPhase: _hostExecutionPhase, hostExecutionChild: _hostExecutionChild, + stopping: _stopping, ...retained } = previous this.published.set(sessionId, retained) @@ -197,7 +200,7 @@ export class StructuredAgentSessionStatusFeed { const record = this.deps.getRecord(sessionId) const projection = this.projections.read(source, record) this.retireSettledChildrenOnNewTurn(sessionId, session, projection.acceptedSendKey) - const summary = this.summaryFor(sessionId, session, source, record, projection.state) + const summary = this.summaryFor(sessionId, session, source, record, projection) const previous = this.published.get(sessionId) if (previous && structuredStatusSummariesEqual(previous, summary)) { if (!this.ownership.matchesLocation(sessionId, session.params.location)) { @@ -250,7 +253,7 @@ export class StructuredAgentSessionStatusFeed { session: StatusFeedSession, journal: AgentSessionJournal, record: AgentSessionRecord | null, - state: StructuredAgentSessionStatusState + { state, stopping }: Pick ): AgentSessionStatusSummary { const projected = state.summary const providerSession = structuredAgentSessionProviderSessionMetadata(record) @@ -269,6 +272,8 @@ export class StructuredAgentSessionStatusFeed { } : {}), ...projected, + // Only a working session is still being stopped; any other status already ended that work. + ...(stopping && projected.status === 'working' ? { stopping: true as const } : {}), ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts index 04ce86d36bca..b4be6c222ff2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts @@ -5,6 +5,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { newestAcceptedSendKey } from './structured-agent-session-status-child-work' +import { structuredAgentSessionStopping } from './structured-agent-session-stopping' export type StructuredAgentSessionStatusState = ReturnType< typeof projectStructuredAgentSessionStatusState @@ -18,6 +19,8 @@ export type StructuredAgentSessionJournalProjection = { state: StructuredAgentSessionStatusState /** Null for an unreadable journal, which says nothing about the user's turns. */ acceptedSendKey: string | null + /** A person's Stop is still ending the work it stopped (`structuredAgentSessionStopping`). */ + stopping: boolean } export class StructuredAgentSessionJournalProjections { @@ -58,7 +61,8 @@ export class StructuredAgentSessionJournalProjections { ), acceptedSendKey: snapshot ? newestAcceptedSendKey(cursor.epoch, snapshot.submissions ?? []) - : null + : null, + stopping: snapshot ? structuredAgentSessionStopping(journal, snapshot.items) : false } this.byJournal.set(journal, projection) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts index 739d9156c2dc..4abab707b84e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -36,6 +36,7 @@ export function structuredStatusSummariesEqual( a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && a.turnOutcome === b.turnOutcome && + a.stopping === b.stopping && agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && structuredStatusChildrenEqual(a.children, b.children) && agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts new file mode 100644 index 000000000000..766ed4ac8a14 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -0,0 +1,202 @@ +// The host's "Stopping…": published on the session's status from the moment a person's Stop takes +// effect until the work it stopped ends, or the Stop answers that it stopped nothing. Derived from +// the journal on every publish, so it clears by itself. Driven through the real host and its status +// feed, with turn rows named as Codex writes them. + +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +function fence(): number { + return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 +} + +function turnIdentity(turnId: string): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 } +} + +/** Turn `turnId`, opened by send `clientMessageId`, running or ended as the provider cut it. */ +async function turn(turnId: string, clientMessageId: string, state: 'running' | 'interrupted') { + await journal().appendItem( + turnIdentity(turnId), + { + kind: 'turn', + turnId, + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(clientMessageId), + ...(state === 'running' ? { state } : { state, completedAt: Date.now() + 5 }) + }, + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +/** The session's summaries as a session list receives them. */ +function watchStatus(): () => AgentSessionStatusSummary | undefined { + const events: AgentSessionStatusEvent[] = [] + rig.host.subscribeStatus({ id: 'list', emit: (event) => events.push(event) }) + return () => { + for (const event of events.toReversed()) { + if (event.type === 'status' && event.session.sessionId === HOST_TEST_SESSION) { + return event.session + } + if (event.type === 'snapshot') { + return event.sessions.find((session) => session.sessionId === HOST_TEST_SESSION) + } + } + return undefined + } +} + +/** A send whose turn `turn-1` is running, and the session's status. */ +async function runningTurn() { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + const status = watchStatus() + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + return { sent, status } +} + +describe("a person's Stop reads Stopping until the work it stopped ends", () => { + it('reads Stopping once the Stop takes effect, and clears when the turn ends', async () => { + const { sent, status } = await runningTurn() + + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + expect(status()).not.toHaveProperty('stopping') + }) + + it('reads Stopping before the agent answers the interrupt', async () => { + const { status } = await runningTurn() + let answer: (outcome: AgentSessionCancelOutcome) => void = () => undefined + rig.cancelTurn.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) + ) + + const stopped = rig.stop() + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + + answer({ cancelled: true }) + expect(await stopped).toMatchObject({ ok: true }) + expect(status()).toMatchObject({ stopping: true }) + }) + + it.each([ + ['refused', async () => ({ cancelled: false })], + [ + 'left unconfirmed', + async (): Promise => { + throw new Error('the interrupt request timed out') + } + ] + ])( + 'reads Working again once the agent %s the Stop, while its turn runs on', + async (_, cancel) => { + const { status } = await runningTurn() + rig.cancelTurn.mockImplementationOnce(cancel) + + expect(await rig.stop()).toMatchObject({ ok: true }) + + await eventually(() => expect(journal().stopMarks.latest()).not.toBeNull()) + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + } + ) + + it('reads Stopping again when a later press of the same Stop takes', async () => { + const { status } = await runningTurn() + rig.cancelTurn.mockImplementationOnce(async () => ({ cancelled: false })) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(journal().stopMarks.latest()).not.toBeNull()) + const refusedStop = journal().stopMarks.latest() + + expect(await rig.stop()).toMatchObject({ ok: true }) + + // That press wrote no second event: its answer alone tells. + expect(journal().stopMarks.latest()).toEqual(refusedStop) + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + }) + + it('never marks a turn that opened after the stopped one ended', async () => { + const { sent, status } = await runningTurn() + expect(await rig.stop()).toMatchObject({ ok: true }) + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + + const next = await rig.workingSend() + await rig.settleAccepted(next, 'next') + await turn('turn-2', next, 'running') + + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + }) +}) + +describe('a Stop pressed before its send opened a turn', () => { + it('reads Stopping through the turn that send opens, until that turn ends', async () => { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + const status = watchStatus() + + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + await eventually(() => expect(journal().activeTurnId()).toBe('turn-1')) + expect(status()).toMatchObject({ status: 'working', stopping: true }) + + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + expect(status()).not.toHaveProperty('stopping') + }) + + it('reads a send made after the Stop as Working', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + const status = watchStatus() + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(stopped, 'stopped') + await turn('turn-1', stopped, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + + const later = await rig.workingSend() + await rig.settleAccepted(later, 'later') + await turn('turn-2', later, 'running') + + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts new file mode 100644 index 000000000000..15a9ef2ec714 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts @@ -0,0 +1,44 @@ +// Whether a chat reads "Stopping…": a person's Stop took effect and the work it stopped has not +// ended yet. The host derives it on each journal commit from facts the journal already holds — the +// Stop's event, the live turn and the Stop's own answer — so nothing is stored, and it clears when +// the turn ends or the Stop answers that it stopped nothing. Clients only present it. + +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' + +/** A Stop's note saying it did not take: the agent refused it, or it went unconfirmed. */ +function noteSaysStopTookNoEffect(item: AgentJournalRenderItem): boolean { + const { body } = item + if (body.kind !== 'status' || !('failure' in body)) { + return false + } + return body.failure.kind === 'stopRefused' || body.failure.kind === 'cancelUnconfirmed' +} + +/** + * Whether the latest person's Stop is still ending the work it stopped: the live turn it named, or + * with none, the sends it stopped (`personStopDecidesTurn`), unless the newest Stop answer since + * that Stop says it stopped nothing. A later press of the same Stop writes no event, only its + * answer, so the newest answer is what counts. + */ +export function structuredAgentSessionStopping( + journal: Pick, + items: readonly AgentJournalRenderItem[] +): boolean { + const stop = journal.stopMarks.latest() + if (stop === null || !journal.stopMarks.personStopDecides(journal.activeTurnId())) { + return false + } + let answer: AgentJournalRenderItem | undefined + for (const item of items) { + if ( + item.sequence > stop.sequence && + isStructuredAgentSessionStopNote(item.itemId) && + item.sequence >= (answer?.sequence ?? -1) + ) { + answer = item + } + } + return answer === undefined || !noteSaysStopTookNoEffect(answer) +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 85d3e2a5b4bf..de7d5a5a2a9c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -102,6 +102,7 @@ function statusFeed(): StructuredAgentSessionStatusFeed { isReadOnly: false, cursor: () => ({ epoch: 'epoch-status', sequence: 2 }), lastActivityAt: () => 2, + stopMarks: { latest: () => null }, snapshot: () => ({ items: STATUS_ITEMS }) } as unknown as AgentSessionJournal, params: { diff --git a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts index 9116bbb6605d..3b30f932a1e9 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts @@ -82,6 +82,16 @@ beforeEach(() => { }) describe('worktree ps reports structured sessions', () => { + it("lists a person's Stop still ending the turn, and drops it once the host does", () => { + expect(attach([summary({ stopping: true })]).agents[0]).toMatchObject({ + state: 'working', + mainAgent: { state: 'working', stopping: true } + }) + const ended = attach([summary({ stopping: true }), summary({ updatedAt: 1_757_030_401_000 })]) + expect(ended.agents[0]?.state).toBe('working') + expect(ended.agents[0]?.mainAgent).not.toHaveProperty('stopping') + }) + it('a busy structured session is not reported idle', () => { const row = attach([summary()]) expect(row.agents).toHaveLength(1) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index ad5ad98a62a2..9d92644509ec 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ sessionOptionsSnapshot?: SessionOptionDescriptor[] attachDisabled?: boolean sendButtonDisabled?: boolean + isStopping?: boolean autocomplete?: { mode: string; items?: { kind: string; name: string }[] } } | null, modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'unknown', @@ -245,6 +246,23 @@ describe('NativeChatComposer', () => { ) }) + it("disables Stop while a person's Stop is ending the turn, and says so", () => { + const props = { + terminalTabId: 'tab-1', + paneKey: 'tab-1:leaf-1', + targetPtyId: 'pty-1', + agent: 'codex' as const, + isWorking: true, + onStop: vi.fn() + } + const { rerender } = render() + expect(mocks.fieldProps?.sendButtonDisabled).toBe(false) + + rerender() + + expect(mocks.fieldProps).toMatchObject({ sendButtonDisabled: true, isStopping: true }) + }) + it('associates a delayed submit with its optimistic cache entry', () => { const onOptimisticSend = vi.fn(() => 'pending-1') render( diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d9fb9f1ec2d7..3d82188a41e1 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -58,6 +58,7 @@ const NativeChatComposerPane = forwardRef attachment.pending) const sendButtonDisabled = isWorking - ? !hasPty || !onStop + ? !hasPty || !onStop || isStopping : disabled || hasPendingAttachment || (draft.trim() === '' && imageAttachments.length === 0) const { attachExternalPaths, resolveAttachmentOwner } = useNativeChatExternalAttachments({ @@ -372,6 +373,7 @@ const NativeChatComposerPane = forwardRef cleanup()) describe('NativeChatComposerActions', () => { + it("names the Stop control Stopping while a person's Stop is ending the turn", () => { + const props = { + attachDisabled: false, + dictationDisabled: false, + isWorking: true, + isDictating: false, + isDictationHoldMode: false, + onAttach: vi.fn(), + onDictationToggle: vi.fn(), + onDictationHoldStart: vi.fn(), + onDictationHoldEnd: vi.fn(), + onSend: vi.fn(), + sessionOptionsSurface: null, + sessionOptionsSnapshot: [] + } + const { rerender } = render() + expect(screen.getByRole('button', { name: 'Stop the agent' })).toBeTruthy() + + rerender() + + expect(screen.getByRole('button', { name: 'Stopping…' }).hasAttribute('disabled')).toBe(true) + }) + it('places session option pickers immediately beside dictation', () => { render( void @@ -39,6 +41,7 @@ export function NativeChatComposerActions({ dictationDisabled, sendDisabled, isWorking, + isStopping = false, isDictating, isDictationHoldMode, onAttach, @@ -150,7 +153,9 @@ export function NativeChatComposerActions({ data-native-chat-critical-action={isWorking ? 'stop' : undefined} aria-label={ isWorking - ? translate('components.native-chat.stop', 'Stop the agent') + ? isStopping + ? translate('components.native-chat.status.stopping', 'Stopping…') + : translate('components.native-chat.stop', 'Stop the agent') : translate('components.native-chat.composer.send', 'Send') } disabled={sendDisabled} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index ccbd38afe84f..db74158814a4 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -35,6 +35,8 @@ export type NativeChatComposerFieldProps = { imageAttachments: readonly NativeChatComposerImageAttachment[] sendButtonDisabled: boolean isWorking: boolean + /** A person's Stop is ending the turn: the Stop control is disabled and says so. */ + isStopping?: boolean attachDisabled: boolean dictationDisabled: boolean isDictating: boolean @@ -109,6 +111,7 @@ export function NativeChatComposerField({ imageAttachments, sendButtonDisabled, isWorking, + isStopping = false, attachDisabled, dictationDisabled, isDictating, @@ -286,6 +289,7 @@ export function NativeChatComposerField({ dictationDisabled={dictationDisabled} sendDisabled={sendButtonDisabled} isWorking={isWorking} + isStopping={isStopping} isDictating={isDictating} isDictationHoldMode={isDictationHoldMode} onAttach={onAttach} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index efe1c54dd014..160ee088dd8f 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -83,6 +83,7 @@ export function NativeChatMessageList({ deliveryNotices, awaitingInput = null, turnActivity, + stopping = false, runtimeContext }: { session: NativeChatLiveSession @@ -109,6 +110,8 @@ export function NativeChatMessageList({ /** Set while the turn waits on the reader; the live activity line yields to it. */ awaitingInput?: NativeChatAwaitingInput | null turnActivity?: NativeChatTurnActivity | null + /** A person's Stop is ending the live turn: its tail line reads "Stopping…". */ + stopping?: boolean runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element { const [navigationRequest, setNavigationRequest] = useState( @@ -401,6 +404,7 @@ export function NativeChatMessageList({ ) : tailRow === 'awaiting-input' ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx new file mode 100644 index 000000000000..14a624a9ffe6 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusEvent } from '../../../../shared/agent-session-wire' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) +const hostStatus = vi.hoisted(() => ({ + emit: null as ((event: AgentSessionStatusEvent) => void) | null +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + ...moduleFactories.structuredAgentSessionClient(), + subscribeStructuredAgentSessionStatus: async ( + _target: unknown, + onEvent: (event: AgentSessionStatusEvent) => void + ) => { + hostStatus.emit = onEvent + return { unsubscribe: () => {} } + } +})) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +function renderPane(): void { + render( + + ) +} + +/** The host's summary for the pane's session, as its status feed publishes it. */ +function hostSays(stopping: boolean): void { + act(() => + hostStatus.emit?.({ + type: 'status', + session: { + sessionId: 'session-1', + workspaceId: 'wt-1', + agent: 'codex', + status: 'working', + latestPrompt: 'work on this', + updatedAt: Date.now(), + ...(stopping ? { stopping: true as const } : {}) + } + }) + ) +} + +describe("the chat pane while a person's Stop ends the turn", () => { + afterEach(() => { + hostSays(false) + cleanup() + resetStructuredSessionMocks() + }) + + it("reads Stopping from the host's word, and a second Stop sends nothing", async () => { + mocks.turnId = 'turn-1' + mocks.isWorking = true + renderPane() + await waitFor(() => expect(hostStatus.emit).not.toBeNull()) + expect(mocks.composerProps).toMatchObject({ isWorking: true, isStopping: false }) + + hostSays(true) + + await waitFor(() => expect(mocks.composerProps).toMatchObject({ isStopping: true })) + expect(mocks.messageListProps).toMatchObject({ stopping: true }) + mocks.composerProps?.onStop?.() + expect(mocks.stop).not.toHaveBeenCalled() + }) + + it('reads Stopping from its own press until the host has answered', () => { + mocks.turnId = 'turn-1' + mocks.stopPressed = true + renderPane() + + expect(mocks.composerProps).toMatchObject({ isStopping: true }) + expect(mocks.messageListProps).toMatchObject({ stopping: true }) + }) + + it('stops normally while nothing is stopping', () => { + mocks.turnId = 'turn-1' + renderPane() + + expect(mocks.composerProps).toMatchObject({ isStopping: false }) + mocks.composerProps?.onStop?.() + expect(mocks.stop).toHaveBeenCalledOnce() + }) + + it('reads nothing once the chat has nothing left to stop', async () => { + renderPane() + await waitFor(() => expect(hostStatus.emit).not.toBeNull()) + + hostSays(true) + + expect(mocks.composerProps).toMatchObject({ isStopping: false }) + expect(mocks.messageListProps).toMatchObject({ stopping: false }) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 6e39ec0129de..23251ae62715 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -55,6 +55,7 @@ type StructuredSessionMessageListProps = { onLinkClick?: (...args: unknown[]) => void awaitingInput?: 'shown' | 'unshown' | null isWorking?: boolean + stopping?: boolean runtimeContext?: unknown session?: { hasMore: boolean; loadingEarlier: boolean; loadEarlier: () => Promise } deliveryNotices?: ReadonlyMap @@ -86,6 +87,7 @@ export function createStructuredSessionMocks() { launchSeed?: NativeChatLaunchSeed structuredTransport?: Record isWorking?: boolean + isStopping?: boolean onStop?: () => void }>(), approvalCardProps: initialApprovalCardProps, @@ -104,6 +106,7 @@ export function createStructuredSessionMocks() { turnId: null as string | null, // Unset: Stop follows the turn, as against an older host. canStop: nullable(), + stopPressed: false, supportsBackgroundTaskStop: false, supportsBackgroundTaskStopAll: true, backgroundTasks: [] as AgentSessionBackgroundTask[], @@ -189,6 +192,7 @@ export function createStructuredSessionMocks() { }, turnId: mocks.turnId, canStop: mocks.canStop ?? mocks.turnId !== null, + stopPressed: mocks.stopPressed, stop: mocks.stop, queuedMessages: { cards: mocks.queuedCards, @@ -322,6 +326,7 @@ export function createStructuredSessionMocks() { mocks.isWorking = false mocks.turnId = null mocks.canStop = null + mocks.stopPressed = false mocks.supportsBackgroundTaskStop = false mocks.supportsBackgroundTaskStopAll = true mocks.stopBackgroundTask.mockReset() diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 60041265bc14..ecf2f82e6307 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -59,6 +59,8 @@ export function NativeChatStructuredSession( transportEnabled: provisionalLaunch.transportEnabled, ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) }) + // The host's word, bridged by this client's own press until its Stop event lands. + const stopping = controller.canStop && (hostExecution.stopping || controller.stopPressed) const launchDraftSignal = useNativeChatLaunchDraftSignal({ terminalTabId: props.tabId, agent: props.agent, @@ -291,6 +293,7 @@ export function NativeChatStructuredSession( settledTurns={controller.settledTurns} awaitingInput={prompt === null ? null : 'shown'} turnActivity={controller.turnActivity} + stopping={stopping} onLinkClick={onLinkClick} allowFileUriLinks={onLinkClick !== undefined} runtimeContext={imageRuntimeContext} @@ -393,7 +396,9 @@ export function NativeChatStructuredSession( agent={props.agent} canSend={!prompt} isWorking={controller.canStop} - onStop={() => void controller.stop()} + isStopping={stopping} + // A second Stop while one is ending the turn sends nothing. + onStop={() => void (stopping || controller.stop())} steerQueued={controller.queuedMessages.steerNewest} structuredTransport={structuredTransport} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} diff --git a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx new file mode 100644 index 000000000000..64c14f121483 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx @@ -0,0 +1,20 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { NativeChatTurnActivityLine } from './NativeChatTurnActivityLine' + +afterEach(() => cleanup()) + +describe('NativeChatTurnActivityLine', () => { + it("reads Stopping over the provider's activity while a person's Stop ends the turn", () => { + const activity = { kind: 'description', text: 'Running pnpm test' } as const + const { rerender } = render() + expect(screen.getByText('Running pnpm test')).toBeTruthy() + + rerender() + + expect(screen.getByText('Stopping…')).toBeTruthy() + expect(screen.queryByText('Running pnpm test')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx index 3813ff8d2038..7b38fd41493e 100644 --- a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx @@ -1,25 +1,41 @@ import { Loader2 } from 'lucide-react' import { translate } from '@/i18n/i18n' import type { NativeChatTurnActivity } from '../../../../shared/native-chat-turn-activity' -import { describeNativeChatActiveTurnLabel } from '../../../../shared/native-chat-turn-status' +import { + describeNativeChatActiveTurnLabel, + type NativeChatActiveTurnLabel +} from '../../../../shared/native-chat-turn-status' + +// Literal keys with literal fallbacks: a dynamic key registers no catalog reference. +function statusLabel(key: Extract['key']): string { + switch (key) { + case 'thinking': + return translate('components.native-chat.status.thinking', 'Thinking') + case 'stopping': + return translate('components.native-chat.status.stopping', 'Stopping…') + case 'working': + return translate('components.native-chat.status.working', 'Working…') + } +} /** The live turn's tail line: a spinner plus what the turn is doing right now — - * the provider's activity text, else that it is reasoning, else plain "Working…". - * The clock lives in the turn bar under the user's message, not here. */ + * "Stopping…" once the person's Stop is ending it, else the provider's activity text, else + * that it is reasoning, else plain "Working…". The clock lives in the turn bar, not here. */ export function NativeChatTurnActivityLine({ activity, - thinking + thinking, + stopping = false }: { activity?: NativeChatTurnActivity | null thinking: boolean + stopping?: boolean }): React.JSX.Element { - const resolved = describeNativeChatActiveTurnLabel({ activityText: activity?.text, thinking }) - const label = - resolved.source === 'activity' - ? resolved.text - : resolved.key === 'thinking' - ? translate('components.native-chat.status.thinking', 'Thinking') - : translate('components.native-chat.status.working', 'Working…') + const resolved = describeNativeChatActiveTurnLabel({ + activityText: activity?.text, + thinking, + stopping + }) + const label = resolved.source === 'activity' ? resolved.text : statusLabel(resolved.key) return (
{ ) expect(executions.at(-1)).toEqual({ phase: 'starting', - childKey: 'child-2' + childKey: 'child-2', + stopping: false }) expect(executions.some(({ phase }) => phase === 'starting')).toBe(true) }) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 72669bf936c0..0941dabdd6e3 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -57,13 +57,15 @@ export function useStructuredAgentSessionStatusSummary( return { summary, observation } } -/** The host's child state, projected to stable primitives so journal updates do not re-render chat. */ +/** The host's child state, projected to stable primitives so journal updates do not re-render chat. + * `stopping`: the host says a person's Stop is still ending the work; an older host never does. */ export function useStructuredAgentSessionHostExecution( sessionId: string, target: RuntimeClientTarget ): { phase: NonNullable | null childKey: string | number | null + stopping: boolean } { const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) useEffect(() => feed.activate(), [feed]) @@ -80,7 +82,12 @@ export function useStructuredAgentSessionHostExecution( }, () => null ) - return { phase, childKey } + const stopping = useSyncExternalStore( + feed.subscribe, + () => feed.getSnapshot().get(sessionId)?.stopping === true, + () => false + ) + return { phase, childKey, stopping } } /** The host's child records for the row, and the legacy roster readers of `subagents` keep. A host @@ -121,7 +128,8 @@ function projectStatus( const agentStatus = structuredAgentSessionAgentStatus({ status: summary.status, childWork: children ?? summary.backgroundTasks, - turnOutcome: summary.turnOutcome + turnOutcome: summary.turnOutcome, + ...(summary.stopping ? { stopping: summary.stopping } : {}) }) const current = store.agentStatusByPaneKey?.[paneKey] // Same continuity rule as the host ingest, on the main agent's own clock. diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 922d16924f3f..df6ccf5f7535 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -57,6 +57,8 @@ export type NativeChatComposerProps = { canSend?: boolean /** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */ isWorking?: boolean + /** A person's Stop is ending the turn: the Stop control is disabled and says so. */ + isStopping?: boolean /** Interrupt the hosted agent, usually by sending ESC into the PTY. */ onStop?: () => void /** Render an optimistic echo until the real transcript turn lands. */ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.test.tsx new file mode 100644 index 000000000000..e4fc26e36bd4 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { useStructuredAgentSessionStopPress } from './use-structured-agent-session-stop-press' + +afterEach(() => cleanup()) + +function deferred() { + let resolve: (value: T) => void = () => undefined + let reject: (error: unknown) => void = () => undefined + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('a Stop press read as Stopping', () => { + it('holds while the request is in flight and clears when it answers', async () => { + const { result } = renderHook(() => useStructuredAgentSessionStopPress('session-1')) + const answer = deferred() + + let tracked: Promise = Promise.resolve(null) + act(() => { + tracked = result.current.track(() => answer.promise) + }) + expect(result.current.pressed).toBe(true) + + await act(async () => { + answer.resolve(null) + await tracked + }) + expect(result.current.pressed).toBe(false) + }) + + it('clears when the request fails', async () => { + const { result } = renderHook(() => useStructuredAgentSessionStopPress('session-1')) + const answer = deferred() + + let tracked: Promise = Promise.resolve(null) + act(() => { + tracked = result.current.track(() => answer.promise) + }) + await act(async () => { + answer.reject(new Error('transport closed')) + await tracked.catch(() => undefined) + }) + + expect(result.current.pressed).toBe(false) + }) + + it("never reads another session's press", () => { + const { result, rerender } = renderHook( + ({ sessionId }) => useStructuredAgentSessionStopPress(sessionId), + { initialProps: { sessionId: 'session-1' } } + ) + act(() => { + void result.current.track(() => new Promise(() => undefined)) + }) + + rerender({ sessionId: 'session-2' }) + + expect(result.current.pressed).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.ts new file mode 100644 index 000000000000..fb60493da0ff --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-stop-press.ts @@ -0,0 +1,32 @@ +import { useCallback, useRef, useState } from 'react' + +/** + * The person's own Stop press, read as "Stopping…" until its request answers, so the control + * flips on click while the host's Stop event is still on its way. Presentation only: never + * stored or sent. Once it answers, the host's own `stopping` is what the chat shows. + */ +export function useStructuredAgentSessionStopPress(sessionId: string): { + pressed: boolean + track: (stop: () => Promise) => Promise +} { + const [press, setPress] = useState<{ sessionId: string; token: symbol } | null>(null) + const latest = useRef(null) + const track = useCallback( + async (stop: () => Promise): Promise => { + const token = Symbol('stop-press') + latest.current = token + setPress({ sessionId, token }) + try { + return await stop() + } finally { + // Answered or failed alike: a failed request's notice is the existing one. + if (latest.current === token) { + latest.current = null + setPress(null) + } + } + }, + [sessionId] + ) + return { pressed: press?.sessionId === sessionId, track } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index d63a5263baa3..9a295bd267f7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -28,6 +28,7 @@ import { } from './structured-agent-session-message-projection' import { useStructuredAgentSessionMessages } from './use-structured-agent-session-messages' import { useStructuredAgentSessionTransportState } from './use-structured-agent-session-transport-state' +import { useStructuredAgentSessionStopPress } from './use-structured-agent-session-stop-press' import { useStructuredAgentSessionTransport } from './use-structured-agent-session-transport' import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch' @@ -142,6 +143,7 @@ export function useStructuredAgentSession(args: { enabled: providerVisible }) + const stopPress = useStructuredAgentSessionStopPress(sessionId) const prompts = pendingStructuredSessionPrompts(transportState.journalItems) const { outbox } = outboxController // A host that takes a Stop naming no turn gets Stop from the send until the work settles; every @@ -229,16 +231,19 @@ export function useStructuredAgentSession(args: { backgroundTasks: transportState.backgroundTasks, turnId: transportState.turnId, canStop, + /** This client's Stop request is in flight. */ + stopPressed: stopPress.pressed, stop: () => { if (stopsConversation) { // Unsent text this client still owns goes back to its composer — a local move. // Host-held drafts are never withdrawn by a Stop: the host pauses them and // they stay visible as cards, on every device, until the user acts on one. outboxController.withdrawUnsent() - return mutate('agentSession.cancel', 'agentSession.cancel', {}) + return stopPress.track(() => mutate('agentSession.cancel', 'agentSession.cancel', {})) } - return transportState.turnId - ? mutate('agentSession.cancel', 'agentSession.cancel', { turnId: transportState.turnId }) + const turnId = transportState.turnId + return turnId + ? stopPress.track(() => mutate('agentSession.cancel', 'agentSession.cancel', { turnId })) : Promise.resolve(null) }, queuedMessages: queuedController, diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts new file mode 100644 index 000000000000..4fe1f2ad1213 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { makeTab } from '../../store/slices/store-test-helpers' +import { buildWorktreeAgentRows } from './worktree-agent-rows' +import { getAgentDotState } from './worktree-card-agent-summary' +import { getCompactAgentSecondary } from './worktree-card-compact-agent-row' + +const NOW = new Date('2026-05-04T12:00:00.000Z').getTime() +const TAB_ID = 'tab-1' + +function row(mainAgent: AgentStatusEntry['mainAgent']) { + const [agent] = buildWorktreeAgentRows({ + tabs: [makeTab({ id: TAB_ID, worktreeId: 'wt-1' })], + entries: [ + { + paneKey: `${TAB_ID}:11111111-1111-4111-8111-111111111111`, + state: 'working', + prompt: 'run the long build', + toolName: 'Bash', + toolInput: 'pnpm test', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + agentType: 'claude', + mainAgent + } + ], + retained: [], + ptyIdsByTabId: { [TAB_ID]: ['pty-1'] }, + now: NOW + }) + if (!agent) { + throw new Error('expected one agent row') + } + return agent +} + +describe("the sidebar row while a person's Stop ends the turn", () => { + it('says Stopping in place of the last tool line, and keeps the working spinner', () => { + const stopping = row({ state: 'working', stopping: true, stateStartedAt: NOW }) + + expect(getCompactAgentSecondary(stopping, NOW)).toBe('Stopping…') + expect(getAgentDotState(stopping)).toBe('working') + }) + + it('shows the tool line otherwise', () => { + expect(getCompactAgentSecondary(row({ state: 'working', stateStartedAt: NOW }), NOW)).not.toBe( + 'Stopping…' + ) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index 4f3a34a2b05a..9462235e5595 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -15,6 +15,7 @@ import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timesta import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer' import { formatShortTimeAgo } from '@/lib/short-time-ago' import { agentVerdictStatusLine } from '@/lib/agent-verdict-status-line' +import { translate } from '@/i18n/i18n' function getCompactAgentPrimary( agent: DashboardAgentRowData, @@ -38,6 +39,10 @@ export function getCompactAgentSecondary( if (agent.state === 'unverifiable') { return agentNoUpdateLabel(agent.entry, now) } + // Why: a person's Stop is ending the turn, so its last tool line no longer says what comes next. + if (agent.state === 'working' && agent.entry.mainAgent?.stopping) { + return translate('components.native-chat.status.stopping', 'Stopping…') + } // Why: the lead turn is over in monitoring, so its last tool line is stale; name the state instead. if (agent.state === 'working' && agent.entry.workingMode === 'monitoring') { return agentStateLabel('monitoring') diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d8ecc0df0040..c10d4da19ea9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17757,6 +17757,7 @@ "status": { "responding": "Agent is responding", "working": "Working…", + "stopping": "Stopping…", "thinking": "Thinking", "workingFor": "Working for {{value0}}", "workedFor": "Worked for {{value0}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5ab6bbaaa5dc..3f227885f6f2 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15083,6 +15083,7 @@ }, "status": { "responding": "El agente está respondiendo", + "stopping": "Deteniendo…", "interruptedAfter": "Interrumpido tras {{value0}}", "failedAfter": "Falló tras {{value0}}" }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 6465f23c87ec..dd26767652cd 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17632,6 +17632,7 @@ "status": { "responding": "L'agent répond", "working": "Fonctionnement…", + "stopping": "Arrêt en cours…", "thinking": "Réflexion", "workingFor": "En cours depuis {{value0}}", "workedFor": "A travaillé pendant {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4af40935b246..1dc571800630 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17568,6 +17568,7 @@ "status": { "responding": "Agent が応答中です", "working": "処理中…", + "stopping": "停止中…", "thinking": "考え", "workingFor": "{{value0}} 処理中", "workedFor": "{{value0}} で完了", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a72fbaa9eaa6..0263f2680b29 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17568,6 +17568,7 @@ "status": { "responding": "Agent이 응답 중입니다.", "working": "일하고 있는…", + "stopping": "중지하는 중…", "thinking": "생각", "workingFor": "{{value0}} 작업 중", "workedFor": "{{value0}} 작업 완료", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cf73cbb7d8ff..a678ae1c0f98 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17533,6 +17533,7 @@ "status": { "responding": "智能体正在回复", "working": "处理中…", + "stopping": "正在停止…", "thinking": "思维", "workingFor": "已工作 {{value0}}", "workedFor": "工作了 {{value0}}", diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts index e192bddae52e..276d7b69f7a4 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts @@ -88,6 +88,27 @@ describe('structured status feed execution authority lifecycle', () => { expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBe(true) }) + it('drops a Stop the host was ending once contact is lost, owned or not', async () => { + const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) + feed.activate() + await vi.advanceTimersByTimeAsync(0) + const unowned = { ...owned, sessionId: 'unowned', hostExecutionOwned: undefined } + subscription().emit({ + type: 'snapshot', + sessions: [ + { ...owned, stopping: true }, + { ...unowned, stopping: true } + ] + }) + expect(feed.getSnapshot().get('running')?.stopping).toBe(true) + + subscription().emit({ type: 'end' }) + + expect(feed.getSnapshot().get('running')).not.toHaveProperty('stopping') + expect(feed.getSnapshot().get('unowned')).not.toHaveProperty('stopping') + expect(feed.getSnapshot().get('unowned')?.status).toBe('working') + }) + it('retains history without ownership while stopped and until remount receives fresh evidence', async () => { const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) const deactivate = feed.activate() diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index 7e4d7f27eb8e..4f678367ea39 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -86,10 +86,11 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { handle?.unsubscribe() handle = null } + // A Stop the host was ending is its live state too: with contact lost, no Stopping outlives it. const revokeSnapshotOwnership = (): void => { let next: Map | null = null for (const [sessionId, summary] of snapshot) { - if (!summary.hostExecutionOwned) { + if (!summary.hostExecutionOwned && !summary.stopping) { continue } if (!next) { @@ -99,6 +100,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { hostExecutionOwned: _owned, hostExecutionPhase: _phase, hostExecutionChild: _child, + stopping: _stopping, ...retained } = summary next.set(sessionId, retained) diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index 854fea121dce..38920f5e1f92 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -84,6 +84,7 @@ export function continueMainAgentStatus( next: { state: AgentStatusState outcome?: AgentMainAgentStatus['outcome'] + stopping?: true stateStartedAt?: number }, now: number @@ -94,6 +95,7 @@ export function continueMainAgentStatus( return { state: next.state, ...(next.state === 'done' && next.outcome ? { outcome: next.outcome } : {}), + ...(next.state === 'working' && next.stopping ? { stopping: true as const } : {}), stateStartedAt } } diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 3100087faa11..3f795055de2f 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -245,6 +245,10 @@ export type AgentSessionStatusSummary = { * UNKNOWN, never success. Optional for mixed-version hosts; an older client reads an arm it * does not know as no verdict. The agent-status row publishes it as `mainAgent.outcome`. */ turnOutcome?: AgentTurnOutcome + /** Present only while `status` is 'working' and a person's Stop is still ending that work: it + * clears when the turn ends or the Stop answers that it stopped nothing. Derived by the host on + * each publish, never stored. Absent from older hosts; an older client ignores it. */ + stopping?: true /** Live provider-owned background tasks, so session lists can render * subagent children without holding a journal reader open. Optional for * mixed-version hosts. Derived from `children` on hosts that publish it. */ diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 18ec79ac74c0..e8a7d3ce4279 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -292,6 +292,7 @@ export function normalizeMainAgentStatusField(value: unknown): AgentMainAgentSta state, // Why: a verdict belongs to a finished turn; anything riding on a live state is stale. ...(state === 'done' && isAgentTurnOutcome(obj.outcome) ? { outcome: obj.outcome } : {}), + ...(state === 'working' && obj.stopping === true ? { stopping: true as const } : {}), stateStartedAt: obj.stateStartedAt } } diff --git a/src/shared/main-agent-status.ts b/src/shared/main-agent-status.ts index 88518130cc84..4d9467e6dbe3 100644 --- a/src/shared/main-agent-status.ts +++ b/src/shared/main-agent-status.ts @@ -13,6 +13,9 @@ export type AgentMainAgentStatus = { * new turn clears it. ABSENT MEANS UNKNOWN — a plain Stop never infers `success`, because an * older provider that omits its interrupt flag would turn a cancel into a false success. */ outcome?: AgentTurnOutcome + /** Present only while `state` is `working`: a person's Stop is ending this turn. Published by a + * structured session's host; readers present it ("Stopping…") and never store it. */ + stopping?: true /** When the main agent's own `state` first appeared (ms). The row's `stateStartedAt` dates the * combined state instead, so the two differ while child work holds the row open. */ stateStartedAt: number @@ -28,5 +31,10 @@ export function mainAgentStatusEqual( if (!a || !b) { return false } - return a.state === b.state && a.outcome === b.outcome && a.stateStartedAt === b.stateStartedAt + return ( + a.state === b.state && + a.outcome === b.outcome && + a.stopping === b.stopping && + a.stateStartedAt === b.stateStartedAt + ) } diff --git a/src/shared/native-chat-turn-status.test.ts b/src/shared/native-chat-turn-status.test.ts index 3b70e57bdafb..0f05af9ad392 100644 --- a/src/shared/native-chat-turn-status.test.ts +++ b/src/shared/native-chat-turn-status.test.ts @@ -87,6 +87,16 @@ describe('describeNativeChatActiveTurnLabel', () => { key: 'working' }) }) + + it("says Stopping over the provider's activity once a person's Stop is ending the turn", () => { + expect( + describeNativeChatActiveTurnLabel({ + activityText: 'Running pnpm test', + thinking: true, + stopping: true + }) + ).toEqual({ source: 'status', key: 'stopping' }) + }) }) describe('formatNativeChatActiveTurnLabel', () => { @@ -96,6 +106,7 @@ describe('formatNativeChatActiveTurnLabel', () => { ).toBe('Running pnpm test') expect(formatNativeChatActiveTurnLabel({ thinking: true })).toBe('Thinking') expect(formatNativeChatActiveTurnLabel({ thinking: false })).toBe('Working…') + expect(formatNativeChatActiveTurnLabel({ thinking: false, stopping: true })).toBe('Stopping…') }) }) diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index f1dbea00947d..a7fc03a14a61 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -9,6 +9,7 @@ import type { AgentTurnOutcome } from './agent-turn-outcome' export const NATIVE_CHAT_TURN_STATUS_COPY = { thinking: 'Thinking', working: 'Working…', + stopping: 'Stopping…', workingFor: 'Working for {{value0}}', workedFor: 'Worked for {{value0}}', interruptedAfter: 'Interrupted after {{value0}}', @@ -75,22 +76,30 @@ function settledTurnStatusKey( export type NativeChatLiveTurnIndicator = { thinking: boolean activityText: string | null + /** A person's Stop is ending the turn. */ + stopping?: boolean } export type NativeChatActiveTurnLabel = | { source: 'activity'; text: string } - | { source: 'status'; key: 'thinking' | 'working' } + | { source: 'status'; key: 'thinking' | 'working' | 'stopping' } -/** The live tail line's label. Provider activity wins because it is the only text - * that says what the turn is actually doing; reasoning is next. It never carries - * the clock — the turn bar owns that. Shared so desktop and mobile cannot disagree. */ +/** The live tail line's label. The person's own Stop wins: whatever the turn was doing, it is + * now ending. Provider activity is next because it is the only text that says what the turn is + * actually doing; reasoning is next. It never carries the clock — the turn bar owns that. + * Shared so desktop and mobile cannot disagree. */ export function describeNativeChatActiveTurnLabel({ activityText, - thinking + thinking, + stopping = false }: { activityText?: string | null thinking: boolean + stopping?: boolean }): NativeChatActiveTurnLabel { + if (stopping) { + return { source: 'status', key: 'stopping' } + } const text = activityText?.trim() if (text) { return { source: 'activity', text } @@ -102,6 +111,7 @@ export function describeNativeChatActiveTurnLabel({ export function formatNativeChatActiveTurnLabel(input: { activityText?: string | null thinking: boolean + stopping?: boolean }): string { const label = describeNativeChatActiveTurnLabel(input) return label.source === 'activity' ? label.text : NATIVE_CHAT_TURN_STATUS_COPY[label.key] diff --git a/src/shared/structured-agent-session-agent-status.test.ts b/src/shared/structured-agent-session-agent-status.test.ts index 43bae879867e..70529f10f79e 100644 --- a/src/shared/structured-agent-session-agent-status.test.ts +++ b/src/shared/structured-agent-session-agent-status.test.ts @@ -109,4 +109,15 @@ describe('structuredAgentSessionAgentStatus', () => { structuredAgentSessionAgentStatus({ status: 'working', turnOutcome: 'failure' }) ).toEqual({ state: 'working', mainAgent: { state: 'working' } }) }) + + it("carries the host's Stopping on a working main agent only", () => { + expect(structuredAgentSessionAgentStatus({ status: 'working', stopping: true })).toEqual({ + state: 'working', + mainAgent: { state: 'working', stopping: true } + }) + expect(structuredAgentSessionAgentStatus({ status: 'idle', stopping: true })).toEqual({ + state: 'done', + mainAgent: { state: 'done' } + }) + }) }) diff --git a/src/shared/structured-agent-session-agent-status.ts b/src/shared/structured-agent-session-agent-status.ts index 9e424c621657..f00f2f7f2ad7 100644 --- a/src/shared/structured-agent-session-agent-status.ts +++ b/src/shared/structured-agent-session-agent-status.ts @@ -28,7 +28,7 @@ function structuredAgentSessionLeadState( * `childWork` is the host's child records (or their views); only an older host's summary, which * publishes none, is read by its live background tasks. */ export function structuredAgentSessionAgentStatus( - summary: Pick & { + summary: Pick & { status: StructuredAgentSessionProjectedStatus childWork?: readonly AgentChildWorkLivenessCandidate[] } @@ -43,7 +43,8 @@ export function structuredAgentSessionAgentStatus( ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), mainAgent: { state: leadState, - ...(leadState === 'done' && summary.turnOutcome ? { outcome: summary.turnOutcome } : {}) + ...(leadState === 'done' && summary.turnOutcome ? { outcome: summary.turnOutcome } : {}), + ...(leadState === 'working' && summary.stopping ? { stopping: true as const } : {}) } } } From 42964ada7c8f8d005af3ed0920a275da2e675337 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:19:17 -0700 Subject: [PATCH 058/182] test(native-chat): pin Stopping on the phone and across mixed versions --- .../MobileNativeChatTurnStatus.test.ts | 11 +- .../src/session/MobileNativeChatView.test.ts | 13 ++ .../MobileNativeChatView.turn-status.test.ts | 2 +- .../use-mobile-structured-stop-press.test.ts | 59 ++++++ ...-mobile-structured-turn-indicator.test.tsx | 10 +- mobile/src/worktree/agent-row-display.test.ts | 9 + .../cross-version-stopping.unit.test.ts | 184 ++++++++++++++++++ 7 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 mobile/src/session/use-mobile-structured-stop-press.test.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index d2069a134b24..191a11cd2ee5 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -109,7 +109,11 @@ describe('MobileNativeChatTurnStatus', () => { }) describe('MobileNativeChatTurnActivity', () => { - function render(props: { thinking: boolean; activityText?: string | null }): ReactTestRenderer { + function render(props: { + thinking: boolean + activityText?: string | null + stopping?: boolean + }): ReactTestRenderer { act(() => { renderer = create(createElement(MobileNativeChatTurnActivity, props)) }) @@ -135,6 +139,11 @@ describe('MobileNativeChatTurnActivity', () => { expect(spinners(tree.root)).toHaveLength(1) }) + it("reads Stopping over provider activity once a person's Stop is ending the turn", () => { + const tree = render({ thinking: false, activityText: 'Running pnpm test', stopping: true }) + expect(labels(tree.root)).toEqual(['Stopping…']) + }) + it('announces the live line to assistive tech', () => { const tree = render({ thinking: true }) const row = tree.root.findByType('View' as never) diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index d224c2e34a0e..7dc3514d9992 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -147,6 +147,19 @@ describe('MobileNativeChatView', () => { }) } + it("disables Stop and says Stopping while this phone's Stop is ending the turn", async () => { + await render({ + structuredActivityUi: true, + agentWorking: true, + canStop: true, + turnIndicator: { thinking: false, activityText: null, stopping: true } + }) + const stop = renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Stopping…' + ) + expect(stop.props.disabled).toBe(true) + }) + /** Ids of the rows the list is currently rendering. */ it('keeps Stop hidden during a structured dispatch until a provider turn can be cancelled', async () => { const props = { structuredActivityUi: true, agentWorking: true, canStop: false } diff --git a/mobile/src/session/MobileNativeChatView.turn-status.test.ts b/mobile/src/session/MobileNativeChatView.turn-status.test.ts index 1f8191df80b8..1903a71d60f2 100644 --- a/mobile/src/session/MobileNativeChatView.turn-status.test.ts +++ b/mobile/src/session/MobileNativeChatView.turn-status.test.ts @@ -197,7 +197,7 @@ describe('MobileNativeChatView', () => { expect(props.structuredActivityUi).toBe(true) expect(props.turnStatus).toMatchObject({ workedSeconds: null }) // Nothing reports reasoning, so the tail line reads plain working instead of guessing. - expect(footerProps()).toEqual({ thinking: false, activityText: null }) + expect(footerProps()).toEqual({ thinking: false, activityText: null, stopping: false }) expect(listIds().at(-1)).toBe('a1') expect(props.activeTurnIsWorking).toBe(true) expect(workingIndicators()).toHaveLength(0) diff --git a/mobile/src/session/use-mobile-structured-stop-press.test.ts b/mobile/src/session/use-mobile-structured-stop-press.test.ts new file mode 100644 index 000000000000..927522041b1f --- /dev/null +++ b/mobile/src/session/use-mobile-structured-stop-press.test.ts @@ -0,0 +1,59 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import { useMobileStructuredStopPress } from './use-mobile-structured-stop-press' + +let renderer: ReactTestRenderer | null = null +let latest: ReturnType | null = null + +function Probe({ sessionKey }: { sessionKey: string }): null { + latest = useMobileStructuredStopPress(sessionKey) + return null +} + +function mount(sessionKey = 'session-1'): ReturnType { + act(() => { + renderer = create(createElement(Probe, { sessionKey })) + }) + return latest! +} + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + latest = null +}) + +describe("the phone's Stop press read as Stopping", () => { + it('holds while the request is in flight and clears when it answers', async () => { + let answer: (value: boolean) => void = () => undefined + let tracked: Promise = Promise.resolve(false) + const press = mount() + act(() => { + tracked = press.track(() => new Promise((resolve) => (answer = resolve))) + }) + expect(latest?.pressed).toBe(true) + + await act(async () => { + answer(true) + await tracked + }) + expect(latest?.pressed).toBe(false) + }) + + it('clears when the request fails', async () => { + let fail: (error: unknown) => void = () => undefined + let tracked: Promise = Promise.resolve(false) + const press = mount() + act(() => { + tracked = press.track(() => new Promise((_, reject) => (fail = reject))) + }) + + await act(async () => { + fail(new Error('socket closed')) + await tracked.catch(() => undefined) + }) + + expect(latest?.pressed).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index 9a0fb4f3a6c6..193c4abd43fe 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -113,7 +113,7 @@ describe('useMobileStructuredAgentSession turn indicator', () => { listener?.(snapshot([runningTurn, reasoning], 3)) }) - expect(hook?.turnIndicator).toEqual({ thinking: true, activityText: null }) + expect(hook?.turnIndicator).toEqual({ thinking: true, activityText: null, stopping: false }) }) it('hands the row the provider copy once real content ends the reasoning', async () => { @@ -142,7 +142,11 @@ describe('useMobileStructuredAgentSession turn indicator', () => { ) }) - expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: 'Updating the plan' }) + expect(hook?.turnIndicator).toEqual({ + thinking: false, + activityText: 'Updating the plan', + stopping: false + }) }) it('never reads a journal status row as the live activity', async () => { @@ -167,6 +171,6 @@ describe('useMobileStructuredAgentSession turn indicator', () => { ) }) - expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: null }) + expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: null, stopping: false }) }) }) diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index c3fa8c84b6de..89fef0a476a7 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -186,6 +186,15 @@ describe('agentDisplayLabel', () => { ).toBe('Monitoring background tasks') }) + it("says Stopping while the host says a person's Stop is ending the turn", () => { + const stopping = row({ + lastAssistantMessage: 'hello there', + mainAgent: { state: 'working', stopping: true, stateStartedAt: 0 } + }) + expect(agentDisplayLabel(stopping, 0)).toBe('Stopping…') + expect(agentDotState(stopping, 0)).toBe('working') + }) + it('falls back to the decayed state label when stale', () => { expect( agentDisplayLabel( diff --git a/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts new file mode 100644 index 000000000000..392c9f8d9526 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts @@ -0,0 +1,184 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { + importReleaseCheckoutModule, + importWorkingTreeModuleCopy, + materializeReleaseCheckout +} from './release-checkout' + +/** + * "Stopping…", paired across two builds. + * + * The host now publishes `stopping: true` on a working session's status summary, and the main + * agent's row carries it as `mainAgent.stopping`. Both are optional fields an older reader must + * ignore (Rule 1): an old desktop folds a new host's summary into a plain working row, and an old + * phone reads a new host's `worktree ps` row as working with its usual line. A new reader of an old + * host, which never publishes either field, must never read Stopping from it. + * + * The pre-change ref is pinned rather than derived: a newer baseline would carry the new reader. + */ +const PRE_CHANGE_REF = 'v1.4.214' +const SUITE_TIMEOUT_MS = 180_000 +const STATUS_FOLD = 'src/shared/structured-agent-session-agent-status.ts' +// Both builds load a copy in the checkout cache, out of reach of `mobile/tsconfig.json`. +const PHONE_ROW_READER = 'mobile/src/worktree/agent-row-display.ts' + +const WORKTREE_ID = 'repo::/worktree' +const NOW = 1_000_000 + +type Summary = Record +type Row = Record +type Fold = (summary: Summary) => { state: string; mainAgent: Record } + +type HostRowModules = { + collectRuntimeWorktreeAgentSources: (args: Record) => unknown + attachRuntimeWorktreeAgentRows: (args: Record) => void +} + +type Build = { + fold: Fold + agentDotState: (row: Row, now: number) => string + agentDisplayLabel: (row: Row, now: number) => string + host: HostRowModules +} + +/** A working session's summary as the new host publishes it, JSON round-tripped. */ +const STOPPING_SUMMARY: Summary = JSON.parse( + JSON.stringify({ + sessionId: 'session-1', + workspaceId: WORKTREE_ID, + agent: 'claude', + status: 'working', + latestPrompt: 'ship it', + updatedAt: NOW, + stopping: true + }) +) + +/** A host's `worktree ps` rows for one structured session, JSON round-tripped. */ +function publishRow(host: HostRowModules, mainAgent: Record): Row { + const summary: Record = { + worktreeId: WORKTREE_ID, + status: 'inactive', + hasHostSidebarActivity: false, + agents: [] + } + const snapshot = { + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + connectionId: null, + structuredHost: 'owned', + state: 'working', + prompt: 'ship it', + lastAssistantMessage: 'running the tests', + agentType: 'claude', + receivedAt: NOW - 1_000, + stateStartedAt: NOW - 60_000, + mainAgent + } + host.attachRuntimeWorktreeAgentRows({ + summaries: new Map([[WORKTREE_ID, summary]]), + pathIndex: { byPath: new Map(), byRealPath: new Map() }, + missingWorktreeIds: new Set(), + workingTerminalEvidenceByWorktreeId: new Map(), + rowSources: host.collectRuntimeWorktreeAgentSources({ + hookSnapshots: [snapshot], + mirroredWorktreeIdByTabId: new Map(), + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIdByTerminalHandle: new Map() + } + }), + orchestrationByPaneKey: null, + getSummary: (map: Map, _paths: unknown, _missing: unknown, id: string) => + map.get(id) ?? null + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse of the rows the host just attached, which are plain objects. + const [row] = JSON.parse(JSON.stringify(summary.agents)) as Row[] + if (!row) { + throw new Error('expected one row') + } + return row +} + +async function loadBuild(ref: string | null): Promise { + if (ref === null) { + const [fold, display, sources, rows] = await Promise.all([ + import('../../../src/shared/structured-agent-session-agent-status'), + importWorkingTreeModuleCopy(PHONE_ROW_READER), + import('../../../src/main/runtime/runtime-worktree-agent-sources'), + import('../../../src/main/runtime/runtime-worktree-agent-rows') + ]) + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this spec drives both builds' fold through one untyped surface. + fold: fold.structuredAgentSessionAgentStatus as unknown as Fold, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import of a copy is typed unknown; this is the current phone's row reader by path. + agentDotState: display.agentDotState as unknown as Build['agentDotState'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above, the current phone's row label. + agentDisplayLabel: display.agentDisplayLabel as unknown as Build['agentDisplayLabel'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed by its module; this spec drives both builds through one untyped surface. + host: { ...sources, ...rows } as unknown as HostRowModules + } + } + const checkout = await materializeReleaseCheckout(ref) + const [fold, display, sources, rows] = await Promise.all([ + importReleaseCheckoutModule(checkout, STATUS_FOLD), + importReleaseCheckoutModule(checkout, PHONE_ROW_READER), + importReleaseCheckoutModule(checkout, 'src/main/runtime/runtime-worktree-agent-sources.ts'), + importReleaseCheckoutModule(checkout, 'src/main/runtime/runtime-worktree-agent-rows.ts') + ]) + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed unknown; this is the old build's summary fold by path. + fold: fold.structuredAgentSessionAgentStatus as unknown as Fold, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed unknown; this is the old phone's row reader by path. + agentDotState: display.agentDotState as unknown as Build['agentDotState'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above, the old phone's row label. + agentDisplayLabel: display.agentDisplayLabel as unknown as Build['agentDisplayLabel'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a dynamic import is typed unknown; these two modules are the old host's row surface by path. + host: { ...sources, ...rows } as unknown as HostRowModules + } +} + +let oldBuild: Build +let newBuild: Build + +beforeAll(async () => { + ;[oldBuild, newBuild] = await Promise.all([loadBuild(PRE_CHANGE_REF), loadBuild(null)]) +}, SUITE_TIMEOUT_MS) + +describe('cross-version Stopping', () => { + it('pairs two real builds that disagree on the field', () => { + // Anti-vacuous-pass oracle: one module resolved twice would make every cell same-version. + expect(oldBuild.fold).not.toBe(newBuild.fold) + expect(newBuild.fold(STOPPING_SUMMARY).mainAgent).toMatchObject({ stopping: true }) + expect(oldBuild.fold(STOPPING_SUMMARY).mainAgent).not.toHaveProperty('stopping') + }) + + it('an OLD desktop folds a new host summary into the plain working row it always drew', () => { + expect(oldBuild.fold(STOPPING_SUMMARY)).toEqual({ + state: 'working', + mainAgent: { state: 'working' } + }) + }) + + it("an OLD phone reads a new host's row as working, with its usual line", () => { + const row = publishRow(newBuild.host, { + state: 'working', + stopping: true, + stateStartedAt: NOW - 60_000 + }) + expect(row.mainAgent).toMatchObject({ stopping: true }) + expect(oldBuild.agentDotState(row, NOW)).toBe('working') + expect(oldBuild.agentDisplayLabel(row, NOW)).toBe('running the tests') + expect(newBuild.agentDisplayLabel(row, NOW)).toBe('Stopping…') + }) + + it('a NEW reader of an old host never reads Stopping, which that host never publishes', () => { + const { stopping: _stopping, ...oldSummary } = STOPPING_SUMMARY + expect(newBuild.fold(oldSummary)).toEqual({ state: 'working', mainAgent: { state: 'working' } }) + const row = publishRow(oldBuild.host, { state: 'working', stateStartedAt: NOW - 60_000 }) + expect(newBuild.agentDotState(row, NOW)).toBe('working') + expect(newBuild.agentDisplayLabel(row, NOW)).toBe('running the tests') + }) +}) From 79f661cefdbc6103b589e76df5d353110786748d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:20:42 -0700 Subject: [PATCH 059/182] test: give touched fake journals and mocks their SAFETY notes --- src/main/agent-hooks/first-work-branch-rename.test.ts | 2 ++ .../rpc/methods/structured-agent-session-rpc.test-fixture.ts | 1 + .../src/components/native-chat/NativeChatComposer.test.tsx | 1 + .../native-chat/NativeChatStructuredSession.stopping.test.tsx | 4 ++-- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 0d6792d4d442..49f17ccbc464 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -103,6 +103,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { // A real journal's sequence only ever advances, so the feed's projection // cache must miss on every publish here: this test is about the rename. let sequence = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the snapshot, Stop marks, activity clock, read-only flag and cursor served here. const journal = { snapshot: () => ({ items }), stopMarks: { latest: () => null }, @@ -191,6 +192,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { const { deps, setDisplayName, setRenameError } = makeDeps({ getRepo: () => ({ id: REPO_ID, kind: 'folder', path: '/workspace/platform' }) as Repo }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the read-only flag, activity clock, Stop marks, cursor and snapshot served here. const journal = { isReadOnly: false, lastActivityAt: () => 1, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index de7d5a5a2a9c..6e2ab9ba1f01 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -98,6 +98,7 @@ function statusFeed(): StructuredAgentSessionStatusFeed { [ STATUS_SESSION, { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the read-only flag, cursor, activity clock, Stop marks and snapshot served here. journal: { isReadOnly: false, cursor: () => ({ epoch: 'epoch-status', sequence: 2 }), diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 9d92644509ec..300ea5e6ca95 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ composerIsComposing: null as (() => boolean) | null, attachmentIsComposing: null as (() => boolean) | null, flushPendingAttachments: vi.fn(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: null until the mocked field renders and records its props. fieldProps: null as { onSend?: () => void onStop?: () => void diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx index 14a624a9ffe6..8762e2fa0c13 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx @@ -7,8 +7,8 @@ import type { AgentSessionStatusEvent } from '../../../../shared/agent-session-w const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() ) -const hostStatus = vi.hoisted(() => ({ - emit: null as ((event: AgentSessionStatusEvent) => void) | null +const hostStatus = vi.hoisted((): { emit: ((event: AgentSessionStatusEvent) => void) | null } => ({ + emit: null })) vi.mock('@/runtime/structured-agent-session-client', () => ({ From c2bd62239db9cf07702dcc3efb6ea852a5209030 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:21:44 -0700 Subject: [PATCH 060/182] test(native-chat): a turn waiting on the person reads attention, never Stopping --- ...agent-session-status-feed-stopping.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts index c0244c86c2d2..3a907d0ada9a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts @@ -68,6 +68,50 @@ describe("the status feed's Stopping", () => { expect(published.at(-1)).not.toHaveProperty('stopping') }) + it('reads a turn waiting on the person as attention, never Stopping', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + stateDirectory: join(root, SESSION) + }) + const fence = agentSessionRecordFixture().lease.runtimeFence + const published: AgentSessionStatusSummary[] = [] + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), + statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), + getRecord: () => agentSessionRecordFixture(), + now: () => 1_000 + }) + const turn = { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1' } as const + await journal.appendItem( + { ...turn, ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1_000 }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, fence) + await journal.appendItem( + { ...turn, ordinal: 1 }, + { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + feed.publish(SESSION) + + expect(published.at(-1)).toMatchObject({ status: 'attention' }) + expect(published.at(-1)).not.toHaveProperty('stopping') + }) + it('never says Stopping for a stop that was not a person’s', async () => { const journal = await journals.open({ identity: { From fbca7ee71e97c5a19b94661205ee6a7acc054f60 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:25:28 -0700 Subject: [PATCH 061/182] test(native-chat): the sidebar row follows Stopping when it is the only field that moved --- ...nt-session-status-bridge-stopping.test.tsx | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx new file mode 100644 index 000000000000..dcad87f389d4 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment happy-dom + +// The sidebar row follows the host's Stopping both ways, even when nothing else on the summary +// moved: a Stop that takes effect, then answers that it stopped nothing, changes only that field. + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../../shared/agent-session-wire' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { Tab } from '../../../../shared/tab-types' +import type { AppState } from '@/store/types' + +type TestStore = { + getState: () => AppState + setState: (state: Partial & { testRuntimeOwner?: string | null }) => void +} + +const mocks = vi.hoisted(() => { + const hoisted: { store: TestStore | null; subscribeStatus: Mock; unsubscribe: Mock } = { + store: null, + subscribeStatus: vi.fn(), + unsubscribe: vi.fn() + } + return hoisted +}) + +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + const useAppStore = createTestStore() + mocks.store = useAppStore + return { useAppStore } +}) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: (state: { testRuntimeOwner?: string | null }) => + state.testRuntimeOwner ?? null +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus +})) + +import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' + +const structuredTab = { + id: 'structured-tab-1', + worktreeId: 'wt-1', + groupId: 'group-1', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' +} satisfies Tab + +function summary(overrides: Partial = {}): AgentSessionStatusSummary { + return { + sessionId: 'session-1', + workspaceId: 'wt-1', + agent: 'codex', + status: 'working', + hostExecutionOwned: true, + latestPrompt: 'work on this', + updatedAt: 5_000, + ...overrides + } +} + +function row(): AgentStatusEntry { + const [entry] = Object.values(mocks.store?.getState().agentStatusByPaneKey ?? {}) + if (!entry) { + throw new Error('status row missing') + } + return entry +} + +describe("the sidebar row's Stopping", () => { + beforeEach(() => { + vi.clearAllMocks() + resetStructuredAgentSessionStatusFeedsForTests() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.store?.setState({ + agentStatusByPaneKey: {}, + testRuntimeOwner: null, + unifiedTabsByWorktree: { 'wt-1': [structuredTab] } + }) + }) + + afterEach(() => { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + }) + + it('takes Stopping on and off when it is the only field that moved', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + const emit: (event: AgentSessionStatusEvent) => void = mocks.subscribeStatus.mock.calls[0]?.[1] + + act(() => emit({ type: 'status', session: summary({ stopping: true }) })) + expect(row().mainAgent).toMatchObject({ state: 'working', stopping: true }) + + act(() => emit({ type: 'status', session: summary() })) + expect(row().state).toBe('working') + expect(row().mainAgent).not.toHaveProperty('stopping') + }) +}) From f4b3942e8e7b068633efce582fd846c0b0b48d34 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:26:22 -0700 Subject: [PATCH 062/182] test(mobile): the phone reads Stopping from its own Stop until the request answers --- ...-mobile-structured-turn-indicator.test.tsx | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index 193c4abd43fe..2e6358cf2890 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent, @@ -149,6 +149,39 @@ describe('useMobileStructuredAgentSession turn indicator', () => { }) }) + it("reads Stopping from this phone's own Stop until its request answers", async () => { + let answer: (value: unknown) => void = () => undefined + const passthrough = sendRequest.getMockImplementation()! + // The Stop's request stays in flight until the test answers it; every other call is as usual. + sendRequest.mockImplementation(((method: string) => + method === 'agentSession.cancel' + ? new Promise((resolve) => (answer = resolve)) + : passthrough(method)) as never) + onTestFinished(() => { + sendRequest.mockImplementation(passthrough) + }) + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).not.toBeNull()) + act(() => { + listener?.(snapshot([runningTurn], 3)) + }) + expect(hook?.turnIndicator.stopping).toBe(false) + + act(() => hook?.cancel()) + expect(hook?.turnIndicator.stopping).toBe(true) + + await act(async () => { + answer({ + ok: true, + result: { ok: true, value: { cancelled: true } }, + _meta: { runtimeId: 'r1' } + }) + }) + await vi.waitFor(() => expect(hook?.turnIndicator.stopping).toBe(false)) + }) + it('never reads a journal status row as the live activity', async () => { act(() => { renderer = create(createElement(Harness)) From 8f32fc2e219612ec7be57c1d4b06334e167a187b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:29:02 -0700 Subject: [PATCH 063/182] test(mobile): type the held Stop request instead of casting it --- ...se-mobile-structured-turn-indicator.test.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index 2e6358cf2890..2e62214c910c 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -150,13 +150,15 @@ describe('useMobileStructuredAgentSession turn indicator', () => { }) it("reads Stopping from this phone's own Stop until its request answers", async () => { - let answer: (value: unknown) => void = () => undefined const passthrough = sendRequest.getMockImplementation()! + type Reply = Awaited> + let answer: (value: Reply) => void = () => undefined // The Stop's request stays in flight until the test answers it; every other call is as usual. - sendRequest.mockImplementation(((method: string) => + sendRequest.mockImplementation((method: string) => method === 'agentSession.cancel' - ? new Promise((resolve) => (answer = resolve)) - : passthrough(method)) as never) + ? new Promise((resolve) => (answer = resolve)) + : passthrough(method) + ) onTestFinished(() => { sendRequest.mockImplementation(passthrough) }) @@ -173,11 +175,8 @@ describe('useMobileStructuredAgentSession turn indicator', () => { expect(hook?.turnIndicator.stopping).toBe(true) await act(async () => { - answer({ - ok: true, - result: { ok: true, value: { cancelled: true } }, - _meta: { runtimeId: 'r1' } - }) + const cancelled = { ok: true, value: { cancelled: true } } + answer({ ok: true, result: cancelled, _meta: { runtimeId: 'r1' } }) }) await vi.waitFor(() => expect(hook?.turnIndicator.stopping).toBe(false)) }) From dd8836dd5908862256600962b09e4e441d6fa70d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:31:17 -0700 Subject: [PATCH 064/182] chore: keep the base lockfile (a local pnpm run rewrote it) --- pnpm-lock.yaml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed9826860897..17332ca0bb19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,9 +6,6 @@ importers: .: configDependencies: {} packageManagerDependencies: - '@pnpm/exe': - specifier: 12.0.0 - version: 12.0.0 pnpm: specifier: 12.0.0 version: 12.0.0 @@ -59,11 +56,6 @@ packages: cpu: [x64] os: [win32] - '@pnpm/exe@12.0.0': - resolution: {integrity: sha512-405vw2qYPPghNxoPRt2cvkGtGNU5gnInDcIe5TztQxe73yUZLqFJN/jCJFGs1xbVaIWDXEVuQ71P8npFZeatZQ==} - engines: {node: '>=18.*'} - hasBin: true - pnpm@12.0.0: resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} engines: {node: '>=18.*'} @@ -95,17 +87,6 @@ snapshots: '@pnpm/exe.win32-x64@12.0.0': optional: true - '@pnpm/exe@12.0.0': - optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.0.0 - '@pnpm/exe.darwin-x64': 12.0.0 - '@pnpm/exe.linux-arm64': 12.0.0 - '@pnpm/exe.linux-arm64-musl': 12.0.0 - '@pnpm/exe.linux-x64': 12.0.0 - '@pnpm/exe.linux-x64-musl': 12.0.0 - '@pnpm/exe.win32-arm64': 12.0.0 - '@pnpm/exe.win32-x64': 12.0.0 - pnpm@12.0.0: optionalDependencies: '@pnpm/exe.darwin-arm64': 12.0.0 From fe5ed911cad8a7c979fb1f703909d9a2d7e758ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:35:13 -0700 Subject: [PATCH 065/182] fix(native-chat): narrow the Stop note's optional failure; type the phone test's reply --- .../src/session/use-mobile-structured-turn-indicator.test.tsx | 3 ++- .../agent-session-wire/structured-agent-session-stopping.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index 2e62214c910c..46c7097dd967 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -52,7 +52,8 @@ describe('useMobileStructuredAgentSession turn indicator', () => { let renderer: ReactTestRenderer | null = null let hook: ReturnType | null = null let listener: ((value: unknown) => void) | null = null - const sendRequest = vi.fn(async (method: string) => ({ + type RpcReply = { ok: boolean; result: unknown; _meta: { runtimeId: string } } + const sendRequest = vi.fn(async (method: string): Promise => ({ ok: true, result: method === 'agentSession.options' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts index 15a9ef2ec714..a0154cf0ab7d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts @@ -13,7 +13,8 @@ function noteSaysStopTookNoEffect(item: AgentJournalRenderItem): boolean { if (body.kind !== 'status' || !('failure' in body)) { return false } - return body.failure.kind === 'stopRefused' || body.failure.kind === 'cancelUnconfirmed' + const kind = body.failure?.kind + return kind === 'stopRefused' || kind === 'cancelUnconfirmed' } /** From b10fb505d3eb2af8bf5074f9996d718d4021c8aa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:40:15 -0700 Subject: [PATCH 066/182] test(native-chat): type the Stop test envelope's fields narrowly --- .../structured-agent-session-claude-stop-turn-end.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts index 1c46f0ffc70c..809abef834bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -97,7 +97,10 @@ function eventually(assertion: () => T | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) } -function envelope(method: 'agentSession.send' | 'agentSession.cancel', fields: object) { +function envelope( + method: 'agentSession.send' | 'agentSession.cancel', + fields: Record +) { return { sessionId: SESSION, clientOperationId: hostTestOperationId(), From 3d107333b71909caeee2321b9b4eac22854ec09b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:05:50 -0700 Subject: [PATCH 067/182] fix(native-chat): a Stop the agent declined, or whose child end failed, says so while the turn runs on A Stop naming the turn that still runs, refused by the agent, now writes the Stop's refused fact instead of 'already finished'. A session-ending Stop whose child end fails while the work runs on revises its note to unconfirmed. --- .../structured-agent-session-chat-stop.ts | 12 ++- .../structured-agent-session-turns-cancel.ts | 75 +++++++++++++++++-- .../structured-agent-session-turns.test.ts | 42 +++++++++-- 3 files changed, 111 insertions(+), 18 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 3a9602dc6026..130ae66b9b11 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -48,7 +48,9 @@ export function mutateWithChatStop( const { envelope, turnId } = params const { sessionId } = envelope // Set by the Stop's step only when its provider's session ends; a replay leaves it unset. - let windDown: StructuredAgentSessionStopWindDown | undefined + let windDown: + | { owed: StructuredAgentSessionStopWindDown; ctx: AgentSessionTurnContext } + | undefined const named = turnId !== undefined ? { turnId } : {} // Its own step wrote the Stop's event first. const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) @@ -98,7 +100,7 @@ export function mutateWithChatStop( ...named, stopChild, endSession: (owed) => { - windDown = owed + windDown = { owed, ctx } }, withdrewQueued: withdrawn.length > 0 } @@ -119,9 +121,11 @@ export function mutateWithChatStop( // Queued in the mutation's own tick, so a send made meanwhile lands behind the child's end. void context.serialize(sessionId, async () => { if (windDown) { + const { owed, ctx } = windDown await endStoppedStructuredAgentSession( - { sessionId, adapter: context.deps.adapter }, - windDown, + { ...ctx, adapter: context.deps.adapter }, + owed, + envelope.clientOperationId, stopChild, (error) => context.deps.onEventSinkError?.({ sessionId, error }) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index cdb70ff41d45..328a7ee4c12b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -2,7 +2,12 @@ import { agentChildWorkStopTargets } from '../../../shared/agent-child-work-stop import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalStatusItem, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' @@ -39,13 +44,18 @@ export type StructuredAgentSessionStopWindDown = { waitsForProvider: boolean; st /** * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent - * meanwhile reaches the child it ends. The Stop has answered: a failure here is reported. The next - * Stop retries the wind-down it leaves owed, and so does the idle sweep: at its next tick once the - * child is proven gone, else only after the chat idles with no child work left. + * meanwhile reaches the child it ends. The Stop has answered: a failure here is reported, and its + * note revised to say the Stop went unconfirmed. The next Stop retries the wind-down it leaves + * owed, and so does the idle sweep: at its next tick once the child is proven gone, else only after + * the chat idles with no child work left. */ export async function endStoppedStructuredAgentSession( - ctx: Pick, + ctx: Pick< + AgentSessionTurnContext, + 'sessionId' | 'adapter' | 'journal' | 'fence' | 'flushStreamedEvents' + >, windDown: StructuredAgentSessionStopWindDown, + clientOperationId: string, stopChild: () => Promise, onError: (error: unknown) => void ): Promise { @@ -56,7 +66,52 @@ export async function endStoppedStructuredAgentSession( await stopChild() } catch (error) { onError(error) + await reviseStopNoteUnconfirmed(ctx, clientOperationId).catch(onError) + } +} + +/** While the work it stopped runs on, the Stop's own note, if it wrote one, says what a lost + * interrupt's says. Work that ended took the Stop, whatever became of the child. */ +async function reviseStopNoteUnconfirmed( + ctx: Pick, + clientOperationId: string +): Promise { + if (!(await isMainAgentWorkingOnceFlushed(ctx))) { + return + } + const identity = structuredAgentSessionStopNoteIdentity(clientOperationId) + const itemId = agentJournalItemKey(identity) + let written: { turnScope?: AgentJournalTurnScope } | undefined + ctx.journal.visitItemsWithLinkage((id, _sequence, _body, linkage) => { + if (id === itemId) { + written = linkage + } + }) + if (written === undefined) { + return + } + await ctx.journal.appendItem( + identity, + { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + }, + { fence: ctx.fence, turnScope: written.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +/** Whether turn `turnId`, which a refused Stop named while it ran, still runs once the provider's + * rows land: a refusal of a turn that ended is not one of a turn that runs on. */ +async function namedTurnRunsOnOnceFlushed( + ctx: Pick, + turnId: string +): Promise { + try { + await ctx.flushStreamedEvents() + } catch { + // Unflushed, the journal still shows the turn the refusal left running. } + return ctx.journal.activeTurnId() === turnId } export async function performCancel( @@ -132,14 +187,20 @@ export async function performCancel( }) taken = outcome.cancelled cancelled = outcome.cancelled + // Refused while the turn it named runs on: the provider declined it, whatever the Stop was. + const refusedLiveTurn = + !cancelled && + input.turnId !== undefined && + input.turnId === liveTurnId && + (await namedTurnRunsOnOnceFlushed(ctx, input.turnId)) if (!cancelled && input.withdrewQueued && !(await isMainAgentWorkingOnceFlushed(ctx))) { // A Stop that withdrew what was queued and left nothing working ended what it was sent for, // named or not. The journal judges it: providers differ on refusing a turn that has ended. cancelled = true note = null - } else if (!cancelled && input.turnId !== undefined) { + } else if (!cancelled && input.turnId !== undefined && !refusedLiveTurn) { note = { kind: 'status', text: 'The provider had already finished this turn.' } - } else if (!cancelled && input.prompt) { + } else if (!cancelled && input.prompt && !refusedLiveTurn) { note = null } else if (!cancelled) { // Sent only while the chat reads working, so a Stop that ended nothing must say why. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 7c5ecf1ee15d..d8ae3edb3a5c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -8,6 +8,11 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' + +/** Codex's own words refusing an interrupt, as its request error carries them. */ +const REFUSAL = { text: 'failed to interrupt turn', audience: 'person' as const } const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -130,7 +135,7 @@ describe('performCancel', () => { expect(resolveLiveTurnId?.()).toBeNull() }) - it('keeps the running lifecycle when cancellation cannot be confirmed', async () => { + it('keeps the running lifecycle and says the agent declined a Stop naming the turn that runs on', async () => { root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-unconfirmed-')) const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) await journal.appendItem( @@ -152,7 +157,7 @@ describe('performCancel', () => { journal, fence: 1, adapter: { - cancelTurn: vi.fn(async () => ({ cancelled: false })) + cancelTurn: vi.fn(async () => ({ cancelled: false, refusal: { detail: REFUSAL } })) } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', @@ -173,7 +178,12 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { kind: 'status', text: 'The provider had already finished this turn.' } + { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('stopRefused', { detail: REFUSAL }), { + surface: 'row' + }) + } ]) }) @@ -277,11 +287,11 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( async function cancelWith( outcome: Awaited>, input: { turnId?: string; withdrewQueued?: boolean }, - turnRow: 'none' | 'running' | 'lands-on-flush' = 'none' + turnRow: 'none' | 'running' | 'lands-on-flush' | 'ends-on-flush' = 'none' ) { root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-report-')) const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const openTurn = () => + const openTurn = (state: 'running' | 'completed' = 'running') => journal.appendItem( { provider: 'legacy', @@ -292,11 +302,11 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( { kind: 'status', text: 'Agent is working…', - turnLifecycle: { turnId: 'turn-1', state: 'running' } + turnLifecycle: { turnId: 'turn-1', state } }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - if (turnRow === 'running') { + if (turnRow === 'running' || turnRow === 'ends-on-flush') { await openTurn() } const ctx: AgentSessionTurnContext = { @@ -318,6 +328,9 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( if (turnRow === 'lands-on-flush') { await openTurn() } + if (turnRow === 'ends-on-flush') { + await openTurn('completed') + } }, now: () => 1 } @@ -355,4 +368,19 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( rows: ['The provider had already finished this turn.'] }) }) + + it('says the turn had finished when the refusal of the turn it named crossed that turn ending', async () => { + expect(await cancelWith({ cancelled: false }, { turnId: 'turn-1' }, 'ends-on-flush')).toEqual({ + cancelled: false, + rows: ['The provider had already finished this turn.'] + }) + }) + + it('says the agent declined a Stop naming the turn that runs on', async () => { + const reported = await cancelWith({ cancelled: false }, { turnId: 'turn-1' }, 'running') + expect(reported.cancelled).toBe(false) + expect(reported.rows).toEqual([ + agentSessionFailureWords(agentSessionFailureFact('stopRefused'), { surface: 'row' }).text + ]) + }) }) From 5042f69a810b2df83c505f7a75c39aeef6344924 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:05:51 -0700 Subject: [PATCH 068/182] fix(native-chat): Stopping holds while any press of the Stop took A repeat press refused after an earlier press took no longer clears Stopping. Exit early when the Stop named a turn that is not the live one. --- .../structured-agent-session-stopping.test.ts | 88 ++++++++++++++++--- .../structured-agent-session-stopping.ts | 31 ++++--- ...structured-conversation-compaction.test.ts | 20 ++++- 3 files changed, 113 insertions(+), 26 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts index 766ed4ac8a14..03e0f5cc74b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -14,10 +14,12 @@ import type { AgentSessionStatusEvent, AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' +import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' import { createQueuedMessageTestRig, eventually, + QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' @@ -73,9 +75,25 @@ function watchStatus(): () => AgentSessionStatusSummary | undefined { } } +/** The Stop as the phone sends it, naming the turn its journal shows running. */ +function namedStop(turnId: string) { + return rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ turnId }, 'agentSession.cancel', hostTestOperationId()), + turnId + }) +} + +/** What each Stop answered, oldest first. */ +function stopAnswers(): (string | undefined)[] { + return journal() + .snapshot() + .items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)) + .map((item) => (item.body.kind === 'status' ? (item.body.failure?.kind ?? 'took') : undefined)) +} + /** A send whose turn `turn-1` is running, and the session's status. */ -async function runningTurn() { - rig = await createQueuedMessageTestRig() +async function runningTurn(options: { stopEndsSession?: true } = {}) { + rig = await createQueuedMessageTestRig(options) const sent = await rig.workingSend() await rig.settleAccepted(sent, 'sent') await turn('turn-1', sent, 'running') @@ -112,21 +130,23 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => expect(status()).toMatchObject({ stopping: true }) }) + const refused = async (): Promise => ({ cancelled: false }) + const unconfirmed = async (): Promise => { + throw new Error('the interrupt request timed out') + } it.each([ - ['refused', async () => ({ cancelled: false })], - [ - 'left unconfirmed', - async (): Promise => { - throw new Error('the interrupt request timed out') - } - ] + ['refused', 'names no turn', refused], + ['left unconfirmed', 'names no turn', unconfirmed], + ['refused', 'names the turn', refused], + ['left unconfirmed', 'names the turn', unconfirmed] ])( - 'reads Working again once the agent %s the Stop, while its turn runs on', - async (_, cancel) => { + 'reads Working again once the agent %s a Stop that %s, while its turn runs on', + async (_, naming, cancel) => { const { status } = await runningTurn() rig.cancelTurn.mockImplementationOnce(cancel) - expect(await rig.stop()).toMatchObject({ ok: true }) + const stopped = naming === 'names the turn' ? namedStop('turn-1') : rig.stop() + expect(await stopped).toMatchObject({ ok: true }) await eventually(() => expect(journal().stopMarks.latest()).not.toBeNull()) await eventually(() => expect(status()).toMatchObject({ status: 'working' })) @@ -148,6 +168,26 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => await eventually(() => expect(status()).toMatchObject({ stopping: true })) }) + it('keeps Stopping when a repeat press is refused after an earlier press took', async () => { + const { status } = await runningTurn() + let answer: (outcome: AgentSessionCancelOutcome) => void = () => undefined + rig.cancelTurn.mockImplementationOnce( + () => new Promise((resolve) => (answer = resolve)) + ) + const first = rig.stop() + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + // Queued behind the first on the session's lane, it reaches an agent already stopping. + rig.cancelTurn.mockImplementationOnce(refused) + const repeat = rig.stop() + + answer({ cancelled: true }) + expect(await first).toMatchObject({ ok: true }) + expect(await repeat).toMatchObject({ ok: true }) + + await eventually(() => expect(stopAnswers()).toEqual(['took', 'stopRefused'])) + expect(status()).toMatchObject({ status: 'working', stopping: true }) + }) + it('never marks a turn that opened after the stopped one ended', async () => { const { sent, status } = await runningTurn() expect(await rig.stop()).toMatchObject({ ok: true }) @@ -200,3 +240,25 @@ describe('a Stop pressed before its send opened a turn', () => { expect(status()).not.toHaveProperty('stopping') }) }) + +describe('a Stop whose provider ends its session', () => { + it("reads Working once the child's end fails, and Stopping again when the next Stop retries it", async () => { + const { status } = await runningTurn({ stopEndsSession: true }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + + expect(await rig.stop()).toMatchObject({ ok: true }) + + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + + // The retry's child end is held, so the status can be read while it runs. + const retried = Promise.withResolvers() + rig.closeSession.mockImplementationOnce(() => retried.promise) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(rig.closeSession).toHaveBeenCalledTimes(2)) + expect(stopAnswers()).toEqual(['cancelUnconfirmed', 'took']) + expect(status()).toMatchObject({ status: 'working', stopping: true }) + retried.resolve(true) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts index a0154cf0ab7d..b99f1bc184f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts @@ -19,27 +19,34 @@ function noteSaysStopTookNoEffect(item: AgentJournalRenderItem): boolean { /** * Whether the latest person's Stop is still ending the work it stopped: the live turn it named, or - * with none, the sends it stopped (`personStopDecidesTurn`), unless the newest Stop answer since - * that Stop says it stopped nothing. A later press of the same Stop writes no event, only its - * answer, so the newest answer is what counts. + * with none, the sends it stopped (`personStopDecidesTurn`), unless every Stop answer since then + * says it stopped nothing. A repeat press writes no event, only its answer, so one press that took + * keeps it whichever order the answers came in. */ export function structuredAgentSessionStopping( journal: Pick, items: readonly AgentJournalRenderItem[] ): boolean { const stop = journal.stopMarks.latest() - if (stop === null || !journal.stopMarks.personStopDecides(journal.activeTurnId())) { + if (stop === null) { return false } - let answer: AgentJournalRenderItem | undefined + const liveTurnId = journal.activeTurnId() + // Cheap exit before the full turn scan: a Stop naming a turn decides only that turn. + if (stop.event.turnId !== undefined && stop.event.turnId !== liveTurnId) { + return false + } + if (!journal.stopMarks.personStopDecides(liveTurnId)) { + return false + } + let answered = false for (const item of items) { - if ( - item.sequence > stop.sequence && - isStructuredAgentSessionStopNote(item.itemId) && - item.sequence >= (answer?.sequence ?? -1) - ) { - answer = item + if (item.sequence > stop.sequence && isStructuredAgentSessionStopNote(item.itemId)) { + if (!noteSaysStopTookNoEffect(item)) { + return true + } + answered = true } } - return answer === undefined || !noteSaysStopTookNoEffect(answer) + return !answered } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 283f7c4f934c..16f86e66c692 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -13,7 +13,11 @@ import { type AgentJournalItemBody, type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' @@ -443,6 +447,8 @@ it('leaves the command to the provider when it takes the Stop, and ends it as ca it('ends the command by stopping the child at a second Stop the provider never answered (B4)', async () => { await attach() + const statuses: AgentSessionStatusEvent[] = [] + state.host.subscribeStatus({ id: 'list', emit: (event) => statuses.push(event) }) const params = compactParams() const cmid = params.envelope.clientOperationId await state.host.conversationCommand(CALLER, params) @@ -452,13 +458,25 @@ it('ends the command by stopping the child at a second Stop the provider never a // The provider takes the interrupt and then never answers it. await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) expect(closeSession).not.toHaveBeenCalled() + // The chat reads Stopping, yet only the next Stop ends the command: clients keep Stop enabled. + expect(latestSummary(statuses)).toMatchObject({ status: 'working', stopping: true }) await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) expect(state.cancelTurn).toHaveBeenCalledOnce() expect(closeSession).toHaveBeenCalledOnce() expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + expect(latestSummary(statuses)).not.toHaveProperty('stopping') }) +/** The session's newest summary in what a session list received. */ +function latestSummary(events: AgentSessionStatusEvent[]): AgentSessionStatusSummary | undefined { + return events + .flatMap((event) => + event.type === 'status' ? [event.session] : event.type === 'snapshot' ? event.sessions : [] + ) + .findLast((summary) => summary.sessionId === SESSION) +} + it('ends the command by stopping the child when the provider cannot take the Stop (B4)', async () => { await attach() // Codex before it opened the command's turn, or Claude refusing the interrupt. From 552a4ad0bf239be66beaeffc1fababb5eca2bb3e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:05:52 -0700 Subject: [PATCH 069/182] fix(native-chat): keep Stop enabled while the host says Stopping Only this client's own Stop request in flight disables Stop and Esc. A repeat Stop is how a stop the provider took but never answered escalates. --- .../native-chat/NativeChatComposerActions.tsx | 2 +- .../native-chat/NativeChatComposerField.tsx | 2 +- .../NativeChatStructuredSession.stopping.test.tsx | 13 ++++++++----- .../native-chat/NativeChatStructuredSession.tsx | 7 ++++--- .../native-chat/native-chat-composer-types.ts | 2 +- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index 4b449760c3f7..806730ad6550 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -17,7 +17,7 @@ export type NativeChatComposerActionsProps = { dictationDisabled: boolean sendDisabled: boolean isWorking: boolean - /** A person's Stop is ending the turn: the Stop control is disabled and says so. */ + /** This client's Stop request is in flight: the Stop control is disabled and says so. */ isStopping?: boolean isDictating: boolean isDictationHoldMode: boolean diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index db74158814a4..2d1f76e6570b 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -35,7 +35,7 @@ export type NativeChatComposerFieldProps = { imageAttachments: readonly NativeChatComposerImageAttachment[] sendButtonDisabled: boolean isWorking: boolean - /** A person's Stop is ending the turn: the Stop control is disabled and says so. */ + /** This client's Stop request is in flight: the Stop control is disabled and says so. */ isStopping?: boolean attachDisabled: boolean dictationDisabled: boolean diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx index 8762e2fa0c13..1de41489a162 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx @@ -71,7 +71,7 @@ describe("the chat pane while a person's Stop ends the turn", () => { resetStructuredSessionMocks() }) - it("reads Stopping from the host's word, and a second Stop sends nothing", async () => { + it("reads Stopping from the host's word and keeps Stop for the repeat that escalates", async () => { mocks.turnId = 'turn-1' mocks.isWorking = true renderPane() @@ -80,19 +80,22 @@ describe("the chat pane while a person's Stop ends the turn", () => { hostSays(true) - await waitFor(() => expect(mocks.composerProps).toMatchObject({ isStopping: true })) - expect(mocks.messageListProps).toMatchObject({ stopping: true }) + // A Stop the provider took and never answered (a Codex command) ends only at a second Stop. + await waitFor(() => expect(mocks.messageListProps).toMatchObject({ stopping: true })) + expect(mocks.composerProps).toMatchObject({ isStopping: false }) mocks.composerProps?.onStop?.() - expect(mocks.stop).not.toHaveBeenCalled() + expect(mocks.stop).toHaveBeenCalledOnce() }) - it('reads Stopping from its own press until the host has answered', () => { + it('reads Stopping from its own press, and holds Stop until that request answers', () => { mocks.turnId = 'turn-1' mocks.stopPressed = true renderPane() expect(mocks.composerProps).toMatchObject({ isStopping: true }) expect(mocks.messageListProps).toMatchObject({ stopping: true }) + mocks.composerProps?.onStop?.() + expect(mocks.stop).not.toHaveBeenCalled() }) it('stops normally while nothing is stopping', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index ecf2f82e6307..ef1112fd3b03 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -61,6 +61,8 @@ export function NativeChatStructuredSession( }) // The host's word, bridged by this client's own press until its Stop event lands. const stopping = controller.canStop && (hostExecution.stopping || controller.stopPressed) + // Only this client's own request in flight holds Stop: a repeat is how a stuck stop escalates. + const stopInFlight = controller.canStop && controller.stopPressed const launchDraftSignal = useNativeChatLaunchDraftSignal({ terminalTabId: props.tabId, agent: props.agent, @@ -396,9 +398,8 @@ export function NativeChatStructuredSession( agent={props.agent} canSend={!prompt} isWorking={controller.canStop} - isStopping={stopping} - // A second Stop while one is ending the turn sends nothing. - onStop={() => void (stopping || controller.stop())} + isStopping={stopInFlight} + onStop={() => void (stopInFlight || controller.stop())} steerQueued={controller.queuedMessages.steerNewest} structuredTransport={structuredTransport} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index df6ccf5f7535..6049f330537f 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -57,7 +57,7 @@ export type NativeChatComposerProps = { canSend?: boolean /** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */ isWorking?: boolean - /** A person's Stop is ending the turn: the Stop control is disabled and says so. */ + /** This client's Stop request is in flight: the Stop control is disabled and says so. */ isStopping?: boolean /** Interrupt the hosted agent, usually by sending ESC into the PTY. */ onStop?: () => void From 28a4de474be25174f5143d68107887eb64248f77 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:15:50 -0700 Subject: [PATCH 070/182] fix(sidebar): every agent row says Stopping in place of its tool line The dashboard row, which the sidebar's non-compact mode also draws, read the last tool line while a person's Stop ended the turn. It now shares the compact row's rule. --- .../dashboard/DashboardAgentRow.test.tsx | 17 +++++++++++++++++ .../components/dashboard/DashboardAgentRow.tsx | 5 ++++- .../dashboard/DashboardAgentRowToolStep.tsx | 15 ++++++++++++++- .../sidebar/worktree-card-compact-agent-row.tsx | 8 ++++---- .../src/lib/agent-row-stopping-label.ts | 14 ++++++++++++++ 5 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/lib/agent-row-stopping-label.ts diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 5ac748027972..0631251d8492 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -380,6 +380,23 @@ describe('DashboardAgentRow', () => { expect(activeToolMarkup).toContain('ListDir') }) + it("says Stopping in place of the tool line while a person's Stop ends the turn", () => { + const markup = renderRow( + makeAgent( + {}, + { + toolName: 'Bash', + toolInput: 'pnpm test', + mainAgent: { state: 'working', stopping: true, stateStartedAt: 60_000 } + } + ) + ) + + expect(markup).toContain('Stopping…') + expect(markup).not.toContain('lucide-wrench') + expect(markup).toContain('data-agent-spinner') + }) + it('renders monitoring without a spinner or active tool line', () => { const markup = renderRow( makeAgent( diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 2cacdfb5474b..5770ab66cdfd 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -8,6 +8,7 @@ import { DashboardAgentRowMessage } from './DashboardAgentRowMessage' import { DashboardAgentRowTrailingControls } from './DashboardAgentRowTrailingControls' import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep' import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview' +import { agentRowStoppingLabel } from '@/lib/agent-row-stopping-label' import { agentNoUpdateLabel, formatCompactDuration } from '@/lib/agent-row-decay-state' import { agentRowDisplayDotState, agentRowDotState as asDotState } from '@/lib/agent-row-dot-state' import { @@ -151,7 +152,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ // Why: 'working' names the running tool and 'waiting' names what an approval is blocked on; // anywhere else a leftover tool line reads as still-running. See showsAgentToolPreview. // Monitoring is excluded too: the lead turn is over, so its last tool line is stale. - const showsTool = showsAgentToolPreview(agent.state) && !isMonitoring + const stoppingLabel = agentRowStoppingLabel(agent.entry, agent.state) + const showsTool = showsAgentToolPreview(agent.state) && !isMonitoring && stoppingLabel === null const toolName = showsTool ? (agent.entry.toolName?.trim() ?? '') : '' const toolInput = showsTool ? (agent.entry.toolInput?.trim() ?? '') : '' // Why: a child row's message line is the model's, so a child that ended without an outcome says so. @@ -314,6 +316,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ reservesHeight={isWorking} toolName={toolName} toolInput={toolInput} + statusLabel={stoppingLabel} /> + {statusLabel} +
+ ) + } if (!showsTool || (!toolName && !reservesHeight)) { return null } diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index 9462235e5595..c2357240f95f 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -15,7 +15,7 @@ import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timesta import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer' import { formatShortTimeAgo } from '@/lib/short-time-ago' import { agentVerdictStatusLine } from '@/lib/agent-verdict-status-line' -import { translate } from '@/i18n/i18n' +import { agentRowStoppingLabel } from '@/lib/agent-row-stopping-label' function getCompactAgentPrimary( agent: DashboardAgentRowData, @@ -39,9 +39,9 @@ export function getCompactAgentSecondary( if (agent.state === 'unverifiable') { return agentNoUpdateLabel(agent.entry, now) } - // Why: a person's Stop is ending the turn, so its last tool line no longer says what comes next. - if (agent.state === 'working' && agent.entry.mainAgent?.stopping) { - return translate('components.native-chat.status.stopping', 'Stopping…') + const stoppingLabel = agentRowStoppingLabel(agent.entry, agent.state) + if (stoppingLabel) { + return stoppingLabel } // Why: the lead turn is over in monitoring, so its last tool line is stale; name the state instead. if (agent.state === 'working' && agent.entry.workingMode === 'monitoring') { diff --git a/src/renderer/src/lib/agent-row-stopping-label.ts b/src/renderer/src/lib/agent-row-stopping-label.ts new file mode 100644 index 000000000000..5d50cea290e9 --- /dev/null +++ b/src/renderer/src/lib/agent-row-stopping-label.ts @@ -0,0 +1,14 @@ +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { translate } from '@/i18n/i18n' +import type { AgentRowState } from './agent-row-decay-state' + +/** "Stopping…" while a person's Stop ends a working row's turn, else null. Every agent row says it + * in place of the last tool line, which no longer says what comes next. */ +export function agentRowStoppingLabel( + entry: Pick, + state: AgentRowState | null | undefined +): string | null { + return state === 'working' && entry.mainAgent?.stopping + ? translate('components.native-chat.status.stopping', 'Stopping…') + : null +} From cd44de4cfbbc15a602ee922ea7cb68580f1afc5a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:15:51 -0700 Subject: [PATCH 071/182] fix(mobile): the worktree list sees Stopping change on its own A snapshot whose only change was the host dropping Stopping compared equal and was thrown away, leaving the row on Stopping. --- .../src/worktree/worktree-list-snapshot.test.ts | 17 +++++++++++++++++ mobile/src/worktree/worktree-list-snapshot.ts | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/mobile/src/worktree/worktree-list-snapshot.test.ts b/mobile/src/worktree/worktree-list-snapshot.test.ts index 25c9ecbe852b..b575007b0028 100644 --- a/mobile/src/worktree/worktree-list-snapshot.test.ts +++ b/mobile/src/worktree/worktree-list-snapshot.test.ts @@ -192,6 +192,23 @@ describe('areWorktreeListsEqual', () => { expect(areWorktreeListsEqual(at(1), at(1))).toBe(true) }) + // The host drops Stopping on letting go of a session without moving any clock the row carries. + it("detects the host's Stopping coming and going with nothing else moved", () => { + const stopping = (value: boolean) => [ + worktree({ + agents: [ + agent({ + mainAgent: { state: 'working', stateStartedAt: 1, ...(value ? { stopping: true } : {}) } + }) + ] + }) + ] + + expect(areWorktreeListsEqual(stopping(false), stopping(true))).toBe(false) + expect(areWorktreeListsEqual(stopping(true), stopping(false))).toBe(false) + expect(areWorktreeListsEqual(stopping(true), stopping(true))).toBe(true) + }) + it('detects monitoring mode changes within working', () => { const first = [worktree({ agents: [agent({ state: 'working' })] })] const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })] diff --git a/mobile/src/worktree/worktree-list-snapshot.ts b/mobile/src/worktree/worktree-list-snapshot.ts index 250c871493fb..3fe50cec69aa 100644 --- a/mobile/src/worktree/worktree-list-snapshot.ts +++ b/mobile/src/worktree/worktree-list-snapshot.ts @@ -133,6 +133,7 @@ function areMainAgentsEqual( return ( left.state === right.state && left.outcome === right.outcome && - left.stateStartedAt === right.stateStartedAt + left.stateStartedAt === right.stateStartedAt && + left.stopping === right.stopping ) } From 514a216699c81d957097bffbfda17a72ee287d73 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:20:19 -0700 Subject: [PATCH 072/182] refactor(native-chat): fold the status feed in src/shared for both clients The snapshot merge and the contact-loss strip move out of the renderer feed so the phone folds the same stream the same way. --- .../structured-agent-session-status-feed.ts | 51 +++++------------- .../agent-session-status-snapshot-fold.ts | 52 +++++++++++++++++++ 2 files changed, 66 insertions(+), 37 deletions(-) create mode 100644 src/shared/agent-session-status-snapshot-fold.ts diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index 4f678367ea39..e0c14dc57729 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -5,10 +5,12 @@ // keeps the cached summaries and reconnects; a fresh snapshot merges over them. // Which sessions are listed is the tab map's decision, so the feed never retracts a summary. -import type { - AgentSessionStatusEvent, - AgentSessionStatusSummary -} from '../../../shared/agent-session-wire' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { + foldAgentSessionStatusEvent, + revokeAgentSessionStatusLive, + type AgentSessionStatusSnapshot +} from '../../../shared/agent-session-status-snapshot-fold' import { AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { runtimeEnvironmentSupportsCapability, @@ -16,7 +18,7 @@ import { } from './runtime-rpc-client' import { subscribeStructuredAgentSessionStatus } from './structured-agent-session-client' -export type StructuredAgentSessionStatusSnapshot = ReadonlyMap +export type StructuredAgentSessionStatusSnapshot = AgentSessionStatusSnapshot export type StructuredAgentSessionStatusFeedOwner = { activate: () => () => void @@ -58,22 +60,15 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { const applyEvent = (event: AgentSessionStatusEvent): void => { if (event.type === 'snapshot') { reconnectAttempt = 0 - // Merged, not replaced: a restarted host restores its readable sessions asynchronously, so - // the first snapshot can be empty and dropping those rows flickers every one to no-status. - const next = new Map(snapshot) for (const session of event.sessions) { confirmedSessions.add(session.sessionId) - next.set(session.sessionId, session) } - setSnapshot(next) - return - } - if (event.type === 'status') { + } else if (event.type === 'status') { confirmedSessions.add(event.session.sessionId) - const next = new Map(snapshot) - next.set(event.session.sessionId, event.session) - setSnapshot(next) + } else { + return } + setSnapshot(foldAgentSessionStatusEvent(snapshot, event)) } const active = (candidate: number): boolean => activations.size > 0 && candidate === generation const clearReconnect = (): void => { @@ -86,28 +81,10 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { handle?.unsubscribe() handle = null } - // A Stop the host was ending is its live state too: with contact lost, no Stopping outlives it. const revokeSnapshotOwnership = (): void => { - let next: Map | null = null - for (const [sessionId, summary] of snapshot) { - if (!summary.hostExecutionOwned && !summary.stopping) { - continue - } - if (!next) { - next = new Map(snapshot) - } - const { - hostExecutionOwned: _owned, - hostExecutionPhase: _phase, - hostExecutionChild: _child, - stopping: _stopping, - ...retained - } = summary - next.set(sessionId, retained) - } - if (next) { - snapshot = next - emit() + const next = revokeAgentSessionStatusLive(snapshot) + if (next !== snapshot) { + setSnapshot(next) } } let open = (): void => {} diff --git a/src/shared/agent-session-status-snapshot-fold.ts b/src/shared/agent-session-status-snapshot-fold.ts new file mode 100644 index 000000000000..2ffb0464cbd1 --- /dev/null +++ b/src/shared/agent-session-status-snapshot-fold.ts @@ -0,0 +1,52 @@ +// A client's mirror of the host's status feed: the latest summary per session. The desktop and +// the phone fold the stream with these, so they agree on what a frame changes and on what losing +// contact takes away. + +import type { AgentSessionStatusEvent, AgentSessionStatusSummary } from './agent-session-wire' + +export type AgentSessionStatusSnapshot = ReadonlyMap + +/** The mirror after one frame; `end` leaves it as it is. A snapshot merges over what is held: a + * restarted host restores its readable sessions asynchronously, so its first snapshot can be + * empty, and dropping those rows would flicker every one to no status. */ +export function foldAgentSessionStatusEvent( + snapshot: AgentSessionStatusSnapshot, + event: AgentSessionStatusEvent +): AgentSessionStatusSnapshot { + if (event.type === 'snapshot') { + const next = new Map(snapshot) + for (const session of event.sessions) { + next.set(session.sessionId, session) + } + return next + } + if (event.type === 'status') { + const next = new Map(snapshot) + next.set(event.session.sessionId, event.session) + return next + } + return snapshot +} + +/** With contact lost, what only a live host vouches for goes: its execution, and a Stop it was + * ending. The rest is kept, since losing contact is never exit. `snapshot` itself if unchanged. */ +export function revokeAgentSessionStatusLive( + snapshot: AgentSessionStatusSnapshot +): AgentSessionStatusSnapshot { + let next: Map | null = null + for (const [sessionId, summary] of snapshot) { + if (!summary.hostExecutionOwned && !summary.stopping) { + continue + } + next ??= new Map(snapshot) + const { + hostExecutionOwned: _owned, + hostExecutionPhase: _phase, + hostExecutionChild: _child, + stopping: _stopping, + ...retained + } = summary + next.set(sessionId, retained) + } + return next ?? snapshot +} From 6b65121e317ff8d484e4c9c130fd0980ae19c410 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:20:20 -0700 Subject: [PATCH 073/182] feat(mobile): let phones read the structured session status stream agentSession.subscribeStatus joins the mobile allowlist. The agent-session methods move to their own file, which the at-cap allowlist spreads in, and the allowlist test reads the Set instead of parsing the source. --- src/main/runtime/mobile-rpc-allowlist.test.ts | 14 +++------ ...untime-rpc-mobile-agent-session-methods.ts | 30 +++++++++++++++++++ .../runtime-rpc-mobile-method-allowlist.ts | 28 ++--------------- 3 files changed, 37 insertions(+), 35 deletions(-) create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 841c22ae9255..1842d0a84210 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { ALL_RPC_METHODS } from './rpc/methods' +import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc/runtime-rpc-mobile-method-allowlist' const MOBILE_DYNAMIC_RPC_METHODS = [ // Why: computed sendRequest method names do not appear as literals in the @@ -100,16 +101,8 @@ function mobileRpcMethods(): string[] { return [...new Set([...mobileLiteralRpcMethods(), ...MOBILE_DYNAMIC_RPC_METHODS])].sort() } -function mobileRpcAllowlist(): Set { - const source = readFileSync( - join(process.cwd(), 'src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts'), - 'utf8' - ) - const allowlist = source.match(/const MOBILE_RPC_METHOD_ALLOWLIST = new Set\(\[([\s\S]*?)\]\)/) - if (!allowlist) { - throw new Error('MOBILE_RPC_METHOD_ALLOWLIST not found') - } - return new Set([...allowlist[1]!.matchAll(/'([^']+)'/g)].map((match) => match[1]!)) +function mobileRpcAllowlist(): ReadonlySet { + return MOBILE_RPC_METHOD_ALLOWLIST } function registeredRuntimeMethods(): Set { @@ -176,6 +169,7 @@ describe('mobile RPC allowlist', () => { 'agentSession.history', 'agentSession.subscribe', 'agentSession.unsubscribe', + 'agentSession.subscribeStatus', 'agentSession.hold', 'agentSession.release' ]) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts new file mode 100644 index 000000000000..53c0a8717031 --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts @@ -0,0 +1,30 @@ +/** The structured agent-session methods a phone may call; spread into the mobile allowlist. */ +export const MOBILE_AGENT_SESSION_RPC_METHODS = [ + 'agentSession.createSupport', + 'agentSession.create', + 'agentSession.ensure', + 'agentSession.reveal', + 'agentSession.send', + 'agentSession.cancel', + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageDelete', + 'agentSession.queuedMessagesResume', + 'agentSession.close', + 'agentSession.respondToApproval', + 'agentSession.respondToQuestion', + 'agentSession.setOption', + 'agentSession.handoffStatus', + 'agentSession.options', + 'agentSession.modelCatalog', + 'agentSession.conversationCommand', + 'agentSession.commands', + 'agentSession.history', + 'agentSession.subscribe', + 'agentSession.unsubscribe', + // Every session's status on one stream: the phone's chat reads the host's "Stopping…" from it. + 'agentSession.subscribeStatus', + // No-ops on a current host; kept until MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION passes the + // mobile builds that still call them. + 'agentSession.hold', + 'agentSession.release' +] as const diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index d544dc6720b0..0b2192751c5f 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -1,3 +1,5 @@ +import { MOBILE_AGENT_SESSION_RPC_METHODS } from './runtime-rpc-mobile-agent-session-methods' + export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'accounts.list', 'accounts.consumeCodexResetCredit', @@ -212,31 +214,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'session.tabs.subscribeAll', 'session.tabs.unsubscribe', 'session.tabs.unsubscribeAll', - 'agentSession.createSupport', - 'agentSession.create', - 'agentSession.ensure', - 'agentSession.reveal', - 'agentSession.send', - 'agentSession.cancel', - 'agentSession.queuedMessageSend', - 'agentSession.queuedMessageDelete', - 'agentSession.queuedMessagesResume', - 'agentSession.close', - 'agentSession.respondToApproval', - 'agentSession.respondToQuestion', - 'agentSession.setOption', - 'agentSession.handoffStatus', - 'agentSession.options', - 'agentSession.modelCatalog', - 'agentSession.conversationCommand', - 'agentSession.commands', - 'agentSession.history', - 'agentSession.subscribe', - 'agentSession.unsubscribe', - // No-ops on a current host; kept until MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION passes the - // mobile builds that still call them. - 'agentSession.hold', - 'agentSession.release', + ...MOBILE_AGENT_SESSION_RPC_METHODS, 'nativeChat.readSession', 'nativeChat.subscribe', 'nativeChat.unsubscribe', From e6b86323812e367a4d06db5577e7b46e2949d101 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:20:22 -0700 Subject: [PATCH 074/182] feat(mobile): the phone chat reads Stopping from the host, like the desktop One status stream per client, opened on a host that advertises the status feed; a refusal to phones reads as no feed. The chat reads Stopping from the host or its own press, and holds Stop only while its own request is in flight. --- .../src/session/MobileNativeChatView.test.ts | 23 ++- mobile/src/session/MobileNativeChatView.tsx | 12 +- ...uctured-agent-session-host-support.test.ts | 19 ++- ...e-structured-agent-session-host-support.ts | 9 +- ...ile-structured-session-status-feed.test.ts | 112 +++++++++++++ .../mobile-structured-session-status-feed.ts | 120 ++++++++++++++ ...tured-agent-session-prompt-cancel.test.tsx | 3 +- ...tured-agent-session-queued.test-fixture.ts | 3 +- .../use-mobile-structured-agent-session.ts | 15 +- ...mobile-structured-session-host-stopping.ts | 28 ++++ ...-mobile-structured-turn-indicator.test.tsx | 156 +++++++++++++++++- .../transport/rpc-subscription-inventory.ts | 7 + src/shared/native-chat-turn-status.ts | 2 + .../cross-version-stopping.unit.test.ts | 23 +++ 14 files changed, 503 insertions(+), 29 deletions(-) create mode 100644 mobile/src/session/mobile-structured-session-status-feed.test.ts create mode 100644 mobile/src/session/mobile-structured-session-status-feed.ts create mode 100644 mobile/src/session/use-mobile-structured-session-host-stopping.ts diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 7dc3514d9992..6f2297650039 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -147,12 +147,17 @@ describe('MobileNativeChatView', () => { }) } - it("disables Stop and says Stopping while this phone's Stop is ending the turn", async () => { + it("holds Stop and says Stopping while this phone's own Stop request is in flight", async () => { await render({ structuredActivityUi: true, agentWorking: true, canStop: true, - turnIndicator: { thinking: false, activityText: null, stopping: true } + turnIndicator: { + thinking: false, + activityText: null, + stopping: true, + stopRequestInFlight: true + } }) const stop = renderer!.root.find( (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Stopping…' @@ -160,6 +165,20 @@ describe('MobileNativeChatView', () => { expect(stop.props.disabled).toBe(true) }) + // A Stop the provider took and never answered ends only at a repeat Stop. + it('keeps Stop for the repeat that escalates while the host alone says Stopping', async () => { + await render({ + structuredActivityUi: true, + agentWorking: true, + canStop: true, + turnIndicator: { thinking: false, activityText: null, stopping: true } + }) + const stop = renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Stop the agent' + ) + expect(stop.props.disabled).toBe(false) + }) + /** Ids of the rows the list is currently rendering. */ it('keeps Stop hidden during a structured dispatch until a provider turn can be cancelled', async () => { const props = { structuredActivityUi: true, agentWorking: true, canStop: false } diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 24218a1ec51a..2b7c1c62f1a7 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -268,6 +268,9 @@ export function MobileNativeChatView({ // "Worked for N" row. The structured lane owns them; the bridge lane keeps its // three-dot indicator. const stopping = agentWorking === true && turnIndicator?.stopping === true + // Only this phone's own request holds Stop: a repeat is how a stuck stop escalates. + const stopHeld = agentWorking === true && turnIndicator?.stopRequestInFlight === true + const stoppingCopy = NATIVE_CHAT_TURN_STATUS_COPY.stopping const turns = useMobileNativeChatTurnDisclosure({ messages: data, enabled: structuredActivityUi, @@ -409,18 +412,15 @@ export function MobileNativeChatView({ {canStop ? ( - // A second Stop while one is ending the turn sends nothing. [styles.stopButton, pressed && styles.pressed]} onPress={onStop} - disabled={stopping} + disabled={stopHeld} hitSlop={8} - accessibilityLabel={stopping ? NATIVE_CHAT_TURN_STATUS_COPY.stopping : 'Stop the agent'} + accessibilityLabel={stopHeld ? stoppingCopy : 'Stop the agent'} > - - {stopping ? NATIVE_CHAT_TURN_STATUS_COPY.stopping : 'Stop'} - + {stopHeld ? stoppingCopy : 'Stop'} ) : null} diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts index 1cb211affb6e..cb7a83a28ebb 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts @@ -2,25 +2,26 @@ import { describe, expect, it } from 'vitest' import { AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, - AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' describe('structuredAgentSessionHostSupport', () => { it('reads each structured-session feature from the host capability list', () => { - expect(structuredAgentSessionHostSupport([])).toEqual({ - promptCancel: false, - questionAnswers: false, - queuedMessages: false - }) + const none = { promptCancel: false, questionAnswers: false, queuedMessages: false } + expect(structuredAgentSessionHostSupport([])).toEqual({ ...none, statusFeed: false }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY]) - ).toEqual({ promptCancel: false, questionAnswers: true, queuedMessages: false }) + ).toEqual({ ...none, questionAnswers: true, statusFeed: false }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY]) - ).toEqual({ promptCancel: true, questionAnswers: false, queuedMessages: false }) + ).toEqual({ ...none, promptCancel: true, statusFeed: false }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY]) - ).toEqual({ promptCancel: false, questionAnswers: false, queuedMessages: true }) + ).toEqual({ ...none, queuedMessages: true, statusFeed: false }) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY]) + ).toEqual({ ...none, statusFeed: true }) }) }) diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index 8994bb73cacf..10b75efea3e3 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -1,7 +1,8 @@ import { AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, - AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' /** Structured-session features the connected host advertised; null until the status probe answers. */ @@ -10,6 +11,9 @@ export type StructuredAgentSessionHostSupport = { questionAnswers: boolean /** Mid-turn sends queue as host-held drafts; an older host keeps today's immediate path. */ queuedMessages: boolean + /** The host publishes every session's status on one stream. A host from before phones could + * read it refuses the call, which reads the same as its absence. */ + statusFeed: boolean } export function structuredAgentSessionHostSupport( @@ -18,6 +22,7 @@ export function structuredAgentSessionHostSupport( return { promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY), questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY), - queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY) + queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY), + statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY) } } diff --git a/mobile/src/session/mobile-structured-session-status-feed.test.ts b/mobile/src/session/mobile-structured-session-status-feed.test.ts new file mode 100644 index 000000000000..f3686f15fdae --- /dev/null +++ b/mobile/src/session/mobile-structured-session-status-feed.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' +import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' + +function summary(stopping: boolean): AgentSessionStatusSummary { + return { + sessionId: 'session-1', + workspaceId: 'workspace-1', + agent: 'codex', + status: 'working', + latestPrompt: 'ship it', + updatedAt: 1, + hostExecutionOwned: true, + ...(stopping ? { stopping: true } : {}) + } +} + +describe("the phone's status stream", () => { + let frames: ((value: unknown) => void)[] + let stateListeners: ((state: ConnectionState) => void)[] + let client: RpcClient + + beforeEach(() => { + frames = [] + stateListeners = [] + // A fresh client per test: the stream is one per client for its life. + client = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + frames.push(onData) + return () => {} + }), + onStateChange: (listener: (state: ConnectionState) => void) => { + stateListeners.push(listener) + return () => {} + } + } as unknown as RpcClient + }) + + function read(): AgentSessionStatusSummary | undefined { + return mobileStructuredSessionStatusFeed(client).getSnapshot().get('session-1') + } + + function connection(state: ConnectionState): void { + for (const listener of stateListeners) { + listener(state) + } + } + + it('opens one stream per client however many chats read it', () => { + const feed = mobileStructuredSessionStatusFeed(client) + feed.subscribe(() => {}) + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + + expect(client.subscribe).toHaveBeenCalledOnce() + expect(client.subscribe).toHaveBeenCalledWith( + 'agentSession.subscribeStatus', + {}, + expect.any(Function) + ) + }) + + it("follows the host's Stopping from the snapshot through each status frame", () => { + const listener = vi.fn() + mobileStructuredSessionStatusFeed(client).subscribe(listener) + + frames[0]?.({ type: 'snapshot', sessions: [summary(true)] }) + expect(read()?.stopping).toBe(true) + + frames[0]?.({ type: 'status', session: summary(false) }) + expect(read()).toMatchObject({ status: 'working' }) + expect(read()).not.toHaveProperty('stopping') + expect(listener).toHaveBeenCalledTimes(2) + }) + + it('drops Stopping when the host ends the stream, and opens it again on the next connection', () => { + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + frames[0]?.({ type: 'snapshot', sessions: [summary(true)] }) + + frames[0]?.({ type: 'end' }) + + expect(read()).not.toHaveProperty('stopping') + connection('connected') + expect(client.subscribe).toHaveBeenCalledTimes(2) + }) + + it('drops Stopping, keeping the rest, once the phone loses contact', () => { + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + frames[0]?.({ type: 'snapshot', sessions: [summary(true)] }) + + connection('reconnecting') + + expect(read()).toMatchObject({ sessionId: 'session-1', status: 'working' }) + expect(read()).not.toHaveProperty('stopping') + expect(read()).not.toHaveProperty('hostExecutionOwned') + }) + + it('reads a host that refuses the method to phones as one without it, and never asks again', () => { + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + + // As the host's mobile gate answers it, through the client's failed-opener frame. + const refusal = "Method 'agentSession.subscribeStatus' is not available to mobile clients" + frames[0]?.({ type: 'error', message: refusal, error: { code: 'forbidden', message: refusal } }) + connection('reconnecting') + connection('connected') + mobileStructuredSessionStatusFeed(client).subscribe(() => {}) + + expect(client.subscribe).toHaveBeenCalledOnce() + expect(read()).toBeUndefined() + }) +}) diff --git a/mobile/src/session/mobile-structured-session-status-feed.ts b/mobile/src/session/mobile-structured-session-status-feed.ts new file mode 100644 index 000000000000..aa839da82066 --- /dev/null +++ b/mobile/src/session/mobile-structured-session-status-feed.ts @@ -0,0 +1,120 @@ +// The connected host's status stream, which every structured chat on that host reads the host's +// "Stopping…" from. One per client, opened by the first chat that reads it and kept for the +// client's life: a phone has no way to end just this stream, so one per chat would leave a host +// subscriber behind each. The client replays it after a reconnect, and the host's fresh snapshot +// then replaces what contact loss revoked. + +import { + foldAgentSessionStatusEvent, + revokeAgentSessionStatusLive, + type AgentSessionStatusSnapshot +} from '../../../src/shared/agent-session-status-snapshot-fold' +import type { AgentSessionStatusEvent } from '../../../src/shared/agent-session-wire' +import { isMobileMethodUnavailableError } from '../files/file-list-fallback' +import type { RpcClient } from '../transport/rpc-client' + +export type MobileStructuredSessionStatusFeed = { + subscribe: (listener: () => void) => () => void + getSnapshot: () => AgentSessionStatusSnapshot +} + +const feeds = new WeakMap() + +function isStatusEvent(value: unknown): value is AgentSessionStatusEvent { + if (typeof value !== 'object' || value === null || !('type' in value)) { + return false + } + switch (value.type) { + case 'snapshot': + return 'sessions' in value && Array.isArray(value.sessions) + case 'status': + return 'session' in value && typeof value.session === 'object' && value.session !== null + case 'end': + return true + default: + return false + } +} + +/** The stream's refusal, as the client hands a failed opener to its listener. */ +function streamRefusal(value: unknown): { code?: string; message?: string } | null { + if (typeof value !== 'object' || value === null || !('type' in value) || value.type !== 'error') { + return null + } + const error = 'error' in value && typeof value.error === 'object' ? value.error : null + const code = error && 'code' in error && typeof error.code === 'string' ? error.code : undefined + const message = + 'message' in value && typeof value.message === 'string' ? value.message : undefined + return { ...(code !== undefined ? { code } : {}), ...(message !== undefined ? { message } : {}) } +} + +function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { + let snapshot: AgentSessionStatusSnapshot = new Map() + // `unavailable`: the host refused the method to phones, so this client never asks again. + let stream: 'closed' | 'open' | 'unavailable' = 'closed' + const listeners = new Set<() => void>() + const setSnapshot = (next: AgentSessionStatusSnapshot): void => { + if (next === snapshot) { + return + } + snapshot = next + for (const listener of listeners) { + listener() + } + } + const revokeLive = (): void => setSnapshot(revokeAgentSessionStatusLive(snapshot)) + const onFrame = (raw: unknown): void => { + if (isStatusEvent(raw)) { + if (raw.type === 'end') { + stream = 'closed' + revokeLive() + return + } + setSnapshot(foldAgentSessionStatusEvent(snapshot, raw)) + return + } + const refusal = streamRefusal(raw) + if (refusal) { + // A host with the feed but from before phones could read it answers `forbidden`. + stream = isMobileMethodUnavailableError(refusal.code, refusal.message) + ? 'unavailable' + : 'closed' + revokeLive() + } + } + const open = (): void => { + if (stream === 'closed' && listeners.size > 0) { + stream = 'open' + client.subscribe('agentSession.subscribeStatus', {}, onFrame) + } + } + // Losing contact is never exit: only what a live host vouches for goes until it answers again. + client.onStateChange((state) => { + if (state === 'connected') { + open() + } else { + revokeLive() + } + }) + return { + subscribe: (listener) => { + listeners.add(listener) + open() + return () => { + listeners.delete(listener) + } + }, + getSnapshot: () => snapshot + } +} + +export function mobileStructuredSessionStatusFeed( + client: RpcClient +): MobileStructuredSessionStatusFeed { + let feed = feeds.get(client) + if (!feed) { + feed = createFeed(client) + feeds.set(client, feed) + } + return feed +} diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 97591878d7e2..d977ddd69e37 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -124,7 +124,8 @@ function Harness({ hostSupport: { promptCancel: promptCancelSupported, questionAnswers: questionAnswersSupported, - queuedMessages: false + queuedMessages: false, + statusFeed: false }, onSendError: vi.fn() }) diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts index e55a26e40c06..5eb123b196a9 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts @@ -15,7 +15,8 @@ export const SESSION_ID = 'session-1' export const CAPABLE: StructuredAgentSessionHostSupport = { promptCancel: false, questionAnswers: false, - queuedMessages: true + queuedMessages: true, + statusFeed: false } export const LEGACY: StructuredAgentSessionHostSupport = { ...CAPABLE, queuedMessages: false } diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index f2ff6cf443f2..140d36c1a3ea 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -21,6 +21,7 @@ import type { MobileNativeChatSession } from './use-mobile-native-chat-session' import type { NativeChatLiveTurnIndicator } from '../../../src/shared/native-chat-turn-status' import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state' import { useMobileStructuredStopPress } from './use-mobile-structured-stop-press' +import { useMobileStructuredSessionHostStopping } from './use-mobile-structured-session-host-stopping' import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses' import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' @@ -170,12 +171,18 @@ export function useMobileStructuredAgentSession(args: { selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state.items) const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) - // The phone reads no session status, so only its own press says "Stopping…". + const hostStopping = useMobileStructuredSessionHostStopping({ + client, + sessionId, + enabled: enabled && connected && hostSupport?.statusFeed === true + }) + // The host's word, bridged by this phone's own press until its Stop event lands. const stopPress = useMobileStructuredStopPress(sessionKey) - const stopping = isWorking && stopPress.pressed + const stopping = isWorking && (hostStopping || stopPress.pressed) + const stopRequestInFlight = isWorking && stopPress.pressed const turnIndicator = useMemo( - () => ({ thinking, activityText, stopping }), - [thinking, activityText, stopping] + () => ({ thinking, activityText, stopping, stopRequestInFlight }), + [thinking, activityText, stopping, stopRequestInFlight] ) const status = state.status === 'idle' ? 'idle' : state.status const approvalPrompt = useMemo( diff --git a/mobile/src/session/use-mobile-structured-session-host-stopping.ts b/mobile/src/session/use-mobile-structured-session-host-stopping.ts new file mode 100644 index 000000000000..75456c4d9cfa --- /dev/null +++ b/mobile/src/session/use-mobile-structured-session-host-stopping.ts @@ -0,0 +1,28 @@ +import { useCallback, useSyncExternalStore } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' + +const noSubscription = (): (() => void) => () => {} + +/** + * Whether the host says a person's Stop is ending session `sessionId`'s turn: the same status + * stream and field the desktop chat reads. `enabled` carries the host's status-feed capability + * and a live connection; off it, nothing is opened and nothing reads as stopping. + */ +export function useMobileStructuredSessionHostStopping(args: { + client: RpcClient | null + sessionId: string | null + enabled: boolean +}): boolean { + const { client, sessionId, enabled } = args + const feed = enabled && client && sessionId ? mobileStructuredSessionStatusFeed(client) : null + const subscribe = useCallback( + (listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()), + [feed] + ) + const readStopping = (): boolean => { + const summary = feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined + return summary?.status === 'working' && summary.stopping === true + } + return useSyncExternalStore(subscribe, readStopping, readStopping) +} diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index 46c7097dd967..87a15d4b6267 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -114,7 +114,12 @@ describe('useMobileStructuredAgentSession turn indicator', () => { listener?.(snapshot([runningTurn, reasoning], 3)) }) - expect(hook?.turnIndicator).toEqual({ thinking: true, activityText: null, stopping: false }) + expect(hook?.turnIndicator).toEqual({ + thinking: true, + activityText: null, + stopping: false, + stopRequestInFlight: false + }) }) it('hands the row the provider copy once real content ends the reasoning', async () => { @@ -146,7 +151,8 @@ describe('useMobileStructuredAgentSession turn indicator', () => { expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: 'Updating the plan', - stopping: false + stopping: false, + stopRequestInFlight: false }) }) @@ -173,7 +179,7 @@ describe('useMobileStructuredAgentSession turn indicator', () => { expect(hook?.turnIndicator.stopping).toBe(false) act(() => hook?.cancel()) - expect(hook?.turnIndicator.stopping).toBe(true) + expect(hook?.turnIndicator).toMatchObject({ stopping: true, stopRequestInFlight: true }) await act(async () => { const cancelled = { ok: true, value: { cancelled: true } } @@ -204,6 +210,148 @@ describe('useMobileStructuredAgentSession turn indicator', () => { ) }) - expect(hook?.turnIndicator).toEqual({ thinking: false, activityText: null, stopping: false }) + expect(hook?.turnIndicator).toEqual({ + thinking: false, + activityText: null, + stopping: false, + stopRequestInFlight: false + }) + }) +}) + +/** The phone's chat reads the host's "Stopping…" from the status stream the desktop chat reads. */ +describe("useMobileStructuredAgentSession and the host's Stopping", () => { + type RpcReply = { ok: boolean; result: unknown; _meta: { runtimeId: string } } + let renderer: ReactTestRenderer | null = null + let hook: ReturnType | null = null + let streams: Map void> + let cancelReply: ((reply: RpcReply) => void) | null + let subscribe: ReturnType + let client: RpcClient + const onSendError = vi.fn() + const runningTurn = journalItem(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) + + function hostSays(sessionId: string, stopping: boolean): void { + act(() => + streams.get('agentSession.subscribeStatus')?.({ + type: 'status', + session: { + sessionId, + workspaceId: 'workspace-a', + agent: 'codex', + status: 'working', + updatedAt: 1, + ...(stopping ? { stopping: true } : {}) + } + }) + ) + } + + let connected = true + + function Harness({ statusFeed }: { statusFeed: boolean }): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected, + hostSupport: { + promptCancel: false, + questionAnswers: false, + queuedMessages: false, + statusFeed + }, + agent: 'codex', + onSendError + }) + return null + } + + async function mount(options: { statusFeed?: boolean } = {}) { + act(() => { + renderer = create(createElement(Harness, { statusFeed: options.statusFeed ?? true })) + }) + await vi.waitFor(() => expect(streams.has('agentSession.subscribe')).toBe(true)) + act(() => streams.get('agentSession.subscribe')?.(snapshot([runningTurn], 3))) + } + + beforeEach(() => { + streams = new Map() + cancelReply = null + connected = true + subscribe = vi.fn((method: string, _params: unknown, onData: (value: unknown) => void) => { + streams.set(method, onData) + return vi.fn() + }) + const sendRequest = vi.fn((method: string): Promise => + method === 'agentSession.cancel' + ? new Promise((resolve) => (cancelReply = resolve)) + : Promise.resolve({ ok: true, result: {}, _meta: { runtimeId: 'r1' } }) + ) + // A fresh client per test: the status stream is one per client for its life. + client = { + sendRequest, + subscribe, + getState: () => 'connected', + onStateChange: () => () => {} + } as unknown as RpcClient + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + }) + + it('reads Stopping from the host with no press of its own, and keeps Stop for a repeat', async () => { + await mount() + + hostSays('session-1', true) + + expect(hook?.turnIndicator).toMatchObject({ stopping: true, stopRequestInFlight: false }) + }) + + it("stays Stopping after this phone's own Stop answers while the host still says so", async () => { + await mount() + act(() => hook?.cancel()) + hostSays('session-1', true) + expect(hook?.turnIndicator).toMatchObject({ stopping: true, stopRequestInFlight: true }) + + await act(async () => { + const cancelled = { ok: true, value: { cancelled: true } } + cancelReply?.({ ok: true, result: cancelled, _meta: { runtimeId: 'r1' } }) + }) + + await vi.waitFor(() => expect(hook?.turnIndicator.stopRequestInFlight).toBe(false)) + expect(hook?.turnIndicator.stopping).toBe(true) + }) + + it("never reads another session's Stopping", async () => { + await mount() + + hostSays('session-2', true) + + expect(hook?.turnIndicator.stopping).toBe(false) + }) + + it('reads nothing from the host while the phone is not connected', async () => { + await mount() + hostSays('session-1', true) + expect(hook?.turnIndicator.stopping).toBe(true) + + connected = false + act(() => renderer?.update(createElement(Harness, { statusFeed: true }))) + + expect(hook?.turnIndicator.stopping).toBe(false) + }) + + it('never opens the status stream on a host without the status feed', async () => { + await mount({ statusFeed: false }) + + expect(subscribe.mock.calls.map(([method]) => method)).not.toContain( + 'agentSession.subscribeStatus' + ) + expect(hook?.turnIndicator.stopping).toBe(false) }) }) diff --git a/mobile/src/transport/rpc-subscription-inventory.ts b/mobile/src/transport/rpc-subscription-inventory.ts index c1320c8f5c9e..859c82f025d3 100644 --- a/mobile/src/transport/rpc-subscription-inventory.ts +++ b/mobile/src/transport/rpc-subscription-inventory.ts @@ -87,6 +87,13 @@ export const RPC_SUBSCRIPTION_SITES: readonly RpcSubscriptionSite[] = [ method: 'agentSession.subscribe', coverage: { kind: 'unwritten-scenario' } }, + // The host's status stream, one per client, which the chat reads the host's "Stopping…" from. + // Mountable: the feed guards each frame and touches no device surface. The scenario is missing. + { + file: 'src/session/mobile-structured-session-status-feed.ts', + method: 'agentSession.subscribeStatus', + coverage: { kind: 'unwritten-scenario' } + }, // The session tab snapshot. Mountable behind the reconciliation controller the hook already // takes; no device surface is involved. { diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index a7fc03a14a61..4a22f2a5f5b6 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -78,6 +78,8 @@ export type NativeChatLiveTurnIndicator = { activityText: string | null /** A person's Stop is ending the turn. */ stopping?: boolean + /** This client's own Stop request is in flight: only then does its Stop control hold. */ + stopRequestInFlight?: boolean } export type NativeChatActiveTurnLabel = diff --git a/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts index 392c9f8d9526..8fdfd9033dec 100644 --- a/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-stopping.unit.test.ts @@ -21,6 +21,9 @@ const SUITE_TIMEOUT_MS = 180_000 const STATUS_FOLD = 'src/shared/structured-agent-session-agent-status.ts' // Both builds load a copy in the checkout cache, out of reach of `mobile/tsconfig.json`. const PHONE_ROW_READER = 'mobile/src/worktree/agent-row-display.ts' +const PROTOCOL_VERSION = 'src/shared/protocol-version.ts' +const MOBILE_ALLOWLIST = 'src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts' +const STATUS_STREAM = 'agentSession.subscribeStatus' const WORKTREE_ID = 'repo::/worktree' const NOW = 1_000_000 @@ -181,4 +184,24 @@ describe('cross-version Stopping', () => { expect(newBuild.agentDotState(row, NOW)).toBe('working') expect(newBuild.agentDisplayLabel(row, NOW)).toBe('running the tests') }) + + // The phone's chat reads Stopping from the status stream only on a host that lets phones call it. + it('an OLD host advertising the status feed refuses it to a NEW phone, which reads that as no feed', async () => { + const checkout = await materializeReleaseCheckout(PRE_CHANGE_REF) + const [oldProtocol, oldAllowlist, newProtocol, newAllowlist] = await Promise.all([ + importReleaseCheckoutModule(checkout, PROTOCOL_VERSION), + importReleaseCheckoutModule(checkout, MOBILE_ALLOWLIST), + import('../../../src/shared/protocol-version'), + import('../../../src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist') + ]) + const capability = newProtocol.AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY + expect(oldProtocol.RUNTIME_CAPABILITIES).toContain(capability) + // Anti-vacuous oracle: the old Set answers membership for a method it does grant phones. + expect(oldAllowlist.MOBILE_RPC_METHOD_ALLOWLIST).toContain('agentSession.subscribe') + // The capability alone is no grant: this host's mobile gate answers `forbidden`, which the + // phone's feed reads as absence (mobile-structured-session-status-feed.test.ts). + expect(oldAllowlist.MOBILE_RPC_METHOD_ALLOWLIST).not.toContain(STATUS_STREAM) + expect(newProtocol.RUNTIME_CAPABILITIES).toContain(capability) + expect(newAllowlist.MOBILE_RPC_METHOD_ALLOWLIST.has(STATUS_STREAM)).toBe(true) + }) }) From d59d573e8ef2effe1658730b2ba41710abb1c712 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:21:21 -0700 Subject: [PATCH 075/182] test: give the new fakes checked types or a SAFETY reason --- .../mobile-structured-session-status-feed.test.ts | 1 + .../use-mobile-structured-turn-indicator.test.tsx | 1 + .../structured-agent-session-turns.test.ts | 9 +++++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/mobile/src/session/mobile-structured-session-status-feed.test.ts b/mobile/src/session/mobile-structured-session-status-feed.test.ts index f3686f15fdae..adaee16ee141 100644 --- a/mobile/src/session/mobile-structured-session-status-feed.test.ts +++ b/mobile/src/session/mobile-structured-session-status-feed.test.ts @@ -26,6 +26,7 @@ describe("the phone's status stream", () => { frames = [] stateListeners = [] // A fresh client per test: the stream is one per client for its life. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reaches the client only through subscribe and onStateChange. client = { subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { frames.push(onData) diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index 87a15d4b6267..b56f4033b35e 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -290,6 +290,7 @@ describe("useMobileStructuredAgentSession and the host's Stopping", () => { : Promise.resolve({ ok: true, result: {}, _meta: { runtimeId: 'r1' } }) ) // A fresh client per test: the status stream is one per client for its life. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the session hook and its status feed call only these members here; a missing one would throw at once, never misread. client = { sendRequest, subscribe, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index d8ae3edb3a5c..48be45de6fd8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -157,8 +157,13 @@ describe('performCancel', () => { journal, fence: 1, adapter: { - cancelTurn: vi.fn(async () => ({ cancelled: false, refusal: { detail: REFUSAL } })) - } as unknown as StructuredAgentSessionAdapter, + acquire: vi.fn(), + dispatch: vi.fn(), + closeSession: vi.fn(), + cancelTurn: vi.fn(async () => ({ cancelled: false, refusal: { detail: REFUSAL } })), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), From bc5dcadbbf8612fac6d2f3343d1d457cd030e9d0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:41:17 -0700 Subject: [PATCH 076/182] revert(native-chat): drop the refused-named-turn rewrite of a Stop's note Codex can send its refusal before the turn's end frames, so reading the turn as still live after a flush races; the Codex Stop that ends nothing is handled by ending the process instead. The base's 'already finished' note and its test expectation return. The Claude wind-down failure revise stays. --- .../structured-agent-session-stopping.test.ts | 1 - .../structured-agent-session-turns-cancel.ts | 24 +-------- .../structured-agent-session-turns.test.ts | 49 +++---------------- 3 files changed, 10 insertions(+), 64 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts index 03e0f5cc74b6..b8992a9caa7b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -137,7 +137,6 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => it.each([ ['refused', 'names no turn', refused], ['left unconfirmed', 'names no turn', unconfirmed], - ['refused', 'names the turn', refused], ['left unconfirmed', 'names the turn', unconfirmed] ])( 'reads Working again once the agent %s a Stop that %s, while its turn runs on', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 328a7ee4c12b..c0874ddd5c1e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -100,20 +100,6 @@ async function reviseStopNoteUnconfirmed( ) } -/** Whether turn `turnId`, which a refused Stop named while it ran, still runs once the provider's - * rows land: a refusal of a turn that ended is not one of a turn that runs on. */ -async function namedTurnRunsOnOnceFlushed( - ctx: Pick, - turnId: string -): Promise { - try { - await ctx.flushStreamedEvents() - } catch { - // Unflushed, the journal still shows the turn the refusal left running. - } - return ctx.journal.activeTurnId() === turnId -} - export async function performCancel( ctx: AgentSessionTurnContext, input: { @@ -187,20 +173,14 @@ export async function performCancel( }) taken = outcome.cancelled cancelled = outcome.cancelled - // Refused while the turn it named runs on: the provider declined it, whatever the Stop was. - const refusedLiveTurn = - !cancelled && - input.turnId !== undefined && - input.turnId === liveTurnId && - (await namedTurnRunsOnOnceFlushed(ctx, input.turnId)) if (!cancelled && input.withdrewQueued && !(await isMainAgentWorkingOnceFlushed(ctx))) { // A Stop that withdrew what was queued and left nothing working ended what it was sent for, // named or not. The journal judges it: providers differ on refusing a turn that has ended. cancelled = true note = null - } else if (!cancelled && input.turnId !== undefined && !refusedLiveTurn) { + } else if (!cancelled && input.turnId !== undefined) { note = { kind: 'status', text: 'The provider had already finished this turn.' } - } else if (!cancelled && input.prompt && !refusedLiveTurn) { + } else if (!cancelled && input.prompt) { note = null } else if (!cancelled) { // Sent only while the chat reads working, so a Stop that ended nothing must say why. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 48be45de6fd8..7c5ecf1ee15d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -8,11 +8,6 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' - -/** Codex's own words refusing an interrupt, as its request error carries them. */ -const REFUSAL = { text: 'failed to interrupt turn', audience: 'person' as const } const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -135,7 +130,7 @@ describe('performCancel', () => { expect(resolveLiveTurnId?.()).toBeNull() }) - it('keeps the running lifecycle and says the agent declined a Stop naming the turn that runs on', async () => { + it('keeps the running lifecycle when cancellation cannot be confirmed', async () => { root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-unconfirmed-')) const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) await journal.appendItem( @@ -157,13 +152,8 @@ describe('performCancel', () => { journal, fence: 1, adapter: { - acquire: vi.fn(), - dispatch: vi.fn(), - closeSession: vi.fn(), - cancelTurn: vi.fn(async () => ({ cancelled: false, refusal: { detail: REFUSAL } })), - answerPrompt: vi.fn(), - setOption: vi.fn() - }, + cancelTurn: vi.fn(async () => ({ cancelled: false })) + } as unknown as StructuredAgentSessionAdapter, persistOptions: async () => undefined, resolvedBy: 'client-1', publish: vi.fn(), @@ -183,12 +173,7 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('stopRefused', { detail: REFUSAL }), { - surface: 'row' - }) - } + { kind: 'status', text: 'The provider had already finished this turn.' } ]) }) @@ -292,11 +277,11 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( async function cancelWith( outcome: Awaited>, input: { turnId?: string; withdrewQueued?: boolean }, - turnRow: 'none' | 'running' | 'lands-on-flush' | 'ends-on-flush' = 'none' + turnRow: 'none' | 'running' | 'lands-on-flush' = 'none' ) { root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-report-')) const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const openTurn = (state: 'running' | 'completed' = 'running') => + const openTurn = () => journal.appendItem( { provider: 'legacy', @@ -307,11 +292,11 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( { kind: 'status', text: 'Agent is working…', - turnLifecycle: { turnId: 'turn-1', state } + turnLifecycle: { turnId: 'turn-1', state: 'running' } }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - if (turnRow === 'running' || turnRow === 'ends-on-flush') { + if (turnRow === 'running') { await openTurn() } const ctx: AgentSessionTurnContext = { @@ -333,9 +318,6 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( if (turnRow === 'lands-on-flush') { await openTurn() } - if (turnRow === 'ends-on-flush') { - await openTurn('completed') - } }, now: () => 1 } @@ -373,19 +355,4 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( rows: ['The provider had already finished this turn.'] }) }) - - it('says the turn had finished when the refusal of the turn it named crossed that turn ending', async () => { - expect(await cancelWith({ cancelled: false }, { turnId: 'turn-1' }, 'ends-on-flush')).toEqual({ - cancelled: false, - rows: ['The provider had already finished this turn.'] - }) - }) - - it('says the agent declined a Stop naming the turn that runs on', async () => { - const reported = await cancelWith({ cancelled: false }, { turnId: 'turn-1' }, 'running') - expect(reported.cancelled).toBe(false) - expect(reported.rows).toEqual([ - agentSessionFailureWords(agentSessionFailureFact('stopRefused'), { surface: 'row' }).text - ]) - }) }) From 287c86cdec833e1e5793bbda6b4ddbe90f3d45fc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:43:39 -0700 Subject: [PATCH 077/182] fix(mobile): release the status stream when the host ends it; refusals last one connection The feed now drops the handle of a stream the host ended or refused, so the logical client never replays it on a later session. A refusal to phones holds for one connection, so a host updated while the phone stays paired is asked again. --- ...ile-structured-session-status-feed.test.ts | 97 ++++++++++++++++++- .../mobile-structured-session-status-feed.ts | 27 +++++- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/mobile/src/session/mobile-structured-session-status-feed.test.ts b/mobile/src/session/mobile-structured-session-status-feed.test.ts index adaee16ee141..3b8deaf5dfd6 100644 --- a/mobile/src/session/mobile-structured-session-status-feed.test.ts +++ b/mobile/src/session/mobile-structured-session-status-feed.test.ts @@ -2,8 +2,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionStatusSummary } from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' +import { createStableLogicalRpcClient } from '../transport/stable-logical-rpc-client' import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' +const REFUSAL = "Method 'agentSession.subscribeStatus' is not available to mobile clients" +const FORBIDDEN = { + type: 'error', + message: REFUSAL, + error: { code: 'forbidden', message: REFUSAL } +} + function summary(stopping: boolean): AgentSessionStatusSummary { return { sessionId: 'session-1', @@ -97,17 +105,96 @@ describe("the phone's status stream", () => { expect(read()).not.toHaveProperty('hostExecutionOwned') }) - it('reads a host that refuses the method to phones as one without it, and never asks again', () => { + it('reads a host that refuses the method to phones as one without it, asking once per connection', () => { mobileStructuredSessionStatusFeed(client).subscribe(() => {}) // As the host's mobile gate answers it, through the client's failed-opener frame. - const refusal = "Method 'agentSession.subscribeStatus' is not available to mobile clients" - frames[0]?.({ type: 'error', message: refusal, error: { code: 'forbidden', message: refusal } }) - connection('reconnecting') + frames[0]?.(FORBIDDEN) connection('connected') mobileStructuredSessionStatusFeed(client).subscribe(() => {}) - expect(client.subscribe).toHaveBeenCalledOnce() expect(read()).toBeUndefined() + + // A new connection may reach a host updated meanwhile. + connection('reconnecting') + connection('connected') + expect(client.subscribe).toHaveBeenCalledTimes(2) + }) +}) + +/** A physical session as the logical client sees it; a finished stream is dropped before its + * listener hears the last frame, as the client's stream registry does. */ +class PhysicalSession implements RpcClient { + readonly sendRequest = vi.fn() + readonly updateTerminalSubscriptionViewport = vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn() + readonly streams = new Set<(value: unknown) => void>() + /** Set: the opener fails inside `subscribe`, as a send that cannot reach the socket does. */ + failOnOpen: unknown = null + readonly subscribe = vi.fn((_method, _params, listener) => { + if (this.failOnOpen !== null) { + listener(this.failOnOpen) + return () => {} + } + this.streams.add(listener) + return () => this.streams.delete(listener) + }) + getState = (): ConnectionState => 'connected' + getReconnectAttempt = (): number => 0 + getLastConnectedAt = (): number | null => null + onStateChange = (): (() => void) => () => {} + + finish(frame: unknown): void { + const finished = [...this.streams] + this.streams.clear() + for (const listener of finished) { + listener(frame) + } + } + + statusSubscribes(): number { + return this.subscribe.mock.calls.filter(([method]) => method === 'agentSession.subscribeStatus') + .length + } +} + +describe("the phone's status stream across the logical client's sessions", () => { + it('never replays a refused stream when the client moves to another session', async () => { + const first = new PhysicalSession() + const logical = createStableLogicalRpcClient(first, 'lan') + mobileStructuredSessionStatusFeed(logical).subscribe(() => {}) + first.finish(FORBIDDEN) + + const next = new PhysicalSession() + await logical.migrateTo(next, 'relay') + + expect(first.statusSubscribes()).toBe(1) + expect(next.statusSubscribes()).toBe(0) + }) + + it('holds exactly one host stream after an ended stream and a move to another session', async () => { + const first = new PhysicalSession() + const logical = createStableLogicalRpcClient(first, 'lan') + mobileStructuredSessionStatusFeed(logical).subscribe(() => {}) + first.finish({ type: 'end' }) + + const next = new PhysicalSession() + await logical.migrateTo(next, 'relay') + + expect(next.statusSubscribes()).toBe(1) + expect(next.streams.size).toBe(1) + }) + + it('releases a stream that failed while it was being opened', async () => { + const first = new PhysicalSession() + first.failOnOpen = { type: 'error', message: 'Connection interrupted' } + const logical = createStableLogicalRpcClient(first, 'lan') + mobileStructuredSessionStatusFeed(logical).subscribe(() => {}) + + const next = new PhysicalSession() + await logical.migrateTo(next, 'relay') + + expect(next.statusSubscribes()).toBe(1) }) }) diff --git a/mobile/src/session/mobile-structured-session-status-feed.ts b/mobile/src/session/mobile-structured-session-status-feed.ts index aa839da82066..fad72c879b4d 100644 --- a/mobile/src/session/mobile-structured-session-status-feed.ts +++ b/mobile/src/session/mobile-structured-session-status-feed.ts @@ -50,8 +50,15 @@ function streamRefusal(value: unknown): { code?: string; message?: string } | nu function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { let snapshot: AgentSessionStatusSnapshot = new Map() - // `unavailable`: the host refused the method to phones, so this client never asks again. + // `unavailable`: the host refused the method to phones; asked again only on a new connection. let stream: 'closed' | 'open' | 'unavailable' = 'closed' + // Released once the host ends the stream, so the client never replays it on a later session. + let releaseStream = (): void => {} + const release = (): void => { + const pending = releaseStream + releaseStream = () => {} + pending() + } const listeners = new Set<() => void>() const setSnapshot = (next: AgentSessionStatusSnapshot): void => { if (next === snapshot) { @@ -66,6 +73,7 @@ function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { const onFrame = (raw: unknown): void => { if (isStatusEvent(raw)) { if (raw.type === 'end') { + release() stream = 'closed' revokeLive() return @@ -75,6 +83,7 @@ function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { } const refusal = streamRefusal(raw) if (refusal) { + release() // A host with the feed but from before phones could read it answers `forbidden`. stream = isMobileMethodUnavailableError(refusal.code, refusal.message) ? 'unavailable' @@ -85,16 +94,26 @@ function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { const open = (): void => { if (stream === 'closed' && listeners.size > 0) { stream = 'open' - client.subscribe('agentSession.subscribeStatus', {}, onFrame) + const dispose = client.subscribe('agentSession.subscribeStatus', {}, onFrame) + // A stream that ended inside `subscribe` is released at once. + if (stream === 'open') { + releaseStream = dispose + } else { + dispose() + } } } // Losing contact is never exit: only what a live host vouches for goes until it answers again. client.onStateChange((state) => { if (state === 'connected') { open() - } else { - revokeLive() + return + } + // The next connection may reach an updated host, so a refusal holds for one connection only. + if (stream === 'unavailable') { + stream = 'closed' } + revokeLive() }) return { subscribe: (listener) => { From feeb58957e8d02162feda93ef2c486462b80472b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:00:27 -0700 Subject: [PATCH 078/182] perf(native-chat): read the live turn's opener from its record when deriving Stopping After a Stop that named no turn, every later commit walked and copied the whole journal to find the live turn's record. The derivation now reads that record off the rendered snapshot's tail and decides with the same rule. --- .../journal-stop-marks.ts | 6 +++++ .../journal-stop-turn-end.ts | 15 ++++++++++++ .../structured-agent-session-stopping.test.ts | 24 ++++++++++++++++++- .../structured-agent-session-stopping.ts | 15 ++++++++---- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts index e79958a952c7..5cecb1c6b62e 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -4,6 +4,7 @@ import type { JournalReducerState } from './journal-reducer' import { latestAcceptedSendUnopened, + personStopDecidesOpenedTurn, personStopDecidesTurn, type JournalLatestStop } from './journal-stop-turn-end' @@ -24,4 +25,9 @@ export class JournalStopMarks { personStopDecides(turnId: string | null, endedAt?: number, openedBy?: string): boolean { return personStopDecidesTurn(this.deps.state(), turnId, endedAt, openedBy) } + + /** `personStopDecidesOpenedTurn`: the same, for a turn record the caller already read. */ + personStopDecidesOpenedTurn(turnId: string, userItemId: string | undefined): boolean { + return personStopDecidesOpenedTurn(this.deps.state(), turnId, userItemId) + } } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 4c28a0422a6d..79d2d19fe8a7 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -172,6 +172,21 @@ export function personStopDecidesTurn( .find((candidate) => candidate?.turnId === turnId) const userItemId = turn?.userItemId ?? (openedBy === undefined ? undefined : agentJournalSubmissionKey(openedBy)) + return personStopDecidesOpenedTurn(state, turnId, userItemId, endedAt) +} + +/** `personStopDecidesTurn` for turn `turnId` whose record the caller already holds: `userItemId` + * is that record's opener. Spares a reader that has the record the walk for it. */ +export function personStopDecidesOpenedTurn( + state: TurnEndState, + turnId: string, + userItemId: string | undefined, + endedAt?: number +): boolean { + const stop = state.queuePauseMarks.latestStop + if (stop === null || !stopIsAPersons(stop.event.reason)) { + return false + } return stopEndsTurnAsCancellation(state, turnId, userItemId, endedAt) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts index b8992a9caa7b..f550a7bc8666 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -3,7 +3,7 @@ // the journal on every publish, so it clears by itself. Driven through the real host and its status // feed, with turn rows named as Codex writes them. -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -222,6 +222,28 @@ describe('a Stop pressed before its send opened a turn', () => { expect(status()).not.toHaveProperty('stopping') }) + it("reads the stopped send's turn and a later one from the turn record, never walking the journal for it", async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + const status = watchStatus() + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(stopped, 'stopped') + const walk = vi.spyOn(journal().stopMarks, 'personStopDecides') + + await turn('turn-1', stopped, 'running') + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + await turn('turn-1', stopped, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + const later = await rig.workingSend() + await rig.settleAccepted(later, 'later') + await turn('turn-2', later, 'running') + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + + // Only the no-turn case may walk: a running turn's opener comes from its own record. + expect(walk.mock.calls.filter(([turnId]) => turnId !== null)).toEqual([]) + }) + it('reads a send made after the Stop as Working', async () => { rig = await createQueuedMessageTestRig() const stopped = await rig.workingSend() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts index b99f1bc184f5..8e1cfe5db133 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts @@ -5,6 +5,7 @@ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { newestStructuredAgentSessionTurn } from '../../../shared/structured-agent-session-live-turn' import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' /** A Stop's note saying it did not take: the agent refused it, or it went unconfirmed. */ @@ -24,19 +25,23 @@ function noteSaysStopTookNoEffect(item: AgentJournalRenderItem): boolean { * keeps it whichever order the answers came in. */ export function structuredAgentSessionStopping( - journal: Pick, + journal: Pick, items: readonly AgentJournalRenderItem[] ): boolean { const stop = journal.stopMarks.latest() if (stop === null) { return false } - const liveTurnId = journal.activeTurnId() - // Cheap exit before the full turn scan: a Stop naming a turn decides only that turn. - if (stop.event.turnId !== undefined && stop.event.turnId !== liveTurnId) { + // Read off the snapshot's tail with its opener, so no commit walks the whole journal for it. + const newest = newestStructuredAgentSessionTurn(items) + const live = newest?.state === 'running' ? newest : null + if (stop.event.turnId !== undefined && stop.event.turnId !== live?.turnId) { return false } - if (!journal.stopMarks.personStopDecides(liveTurnId)) { + const decides = live + ? journal.stopMarks.personStopDecidesOpenedTurn(live.turnId, live.userItemId) + : journal.stopMarks.personStopDecides(null) + if (!decides) { return false } let answered = false From c9ba088a1df26b9aaaff24afccf4b334a01c4fa8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:00:29 -0700 Subject: [PATCH 079/182] refactor(mobile): move the method-unavailable check into transport The status feed imported it from the Files tab's fallback. No behaviour change. --- mobile/src/files/MobileFileExplorerPanel.tsx | 3 ++- mobile/src/files/file-explorer-reply-schema.ts | 2 +- mobile/src/files/file-list-fallback.test.ts | 17 +---------------- mobile/src/files/file-list-fallback.ts | 14 -------------- .../mobile-structured-session-status-feed.ts | 2 +- .../transport/mobile-method-unavailable.test.ts | 17 +++++++++++++++++ .../src/transport/mobile-method-unavailable.ts | 13 +++++++++++++ 7 files changed, 35 insertions(+), 33 deletions(-) create mode 100644 mobile/src/transport/mobile-method-unavailable.test.ts create mode 100644 mobile/src/transport/mobile-method-unavailable.ts diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx index 864e8bfec731..28edfb94f716 100644 --- a/mobile/src/files/MobileFileExplorerPanel.tsx +++ b/mobile/src/files/MobileFileExplorerPanel.tsx @@ -28,7 +28,8 @@ import { resetDirectoryLoadRevisions, type DirectoryLoadRevisions } from './directory-load-revisions' -import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback' +import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable' +import { directoryCacheFromFileList } from './file-list-fallback' import { fileDirectoryRead, legacyFileListRead } from './mobile-file-explorer-operations' import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' import { MobileFileExplorerRow } from './mobile-file-explorer-row' diff --git a/mobile/src/files/file-explorer-reply-schema.ts b/mobile/src/files/file-explorer-reply-schema.ts index 696bdde55a1e..1379eb764fe0 100644 --- a/mobile/src/files/file-explorer-reply-schema.ts +++ b/mobile/src/files/file-explorer-reply-schema.ts @@ -31,7 +31,7 @@ export const fileDirectoryEntriesSchema = salvagingArray( * The capped flat list an older desktop answers when `files.readDir` is not allowlisted. * * `files` and `truncated` are both required and both read unguarded: directoryCacheFromFileList - * walks `files` and splits each `relativePath` (file-list-fallback.ts:48), and + * walks `files` and splits each `relativePath` (file-list-fallback.ts:34), and * MobileFileExplorerPanel.tsx:136 publishes `truncated` into the state that draws the "Showing * first 5000" note. A row without a string `relativePath` drops — it can name no directory — where * main crashed the whole fallback on it. diff --git a/mobile/src/files/file-list-fallback.test.ts b/mobile/src/files/file-list-fallback.test.ts index a69affc5fe8b..4f26d733081c 100644 --- a/mobile/src/files/file-list-fallback.test.ts +++ b/mobile/src/files/file-list-fallback.test.ts @@ -1,22 +1,7 @@ import { describe, expect, it } from 'vitest' -import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback' +import { directoryCacheFromFileList } from './file-list-fallback' import { getDirectoryCacheState } from './file-tree' -describe('isMobileMethodUnavailableError', () => { - it('detects old-desktop allowlist and missing-method failures', () => { - expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true) - expect(isMobileMethodUnavailableError('method_not_found', undefined)).toBe(true) - expect( - isMobileMethodUnavailableError( - 'some_code', - "Method 'files.readDir' is not available to mobile clients" - ) - ).toBe(true) - expect(isMobileMethodUnavailableError('internal', 'boom')).toBe(false) - expect(isMobileMethodUnavailableError(undefined, undefined)).toBe(false) - }) -}) - describe('directoryCacheFromFileList', () => { it('synthesizes every ancestor directory from flat paths', () => { const cache = directoryCacheFromFileList([ diff --git a/mobile/src/files/file-list-fallback.ts b/mobile/src/files/file-list-fallback.ts index e0db7236badd..6c9366d8cfb4 100644 --- a/mobile/src/files/file-list-fallback.ts +++ b/mobile/src/files/file-list-fallback.ts @@ -15,20 +15,6 @@ export type LegacyFilesListResult = { truncated: boolean } -// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts: -// 'forbidden' = method exists but is not mobile-allowlisted on the old -// desktop; 'method_not_found' = desktop predates the method entirely. -export function isMobileMethodUnavailableError( - code: string | undefined, - message: string | undefined -): boolean { - return ( - code === 'forbidden' || - code === 'method_not_found' || - message?.includes('not available to mobile clients') === true - ) -} - // Takes only the member it reads: the explorer's reply reader checks `relativePath` and passes the // rest of each row through, so naming the whole row here would re-declare what it deliberately did not. export function directoryCacheFromFileList( diff --git a/mobile/src/session/mobile-structured-session-status-feed.ts b/mobile/src/session/mobile-structured-session-status-feed.ts index fad72c879b4d..12db68c71772 100644 --- a/mobile/src/session/mobile-structured-session-status-feed.ts +++ b/mobile/src/session/mobile-structured-session-status-feed.ts @@ -10,7 +10,7 @@ import { type AgentSessionStatusSnapshot } from '../../../src/shared/agent-session-status-snapshot-fold' import type { AgentSessionStatusEvent } from '../../../src/shared/agent-session-wire' -import { isMobileMethodUnavailableError } from '../files/file-list-fallback' +import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable' import type { RpcClient } from '../transport/rpc-client' export type MobileStructuredSessionStatusFeed = { diff --git a/mobile/src/transport/mobile-method-unavailable.test.ts b/mobile/src/transport/mobile-method-unavailable.test.ts new file mode 100644 index 000000000000..3fcb2a779f54 --- /dev/null +++ b/mobile/src/transport/mobile-method-unavailable.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { isMobileMethodUnavailableError } from './mobile-method-unavailable' + +describe('isMobileMethodUnavailableError', () => { + it('detects old-desktop allowlist and missing-method failures', () => { + expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true) + expect(isMobileMethodUnavailableError('method_not_found', undefined)).toBe(true) + expect( + isMobileMethodUnavailableError( + 'some_code', + "Method 'files.readDir' is not available to mobile clients" + ) + ).toBe(true) + expect(isMobileMethodUnavailableError('internal', 'boom')).toBe(false) + expect(isMobileMethodUnavailableError(undefined, undefined)).toBe(false) + }) +}) diff --git a/mobile/src/transport/mobile-method-unavailable.ts b/mobile/src/transport/mobile-method-unavailable.ts new file mode 100644 index 000000000000..dd959366a2fb --- /dev/null +++ b/mobile/src/transport/mobile-method-unavailable.ts @@ -0,0 +1,13 @@ +// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts: +// 'forbidden' = method exists but is not mobile-allowlisted on the old +// desktop; 'method_not_found' = desktop predates the method entirely. +export function isMobileMethodUnavailableError( + code: string | undefined, + message: string | undefined +): boolean { + return ( + code === 'forbidden' || + code === 'method_not_found' || + message?.includes('not available to mobile clients') === true + ) +} From 0c36f0299144a0801292bfbd12bbe26e06dd556f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:08:43 -0700 Subject: [PATCH 080/182] test(native-chat): a send after a Stop reads Working before its turn opens Pins the derivation's running-only read of the newest turn: the stopped turn, already ended, must not keep the next send on Stopping. --- .../structured-agent-session-stopping.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts index f550a7bc8666..e4de6d279e94 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -187,6 +187,34 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => expect(status()).toMatchObject({ status: 'working', stopping: true }) }) + // The newest turn record is the stopped one until the next send's turn opens; it no longer runs. + it.each(['names its turn', 'names no turn'])( + 'reads a send made after a Stop that %s as Working before its turn opens', + async (naming) => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + const status = watchStatus() + if (naming === 'names its turn') { + await rig.settleAccepted(stopped, 'stopped') + await turn('turn-1', stopped, 'running') + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(await namedStop('turn-1')).toMatchObject({ ok: true }) + } else { + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(stopped, 'stopped') + await turn('turn-1', stopped, 'running') + } + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + await turn('turn-1', stopped, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + + await rig.workingSend() + + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + } + ) + it('never marks a turn that opened after the stopped one ended', async () => { const { sent, status } = await runningTurn() expect(await rig.stop()).toMatchObject({ ok: true }) From 6dc89f677e95d9d22274abe5decdb875cda59314 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:54:42 -0700 Subject: [PATCH 081/182] fix(sidebar): the compact row leads with Stopping so a narrow sidebar keeps it whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At the default width the row read 'Codex Chat - Stoppin…': the model and time keep their room and the line truncates from the end. Stopping now leads the line the way monitoring already does, so the chat name is what gets cut. Also pins that the turn bar keeps its running clock while the tail line says Stopping. --- ...ChatMessageList.stopping-turn-bar.test.tsx | 68 +++++++++++++++++++ .../worktree-card-compact-agent-line-order.ts | 19 ++++++ ...ee-card-compact-agent-row-stopping.test.ts | 20 ++++++ .../worktree-card-compact-agent-row.tsx | 11 +-- 4 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-card-compact-agent-line-order.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx new file mode 100644 index 000000000000..6681d5cf04da --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' +import type { + AgentJournalItemBody, + AgentJournalRenderItem +} from '../../../../shared/agent-session-journal-types' + +const turnItem: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'running' } + +function journalItem(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem { + return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body } +} + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const session: NativeChatLiveSession = { + messages: [ + { + id: 'user-stop', + role: 'user', + blocks: [{ type: 'text', text: 'Start the task' }], + timestamp: Date.now(), + source: 'transcript' + } + ], + status: 'working', + sessionId: 'session-1', + agent: 'codex', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' +} + +// The turn still runs while a Stop ends it, so its bar keeps the running clock; only the tail line +// says the turn is stopping, and the bar settles to "Interrupted after" once the turn ends. +describe("the turn bar while a person's Stop ends the turn", () => { + it('keeps the running clock above the Stopping tail line', () => { + render( + + ) + + const bar = screen.getByText('Working for 0s') + const stopping = screen.getByText('Stopping…') + expect(bar.compareDocumentPosition(stopping)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(screen.getAllByText(/Stopping/)).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-line-order.ts b/src/renderer/src/components/sidebar/worktree-card-compact-agent-line-order.ts new file mode 100644 index 000000000000..d228ca31bb6d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-line-order.ts @@ -0,0 +1,19 @@ +import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { agentRowStoppingLabel } from '@/lib/agent-row-stopping-label' +import type { getAgentDotState } from './worktree-card-agent-summary' + +/** The row's one line, lead first. A short status word the dot cannot carry leads, so the + * row's truncation cuts the prompt instead: monitoring, and a person's Stop ending the turn. */ +export function getCompactAgentLineOrder( + agent: DashboardAgentRowData, + dotState: ReturnType, + primary: string, + secondary: string +): { leadingText: string; trailingText: string } { + const statusLeads = + dotState === 'monitoring' || secondary === agentRowStoppingLabel(agent.entry, agent.state) + if (!statusLeads) { + return { leadingText: primary, trailingText: secondary } + } + return { leadingText: secondary, trailingText: primary === secondary ? '' : primary } +} diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts index 4fe1f2ad1213..ea6227d42285 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row-stopping.test.ts @@ -3,6 +3,7 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { makeTab } from '../../store/slices/store-test-helpers' import { buildWorktreeAgentRows } from './worktree-agent-rows' import { getAgentDotState } from './worktree-card-agent-summary' +import { getCompactAgentLineOrder } from './worktree-card-compact-agent-line-order' import { getCompactAgentSecondary } from './worktree-card-compact-agent-row' const NOW = new Date('2026-05-04T12:00:00.000Z').getTime() @@ -48,4 +49,23 @@ describe("the sidebar row while a person's Stop ends the turn", () => { 'Stopping…' ) }) + + // The row truncates its one line from the end, and the model and time keep their room. + it('leads with Stopping so a narrow sidebar cuts the chat name, not the status', () => { + const stopping = row({ state: 'working', stopping: true, stateStartedAt: NOW }) + const secondary = getCompactAgentSecondary(stopping, NOW) + + expect( + getCompactAgentLineOrder(stopping, getAgentDotState(stopping), 'Codex Chat', secondary) + ).toEqual({ leadingText: 'Stopping…', trailingText: 'Codex Chat' }) + }) + + it('leads with the chat name otherwise', () => { + const working = row({ state: 'working', stateStartedAt: NOW }) + const secondary = getCompactAgentSecondary(working, NOW) + + expect( + getCompactAgentLineOrder(working, getAgentDotState(working), 'Codex Chat', secondary) + ).toEqual({ leadingText: 'Codex Chat', trailingText: secondary }) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index c2357240f95f..469e4a2ecc8e 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -16,6 +16,7 @@ import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer' import { formatShortTimeAgo } from '@/lib/short-time-ago' import { agentVerdictStatusLine } from '@/lib/agent-verdict-status-line' import { agentRowStoppingLabel } from '@/lib/agent-row-stopping-label' +import { getCompactAgentLineOrder } from './worktree-card-compact-agent-line-order' function getCompactAgentPrimary( agent: DashboardAgentRowData, @@ -137,10 +138,12 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ const stableMessage = turnHoldable && !currentMessage && held?.turn === turn ? held.message : undefined const secondary = getCompactAgentSecondary(agent, now, stableMessage) - // Why: sidebar truncation must preserve the passive-vs-active distinction. - const leadingText = dotState === 'monitoring' ? secondary : primary - const trailingText = - dotState === 'monitoring' ? (primary === secondary ? '' : primary) : secondary + const { leadingText, trailingText } = getCompactAgentLineOrder( + agent, + dotState, + primary, + secondary + ) const rowTitle = `${leadingText}${trailingText ? ` - ${trailingText}` : ''}` const model = agent.entry.model?.trim() ?? '' const shortTime = getCompactAgentTime(agent, now) From 32c945f44c7d272f406736448f2ab5e94f6593bc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:23:51 -0700 Subject: [PATCH 082/182] test(native-chat): the retry of a close whose exit was unproven writes no second Stop event The idle sweep finishes a stop left owed with that stop's own cause. It is the same stop, so its event stands alone and the child's end keeps the cause, for a person's close and an eviction. --- ...d-agent-session-stop-event-entries.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index a40633381454..dd0acf82b371 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -286,6 +286,28 @@ describe('every Stop entry writes its event, with its reason, before it ends the expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) }) + // The idle sweep finishes a stop whose exit was unproven: the same stop, so its event stands alone. + it.each([['user-close' as const], ['evict' as const]])( + 'writes one event for a close (%s) whose exit was unproven, and none for its retry', + async (cause) => { + rig = await createQueuedMessageTestRig({ idleSweep: MANUAL_IDLE_SWEEP }) + await runningTurn() + rig.closeSession.mockResolvedValueOnce(false) + const session = () => rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)! + + await rig.host.close(HOST_TEST_SESSION, cause).catch(() => undefined) + expect(session().child).not.toBeNull() + expect(session().owesProviderChildWindDown).toMatchObject({ cause }) + expect(stopEvents()).toEqual([{ reason: cause, turnId: 'turn-1', at: expect.any(Number) }]) + + await idleSweep().tick() + + expect(session().owesProviderChildWindDown).toBeUndefined() + expect(stopEvents().map((event) => event.reason)).toEqual([cause]) + expect(session().lastEndedChild?.cause).toBe(cause) + } + ) + it("writes nothing at quit, whose resume marker's trigger records why", async () => { rig = await createQueuedMessageTestRig() await runningTurn() From eee56e673e04770d9f153c7ab9668889e650bf6f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:57:41 -0700 Subject: [PATCH 083/182] fix(native-chat): read and write a Stop's answer by the turn its event records Stop notes are now one row per turn, keyed by the turn the Stop's event records. Performing a Stop and deriving Stopping share that key. A refused or unconfirmed answer never overwrites one that took at the same key; only a session-ending Stop's failed wind-down downgrades it, and that step now revises the note the Stop actually wrote, carried on the wind-down. Stopping reads the turn's note whenever it was first written, plus newer notes no other turn owns. Test fixtures gain the host logger and the phone's quietRepeatedStop. --- ...-mobile-structured-turn-indicator.test.tsx | 1 + .../structured-agent-session-chat-stop.ts | 7 +- ...agent-session-status-feed-stopping.test.ts | 4 + ...structured-agent-session-stop-wind-down.ts | 78 ++++++++++++ ...red-agent-session-stopping-answers.test.ts | 98 +++++++++++++++ .../structured-agent-session-stopping.test.ts | 48 +++++++- .../structured-agent-session-stopping.ts | 61 +++++++--- ...ured-agent-session-turn-stop-notes.test.ts | 41 +++++++ ...tructured-agent-session-turn-stop-notes.ts | 39 +++++- .../structured-agent-session-turns-cancel.ts | 115 ++++++------------ 10 files changed, 386 insertions(+), 106 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stopping-answers.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.test.ts diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index b56f4033b35e..8e87d59b099a 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -260,6 +260,7 @@ describe("useMobileStructuredAgentSession and the host's Stopping", () => { promptCancel: false, questionAnswers: false, queuedMessages: false, + quietRepeatedStop: false, statusFeed }, agent: 'codex', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index f4d45b2e1625..3e7f0a44939b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -25,9 +25,11 @@ import { } from './structured-agent-session-send-preparation' import { endStoppedStructuredAgentSession, - isMainAgentWorkingOnceFlushed, - performCancel, type StructuredAgentSessionStopWindDown +} from './structured-agent-session-stop-wind-down' +import { + isMainAgentWorkingOnceFlushed, + performCancel } from './structured-agent-session-turns-cancel' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -135,7 +137,6 @@ export function mutateWithChatStop( await endStoppedStructuredAgentSession( { ...ctx, adapter: context.deps.adapter }, owed, - envelope.clientOperationId, stopChild, (error) => context.deps.logger.warn("ending a stopped chat's provider session failed", { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts index 3a907d0ada9a..0d2d2055a53b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts @@ -10,6 +10,7 @@ import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wi import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' const SESSION = 'status-session' @@ -45,6 +46,7 @@ describe("the status feed's Stopping", () => { child: { phase: 'ready', generation: 'child-1', fence } }) const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, session]]), statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), getRecord: () => agentSessionRecordFixture(), @@ -82,6 +84,7 @@ describe("the status feed's Stopping", () => { const fence = agentSessionRecordFixture().lease.runtimeFence const published: AgentSessionStatusSummary[] = [] const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), getRecord: () => agentSessionRecordFixture(), @@ -126,6 +129,7 @@ describe("the status feed's Stopping", () => { const fence = agentSessionRecordFixture().lease.runtimeFence const published: AgentSessionStatusSummary[] = [] const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), statusSink: () => ({ publish: (summary) => published.push(summary), forget: () => {} }), getRecord: () => agentSessionRecordFixture(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts new file mode 100644 index 000000000000..df2a3f14ee11 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts @@ -0,0 +1,78 @@ +// A session-ending Stop's second step: the provider's wind-down, then the child's end, and what +// the Stop's note says when that end fails while the work runs on. + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { isMainAgentWorkingOnceFlushed } from './structured-agent-session-turns-cancel' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +/** What a Stop that ends the provider's session leaves its next serialized step: whether the + * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went + * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. */ +export type StructuredAgentSessionStopWindDown = { + waitsForProvider: boolean + stoppedAt: number + /** The note the Stop wrote, which a failed wind-down revises. */ + stopNote: AgentJournalItemIdentity +} + +/** + * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent + * meanwhile reaches the child it ends. The Stop has answered: a failure here is reported, and while + * the work runs on its note is revised to say the Stop went unconfirmed. The next operation that + * reaches the agent retries the wind-down it leaves owed, and so does the idle sweep's next tick. + */ +export async function endStoppedStructuredAgentSession( + ctx: Pick< + AgentSessionTurnContext, + 'sessionId' | 'adapter' | 'journal' | 'fence' | 'flushStreamedEvents' + >, + windDown: StructuredAgentSessionStopWindDown, + stopChild: () => Promise, + onError: (error: unknown) => void +): Promise { + try { + if (windDown.waitsForProvider) { + await ctx.adapter.awaitStoppedRequestEnd?.(ctx.sessionId, windDown.stoppedAt) + } + await stopChild() + } catch (error) { + onError(error) + await reviseStopNoteUnconfirmed(ctx, windDown.stopNote).catch(onError) + } +} + +/** While the work it stopped runs on, the Stop's own note, if it wrote one, says what a lost + * interrupt's says. Work that ended took the Stop, whatever became of the child. */ +async function reviseStopNoteUnconfirmed( + ctx: Pick, + identity: AgentJournalItemIdentity +): Promise { + if (!(await isMainAgentWorkingOnceFlushed(ctx))) { + return + } + const itemId = agentJournalItemKey(identity) + let written: { turnScope?: AgentJournalTurnScope } | undefined + ctx.journal.visitItemsWithLinkage((id, _sequence, _body, linkage) => { + if (id === itemId) { + written = linkage + } + }) + if (written === undefined) { + return + } + await ctx.journal.appendItem( + identity, + { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + }, + { fence: ctx.fence, turnScope: written.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE } + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-answers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-answers.test.ts new file mode 100644 index 000000000000..ee41d4f8579c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-answers.test.ts @@ -0,0 +1,98 @@ +// Which notes answer a person's Stop for "Stopping…": the note keyed by the turn its event records, +// whenever it was first written, and notes written after the event that no other turn owns. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalStatusItem +} from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import { structuredAgentSessionStopping } from './structured-agent-session-stopping' + +const FENCE = 1 +const journals = createTrackedJournalOpener() +let root: string +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-stopping-answers-')) + journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + stateDirectory: join(root, 'session-1') + }) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function turnIdentity(turnId: string): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 0 } +} + +function turn(turnId: string, state: 'running' | 'completed') { + return journal.appendItem( + turnIdentity(turnId), + { + kind: 'turn', + turnId, + state, + startedAt: 1, + ...(state === 'running' ? {} : { completedAt: 2 }) + }, + { fence: FENCE, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +const unconfirmed: AgentJournalStatusItem = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) +} + +function stopping(): boolean { + return structuredAgentSessionStopping(journal, journal.snapshot().items) +} + +describe("a person's Stop's answers", () => { + it('reads the answer to a later Stop of the same turn from the note an earlier one wrote', async () => { + await turn('turn-1', 'running') + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, FENCE) + await journal.appendItem(structuredAgentSessionStopNoteIdentity('turn-1'), unconfirmed, { + fence: FENCE, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + // A send made since gives the next Stop of this turn an event of its own; its press revised + // the turn's note in place, so that note keeps its first position. + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, FENCE) + + expect(stopping()).toBe(false) + }) + + it("never reads a Stop of an ended turn as the answer to the running turn's Stop", async () => { + const ended = await turn('turn-1', 'completed') + await turn('turn-2', 'running') + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-2' }, FENCE) + // A phone's late Stop of the ended turn, whose interrupt went unconfirmed. + await journal.appendItem(structuredAgentSessionStopNoteIdentity('turn-1'), unconfirmed, { + fence: FENCE, + turnScope: { kind: 'turn', turnItemId: ended.itemId } + }) + + expect(stopping()).toBe(true) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts index e4de6d279e94..bdf5aa0f5243 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -143,6 +143,10 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => async (_, naming, cancel) => { const { status } = await runningTurn() rig.cancelTurn.mockImplementationOnce(cancel) + if (cancel === unconfirmed) { + // A failed interrupt ends the agent's process; this one cannot be ended either. + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + } const stopped = naming === 'names the turn' ? namedStop('turn-1') : rig.stop() expect(await stopped).toMatchObject({ ok: true }) @@ -153,6 +157,26 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => } ) + it.each([ + [ + 'refuses', + async (): Promise => ({ cancelled: false, refusal: {} }) + ], + ['never answers', unconfirmed] + ])( + 'ends the agent when it %s a Stop naming the running turn, so Stopping ends with the turn', + async (_, cancel) => { + const { status } = await runningTurn() + rig.cancelTurn.mockImplementationOnce(cancel) + + expect(await namedStop('turn-1')).toMatchObject({ ok: true }) + + await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) + await eventually(() => expect(status()?.status).not.toBe('working')) + expect(status()).not.toHaveProperty('stopping') + } + ) + it('reads Stopping again when a later press of the same Stop takes', async () => { const { status } = await runningTurn() rig.cancelTurn.mockImplementationOnce(async () => ({ cancelled: false })) @@ -183,7 +207,9 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => expect(await first).toMatchObject({ ok: true }) expect(await repeat).toMatchObject({ ok: true }) - await eventually(() => expect(stopAnswers()).toEqual(['took', 'stopRefused'])) + // One row per turn: the refusal never overwrites the answer that took. + await eventually(() => expect(rig.cancelTurn).toHaveBeenCalledTimes(2)) + expect(stopAnswers()).toEqual(['took']) expect(status()).toMatchObject({ status: 'working', stopping: true }) }) @@ -291,6 +317,24 @@ describe('a Stop pressed before its send opened a turn', () => { }) describe('a Stop whose provider ends its session', () => { + it("revises the note a Stop pressed before its turn showed wrote, once that turn opened and the child's end failed", async () => { + rig = await createQueuedMessageTestRig({ stopEndsSession: true }) + const sent = await rig.workingSend() + const status = watchStatus() + const kill = Promise.withResolvers() + rig.closeSession.mockImplementationOnce(() => kill.promise) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) + + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + kill.reject(new Error('the kill timed out')) + + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + }) + it("reads Working once the child's end fails, and Stopping again when the next Stop retries it", async () => { const { status } = await runningTurn({ stopEndsSession: true }) rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) @@ -306,7 +350,7 @@ describe('a Stop whose provider ends its session', () => { rig.closeSession.mockImplementationOnce(() => retried.promise) expect(await rig.stop()).toMatchObject({ ok: true }) await eventually(() => expect(rig.closeSession).toHaveBeenCalledTimes(2)) - expect(stopAnswers()).toEqual(['cancelUnconfirmed', 'took']) + expect(stopAnswers()).toEqual(['took']) expect(status()).toMatchObject({ status: 'working', stopping: true }) retried.resolve(true) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts index 8e1cfe5db133..cca83fc795a4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts @@ -3,26 +3,39 @@ // Stop's event, the live turn and the Stop's own answer — so nothing is stored, and it clears when // the turn ends or the Stop answers that it stopped nothing. Clients only present it. -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { newestStructuredAgentSessionTurn } from '../../../shared/structured-agent-session-live-turn' -import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' +import { + isStructuredAgentSessionStopNote, + structuredAgentSessionStopNoteIdentity +} from './structured-agent-session-command-turn' +import { stopNoteTookNoEffect } from './structured-agent-session-turn-stop-notes' -/** A Stop's note saying it did not take: the agent refused it, or it went unconfirmed. */ -function noteSaysStopTookNoEffect(item: AgentJournalRenderItem): boolean { - const { body } = item - if (body.kind !== 'status' || !('failure' in body)) { - return false +/** The newest turn record and its item, read backward off a position-ordered snapshot. */ +function newestTurnItem( + items: readonly AgentJournalRenderItem[] +): { item: AgentJournalRenderItem; turn: AgentJournalTurnLifecycle } | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] + const turn = item ? readAgentJournalTurn(item.body) : null + if (item && turn) { + return { item, turn } + } } - const kind = body.failure?.kind - return kind === 'stopRefused' || kind === 'cancelUnconfirmed' + return null } /** * Whether the latest person's Stop is still ending the work it stopped: the live turn it named, or - * with none, the sends it stopped (`personStopDecidesTurn`), unless every Stop answer since then - * says it stopped nothing. A repeat press writes no event, only its answer, so one press that took - * keeps it whichever order the answers came in. + * with none, the sends it stopped (`personStopDecidesTurn`), unless every answer to it says it + * stopped nothing. Its answer is the note keyed by the turn its event records, revised in place by + * each press, plus the notes of Stops pressed before any turn showed (keyed by their operation). + * A press that took is never overwritten by a later no-effect one, so one that took keeps it. */ export function structuredAgentSessionStopping( journal: Pick, @@ -33,21 +46,31 @@ export function structuredAgentSessionStopping( return false } // Read off the snapshot's tail with its opener, so no commit walks the whole journal for it. - const newest = newestStructuredAgentSessionTurn(items) - const live = newest?.state === 'running' ? newest : null - if (stop.event.turnId !== undefined && stop.event.turnId !== live?.turnId) { + const newest = newestTurnItem(items) + const live = newest?.turn.state === 'running' ? newest : null + if (stop.event.turnId !== undefined && stop.event.turnId !== live?.turn.turnId) { return false } const decides = live - ? journal.stopMarks.personStopDecidesOpenedTurn(live.turnId, live.userItemId) + ? journal.stopMarks.personStopDecidesOpenedTurn(live.turn.turnId, live.turn.userItemId) : journal.stopMarks.personStopDecides(null) if (!decides) { return false } + const answerTurnId = stop.event.turnId ?? live?.turn.turnId + const turnAnswer = + answerTurnId === undefined + ? null + : agentJournalItemKey(structuredAgentSessionStopNoteIdentity(answerTurnId)) let answered = false for (const item of items) { - if (item.sequence > stop.sequence && isStructuredAgentSessionStopNote(item.itemId)) { - if (!noteSaysStopTookNoEffect(item)) { + const answers = + item.itemId === turnAnswer || + (item.sequence > stop.sequence && + isStructuredAgentSessionStopNote(item.itemId) && + (item.turnScope?.kind !== 'turn' || item.turnScope.turnItemId === live?.item.itemId)) + if (answers) { + if (!stopNoteTookNoEffect(item.body)) { return true } answered = true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.test.ts new file mode 100644 index 000000000000..18f9a3607080 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import { + structuredAgentSessionStopEventTurnId, + structuredAgentSessionStopNoteKey +} from './structured-agent-session-turn-stop-notes' + +const journal = { activeTurnId: () => 'turn-live' } + +describe("a Stop's note key", () => { + it('names the turn the Stop named, as its event records it', () => { + const turnId = structuredAgentSessionStopEventTurnId(journal, { + namedTurnId: 'turn-old', + endsSession: false + }) + expect(structuredAgentSessionStopNoteKey(turnId, 'op-1')).toEqual( + structuredAgentSessionStopNoteIdentity('turn-old') + ) + }) + + // Its event names whatever is in flight, never a named turn that already ended. + it('names the running turn for a Stop that ends the provider session, whatever it named', () => { + const turnId = structuredAgentSessionStopEventTurnId(journal, { + namedTurnId: 'turn-old', + endsSession: true + }) + expect(structuredAgentSessionStopNoteKey(turnId, 'op-1')).toEqual( + structuredAgentSessionStopNoteIdentity('turn-live') + ) + }) + + it('falls back to the operation with no turn running', () => { + const turnId = structuredAgentSessionStopEventTurnId( + { activeTurnId: () => null }, + { endsSession: true } + ) + expect(structuredAgentSessionStopNoteKey(turnId, 'op-1')).toEqual( + structuredAgentSessionStopNoteIdentity('op-1') + ) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts index 0f86c053ad66..151f120bb50d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts @@ -2,9 +2,14 @@ // by the turn's id whether it still runs or has ended, and keyed by it, so a repeated Stop rewrites // the one row instead of adding one. -import type { AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' export const STOP_NOTE_CANCELLATION_REQUESTED = 'Cancellation requested.' @@ -34,3 +39,35 @@ export function structuredAgentSessionNamedTurnScope( .items.findLast((item) => readAgentJournalTurn(item.body)?.turnId === turnId) return turn ? { kind: 'turn', turnItemId: turn.itemId } : null } + +/** + * The turn a person's Stop records on its event, and so keys its note by: the one it named, unless + * the Stop ends the provider's session (then whatever runs), else the one running. Null: none ran. + */ +export function structuredAgentSessionStopEventTurnId( + journal: Pick, + stop: { namedTurnId?: string; endsSession: boolean } +): string | null { + return structuredAgentSessionStoppedTurnId( + journal, + stop.endsSession ? undefined : stop.namedTurnId + ) +} + +/** The one key of a Stop's note, for its writer and every reader: the turn its event records + * (`structuredAgentSessionStopEventTurnId`), else, with no turn, the Stop's own operation. */ +export function structuredAgentSessionStopNoteKey( + eventTurnId: string | null | undefined, + clientOperationId: string +): AgentJournalItemIdentity { + return structuredAgentSessionStopNoteIdentity(eventTurnId ?? clientOperationId) +} + +/** A Stop's note saying it did not take: the agent refused it, or it went unconfirmed. */ +export function stopNoteTookNoEffect(body: AgentJournalItemBody | null | undefined): boolean { + if (body?.kind !== 'status' || !('failure' in body)) { + return false + } + const kind = body.failure?.kind + return kind === 'stopRefused' || kind === 'cancelUnconfirmed' +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index ffd80b25665c..304ef0628600 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -3,27 +3,29 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalStatusItem, - type AgentJournalTurnScope +import type { + AgentJournalItemIdentity, + AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' import { isStructuredAgentSessionCommandTurnId, - structuredAgentSessionCommandWasStopped, - structuredAgentSessionStopNoteIdentity + structuredAgentSessionCommandWasStopped } from './structured-agent-session-command-turn' +import type { StructuredAgentSessionStopWindDown } from './structured-agent-session-stop-wind-down' import { answerCancelOfSettledPrompt, validatePendingPrompt } from './structured-agent-session-prompt-state' import { STOP_NOTE_CANCELLATION_REQUESTED, + stopNoteTookNoEffect, structuredAgentSessionNamedTurnScope, + structuredAgentSessionStopEventTurnId, structuredAgentSessionStopNamesTurnNotLive, + structuredAgentSessionStopNoteKey, structuredAgentSessionStoppedTurnId } from './structured-agent-session-turn-stop-notes' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' @@ -77,68 +79,6 @@ function stopRefusedNote( } } -/** What a Stop that ends the provider's session leaves its next serialized step: whether the - * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went - * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. */ -export type StructuredAgentSessionStopWindDown = { waitsForProvider: boolean; stoppedAt: number } - -/** - * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent - * meanwhile reaches the child it ends. The Stop has answered: a failure here is reported, and while - * the work runs on its note is revised to say the Stop went unconfirmed. The next operation that - * reaches the agent retries the wind-down it leaves owed, and so does the idle sweep's next tick. - */ -export async function endStoppedStructuredAgentSession( - ctx: Pick< - AgentSessionTurnContext, - 'sessionId' | 'adapter' | 'journal' | 'fence' | 'flushStreamedEvents' - >, - windDown: StructuredAgentSessionStopWindDown, - clientOperationId: string, - stopChild: () => Promise, - onError: (error: unknown) => void -): Promise { - try { - if (windDown.waitsForProvider) { - await ctx.adapter.awaitStoppedRequestEnd?.(ctx.sessionId, windDown.stoppedAt) - } - await stopChild() - } catch (error) { - onError(error) - await reviseStopNoteUnconfirmed(ctx, clientOperationId).catch(onError) - } -} - -/** While the work it stopped runs on, the Stop's own note, if it wrote one, says what a lost - * interrupt's says. Work that ended took the Stop, whatever became of the child. */ -async function reviseStopNoteUnconfirmed( - ctx: Pick, - clientOperationId: string -): Promise { - if (!(await isMainAgentWorkingOnceFlushed(ctx))) { - return - } - const identity = structuredAgentSessionStopNoteIdentity(clientOperationId) - const itemId = agentJournalItemKey(identity) - let written: { turnScope?: AgentJournalTurnScope } | undefined - ctx.journal.visitItemsWithLinkage((id, _sequence, _body, linkage) => { - if (id === itemId) { - written = linkage - } - }) - if (written === undefined) { - return - } - await ctx.journal.appendItem( - identity, - { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) - }, - { fence: ctx.fence, turnScope: written.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE } - ) -} - export async function performCancel( ctx: AgentSessionTurnContext, input: { @@ -176,10 +116,19 @@ export async function performCancel( kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } - // The turn the Stop names, read before the cancel settles it: the note reports on that turn. + // Read while the child is live: a provider whose Stop is a session boundary loses it next. + const endsSession = + input.endSession !== undefined && ctx.adapter.stopEndsSession?.(ctx.sessionId) === true + // The turn the Stop's event records, read before the cancel settles it: the note sits on that + // turn and is keyed by it, as every reader of the Stop's answer looks it up. + const eventTurnId = structuredAgentSessionStopEventTurnId(ctx.journal, { + ...(input.turnId !== undefined ? { namedTurnId: input.turnId } : {}), + endsSession + }) + const noteIdentity = structuredAgentSessionStopNoteKey(eventTurnId, input.clientOperationId) const turnScope = - (input.turnId !== undefined && !input.scope - ? structuredAgentSessionNamedTurnScope(ctx.journal, input.turnId) + (eventTurnId !== null && !input.scope + ? structuredAgentSessionNamedTurnScope(ctx.journal, eventTurnId) : null) ?? ctx.journal.liveTurnScope() // Only the provider's end or the child's ends a command. A command the provider has not opened a // turn for, would not interrupt, or was already asked to stop, ends with its child; that child's @@ -195,9 +144,6 @@ export async function performCancel( !namesTurnNotLive const stoppedBefore = runningCommand && structuredAgentSessionCommandWasStopped(ctx.journal, liveTurnId) - // Read while the child is live: a provider whose Stop is a session boundary loses it next. - const endsSession = - input.endSession !== undefined && ctx.adapter.stopEndsSession?.(ctx.sessionId) === true const stoppedAt = Date.now() // The provider's own answer; unset when its cancel threw, leaving the effect unknown. let taken: boolean | undefined @@ -259,7 +205,7 @@ export async function performCancel( if (endsSession && (!namesTurnNotLive || taken !== false)) { // An interrupt the provider took is worth waiting on, turn row or not: a Stop before the echo // has none, and the echo still opens the turn the Stop interrupted. - input.endSession?.({ waitsForProvider: taken === true, stoppedAt }) + input.endSession?.({ waitsForProvider: taken === true, stoppedAt, stopNote: noteIdentity }) cancelled = true // The child's end confirms the Stop, so a refused or unconfirmed interrupt says nothing more. if (note !== null) { @@ -301,14 +247,21 @@ export async function performCancel( await ctx.flushStreamedEvents() } const value = { ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), cancelled } - if (input.scope || note === null) { + if (input.scope || note === null || noteKeepsTakenAnswer(ctx.journal, noteIdentity, note)) { return { ok: true, value } } // Keyed by the turn it stopped, so another Stop of that turn rewrites this row, never adds one. - await ctx.journal.appendItem( - structuredAgentSessionStopNoteIdentity(stoppedTurnId ?? input.clientOperationId), - note, - { fence: ctx.fence, turnScope } - ) + await ctx.journal.appendItem(noteIdentity, note, { fence: ctx.fence, turnScope }) return { ok: true, value } } + +/** A Stop that took keeps its row: a later press's no-effect answer never overwrites it. Only a + * wind-down that failed while the work runs on downgrades it (`endStoppedStructuredAgentSession`). */ +function noteKeepsTakenAnswer( + journal: Pick, + identity: AgentJournalItemIdentity, + note: AgentJournalStatusItem +): boolean { + const written = journal.itemBody(agentJournalItemKey(identity)) + return stopNoteTookNoEffect(note) && written !== null && !stopNoteTookNoEffect(written) +} From 528d56a44440bee58fe6110edf30bc9b651f9b3f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:12:05 -0700 Subject: [PATCH 084/182] refactor(native-chat): read a Stop's target once for its event and its note The chat's Stop now reads what it is aimed at (the named turn and whether the Stop ends the provider session) once, and both its event and its note's key derive their turn from that one value through the same rule. Adds the host test for a Stop naming an ended turn on a provider whose Stop ends the session. --- .../structured-agent-session-chat-stop.ts | 22 +++++++++++-------- .../structured-agent-session-queued-stop.ts | 10 +++++---- .../structured-agent-session-stopping.test.ts | 21 ++++++++++++++++++ ...tructured-agent-session-turn-stop-notes.ts | 5 ++++- .../structured-agent-session-turns-cancel.ts | 17 +++++++++----- 5 files changed, 56 insertions(+), 19 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 3e7f0a44939b..f5cc899e6675 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -31,6 +31,7 @@ import { isMainAgentWorkingOnceFlushed, performCancel } from './structured-agent-session-turns-cancel' +import type { StructuredAgentSessionStopTarget } from './structured-agent-session-turn-stop-notes' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' type ChatStopOutcome = TurnOutcome @@ -60,16 +61,17 @@ export function mutateWithChatStop( const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) // The same for every client: once the Stop takes effect its event is written, and the queue's // pause follows from it. The cards stay published; no text rides the answer. - const stop = (ctx: AgentSessionTurnContext): Promise => - runRecordedStop( + const stop = (ctx: AgentSessionTurnContext): Promise => { + // Read once for the Stop's event and its note. A Stop that ends the provider's session ends + // whatever is in flight, so both name the live turn, never a named turn that already ended. + const target: StructuredAgentSessionStopTarget = { + ...(turnId !== undefined ? { namedTurnId: turnId } : {}), + endsSession: ctx.adapter.stopEndsSession?.(ctx.sessionId) === true + } + return runRecordedStop( ctx, - { - reason: 'user-stop', - caller: caller.callerKey, - // A Stop that ends the provider's session ends whatever is in flight, so its event names - // the live turn, or none (the turn opened next), never a named turn that already ended. - ...(ctx.adapter.stopEndsSession?.(ctx.sessionId) === true ? {} : named) - }, + { reason: 'user-stop', caller: caller.callerKey }, + target, async (tookEffect) => { // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. const withdrawn = await ctx.journal.rejectQueuedSubmissions( @@ -114,11 +116,13 @@ export function mutateWithChatStop( endSession: (owed) => { windDown = { owed, ctx } }, + stopTarget: target, withdrewQueued: withdrawn.length > 0 } ) } ) + } const result = mutateStructuredAgentSession( context, caller, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index 316793751c55..717bdf57cf59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -15,8 +15,9 @@ import { import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' import { isMainAgentWorkingOnceFlushed } from './structured-agent-session-turns-cancel' import { + structuredAgentSessionStopEventTurnId, structuredAgentSessionStopNamesTurnNotLive, - structuredAgentSessionStoppedTurnId + type StructuredAgentSessionStopTarget } from './structured-agent-session-turn-stop-notes' /** The one unsettled-card predicate /clear's carry and the budget share: @@ -37,14 +38,15 @@ export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMes */ export async function runRecordedStop( ctx: AgentSessionTurnContext, - /** `turnId` absent: the turn running when the Stop takes effect, if any. */ - event: Omit, + event: Omit, + /** Its turn, by the rule its note is keyed by (`structuredAgentSessionStopEventTurnId`). */ + target: StructuredAgentSessionStopTarget, stop: (tookEffect: () => Promise) => Promise> ): Promise> { const skipped = (error: unknown): void => report(ctx, 'event row', error) return stop(() => { try { - const turnId = structuredAgentSessionStoppedTurnId(ctx.journal, event.turnId) ?? undefined + const turnId = structuredAgentSessionStopEventTurnId(ctx.journal, target) ?? undefined return ctx.journal .appendStopEvent({ ...event, ...(turnId ? { turnId } : {}) }, ctx.fence) .then(() => undefined, skipped) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts index bdf5aa0f5243..e3a00e70bedf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -317,6 +317,27 @@ describe('a Stop pressed before its send opened a turn', () => { }) describe('a Stop whose provider ends its session', () => { + // The phone names the turn its journal last showed; the follow-up already written runs next. + it('keys a Stop naming an ended turn by the turn its event records, so its answer clears Stopping', async () => { + const { sent, status } = await runningTurn({ stopEndsSession: true }) + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + const followUp = await rig.workingSend() + const kill = Promise.withResolvers() + rig.closeSession.mockImplementationOnce(() => kill.promise) + + expect(await namedStop('turn-1')).toMatchObject({ ok: true }) + await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + await rig.settleAccepted(followUp, 'follow-up') + await turn('turn-2', followUp, 'running') + kill.reject(new Error('the kill timed out')) + + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + }) + it("revises the note a Stop pressed before its turn showed wrote, once that turn opened and the child's end failed", async () => { rig = await createQueuedMessageTestRig({ stopEndsSession: true }) const sent = await rig.workingSend() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts index 151f120bb50d..5a4d52673fcf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts @@ -40,13 +40,16 @@ export function structuredAgentSessionNamedTurnScope( return turn ? { kind: 'turn', turnItemId: turn.itemId } : null } +/** What a person's Stop is aimed at, read once per Stop for both its event and its note. */ +export type StructuredAgentSessionStopTarget = { namedTurnId?: string; endsSession: boolean } + /** * The turn a person's Stop records on its event, and so keys its note by: the one it named, unless * the Stop ends the provider's session (then whatever runs), else the one running. Null: none ran. */ export function structuredAgentSessionStopEventTurnId( journal: Pick, - stop: { namedTurnId?: string; endsSession: boolean } + stop: StructuredAgentSessionStopTarget ): string | null { return structuredAgentSessionStoppedTurnId( journal, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 304ef0628600..55679e75e6a1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -26,7 +26,8 @@ import { structuredAgentSessionStopEventTurnId, structuredAgentSessionStopNamesTurnNotLive, structuredAgentSessionStopNoteKey, - structuredAgentSessionStoppedTurnId + structuredAgentSessionStoppedTurnId, + type StructuredAgentSessionStopTarget } from './structured-agent-session-turn-stop-notes' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -99,6 +100,9 @@ export async function performCancel( /** Hands the child's end to the Stop's next serialized step, for a provider whose Stop ends * its session. */ endSession?: (windDown: StructuredAgentSessionStopWindDown) => void + /** The person's Stop this runs for, as its event read it, which keys its note. Absent: a Stop + * with no event, keyed by the turn it names. */ + stopTarget?: StructuredAgentSessionStopTarget /** The host already withdrew queued messages for this Stop. */ withdrewQueued?: boolean /** The session's child records: a background Stop reaches the tasks they offer a stop. */ @@ -121,10 +125,13 @@ export async function performCancel( input.endSession !== undefined && ctx.adapter.stopEndsSession?.(ctx.sessionId) === true // The turn the Stop's event records, read before the cancel settles it: the note sits on that // turn and is keyed by it, as every reader of the Stop's answer looks it up. - const eventTurnId = structuredAgentSessionStopEventTurnId(ctx.journal, { - ...(input.turnId !== undefined ? { namedTurnId: input.turnId } : {}), - endsSession - }) + const eventTurnId = structuredAgentSessionStopEventTurnId( + ctx.journal, + input.stopTarget ?? { + ...(input.turnId !== undefined ? { namedTurnId: input.turnId } : {}), + endsSession: false + } + ) const noteIdentity = structuredAgentSessionStopNoteKey(eventTurnId, input.clientOperationId) const turnScope = (eventTurnId !== null && !input.scope From 03d1fcc9efdadd1ca160c7b2622464e9f8ab8080 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:34:07 -0700 Subject: [PATCH 085/182] fix(native-chat): the host never steers a message into a turn a Stop is ending A queued card's Send-now, or a send made while a person's Stop ends the turn, went to the agent as a steer into that turn. The delivery loop now holds any waiting message while the host's own Stopping reading holds, and sends it as its own turn once the turn ends. The Stopping reader also stops at the Stop's position and looks the turn's note up by key, instead of walking the whole journal. --- .../structured-agent-session-delivery-loop.ts | 6 + ...agent-session-steer-while-stopping.test.ts | 110 ++++++++++++++++++ ...red-agent-session-stopping-answers.test.ts | 28 +++++ .../structured-agent-session-stopping.ts | 34 ++++-- 4 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts 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 0414836f4a5e..48566fdbddb6 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 @@ -47,6 +47,7 @@ import { structuredAgentSessionWindDownWaitHolds } from './structured-agent-session-wind-down-wait-row' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' +import { structuredAgentSessionStoppingNow } from './structured-agent-session-stopping' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionDeliveryLoopDeps = { @@ -189,6 +190,11 @@ export class StructuredAgentSessionDeliveryLoop { if (!oldest || (session.child && structuredAgentSessionCommandRunning(session.journal))) { return this.stop(sessionId) } + // Never steer into a turn a person's Stop is ending: the message runs after it, as its own + // turn. The turn's end is a commit, which wakes the loop again. + if (session.child && structuredAgentSessionStoppingNow(session.journal)) { + return this.stop(sessionId) + } // Already waiting on a stop that could not prove its child gone: a new message retries it, and // any other retry that lands wakes this loop itself, so the waiting row's own commit does not. // Another operation's retry may have failed first, so the row is made sure of here too. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts new file mode 100644 index 000000000000..6ba7b8b4c353 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts @@ -0,0 +1,110 @@ +// Nothing steers into a turn a person's Stop is ending: a card's Send-now or a send made then waits +// for the turn to end, and runs after it as its own turn. The host owns the rule, so a client of +// any version gets it. + +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +async function turn(turnId: string, clientMessageId: string, state: 'running' | 'interrupted') { + await journal().appendItem( + { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, + { + kind: 'turn', + turnId, + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(clientMessageId), + ...(state === 'running' ? { state } : { state, completedAt: Date.now() + 5 }) + }, + { + fence: rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) +} + +function watchStatus(): () => AgentSessionStatusSummary | undefined { + const events: AgentSessionStatusEvent[] = [] + rig.host.subscribeStatus({ id: 'list', emit: (event) => events.push(event) }) + return () => { + for (const event of events.toReversed()) { + if (event.type === 'status' && event.session.sessionId === HOST_TEST_SESSION) { + return event.session + } + if (event.type === 'snapshot') { + return event.sessions.find((session) => session.sessionId === HOST_TEST_SESSION) + } + } + return undefined + } +} + +/** The session's lane, after every step queued on it so far, the delivery loop's included. */ +async function laneDrained(): Promise { + for (let step = 0; step < 5; step += 1) { + await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) + } +} + +/** Turn `turn-1` running, a person's Stop ending it, and what was dispatched by then. */ +async function stoppingTurn(options: { card?: true } = {}) { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + const status = watchStatus() + let cardId: string | undefined + if (options.card) { + await rig.send('queued card', 'queue-if-active').result + await eventually(async () => expect(await rig.drafts()).toHaveLength(1)) + cardId = (await rig.drafts())[0]?.messageId + } + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + return { sent, status, cardId, dispatched: rig.dispatch.mock.calls.length } +} + +describe("a message sent while a person's Stop ends the turn", () => { + it.each([ + ['a queued card sent now', true], + ['a send', false] + ])('waits for the turn to end, then runs as its own turn: %s', async (_, fromCard) => { + const { sent, status, cardId, dispatched } = await stoppingTurn(fromCard ? { card: true } : {}) + + const result = cardId ? await rig.sendNow(cardId) : await rig.send('one more thing').result + expect(result).toMatchObject({ ok: true }) + // Held on the host: the delivery loop has run its steps and handed nothing over. + await laneDrained() + const held = await rig.submission(result.ok ? result.value.clientMessageId : '') + expect(held).toMatchObject({ dispatchState: 'pending' }) + expect(held?.handedOverAt).toBeUndefined() + expect(rig.dispatch.mock.calls.length).toBe(dispatched) + expect(status()).toMatchObject({ stopping: true }) + + await turn('turn-1', sent, 'interrupted') + + await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-answers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-answers.test.ts index ee41d4f8579c..8008c8289e7c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-answers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-answers.test.ts @@ -10,6 +10,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemIdentity, + type AgentJournalRenderItem, type AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' @@ -95,4 +96,31 @@ describe("a person's Stop's answers", () => { expect(stopping()).toBe(true) }) + + it('reads only the journal from the running turn on, however long the chat before it', async () => { + for (let index = 0; index < 40; index += 1) { + await journal.appendItem( + { provider: 'orca', clientMessageId: `earlier-${index}` }, + { kind: 'status', text: `earlier row ${index}` }, + { fence: FENCE, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await turn('turn-1', 'running') + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, FENCE) + const items = journal.snapshot().items + const turnIndex = items.findIndex((item) => item.body.kind === 'turn') + let lowestRead = items.length + const counted: AgentJournalRenderItem[] = [] + items.forEach((item, index) => + Object.defineProperty(counted, index, { + enumerable: true, + get: () => { + lowestRead = Math.min(lowestRead, index) + return item + } + }) + ) + expect(structuredAgentSessionStopping(journal, counted)).toBe(true) + expect(lowestRead).toBe(turnIndex) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts index cca83fc795a4..0167b656c959 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts @@ -38,7 +38,7 @@ function newestTurnItem( * A press that took is never overwritten by a later no-effect one, so one that took keeps it. */ export function structuredAgentSessionStopping( - journal: Pick, + journal: Pick, items: readonly AgentJournalRenderItem[] ): boolean { const stop = journal.stopMarks.latest() @@ -62,13 +62,22 @@ export function structuredAgentSessionStopping( answerTurnId === undefined ? null : agentJournalItemKey(structuredAgentSessionStopNoteIdentity(answerTurnId)) - let answered = false - for (const item of items) { + // The turn's note, whenever it was first written, by its key. + const turnNote = turnAnswer === null ? null : journal.itemBody(turnAnswer) + if (turnNote !== null && !stopNoteTookNoEffect(turnNote)) { + return true + } + let answered = turnNote !== null + // The snapshot is in journal order, so the notes written since the Stop are its tail. + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] + if (!item || item.sequence <= stop.sequence) { + break + } const answers = - item.itemId === turnAnswer || - (item.sequence > stop.sequence && - isStructuredAgentSessionStopNote(item.itemId) && - (item.turnScope?.kind !== 'turn' || item.turnScope.turnItemId === live?.item.itemId)) + item.itemId !== turnAnswer && + isStructuredAgentSessionStopNote(item.itemId) && + (item.turnScope?.kind !== 'turn' || item.turnScope.turnItemId === live?.item.itemId) if (answers) { if (!stopNoteTookNoEffect(item.body)) { return true @@ -78,3 +87,14 @@ export function structuredAgentSessionStopping( } return !answered } + +/** The same derivation read straight off the journal, for a host step that must not act into + * a turn a person's Stop is ending. */ +export function structuredAgentSessionStoppingNow( + journal: Pick +): boolean { + return ( + journal.stopMarks.latest() !== null && + structuredAgentSessionStopping(journal, journal.snapshot().items) + ) +} From 723fa84a80fedd750b2264d9f154d6501ba208ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:34:11 -0700 Subject: [PATCH 086/182] feat(native-chat): while Stopping, the composer says a message runs after the stop Desktop and phone: the composer placeholder reads "Queue a message to run after the stop" while the chat reads Stopping, and a queued card's Steer (and the desktop's steer shortcut) is held. New key translated in all 6 catalogs. --- .../session/MobileNativeChatOverlay.test.ts | 32 ++++++++++++++ .../src/session/MobileNativeChatOverlay.tsx | 5 ++- .../MobileNativeChatQueuedMessages.test.tsx | 19 +++++++++ .../MobileNativeChatQueuedMessages.tsx | 11 +++-- .../src/session/MobileNativeChatView.test.ts | 11 +++++ mobile/src/session/MobileNativeChatView.tsx | 9 +--- ...e-native-chat-composer-placeholder.test.ts | 19 +++++++++ ...mobile-native-chat-composer-placeholder.ts | 17 ++++++++ .../use-mobile-native-chat-queued-slot.ts | 17 ++++++-- .../native-chat/NativeChatComposer.test.tsx | 4 ++ .../native-chat/NativeChatComposer.tsx | 2 + .../native-chat/NativeChatComposerField.tsx | 5 ++- .../NativeChatQueuedMessageCard.tsx | 5 ++- .../NativeChatQueuedMessageList.test.tsx | 15 +++++++ .../NativeChatQueuedMessageList.tsx | 4 ++ ...iveChatStructuredSession.stopping.test.tsx | 42 ++++++++++++++----- ...tiveChatStructuredSession.test-harness.tsx | 2 + .../NativeChatStructuredSession.tsx | 9 ++-- .../native-chat-composer-placeholder.test.ts | 25 +++++++++++ .../native-chat-composer-target.ts | 13 +++++- .../native-chat/native-chat-composer-types.ts | 2 + .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + src/shared/native-chat-turn-status.ts | 2 + 29 files changed, 245 insertions(+), 32 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-composer-placeholder.test.ts create mode 100644 mobile/src/session/mobile-native-chat-composer-placeholder.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 0ade370d13b9..23edda19a50e 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -25,6 +25,8 @@ type Tick = { streamingText?: string streamLive?: boolean identity?: string + /** The host says a person's Stop is ending the turn. */ + stopping?: boolean } function overlayElement(tick: Tick): ReturnType { @@ -34,6 +36,11 @@ function overlayElement(tick: Tick): ReturnType { nativeChatSession: { messages: tick.messages ?? [], status: 'ready' }, nativeChatAgent: 'claude', nativeChatAgentWorking: tick.streamLive ?? false, + nativeChatTurnIndicator: { + thinking: false, + activityText: null, + stopping: tick.stopping ?? false + }, nativeChatStreamingText: tick.streamingText, nativeChatStreamLive: tick.streamLive ?? false, nativeChatStreamScopeKey: tick.identity ?? 'tab-a', @@ -185,3 +192,28 @@ describe('MobileNativeChatOverlay streaming gate', () => { expect(streaming()).toBeNull() }) }) + +describe("MobileNativeChatOverlay while a person's Stop ends the turn", () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function steerHeld(tick: Tick): Promise { + await act(async () => { + renderer = create(overlayElement(tick)) + }) + const view = renderer!.root.find((node) => node.type === 'ChatView') + return view.props.queuedSlot?.cards?.props?.steerHeld + } + + it("holds the queued cards' Steer while the agent works and the host says Stopping", async () => { + expect(await steerHeld({ streamLive: true, stopping: true })).toBe(true) + }) + + it('leaves Steer to the cards otherwise', async () => { + expect(await steerHeld({ streamLive: true, stopping: false })).toBe(false) + }) +}) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 3650528d4660..ab1f3c4a7e04 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -68,7 +68,10 @@ export function MobileNativeChatOverlay({ onEdit: queued.edit, pause: queued.pause, onResume: queued.resume, - sessionKey: queued.sessionKey + sessionKey: queued.sessionKey, + // Nothing steers into a turn a Stop is ending; the host holds such a send until it ends. + steerHeld: + controller.nativeChatAgentWorking && controller.nativeChatTurnIndicator?.stopping === true }) if (!controller.showNativeChat) { return null diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index b2929f1a1a57..a8c740bdfd4d 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -156,6 +156,25 @@ describe('MobileNativeChatQueuedMessages', () => { expect(nodeTypes(rows[1]!)).toContain('CornerDownRight') }) + it("holds Steer while a person's Stop ends the turn; Delete still works", async () => { + const onSend = vi.fn(async () => true) + const onDelete = vi.fn(async () => true) + const mounted = await mount({ + cards: [card({ messageId: 'w' })], + onSend, + onDelete, + steerHeld: true + }) + const steer = mounted.root.findByProps({ + accessibilityLabel: 'Submit without interrupting the model' + }) + expect(steer.props.disabled).toBe(true) + expect(steer.props.accessibilityState).toEqual({ disabled: true }) + const trash = mounted.root.findByProps({ accessibilityLabel: 'Delete this queued message' }) + await act(async () => trash.props.onPress()) + expect(onDelete).toHaveBeenCalledWith('w') + }) + it('deletes a card from its trash button', async () => { const onDelete = vi.fn(async () => true) const mounted = await mount({ cards: [card({ messageId: 'w' })], onDelete }) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index 57f2fa410ffe..3a1fcd65fa35 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -34,6 +34,8 @@ export type MobileNativeChatQueuedMessagesProps = { /** The whole queue's pause: the box's first row, with Resume. */ pause?: MobileQueuePause onResume?: () => Promise + /** The chat reads Stopping: no card steers into the turn a Stop is ending. */ + steerHeld?: boolean } /** The host-held queued drafts, as one box of compact rows between transcript and @@ -44,7 +46,8 @@ export function MobileNativeChatQueuedMessages({ onDelete, onEdit, pause, - onResume + onResume, + steerHeld = false }: MobileNativeChatQueuedMessagesProps): React.JSX.Element | null { // One in-flight action per card; a second tap must not double-consume. The ref // closes the same-frame double tap the disabled state cannot. @@ -136,7 +139,7 @@ export function MobileNativeChatQueuedMessages({ [ styles.textAction, pressed && styles.pressed, - busy && styles.disabled + (busy || steerHeld) && styles.disabled ]} - disabled={busy} + disabled={busy || steerHeld} onPress={() => void run(card.messageId, onSend)} > {steers ? ( diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 6f2297650039..c7ef47381eac 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -165,6 +165,17 @@ describe('MobileNativeChatView', () => { expect(stop.props.disabled).toBe(true) }) + it('tells the composer a message sent now runs after the stop', async () => { + await render({ + structuredActivityUi: true, + agentWorking: true, + canStop: true, + turnIndicator: { thinking: false, activityText: null, stopping: true } + }) + const composer = renderer!.root.find((node) => node.type === 'Composer') + expect(composer.props.placeholder).toBe('Queue a message to run after the stop') + }) + // A Stop the provider took and never answered ends only at a repeat Stop. it('keeps Stop for the repeat that escalates while the host alone says Stopping', async () => { await render({ diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 2b7c1c62f1a7..04f4606f20d0 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -20,6 +20,7 @@ import { } from '../../../src/shared/native-chat-turn-status' import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import { colors } from '../theme/mobile-theme' +import { mobileNativeChatComposerPlaceholder } from './mobile-native-chat-composer-placeholder' import { styles } from './mobile-native-chat-view-styles' import { mobileNativeChatListFooter } from './mobile-native-chat-list-footer' import { @@ -455,13 +456,7 @@ export function MobileNativeChatView({ onMicPressIn={onMicPressIn} onMicPressOut={onMicPressOut} disabled={lockReason !== null} - placeholder={ - lockReason === 'disconnected' - ? 'Reconnecting…' - : lockReason === 'waiting' - ? 'Waiting for terminal…' - : 'Message, @files, /commands' - } + placeholder={mobileNativeChatComposerPlaceholder(lockReason, stopping)} filePaths={filePaths} onNeedFiles={onNeedFiles} /> diff --git a/mobile/src/session/mobile-native-chat-composer-placeholder.test.ts b/mobile/src/session/mobile-native-chat-composer-placeholder.test.ts new file mode 100644 index 000000000000..48c05dce55c7 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-composer-placeholder.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { mobileNativeChatComposerPlaceholder } from './mobile-native-chat-composer-placeholder' + +describe("the phone chat composer's placeholder", () => { + it('says a message runs after the stop while the chat reads Stopping', () => { + expect(mobileNativeChatComposerPlaceholder(null, true)).toBe( + 'Queue a message to run after the stop' + ) + }) + + it('reads as usual otherwise', () => { + expect(mobileNativeChatComposerPlaceholder(null, false)).toBe('Message, @files, /commands') + }) + + it('says why the composer is locked first', () => { + expect(mobileNativeChatComposerPlaceholder('disconnected', true)).toBe('Reconnecting…') + expect(mobileNativeChatComposerPlaceholder('waiting', true)).toBe('Waiting for terminal…') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-composer-placeholder.ts b/mobile/src/session/mobile-native-chat-composer-placeholder.ts new file mode 100644 index 000000000000..04a8ce80847b --- /dev/null +++ b/mobile/src/session/mobile-native-chat-composer-placeholder.ts @@ -0,0 +1,17 @@ +import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../src/shared/native-chat-turn-status' +import type { MobileNativeChatInputLockReason } from './MobileNativeChatView' + +/** The chat composer's placeholder: why it is locked, else that a message sent now runs after a + * Stop the chat reads as stopping, else the usual prompt. */ +export function mobileNativeChatComposerPlaceholder( + lockReason: MobileNativeChatInputLockReason | null, + stopping: boolean +): string { + if (lockReason === 'disconnected') { + return 'Reconnecting…' + } + if (lockReason === 'waiting') { + return 'Waiting for terminal…' + } + return stopping ? NATIVE_CHAT_TURN_STATUS_COPY.queueAfterStop : 'Message, @files, /commands' +} diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.ts b/mobile/src/session/use-mobile-native-chat-queued-slot.ts index aac805a5c20e..41b3eb48c8cc 100644 --- a/mobile/src/session/use-mobile-native-chat-queued-slot.ts +++ b/mobile/src/session/use-mobile-native-chat-queued-slot.ts @@ -31,7 +31,7 @@ export function useMobileNativeChatQueuedSlot( const { composerInputRef, editQueuedMessage } = useMobileNativeChatQueuedEditFocus( queuedMessages.onEdit ) - const { cards, onSend, onDelete, pause, onResume, sessionKey } = queuedMessages + const { cards, onSend, onDelete, pause, onResume, sessionKey, steerHeld } = queuedMessages return useMemo( () => ({ cards: createElement(MobileNativeChatQueuedMessages, { @@ -41,10 +41,21 @@ export function useMobileNativeChatQueuedSlot( onDelete, onEdit: editQueuedMessage, pause, - onResume + onResume, + steerHeld }), composerInputRef }), - [cards, composerInputRef, editQueuedMessage, onDelete, onResume, onSend, pause, sessionKey] + [ + cards, + composerInputRef, + editQueuedMessage, + onDelete, + onResume, + onSend, + pause, + sessionKey, + steerHeld + ] ) } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 300ea5e6ca95..aa1eca3f3134 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => ({ attachDisabled?: boolean sendButtonDisabled?: boolean isStopping?: boolean + queuesAfterStop?: boolean autocomplete?: { mode: string; items?: { kind: string; name: string }[] } } | null, modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'unknown', @@ -262,6 +263,9 @@ describe('NativeChatComposer', () => { rerender() expect(mocks.fieldProps).toMatchObject({ sendButtonDisabled: true, isStopping: true }) + // Answered: Stop is back, and the placeholder says a message sent now runs after the stop. + rerender() + expect(mocks.fieldProps).toMatchObject({ sendButtonDisabled: false, queuesAfterStop: true }) }) it('associates a delayed submit with its optimistic cache entry', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 3d82188a41e1..d69699921756 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -59,6 +59,7 @@ const NativeChatComposerPane = forwardRef void onDelete: () => void onEdit: () => void @@ -152,7 +155,7 @@ export function NativeChatQueuedMessageCard({ - diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 41fd4852a202..10f0b52f1014 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -116,6 +116,21 @@ describe('NativeChatQueuedMessageList', () => { expect(owner.remove).toHaveBeenCalledWith('draft-2') }) + it("holds every card's Steer while a person's Stop ends the turn; Delete still works", () => { + const owner = controller([card({ messageId: 'draft-1', position: 1 })]) + render( + + + + ) + const steer = screen.getByRole('button', { name: 'Steer' }) + expect(steer).toHaveProperty('disabled', true) + fireEvent.click(steer) + expect(owner.steer).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + expect(owner.remove).toHaveBeenCalledWith('draft-1') + }) + it.each(['Delete', 'Steer'])( '%s hands focus to the composer once the focused card is gone', async (name) => { diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 17223e0194cb..add3768eaf80 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -13,9 +13,12 @@ import type { StructuredAgentSessionQueuedMessagesController } from './use-struc */ export function NativeChatQueuedMessageList({ controller, + steerHeld = false, focusComposer }: { controller: StructuredAgentSessionQueuedMessagesController + /** The chat reads Stopping: no card steers into the turn a Stop is ending. */ + steerHeld?: boolean /** Where focus goes once Steer, Edit or Delete takes the focused card away. */ focusComposer?: () => void }): React.JSX.Element { @@ -61,6 +64,7 @@ export function NativeChatQueuedMessageList({ key={card.messageId} card={card} showsSteerShortcut={card === newest} + steerHeld={steerHeld} onSteer={() => refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} onEdit={() => refocusAfter(controller.edit(card.messageId))} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx index 1de41489a162..16abfc493e62 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx @@ -31,18 +31,21 @@ vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) +import { TooltipProvider } from '@/components/ui/tooltip' import { NativeChatStructuredSession } from './NativeChatStructuredSession' function renderPane(): void { render( - + + + ) } @@ -82,11 +85,29 @@ describe("the chat pane while a person's Stop ends the turn", () => { // A Stop the provider took and never answered (a Codex command) ends only at a second Stop. await waitFor(() => expect(mocks.messageListProps).toMatchObject({ stopping: true })) - expect(mocks.composerProps).toMatchObject({ isStopping: false }) + expect(mocks.composerProps).toMatchObject({ isStopping: false, queuesAfterStop: true }) + // Nothing steers into a turn a Stop is ending. + expect(mocks.composerProps?.steerQueued).toBeUndefined() mocks.composerProps?.onStop?.() expect(mocks.stop).toHaveBeenCalledOnce() }) + it("holds a queued card's Steer while the host says Stopping", async () => { + mocks.turnId = 'turn-1' + mocks.queuedCards = [ + { messageId: 'draft-1', position: 1, text: 'one more thing', state: 'waiting', hold: 'turn' } + ] + renderPane() + await waitFor(() => expect(hostStatus.emit).not.toBeNull()) + const steer = (): HTMLElement | null => + document.querySelector('[data-queued-message-id="draft-1"] button') + expect(steer()).toHaveProperty('disabled', false) + + hostSays(true) + + await waitFor(() => expect(steer()).toHaveProperty('disabled', true)) + }) + it('reads Stopping from its own press, and holds Stop until that request answers', () => { mocks.turnId = 'turn-1' mocks.stopPressed = true @@ -102,7 +123,8 @@ describe("the chat pane while a person's Stop ends the turn", () => { mocks.turnId = 'turn-1' renderPane() - expect(mocks.composerProps).toMatchObject({ isStopping: false }) + expect(mocks.composerProps).toMatchObject({ isStopping: false, queuesAfterStop: false }) + expect(mocks.composerProps?.steerQueued).toBe(mocks.queuedSteerNewest) mocks.composerProps?.onStop?.() expect(mocks.stop).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 23251ae62715..e9f0e14e17be 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -88,6 +88,8 @@ export function createStructuredSessionMocks() { structuredTransport?: Record isWorking?: boolean isStopping?: boolean + queuesAfterStop?: boolean + steerQueued?: () => boolean onStop?: () => void }>(), approvalCardProps: initialApprovalCardProps, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index ef1112fd3b03..490109c2629e 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -312,9 +312,8 @@ export function NativeChatStructuredSession( {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} { - composerRef.current?.focus() - }} + steerHeld={stopping} + focusComposer={() => composerRef.current?.focus()} /> void (stopInFlight || controller.stop())} - steerQueued={controller.queuedMessages.steerNewest} + // Nothing steers into a turn a Stop is ending; a message sent then runs after it. + steerQueued={stopping ? undefined : controller.queuedMessages.steerNewest} + queuesAfterStop={stopping} structuredTransport={structuredTransport} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} /> diff --git a/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts new file mode 100644 index 000000000000..730d4d95ef3c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false })) + +import { nativeChatComposerPlaceholder } from './native-chat-composer-target' + +describe("the composer's placeholder", () => { + it('says a message runs after the stop while the chat reads Stopping', () => { + expect(nativeChatComposerPlaceholder(true, true, true)).toBe( + 'Queue a message to run after the stop' + ) + }) + + it('reads as usual otherwise', () => { + expect(nativeChatComposerPlaceholder(true, true)).toBe('Send a message…') + }) + + // A held input or a lost terminal says why first: nothing can be queued from here then. + it('keeps the reasons nothing can be sent ahead of it', () => { + expect(nativeChatComposerPlaceholder(true, false, true)).toBe( + 'Input is held by another device.' + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-target.ts b/src/renderer/src/components/native-chat/native-chat-composer-target.ts index c88882f7ced3..1b02f5d7cdcf 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-target.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-target.ts @@ -1,4 +1,5 @@ import { translate } from '@/i18n/i18n' +import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../../shared/native-chat-turn-status' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' @@ -11,7 +12,11 @@ export type NativeChatResolvedTarget = { * pathological clipboard can't stall the round-trip. */ export const NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES = 16 * 1024 * 1024 -export function nativeChatComposerPlaceholder(hasPty: boolean, canSend: boolean): string { +export function nativeChatComposerPlaceholder( + hasPty: boolean, + canSend: boolean, + queuesAfterStop = false +): string { if (!hasPty) { return translate( 'components.native-chat.composer.noPty', @@ -21,6 +26,12 @@ export function nativeChatComposerPlaceholder(hasPty: boolean, canSend: boolean) if (!canSend) { return translate('components.native-chat.composer.locked', 'Input is held by another device.') } + if (queuesAfterStop) { + return translate( + 'components.native-chat.status.queueAfterStop', + NATIVE_CHAT_TURN_STATUS_COPY.queueAfterStop + ) + } return translate('components.native-chat.composer.placeholder', 'Send a message…') } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 6049f330537f..ec3512353c2d 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -59,6 +59,8 @@ export type NativeChatComposerProps = { isWorking?: boolean /** This client's Stop request is in flight: the Stop control is disabled and says so. */ isStopping?: boolean + /** The chat reads Stopping: the placeholder says a message runs after the stop. */ + queuesAfterStop?: boolean /** Interrupt the hosted agent, usually by sending ESC into the PTY. */ onStop?: () => void /** Render an optimistic echo until the real transcript turn lands. */ diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 72118c9ab68d..ddac9d1b4016 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2803,6 +2803,7 @@ } }, "status": { + "queueAfterStop": "Queue a message to run after the stop", "responding": "Agent is responding", "toggleDetails": "Toggle turn details", "workedFor": "Worked for {{value0}}", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 58ccb5ce220b..610958c4c663 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17782,6 +17782,7 @@ "responding": "Agent is responding", "working": "Working…", "stopping": "Stopping…", + "queueAfterStop": "Queue a message to run after the stop", "thinking": "Thinking", "workingFor": "Working for {{value0}}", "workedFor": "Worked for {{value0}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 62d60c4edb50..6fc31f50fdfa 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15107,6 +15107,7 @@ "status": { "responding": "El agente está respondiendo", "stopping": "Deteniendo…", + "queueAfterStop": "Pon en cola un mensaje para que se ejecute después de detenerse", "interruptedAfter": "Interrumpido tras {{value0}}", "failedAfter": "Falló tras {{value0}}" }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 827fa12bec6c..7addeba8e018 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17648,6 +17648,7 @@ "responding": "L'agent répond", "working": "Fonctionnement…", "stopping": "Arrêt en cours…", + "queueAfterStop": "Mettez un message en file d'attente pour l'exécuter après l'arrêt", "thinking": "Réflexion", "workingFor": "En cours depuis {{value0}}", "workedFor": "A travaillé pendant {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 27178a05e160..edc3780a64c0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17584,6 +17584,7 @@ "responding": "Agent が応答中です", "working": "処理中…", "stopping": "停止中…", + "queueAfterStop": "停止後に実行するメッセージをキューに追加", "thinking": "考え", "workingFor": "{{value0}} 処理中", "workedFor": "{{value0}} で完了", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7846d69a912a..cf1c8b827770 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17584,6 +17584,7 @@ "responding": "Agent이 응답 중입니다.", "working": "작업 중…", "stopping": "중지하는 중…", + "queueAfterStop": "중지 후에 실행할 메시지를 대기열에 추가", "thinking": "생각", "workingFor": "{{value0}} 작업 중", "workedFor": "{{value0}} 작업 완료", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4c12ef19e481..264d5c39004d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17549,6 +17549,7 @@ "responding": "智能体正在回复", "working": "处理中…", "stopping": "正在停止…", + "queueAfterStop": "将消息加入队列,在停止后运行", "thinking": "思维", "workingFor": "已工作 {{value0}}", "workedFor": "工作了 {{value0}}", diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index 4a22f2a5f5b6..d22029be9996 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -10,6 +10,8 @@ export const NATIVE_CHAT_TURN_STATUS_COPY = { thinking: 'Thinking', working: 'Working…', stopping: 'Stopping…', + /** The composer's placeholder while the chat reads Stopping. */ + queueAfterStop: 'Queue a message to run after the stop', workingFor: 'Working for {{value0}}', workedFor: 'Worked for {{value0}}', interruptedAfter: 'Interrupted after {{value0}}', From b32f5744c96611572d6127586965c14c2ac0a2d6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:01:19 -0700 Subject: [PATCH 087/182] refactor(native-chat): the chat pane's Stop controls live in their own module The pane went over its line limit once merged with main. Its Stopping reading, the press that holds Stop, and the steer and placeholder it hands the composer move to native-chat-structured-stop-controls.ts. --- .../NativeChatStructuredSession.tsx | 16 +++----- .../native-chat-structured-stop-controls.ts | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 490109c2629e..e913d8113b04 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -24,6 +24,7 @@ import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' import { useStructuredAgentSessionHostExecution } from './StructuredAgentSessionStatusBridge' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' +import { nativeChatStructuredStopControls } from './native-chat-structured-stop-controls' import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' @@ -59,10 +60,7 @@ export function NativeChatStructuredSession( transportEnabled: provisionalLaunch.transportEnabled, ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) }) - // The host's word, bridged by this client's own press until its Stop event lands. - const stopping = controller.canStop && (hostExecution.stopping || controller.stopPressed) - // Only this client's own request in flight holds Stop: a repeat is how a stuck stop escalates. - const stopInFlight = controller.canStop && controller.stopPressed + const stopControls = nativeChatStructuredStopControls(controller, hostExecution.stopping) const launchDraftSignal = useNativeChatLaunchDraftSignal({ terminalTabId: props.tabId, agent: props.agent, @@ -295,7 +293,7 @@ export function NativeChatStructuredSession( settledTurns={controller.settledTurns} awaitingInput={prompt === null ? null : 'shown'} turnActivity={controller.turnActivity} - stopping={stopping} + stopping={stopControls.stopping} onLinkClick={onLinkClick} allowFileUriLinks={onLinkClick !== undefined} runtimeContext={imageRuntimeContext} @@ -312,7 +310,7 @@ export function NativeChatStructuredSession( {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} composerRef.current?.focus()} /> void (stopInFlight || controller.stop())} - // Nothing steers into a turn a Stop is ending; a message sent then runs after it. - steerQueued={stopping ? undefined : controller.queuedMessages.steerNewest} - queuesAfterStop={stopping} + {...stopControls.composer} structuredTransport={structuredTransport} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} /> diff --git a/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts b/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts new file mode 100644 index 000000000000..9d10d41c4991 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts @@ -0,0 +1,37 @@ +import type { useStructuredAgentSession } from './use-structured-agent-session' + +type StopController = Pick< + ReturnType, + 'canStop' | 'stopPressed' | 'stop' | 'queuedMessages' +> + +/** + * The chat pane's Stop state. It reads "Stopping…" from the host's word, bridged by this client's + * own press until its Stop event lands. Only that press in flight holds Stop, since a repeat is how + * a stuck stop escalates. While it reads Stopping nothing steers into the turn: a message sent then + * runs after it. + */ +export function nativeChatStructuredStopControls( + controller: StopController, + hostStopping: boolean +): { + stopping: boolean + composer: { + isStopping: boolean + onStop: () => void + steerQueued: (() => boolean) | undefined + queuesAfterStop: boolean + } +} { + const stopping = controller.canStop && (hostStopping || controller.stopPressed) + const stopInFlight = controller.canStop && controller.stopPressed + return { + stopping, + composer: { + isStopping: stopInFlight, + onStop: () => void (stopInFlight || controller.stop()), + steerQueued: stopping ? undefined : controller.queuedMessages.steerNewest, + queuesAfterStop: stopping + } + } +} From bc4f09c91269bc5f21c1dcb7147cc3bfe55a81fa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:01:22 -0700 Subject: [PATCH 088/182] fix(native-chat): hold a send at its handover, reading the feed's own Stopping The hold was checked when the delivery step started, but the handover runs in a later step after waiting on the agent's start, so a Stop landing in between let a new send steer into the stopping turn. The check now runs at the handover. It reads the status feed's projection for the commit instead of rendering the journal again, so holding a send adds no journal read of its own. --- ...tructured-agent-session-client-delivery.ts | 6 ++- .../structured-agent-session-delivery-loop.ts | 14 ++--- .../structured-agent-session-host-delivery.ts | 6 ++- ...agent-session-recovered-turn-clock.test.ts | 2 +- .../structured-agent-session-status-feed.ts | 17 +++--- ...agent-session-steer-while-stopping.test.ts | 53 ++++++++++++++++++- .../structured-agent-session-stopping.ts | 11 ---- 7 files changed, 79 insertions(+), 30 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 8195e7c18f9b..39e60615ff7e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -47,7 +47,8 @@ export class StructuredAgentSessionClientDelivery { this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ sessions, now, - readStatusState: (sessionId, journal) => this.statusFeed.statusState(sessionId, journal) + readStatusState: (sessionId, journal) => + this.statusFeed.journalProjection(sessionId, journal)?.state ?? null }) this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) => this.requireJournal(sessionId) @@ -84,6 +85,9 @@ export class StructuredAgentSessionClientDelivery { publishChildWork = (sessionId: string, evidence: AgentChildWorkEvidence[]): void => this.statusFeed.publishChildWork(sessionId, evidence) + readStopping = (sessionId: string): boolean => + this.statusFeed.journalProjection(sessionId)?.stopping === true + readChildWork = (sessionId: string): AgentChildWorkView[] | undefined => this.statusFeed.readChildWork(sessionId) 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 48566fdbddb6..7a67774f2e52 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 @@ -47,7 +47,6 @@ import { structuredAgentSessionWindDownWaitHolds } from './structured-agent-session-wind-down-wait-row' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' -import { structuredAgentSessionStoppingNow } from './structured-agent-session-stopping' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' export type StructuredAgentSessionDeliveryLoopDeps = { @@ -74,6 +73,8 @@ export type StructuredAgentSessionDeliveryLoopDeps = { logger: StructuredAgentSessionLogger record: (sessionId: string) => AgentSessionRecord | null readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined + /** A person's Stop is still ending the session's work: the status feed's own reading. */ + stopping: (sessionId: string) => boolean flushStreamedEvents: (sessionId: string) => Promise now: () => number } @@ -190,11 +191,6 @@ export class StructuredAgentSessionDeliveryLoop { if (!oldest || (session.child && structuredAgentSessionCommandRunning(session.journal))) { return this.stop(sessionId) } - // Never steer into a turn a person's Stop is ending: the message runs after it, as its own - // turn. The turn's end is a commit, which wakes the loop again. - if (session.child && structuredAgentSessionStoppingNow(session.journal)) { - return this.stop(sessionId) - } // Already waiting on a stop that could not prove its child gone: a new message retries it, and // any other retry that lands wakes this loop itself, so the waiting row's own commit does not. // Another operation's retry may have failed first, so the row is made sure of here too. @@ -258,6 +254,12 @@ export class StructuredAgentSessionDeliveryLoop { { failure: startFailure ?? agentSessionFailureFact('startFailed') } }) } + // Never steer into a turn a person's Stop is ending: the message runs after it, as its own + // turn, and the turn's end is a commit that wakes the loop again. Read here, at the handover, + // because a Stop can land while this step waits on the child's start. + if (this.deps.stopping(sessionId)) { + return this.stop(sessionId) + } const next = oldestQueuedSubmission(session) if (!next) { return this.stop(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index f2f885e07638..2c292c11a3c2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -55,7 +55,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { startedFor: string ) => Promise reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void - clientDelivery: Pick + clientDelivery: Pick< + StructuredAgentSessionClientDelivery, + 'publishRestored' | 'readChildWork' | 'readStopping' + > flushStreamedEvents: (sessionId: string) => Promise }): StructuredAgentSessionConversationDelivery { const { deps, sessions } = input @@ -81,6 +84,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { logger: deps.logger, record: (sessionId) => deps.store.getRecord(sessionId), readChildWork: input.clientDelivery.readChildWork, + stopping: input.clientDelivery.readStopping, flushStreamedEvents: input.flushStreamedEvents, now: () => deps.now?.() ?? Date.now() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index e8dfe9f7f5e6..79fc27a54e48 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -101,7 +101,7 @@ async function sessionWithRunningTurn() { const completions = new StructuredAgentSessionTurnCompletionFeed({ sessions, now: () => clock, - readStatusState: (sessionId, source) => feed.statusState(sessionId, source) + readStatusState: (sessionId, source) => feed.journalProjection(sessionId, source)?.state ?? null }) const completionEvents: AgentSessionTurnCompletionEvent[] = [] completions.subscribe({ id: 'dot-1', emit: (event) => completionEvents.push(event) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index f54e9de9feba..1b5607153589 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -25,8 +25,7 @@ import { structuredAgentSessionProviderSessionMetadata } from './structured-agen import { structuredStatusChildWork } from './structured-agent-session-status-child-work' import { StructuredAgentSessionJournalProjections, - type StructuredAgentSessionJournalProjection, - type StructuredAgentSessionStatusState + type StructuredAgentSessionJournalProjection } from './structured-agent-session-status-journal-projection' import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' import { @@ -191,15 +190,15 @@ export class StructuredAgentSessionStatusFeed { }) } - /** The projection behind the session's row and the latest request it read, cached per commit, - * so the completion feed follows the same request without snapshotting the journal again. */ - statusState( + /** The projection behind the session's row, cached per commit: the latest request it read, so + * the completion feed follows it without snapshotting the journal again, and its `stopping`, + * which the steer hold reads instead of deriving it again. */ + journalProjection( sessionId: string, journal?: AgentSessionJournal - ): StructuredAgentSessionStatusState | null { - const session = this.deps.sessions.get(sessionId) - const source = journal ?? session?.journal - return source ? this.projections.read(source, this.deps.getRecord(sessionId)).state : null + ): StructuredAgentSessionJournalProjection | null { + const source = journal ?? this.deps.sessions.get(sessionId)?.journal + return source ? this.projections.read(source, this.deps.getRecord(sessionId)) : null } /** Re-projects one session after its journal changed; equal projections are not re-sent. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts index 6ba7b8b4c353..347247c58131 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts @@ -2,7 +2,7 @@ // for the turn to end, and runs after it as its own turn. The host owns the rule, so a client of // any version gets it. -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { @@ -107,4 +107,55 @@ describe("a message sent while a person's Stop ends the turn", () => { await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) }) + + // The delivery step that judged the send waits on the child's start outside the session's lane. + it('holds a send that a Stop overtook while its delivery step waited on the agent', async () => { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + const status = watchStatus() + const started = Promise.withResolvers() + const awaited = rig.awaitStarted.mock.calls.length + rig.awaitStarted.mockImplementationOnce(() => started.promise) + const first = rig.send('steer this in') + expect(await first.result).toMatchObject({ ok: true }) + await eventually(() => expect(rig.awaitStarted.mock.calls.length).toBe(awaited + 1)) + const dispatched = rig.dispatch.mock.calls.length + + // The Stop withdraws the first send; a second one arrives before the step resumes. + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + const second = rig.send('and this one') + expect(await second.result).toMatchObject({ ok: true }) + started.resolve(undefined) + await laneDrained() + + expect(rig.dispatch.mock.calls.length).toBe(dispatched) + expect((await rig.submission(second.id))?.handedOverAt).toBeUndefined() + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) + }) + + // The hold reads the status feed's own projection for the commit, never a journal read of its own. + it('reads Stopping once per commit while a send waits on it', async () => { + const { cardId } = await stoppingTurn({ card: true }) + expect(await rig.sendNow(cardId ?? '')).toMatchObject({ ok: true }) + await laneDrained() + const reads = vi.spyOn(journal(), 'snapshot') + + for (let row = 0; row < 3; row += 1) { + await journal().appendItem( + { provider: 'orca', clientMessageId: `streamed-${row}` }, + { kind: 'status', text: `still streaming ${row}` }, + { + fence: rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) + await laneDrained() + } + + expect(reads.mock.calls.length).toBeLessThanOrEqual(3) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts index 0167b656c959..e3da2816add0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts @@ -87,14 +87,3 @@ export function structuredAgentSessionStopping( } return !answered } - -/** The same derivation read straight off the journal, for a host step that must not act into - * a turn a person's Stop is ending. */ -export function structuredAgentSessionStoppingNow( - journal: Pick -): boolean { - return ( - journal.stopMarks.latest() !== null && - structuredAgentSessionStopping(journal, journal.snapshot().items) - ) -} From 1699657d6b8b469edbef06e552a958c7959c6c2e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:54:09 -0700 Subject: [PATCH 089/182] refactor(native-chat): one display status decides Stopping on every surface agentStopDisplayStatus combines whether the agent works, the host's flag and this client's own press. The chat pane, sidebar and dashboard rows, and the phone's chat all read it, instead of each combining the flags. --- .../src/session/MobileNativeChatOverlay.tsx | 6 ++- mobile/src/session/MobileNativeChatView.tsx | 7 +++- .../use-mobile-structured-agent-session.ts | 8 +++- mobile/src/worktree/agent-row-display.ts | 3 +- .../structured-agent-session-queued-stop.ts | 10 ++--- ...ed-agent-session-stopping-journal.test.ts} | 0 ...ured-agent-session-turn-stop-notes.test.ts | 41 ------------------- .../native-chat-structured-stop-controls.ts | 8 +++- .../src/lib/agent-row-stopping-label.ts | 7 +++- src/shared/agent-stop-display-status.test.ts | 16 ++++++++ src/shared/agent-stop-display-status.ts | 18 ++++++++ 11 files changed, 71 insertions(+), 53 deletions(-) rename src/main/native-chat/agent-session-wire/{structured-agent-session-stopping-answers.test.ts => structured-agent-session-stopping-journal.test.ts} (100%) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.test.ts create mode 100644 src/shared/agent-stop-display-status.test.ts create mode 100644 src/shared/agent-stop-display-status.ts diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index ab1f3c4a7e04..c025f57d2c7a 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -6,6 +6,7 @@ import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat- import type { MobileNativeChatController } from './use-mobile-native-chat-controller' import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble' import { useMobileNativeChatQueuedSlot } from './use-mobile-native-chat-queued-slot' +import { agentStopDisplayStatus } from '../../../src/shared/agent-stop-display-status' type Props = { controller: MobileNativeChatController @@ -71,7 +72,10 @@ export function MobileNativeChatOverlay({ sessionKey: queued.sessionKey, // Nothing steers into a turn a Stop is ending; the host holds such a send until it ends. steerHeld: - controller.nativeChatAgentWorking && controller.nativeChatTurnIndicator?.stopping === true + agentStopDisplayStatus({ + working: controller.nativeChatAgentWorking, + hostStopping: controller.nativeChatTurnIndicator?.stopping === true + }) === 'stopping' }) if (!controller.showNativeChat) { return null diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 04f4606f20d0..10cbb7035bf8 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -21,6 +21,7 @@ import { import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import { colors } from '../theme/mobile-theme' import { mobileNativeChatComposerPlaceholder } from './mobile-native-chat-composer-placeholder' +import { agentStopDisplayStatus } from '../../../src/shared/agent-stop-display-status' import { styles } from './mobile-native-chat-view-styles' import { mobileNativeChatListFooter } from './mobile-native-chat-list-footer' import { @@ -268,7 +269,11 @@ export function MobileNativeChatView({ // Per-turn status rows: one live indicator while the turn runs, then a settled // "Worked for N" row. The structured lane owns them; the bridge lane keeps its // three-dot indicator. - const stopping = agentWorking === true && turnIndicator?.stopping === true + const stopping = + agentStopDisplayStatus({ + working: agentWorking === true, + hostStopping: turnIndicator?.stopping === true + }) === 'stopping' // Only this phone's own request holds Stop: a repeat is how a stuck stop escalates. const stopHeld = agentWorking === true && turnIndicator?.stopRequestInFlight === true const stoppingCopy = NATIVE_CHAT_TURN_STATUS_COPY.stopping diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index d959e67ed3d9..e30c34bbafb9 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -32,6 +32,7 @@ import { requestMobileStructuredAgentSessionCancel } from './mobile-structured-agent-session-cancel' import { useMobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' +import { agentStopDisplayStatus } from '../../../src/shared/agent-stop-display-status' import { useMobileStructuredSendWithOutcome, type StructuredMobileSendAttachment @@ -176,7 +177,12 @@ export function useMobileStructuredAgentSession(args: { }) // The host's word, bridged by this phone's own press until its Stop event lands. const stopPress = useMobileStructuredStopPress(sessionKey) - const stopping = isWorking && (hostStopping || stopPress.pressed) + const stopping = + agentStopDisplayStatus({ + working: isWorking, + hostStopping, + stopPressed: stopPress.pressed + }) === 'stopping' const stopRequestInFlight = isWorking && stopPress.pressed const turnIndicator = useMemo( () => ({ thinking, activityText, stopping, stopRequestInFlight }), diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index 0ab164d079aa..ab061d84cb8a 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -129,7 +129,8 @@ const STOPPING_LABEL = 'Stopping…' // Primary row text: "Stopping…" while the host says a person's Stop is ending the turn, else the // agent's last message, then the user prompt, then a human-readable state label so a row is never -// blank. Matches the desktop DashboardAgentRow displayLabel fallback chain. +// blank. Matches the desktop DashboardAgentRow displayLabel fallback chain. The Stopping test +// mirrors agentStopDisplayStatus (src/shared), which this file cannot import as a value either. export function agentDisplayLabel(row: RuntimeWorktreeAgentRow, now: number): string { if (row.state === 'working' && row.mainAgent?.stopping === true) { return STOPPING_LABEL diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index 717bdf57cf59..316793751c55 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -15,9 +15,8 @@ import { import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' import { isMainAgentWorkingOnceFlushed } from './structured-agent-session-turns-cancel' import { - structuredAgentSessionStopEventTurnId, structuredAgentSessionStopNamesTurnNotLive, - type StructuredAgentSessionStopTarget + structuredAgentSessionStoppedTurnId } from './structured-agent-session-turn-stop-notes' /** The one unsettled-card predicate /clear's carry and the budget share: @@ -38,15 +37,14 @@ export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMes */ export async function runRecordedStop( ctx: AgentSessionTurnContext, - event: Omit, - /** Its turn, by the rule its note is keyed by (`structuredAgentSessionStopEventTurnId`). */ - target: StructuredAgentSessionStopTarget, + /** `turnId` absent: the turn running when the Stop takes effect, if any. */ + event: Omit, stop: (tookEffect: () => Promise) => Promise> ): Promise> { const skipped = (error: unknown): void => report(ctx, 'event row', error) return stop(() => { try { - const turnId = structuredAgentSessionStopEventTurnId(ctx.journal, target) ?? undefined + const turnId = structuredAgentSessionStoppedTurnId(ctx.journal, event.turnId) ?? undefined return ctx.journal .appendStopEvent({ ...event, ...(turnId ? { turnId } : {}) }, ctx.fence) .then(() => undefined, skipped) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-answers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts similarity index 100% rename from src/main/native-chat/agent-session-wire/structured-agent-session-stopping-answers.test.ts rename to src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.test.ts deleted file mode 100644 index 18f9a3607080..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' -import { - structuredAgentSessionStopEventTurnId, - structuredAgentSessionStopNoteKey -} from './structured-agent-session-turn-stop-notes' - -const journal = { activeTurnId: () => 'turn-live' } - -describe("a Stop's note key", () => { - it('names the turn the Stop named, as its event records it', () => { - const turnId = structuredAgentSessionStopEventTurnId(journal, { - namedTurnId: 'turn-old', - endsSession: false - }) - expect(structuredAgentSessionStopNoteKey(turnId, 'op-1')).toEqual( - structuredAgentSessionStopNoteIdentity('turn-old') - ) - }) - - // Its event names whatever is in flight, never a named turn that already ended. - it('names the running turn for a Stop that ends the provider session, whatever it named', () => { - const turnId = structuredAgentSessionStopEventTurnId(journal, { - namedTurnId: 'turn-old', - endsSession: true - }) - expect(structuredAgentSessionStopNoteKey(turnId, 'op-1')).toEqual( - structuredAgentSessionStopNoteIdentity('turn-live') - ) - }) - - it('falls back to the operation with no turn running', () => { - const turnId = structuredAgentSessionStopEventTurnId( - { activeTurnId: () => null }, - { endsSession: true } - ) - expect(structuredAgentSessionStopNoteKey(turnId, 'op-1')).toEqual( - structuredAgentSessionStopNoteIdentity('op-1') - ) - }) -}) diff --git a/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts b/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts index 9d10d41c4991..b4c370c71cde 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts @@ -1,3 +1,4 @@ +import { agentStopDisplayStatus } from '../../../../shared/agent-stop-display-status' import type { useStructuredAgentSession } from './use-structured-agent-session' type StopController = Pick< @@ -23,7 +24,12 @@ export function nativeChatStructuredStopControls( queuesAfterStop: boolean } } { - const stopping = controller.canStop && (hostStopping || controller.stopPressed) + const stopping = + agentStopDisplayStatus({ + working: controller.canStop, + hostStopping, + stopPressed: controller.stopPressed + }) === 'stopping' const stopInFlight = controller.canStop && controller.stopPressed return { stopping, diff --git a/src/renderer/src/lib/agent-row-stopping-label.ts b/src/renderer/src/lib/agent-row-stopping-label.ts index 5d50cea290e9..66c1800d83df 100644 --- a/src/renderer/src/lib/agent-row-stopping-label.ts +++ b/src/renderer/src/lib/agent-row-stopping-label.ts @@ -1,3 +1,4 @@ +import { agentStopDisplayStatus } from '../../../shared/agent-stop-display-status' import type { AgentStatusEntry } from '../../../shared/agent-status-types' import { translate } from '@/i18n/i18n' import type { AgentRowState } from './agent-row-decay-state' @@ -8,7 +9,11 @@ export function agentRowStoppingLabel( entry: Pick, state: AgentRowState | null | undefined ): string | null { - return state === 'working' && entry.mainAgent?.stopping + const display = agentStopDisplayStatus({ + working: state === 'working', + hostStopping: entry.mainAgent?.stopping === true + }) + return display === 'stopping' ? translate('components.native-chat.status.stopping', 'Stopping…') : null } diff --git a/src/shared/agent-stop-display-status.test.ts b/src/shared/agent-stop-display-status.test.ts new file mode 100644 index 000000000000..b001bb60b314 --- /dev/null +++ b/src/shared/agent-stop-display-status.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { agentStopDisplayStatus } from './agent-stop-display-status' + +describe('agentStopDisplayStatus', () => { + it("reads stopping from the host's flag or this client's own press while the agent works", () => { + expect(agentStopDisplayStatus({ working: true, hostStopping: true })).toBe('stopping') + expect(agentStopDisplayStatus({ working: true, stopPressed: true })).toBe('stopping') + expect(agentStopDisplayStatus({ working: true })).toBe('working') + }) + + it('never reads stopping on an agent that is not working', () => { + expect(agentStopDisplayStatus({ working: false, hostStopping: true, stopPressed: true })).toBe( + 'not-working' + ) + }) +}) diff --git a/src/shared/agent-stop-display-status.ts b/src/shared/agent-stop-display-status.ts new file mode 100644 index 000000000000..577612774c82 --- /dev/null +++ b/src/shared/agent-stop-display-status.ts @@ -0,0 +1,18 @@ +// What an agent's row, chat or composer shows about a person's Stop: one rule for every surface, +// so none of them combines the host's flag and this client's own press on its own. + +export type AgentStopDisplayStatus = 'stopping' | 'working' | 'not-working' + +/** `stopping` while the agent works and either the host says a person's Stop is ending its turn + * or this client's own Stop request is still in flight; the host's flag is never shown on a + * row that is not working. */ +export function agentStopDisplayStatus(input: { + working: boolean + hostStopping?: boolean + stopPressed?: boolean +}): AgentStopDisplayStatus { + if (!input.working) { + return 'not-working' + } + return input.hostStopping === true || input.stopPressed === true ? 'stopping' : 'working' +} From aff58493d6092147b4ea795ef380dac1f21eb3ec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:55:18 -0700 Subject: [PATCH 090/182] fix(native-chat): Stopping holds until the stopped turn ends, whatever the Stop's answer A Stop the agent declined, or whose end went unconfirmed, used to drop the chat back to Working. It now stays on Stopping until the turn ends, and Stop stays enabled so a repeat press escalates. The Stop's answer is no longer read for Stopping, so its note goes back to the key the base gives it (the restore of queued-stop.ts and the removed key test landed in the previous commit). The note still keeps a press that took over a later refusal, and a failed process end still says the Stop went unconfirmed. --- .../structured-agent-session-chat-stop.ts | 22 ++--- ...agent-session-steer-while-stopping.test.ts | 17 ++-- ...red-agent-session-stopping-journal.test.ts | 28 ++----- .../structured-agent-session-stopping.test.ts | 45 ++++------ .../structured-agent-session-stopping.ts | 82 +++---------------- ...tructured-agent-session-turn-stop-notes.ts | 28 ------- .../structured-agent-session-turns-cancel.ts | 37 +++------ 7 files changed, 69 insertions(+), 190 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index f5cc899e6675..3e7f0a44939b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -31,7 +31,6 @@ import { isMainAgentWorkingOnceFlushed, performCancel } from './structured-agent-session-turns-cancel' -import type { StructuredAgentSessionStopTarget } from './structured-agent-session-turn-stop-notes' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' type ChatStopOutcome = TurnOutcome @@ -61,17 +60,16 @@ export function mutateWithChatStop( const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) // The same for every client: once the Stop takes effect its event is written, and the queue's // pause follows from it. The cards stay published; no text rides the answer. - const stop = (ctx: AgentSessionTurnContext): Promise => { - // Read once for the Stop's event and its note. A Stop that ends the provider's session ends - // whatever is in flight, so both name the live turn, never a named turn that already ended. - const target: StructuredAgentSessionStopTarget = { - ...(turnId !== undefined ? { namedTurnId: turnId } : {}), - endsSession: ctx.adapter.stopEndsSession?.(ctx.sessionId) === true - } - return runRecordedStop( + const stop = (ctx: AgentSessionTurnContext): Promise => + runRecordedStop( ctx, - { reason: 'user-stop', caller: caller.callerKey }, - target, + { + reason: 'user-stop', + caller: caller.callerKey, + // A Stop that ends the provider's session ends whatever is in flight, so its event names + // the live turn, or none (the turn opened next), never a named turn that already ended. + ...(ctx.adapter.stopEndsSession?.(ctx.sessionId) === true ? {} : named) + }, async (tookEffect) => { // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. const withdrawn = await ctx.journal.rejectQueuedSubmissions( @@ -116,13 +114,11 @@ export function mutateWithChatStop( endSession: (owed) => { windDown = { owed, ctx } }, - stopTarget: target, withdrewQueued: withdrawn.length > 0 } ) } ) - } const result = mutateStructuredAgentSession( context, caller, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts index 347247c58131..056f1f204b77 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts @@ -69,7 +69,7 @@ async function laneDrained(): Promise { } /** Turn `turn-1` running, a person's Stop ending it, and what was dispatched by then. */ -async function stoppingTurn(options: { card?: true } = {}) { +async function stoppingTurn(options: { card?: true; refused?: true } = {}) { rig = await createQueuedMessageTestRig() const sent = await rig.workingSend() await rig.settleAccepted(sent, 'sent') @@ -81,6 +81,9 @@ async function stoppingTurn(options: { card?: true } = {}) { await eventually(async () => expect(await rig.drafts()).toHaveLength(1)) cardId = (await rig.drafts())[0]?.messageId } + if (options.refused) { + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false }) + } expect(await rig.stop()).toMatchObject({ ok: true }) await eventually(() => expect(status()).toMatchObject({ stopping: true })) return { sent, status, cardId, dispatched: rig.dispatch.mock.calls.length } @@ -88,10 +91,14 @@ async function stoppingTurn(options: { card?: true } = {}) { describe("a message sent while a person's Stop ends the turn", () => { it.each([ - ['a queued card sent now', true], - ['a send', false] - ])('waits for the turn to end, then runs as its own turn: %s', async (_, fromCard) => { - const { sent, status, cardId, dispatched } = await stoppingTurn(fromCard ? { card: true } : {}) + ['a queued card sent now', true, false], + ['a send', false, false], + ['a send, after a Stop the agent declined', false, true] + ])('waits for the turn to end, then runs as its own turn: %s', async (_, fromCard, refused) => { + const { sent, status, cardId, dispatched } = await stoppingTurn({ + ...(fromCard ? { card: true as const } : {}), + ...(refused ? { refused: true as const } : {}) + }) const result = cardId ? await rig.sendNow(cardId) : await rig.send('one more thing').result expect(result).toMatchObject({ ok: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts index 8008c8289e7c..89dd2cc00c9c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts @@ -1,5 +1,5 @@ -// Which notes answer a person's Stop for "Stopping…": the note keyed by the turn its event records, -// whenever it was first written, and notes written after the event that no other turn owns. +// "Stopping…" read straight off a journal: it holds from a person's Stop until the work it stopped +// ends, whatever the Stop's answer, and reads only the journal's tail to tell. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -69,32 +69,20 @@ function stopping(): boolean { return structuredAgentSessionStopping(journal, journal.snapshot().items) } -describe("a person's Stop's answers", () => { - it('reads the answer to a later Stop of the same turn from the note an earlier one wrote', async () => { +describe("a person's Stop's Stopping", () => { + // The Stop is the person's whatever the agent answered; only the turn's end clears it. + it('holds through an answer that says the Stop stopped nothing, until the turn ends', async () => { await turn('turn-1', 'running') await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, FENCE) await journal.appendItem(structuredAgentSessionStopNoteIdentity('turn-1'), unconfirmed, { fence: FENCE, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - // A send made since gives the next Stop of this turn an event of its own; its press revised - // the turn's note in place, so that note keeps its first position. - await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, FENCE) - - expect(stopping()).toBe(false) - }) + expect(stopping()).toBe(true) - it("never reads a Stop of an ended turn as the answer to the running turn's Stop", async () => { - const ended = await turn('turn-1', 'completed') - await turn('turn-2', 'running') - await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-2' }, FENCE) - // A phone's late Stop of the ended turn, whose interrupt went unconfirmed. - await journal.appendItem(structuredAgentSessionStopNoteIdentity('turn-1'), unconfirmed, { - fence: FENCE, - turnScope: { kind: 'turn', turnItemId: ended.itemId } - }) + await turn('turn-1', 'completed') - expect(stopping()).toBe(true) + expect(stopping()).toBe(false) }) it('reads only the journal from the running turn on, however long the chat before it', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts index e3a00e70bedf..a326ef0582b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -139,9 +139,9 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => ['left unconfirmed', 'names no turn', unconfirmed], ['left unconfirmed', 'names the turn', unconfirmed] ])( - 'reads Working again once the agent %s a Stop that %s, while its turn runs on', + 'keeps Stopping until the turn ends after the agent %s a Stop that %s', async (_, naming, cancel) => { - const { status } = await runningTurn() + const { sent, status } = await runningTurn() rig.cancelTurn.mockImplementationOnce(cancel) if (cancel === unconfirmed) { // A failed interrupt ends the agent's process; this one cannot be ended either. @@ -151,8 +151,13 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => const stopped = naming === 'names the turn' ? namedStop('turn-1') : rig.stop() expect(await stopped).toMatchObject({ ok: true }) - await eventually(() => expect(journal().stopMarks.latest()).not.toBeNull()) - await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + // The answer says it stopped nothing, yet the Stop is still the person's: a repeat escalates. + await eventually(() => expect(stopAnswers()).toHaveLength(1)) + expect(stopAnswers()[0]).not.toBe('took') + expect(status()).toMatchObject({ status: 'working', stopping: true }) + + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) expect(status()).not.toHaveProperty('stopping') } ) @@ -191,7 +196,7 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => await eventually(() => expect(status()).toMatchObject({ stopping: true })) }) - it('keeps Stopping when a repeat press is refused after an earlier press took', async () => { + it('keeps the row of a press that took when a repeat press is refused', async () => { const { status } = await runningTurn() let answer: (outcome: AgentSessionCancelOutcome) => void = () => undefined rig.cancelTurn.mockImplementationOnce( @@ -317,27 +322,6 @@ describe('a Stop pressed before its send opened a turn', () => { }) describe('a Stop whose provider ends its session', () => { - // The phone names the turn its journal last showed; the follow-up already written runs next. - it('keys a Stop naming an ended turn by the turn its event records, so its answer clears Stopping', async () => { - const { sent, status } = await runningTurn({ stopEndsSession: true }) - await turn('turn-1', sent, 'interrupted') - await eventually(() => expect(status()?.status).toBe('idle')) - const followUp = await rig.workingSend() - const kill = Promise.withResolvers() - rig.closeSession.mockImplementationOnce(() => kill.promise) - - expect(await namedStop('turn-1')).toMatchObject({ ok: true }) - await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) - expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') - await rig.settleAccepted(followUp, 'follow-up') - await turn('turn-2', followUp, 'running') - kill.reject(new Error('the kill timed out')) - - await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) - await eventually(() => expect(status()).toMatchObject({ status: 'working' })) - expect(status()).not.toHaveProperty('stopping') - }) - it("revises the note a Stop pressed before its turn showed wrote, once that turn opened and the child's end failed", async () => { rig = await createQueuedMessageTestRig({ stopEndsSession: true }) const sent = await rig.workingSend() @@ -352,19 +336,18 @@ describe('a Stop whose provider ends its session', () => { kill.reject(new Error('the kill timed out')) await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) - await eventually(() => expect(status()).toMatchObject({ status: 'working' })) - expect(status()).not.toHaveProperty('stopping') + expect(status()).toMatchObject({ status: 'working', stopping: true }) }) - it("reads Working once the child's end fails, and Stopping again when the next Stop retries it", async () => { + it("says the Stop went unconfirmed once the child's end fails, and the next Stop retries it", async () => { const { status } = await runningTurn({ stopEndsSession: true }) rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) expect(await rig.stop()).toMatchObject({ ok: true }) await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) - await eventually(() => expect(status()).toMatchObject({ status: 'working' })) - expect(status()).not.toHaveProperty('stopping') + // Still the person's Stop while the work runs on: Stop stays enabled for the retry. + expect(status()).toMatchObject({ status: 'working', stopping: true }) // The retry's child end is held, so the status can be read while it runs. const retried = Promise.withResolvers() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts index e3da2816add0..2caa6e576ecb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts @@ -1,44 +1,20 @@ // Whether a chat reads "Stopping…": a person's Stop took effect and the work it stopped has not // ended yet. The host derives it on each journal commit from facts the journal already holds — the -// Stop's event, the live turn and the Stop's own answer — so nothing is stored, and it clears when -// the turn ends or the Stop answers that it stopped nothing. Clients only present it. +// Stop's event and the live turn — so nothing is stored, and it clears when that work ends. Clients +// only present it. -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { - AgentJournalRenderItem, - AgentJournalTurnLifecycle -} from '../../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { newestStructuredAgentSessionTurn } from '../../../shared/structured-agent-session-live-turn' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { - isStructuredAgentSessionStopNote, - structuredAgentSessionStopNoteIdentity -} from './structured-agent-session-command-turn' -import { stopNoteTookNoEffect } from './structured-agent-session-turn-stop-notes' - -/** The newest turn record and its item, read backward off a position-ordered snapshot. */ -function newestTurnItem( - items: readonly AgentJournalRenderItem[] -): { item: AgentJournalRenderItem; turn: AgentJournalTurnLifecycle } | null { - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] - const turn = item ? readAgentJournalTurn(item.body) : null - if (item && turn) { - return { item, turn } - } - } - return null -} /** * Whether the latest person's Stop is still ending the work it stopped: the live turn it named, or - * with none, the sends it stopped (`personStopDecidesTurn`), unless every answer to it says it - * stopped nothing. Its answer is the note keyed by the turn its event records, revised in place by - * each press, plus the notes of Stops pressed before any turn showed (keyed by their operation). - * A press that took is never overwritten by a later no-effect one, so one that took keeps it. + * with none, the sends it stopped (`personStopDecidesTurn`). It holds until that work ends, whatever + * the Stop's answer: a Stop the agent declined is still the person's, and a repeat press is how it + * escalates. */ export function structuredAgentSessionStopping( - journal: Pick, + journal: Pick, items: readonly AgentJournalRenderItem[] ): boolean { const stop = journal.stopMarks.latest() @@ -46,44 +22,12 @@ export function structuredAgentSessionStopping( return false } // Read off the snapshot's tail with its opener, so no commit walks the whole journal for it. - const newest = newestTurnItem(items) - const live = newest?.turn.state === 'running' ? newest : null - if (stop.event.turnId !== undefined && stop.event.turnId !== live?.turn.turnId) { + const newest = newestStructuredAgentSessionTurn(items) + const live = newest?.state === 'running' ? newest : null + if (stop.event.turnId !== undefined && stop.event.turnId !== live?.turnId) { return false } - const decides = live - ? journal.stopMarks.personStopDecidesOpenedTurn(live.turn.turnId, live.turn.userItemId) + return live + ? journal.stopMarks.personStopDecidesOpenedTurn(live.turnId, live.userItemId) : journal.stopMarks.personStopDecides(null) - if (!decides) { - return false - } - const answerTurnId = stop.event.turnId ?? live?.turn.turnId - const turnAnswer = - answerTurnId === undefined - ? null - : agentJournalItemKey(structuredAgentSessionStopNoteIdentity(answerTurnId)) - // The turn's note, whenever it was first written, by its key. - const turnNote = turnAnswer === null ? null : journal.itemBody(turnAnswer) - if (turnNote !== null && !stopNoteTookNoEffect(turnNote)) { - return true - } - let answered = turnNote !== null - // The snapshot is in journal order, so the notes written since the Stop are its tail. - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] - if (!item || item.sequence <= stop.sequence) { - break - } - const answers = - item.itemId !== turnAnswer && - isStructuredAgentSessionStopNote(item.itemId) && - (item.turnScope?.kind !== 'turn' || item.turnScope.turnItemId === live?.item.itemId) - if (answers) { - if (!stopNoteTookNoEffect(item.body)) { - return true - } - answered = true - } - } - return !answered } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts index 5a4d52673fcf..8b9263e00337 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.ts @@ -4,12 +4,10 @@ import type { AgentJournalItemBody, - AgentJournalItemIdentity, AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' export const STOP_NOTE_CANCELLATION_REQUESTED = 'Cancellation requested.' @@ -40,32 +38,6 @@ export function structuredAgentSessionNamedTurnScope( return turn ? { kind: 'turn', turnItemId: turn.itemId } : null } -/** What a person's Stop is aimed at, read once per Stop for both its event and its note. */ -export type StructuredAgentSessionStopTarget = { namedTurnId?: string; endsSession: boolean } - -/** - * The turn a person's Stop records on its event, and so keys its note by: the one it named, unless - * the Stop ends the provider's session (then whatever runs), else the one running. Null: none ran. - */ -export function structuredAgentSessionStopEventTurnId( - journal: Pick, - stop: StructuredAgentSessionStopTarget -): string | null { - return structuredAgentSessionStoppedTurnId( - journal, - stop.endsSession ? undefined : stop.namedTurnId - ) -} - -/** The one key of a Stop's note, for its writer and every reader: the turn its event records - * (`structuredAgentSessionStopEventTurnId`), else, with no turn, the Stop's own operation. */ -export function structuredAgentSessionStopNoteKey( - eventTurnId: string | null | undefined, - clientOperationId: string -): AgentJournalItemIdentity { - return structuredAgentSessionStopNoteIdentity(eventTurnId ?? clientOperationId) -} - /** A Stop's note saying it did not take: the agent refused it, or it went unconfirmed. */ export function stopNoteTookNoEffect(body: AgentJournalItemBody | null | undefined): boolean { if (body?.kind !== 'status' || !('failure' in body)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 55679e75e6a1..cfd5d00fe7f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -12,7 +12,8 @@ import { latestJournalDispatchObservation } from '../agent-session-journal/journ import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' import { isStructuredAgentSessionCommandTurnId, - structuredAgentSessionCommandWasStopped + structuredAgentSessionCommandWasStopped, + structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' import type { StructuredAgentSessionStopWindDown } from './structured-agent-session-stop-wind-down' import { @@ -23,11 +24,8 @@ import { STOP_NOTE_CANCELLATION_REQUESTED, stopNoteTookNoEffect, structuredAgentSessionNamedTurnScope, - structuredAgentSessionStopEventTurnId, structuredAgentSessionStopNamesTurnNotLive, - structuredAgentSessionStopNoteKey, - structuredAgentSessionStoppedTurnId, - type StructuredAgentSessionStopTarget + structuredAgentSessionStoppedTurnId } from './structured-agent-session-turn-stop-notes' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -100,9 +98,6 @@ export async function performCancel( /** Hands the child's end to the Stop's next serialized step, for a provider whose Stop ends * its session. */ endSession?: (windDown: StructuredAgentSessionStopWindDown) => void - /** The person's Stop this runs for, as its event read it, which keys its note. Absent: a Stop - * with no event, keyed by the turn it names. */ - stopTarget?: StructuredAgentSessionStopTarget /** The host already withdrew queued messages for this Stop. */ withdrewQueued?: boolean /** The session's child records: a background Stop reaches the tasks they offer a stop. */ @@ -120,22 +115,10 @@ export async function performCancel( kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } - // Read while the child is live: a provider whose Stop is a session boundary loses it next. - const endsSession = - input.endSession !== undefined && ctx.adapter.stopEndsSession?.(ctx.sessionId) === true - // The turn the Stop's event records, read before the cancel settles it: the note sits on that - // turn and is keyed by it, as every reader of the Stop's answer looks it up. - const eventTurnId = structuredAgentSessionStopEventTurnId( - ctx.journal, - input.stopTarget ?? { - ...(input.turnId !== undefined ? { namedTurnId: input.turnId } : {}), - endsSession: false - } - ) - const noteIdentity = structuredAgentSessionStopNoteKey(eventTurnId, input.clientOperationId) + // The turn the Stop names, read before the cancel settles it: the note reports on that turn. const turnScope = - (eventTurnId !== null && !input.scope - ? structuredAgentSessionNamedTurnScope(ctx.journal, eventTurnId) + (input.turnId !== undefined && !input.scope + ? structuredAgentSessionNamedTurnScope(ctx.journal, input.turnId) : null) ?? ctx.journal.liveTurnScope() // Only the provider's end or the child's ends a command. A command the provider has not opened a // turn for, would not interrupt, or was already asked to stop, ends with its child; that child's @@ -151,6 +134,13 @@ export async function performCancel( !namesTurnNotLive const stoppedBefore = runningCommand && structuredAgentSessionCommandWasStopped(ctx.journal, liveTurnId) + // Read while the child is live: a provider whose Stop is a session boundary loses it next. + const endsSession = + input.endSession !== undefined && ctx.adapter.stopEndsSession?.(ctx.sessionId) === true + // Keyed by the turn it stopped, so another Stop of that turn rewrites this row, never adds one. + const noteIdentity = structuredAgentSessionStopNoteIdentity( + stoppedTurnId ?? input.clientOperationId + ) const stoppedAt = Date.now() // The provider's own answer; unset when its cancel threw, leaving the effect unknown. let taken: boolean | undefined @@ -257,7 +247,6 @@ export async function performCancel( if (input.scope || note === null || noteKeepsTakenAnswer(ctx.journal, noteIdentity, note)) { return { ok: true, value } } - // Keyed by the turn it stopped, so another Stop of that turn rewrites this row, never adds one. await ctx.journal.appendItem(noteIdentity, note, { fence: ctx.fence, turnScope }) return { ok: true, value } } From 486a4aa7d806a9d42b739f61b1c568dd374298d2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:39:39 -0700 Subject: [PATCH 091/182] fix(native-chat): a Stop binds only the turn it actually stopped A Stop pressed before any turn showed used to claim, at end-write time, whatever turn the stopped send later opened, even when the Stop stopped nothing. A turn that then died on its own read as "Interrupted" (your cancellation) instead of "Failed". Now a person's Stop that named no turn binds, in memory only, every turn that ends while the Stop settles, and afterwards only the turn its interrupt took. The settle ends a still-running stopped turn once. A relaunch finds nothing in memory, so an unsettled turnless Stop binds no turn. A Codex Stop whose answered turn does not open within its wait, or whose send's answer was lost, now answers refused, so the host ends the child and the turn can never run. --- src/main/claude/claude-result-journaling.ts | 4 +- ...codex-structured-conversation-stop.test.ts | 2 +- .../codex-structured-prompt-ownership.test.ts | 2 +- .../codex-structured-prompt-ownership.ts | 44 +++++-- .../codex-structured-session-cancel.test.ts | 6 +- .../codex-structured-turn-open-wait.test.ts | 41 +++++- .../codex/codex-structured-turn-open-wait.ts | 46 ++++--- .../codex/codex-structured-turn-steer.test.ts | 3 +- .../journal-stop-marks.ts | 25 +++- .../journal-stop-turn-end.test.ts | 119 +++++++++++++++++ .../journal-stop-turn-end.ts | 114 ++++++---------- .../queued-message-pause.ts | 6 +- .../structured-agent-session-adapter-stop.ts | 4 +- ...ructured-agent-session-event-sink-queue.ts | 4 +- .../structured-agent-session-event-sink.ts | 5 +- ...d-agent-session-stop-event-binding.test.ts | 94 ++++++++++--- ...d-agent-session-stop-event-restart.test.ts | 40 +++++- ...-agent-session-stop-settle-binding.test.ts | 121 +++++++++++++++++ .../structured-agent-session-turns-cancel.ts | 123 ++++++++++++++---- ...-session-codex-turn-end-settlement.test.ts | 49 ++++++- 20 files changed, 681 insertions(+), 171 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index a4226067b98e..1473886b9eac 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -59,9 +59,7 @@ export function journalClaudeResult( // Read before the settle below closes the turn: an error end the journal's Stop rule makes a // person's cancellation is theirs to decide as the end is written (`turnEndAfterStop`). const turnId = settlesTurn ? turn.id : null - const leftToStop = - turnId !== null && - sink.journalStopDecidesTurn?.(turnId, observedAt, turn.openedBy ?? undefined) === true + const leftToStop = turnId !== null && sink.journalStopDecidesTurn?.(turnId, observedAt) === true if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) diff --git a/src/main/codex/codex-structured-conversation-stop.test.ts b/src/main/codex/codex-structured-conversation-stop.test.ts index 62ecaf8f2ea1..4650603256bd 100644 --- a/src/main/codex/codex-structured-conversation-stop.test.ts +++ b/src/main/codex/codex-structured-conversation-stop.test.ts @@ -29,7 +29,7 @@ describe('a Codex Stop that names no turn', () => { const rig = await codexTurnLifecycleRig() await openedTurn(rig) - await expect(stop(rig, () => null)).resolves.toEqual({ cancelled: true }) + await expect(stop(rig, () => null)).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) }) diff --git a/src/main/codex/codex-structured-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts index ef1f8cc426f8..a0b2dc4146ed 100644 --- a/src/main/codex/codex-structured-prompt-ownership.test.ts +++ b/src/main/codex/codex-structured-prompt-ownership.test.ts @@ -509,7 +509,7 @@ describe('Codex live prompt ownership', () => { await expect( adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) + ).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) expect(completionThreads(events)).toEqual([]) }) diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index effab9662bf5..efce60e6fad9 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -12,7 +12,7 @@ import { } from './codex-structured-prompt-replies' import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' import { interruptCodexTurn } from './codex-structured-turn-cancellation' -import { codexRunningOrOpeningTurn } from './codex-structured-turn-open-wait' +import { codexStopTarget } from './codex-structured-turn-open-wait' type CancelInput = Parameters[0] type AnswerInput = Parameters[0] @@ -22,10 +22,25 @@ function providerTurnId(session: CodexSession, turnId: string): string | undefin return session.translator ? session.translator.commandProviderTurnId(turnId) : turnId } +/** A send whose answer this child lost: Codex may still open a turn for it that no Stop can name. + * A pending one Codex answered is not: its turn is the one the Stop waits for. */ +function sendInDoubt(dispatchStatus: CancelInput['dispatchStatus']): boolean { + return dispatchStatus?.state === 'unknown' && !dispatchStatus.recovered +} + +/** The Stop's own answer, naming the journal turn it stopped when it took. */ +function stoppedTurn( + outcome: AgentSessionCancelOutcome, + turnId: string +): AgentSessionCancelOutcome { + return outcome.cancelled ? { ...outcome, turnId } : outcome +} + /** * A Stop that names no turn: interrupt the turn the journal shows, or else the one Codex reported * started and not yet ended, which the journal can trail by a publish, or else the one Codex - * answered a send into, once it opens. + * answered a send into, once it opens. A turn that may still open, unanswered or not open in time, + * is one this Stop cannot reach: it answers refused, and the host ends the child. */ async function cancelCodexConversation( input: Parameters[0], @@ -34,20 +49,28 @@ async function cancelCodexConversation( const { request, sessions, requestTimeoutMs } = input const liveTurnId = request.resolveLiveTurnId?.() ?? null // A turn the journal shows that Codex has not started yet (a compaction's) has nothing to stop. - const turnId = - liveTurnId === null - ? await codexRunningOrOpeningTurn(session) - : providerTurnId(session, liveTurnId) + const target = liveTurnId === null ? await codexStopTarget(session) : { turnId: liveTurnId } // The wait for the turn to open can outlive the session it began on. if ( - !turnId || sessions.get(request.sessionId) !== session || session.ended || session.fence !== request.fence ) { return { cancelled: false } } - return interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) + if (target === null || 'opening' in target) { + return target !== null || sendInDoubt(request.dispatchStatus) + ? { cancelled: false, refusal: {} } + : { cancelled: false } + } + const turnId = liveTurnId === null ? target.turnId : providerTurnId(session, liveTurnId) + if (!turnId) { + return { cancelled: false } + } + return stoppedTurn( + await interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }), + target.turnId + ) } export async function cancelCodexStructuredTurn(input: { @@ -67,7 +90,10 @@ export async function cancelCodexStructuredTurn(input: { return { cancelled: false } } if (!prompt) { - return interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) + return stoppedTurn( + await interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }), + requestedTurnId + ) } if (session.fence !== request.fence) { return { cancelled: false } diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index cd30bfa2e6d1..939d5faf0cdb 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -143,7 +143,7 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { await expect( adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) + ).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) expect(codex.connections[0].calls.at(-1)).toEqual({ method: 'turn/interrupt', params: { threadId: THREAD_ID, turnId: 'turn-1' } @@ -288,7 +288,7 @@ describe('a Codex Stop is the interrupt alone', () => { params: expect.objectContaining({ turn: { id: 'turn-1', status: 'interrupted' } }) }) ) - await expect(stopped).resolves.toEqual({ cancelled: true }) + await expect(stopped).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) }) it('reads and kills no processes, from the send through the Stop', async () => { @@ -299,7 +299,7 @@ describe('a Codex Stop is the interrupt alone', () => { for (const work of Object.values(processWork)) { expect(work).not.toHaveBeenCalled() } - await expect(stopped).resolves.toEqual({ cancelled: true }) + await expect(stopped).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) for (const work of Object.values(processWork)) { expect(work).not.toHaveBeenCalled() } diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index c86a39fb59ef..44f1a5269289 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -57,7 +57,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.turns.start() - expect(await settledWithin(stopping)).toEqual({ cancelled: true }) + expect(await settledWithin(stopping)).toEqual({ cancelled: true, turnId: 'turn-1' }) expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) expect(rig.turns.turnId).toBeNull() }) @@ -71,7 +71,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi release() await sending - expect(await settledWithin(stop(rig))).toEqual({ cancelled: true }) + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true, turnId: 'turn-1' }) }) it('does not wait for a send Codex steered into the running turn', async () => { @@ -80,7 +80,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.turns.start() expect(await settledWithin(rig.send('client-2'))).toEqual(ADMITTED) - expect(await settledWithin(stop(rig))).toEqual({ cancelled: true }) + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true, turnId: 'turn-1' }) expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) }) @@ -115,7 +115,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(await settledWithin(stopping)).toBe('held') rig.turns.start() - expect(await settledWithin(stopping)).toEqual({ cancelled: true }) + expect(await settledWithin(stopping)).toEqual({ cancelled: true, turnId: 'turn-1' }) }) it('stops nothing when the child exits', async () => { @@ -128,7 +128,8 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(rig.interrupts()).toEqual([]) }) - it('stops nothing once its bound runs out', async () => { + // The turn may still open and run: the host ends the child (`performCancel`). + it('answers refused once its bound runs out', async () => { const rig = await codexTurnLifecycleRig() await answeredColdSend(rig) vi.useFakeTimers() @@ -141,7 +142,35 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(outcome).toBe('held') await vi.advanceTimersByTimeAsync(1) - expect(outcome).toEqual(REFUSED) + expect(outcome).toEqual({ cancelled: false, refusal: {} }) expect(rig.interrupts()).toEqual([]) }) }) + +describe('a no-turn Stop with no turn Codex answered', () => { + it('answers refused when the answer to the latest send was lost: Codex may still open its turn', async () => { + const rig = await codexTurnLifecycleRig() + + const outcome = await rig.adapter.cancelTurn({ + sessionId: 'session-1', + fence: 7, + dispatchStatus: { state: 'unknown', recovered: false } + }) + + expect(outcome).toEqual({ cancelled: false, refusal: {} }) + expect(rig.interrupts()).toEqual([]) + }) + + it('stops nothing when nothing was sent, or the lost answer outlived its child', async () => { + const rig = await codexTurnLifecycleRig() + + expect(await stop(rig)).toEqual(REFUSED) + expect( + await rig.adapter.cancelTurn({ + sessionId: 'session-1', + fence: 7, + dispatchStatus: { state: 'unknown', recovered: true } + }) + ).toEqual(REFUSED) + }) +}) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index 9f30b292ff0c..a6e1d50a46ed 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -14,8 +14,8 @@ import { export const CODEX_TURN_OPEN_WAIT_MS = 5_000 export type CodexTurnOpenWaits = { - /** Resolves once `turnId` opens or can no longer, and after `withinMs` at the latest. */ - wait: (turnId: string, withinMs: number) => Promise + /** Resolves once `turnId` opens or can no longer (true), or after `withinMs` (false). */ + wait: (turnId: string, withinMs: number) => Promise /** Ends the waits a notification on the session's own thread answers. */ observe: (threadId: string, method: string, params: unknown) => void /** Ends every wait: the child that would open their turns is gone. */ @@ -23,23 +23,23 @@ export type CodexTurnOpenWaits = { } export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { - const waits = new Map<() => void, string>() + const waits = new Map<(answered: boolean) => void, string>() const release = (turnId?: string): void => { for (const [endWait, waitedTurnId] of waits) { if (turnId === undefined || waitedTurnId === turnId) { - endWait() + endWait(true) } } } return { wait: (turnId, withinMs) => - new Promise((resolve) => { - const endWait = (): void => { + new Promise((resolve) => { + const endWait = (answered: boolean): void => { clearTimeout(bound) waits.delete(endWait) - resolve() + resolve(answered) } - const bound = setTimeout(endWait, withinMs) + const bound = setTimeout(() => endWait(false), withinMs) // A Stop's wait must never be what keeps the process alive at quit. bound.unref?.() waits.set(endWait, turnId) @@ -61,21 +61,25 @@ export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { } } +/** What a Stop or a send finds to act on: a turn running, or one Codex answered a send into that + * has not opened in time and may still open; null when neither. */ +export type CodexStopTarget = { turnId: string } | { opening: string } | null + /** * The turn a Stop or a send names: the latest Codex reported started and not ended, or else the - * one Codex answered a send into, once it opens. Null when none is running and that one ends, the - * thread stops running or the child exits first, or the wait runs out; each such turn is waited - * for once. + * one Codex answered a send into, once it opens. `opening` when the wait runs out first. Null when + * none is running and that one ends, the thread stops running or the child exits first; each such + * turn is waited for once. */ -export async function codexRunningOrOpeningTurn(session: { +export async function codexStopTarget(session: { threadId: string activeTurnIds?: ReadonlySet dispatchEchoes: Pick turnOpenWaits: Pick -}): Promise { +}): Promise { const running = [...(session.activeTurnIds ?? [])].at(-1) if (running) { - return running + return { turnId: running } } const answered = session.dispatchEchoes.answeredUnopenedTurn( session.threadId, @@ -85,12 +89,20 @@ export async function codexRunningOrOpeningTurn(session: { return null } // Codex refuses an interrupt, and before 0.148 a steer, until it opens the turn. - await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) + const settled = await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) if (session.activeTurnIds?.has(answered)) { - return answered + return { turnId: answered } } // Before 0.148 a turn that fails before it starts reports no end; one that opens later is still // found running. session.dispatchEchoes.leftUnopened(session.threadId, answered) - return null + return settled ? null : { opening: answered } +} + +/** `codexStopTarget`'s running or opened turn, for a send. */ +export async function codexRunningOrOpeningTurn( + session: Parameters[0] +): Promise { + const target = await codexStopTarget(session) + return target !== null && 'turnId' in target ? target.turnId : null } diff --git a/src/main/codex/codex-structured-turn-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts index c1b982709b30..20d009ed0cc7 100644 --- a/src/main/codex/codex-structured-turn-steer.test.ts +++ b/src/main/codex/codex-structured-turn-steer.test.ts @@ -223,7 +223,8 @@ describe('a Codex send made after Codex answered an earlier one, before it opene expect(await rig.sending).toEqual({ state: 'admitted' }) expect(rig.methods()).toEqual(['thread/start', 'turn/start', 'turn/steer']) expect(await rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).toEqual({ - cancelled: true + cancelled: true, + turnId: 'turn-1' }) expect(rig.settlements.map(({ clientMessageId }) => clientMessageId).sort()).toEqual([ 'client-1', diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts index e79958a952c7..baba17c0058d 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -1,12 +1,15 @@ // What the journal answers about its Stops beyond the queue's pause: the latest Stop event, which -// the turn-end rule reads (`journal-stop-turn-end.ts`), and whether a person's still decides. +// the turn-end rule reads (`journal-stop-turn-end.ts`), whether a person's still decides, and what +// a person's Stop that named no turn binds while and after it settles. import type { JournalReducerState } from './journal-reducer' import { + beginJournalStopSettle, latestAcceptedSendUnopened, personStopDecidesTurn, type JournalLatestStop } from './journal-stop-turn-end' +import type { JournalStopSettle } from './queued-message-pause' export class JournalStopMarks { constructor(private readonly deps: { state: () => JournalReducerState }) {} @@ -21,7 +24,23 @@ export class JournalStopMarks { } /** `personStopDecidesTurn`: a person's Stop decides how turn `turnId` ends. */ - personStopDecides(turnId: string | null, endedAt?: number, openedBy?: string): boolean { - return personStopDecidesTurn(this.deps.state(), turnId, endedAt, openedBy) + personStopDecides(turnId: string | null, endedAt?: number): boolean { + return personStopDecidesTurn(this.deps.state(), turnId, endedAt) + } + + /** `beginJournalStopSettle`: a turn that ends from here until `settled` is the Stop's. */ + beginSettle(): JournalStopSettle | null { + return beginJournalStopSettle(this.deps.state()) + } + + /** Closes a settle `beginSettle` opened, binding `turnId` when the Stop stopped one. */ + settled(settle: JournalStopSettle | null, turnId?: string): void { + if (!settle) { + return + } + settle.settling = false + if (turnId !== undefined) { + settle.turnId = turnId + } } } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts new file mode 100644 index 000000000000..8c7bc0466cfe --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts @@ -0,0 +1,119 @@ +// The turn-end rule as a Stop that named no turn binds it: every turn that ends while the Stop +// settles, then only the turn it stopped. A cancellation already written stays one. + +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { createJournalReducerState, type JournalReducerState } from './journal-reducer' +import type { JournalStopEvent } from './journal-row-schema' +import { + beginJournalStopSettle, + personStopDecidesTurn, + turnEndAfterStop +} from './journal-stop-turn-end' +import { JournalStopMarks } from './journal-stop-marks' + +const STOPPED_AT = 1_000 + +function stateWith(event?: JournalStopEvent): JournalReducerState { + const state = createJournalReducerState('session-1', 'epoch-1') + state.queuePauseMarks.latestStop = event ? { sequence: 5, event } : null + return state +} + +type TurnBody = Extract + +function ended(turnId: string): TurnBody { + return { kind: 'turn', turnId, state: 'interrupted', completedAt: STOPPED_AT + 10 } +} + +function withTurn(state: JournalReducerState, body: AgentJournalItemBody): void { + state.items.set('turn-item', { + itemId: 'turn-item', + revision: 1, + body, + sequence: 3, + observedAt: STOPPED_AT + }) +} + +const turnless: JournalStopEvent = { reason: 'user-stop', at: STOPPED_AT } + +describe("a person's Stop that named no turn", () => { + it('makes any turn that ends while it settles theirs', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.beginSettle() + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ + outcome: 'cancellation' + }) + expect(personStopDecidesTurn(state, null)).toBe(true) + }) + + it('once settled, binds only the turn it stopped', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.settled(marks.beginSettle(), 'turn-a') + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ + outcome: 'cancellation' + }) + expect(turnEndAfterStop(state, 'other-item', ended('turn-b'))).not.toHaveProperty('outcome') + expect(personStopDecidesTurn(state, null)).toBe(false) + expect(personStopDecidesTurn(state, 'turn-a')).toBe(true) + }) + + it('binds nothing once it settled having stopped nothing', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.settled(marks.beginSettle()) + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).not.toHaveProperty('outcome') + }) + + it('binds nothing before it settles: a relaunch folds the event alone', () => { + const state = stateWith(turnless) + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).not.toHaveProperty('outcome') + expect(personStopDecidesTurn(state, null)).toBe(false) + }) + + it('keeps the turn an earlier press bound when pressed again', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.settled(marks.beginSettle(), 'turn-a') + marks.settled(marks.beginSettle()) + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ + outcome: 'cancellation' + }) + }) +}) + +describe('a Stop with nothing in memory to bind', () => { + it.each([ + ['one that named its turn', { reason: 'user-stop', turnId: 'turn-a', at: STOPPED_AT }], + ['a host stop', { reason: 'host-stop', at: STOPPED_AT }] + ] satisfies [string, JournalStopEvent][])('opens no settle: %s', (_label, event) => { + expect(beginJournalStopSettle(stateWith(event))).toBeNull() + }) +}) + +describe('an end already written as a cancellation', () => { + it('stays one when a later end with no verdict is written over it', () => { + const state = stateWith() + withTurn(state, { ...ended('turn-a'), outcome: 'cancellation' }) + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ + outcome: 'cancellation' + }) + }) + + it("gives way to the provider's own verdict", () => { + const state = stateWith() + withTurn(state, { ...ended('turn-a'), outcome: 'cancellation' }) + const failed: TurnBody = { ...ended('turn-a'), outcome: 'failure' } + + expect(turnEndAfterStop(state, 'turn-item', failed)).toEqual(failed) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 4c28a0422a6d..b0f1f8665e47 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -1,11 +1,11 @@ -// What a Stop decides about the turn it named: the one rule every turn-end write passes through. +// What a Stop decides about the turn it stopped: the one rule every turn-end write passes through. // -// A turn a person's Stop or close of this chat named, or, when it named none, a turn opened by a -// send it stopped, ending with no verdict of its own after that Stop's event, ends as their -// cancellation. A host stop, an eviction and no Stop at all leave the end as written. It runs -// where each row is built, inside the journal's serialized write, so it reads every Stop folded -// before the end: the adapter's settle, the host's fallback and a relaunch's settle all write -// through it, and every client folds the row it wrote. +// A turn a person's Stop or close of this chat named, or, when it named none, one that ended while +// that Stop settled or that its settle bound, ending with no verdict of its own after that Stop's +// event, ends as their cancellation. A host stop, an eviction and no Stop at all leave the end as +// written. It runs where each row is built, inside the journal's serialized write: the adapter's +// end, the host's fallback and a relaunch's settle all write through it, and every client folds +// the row it wrote. What an unnamed Stop binds is held in memory, so after a relaunch it binds none. import { agentJournalSubmissionKey, @@ -16,15 +16,14 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch' import type { JournalReducerState } from './journal-reducer' import type { JournalStopEvent } from './journal-row-schema' -import type { JournalQueuePauseMarks } from './queued-message-pause' +import type { JournalQueuePauseMarks, JournalStopSettle } from './queued-message-pause' export type JournalLatestStop = NonNullable /** Only a person's own Stop, or their close of this chat, makes a cut turn their cancellation. */ -function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { +export function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { switch (reason) { case 'user-stop': case 'user-close': @@ -40,7 +39,8 @@ type TurnEndState = Pick< 'items' | 'queuePauseMarks' | 'submissions' | 'aliases' > -/** The send whose journal item `userItemId` (a turn's opener, by its own or its provider key) is. */ +/** The send whose journal item `userItemId` (a turn's opener, by its own or its provider key) is. + * Only says whether a send's turn has opened; it never decides whose a turn's end is. */ function openingSubmission( state: TurnEndState, userItemId: string | undefined @@ -83,72 +83,36 @@ export function latestAcceptedSendUnopened(state: TurnEndState): boolean { return true } -/** A send a Stop that named no turn stopped: one already handed to the agent at the Stop's - * position, whose turn had not opened. A card the Stop held, or anything sent after it, is not. */ -function isStopTarget( - state: TurnEndState, - stop: JournalLatestStop, - submission: AgentJournalSubmission -): boolean { - if ( - submission.dispatchState === 'rejected' || - (submission.handoverRecorded === true && submission.handedOverAt === undefined) - ) { - return false - } - // A handed-over send's item sits at its handover (`placeHandedOverMessage`). - const handedOver = state.items.get(agentJournalSubmissionKey(submission.clientMessageId)) - return handedOver !== undefined && handedOver.sequence < stop.sequence -} - /** Whether `stop`, a person's, makes the end of turn `turnId` theirs: it named that turn, or named - * none and stopped the send that opened it (`userItemId`). */ -function stopIsTurnCancellation( - state: TurnEndState, - stop: JournalLatestStop, - turnId: string, - userItemId: string | undefined -): boolean { + * none and the turn ends while it settles, or its settle bound that turn. */ +function stopIsTurnCancellation(stop: JournalLatestStop, turnId: string): boolean { if (!stopIsAPersons(stop.event.reason)) { return false } if (stop.event.turnId !== undefined) { return stop.event.turnId === turnId } - const opener = openingSubmission(state, userItemId) - return opener !== undefined && isStopTarget(state, stop, opener) + return stop.settle?.settling === true || stop.settle?.turnId === turnId } -/** THE rule: whether the latest Stop makes turn `turnId`, opened by `userItemId` and ending at - * `endedAt` with no verdict of its own, a person's cancellation. An exit the provider saw before - * the Stop was news, whenever its end is written. */ +/** THE rule: whether the latest Stop makes turn `turnId`, ending at `endedAt` with no verdict of its + * own, a person's cancellation. An exit the provider saw before the Stop was news, whenever its + * end is written. */ function stopEndsTurnAsCancellation( state: TurnEndState, turnId: string, - userItemId: string | undefined, endedAt: number | undefined ): boolean { const stop = state.queuePauseMarks.latestStop return ( stop !== null && - stopIsTurnCancellation(state, stop, turnId, userItemId) && + stopIsTurnCancellation(stop, turnId) && (endedAt === undefined || endedAt >= stop.event.at) ) } -/** With no turn running, the work in flight is the person's Stop's: every send still unanswered is - * one it stopped. */ -function unansweredSendsAreStopTargets(state: TurnEndState, stop: JournalLatestStop): boolean { - const unanswered = [...state.submissions.values()].filter((submission) => - isUnansweredStructuredAgentSessionDispatch(submission) - ) - return ( - unanswered.length > 0 && unanswered.every((submission) => isStopTarget(state, stop, submission)) - ) -} - /** - * Whether a person's Stop decides the end of turn `turnId` (null: the sends in flight with no turn + * Whether a person's Stop decides the end of turn `turnId` (null: the work in flight with no turn * running), by `turnEndAfterStop`'s rule: ending at `endedAt` it is their cancellation, and still * running it is theirs to end. For a writer that must choose before the end is written: a host * stop must not supersede it, and a Claude error result naming no reason leaves its verdict to it. @@ -156,29 +120,36 @@ function unansweredSendsAreStopTargets(state: TurnEndState, stop: JournalLatestS export function personStopDecidesTurn( state: TurnEndState, turnId: string | null, - endedAt?: number, - /** The submission that opened the turn, for one whose rows have yet to land. */ - openedBy?: string + endedAt?: number ): boolean { const stop = state.queuePauseMarks.latestStop if (stop === null || !stopIsAPersons(stop.event.reason)) { return false } if (turnId === null) { - return stop.event.turnId === undefined && unansweredSendsAreStopTargets(state, stop) + return stop.event.turnId === undefined && stop.settle?.settling === true } - const turn = [...state.items.values()] - .map((item) => readAgentJournalTurn(item.body)) - .find((candidate) => candidate?.turnId === turnId) - const userItemId = - turn?.userItemId ?? (openedBy === undefined ? undefined : agentJournalSubmissionKey(openedBy)) - return stopEndsTurnAsCancellation(state, turnId, userItemId, endedAt) + return stopEndsTurnAsCancellation(state, turnId, endedAt) +} + +/** Opens the settle of the latest Stop, a person's that named no turn: until it closes, every turn + * that ends is that Stop's. Null when there is none, or it named its turn. */ +export function beginJournalStopSettle( + state: Pick +): JournalStopSettle | null { + const stop = state.queuePauseMarks.latestStop + if (stop === null || stop.event.turnId !== undefined || !stopIsAPersons(stop.event.reason)) { + return null + } + // A repeat press keeps the turn an earlier one bound. + stop.settle = { settling: true, ...(stop.settle?.turnId ? { turnId: stop.settle.turnId } : {}) } + return stop.settle } /** * The body to write for item `itemId`: unchanged unless it ends, with no verdict of its own and no - * earlier than the latest Stop event, a person's, which named it, or stopped the send that opened - * it, while it was still open (running, or unproven). A provider's own verdict always stands. + * earlier than the latest Stop event, a person's that named it or bound it, while it was still open + * (running, or unproven). A provider's own verdict always stands. */ export function turnEndAfterStop( state: TurnEndState, @@ -189,12 +160,13 @@ export function turnEndAfterStop( return body } const previous = readAgentJournalTurn(state.items.get(itemId)?.body) - // An end already written stands: the Stop came after it. + // An end already written stands: the Stop came after it. A cancellation written stays one. if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { - return body + return previous.state === 'interrupted' && previous.outcome === 'cancellation' + ? { ...body, outcome: 'cancellation' } + : body } - const userItemId = body.userItemId ?? previous?.userItemId - return stopEndsTurnAsCancellation(state, body.turnId, userItemId, body.completedAt) + return stopEndsTurnAsCancellation(state, body.turnId, body.completedAt) ? { ...body, outcome: 'cancellation' } : body } diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 9039760ba43c..63dc6823df28 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -15,9 +15,13 @@ import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' +/** What a person's Stop that named no turn binds, held in memory and never read from a row, so a + * reopen binds nothing: while it settles, every turn that ends; once settled, the turn it stopped. */ +export type JournalStopSettle = { settling: boolean; turnId?: string } + /** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ export type JournalQueuePauseMarks = { - latestStop: { sequence: number; event: JournalStopEvent } | null + latestStop: { sequence: number; event: JournalStopEvent; settle?: JournalStopSettle } | null /** 0 when none. */ resumedSequence: number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts index 4709234ad5c6..95ae77a43c29 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts @@ -10,10 +10,12 @@ import type { ProviderDiagnostic } from '../../../shared/agent-session-failure' /** `refusal`: the provider answered the Stop and declined it, in its own words when it gave any. * `turnNotRunning`: its refusal is the kind it gives for a turn not running there, so the Stop keeps - * the child; absent, it could not interrupt that turn, which may run on. */ + * the child; absent, it could not interrupt that turn, which may run on. `turnId`: the journal + * turn a Stop that took interrupted, which the Stop then binds. */ export type AgentSessionCancelOutcome = { cancelled: boolean refusal?: { detail?: ProviderDiagnostic; turnNotRunning?: true } + turnId?: string } /** Where a card's Cancel goes: a dismissal (`dismissPrompt`), or the chat's Stop. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 60ca5d535c53..5ce3de7d0e5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -68,8 +68,8 @@ export class StructuredAgentSessionSinkQueue { journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null - journalStopDecidesTurn = (turnId: string, endedAt: number, openedBy?: string): boolean => - this.target?.journal.stopMarks.personStopDecides(turnId, endedAt, openedBy) ?? false + journalStopDecidesTurn = (turnId: string, endedAt: number): boolean => + this.target?.journal.stopMarks.personStopDecides(turnId, endedAt) ?? false bindReadingControl(control: StructuredAgentSessionReadingControl): () => void { this.readingControl = control diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index 47f9e03d0cc4..70d1f3f85d3f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -137,9 +137,8 @@ export type StructuredAgentSessionEventSink = { /** The bound journal's producer linkage; null until bound. */ journalLinkage?(): StructuredAgentSessionLinkageJournal | null /** Whether the bound journal's Stop rule makes turn `turnId`, ending at `endedAt` with no verdict - * of its own, a person's cancellation (`personStopDecidesTurn`); false until bound. `openedBy`: - * the submission that opened it, for a turn whose rows have yet to land. */ - journalStopDecidesTurn?(turnId: string, endedAt: number, openedBy?: string): boolean + * of its own, a person's cancellation (`personStopDecidesTurn`); false until bound. */ + journalStopDecidesTurn?(turnId: string, endedAt: number): boolean appendLifecycleBatch?( settlementId: string, mutations: readonly JournalLifecycleMutationInput[], diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 2654b20ac730..019d31c63597 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -1,7 +1,7 @@ -// Which turn a person's Stop that named no turn binds: only a turn a send it stopped opens. A Stop -// of a start that never landed stopped a send that opens no turn; a card it held, which Resume -// releases, and anything sent after it open their own; a rewind keeps the binding. Turn rows name -// the send that opened them, as Codex writes them. +// Which turn a person's Stop that named no turn binds: one that ends while the Stop settles, and +// the turn its interrupt took. A Stop that stopped nothing binds no turn that opens after it, nor +// does a Stop of a start that never landed; a card it held, which Resume releases, and anything +// sent after it end as their own. Turn rows name the send that opened them, as Codex writes them. import { afterEach, describe, expect, it } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' @@ -63,6 +63,18 @@ async function laterTurn() { .find((turn) => turn?.turnId === 'turn-later') } +/** Every end row written for the later turn, in order: a relabel would show as two. */ +function laterTurnEndRows() { + const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + return since.rows.flatMap((row) => { + const turn = row.kind === 'item' ? readAgentJournalTurn(row.body) : undefined + return turn?.turnId === 'turn-later' && turn.state !== 'running' ? [turn] : [] + }) +} + /** The turn send `clientMessageId` opens, running, named by its row as Codex writes it. */ async function turnOpenedBy(clientMessageId: string, state: 'running' | 'interrupted' = 'running') { await journal().appendItem( @@ -185,19 +197,22 @@ describe('a Stop of a start that never landed binds no later turn', () => { }) }) -describe('a Stop pressed before its send opened a turn binds only that turn', () => { +describe('a Stop pressed before its send opened a turn binds only the turn it stopped', () => { /** The send the Stop stopped is handed over and unopened; a card waits behind it. */ - async function stopBeforeTheTurnShowed(): Promise<{ stopped: string; held: string }> { + async function stopBeforeTheTurnShowed( + answer: Awaited> = { cancelled: true } + ): Promise<{ stopped: string; held: string }> { rig = await createQueuedMessageTestRig() const stopped = await rig.workingSend() const held = await queuedDraft('queued behind the turn') + rig.cancelTurn.mockResolvedValueOnce(answer) expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') return { stopped, held } } - it("binds the stopped send's own turn", async () => { - const { stopped } = await stopBeforeTheTurnShowed() + it('binds the turn its interrupt took, whose end lands after the Stop answered', async () => { + const { stopped } = await stopBeforeTheTurnShowed({ cancelled: true, turnId: 'turn-later' }) await rig.settleAccepted(stopped, 'stopped') await turnOpenedBy(stopped, 'interrupted') @@ -205,6 +220,34 @@ describe('a Stop pressed before its send opened a turn binds only that turn', () expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) }) + // The interrupt reaches the turn as it opens; its end is written before the Stop answers. + it('reads a turn that ends while the Stop settles as interrupted from its first end row', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + rig.cancelTurn.mockImplementationOnce(async () => { + await turnOpenedBy(stopped) + await turnOpenedBy(stopped, 'interrupted') + return { cancelled: true, turnId: 'turn-later' } + }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(laterTurnEndRows()).toEqual([ + expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' }) + ]) + expect(journal().activeTurnId()).toBeNull() + }) + + it('binds no turn that opens after a Stop that stopped nothing', async () => { + const { stopped } = await stopBeforeTheTurnShowed({ cancelled: false }) + await rig.settleAccepted(stopped, 'stopped') + + await turnOpenedBy(stopped) + await turnOpenedBy(stopped, 'interrupted') + + await expectNews() + }) + it("writes the host's event when it evicts the turn of the card Resume sent", async () => { const { stopped, held } = await stopBeforeTheTurnShowed() await rig.settleAccepted(stopped, 'stopped') @@ -229,25 +272,42 @@ describe('a Stop pressed before its send opened a turn binds only that turn', () }) }) +describe("a Stop's settle that ends the turn its interrupt took", () => { + it("ends a turn still running once the stream drains, as the Stop's, once", async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + await rig.settleAccepted(stopped, 'stopped') + await turnOpenedBy(stopped) + rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: 'turn-later' }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(journal().activeTurnId()).toBeNull() + }) +}) + describe('a host stop with no turn running after a Stop that named none', () => { - it('writes nothing while every unanswered send is one the Stop stopped', async () => { + it('defers to that Stop while it settles, and not after', async () => { rig = await createQueuedMessageTestRig() await rig.workingSend() + let whileSettling: boolean | undefined + rig.cancelTurn.mockImplementationOnce(async () => { + whileSettling = journal().stopMarks.personStopDecides(null) + return { cancelled: true } + }) + expect(await rig.stop()).toMatchObject({ ok: true }) - expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') - expect(await evictedAt()).toEqual(['user-stop']) + expect(whileSettling).toBe(true) + expect(journal().stopMarks.personStopDecides(null)).toBe(false) }) - it("writes the host's event when a send after the Stop is unanswered beside the stopped one", async () => { + it("writes the host's event for work still unanswered once that Stop settled", async () => { rig = await createQueuedMessageTestRig() await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true }) - const mail = rig.send('mail for the lead', undefined, { internal: true }) - await mail.result - await eventually(async () => - expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() - ) + expect(journal().stopMarks.personStopDecides(null)).toBe(false) expect(await evictedAt()).toEqual(['user-stop', 'evict']) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 4ae9f59a015a..55d840315ed9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -1,7 +1,7 @@ -// A Stop that took effect still decides its turn after Orca restarts before the turn's end was -// written: the relaunch's settle reads the Stop's event, so the turn reads "Interrupted after N" -// with the muted mark, not "Failed". A turn nobody stopped, and one a Stop never named, still read -// as the news they are. +// A Stop that took effect still decides the turn it named after Orca restarts before the turn's end +// was written: the relaunch's settle reads the Stop's event, so the turn reads "Interrupted after N" +// with the muted mark, not "Failed". A turn nobody stopped, one a Stop never named, and one a +// turnless Stop was still settling when Orca died, still read as the news they are. import { afterEach, describe, expect, it } from 'vitest' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' @@ -272,10 +272,11 @@ describe('a restart between a Stop and its turn end', () => { expect(settled().turn).not.toHaveProperty('outcome') }) - // A Stop pressed before any turn showed stopped the turn its send was about to open, and no other. + // A Stop pressed before any turn showed stopped the turn its interrupt took, and no other. it('reads a turn a host send opened after the turnless Stop ended its own turn as no Stop of its', async () => { rig = await createQueuedMessageTestRig() const stopped = await rig.workingSend() + rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: TURN }) expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() await rig.settleAccepted(stopped, 'stopped') @@ -322,6 +323,35 @@ describe('a restart between a Stop and its turn end', () => { expect(settled(NEXT_TURN).turn).not.toHaveProperty('outcome') }) + // What a turnless Stop binds is held in memory: a relaunch finds it unsettled, and it binds none. + it('reads the turn a turnless Stop was settling when Orca died as no Stop of its', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + // Orca dies while the interrupt is out: the Stop never settles. + rig.cancelTurn.mockImplementationOnce(async () => { + await journal().appendItem( + CODEX_TURN, + { + kind: 'turn', + turnId: TURN, + state: 'running', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(stopped) + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return new Promise(() => undefined) + }) + void rig.stop() + await expect.poll(() => journal().activeTurnId()).toBe(TURN) + expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() + + await restartAndSettle('exit-observed') + + expect(settled(TURN).turn).toMatchObject({ state: 'interrupted' }) + expect(settled(TURN).turn).not.toHaveProperty('outcome') + }) + // A refusal is no record: the first Stop stays the one in force, so pressing again repeats it. it('writes nothing for a Stop pressed again after the provider refused one', async () => { await runningTurn(CODEX_TURN) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts new file mode 100644 index 000000000000..3950d95916b6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts @@ -0,0 +1,121 @@ +// A Stop pressed before its send's turn showed binds the turn that send opens only by stopping it: +// a Codex turn that did not open in time ends with the child the Stop ends, and a Claude turn that +// opens before the Stop's child end is proven ends as the Stop's. Nothing reads as running after. + +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +const OPENED: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-opened', + ordinal: 999 +} + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +/** The turn `clientMessageId` opens, running, as the provider writes it. */ +async function turnOpens(clientMessageId: string): Promise { + await journal().appendItem( + OPENED, + { + kind: 'turn', + turnId: 'turn-opened', + state: 'running', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(clientMessageId) + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +function openedTurn() { + return journal() + .snapshot() + .items.map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-opened') +} + +function nothingRuns(): boolean { + return !isStructuredAgentSessionMainAgentWorking( + journal().activeTurnId(), + journal().submissions() + ) +} + +/** Drains the Stop's next step on the session's lane: a wind-down it handed the child's end to. */ +async function windDown(): Promise { + await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) +} + +describe('a Codex Stop whose answered turn did not open in time', () => { + // Codex admitted the send, answered into a turn it has not opened; the Stop's wait ran out. + async function stopOfAnsweredSend(): Promise { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) + return sent + } + + it('ends the child, so the turn can never run', async () => { + await stopOfAnsweredSend() + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(rig.closeSession).toHaveBeenCalled() + expect(openedTurn()).toBeUndefined() + expect(nothingRuns()).toBe(true) + }) + + it('reads a turn that opened before the child end as interrupted by the Stop', async () => { + const sent = await stopOfAnsweredSend() + rig.closeSession.mockImplementationOnce(async () => { + await turnOpens(sent) + return true + }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(openedTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(nothingRuns()).toBe(true) + }) +}) + +describe('a Claude Stop pressed before its send echoed', () => { + it('reads the turn that opens before its child end is proven as interrupted by the Stop', async () => { + rig = await createQueuedMessageTestRig({ stopEndsSession: true }) + const sent = await rig.workingSend() + rig.closeSession.mockImplementationOnce(async () => { + await turnOpens(sent) + return true + }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await windDown() + + expect(rig.closeSession).toHaveBeenCalled() + expect(openedTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(nothingRuns()).toBe(true) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 48500afa12f3..d96a6c085bbd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -3,6 +3,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' @@ -22,6 +23,7 @@ import { structuredAgentSessionStoppedTurnId } from './structured-agent-session-turn-stop-notes' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { runningTurnLifecycleRevisions } from './structured-agent-session-stale-turn-verdict' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' /** Claude's echo accepts a send one sink write before its turn row lands, so read after the drain. @@ -74,8 +76,13 @@ function stopRefusedNote( /** What a Stop that ends the provider's session leaves its next serialized step: whether the * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went - * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. */ -export type StructuredAgentSessionStopWindDown = { waitsForProvider: boolean; stoppedAt: number } + * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. + * `settled` closes the Stop's settle once the child's end is proven: until then, what ends is its. */ +export type StructuredAgentSessionStopWindDown = { + waitsForProvider: boolean + stoppedAt: number + settled?: () => void +} /** * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent @@ -96,34 +103,45 @@ export async function endStoppedStructuredAgentSession( await stopChild() } catch (error) { onError(error) + } finally { + windDown.settled?.() + } +} + +/** A turn the Stop's interrupt took that still reads running once the stream drains: the Stop ends + * it, once, while it still binds it. Bookkeeping: a failure is reported, never the Stop's. */ +async function endStoppedTurnAtSettle(ctx: AgentSessionTurnContext, turnId: string): Promise { + try { + await ctx.flushStreamedEvents() + const running = ctx.journal + .snapshot() + .items.filter((item) => readAgentJournalTurn(item.body)?.turnId === turnId) + const mutations = runningTurnLifecycleRevisions(running, { + state: 'interrupted', + completedAt: ctx.now() + }) + if (mutations.length > 0) { + await ctx.journal.appendLifecycleBatch({ + settlementId: `stop-settled:${turnId}`, + mutations, + fence: ctx.fence + }) + } + } catch (error) { + ctx.logger.warn("ending a stopped turn at its Stop's settle failed", { + scope: 'stop-settle', + sessionId: ctx.sessionId, + error + }) } } +/** What the Stop's settle binds: the turn it stopped, and whether its wind-down closes it. */ +type StopSettleBinding = { turnId?: string; closedByWindDown?: true } + export async function performCancel( ctx: AgentSessionTurnContext, - input: { - clientOperationId: string - /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ - turnId?: string - scope?: 'background-tasks' - taskId?: string - prompt?: { itemId: string; expectedRevision: number } - /** Ends the provider child, for a running command the provider did not take the Stop on, or a - * turn whose interrupt failed. */ - stopChild?: () => Promise - /** A child end after a failed interrupt that threw. */ - onStopChildError?: (error: unknown) => void - /** After that throw: whether the host let go of the child, its exit proven before a later - * cleanup step failed. */ - childReleased?: () => boolean - /** Hands the child's end to the Stop's next serialized step, for a provider whose Stop ends - * its session. */ - endSession?: (windDown: StructuredAgentSessionStopWindDown) => void - /** The host already withdrew queued messages for this Stop. */ - withdrewQueued?: boolean - /** The session's child records: a background Stop reaches the tasks they offer a stop. */ - childWork?: () => readonly AgentChildWorkView[] | undefined - } + input: PerformCancelInput ): Promise> { if (input.prompt) { const validated = validatePendingPrompt(ctx, input.prompt) @@ -131,6 +149,49 @@ export async function performCancel( return answerCancelOfSettledPrompt(ctx, { ...input, prompt: input.prompt }, validated) } } + // A person's Stop that named no turn binds what ends while it settles (`beginJournalStopSettle`). + const settle = input.scope ? null : ctx.journal.stopMarks.beginSettle() + const binding: StopSettleBinding = {} + const close = (): void => ctx.journal.stopMarks.settled(settle, binding.turnId) + try { + return await cancelAndNote(ctx, input, binding, close) + } finally { + if (!binding.closedByWindDown) { + close() + } + } +} + +type PerformCancelInput = { + clientOperationId: string + /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ + turnId?: string + scope?: 'background-tasks' + taskId?: string + prompt?: { itemId: string; expectedRevision: number } + /** Ends the provider child, for a running command the provider did not take the Stop on, or a + * turn whose interrupt failed. */ + stopChild?: () => Promise + /** A child end after a failed interrupt that threw. */ + onStopChildError?: (error: unknown) => void + /** After that throw: whether the host let go of the child, its exit proven before a later + * cleanup step failed. */ + childReleased?: () => boolean + /** Hands the child's end to the Stop's next serialized step, for a provider whose Stop ends + * its session. */ + endSession?: (windDown: StructuredAgentSessionStopWindDown) => void + /** The host already withdrew queued messages for this Stop. */ + withdrewQueued?: boolean + /** The session's child records: a background Stop reaches the tasks they offer a stop. */ + childWork?: () => readonly AgentChildWorkView[] | undefined +} + +async function cancelAndNote( + ctx: AgentSessionTurnContext, + input: PerformCancelInput, + binding: StopSettleBinding, + closeSettle: () => void +): Promise> { let cancelled = false let note: AgentJournalStatusItem | null = { kind: 'status', @@ -164,6 +225,8 @@ export async function performCancel( // The provider could not interrupt the turn, or its cancel threw: the turn may run on. let interruptFailed = false let refusal: AgentSessionCancelOutcome['refusal'] + // The turn the provider says this Stop's interrupt took. + let stoppedTurn: string | undefined try { const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) const outcome: AgentSessionCancelOutcome = stoppedBefore @@ -191,6 +254,7 @@ export async function performCancel( taken = outcome.cancelled cancelled = outcome.cancelled refusal = outcome.refusal + stoppedTurn = outcome.turnId interruptFailed = refusal !== undefined && refusal.turnNotRunning !== true if (!cancelled && input.withdrewQueued && !(await isMainAgentWorkingOnceFlushed(ctx))) { // A Stop that withdrew what was queued and left nothing working ended what it was sent for, @@ -219,7 +283,8 @@ export async function performCancel( if (endsSession && (!namesTurnNotLive || taken !== false)) { // An interrupt the provider took is worth waiting on, turn row or not: a Stop before the echo // has none, and the echo still opens the turn the Stop interrupted. - input.endSession?.({ waitsForProvider: taken === true, stoppedAt }) + binding.closedByWindDown = true + input.endSession?.({ waitsForProvider: taken === true, stoppedAt, settled: closeSettle }) cancelled = true // The child's end confirms the Stop, so a refused or unconfirmed interrupt says nothing more. if (note !== null) { @@ -260,6 +325,12 @@ export async function performCancel( if (cancelled && input.prompt) { await ctx.flushStreamedEvents() } + if (cancelled) { + binding.turnId = stoppedTurn ?? stoppedTurnId ?? undefined + } + if (taken === true && stoppedTurn !== undefined && !binding.closedByWindDown && !input.scope) { + await endStoppedTurnAtSettle(ctx, stoppedTurn) + } const value = { ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), cancelled } if (input.scope || note === null) { return { ok: true, value } diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 36ab233a9159..103eebed8858 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -22,6 +22,7 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, @@ -506,7 +507,8 @@ describe('a Stop in that window that the turn never opens for', () => { expect(await settledWithin(stopping, 0)).not.toBe('held') expect(childCloses).toBe(1) expect(interrupts).toBe(0) - expect(await statusRows()).toContain('Codex had no turn running to stop.') + // Its wait ran out with the turn still able to open, so the Stop ended the child. + expect(await statusRows()).toContain('Cancellation requested.') }) it('lets the app quit behind it within the eviction budget, and still close the child', async () => { @@ -519,6 +521,51 @@ describe('a Stop in that window that the turn never opens for', () => { }) }) +describe("a Stop pressed while Codex's turn/start is in flight", () => { + function turnRow( + items: Awaited>['items'] + ) { + return items + .map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-1') + } + + it("interrupts the turn that opens, which reads as the Stop's, and nothing reads as running", async () => { + const release = turns.holdNextAnswer() + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + // Queued behind the send's handover on the session's lane, so it runs after Codex answers. + const stopping = stop() + release() + await vi.waitFor(() => expect(openWaits.turnIds).toContain('turn-1')) + turns.start() + await stopping + + expect(interrupts).toBe(1) + const after = await settled() + expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + expect(after.owesWork).toBe(false) + }) + + it('ends the child when the turn has not opened by the end of its wait', async () => { + const release = turns.holdNextAnswer() + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + release() + + expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(interrupts).toBe(0) + expect(childCloses).toBe(1) + expect((await settled()).owesWork).toBe(false) + expect(turnRow((await host.journalSnapshot(SESSION)).items)).toBeUndefined() + }) +}) + describe('a cold send with no Stop behind it', () => { /** Well inside one eviction step's budget, and far inside the bound a Stop waits. */ const PROMPTLY_MS = 1_000 From 4d5e13e1c19a9d797879bfc164f397975f522a2c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:33:47 -0700 Subject: [PATCH 092/182] fix(native-chat): keep the person's queue pause and close binding after a Stop settles A host stop or eviction with no turn running now defers to a person's Stop while its queue pause still holds with nothing sent since, read from rows, so a held card is not handed off on reopen after a Stop that did nothing or whose kill failed. A person's close that named no turn opens a settle around its child's end, so a turn that end cuts reads as theirs. A press opens its settle only when the latest Stop event is its own or the one in force it repeats: a late Stop, a card's interrupt or a lost event row reopens no earlier Stop. A Codex Stop that cannot reach a turn still able to open says the Stop is unconfirmed rather than that no turn ran, and a second Stop still reaches a turn an earlier wait left unopened. Also drops the unused openedBy plumbing and the unreachable "a written cancellation stays one" rule, and pins a relaunch after a named Stop. --- src/main/claude/claude-message-journaling.ts | 5 +- src/main/claude/claude-open-turn.ts | 5 -- .../claude-structured-journal-translation.ts | 9 +-- src/main/claude/claude-turn-lifecycle-item.ts | 2 - src/main/claude/claude-turn-opening.ts | 5 +- .../codex/codex-structured-dispatch-echo.ts | 31 +++++--- .../codex-structured-prompt-ownership.ts | 2 +- .../codex-structured-turn-open-wait.test.ts | 9 ++- .../codex/codex-structured-turn-open-wait.ts | 18 +++-- .../journal-stop-turn-end.test.ts | 31 +------- .../journal-stop-turn-end.ts | 8 +- .../structured-agent-session-adapter-stop.ts | 7 +- .../structured-agent-session-chat-stop.ts | 11 ++- .../structured-agent-session-host-lifetime.ts | 5 +- ...tructured-agent-session-host-stop-event.ts | 34 +++++++-- .../structured-agent-session-queued-stop.ts | 46 +++++++---- ...d-agent-session-stop-event-binding.test.ts | 76 ++++++++++++++++++- ...d-agent-session-stop-event-restart.test.ts | 20 +++++ ...-agent-session-stop-settle-binding.test.ts | 37 ++++++++- .../structured-agent-session-turns-cancel.ts | 18 +++-- 20 files changed, 261 insertions(+), 118 deletions(-) diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index 0ade48d58125..9d75db1fca9f 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -63,9 +63,7 @@ export function journalClaudeMessage( startsTurn: boolean, observedAt: number, /** Host clock on the submission that produced this send, when known. */ - requestedAt?: number, - /** The submission this send echo acknowledged. */ - openedBy?: string + requestedAt?: number ): boolean { const envelope = readClaudeMessageEnvelope(message) if (!envelope) { @@ -178,7 +176,6 @@ export function journalClaudeMessage( startsTurn, observedAt, ...(requestedAt === undefined ? {} : { requestedAt }), - ...(openedBy === undefined ? {} : { openedBy }), userItemId: agentJournalItemKey(identity) }) if (sendEchoTurn) { diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index f6edf49d56b2..f946e4ee9cfb 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -60,11 +60,6 @@ export class ClaudeOpenTurn { } /** The open turn's row, where a fact about the running turn lands. */ - /** The submission that opened the open turn, when known (`ClaudeCurrentTurn.openedBy`). */ - get openedBy(): string | null { - return this.current?.openedBy ?? null - } - get identity(): AgentJournalItemIdentity | null { return this.current ? claudeCurrentTurnIdentity(this.current) : null } diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index f47b71b51180..c036b7d67405 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -175,10 +175,8 @@ export function createClaudeJournalTranslator( message: Record, startsTurn: boolean, observedAt: number, - requestedAt?: number, - openedBy?: string - ): boolean => - journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt, openedBy) + requestedAt?: number + ): boolean => journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt) return { handle: (event) => { @@ -246,8 +244,7 @@ export function createClaudeJournalTranslator( event.message, event.startsTurn === true, event.observedAt ?? Date.now(), - event.requestedAt, - event.clientMessageId + event.requestedAt ) ) { providerFallback.append( diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index 1d0db5fa21f4..e816be7f2b29 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -19,8 +19,6 @@ export type ClaudeCurrentTurn = { /** Provider key of the user echo, or the lifecycle row itself when provider * output opened a turn with no user row to receive its timing. */ userItemId: string - /** The submission whose send opened the turn, while its echo has yet to land in the journal. */ - openedBy?: string /** Present when the turn is the host's record of a conversation command. */ command?: ClaudeCommandTurn } diff --git a/src/main/claude/claude-turn-opening.ts b/src/main/claude/claude-turn-opening.ts index b5e13a39c71c..1406928b37ed 100644 --- a/src/main/claude/claude-turn-opening.ts +++ b/src/main/claude/claude-turn-opening.ts @@ -30,8 +30,6 @@ export type ClaudeSendEchoTurnInput = { requestedAt?: number /** Provider key of the user row this turn is anchored to. */ userItemId: string - /** The submission this echo acknowledged. */ - openedBy?: string } /** The turn a replayed send echo opens, or null when this frame is not one. */ @@ -48,8 +46,7 @@ export function claudeTurnOpenedBySendEcho( turnId: envelope.uuid, startedAt: input.observedAt, ...(input.requestedAt === undefined ? {} : { requestedAt: input.requestedAt }), - userItemId: input.userItemId, - ...(input.openedBy === undefined ? {} : { openedBy: input.openedBy }) + userItemId: input.userItemId } : null } diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 775cac5bca4f..1eee3bc9a0a7 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -44,6 +44,9 @@ export type CodexDispatchEchoes = { answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | null /** Codex did not open this answered turn within a wait, so no later wait is spent on it. */ leftUnopened: (threadId: string, turnId: string) => void + /** The latest armed send's answered turn a wait left unopened, neither open nor ended: Codex may + * still open it, though no wait is spent on it again. */ + answeredTurnLeftUnopened: (threadId: string, openTurnIds: ReadonlySet) => string | null /** * Records a turn's end and returns the sends bound to it that it settles: all of them unless it * completed, which echoes its pending input first, so one it never echoed waits for recovery. An @@ -68,6 +71,19 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { let nextSequence = 0 const turnKey = (threadId: string, turnId: string): string => JSON.stringify([threadId, turnId]) const settles = (end: CodexTurnEnd): boolean => end.status !== 'completed' + const answeredTurn = ( + threadId: string, + openTurnIds: ReadonlySet, + admits: (key: string) => boolean + ): string | null => { + const answered = [...armed.values()].flatMap(({ turn }) => { + const key = turn?.threadId === threadId ? turnKey(threadId, turn.turnId) : null + return turn && key && !openTurnIds.has(turn.turnId) && !endedTurns.has(key) && admits(key) + ? [turn.turnId] + : [] + }) + return answered.at(-1) ?? null + } return { arm(clientMessageId, requestedAt) { const existing = armed.get(clientMessageId) @@ -97,17 +113,10 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { } return end }, - answeredUnopenedTurn: (threadId, openTurnIds) => { - const answered = [...armed.values()].flatMap(({ turn }) => - turn?.threadId === threadId && - !openTurnIds.has(turn.turnId) && - !endedTurns.has(turnKey(threadId, turn.turnId)) && - !unopenedTurns.has(turnKey(threadId, turn.turnId)) - ? [turn.turnId] - : [] - ) - return answered.at(-1) ?? null - }, + answeredUnopenedTurn: (threadId, openTurnIds) => + answeredTurn(threadId, openTurnIds, (key) => !unopenedTurns.has(key)), + answeredTurnLeftUnopened: (threadId, openTurnIds) => + answeredTurn(threadId, openTurnIds, (key) => unopenedTurns.has(key)), leftUnopened: (threadId, turnId) => { unopenedTurns.add(turnKey(threadId, turnId)) for (const oldest of unopenedTurns) { diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index efce60e6fad9..813da8f01b6a 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -60,7 +60,7 @@ async function cancelCodexConversation( } if (target === null || 'opening' in target) { return target !== null || sendInDoubt(request.dispatchStatus) - ? { cancelled: false, refusal: {} } + ? { cancelled: false, refusal: { turnMayOpen: true } } : { cancelled: false } } const turnId = liveTurnId === null ? target.turnId : providerTurnId(session, liveTurnId) diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index 44f1a5269289..7fa3af292eee 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -142,8 +142,13 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(outcome).toBe('held') await vi.advanceTimersByTimeAsync(1) - expect(outcome).toEqual({ cancelled: false, refusal: {} }) + expect(outcome).toEqual({ cancelled: false, refusal: { turnMayOpen: true } }) expect(rig.interrupts()).toEqual([]) + // A second Stop spends no wait on that turn, and still finds it able to open. + expect(await settledWithin(stop(rig), 0)).toEqual({ + cancelled: false, + refusal: { turnMayOpen: true } + }) }) }) @@ -157,7 +162,7 @@ describe('a no-turn Stop with no turn Codex answered', () => { dispatchStatus: { state: 'unknown', recovered: false } }) - expect(outcome).toEqual({ cancelled: false, refusal: {} }) + expect(outcome).toEqual({ cancelled: false, refusal: { turnMayOpen: true } }) expect(rig.interrupts()).toEqual([]) }) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index a6e1d50a46ed..b5a3e183f4c6 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -67,14 +67,17 @@ export type CodexStopTarget = { turnId: string } | { opening: string } | null /** * The turn a Stop or a send names: the latest Codex reported started and not ended, or else the - * one Codex answered a send into, once it opens. `opening` when the wait runs out first. Null when - * none is running and that one ends, the thread stops running or the child exits first; each such - * turn is waited for once. + * one Codex answered a send into, once it opens. `opening` when the wait runs out first, or an + * earlier wait already gave up on it with its send still owed. Null when none is running and that + * one ends, the thread stops running or the child exits first; each such turn is waited for once. */ export async function codexStopTarget(session: { threadId: string activeTurnIds?: ReadonlySet - dispatchEchoes: Pick + dispatchEchoes: Pick< + CodexDispatchEchoes, + 'answeredUnopenedTurn' | 'leftUnopened' | 'answeredTurnLeftUnopened' + > turnOpenWaits: Pick }): Promise { const running = [...(session.activeTurnIds ?? [])].at(-1) @@ -86,7 +89,12 @@ export async function codexStopTarget(session: { session.activeTurnIds ?? new Set() ) if (!answered) { - return null + // An earlier wait gave up on it, yet its send is still owed: it may still open. + const left = session.dispatchEchoes.answeredTurnLeftUnopened( + session.threadId, + session.activeTurnIds ?? new Set() + ) + return left ? { opening: left } : null } // Codex refuses an interrupt, and before 0.148 a steer, until it opens the turn. const settled = await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts index 8c7bc0466cfe..59ab1bbb990f 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts @@ -1,5 +1,5 @@ // The turn-end rule as a Stop that named no turn binds it: every turn that ends while the Stop -// settles, then only the turn it stopped. A cancellation already written stays one. +// settles, then only the turn it stopped. import { describe, expect, it } from 'vitest' import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' @@ -26,16 +26,6 @@ function ended(turnId: string): TurnBody { return { kind: 'turn', turnId, state: 'interrupted', completedAt: STOPPED_AT + 10 } } -function withTurn(state: JournalReducerState, body: AgentJournalItemBody): void { - state.items.set('turn-item', { - itemId: 'turn-item', - revision: 1, - body, - sequence: 3, - observedAt: STOPPED_AT - }) -} - const turnless: JournalStopEvent = { reason: 'user-stop', at: STOPPED_AT } describe("a person's Stop that named no turn", () => { @@ -98,22 +88,3 @@ describe('a Stop with nothing in memory to bind', () => { expect(beginJournalStopSettle(stateWith(event))).toBeNull() }) }) - -describe('an end already written as a cancellation', () => { - it('stays one when a later end with no verdict is written over it', () => { - const state = stateWith() - withTurn(state, { ...ended('turn-a'), outcome: 'cancellation' }) - - expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ - outcome: 'cancellation' - }) - }) - - it("gives way to the provider's own verdict", () => { - const state = stateWith() - withTurn(state, { ...ended('turn-a'), outcome: 'cancellation' }) - const failed: TurnBody = { ...ended('turn-a'), outcome: 'failure' } - - expect(turnEndAfterStop(state, 'turn-item', failed)).toEqual(failed) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index b0f1f8665e47..6b71477f74e4 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -23,7 +23,7 @@ import type { JournalQueuePauseMarks, JournalStopSettle } from './queued-message export type JournalLatestStop = NonNullable /** Only a person's own Stop, or their close of this chat, makes a cut turn their cancellation. */ -export function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { +function stopIsAPersons(reason: JournalStopEvent['reason']): boolean { switch (reason) { case 'user-stop': case 'user-close': @@ -160,11 +160,9 @@ export function turnEndAfterStop( return body } const previous = readAgentJournalTurn(state.items.get(itemId)?.body) - // An end already written stands: the Stop came after it. A cancellation written stays one. + // An end already written stands: the Stop came after it. if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { - return previous.state === 'interrupted' && previous.outcome === 'cancellation' - ? { ...body, outcome: 'cancellation' } - : body + return body } return stopEndsTurnAsCancellation(state, body.turnId, body.completedAt) ? { ...body, outcome: 'cancellation' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts index 95ae77a43c29..a6b12b4f4fd1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts @@ -10,11 +10,12 @@ import type { ProviderDiagnostic } from '../../../shared/agent-session-failure' /** `refusal`: the provider answered the Stop and declined it, in its own words when it gave any. * `turnNotRunning`: its refusal is the kind it gives for a turn not running there, so the Stop keeps - * the child; absent, it could not interrupt that turn, which may run on. `turnId`: the journal - * turn a Stop that took interrupted, which the Stop then binds. */ + * the child; absent, it could not interrupt that turn, which may run on. `turnMayOpen`: no turn + * was there to interrupt, but one a send is owed may still open. `turnId`: the journal turn a Stop + * that took interrupted, which the Stop then binds. */ export type AgentSessionCancelOutcome = { cancelled: boolean - refusal?: { detail?: ProviderDiagnostic; turnNotRunning?: true } + refusal?: { detail?: ProviderDiagnostic; turnNotRunning?: true; turnMayOpen?: true } turnId?: string } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 77a6fa3434e0..a0ef74c6b418 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -90,9 +90,11 @@ export function mutateWithChatStop( return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } } } // Awaited until journal appends are synchronous; then issued here, and a `finally` awaits it. - if (withdrawn.length > 0 || (await stopReachesUnrecordedWork(ctx, turnId))) { - await tookEffect() - } + const reach = + withdrawn.length > 0 ? 'unrecorded' : await stopReachesUnrecordedWork(ctx, turnId) + // Its settle binds the latest Stop only when that Stop is this press's own, or the one in + // force this press repeats; a late Stop or a lost event row binds nothing. + const ownsLatestStop = reach === 'unrecorded' ? await tookEffect() : reach === 'repeat' return performCancel( { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, { @@ -110,7 +112,8 @@ export function mutateWithChatStop( endSession: (owed) => { windDown = owed }, - withdrewQueued: withdrawn.length > 0 + withdrewQueued: withdrawn.length > 0, + ...(ownsLatestStop ? { opensSettle: true as const } : {}) } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 60c20cb768d9..2c312ef17e84 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -140,7 +140,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( // Judged before the kill: a stop that ends nothing writes nothing. const recorded = (await stopEndsWork(context, sessionId, session, ending)) ? recordStopEvent(context, sessionId, session, ending) - : Promise.resolve() + : Promise.resolve(null) // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, // so a step that aborts after that point would otherwise leave the retry reading "no child // here" and skipping the settlement and the lease release it still owes. @@ -233,6 +233,9 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( session.owesProviderChildWindDown = { ...owed, failedAt: session.journal.cursor() } } throw error + } finally { + // A person's close binds what its child's end cut; done, proven or not, it binds no more. + void recorded.then((settle) => session.journal.stopMarks.settled(settle)) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts index 757a76a90590..70cfd07bb7a6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts @@ -7,6 +7,8 @@ import type { StructuredAgentSessionStopCause } from './structured-agent-session import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import type { JournalStopSettle } from '../agent-session-journal/queued-message-pause' +import { sentSinceStop } from './structured-agent-session-queued-stop' /** How a stop ends the child, and why (`lastEndedChild`). A person's Stop wrote its event in its * own step (`recorded` names its reason); any other stop names the reason its event records, with @@ -63,23 +65,37 @@ export async function stopEndsWork( journal.submissions(), child.fence ) - // A host stop of work a person's Stop is already ending must not supersede that Stop's reason. + return working && (ending.cause === 'user-close' || !defersToPersonsStop(session)) +} + +/** A host stop of work a person's Stop is already ending must not supersede that Stop's reason: + * the turn it decides, or, with no turn running, the work under the queue pause it still holds, + * with nothing sent since, which a host's event would lift. */ +function defersToPersonsStop(session: StructuredAgentSessionHostSession): boolean { + const { journal } = session + const live = journal.activeTurnId() + if (live !== null) { + return journal.stopMarks.personStopDecides(live) + } + const inForce = journal.queuedMessages.userStopInForce() return ( - working && - (ending.cause === 'user-close' || !journal.stopMarks.personStopDecides(journal.activeTurnId())) + (inForce !== null && !sentSinceStop(journal, inForce)) || + journal.stopMarks.personStopDecides(null) ) } /** Writes this stop's event (`JournalStopEvent`). Issued before the kill and never awaited by it: - * bookkeeping, reported on failure. */ + * bookkeeping, reported on failure. A person's close that named no turn opens its settle once its + * event lands, as a person's Stop does, so a turn the child's end cuts is theirs; the caller + * closes it once that end is done. */ export function recordStopEvent( context: StructuredAgentSessionLifetimeContext, sessionId: string, session: StructuredAgentSessionHostSession, ending: StructuredAgentSessionStopEnding -): Promise { +): Promise { if ('recorded' in ending) { - return Promise.resolve() + return Promise.resolve(null) } const turnId = session.journal.activeTurnId() return session.journal @@ -88,12 +104,14 @@ export function recordStopEvent( structuredAgentSessionConversationFence(context.deps.store, sessionId) ) .then( - () => undefined, - (error: unknown) => + () => (ending.cause === 'user-close' ? session.journal.stopMarks.beginSettle() : null), + (error: unknown) => { context.deps.logger.warn("a host stop's Stop event row skipped", { scope: 'stop-event', sessionId, error }) + return null + } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index 316793751c55..dea812e50641 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -39,48 +39,63 @@ export async function runRecordedStop( ctx: AgentSessionTurnContext, /** `turnId` absent: the turn running when the Stop takes effect, if any. */ event: Omit, - stop: (tookEffect: () => Promise) => Promise> + /** `tookEffect` resolves whether the Stop's event landed. */ + stop: (tookEffect: () => Promise) => Promise> ): Promise> { - const skipped = (error: unknown): void => report(ctx, 'event row', error) + const skipped = (error: unknown): false => { + report(ctx, 'event row', error) + return false + } return stop(() => { try { const turnId = structuredAgentSessionStoppedTurnId(ctx.journal, event.turnId) ?? undefined return ctx.journal .appendStopEvent({ ...event, ...(turnId ? { turnId } : {}) }, ctx.fence) - .then(() => undefined, skipped) + .then(() => true, skipped) } catch (error) { // A throw before the append is queued is reported too: the Stop still interrupts. - skipped(error) - return Promise.resolve() + return Promise.resolve(skipped(error)) } }) } /** - * Whether a Stop reaching a running agent stops anything no Stop event records yet. Not when it - * names a turn already over (a late Stop from a phone), nor when it repeats the Stop still in force - * with nothing sent since, on the same turn or one that opened after a Stop pressed before any - * turn showed: a card queued between the presses then sends normally, as after one Stop. + * What a Stop reaching a running agent stops: work no Stop event records yet (`unrecorded`), or + * only what the Stop still in force already records, which it repeats with nothing sent since, on + * the same turn or one that opened after a Stop pressed before any turn showed (`repeat`): a card + * queued between the presses then sends normally, as after one Stop. `late`: it names a turn + * already over, as a late Stop from a phone can. */ export async function stopReachesUnrecordedWork( ctx: Pick, namedTurnId: string | undefined -): Promise { +): Promise<'unrecorded' | 'repeat' | 'late'> { const live = ctx.journal.activeTurnId() // No turn published yet while the agent works: the named one may still be opening. if ( structuredAgentSessionStopNamesTurnNotLive(namedTurnId, live) && (live !== null || !(await isMainAgentWorkingOnceFlushed(ctx))) ) { - return false + return 'late' } const inForce = ctx.journal.queuedMessages.userStopInForce() if (inForce === null) { - return true + return 'unrecorded' } - // Sent after that Stop and not refused, even if its fate is unknown: this interrupt may send it - // back to waiting, so this Stop must hold it. A send with no sequence is an older host's. - const sentSince = ctx.journal + // This interrupt may send a later send back to waiting, so this Stop must hold it. + return sentSinceStop(ctx.journal, inForce) || + structuredAgentSessionStopNamesTurnNotLive(inForce.event.turnId, live) + ? 'unrecorded' + : 'repeat' +} + +/** Whether anything was sent after the Stop in force and not refused, even if its fate is + * unknown. A send with no sequence is an older host's. */ +export function sentSinceStop( + journal: Pick, + inForce: { sequence: number } +): boolean { + return journal .submissions() .some( (entry) => @@ -88,7 +103,6 @@ export async function stopReachesUnrecordedWork( entry.acceptedSequence !== undefined && entry.acceptedSequence > inForce.sequence ) - return sentSince || structuredAgentSessionStopNamesTurnNotLive(inForce.event.turnId, live) } function report(ctx: AgentSessionTurnContext, step: string, error: unknown): void { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 019d31c63597..169c925f9bc8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -12,10 +12,11 @@ import { import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' -import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' import { createQueuedMessageTestRig, eventually, + QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' @@ -272,6 +273,38 @@ describe('a Stop pressed before its send opened a turn binds only the turn it st }) }) +describe('a press that writes no Stop event of its own', () => { + // A turnless Stop long since settled, then a later turn the person's own send opened. + async function laterTurnAfterAnEarlierStop(): Promise { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + const sent = await rig.workingSend() + await rig.settleAccepted(sent, 'sent') + await turnOpenedBy(sent) + return sent + } + + it('reopens no earlier Stop: a late Stop naming a turn already over binds no turn', async () => { + const sent = await laterTurnAfterAnEarlierStop() + const earlier = journal().stopMarks.latest() + rig.cancelTurn.mockImplementationOnce(async () => { + await turnOpenedBy(sent, 'interrupted') + return { cancelled: false } + }) + + const late = await rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ turnId: 'turn-gone' }, 'agentSession.cancel', hostTestOperationId()), + turnId: 'turn-gone' + }) + + expect(late).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()).toBe(earlier) + await expectNews() + }) +}) + describe("a Stop's settle that ends the turn its interrupt took", () => { it("ends a turn still running once the stream drains, as the Stop's, once", async () => { rig = await createQueuedMessageTestRig() @@ -303,11 +336,48 @@ describe('a host stop with no turn running after a Stop that named none', () => expect(journal().stopMarks.personStopDecides(null)).toBe(false) }) - it("writes the host's event for work still unanswered once that Stop settled", async () => { + it.each([ + ['stopped nothing', { cancelled: false }], + ['took', { cancelled: true }] + ] as const)( + 'writes nothing while the Stop, which %s, still pauses the queue, and the card stays held', + async (_, answer) => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + const card = await queuedDraft('queued behind the send') + rig.cancelTurn.mockResolvedValueOnce(answer) + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + + expect(await evictedAt()).toEqual(['user-stop']) + expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await rig.handoff(card)).toBeUndefined() + } + ) + + // Codex could not reach a turn still able to open, and its process could not be ended. + it('writes nothing after a Stop whose kill failed, and the card stays held', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + const card = await queuedDraft('queued behind the send') + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + expect(await evictedAt()).toEqual(['user-stop']) + expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await rig.handoff(card)).toBeUndefined() + }) + + it("writes the host's event when a send after the Stop is unanswered beside the stopped one", async () => { rig = await createQueuedMessageTestRig() await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true }) - expect(journal().stopMarks.personStopDecides(null)).toBe(false) + const mail = rig.send('mail for the lead', undefined, { internal: true }) + await mail.result + await eventually(async () => + expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() + ) expect(await evictedAt()).toEqual(['user-stop', 'evict']) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 55d840315ed9..f427ee420177 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -138,6 +138,26 @@ describe('a restart between a Stop and its turn end', () => { expect(errorRows).toEqual([]) }) + // A Stop naming its turn, as the phone sends one: its event names the turn, which is what a + // relaunch reads, so it still counts though Orca died with its interrupt out. + it.each([ + ['Claude', CLAUDE_TURN], + ['Codex', CODEX_TURN] + ])('reads Interrupted after N for a %s Stop that named its turn', async (_label, identity) => { + await runningTurn(identity) + rig.cancelTurn.mockImplementationOnce(() => new Promise(() => undefined)) + void rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ turnId: TURN }, 'agentSession.cancel', hostTestOperationId()), + turnId: TURN + }) + await expect.poll(() => journal().stopMarks.latest()?.event.turnId).toBe(TURN) + + await restartAndSettle() + + expect(settled().turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(settled().label).toMatch(/^Interrupted after /) + }) + it('reads Interrupted after N, marked interrupted: a close of the chat that died midway', async () => { await runningTurn(CODEX_TURN) // The host dies inside the close: the provider's close never answers, and nothing settles. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts index 3950d95916b6..2247471072b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts @@ -74,7 +74,7 @@ describe('a Codex Stop whose answered turn did not open in time', () => { async function stopOfAnsweredSend(): Promise { rig = await createQueuedMessageTestRig() const sent = await rig.workingSend() - rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) return sent } @@ -102,6 +102,22 @@ describe('a Codex Stop whose answered turn did not open in time', () => { }) }) +describe('a Codex Stop that could not reach a turn still able to open, whose kill failed', () => { + it('says the Stop is unconfirmed, never that no turn was running', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + const rows = journal() + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.failure?.kind] : [])) + expect(rows).toEqual(['cancelUnconfirmed']) + }) +}) + describe('a Claude Stop pressed before its send echoed', () => { it('reads the turn that opens before its child end is proven as interrupted by the Stop', async () => { rig = await createQueuedMessageTestRig({ stopEndsSession: true }) @@ -119,3 +135,22 @@ describe('a Claude Stop pressed before its send echoed', () => { expect(nothingRuns()).toBe(true) }) }) + +describe("a person's close pressed before its send's turn showed", () => { + it('reads the turn its child end cut as interrupted by the person', async () => { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + rig.closeSession.mockImplementationOnce(async () => { + await turnOpens(sent) + return true + }) + + await rig.host.close(HOST_TEST_SESSION, 'user-close') + + const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION) + const turn = items + .map((item) => readAgentJournalTurn(item.body)) + .find((entry) => entry?.turnId === 'turn-opened') + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index d96a6c085bbd..256edb420f21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -59,25 +59,27 @@ async function stillRunsStoppedTurn( return stoppedTurnId === null || ctx.journal.activeTurnId() === stoppedTurnId } -/** The row for a Stop the provider declined, in its words when it gave any. */ +/** The row for a Stop the provider declined, in its words when it gave any. One that could not + * reach a turn still able to open says the Stop is unconfirmed, never that nothing ran. */ function stopRefusedNote( ctx: Pick, refusal: AgentSessionCancelOutcome['refusal'] ): AgentJournalStatusItem { const detail = refusal?.detail + const fact = refusal?.turnMayOpen + ? agentSessionFailureFact('cancelUnconfirmed') + : agentSessionFailureFact('stopRefused', detail ? { detail } : {}) return { kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('stopRefused', detail ? { detail } : {}), { - ...ctx.failureTextContext, - surface: 'row' - }) + ...agentSessionFailureWords(fact, { ...ctx.failureTextContext, surface: 'row' }) } } /** What a Stop that ends the provider's session leaves its next serialized step: whether the * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. - * `settled` closes the Stop's settle once the child's end is proven: until then, what ends is its. */ + * `settled` closes the Stop's settle once the wind-down finishes, whether or not the child's exit + * was proven: until then, what ends is the Stop's. */ export type StructuredAgentSessionStopWindDown = { waitsForProvider: boolean stoppedAt: number @@ -150,7 +152,7 @@ export async function performCancel( } } // A person's Stop that named no turn binds what ends while it settles (`beginJournalStopSettle`). - const settle = input.scope ? null : ctx.journal.stopMarks.beginSettle() + const settle = input.opensSettle ? ctx.journal.stopMarks.beginSettle() : null const binding: StopSettleBinding = {} const close = (): void => ctx.journal.stopMarks.settled(settle, binding.turnId) try { @@ -184,6 +186,8 @@ type PerformCancelInput = { withdrewQueued?: boolean /** The session's child records: a background Stop reaches the tasks they offer a stop. */ childWork?: () => readonly AgentChildWorkView[] | undefined + /** The latest Stop event is this press's own, or the in-force one it repeats: its settle binds. */ + opensSettle?: true } async function cancelAndNote( From 4ed011b2a7230cb0ba46efe84094e272a8e336b3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:00:33 -0700 Subject: [PATCH 093/182] fix(native-chat): keep a failed Stop's turn display-only, and settle edges off the commit path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Stop that failed marks the turn it could not stop for "Stopping…" only (JournalStopSettle.failedOn): no turn-end rule reads it, so that turn's own end with no verdict reads as a failure, not the person's. A settle edge writes no row, so it no longer goes through the journal's commit listener, which also delivers history, counts as activity for the idle sweep and schedules the queue drain. A narrow settle-edge hook republishes the status row and wakes the steer hold's handover, and nothing else. --- .../journal-stop-marks.ts | 24 ++++++++------- .../journal-store-collaborators.ts | 2 +- .../queued-message-pause.ts | 6 ++-- ...nt-session-background-task-channel.test.ts | 2 +- .../structured-agent-session-conversations.ts | 18 ++++++++++++ .../structured-agent-session-host-delivery.ts | 9 +++++- .../structured-agent-session-host.ts | 2 ++ ...agent-session-steer-while-stopping.test.ts | 17 +++++++++++ ...structured-agent-session-stop-wind-down.ts | 4 +-- ...red-agent-session-stopping-journal.test.ts | 13 ++++++--- .../structured-agent-session-stopping.test.ts | 29 ++++++++++++++++++- .../structured-agent-session-stopping.ts | 4 +-- .../structured-agent-session-turns-cancel.ts | 10 +++---- 13 files changed, 111 insertions(+), 29 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts index 6b695186d502..b4b0bdc22fcc 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -14,14 +14,15 @@ import type { JournalStopSettle } from './queued-message-pause' export class JournalStopMarks { // Bumped on each settle edge: readers cached per commit see an edge that wrote no row. private settleRevision = 0 + private onSettleEdge: (() => void) | null = null - constructor( - private readonly deps: { - state: () => JournalReducerState - /** A settle opened or closed: what readers derive from it changed, as after a commit. */ - changed?: () => void - } - ) {} + constructor(private readonly deps: { state: () => JournalReducerState }) {} + + /** Told of each settle edge, which writes no row, so it is no commit: it moves only what reads + * the settle. One listener: a later call replaces it. It must not throw. */ + observeSettleEdges(listener: () => void): void { + this.onSettleEdge = listener + } latest(): JournalLatestStop | null { return this.deps.state().queuePauseMarks.latestStop @@ -51,20 +52,23 @@ export class JournalStopMarks { return settle } - /** Closes a settle `beginSettle` opened, binding `turnId` when the Stop stopped one. */ - settled(settle: JournalStopSettle | null, turnId?: string): void { + /** Closes a settle `beginSettle` opened, binding `turnId` when the Stop stopped one, or marking + * `failedOn` (display only) when it failed to stop that turn. */ + settled(settle: JournalStopSettle | null, turnId?: string, failedOn?: string): void { if (!settle) { return } settle.settling = false if (turnId !== undefined) { settle.turnId = turnId + } else if (failedOn !== undefined) { + settle.failedOn = failedOn } this.edge() } private edge(): void { this.settleRevision += 1 - this.deps.changed?.() + this.onSettleEdge?.() } } diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index a325c5f29830..b938efa3c5c9 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -94,7 +94,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal return { epochController, queuedMessages, - stopMarks: new JournalStopMarks({ state: host.state, changed: host.notifyCommitted }), + stopMarks: new JournalStopMarks({ state: host.state }), // Behind the stored fact: settles drafts whose consumed submission the loaded journal shows // refused (a downgrade wrote no hook), then prunes. Bookkeeping, never failing the open. restore: () => diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 63dc6823df28..091055076f1b 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -16,8 +16,10 @@ import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' /** What a person's Stop that named no turn binds, held in memory and never read from a row, so a - * reopen binds nothing: while it settles, every turn that ends; once settled, the turn it stopped. */ -export type JournalStopSettle = { settling: boolean; turnId?: string } + * reopen binds nothing: while it settles, every turn that ends; once settled, the turn it stopped. + * `failedOn`: the turn a Stop that failed could not stop, which reads "Stopping…" until it ends. + * Display only: no turn-end rule reads it, so that turn's own end stays its own. */ +export type JournalStopSettle = { settling: boolean; turnId?: string; failedOn?: string } /** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ export type JournalQueuePauseMarks = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts index 92c52f88a68e..dfbc6df526b3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts @@ -52,7 +52,7 @@ function channelOver( ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the map reads only the journal's commit observer and the session's provider. const session = { - journal: { observeCommits: () => {} }, + journal: { observeCommits: () => {}, stopMarks: { observeSettleEdges: () => {} } }, params: { provider: 'claude' } } as unknown as StructuredAgentSessionHostSession return { sessions, sent, channel, session } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts index b634b74c2120..f384e2bff586 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts @@ -23,6 +23,8 @@ export class StructuredAgentSessionConversations extends Map< constructor( private readonly delivery: { deliver: (sessionId: string, journal: AgentSessionJournal) => void + /** A person's Stop settle opened or closed: no row, so neither a publish nor activity. */ + deliverSettleEdge?: (sessionId: string, journal: AgentSessionJournal) => void logger: StructuredAgentSessionLogger /** A conversation became held: state that waited on it (queued drafts) re-derives. */ onOpened?: (sessionId: string) => void @@ -56,6 +58,22 @@ export class StructuredAgentSessionConversations extends Map< } }) }) + journal.stopMarks.observeSettleEdges(() => { + queueMicrotask(() => { + if (this.get(sessionId)?.journal !== journal) { + return + } + try { + this.delivery.deliverSettleEdge?.(sessionId, journal) + } catch (error) { + this.delivery.logger.warn("delivering a Stop's settle edge failed", { + scope: 'stop-settle-delivery', + sessionId, + error + }) + } + }) + }) this.activity.set(sessionId, this.delivery.now()) const adopted = super.set(sessionId, session) this.delivery.onOpened?.(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 2c292c11a3c2..d9f57c637abb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -35,6 +35,9 @@ export type StructuredAgentSessionConversationDelivery = { * queue. Enqueued through the session's serialize, never read here, so a commit that lands while * a step is deciding to stop wakes the loop after that step rather than being lost to it. */ afterCommit: (sessionId: string, journal: AgentSessionJournal) => void + /** A person's Stop settle opened or closed. It writes no row, so only what reads the settle + * moves: the session's status row and the steer hold's handover. Never activity. */ + afterSettleEdge: (sessionId: string, journal: AgentSessionJournal) => void /** Stops the loop and the resettle on a proof of death; quit's first step. */ dispose: () => void /** Indexes a conversation some other open produced, as `open` would have. */ @@ -57,7 +60,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void clientDelivery: Pick< StructuredAgentSessionClientDelivery, - 'publishRestored' | 'readChildWork' | 'readStopping' + 'publishRestored' | 'readChildWork' | 'readStopping' | 'publishStatus' > flushStreamedEvents: (sessionId: string) => Promise }): StructuredAgentSessionConversationDelivery { @@ -149,6 +152,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { return { loop, afterCommit, + afterSettleEdge: (sessionId, journal) => { + input.clientDelivery.publishStatus(sessionId) + afterCommit(sessionId, journal) + }, adoptOpened, dispose: () => { loop.dispose() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 44a576e2f781..04e253f71914 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -66,6 +66,8 @@ export class StructuredAgentSessionHost { this.subscribers.publish(sessionId, journal) this.conversationDelivery.afterCommit(sessionId, journal) }, + deliverSettleEdge: (sessionId, journal) => + this.conversationDelivery.afterSettleEdge(sessionId, journal), logger: sessionLogger.deferredStructuredAgentSessionLogger(() => this.deps.logger), onOpened: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts index 9ee518488cf3..1bc687e31e34 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-steer-while-stopping.test.ts @@ -150,6 +150,23 @@ describe("a message sent while a person's Stop ends the turn", () => { expect(seen.slice(seen.lastIndexOf(true) + 1)).not.toContain(true) }) + // A settle edge writes no row, so nothing else wakes the handover once it closes. + it('hands a held send over when the Stop settles with no row after it', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + await journal().appendStopEvent({ reason: 'user-stop' }, 1) + const settle = journal().stopMarks.beginSettle() + const later = rig.send('sent while the Stop settles') + expect(await later.result).toMatchObject({ ok: true }) + await laneDrained() + const dispatched = rig.dispatch.mock.calls.length + expect((await rig.submission(later.id))?.handedOverAt).toBeUndefined() + + journal().stopMarks.settled(settle) + + await eventually(() => expect(rig.dispatch.mock.calls.length).toBe(dispatched + 1)) + }) + // The delivery step that judged the send waits on the child's start outside the session's lane. it('holds a send that a Stop overtook while its delivery step waited on the agent', async () => { rig = await createQueuedMessageTestRig() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts index 1378f7bbf7fe..2770dcbaebd9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts @@ -21,8 +21,8 @@ export type StructuredAgentSessionStopWindDown = { /** The note the Stop wrote, which a failed wind-down revises. */ stopNote: AgentJournalItemIdentity /** Closes the Stop's settle once the wind-down finishes, whether or not the child's exit was - * proven, binding the turn running on after an end that failed: until then, what ends is the - * Stop's. */ + * proven, marking the turn running on after an end that failed (`JournalStopSettle.failedOn`): + * until then, what ends is the Stop's. */ settled?: (failedOn?: string) => void } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts index 3d0d6a945c9a..74eca0fadb3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts @@ -127,19 +127,24 @@ describe("a person's Stop's Stopping", () => { expect(stopping()).toBe(true) }) - it('tells readers of each settle edge, which writes no row', async () => { + it('tells its settle readers of each edge, which writes no row and so is no commit', async () => { await journal.appendStopEvent({ reason: 'user-stop' }, FENCE) const before = journal.stopMarks.revision() - let told = 0 + let commits = 0 + let edges = 0 journal.observeCommits(() => { - told += 1 + commits += 1 + }) + journal.stopMarks.observeSettleEdges(() => { + edges += 1 }) const cursor = journal.cursor() journal.stopMarks.settled(journal.stopMarks.beginSettle()) expect(journal.stopMarks.revision()).toBe(before + 2) - expect(told).toBe(2) + expect(edges).toBe(2) + expect(commits).toBe(0) expect(journal.cursor()).toEqual(cursor) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts index 6b5ac34d90b6..43aab4ee386d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -3,7 +3,7 @@ // A Stop that settles having stopped nothing ends it. Driven through the real host and its status // feed, with turn rows named as Codex writes them. -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -240,6 +240,26 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => }) }) +describe("a Stop's settle edge", () => { + // It writes no row: it republishes the status and wakes the handover, and is no activity. + it('republishes Stopping without counting as activity', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + const status = watchStatus() + await journal().appendStopEvent({ reason: 'user-stop' }, fence()) + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + const sessions = rig.host.collaboratorsForTests().sessions + const touch = vi.spyOn(sessions, 'touch') + + const settle = journal().stopMarks.beginSettle() + await eventually(() => expect(status()).toMatchObject({ stopping: true })) + journal().stopMarks.settled(settle) + await eventually(() => expect(status()).not.toHaveProperty('stopping')) + + expect(touch).not.toHaveBeenCalled() + }) +}) + describe('a Stop pressed before its send opened a turn', () => { /** A Stop pressed before the send's turn showed, which opens while the Stop waits for it. */ async function stopAsTheTurnOpens(answer: AgentSessionCancelOutcome, killFails?: true) { @@ -275,6 +295,13 @@ describe('a Stop pressed before its send opened a turn', () => { await turn('turn-1', sent, 'interrupted') await eventually(() => expect(status()?.status).toBe('idle')) expect(status()).not.toHaveProperty('stopping') + // The Stop never stopped it: its own end, with no verdict, reads as a failure, not theirs. + const ended = journal() + .snapshot() + .items.map((item) => item.body) + .find((body) => body.kind === 'turn' && body.turnId === 'turn-1') + expect(ended).toMatchObject({ state: 'interrupted' }) + expect(ended).not.toHaveProperty('outcome') }) it('reads Stopping while the Stop settles, and Working once it settles having stopped nothing', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts index 20b86780e8f0..b6c6044cf62c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts @@ -10,7 +10,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import { stopIsAPersons } from '../agent-session-journal/journal-stop-turn-end' /** Whether the latest person's Stop is still settling, or is bound to the turn running now: the - * one it named, or the one its settle bound. */ + * one it named, the one its settle bound, or the one it failed to stop. */ export function structuredAgentSessionStopping( journal: Pick, items: readonly AgentJournalRenderItem[] @@ -22,6 +22,6 @@ export function structuredAgentSessionStopping( if (stop.settle?.settling === true) { return true } - const bound = stop.event.turnId ?? stop.settle?.turnId + const bound = stop.event.turnId ?? stop.settle?.turnId ?? stop.settle?.failedOn return bound !== undefined && bound === activeStructuredAgentSessionTurnId(items) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 87efb9f4e1ac..f4ffe240df92 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -105,7 +105,7 @@ async function endStoppedTurnAtSettle(ctx: AgentSessionTurnContext, turnId: stri } /** What the Stop's settle binds: the turn it stopped, and whether its wind-down closes it. */ -type StopSettleBinding = { turnId?: string; closedByWindDown?: true } +type StopSettleBinding = { turnId?: string; failedOn?: string; closedByWindDown?: true } export async function performCancel( ctx: AgentSessionTurnContext, @@ -120,9 +120,9 @@ export async function performCancel( // A person's Stop that named no turn binds what ends while it settles (`beginJournalStopSettle`). const settle = input.opensSettle ? ctx.journal.stopMarks.beginSettle() : null const binding: StopSettleBinding = {} - // A wind-down that failed with work running on binds that work's turn, as a failed Stop does. + // A wind-down that failed with work running on marks that work's turn, as a failed Stop does. const close = (failedOn?: string): void => - ctx.journal.stopMarks.settled(settle, binding.turnId ?? failedOn) + ctx.journal.stopMarks.settled(settle, binding.turnId, binding.failedOn ?? failedOn) try { return await cancelAndNote(ctx, input, binding, close) } finally { @@ -309,8 +309,8 @@ async function cancelAndNote( if (cancelled) { binding.turnId = stoppedTurn ?? stoppedTurnId ?? undefined } else if (interruptFailed) { - // A Stop that failed still holds the turn it could not stop, until that turn ends. - binding.turnId = ctx.journal.activeTurnId() ?? undefined + // A Stop that failed reads "Stopping…" through the turn it could not stop; its end stays its own. + binding.failedOn = ctx.journal.activeTurnId() ?? undefined } if (taken === true && stoppedTurn !== undefined && !binding.closedByWindDown && !input.scope) { await endStoppedTurnAtSettle(ctx, stoppedTurn) From 9f6140439efa1922a071dad104b73860acddeea0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:50:05 -0700 Subject: [PATCH 094/182] fix(native-chat): a Codex Stop agrees on both presses when a turn is still owed, and pin the close's settle A Codex Stop that waited for a turn Codex answered a send into now answers "may still open" whenever that turn neither opened nor ended and its send is still owed, however the wait ended (it ran out, or the thread went idle). Before, a first press after an idle thread said no turn was running and kept Codex, while an identical second press ended it. Adds a test that a person's close the conversation outlives (as /clear does) closes its settle, so a later turn that ends on its own reads as a failure. --- .../codex-structured-turn-open-wait.test.ts | 7 ++- .../codex/codex-structured-turn-open-wait.ts | 46 +++++++++++-------- ...-agent-session-stop-settle-binding.test.ts | 41 +++++++++++++++++ 3 files changed, 72 insertions(+), 22 deletions(-) diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index 7fa3af292eee..9a2e20eb1e80 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -15,6 +15,7 @@ type Rig = Awaited> const ADMITTED = { state: 'admitted' } const REFUSED = { cancelled: false } +const MAY_OPEN = { cancelled: false, refusal: { turnMayOpen: true } } const stop = (rig: Rig) => rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) @@ -94,15 +95,17 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(rig.interrupts()).toEqual([]) }) + // The turn neither opened nor ended, and its send is still owed: no press calls that nothing. it.each(['idle', 'systemError'])( - 'stops nothing when Codex reports the thread %s', + 'answers refused, on the first press and on a second alike, when Codex reports the thread %s', async (type) => { const rig = await codexTurnLifecycleRig() const { stopping } = await waitingStop(rig) rig.notify('thread/status/changed', { threadId: CODEX_TEST_THREAD_ID, status: { type } }) - expect(await settledWithin(stopping)).toEqual(REFUSED) + expect(await settledWithin(stopping)).toEqual(MAY_OPEN) + expect(await settledWithin(stop(rig), 0)).toEqual(MAY_OPEN) expect(rig.interrupts()).toEqual([]) } ) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index b5a3e183f4c6..3f43e9c787d4 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -14,8 +14,8 @@ import { export const CODEX_TURN_OPEN_WAIT_MS = 5_000 export type CodexTurnOpenWaits = { - /** Resolves once `turnId` opens or can no longer (true), or after `withinMs` (false). */ - wait: (turnId: string, withinMs: number) => Promise + /** Resolves once `turnId` opens or can no longer, and after `withinMs` at the latest. */ + wait: (turnId: string, withinMs: number) => Promise /** Ends the waits a notification on the session's own thread answers. */ observe: (threadId: string, method: string, params: unknown) => void /** Ends every wait: the child that would open their turns is gone. */ @@ -23,23 +23,23 @@ export type CodexTurnOpenWaits = { } export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { - const waits = new Map<(answered: boolean) => void, string>() + const waits = new Map<() => void, string>() const release = (turnId?: string): void => { for (const [endWait, waitedTurnId] of waits) { if (turnId === undefined || waitedTurnId === turnId) { - endWait(true) + endWait() } } } return { wait: (turnId, withinMs) => - new Promise((resolve) => { - const endWait = (answered: boolean): void => { + new Promise((resolve) => { + const endWait = (): void => { clearTimeout(bound) waits.delete(endWait) - resolve(answered) + resolve() } - const bound = setTimeout(() => endWait(false), withinMs) + const bound = setTimeout(endWait, withinMs) // A Stop's wait must never be what keeps the process alive at quit. bound.unref?.() waits.set(endWait, turnId) @@ -62,14 +62,15 @@ export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { } /** What a Stop or a send finds to act on: a turn running, or one Codex answered a send into that - * has not opened in time and may still open; null when neither. */ + * has not opened and has not ended, so may still open while its send is owed; null when neither. */ export type CodexStopTarget = { turnId: string } | { opening: string } | null /** * The turn a Stop or a send names: the latest Codex reported started and not ended, or else the - * one Codex answered a send into, once it opens. `opening` when the wait runs out first, or an - * earlier wait already gave up on it with its send still owed. Null when none is running and that - * one ends, the thread stops running or the child exits first; each such turn is waited for once. + * one Codex answered a send into, once it opens. `opening` when it has not opened by the end of + * the wait, however the wait ended (it ran out, or the thread stopped running), while it has not + * ended and its send is still owed: nothing to stop holds only when nothing is owed. Null when + * none is running and none is owed. Each such turn is waited for once. */ export async function codexStopTarget(session: { threadId: string @@ -89,22 +90,27 @@ export async function codexStopTarget(session: { session.activeTurnIds ?? new Set() ) if (!answered) { - // An earlier wait gave up on it, yet its send is still owed: it may still open. - const left = session.dispatchEchoes.answeredTurnLeftUnopened( - session.threadId, - session.activeTurnIds ?? new Set() - ) - return left ? { opening: left } : null + // An earlier wait left it unopened, yet its send is still owed: it may still open. + return leftUnopenedTarget(session) } // Codex refuses an interrupt, and before 0.148 a steer, until it opens the turn. - const settled = await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) + await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) if (session.activeTurnIds?.has(answered)) { return { turnId: answered } } // Before 0.148 a turn that fails before it starts reports no end; one that opens later is still // found running. session.dispatchEchoes.leftUnopened(session.threadId, answered) - return settled ? null : { opening: answered } + return leftUnopenedTarget(session) +} + +/** The answered turn a wait left unopened, while it has not ended and its send is still owed. */ +function leftUnopenedTarget(session: Parameters[0]): CodexStopTarget { + const left = session.dispatchEchoes.answeredTurnLeftUnopened( + session.threadId, + session.activeTurnIds ?? new Set() + ) + return left ? { opening: left } : null } /** `codexStopTarget`'s running or opened turn, for a send. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts index 2247471072b0..14e62854bbcc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts @@ -20,6 +20,13 @@ let rig: QueuedMessageTestRig afterEach(() => rig.dispose()) +const LATER: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-later', + ordinal: 1000 +} + const OPENED: AgentJournalItemIdentity = { provider: 'codex', threadId: 'thread-1', @@ -153,4 +160,38 @@ describe("a person's close pressed before its send's turn showed", () => { .find((entry) => entry?.turnId === 'turn-opened') expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) }) + + // A /clear stops the agent as the person's close and keeps the conversation, so the close's + // settle must have closed by the time a later turn ends. + it('binds no turn that ends on its own after a close the conversation outlived', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + await rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'user-close' }) + expect(journal().stopMarks.latest()?.event).toMatchObject({ reason: 'user-close' }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + + await journal().appendItem( + LATER, + { kind: 'turn', turnId: 'turn-later', state: 'running', startedAt: Date.now() }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal().appendItem( + LATER, + { + kind: 'turn', + turnId: 'turn-later', + state: 'interrupted', + startedAt: Date.now(), + completedAt: Date.now() + 5 + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + const later = journal() + .snapshot() + .items.map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-later') + expect(later).toMatchObject({ state: 'interrupted' }) + expect(later).not.toHaveProperty('outcome') + }) }) From c2f54bedc69c8f5ecf63e746b2fa210d3d148c50 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:10:22 -0700 Subject: [PATCH 095/182] fix(native-chat): a Stop that failed before its turn showed still reads Stopping through that turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Stop that failed with no turn open marked nothing, so the chat dropped to Working and the turn that then opened never read "Stopping…". The display-only mark now also covers that case: the first turn that opens after the Stop failed, provided no message was handed to the agent in between. No turn-end rule reads the mark, so that turn's own end with no verdict still reads as a failure. --- .../journal-stop-marks.ts | 4 +- .../queued-message-pause.ts | 13 +++- ...agent-session-status-journal-projection.ts | 4 +- ...structured-agent-session-stop-wind-down.ts | 8 ++- .../structured-agent-session-stopping.test.ts | 63 ++++++++++++++++ .../structured-agent-session-stopping.ts | 71 +++++++++++++++++-- .../structured-agent-session-turns-cancel.ts | 14 ++-- 7 files changed, 160 insertions(+), 17 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts index b4b0bdc22fcc..18c4becacbe1 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -9,7 +9,7 @@ import { personStopDecidesTurn, type JournalLatestStop } from './journal-stop-turn-end' -import type { JournalStopSettle } from './queued-message-pause' +import type { JournalStopFailedOn, JournalStopSettle } from './queued-message-pause' export class JournalStopMarks { // Bumped on each settle edge: readers cached per commit see an edge that wrote no row. @@ -54,7 +54,7 @@ export class JournalStopMarks { /** Closes a settle `beginSettle` opened, binding `turnId` when the Stop stopped one, or marking * `failedOn` (display only) when it failed to stop that turn. */ - settled(settle: JournalStopSettle | null, turnId?: string, failedOn?: string): void { + settled(settle: JournalStopSettle | null, turnId?: string, failedOn?: JournalStopFailedOn): void { if (!settle) { return } diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 091055076f1b..71a17d871ef9 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -17,9 +17,16 @@ export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' /** What a person's Stop that named no turn binds, held in memory and never read from a row, so a * reopen binds nothing: while it settles, every turn that ends; once settled, the turn it stopped. - * `failedOn`: the turn a Stop that failed could not stop, which reads "Stopping…" until it ends. - * Display only: no turn-end rule reads it, so that turn's own end stays its own. */ -export type JournalStopSettle = { settling: boolean; turnId?: string; failedOn?: string } + * `failedOn`: the turn a Stop that failed could not stop, which reads "Stopping…" until it ends: + * the one running when it failed, or with none, the first that opens after the journal position + * it failed at. Display only: no turn-end rule reads it, so that turn's own end stays its own. */ +export type JournalStopSettle = { + settling: boolean + turnId?: string + failedOn?: JournalStopFailedOn +} + +export type JournalStopFailedOn = { turnId: string } | { openedAfter: number } /** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ export type JournalQueuePauseMarks = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts index 64f0958c7a45..17dbb5849398 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts @@ -67,7 +67,9 @@ export class StructuredAgentSessionJournalProjections { acceptedSendKey: snapshot ? newestAcceptedSendKey(cursor.epoch, snapshot.submissions ?? []) : null, - stopping: snapshot ? structuredAgentSessionStopping(journal, snapshot.items) : false + stopping: snapshot + ? structuredAgentSessionStopping(journal, snapshot.items, snapshot.submissions ?? []) + : false } this.byJournal.set(journal, projection) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts index 2770dcbaebd9..05932a73697e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts @@ -11,6 +11,8 @@ import { } from '../../../shared/agent-session-journal-types' import { isMainAgentWorkingOnceFlushed } from './structured-agent-session-turns-cancel' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' +import { structuredAgentSessionFailedStopMark } from './structured-agent-session-stopping' /** What a Stop that ends the provider's session leaves its next serialized step: whether the * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went @@ -23,7 +25,7 @@ export type StructuredAgentSessionStopWindDown = { /** Closes the Stop's settle once the wind-down finishes, whether or not the child's exit was * proven, marking the turn running on after an end that failed (`JournalStopSettle.failedOn`): * until then, what ends is the Stop's. */ - settled?: (failedOn?: string) => void + settled?: (failedOn?: JournalStopFailedOn) => void } /** @@ -41,7 +43,7 @@ export async function endStoppedStructuredAgentSession( stopChild: () => Promise, onError: (error: unknown) => void ): Promise { - let failedOn: string | undefined + let failedOn: JournalStopFailedOn | undefined try { if (windDown.waitsForProvider) { await ctx.adapter.awaitStoppedRequestEnd?.(ctx.sessionId, windDown.stoppedAt) @@ -49,7 +51,7 @@ export async function endStoppedStructuredAgentSession( await stopChild() } catch (error) { onError(error) - failedOn = ctx.journal.activeTurnId() ?? undefined + failedOn = structuredAgentSessionFailedStopMark(ctx.journal) await reviseStopNoteUnconfirmed(ctx, windDown.stopNote).catch(onError) } finally { windDown.settled?.(failedOn) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts index 43aab4ee386d..e48ea4b03bff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -240,6 +240,69 @@ describe("a person's Stop reads Stopping until the work it stopped ends", () => }) }) +describe('a Stop that failed before the turn it meant to stop opened', () => { + /** Its end, with no verdict of its own, read off the journal. */ + function turnEnd(turnId: string) { + return journal() + .snapshot() + .items.map((item) => item.body) + .find((body) => body.kind === 'turn' && body.turnId === turnId) + } + + async function failedBeforeTheTurn(options: { stopEndsSession?: true; restartable?: true } = {}) { + rig = await createQueuedMessageTestRig(options) + const sent = await rig.workingSend() + const status = watchStatus() + if (!options.stopEndsSession) { + // Codex could not reach a turn still able to open. + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) + } + // The process could not be ended either. + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true }) + // A Stop that ends its session ends the child on the session's next step. + await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) + expect(journal().activeTurnId()).toBeNull() + return { sent, status } + } + + it.each([ + ['Codex, refused, then its kill failed', {}], + ['Claude-like, whose kill failed before the echo', { stopEndsSession: true as const }] + ])( + 'reads Stopping through the turn that then opens, whose own end stays its own: %s', + async (_, options) => { + const { sent, status } = await failedBeforeTheTurn(options) + await eventually(() => expect(status()).toMatchObject({ status: 'working', stopping: true })) + + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + await eventually(() => expect(journal().activeTurnId()).toBe('turn-1')) + expect(status()).toMatchObject({ status: 'working', stopping: true }) + await turn('turn-1', sent, 'interrupted') + await eventually(() => expect(status()?.status).toBe('idle')) + + expect(status()).not.toHaveProperty('stopping') + expect(turnEnd('turn-1')).toMatchObject({ state: 'interrupted' }) + expect(turnEnd('turn-1')).not.toHaveProperty('outcome') + } + ) + + it("never reads Stopping on a send's turn handed over after it", async () => { + // A later send needs a child again once the failed end is retried. + const { sent, status } = await failedBeforeTheTurn({ restartable: true }) + await rig.settleRejected(sent, 'no turn for this one') + await eventually(() => expect(status()?.status).not.toBe('working')) + + const later = await rig.workingSend() + await rig.settleAccepted(later, 'later') + await turn('turn-2', later, 'running') + + await eventually(() => expect(status()).toMatchObject({ status: 'working' })) + expect(status()).not.toHaveProperty('stopping') + }) +}) + describe("a Stop's settle edge", () => { // It writes no row: it republishes the status and wakes the handover, and is no activity. it('republishes Stopping without counting as activity', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts index b6c6044cf62c..284a474b9c65 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.ts @@ -4,16 +4,22 @@ // and it clears when that turn ends. A Stop that settled having stopped nothing binds no turn, so // a later one never reads Stopping. Clients only present it. -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { stopIsAPersons } from '../agent-session-journal/journal-stop-turn-end' +import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' /** Whether the latest person's Stop is still settling, or is bound to the turn running now: the * one it named, the one its settle bound, or the one it failed to stop. */ export function structuredAgentSessionStopping( journal: Pick, - items: readonly AgentJournalRenderItem[] + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] = [] ): boolean { const stop = journal.stopMarks.latest() if (stop === null || !stopIsAPersons(stop.event.reason)) { @@ -22,6 +28,63 @@ export function structuredAgentSessionStopping( if (stop.settle?.settling === true) { return true } - const bound = stop.event.turnId ?? stop.settle?.turnId ?? stop.settle?.failedOn - return bound !== undefined && bound === activeStructuredAgentSessionTurnId(items) + const failed = stop.settle?.failedOn + const bound = + stop.event.turnId ?? + stop.settle?.turnId ?? + (failed && 'turnId' in failed ? failed.turnId : undefined) + const live = activeStructuredAgentSessionTurnId(items) + if (bound !== undefined) { + return bound === live + } + if (failed && 'openedAfter' in failed) { + // Only what was in flight when it failed: a send handed over since opens its own turn. + const sentSince = firstSendHandedOverAfter(submissions, failed.openedAfter) + const first = firstTurnOpenedAfter(items, failed.openedAfter, sentSince) + return first === null ? sentSince === Infinity : first === live + } + return false +} + +/** Where the first send handed over after `sequence` was, or Infinity when none was. */ +function firstSendHandedOverAfter( + submissions: readonly AgentJournalSubmission[], + sequence: number +): number { + let first = Infinity + for (const submission of submissions) { + const at = submission.acceptedSequence + if (submission.dispatchState !== 'rejected' && at !== undefined && at > sequence) { + first = Math.min(first, at) + } + } + return first +} + +/** Where a Stop that failed leaves "Stopping…" (display only): the turn running now, or, with + * none, the first that opens after this position. */ +export function structuredAgentSessionFailedStopMark( + journal: Pick +): JournalStopFailedOn { + const live = journal.activeTurnId() + return live !== null ? { turnId: live } : { openedAfter: journal.cursor().sequence } +} + +/** The first turn whose row was created after `sequence` and before `before`, read from the tail + * back to `sequence`. */ +function firstTurnOpenedAfter( + items: readonly AgentJournalRenderItem[], + sequence: number, + before: number +): string | null { + let first: string | null = null + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] + if (!item || item.sequence <= sequence) { + break + } + const turnId = item.sequence < before ? readAgentJournalTurn(item.body)?.turnId : undefined + first = turnId ?? first + } + return first } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index f4ffe240df92..0e89acdfd0e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -25,6 +25,8 @@ import { import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { runningTurnLifecycleRevisions } from './structured-agent-session-stale-turn-verdict' import type { StructuredAgentSessionStopWindDown } from './structured-agent-session-stop-wind-down' +import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' +import { structuredAgentSessionFailedStopMark } from './structured-agent-session-stopping' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' /** Claude's echo accepts a send one sink write before its turn row lands, so read after the drain. @@ -105,7 +107,11 @@ async function endStoppedTurnAtSettle(ctx: AgentSessionTurnContext, turnId: stri } /** What the Stop's settle binds: the turn it stopped, and whether its wind-down closes it. */ -type StopSettleBinding = { turnId?: string; failedOn?: string; closedByWindDown?: true } +type StopSettleBinding = { + turnId?: string + failedOn?: JournalStopFailedOn + closedByWindDown?: true +} export async function performCancel( ctx: AgentSessionTurnContext, @@ -121,7 +127,7 @@ export async function performCancel( const settle = input.opensSettle ? ctx.journal.stopMarks.beginSettle() : null const binding: StopSettleBinding = {} // A wind-down that failed with work running on marks that work's turn, as a failed Stop does. - const close = (failedOn?: string): void => + const close = (failedOn?: JournalStopFailedOn): void => ctx.journal.stopMarks.settled(settle, binding.turnId, binding.failedOn ?? failedOn) try { return await cancelAndNote(ctx, input, binding, close) @@ -162,7 +168,7 @@ async function cancelAndNote( ctx: AgentSessionTurnContext, input: PerformCancelInput, binding: StopSettleBinding, - closeSettle: (failedOn?: string) => void + closeSettle: (failedOn?: JournalStopFailedOn) => void ): Promise> { let cancelled = false let note: AgentJournalStatusItem | null = { @@ -310,7 +316,7 @@ async function cancelAndNote( binding.turnId = stoppedTurn ?? stoppedTurnId ?? undefined } else if (interruptFailed) { // A Stop that failed reads "Stopping…" through the turn it could not stop; its end stays its own. - binding.failedOn = ctx.journal.activeTurnId() ?? undefined + binding.failedOn = structuredAgentSessionFailedStopMark(ctx.journal) } if (taken === true && stoppedTurn !== undefined && !binding.closedByWindDown && !input.scope) { await endStoppedTurnAtSettle(ctx, stoppedTurn) From 06261d00dbbc38099b0d5aa5e65540fd49c5805e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:02:09 -0700 Subject: [PATCH 096/182] test(native-chat): a Stop whose event row failed binds no turn to an earlier Stop With one ordered journal writer the Stop's event is in the fold when its write returns, so the press reads whether it owns the latest Stop from the fold instead of awaiting the write. Pins the case the read must refuse. --- ...ed-agent-session-stop-event-binding.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 169c925f9bc8..d5c52b25766b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -3,7 +3,7 @@ // does a Stop of a start that never landed; a card it held, which Resume releases, and anything // sent after it end as their own. Turn rows name the send that opened them, as Codex writes them. -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -303,6 +303,22 @@ describe('a press that writes no Stop event of its own', () => { expect(journal().stopMarks.latest()).toBe(earlier) await expectNews() }) + + // The event is in the fold by its write's return, so one that failed leaves the earlier Stop latest. + it('reopens no earlier Stop: a press whose event row failed binds no turn', async () => { + const sent = await laterTurnAfterAnEarlierStop() + const earlier = journal().stopMarks.latest() + vi.spyOn(journal(), 'appendStopEvent').mockRejectedValueOnce(new Error('disk full')) + rig.cancelTurn.mockImplementationOnce(async () => { + await turnOpenedBy(sent, 'interrupted') + return { cancelled: false } + }) + + expect(await rig.stop()).toMatchObject({ ok: true }) + + expect(journal().stopMarks.latest()).toBe(earlier) + await expectNews() + }) }) describe("a Stop's settle that ends the turn its interrupt took", () => { From 60572cca43df73345772ede96b98be2852f8f9cf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:03:29 -0700 Subject: [PATCH 097/182] test(native-chat): name the settle, not a stream drain, in the Stop's own-end test --- .../structured-agent-session-stop-event-binding.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index d5c52b25766b..a91ed7c8cbd4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -322,7 +322,7 @@ describe('a press that writes no Stop event of its own', () => { }) describe("a Stop's settle that ends the turn its interrupt took", () => { - it("ends a turn still running once the stream drains, as the Stop's, once", async () => { + it("ends a turn still running at the Stop's settle, as the Stop's, once", async () => { rig = await createQueuedMessageTestRig() const stopped = await rig.workingSend() await rig.settleAccepted(stopped, 'stopped') From 0855e218b6dec069384a38e27c4f1658a5ff3313 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:27:32 -0700 Subject: [PATCH 098/182] test(native-chat): a Codex Stop answered before Codex ends the turn reads interrupted throughout Codex answers an interrupt it took before it sends turn/completed (interrupted): on TurnAborted the app-server answers pending interrupts, then ends the turn, on one channel. The test fake did the reverse. It now answers first and ends the turn on a later read, and the tests that read the turn's end right after a Stop wait for it. New end-to-end test through the shipped host, journal and Codex adapter: with the real order, every end row of the stopped turn reads interrupted by the Stop (named, unnamed, and a Stop pressed while turn/start was in flight). Breaking the settle window turns the in-flight case red: the Stop's own end row then has no verdict, which reads as failed until Codex's end lands. --- .../codex-structured-turn-open-wait.test.ts | 3 +- .../codex/codex-structured-turn-steer.test.ts | 11 +- src/main/codex/codex-turn-lifecycle-fake.ts | 20 +- ...gent-session-codex-interrupt-order.test.ts | 255 ++++++++++++++++++ ...-session-codex-turn-end-settlement.test.ts | 8 +- 5 files changed, 283 insertions(+), 14 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index 9a2e20eb1e80..f1c2194d6da4 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -60,7 +60,8 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(await settledWithin(stopping)).toEqual({ cancelled: true, turnId: 'turn-1' }) expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) - expect(rig.turns.turnId).toBeNull() + // Codex answers the interrupt, then ends the turn. + await vi.waitFor(() => expect(rig.turns.turnId).toBeNull()) }) it('does not wait when Codex opened the turn before its answer was read', async () => { diff --git a/src/main/codex/codex-structured-turn-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts index 20d009ed0cc7..15f95aa29275 100644 --- a/src/main/codex/codex-structured-turn-steer.test.ts +++ b/src/main/codex/codex-structured-turn-steer.test.ts @@ -226,10 +226,13 @@ describe('a Codex send made after Codex answered an earlier one, before it opene cancelled: true, turnId: 'turn-1' }) - expect(rig.settlements.map(({ clientMessageId }) => clientMessageId).sort()).toEqual([ - 'client-1', - 'client-2' - ]) + // Codex answers the interrupt, then ends the turn, which settles both. + await vi.waitFor(() => + expect(rig.settlements.map(({ clientMessageId }) => clientMessageId).sort()).toEqual([ + 'client-1', + 'client-2' + ]) + ) }) it('starts its own turn when that turn ends without opening', async () => { diff --git a/src/main/codex/codex-turn-lifecycle-fake.ts b/src/main/codex/codex-turn-lifecycle-fake.ts index a958faca1eaa..3905c7f7f158 100644 --- a/src/main/codex/codex-turn-lifecycle-fake.ts +++ b/src/main/codex/codex-turn-lifecycle-fake.ts @@ -2,9 +2,14 @@ // it, for tests. From app-server `turn_processor.rs`: `turn/start` picks the turn before it // answers, a send while a turn is open is steered into it under the same id with no // second `turn/started`, `turn/steer` refuses with -32600 unless its `expectedTurnId` is -// the running turn, and `turn_interrupt_inner` refuses with -32600 until the -// turn has started. The answer can be held, so a test can deliver it after the -// turn's own frames, as the wire allows. `legacyStartAnswers` is a Codex before 0.148, +// the running turn. This fake refuses `turn/interrupt` with -32600 until the turn has +// started; with no turn active, `turn_interrupt_inner` refuses only an idle thread or a turn +// that already ended. +// An interrupt Codex takes is answered before its turn ends: on `TurnAborted`, +// `bespoke_event_handling.rs` answers pending interrupts, then sends `turn/completed` +// (interrupted) on the same channel; here that end comes on a later read. The `turn/start` +// answer can be held, so a test can deliver it after the turn's own frames, as the wire +// allows. `legacyStartAnswers` is a Codex before 0.148, // whose `turn/start` answers a steered send with its submission id, a turn that never opens, // and whose `turn/steer` finds no turn to steer until the picked one has started. @@ -97,8 +102,13 @@ export function codexTurnLifecycleFake( `expected active turn id ${String(turnId)} but found ${active}` ) } - // Codex answers the interrupt once the turn has aborted. - finish(active, 'interrupted') + const aborted = active + // Answered, then the turn's end on the next read; a turn that ends meanwhile is not ended twice. + setTimeout(() => { + if (active === aborted) { + finish(aborted, 'interrupted') + } + }, 0) return {} } }, diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts new file mode 100644 index 000000000000..bbdcf3b6a97c --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -0,0 +1,255 @@ +// Codex answers an interrupt it took before it ends the turn: on `TurnAborted` the app-server +// answers pending interrupts, then sends `turn/completed` (interrupted) on the same channel. The +// Stop's settle, which runs between the two, must label the turn the Stop's: no end row for it +// ever reads as a failure. Driven through the shipped host, journal and Codex adapter; only the +// Codex child is fake, and the test delivers the turn's end after the Stop has answered. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' +import type * as CodexTurnOpenWait from '../codex/codex-structured-turn-open-wait' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentJournalTurnItem } from '../../shared/agent-session-journal-types' +import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { + liveTestJournalRows, + openTestJournalHostDatabase +} from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { parseJournalRow } from '../native-chat/agent-session-journal/journal-row-schema' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +// The turns a Stop is waiting on to open, so a test knows the wait began. +const openWaits = vi.hoisted(() => { + const turnIds: string[] = [] + return { turnIds } +}) +vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createCodexTurnOpenWaits: () => { + const waits = actual.createCodexTurnOpenWaits() + return { + ...waits, + wait: (turnId: string, withinMs: number) => { + openWaits.turnIds.push(turnId) + return waits.wait(turnId, withinMs) + } + } + } + } +}) + +const CALLER = { callerKey: 'codex-interrupt-order-test' } +const MODEL = { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true +} + +let root: string +let host: StructuredAgentSessionHost +let fence: number +let handlers: CodexAppServerConnectionHandlers | undefined +let answers: number +let interrupts: number +let turns: ReturnType +let operations = 0 + +/** The durable ledger stamps its own clock and refuses an id far from it. */ +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +async function stop(turnId?: string): Promise { + const stopped = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', turnId === undefined ? {} : { turnId }), + ...(turnId === undefined ? {} : { turnId }) + }) + if (!stopped.ok) { + throw new Error(JSON.stringify(stopped.refusal)) + } +} + +/** Every end row written for turn-1, as a row or a lifecycle batch's mutation, in order. */ +function turnEndRows(): AgentJournalTurnItem[] { + return liveTestJournalRows(openTestJournalHostDatabase(root).db, SESSION).flatMap((stored) => { + const parsed = parseJournalRow(stored.rowJson) + if (!parsed.ok) { + return [] + } + const { row } = parsed + const bodies = row.kind === 'lifecycle-batch' ? row.mutations : [row] + return bodies.flatMap((entry) => + entry.kind === 'item' && + entry.body.kind === 'turn' && + entry.body.turnId === 'turn-1' && + entry.body.state !== 'running' + ? [entry.body] + : [] + ) + }) +} + +/** The Stop has answered with Codex's end still to come; then Codex ends the turn. */ +async function expectInterruptedThroughout(): Promise { + expect(interrupts).toBe(1) + expect(turnEndRows()).toEqual([ + expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' }) + ]) + + turns.end('interrupted') + + await vi.waitFor(async () => expect(await owesWork()).toBe(false)) + const ends = turnEndRows() + expect(ends.length).toBeGreaterThan(0) + for (const end of ends) { + expect(end).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + } +} + +async function owesWork(): Promise { + const snapshot = await host.journalSnapshot(SESSION) + return owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-interrupt-order-')) + openWaits.turnIds.length = 0 + answers = 0 + interrupts = 0 + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params) + ) + const openConnection: typeof openCodexAppServerConnection = async ( + _launch, + connectionHandlers = {} + ) => { + handlers = connectionHandlers + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method) => { + if (method === 'thread/start' || method === 'thread/resume') { + return { thread: { id: THREAD } } + } + if (method === 'model/list') { + return { data: [MODEL], nextCursor: null } + } + if (method === 'turn/start') { + answers += 1 + return turns.routes['turn/start']() + } + if (method === 'turn/interrupt') { + // Taken: answered now; the test sends the turn's end once the Stop has answered. + interrupts += 1 + return {} + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } + return connection + } + host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = operationId() + const attached = await host.attach(CALLER, attachParams) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + fence = attached.value.fence +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe("a Codex Stop answered before Codex's turn/completed (interrupted)", () => { + it.each([ + ['names the running turn', 'turn-1'], + ['names no turn', undefined] + ] as const)( + 'reads the turn as interrupted by the Stop, never failed, when it %s', + async (_, named) => { + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + + await stop(named) + + await expectInterruptedThroughout() + } + ) + + // No turn showed when the person pressed Stop: the turn that opens is bound by the Stop's settle. + it('reads the turn that opens behind an in-flight turn/start as interrupted, never failed', async () => { + const release = turns.holdNextAnswer() + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + release() + await vi.waitFor(() => expect(openWaits.turnIds).toContain('turn-1')) + turns.start() + await stopping + + await expectInterruptedThroughout() + }) +}) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 103eebed8858..24ab2a163df7 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -1,7 +1,7 @@ // A Codex send the turn it went into never took: the Stop that interrupts that turn // withdraws it, and nothing reads as working after. A send made while that turn runs, a // queued card's Send-now included, goes in as `turn/steer` naming it. A Stop or a send made -// before Codex opens that turn waits for it to open, since Codex refuses an interrupt until then. +// before Codex opens that turn waits for it to open: Orca interrupts only a turn that has opened. // Driven through the shipped host, journal and Codex adapter; only the Codex child is fake, // keeping Codex 0.157's turn bookkeeping. @@ -463,7 +463,7 @@ describe('a Stop sent after Codex answered a cold send, before it opened the tur await stopping expect(interrupts).toBe(1) - expect(turns.turnId).toBeNull() + await vi.waitFor(() => expect(turns.turnId).toBeNull()) await vi.waitFor(async () => expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') ) @@ -542,12 +542,12 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { await stopping expect(interrupts).toBe(1) - const after = await settled() expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect(after.owesWork).toBe(false) + // Codex's end, after its answer, settles the send. + await vi.waitFor(async () => expect((await settled()).owesWork).toBe(false)) }) it('ends the child when the turn has not opened by the end of its wait', async () => { From 8b526b714ed8aff8a3c1df63f13fb4a32092595d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:30:46 -0700 Subject: [PATCH 099/182] test(native-chat): Stopping ends with a Codex turn whose interrupt is answered before its end With Codex's real order (the interrupt's answer, then turn/completed interrupted), the status shows Stopping while the Stop settles, drops it once the turn ends, and never carries a verdict other than the person's cancellation. --- ...gent-session-codex-interrupt-order.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index bbdcf3b6a97c..789fb220d4fe 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -17,6 +17,7 @@ import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' import type * as CodexTurnOpenWait from '../codex/codex-structured-turn-open-wait' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import type { AgentJournalTurnItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' import { HOST_TEST_SESSION as SESSION, @@ -75,6 +76,7 @@ let handlers: CodexAppServerConnectionHandlers | undefined let answers: number let interrupts: number let turns: ReturnType +let statuses: AgentSessionStatusSummary[] let operations = 0 /** The durable ledger stamps its own clock and refuses an id far from it. */ @@ -132,7 +134,8 @@ function turnEndRows(): AgentJournalTurnItem[] { }) } -/** The Stop has answered with Codex's end still to come; then Codex ends the turn. */ +/** The Stop has answered with Codex's end still to come; then Codex ends the turn. Stopping + * showed while the Stop settled, ended with the turn, and no status read the turn as failed. */ async function expectInterruptedThroughout(): Promise { expect(interrupts).toBe(1) expect(turnEndRows()).toEqual([ @@ -147,6 +150,15 @@ async function expectInterruptedThroughout(): Promise { for (const end of ends) { expect(end).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) } + await vi.waitFor(() => + expect(statuses.at(-1)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) + ) + expect(statuses.some((summary) => summary.stopping === true)).toBe(true) + expect(statuses.at(-1)).not.toHaveProperty('stopping') + const otherVerdicts = statuses.filter( + (summary) => summary.turnOutcome !== undefined && summary.turnOutcome !== 'cancellation' + ) + expect(otherVerdicts).toEqual([]) } async function owesWork(): Promise { @@ -215,6 +227,15 @@ beforeEach(async () => { throw new Error(JSON.stringify(attached.refusal)) } fence = attached.value.fence + statuses = [] + host.subscribeStatus({ + id: 'interrupt-order', + emit: (event) => { + if (event.type === 'status' && event.session.sessionId === SESSION) { + statuses.push(event.session) + } + } + }) }) afterEach(async () => { From bd95932b2c03594023fee9739adf310c816d68df Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:45:40 -0700 Subject: [PATCH 100/182] fix(native-chat): a Codex Stop interrupts a turn Codex answered but has not opened at once A Stop that named no turn, made after Codex answered a send but before the turn opened, used to wait up to 5 s for the turn to open before interrupting, and ended the Codex process when it didn't. The stated reason, that Codex refuses an interrupt until it opens the turn, holds only part of the time: with no turn active, Codex takes an interrupt once its thread runs (turn_interrupt_inner), and refuses it with -32600 "no active turn to interrupt" before that or once the turn has ended. The Stop now sends the interrupt at once. Only on that refusal, while the turn has neither opened nor ended, does it wait for the turn to open (bounded at 5 s) and send it once more. A turn that ended meanwhile was nothing to stop. One that never opens, or that an earlier wait already gave up on, fails the Stop, and the host ends the child as before. Sends still wait for the turn to open before steering into it. The test fake models Codex taking an interrupt once the thread runs (run()). --- ...codex-structured-conversation-stop.test.ts | 3 +- .../codex-structured-prompt-ownership.ts | 36 +++--- .../codex-structured-turn-open-wait.test.ts | 62 +++++++--- .../codex/codex-structured-turn-open-wait.ts | 113 +++++++++++++----- src/main/codex/codex-turn-lifecycle-fake.ts | 31 +++-- ...gent-session-codex-interrupt-order.test.ts | 47 +++----- ...-session-codex-turn-end-settlement.test.ts | 24 ++-- 7 files changed, 199 insertions(+), 117 deletions(-) diff --git a/src/main/codex/codex-structured-conversation-stop.test.ts b/src/main/codex/codex-structured-conversation-stop.test.ts index 4650603256bd..1e548dd0909f 100644 --- a/src/main/codex/codex-structured-conversation-stop.test.ts +++ b/src/main/codex/codex-structured-conversation-stop.test.ts @@ -1,5 +1,6 @@ // A Codex Stop that names no turn. The fake keeps Codex 0.157's turn bookkeeping: it answers -// `turn/start` before it opens the turn, and refuses an interrupt until then. +// `turn/start` before it opens the turn, and refuses an interrupt with no turn active until the +// thread runs. import { describe, expect, it, vi } from 'vitest' import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index 813da8f01b6a..fdef6e750f48 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -12,7 +12,7 @@ import { } from './codex-structured-prompt-replies' import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' import { interruptCodexTurn } from './codex-structured-turn-cancellation' -import { codexStopTarget } from './codex-structured-turn-open-wait' +import { codexStopTarget, interruptOpeningCodexTurn } from './codex-structured-turn-open-wait' type CancelInput = Parameters[0] type AnswerInput = Parameters[0] @@ -23,7 +23,7 @@ function providerTurnId(session: CodexSession, turnId: string): string | undefin } /** A send whose answer this child lost: Codex may still open a turn for it that no Stop can name. - * A pending one Codex answered is not: its turn is the one the Stop waits for. */ + * A pending one Codex answered is not: its turn is the one the Stop interrupts. */ function sendInDoubt(dispatchStatus: CancelInput['dispatchStatus']): boolean { return dispatchStatus?.state === 'unknown' && !dispatchStatus.recovered } @@ -39,8 +39,8 @@ function stoppedTurn( /** * A Stop that names no turn: interrupt the turn the journal shows, or else the one Codex reported * started and not yet ended, which the journal can trail by a publish, or else the one Codex - * answered a send into, once it opens. A turn that may still open, unanswered or not open in time, - * is one this Stop cannot reach: it answers refused, and the host ends the child. + * answered a send into, at once (`interruptOpeningCodexTurn`). A turn that may still open but + * cannot be reached, or a send whose answer was lost, answers refused, and the host ends the child. */ async function cancelCodexConversation( input: Parameters[0], @@ -49,27 +49,31 @@ async function cancelCodexConversation( const { request, sessions, requestTimeoutMs } = input const liveTurnId = request.resolveLiveTurnId?.() ?? null // A turn the journal shows that Codex has not started yet (a compaction's) has nothing to stop. - const target = liveTurnId === null ? await codexStopTarget(session) : { turnId: liveTurnId } - // The wait for the turn to open can outlive the session it began on. - if ( - sessions.get(request.sessionId) !== session || - session.ended || - session.fence !== request.fence - ) { + const target = liveTurnId === null ? codexStopTarget(session) : { turnId: liveTurnId } + // Read again after any wait for the turn to open, which can outlive the session it began on. + const current = (): boolean => + sessions.get(request.sessionId) === session && !session.ended && session.fence === request.fence + if (!current()) { return { cancelled: false } } - if (target === null || 'opening' in target) { - return target !== null || sendInDoubt(request.dispatchStatus) + if (target === null) { + return sendInDoubt(request.dispatchStatus) ? { cancelled: false, refusal: { turnMayOpen: true } } : { cancelled: false } } - const turnId = liveTurnId === null ? target.turnId : providerTurnId(session, liveTurnId) + const journalTurnId = 'opening' in target ? target.opening : target.turnId + const turnId = liveTurnId === null ? journalTurnId : providerTurnId(session, liveTurnId) if (!turnId) { return { cancelled: false } } + const interrupt = () => + interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) + if (!('opening' in target)) { + return stoppedTurn(await interrupt(), journalTurnId) + } return stoppedTurn( - await interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }), - target.turnId + await interruptOpeningCodexTurn(session, turnId, interrupt, current), + journalTurnId ) } diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index f1c2194d6da4..5b79f4386b92 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -1,7 +1,8 @@ // A Codex Stop that names no turn, sent after Codex answered a cold send and before it opened that -// turn, waits for the turn to open, or provably not to, and never the send itself. The fake keeps -// Codex 0.157's turn bookkeeping: it answers before it opens the turn, and refuses an interrupt -// until then. +// turn, interrupts that turn at once. Refused as finding no turn while the turn has yet to run, it +// waits for the turn to open, or provably not to, and interrupts once more; it never waits for the +// send itself. The fake keeps Codex 0.157's turn bookkeeping: it answers before it opens the turn, +// and refuses an interrupt with no turn active until the thread runs. import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -27,12 +28,14 @@ async function answeredColdSend(rig: Rig): Promise { expect(await settledWithin(sending)).toEqual(ADMITTED) } -/** A Stop sent in the window, which Codex would refuse if it reached Codex now. */ +const interruptedTurns = (rig: Rig) => rig.interrupts().map((call) => call.params?.turnId) + +/** A Stop sent in the window before Codex's thread runs the turn: Codex refused its interrupt. */ async function waitingStop(rig: Rig) { await answeredColdSend(rig) const stopping = stop(rig) expect(await settledWithin(stopping)).toBe('held') - expect(rig.interrupts()).toEqual([]) + expect(interruptedTurns(rig)).toEqual(['turn-1']) // Wrapped: an async function returning the promise itself would wait for it. return { stopping } } @@ -52,14 +55,24 @@ describe('a Codex send answered before its turn opens', () => { }) describe("a no-turn Stop in the window between Codex's answer and its turn opening", () => { - it('waits for the turn to open, then stops it', async () => { + it("is taken at once once Codex's thread runs the turn", async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + rig.turns.run() + + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true, turnId: 'turn-1' }) + expect(interruptedTurns(rig)).toEqual(['turn-1']) + await vi.waitFor(() => expect(rig.turns.turnId).toBeNull()) + }) + + it('refused before the thread runs, waits for the turn to open and interrupts once more', async () => { const rig = await codexTurnLifecycleRig() const { stopping } = await waitingStop(rig) rig.turns.start() expect(await settledWithin(stopping)).toEqual({ cancelled: true, turnId: 'turn-1' }) - expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) + expect(interruptedTurns(rig)).toEqual(['turn-1', 'turn-1']) // Codex answers the interrupt, then ends the turn. await vi.waitFor(() => expect(rig.turns.turnId).toBeNull()) }) @@ -93,7 +106,21 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.turns.end('interrupted') expect(await settledWithin(stopping)).toEqual(REFUSED) - expect(rig.interrupts()).toEqual([]) + expect(interruptedTurns(rig)).toEqual(['turn-1']) + }) + + // Codex ended the turn as the interrupt reached it: it refuses the turn as its last ended one. + it('stops nothing, and waits for nothing, when Codex already ended the turn', async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + const interrupt = rig.codex.routes['turn/interrupt']! + rig.codex.routes['turn/interrupt'] = (params) => { + rig.turns.end('completed') + return interrupt(params) + } + + expect(await settledWithin(stop(rig))).toEqual(REFUSED) + expect(interruptedTurns(rig)).toEqual(['turn-1']) }) // The turn neither opened nor ended, and its send is still owed: no press calls that nothing. @@ -106,8 +133,9 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.notify('thread/status/changed', { threadId: CODEX_TEST_THREAD_ID, status: { type } }) expect(await settledWithin(stopping)).toEqual(MAY_OPEN) - expect(await settledWithin(stop(rig), 0)).toEqual(MAY_OPEN) - expect(rig.interrupts()).toEqual([]) + // The second press's interrupt is refused too, and spends no wait on that turn. + expect(await settledWithin(stop(rig))).toEqual(MAY_OPEN) + expect(interruptedTurns(rig)).toEqual(['turn-1', 'turn-1']) } ) @@ -129,7 +157,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.codex.connections[0]!.handlers.onExit?.(new Error('codex app-server exited')) expect(await settledWithin(stopping)).toEqual(REFUSED) - expect(rig.interrupts()).toEqual([]) + expect(interruptedTurns(rig)).toEqual(['turn-1']) }) // The turn may still open and run: the host ends the child (`performCancel`). @@ -146,13 +174,15 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(outcome).toBe('held') await vi.advanceTimersByTimeAsync(1) - expect(outcome).toEqual({ cancelled: false, refusal: { turnMayOpen: true } }) - expect(rig.interrupts()).toEqual([]) + expect(outcome).toEqual(MAY_OPEN) + expect(interruptedTurns(rig)).toEqual(['turn-1']) // A second Stop spends no wait on that turn, and still finds it able to open. - expect(await settledWithin(stop(rig), 0)).toEqual({ - cancelled: false, - refusal: { turnMayOpen: true } + let second: unknown = 'held' + void stop(rig).then((value) => { + second = value }) + await vi.advanceTimersByTimeAsync(0) + expect(second).toEqual(MAY_OPEN) }) }) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index 3f43e9c787d4..c29fa61eb020 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -1,6 +1,7 @@ // A Stop's or a send's wait for the turn Codex answered a send into to open, or provably not // to: it ended, the thread stopped running, or the child is gone. Held in memory only. +import type { AgentSessionCancelOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import { codexThreadStoppedRunning, @@ -61,18 +62,12 @@ export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { } } -/** What a Stop or a send finds to act on: a turn running, or one Codex answered a send into that - * has not opened and has not ended, so may still open while its send is owed; null when neither. */ +/** What a send finds to steer, or a Stop to interrupt: a turn running, or one Codex answered a send + * into that has not opened and has not ended, so may still open while its send is owed; null when + * neither. */ export type CodexStopTarget = { turnId: string } | { opening: string } | null -/** - * The turn a Stop or a send names: the latest Codex reported started and not ended, or else the - * one Codex answered a send into, once it opens. `opening` when it has not opened by the end of - * the wait, however the wait ended (it ran out, or the thread stopped running), while it has not - * ended and its send is still owed: nothing to stop holds only when nothing is owed. Null when - * none is running and none is owed. Each such turn is waited for once. - */ -export async function codexStopTarget(session: { +type CodexOpeningTurnSession = { threadId: string activeTurnIds?: ReadonlySet dispatchEchoes: Pick< @@ -80,43 +75,95 @@ export async function codexStopTarget(session: { 'answeredUnopenedTurn' | 'leftUnopened' | 'answeredTurnLeftUnopened' > turnOpenWaits: Pick -}): Promise { +} + +/** The latest turn Codex reported started and not ended, or else the one it answered a send into + * that has neither opened nor ended, whether or not a wait already gave up on it. */ +export function codexStopTarget(session: CodexOpeningTurnSession): CodexStopTarget { const running = [...(session.activeTurnIds ?? [])].at(-1) if (running) { return { turnId: running } } + const open = session.activeTurnIds ?? new Set() + const opening = + session.dispatchEchoes.answeredUnopenedTurn(session.threadId, open) ?? + session.dispatchEchoes.answeredTurnLeftUnopened(session.threadId, open) + return opening ? { opening } : null +} + +/** Codex's -32600 for an interrupt that finds no turn: before the turn has started running, or + * once it has ended. */ +const CODEX_NO_ACTIVE_TURN_TO_INTERRUPT = 'no active turn to interrupt' + +/** + * A Stop of a turn Codex answered a send into that has not opened. Codex takes the interrupt once + * its thread runs, so it goes out at once. Refused as finding no turn while that turn has yet to + * start, the Stop waits for it to open, bounded, and sends it once more. A turn that ended + * meanwhile was nothing to stop. One that never opened, or that an earlier wait already gave up on, + * fails the Stop (`turnMayOpen`): it may still open and run, so the host ends the child. + */ +export async function interruptOpeningCodexTurn( + session: CodexOpeningTurnSession, + turnId: string, + interrupt: () => Promise, + /** Whether the session the Stop began on still runs it, read after the wait. */ + stillCurrent: () => boolean +): Promise { + const open = (): ReadonlySet => session.activeTurnIds ?? new Set() + const waitedBefore = + session.dispatchEchoes.answeredTurnLeftUnopened(session.threadId, open()) === turnId + const first = await interrupt() + if (first.cancelled || first.refusal?.detail?.text !== CODEX_NO_ACTIVE_TURN_TO_INTERRUPT) { + return first + } + const opened = (): boolean => open().has(turnId) + const stillOpening = (): boolean => { + const target = codexStopTarget(session) + return target !== null && 'opening' in target && target.opening === turnId + } + if (!opened() && stillOpening() && !waitedBefore) { + await session.turnOpenWaits.wait(turnId, CODEX_TURN_OPEN_WAIT_MS) + if (!stillCurrent()) { + return { cancelled: false } + } + } + if (opened()) { + return interrupt() + } + if (!stillOpening()) { + // It ended: the Stop found no turn running. + return { cancelled: false } + } + session.dispatchEchoes.leftUnopened(session.threadId, turnId) + return { cancelled: false, refusal: { turnMayOpen: true } } +} + +/** + * The turn a send steers into: the running one, or else the one Codex answered a send into, once + * it opens. Before 0.148 Codex refuses a steer until it opens the turn, so the send waits for that, + * once per turn: a turn a wait left unopened is not waited for again. Null when no turn is running + * by then. + */ +export async function codexRunningOrOpeningTurn( + session: CodexOpeningTurnSession +): Promise { + const running = [...(session.activeTurnIds ?? [])].at(-1) + if (running) { + return running + } const answered = session.dispatchEchoes.answeredUnopenedTurn( session.threadId, session.activeTurnIds ?? new Set() ) if (!answered) { - // An earlier wait left it unopened, yet its send is still owed: it may still open. - return leftUnopenedTarget(session) + return null } - // Codex refuses an interrupt, and before 0.148 a steer, until it opens the turn. await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) if (session.activeTurnIds?.has(answered)) { - return { turnId: answered } + return answered } // Before 0.148 a turn that fails before it starts reports no end; one that opens later is still // found running. session.dispatchEchoes.leftUnopened(session.threadId, answered) - return leftUnopenedTarget(session) -} - -/** The answered turn a wait left unopened, while it has not ended and its send is still owed. */ -function leftUnopenedTarget(session: Parameters[0]): CodexStopTarget { - const left = session.dispatchEchoes.answeredTurnLeftUnopened( - session.threadId, - session.activeTurnIds ?? new Set() - ) - return left ? { opening: left } : null -} - -/** `codexStopTarget`'s running or opened turn, for a send. */ -export async function codexRunningOrOpeningTurn( - session: Parameters[0] -): Promise { - const target = await codexStopTarget(session) - return target !== null && 'turnId' in target ? target.turnId : null + return null } diff --git a/src/main/codex/codex-turn-lifecycle-fake.ts b/src/main/codex/codex-turn-lifecycle-fake.ts index 3905c7f7f158..dda6060ee3a6 100644 --- a/src/main/codex/codex-turn-lifecycle-fake.ts +++ b/src/main/codex/codex-turn-lifecycle-fake.ts @@ -2,9 +2,8 @@ // it, for tests. From app-server `turn_processor.rs`: `turn/start` picks the turn before it // answers, a send while a turn is open is steered into it under the same id with no // second `turn/started`, `turn/steer` refuses with -32600 unless its `expectedTurnId` is -// the running turn. This fake refuses `turn/interrupt` with -32600 until the turn has -// started; with no turn active, `turn_interrupt_inner` refuses only an idle thread or a turn -// that already ended. +// the running turn, and `turn_interrupt_inner`, with no turn active, refuses with -32600 "no +// active turn to interrupt" until the thread runs (`run`, or `start`) and once the turn has ended. // An interrupt Codex takes is answered before its turn ends: on `TurnAborted`, // `bespoke_event_handling.rs` answers pending interrupts, then sends `turn/completed` // (interrupted) on the same channel; here that end comes on a later read. The `turn/start` @@ -27,6 +26,9 @@ export type CodexTurnLifecycleFake = { holdNextAnswer: () => () => void /** Codex emits `turn/started` for the turn it picked last. */ start: () => void + /** The thread runs the picked turn, whose `turn/started` has yet to be read: an interrupt of it + * is taken, and that `turn/started` comes ahead of the answer. */ + run: () => void /** Codex ends the picked or running turn on its own. */ end: (status: 'completed' | 'interrupted' | 'failed', errorMessage?: string) => void /** Codex fails the picked turn before starting it, which reports an `error` and no turn end. */ @@ -56,14 +58,24 @@ export function codexTurnLifecycleFake( let active: string | null = null let lastTurn: string | null = null let held: Promise | null = null + let pickedRuns = false const finish = (turnId: string, status: string, errorMessage?: string): void => { picked = null active = null + pickedRuns = false notify()('turn/completed', { threadId, turn: { id: turnId, status, ...(errorMessage ? { error: { message: errorMessage } } : {}) } }) } + const open = (): void => { + if (!picked) { + throw new Error('no picked turn to start') + } + active = picked + pickedRuns = false + notify()('turn/started', { threadId, turn: { id: active, status: 'inProgress' } }) + } return { routes: { 'turn/start': () => { @@ -93,6 +105,9 @@ export function codexTurnLifecycleFake( }, 'turn/interrupt': (params) => { const turnId = params?.turnId + if (!active && pickedRuns && picked === turnId) { + open() + } if (!active) { throw refusal('turn/interrupt', 'no active turn to interrupt') } @@ -119,12 +134,12 @@ export function codexTurnLifecycleFake( }) return () => release() }, - start: () => { - if (!picked) { - throw new Error('no picked turn to start') + start: () => open(), + run: () => { + if (!picked || active) { + throw new Error('no picked turn to run') } - active = picked - notify()('turn/started', { threadId, turn: { id: active, status: 'inProgress' } }) + pickedRuns = true }, end: (status, errorMessage) => { const turnId = active ?? picked diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index bbdcf3b6a97c..de09e72b7e5c 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -14,7 +14,6 @@ import type { openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' -import type * as CodexTurnOpenWait from '../codex/codex-structured-turn-open-wait' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import type { AgentJournalTurnItem } from '../../shared/agent-session-journal-types' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' @@ -36,28 +35,6 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' -// The turns a Stop is waiting on to open, so a test knows the wait began. -const openWaits = vi.hoisted(() => { - const turnIds: string[] = [] - return { turnIds } -}) -vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - createCodexTurnOpenWaits: () => { - const waits = actual.createCodexTurnOpenWaits() - return { - ...waits, - wait: (turnId: string, withinMs: number) => { - openWaits.turnIds.push(turnId) - return waits.wait(turnId, withinMs) - } - } - } - } -}) - const CALLER = { callerKey: 'codex-interrupt-order-test' } const MODEL = { model: 'gpt-test', @@ -75,6 +52,7 @@ let handlers: CodexAppServerConnectionHandlers | undefined let answers: number let interrupts: number let turns: ReturnType +let opened: Set let operations = 0 /** The durable ledger stamps its own clock and refuses an id far from it. */ @@ -156,13 +134,15 @@ async function owesWork(): Promise { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-codex-interrupt-order-')) - openWaits.turnIds.length = 0 answers = 0 interrupts = 0 - turns = codexTurnLifecycleFake( - THREAD, - () => (method, params) => handlers?.onNotification?.(method, params) - ) + opened = new Set() + turns = codexTurnLifecycleFake(THREAD, () => (method, params) => { + if (method === 'turn/started') { + opened.add(turns.turnId ?? '') + } + handlers?.onNotification?.(method, params) + }) const openConnection: typeof openCodexAppServerConnection = async ( _launch, connectionHandlers = {} @@ -183,8 +163,12 @@ beforeEach(async () => { return turns.routes['turn/start']() } if (method === 'turn/interrupt') { - // Taken: answered now; the test sends the turn's end once the Stop has answered. + // Taken: answered now; the test sends the turn's end once the Stop has answered. A turn + // Codex runs but whose turn/started was not yet read gets that frame ahead of the answer. interrupts += 1 + if (turns.turnId !== null && !opened.has(turns.turnId)) { + turns.start() + } return {} } return {} @@ -239,15 +223,14 @@ describe("a Codex Stop answered before Codex's turn/completed (interrupted)", () } ) - // No turn showed when the person pressed Stop: the turn that opens is bound by the Stop's settle. + // No turn showed when the person pressed Stop, which interrupts the answered turn at once: the + // turn that opens is bound by the Stop's settle. it('reads the turn that opens behind an in-flight turn/start as interrupted, never failed', async () => { const release = turns.holdNextAnswer() await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) const stopping = stop() release() - await vi.waitFor(() => expect(openWaits.turnIds).toContain('turn-1')) - turns.start() await stopping await expectInterruptedThroughout() diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 24ab2a163df7..7502ab93b77c 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -1,7 +1,8 @@ // A Codex send the turn it went into never took: the Stop that interrupts that turn // withdraws it, and nothing reads as working after. A send made while that turn runs, a -// queued card's Send-now included, goes in as `turn/steer` naming it. A Stop or a send made -// before Codex opens that turn waits for it to open: Orca interrupts only a turn that has opened. +// queued card's Send-now included, goes in as `turn/steer` naming it. A Stop made before Codex +// opens that turn interrupts it at once; refused before Codex's thread runs it, the Stop waits +// for it to open and interrupts once more. A send made then waits for it to open. // Driven through the shipped host, journal and Codex adapter; only the Codex child is fake, // keeping Codex 0.157's turn bookkeeping. @@ -451,18 +452,17 @@ describe('a second send made after Codex answered the first, before it opened th }) describe('a Stop sent after Codex answered a cold send, before it opened the turn', () => { - it('waits for the turn to open, then stops it and withdraws the send', async () => { + it('refused before the thread runs it, waits for the turn to open, then stops it and withdraws the send', async () => { const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) const stopping = stop() - // Without the wait, it would reach Codex now, which would refuse it, and be done. expect(await settledWithin(stopping, 1_000)).toBe('held') - expect(interrupts).toBe(0) + expect(interrupts).toBe(1) turns.start() await stopping - expect(interrupts).toBe(1) + expect(interrupts).toBe(2) await vi.waitFor(() => expect(turns.turnId).toBeNull()) await vi.waitFor(async () => expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') @@ -492,7 +492,8 @@ describe('a Stop in that window that the turn never opens for', () => { turns.end('interrupted') await stopping - expect(interrupts).toBe(0) + expect(interrupts).toBe(1) + expect(childCloses).toBe(0) expect(await statusRows()).toContain('Codex had no turn running to stop.') }) @@ -506,7 +507,7 @@ describe('a Stop in that window that the turn never opens for', () => { ).not.toBe('held') expect(await settledWithin(stopping, 0)).not.toBe('held') expect(childCloses).toBe(1) - expect(interrupts).toBe(0) + expect(interrupts).toBe(1) // Its wait ran out with the turn still able to open, so the Stop ended the child. expect(await statusRows()).toContain('Cancellation requested.') }) @@ -541,7 +542,8 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { turns.start() await stopping - expect(interrupts).toBe(1) + // Refused before the thread ran the turn, then taken once it opened. + expect(interrupts).toBe(2) expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' @@ -550,7 +552,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { await vi.waitFor(async () => expect((await settled()).owesWork).toBe(false)) }) - it('ends the child when the turn has not opened by the end of its wait', async () => { + it('ends the child when its interrupt was refused and the turn has not opened by the end of its wait', async () => { const release = turns.holdNextAnswer() await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) @@ -559,7 +561,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') - expect(interrupts).toBe(0) + expect(interrupts).toBe(1) expect(childCloses).toBe(1) expect((await settled()).owesWork).toBe(false) expect(turnRow((await host.journalSnapshot(SESSION)).items)).toBeUndefined() From c120cb021a5343ef8d79696a1b8e26fd4c9e4c2a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:56:10 -0700 Subject: [PATCH 101/182] fix(mobile): name how the phone's status stream is released in the subscription inventory Main made each inventory entry state its release; the status feed's stream is released from its subscribe params, as the session event stream is. --- mobile/src/transport/rpc-subscription-inventory.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/src/transport/rpc-subscription-inventory.ts b/mobile/src/transport/rpc-subscription-inventory.ts index 3f53b87559af..e39f601ad0ce 100644 --- a/mobile/src/transport/rpc-subscription-inventory.ts +++ b/mobile/src/transport/rpc-subscription-inventory.ts @@ -112,6 +112,7 @@ export const RPC_SUBSCRIPTION_SITES: readonly RpcSubscriptionSite[] = [ { file: 'src/session/mobile-structured-session-status-feed.ts', method: 'agentSession.subscribeStatus', + release: 'params', coverage: { kind: 'unwritten-scenario' } }, // The session tab snapshot. Mountable behind the reconciliation controller the hook already From 980f0e79d70f29ff6212b1ba39e87f1fc7b7b0b7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:12:47 -0700 Subject: [PATCH 102/182] fix(native-chat): every Codex Stop waits for an answered turn to start, as the first did A Stop whose interrupt Codex refused as finding no active turn skipped the wait when an earlier wait, a Stop's or a send's, had already given up on that turn. Every press now waits its own bound and retries once if the turn starts, so a turn that opens during a later press is still stopped. Both presses still reach the same verdict when it never starts. --- .../codex-structured-turn-open-wait.test.ts | 51 ++++++++++++++----- .../codex/codex-structured-turn-open-wait.ts | 15 +++--- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index 5b79f4386b92..d669c088d080 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -30,6 +30,15 @@ async function answeredColdSend(rig: Rig): Promise { const interruptedTurns = (rig: Rig) => rig.interrupts().map((call) => call.params?.turnId) +/** A Stop under fake timers, read without awaiting it. */ +function pressed(rig: Rig): () => unknown { + let outcome: unknown = 'held' + void stop(rig).then((value) => { + outcome = value + }) + return () => outcome +} + /** A Stop sent in the window before Codex's thread runs the turn: Codex refused its interrupt. */ async function waitingStop(rig: Rig) { await answeredColdSend(rig) @@ -133,8 +142,13 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.notify('thread/status/changed', { threadId: CODEX_TEST_THREAD_ID, status: { type } }) expect(await settledWithin(stopping)).toEqual(MAY_OPEN) - // The second press's interrupt is refused too, and spends no wait on that turn. - expect(await settledWithin(stop(rig))).toEqual(MAY_OPEN) + // The second press's interrupt is refused too; it waits its own bound, then agrees. + vi.useFakeTimers() + const second = pressed(rig) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 1) + expect(second()).toBe('held') + await vi.advanceTimersByTimeAsync(1) + expect(second()).toEqual(MAY_OPEN) expect(interruptedTurns(rig)).toEqual(['turn-1', 'turn-1']) } ) @@ -165,24 +179,33 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi const rig = await codexTurnLifecycleRig() await answeredColdSend(rig) vi.useFakeTimers() - let outcome: unknown = 'held' - void stop(rig).then((value) => { - outcome = value - }) + const first = pressed(rig) await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 1) - expect(outcome).toBe('held') + expect(first()).toBe('held') await vi.advanceTimersByTimeAsync(1) - expect(outcome).toEqual(MAY_OPEN) + expect(first()).toEqual(MAY_OPEN) expect(interruptedTurns(rig)).toEqual(['turn-1']) - // A second Stop spends no wait on that turn, and still finds it able to open. - let second: unknown = 'held' - void stop(rig).then((value) => { - second = value - }) + }) + + // A press after an earlier wait gave up waits again, so a turn that opens then is still stopped. + it('waits again on a second press, and stops the turn if it opens then', async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + vi.useFakeTimers() + const first = pressed(rig) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS) + expect(first()).toEqual(MAY_OPEN) + + const second = pressed(rig) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 1) + expect(second()).toBe('held') + rig.turns.start() await vi.advanceTimersByTimeAsync(0) - expect(second).toEqual(MAY_OPEN) + + expect(second()).toEqual({ cancelled: true, turnId: 'turn-1' }) + expect(interruptedTurns(rig)).toEqual(['turn-1', 'turn-1', 'turn-1']) }) }) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index c29fa61eb020..9dd071656f35 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -96,11 +96,11 @@ export function codexStopTarget(session: CodexOpeningTurnSession): CodexStopTarg const CODEX_NO_ACTIVE_TURN_TO_INTERRUPT = 'no active turn to interrupt' /** - * A Stop of a turn Codex answered a send into that has not opened. Codex takes the interrupt once - * its thread runs, so it goes out at once. Refused as finding no turn while that turn has yet to - * start, the Stop waits for it to open, bounded, and sends it once more. A turn that ended - * meanwhile was nothing to stop. One that never opened, or that an earlier wait already gave up on, - * fails the Stop (`turnMayOpen`): it may still open and run, so the host ends the child. + * A Stop of a turn Codex answered a send into that has not opened. Codex takes an interrupt as + * soon as the turn starts, which can be before its `turn/started` is read, so it goes out at once. + * Refused as finding no turn while that turn has yet to start, the Stop waits for it to open, + * bounded, and sends it once more. A turn that ended meanwhile was nothing to stop. One that never + * opens fails the Stop (`turnMayOpen`): it may still open and run, so the host ends the child. */ export async function interruptOpeningCodexTurn( session: CodexOpeningTurnSession, @@ -110,8 +110,6 @@ export async function interruptOpeningCodexTurn( stillCurrent: () => boolean ): Promise { const open = (): ReadonlySet => session.activeTurnIds ?? new Set() - const waitedBefore = - session.dispatchEchoes.answeredTurnLeftUnopened(session.threadId, open()) === turnId const first = await interrupt() if (first.cancelled || first.refusal?.detail?.text !== CODEX_NO_ACTIVE_TURN_TO_INTERRUPT) { return first @@ -121,7 +119,7 @@ export async function interruptOpeningCodexTurn( const target = codexStopTarget(session) return target !== null && 'opening' in target && target.opening === turnId } - if (!opened() && stillOpening() && !waitedBefore) { + if (!opened() && stillOpening()) { await session.turnOpenWaits.wait(turnId, CODEX_TURN_OPEN_WAIT_MS) if (!stillCurrent()) { return { cancelled: false } @@ -134,7 +132,6 @@ export async function interruptOpeningCodexTurn( // It ended: the Stop found no turn running. return { cancelled: false } } - session.dispatchEchoes.leftUnopened(session.threadId, turnId) return { cancelled: false, refusal: { turnMayOpen: true } } } From 0138f0b8f2ced9ad73de6d90054a45b9fb45d782 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 06:53:35 -0700 Subject: [PATCH 103/182] fix(codex): never steer a turn whose interrupt Codex answered Codex answers an interrupt as the turn aborts, before it sends that turn's turn/completed. In that gap the adapter still counted the turn as running, so a message handed over right after a Stop settled (the Stop's own end row already reads the turn ended) went out as turn/steer, which Codex refused with -32600 "no active turn to steer", and only then as turn/start. The adapter now marks a turn whose interrupt Codex answered as aborted until its turn/completed, never steers into it, and starts the message's own turn directly. Steering a turn that is genuinely running is unchanged. --- src/main/codex/codex-structured-rewind.ts | 1 + .../codex/codex-structured-session-acquire.ts | 1 + .../codex/codex-structured-session-state.ts | 3 + .../codex-structured-turn-cancellation.ts | 3 + src/main/codex/codex-structured-turn-start.ts | 10 ++- ...gent-session-codex-interrupt-order.test.ts | 61 +++++++++++++++++-- 6 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/main/codex/codex-structured-rewind.ts b/src/main/codex/codex-structured-rewind.ts index b222694422d4..38b13fcad038 100644 --- a/src/main/codex/codex-structured-rewind.ts +++ b/src/main/codex/codex-structured-rewind.ts @@ -304,5 +304,6 @@ export function observeCodexRewindActivity( } if (turnId && method === 'turn/completed') { session.activeTurnIds?.delete(turnId) + session.abortedTurnIds?.delete(turnId) } } diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 37c34a4bf3a1..3fddcccccef7 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -237,6 +237,7 @@ export async function acquireCodexStructuredSession(input: { historyPath: opened.historyPath, historyMode: opened.historyMode, activeTurnIds: new Set(), + abortedTurnIds: new Set(), prompts: acquisition.prompts, options, reportedOptions: reportedCodexThreadOptions(opened), diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index a47cb5a78fe2..022628e216c6 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -114,6 +114,9 @@ export type CodexSession = { /** Primary-thread turns Codex reported started and not yet ended, as read off the wire: what * rewind waits out and what a Stop naming no turn interrupts when the journal shows none. */ activeTurnIds?: Set + /** Of those, the turns whose interrupt Codex answered. It answers as the turn aborts, ahead of + * that turn's `turn/completed`, so none of them can take a steer any more. */ + abortedTurnIds?: Set /** Stops waiting for the turn Codex answered a send into to open. */ turnOpenWaits: CodexTurnOpenWaits dispatchPending?: boolean diff --git a/src/main/codex/codex-structured-turn-cancellation.ts b/src/main/codex/codex-structured-turn-cancellation.ts index 4330ebed0fb1..b66b158ba3f6 100644 --- a/src/main/codex/codex-structured-turn-cancellation.ts +++ b/src/main/codex/codex-structured-turn-cancellation.ts @@ -48,6 +48,9 @@ export async function interruptCodexTurn(input: { } } } + if (threadId === session.threadId) { + session.abortedTurnIds?.add(turnId) + } const promptAdmission = input.onConfirmed?.() if (promptAdmission && !promptAdmission.accepted) { throw new Error( diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 7f0a2ec0c239..dd577074c2b7 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -54,6 +54,8 @@ export type CodexTurnHost = { fastModeTierByModel: ReadonlyMap dispatchEchoes: CodexDispatchEchoes activeTurnIds?: ReadonlySet + /** Running turns whose interrupt Codex already answered: aborted, so never steered. */ + abortedTurnIds?: ReadonlySet turnOpenWaits: Pick } @@ -147,11 +149,15 @@ export async function startCodexTurn( if (!host.dispatchEchoes.arm(input.clientMessageId, input.requestedAt)) { return false } + // A turn whose interrupt Codex answered has aborted, though its end may still be on the wire: + // the send opens its own turn. + const steerable = (turnId: string | null | undefined): turnId is string => + typeof turnId === 'string' && turnId !== '' && !host.abortedTurnIds?.has(turnId) const runningTurnId = await codexRunningOrOpeningTurn(host) - let steered = runningTurnId ? await steerCodexTurn(host, runningTurnId, input) : null + let steered = steerable(runningTurnId) ? await steerCodexTurn(host, runningTurnId, input) : null // Refused because a turn Orca heard of meanwhile is running: steer that one, once. const runningSince = steered ? undefined : [...(host.activeTurnIds ?? [])].at(-1) - if (runningSince && runningSince !== runningTurnId) { + if (steerable(runningSince) && runningSince !== runningTurnId) { steered = await steerCodexTurn(host, runningSince, input) } if (steered) { diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index 8b10d40f7261..5d122042af90 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -8,10 +8,11 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - CodexAppServerConnection, - CodexAppServerConnectionHandlers, - openCodexAppServerConnection +import { + CodexAppServerRequestError, + type CodexAppServerConnection, + type CodexAppServerConnectionHandlers, + type openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' @@ -55,6 +56,10 @@ let interrupts: number let turns: ReturnType let statuses: AgentSessionStatusSummary[] let opened: Set +let steers: number +let aborted: Set +/** When set, Codex answers the interrupt only once it resolves. */ +let interruptAnswer: Promise | null let operations = 0 /** The durable ledger stamps its own clock and refuses an id far from it. */ @@ -149,6 +154,9 @@ beforeEach(async () => { answers = 0 interrupts = 0 opened = new Set() + steers = 0 + aborted = new Set() + interruptAnswer = null turns = codexTurnLifecycleFake(THREAD, () => (method, params) => { if (method === 'turn/started') { opened.add(turns.turnId ?? '') @@ -163,7 +171,7 @@ beforeEach(async () => { const connection: CodexAppServerConnection = { pid: 4321, closed: false, - request: async (method) => { + request: async (method, params) => { if (method === 'thread/start' || method === 'thread/resume') { return { thread: { id: THREAD } } } @@ -174,6 +182,19 @@ beforeEach(async () => { answers += 1 return turns.routes['turn/start']() } + if (method === 'turn/steer') { + steers += 1 + // A turn Codex answered an interrupt for has aborted: Codex refuses to steer it. + if (turns.turnId === null || aborted.has(turns.turnId)) { + throw new CodexAppServerRequestError( + 'turn/steer', + -32600, + 'codex app-server turn/steer failed: no active turn to steer', + 'no active turn to steer' + ) + } + return turns.routes['turn/steer'](params) + } if (method === 'turn/interrupt') { // Taken: answered now; the test sends the turn's end once the Stop has answered. A turn // Codex runs but whose turn/started was not yet read gets that frame ahead of the answer. @@ -181,6 +202,8 @@ beforeEach(async () => { if (turns.turnId !== null && !opened.has(turns.turnId)) { turns.start() } + await interruptAnswer + aborted.add(turns.turnId ?? '') return {} } return {} @@ -257,3 +280,31 @@ describe("a Codex Stop answered before Codex's turn/completed (interrupted)", () await expectInterruptedThroughout() }) }) + +// A message sent while the Stop ends the turn goes out once that turn has ended. Codex answered the +// interrupt, so the turn has aborted even while its turn/completed is still on the wire: the +// message opens its own turn, never a steer Codex would refuse. +describe('a send behind a Codex Stop answered before its turn ends', () => { + it('opens its own turn with turn/start, never turn/steer', async () => { + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + const answer = Promise.withResolvers() + interruptAnswer = answer.promise + const stopping = stop() + await vi.waitFor(() => expect(interrupts).toBe(1)) + + // Accepted behind the Stop on the session's lane, so it reaches Codex only once the Stop has + // answered and the turn reads ended, while Codex's turn/completed is still to come. + const sent = send('run this after the stop') + await vi.waitFor(() => expect(statuses.some((summary) => summary.stopping === true)).toBe(true)) + expect(answers).toBe(1) + answer.resolve() + await stopping + await sent + + await vi.waitFor(() => expect(answers).toBe(2)) + expect(steers).toBe(0) + turns.end('interrupted') + }) +}) From 8a46f7ca1818f58edf15bc9d74852d89070da5a0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 06:53:35 -0700 Subject: [PATCH 104/182] fix(native-chat): a message sent while Stopping is queued as a card, whatever the setting While the chat reads Stopping (the host's flag or this client's own Stop in flight) there is no turn left to steer into, so the desktop asks the host to queue the send even with the queueing setting off, and it is never drawn as a bubble inside the turn being stopped. The phone already queued every send on a capable host; a test now pins that it does so while Stopping. --- ...e-structured-agent-session-queued.test.tsx | 26 ++++++++ .../NativeChatStructuredSession.tsx | 1 + ...tured-agent-session.queued-gating.test.tsx | 64 ++++++++++++++++++- .../use-structured-agent-session.ts | 28 +++++--- 4 files changed, 108 insertions(+), 11 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index 277beb17d75a..c1bc7d40a75c 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -173,6 +173,32 @@ describe('mobile structured queued messages', () => { ) }) + // Nothing is left to steer into while a Stop ends the turn: the send runs after it, as a card. + it('queues a send made while this phone reads Stopping', async () => { + sendRequest.mockImplementation(async (method) => { + if (method === 'agentSession.cancel') { + return new Promise(() => {}) + } + if (method === 'agentSession.send') { + return mutationOk({ + clientMessageId: 'client-1', + queued: { messageId: 'client-1', position: 1, state: 'waiting' } + }) + } + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + }) + await mountSession(CAPABLE, snapshotEvent({ runningTurn: true })) + act(() => hook!.cancel()) + await vi.waitFor(() => expect(hook!.turnIndicator.stopping).toBe(true)) + + await act(async () => { + expect(await hook!.sendWithOutcome('run this after the stop')).toBe('queued') + }) + + expect(calls('agentSession.send')).toHaveLength(1) + expect(requestOf('agentSession.send').params.delivery).toBe('queue-if-active') + }) + it('keeps today’s request exactly against an incapable host', async () => { sendRequest.mockImplementation(async (method) => { if (method === 'agentSession.send') { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 59817ebc6225..881afb0b6b3c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -56,6 +56,7 @@ export function NativeChatStructuredSession( ...props, composerScopeKey: paneKey, queueFollowUps, + hostStopping: hostExecution.stopping, providerStarting: hostExecution.phase === 'starting', transportEnabled: provisionalLaunch.transportEnabled, ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index ebfd23060bbf..be6f67f6e2af 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -7,7 +7,7 @@ // and no draft text ever rides an answer. A host-held draft is a card above // the composer, never a transcript bubble. -import { act, renderHook } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' @@ -92,7 +92,7 @@ function draft(id: string): AgentSessionQueuedMessage { } } -function render(queueFollowUps?: boolean) { +function render(queueFollowUps?: boolean, hostStopping?: boolean) { return renderHook(() => useStructuredAgentSession({ sessionId: 'session-1', @@ -100,7 +100,8 @@ function render(queueFollowUps?: boolean) { target: { kind: 'local' }, isVisible: true, composerScopeKey: 'scope-1', - ...(queueFollowUps === undefined ? {} : { queueFollowUps }) + ...(queueFollowUps === undefined ? {} : { queueFollowUps }), + ...(hostStopping === undefined ? {} : { hostStopping }) }) ) } @@ -158,6 +159,45 @@ describe('against a capable host', () => { }) }) + // Nothing is left to steer into while a Stop ends the turn: a send runs after it, as a card. + it('queues a send while the host reads stopping, with the setting off', () => { + render(false, true) + expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({ + capability: 'supported', + enabled: true + }) + }) + + it("queues a send while this client's own Stop is in flight, with the setting off", async () => { + const cancel = Promise.withResolvers() + mocks.call.mockImplementation(async (_target, method) => + method === 'agentSession.cancel' ? cancel.promise : null + ) + const { result } = render(false) + let stopped: Promise = Promise.resolve() + act(() => { + stopped = result.current.stop() + }) + await waitFor(() => expect(mocks.outboxArgs.at(-1)?.queueDelivery?.enabled).toBe(true)) + await act(async () => { + cancel.resolve({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'e', sequence: 1 }, + value: { cancelled: true } + }) + await stopped + }) + expect(mocks.outboxArgs.at(-1)?.queueDelivery?.enabled).toBe(false) + }) + + it('sends immediately with the setting off once the session is not working', () => { + items = [] + render(false, true) + expect(mocks.outboxArgs.at(-1)?.queueDelivery?.enabled).toBe(false) + }) + it('Stop is a plain cancel: drafts stay as cards and no text lands in the composer', async () => { queuedMessages = [{ ...draft('draft-1'), paused: true }] const { result } = render(false) @@ -272,6 +312,24 @@ describe('against a capable host', () => { expect(JSON.stringify(idleUnheld.result.current.messages)).toContain('awaiting the answer') }) + it('draws a send made while stopping as no bubble in the turn the Stop ends, with the setting off', () => { + outboxEntries = [ + createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'while-stopping', + sessionId: 'session-1', + text: 'run this after the stop', + attachments: [], + queuedAt: 1 + }) + ] + const stopping = render(false, true) + expect(JSON.stringify(stopping.result.current.messages)).not.toContain( + 'run this after the stop' + ) + const working = render(false, false) + expect(JSON.stringify(working.result.current.messages)).toContain('run this after the stop') + }) + it('shows host-held drafts as cards, never as transcript bubbles', () => { queuedMessages = [draft('draft-1')] const { result } = render() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index fff3df7f7976..6ee83db75cc1 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -39,6 +39,7 @@ import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent- import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' +import { agentStopDisplayStatus } from '../../../../shared/agent-stop-display-status' export type { StructuredPromptItem } from './structured-agent-session-message-projection' @@ -58,10 +59,13 @@ export function useStructuredAgentSession(args: { composerScopeKey?: string /** The chat-wide "queue follow-ups" setting; off keeps mid-turn sends immediate. */ queueFollowUps?: boolean + /** The host says a person's Stop is still ending this session's work. */ + hostStopping?: boolean }) { const { agent, composerScopeKey, + hostStopping = false, isVisible, launch, providerStarting = false, @@ -116,13 +120,26 @@ export function useStructuredAgentSession(args: { () => (transportState.queuedMessages ?? []).map((message) => message.messageId), [transportState.queuedMessages] ) + const stopPress = useStructuredAgentSessionStopPress(sessionId) + // While a Stop ends the turn there is nothing to steer into: a send is queued to run after it, + // whatever the setting. + const stopping = + agentStopDisplayStatus({ + working: transportState.isWorking, + hostStopping, + stopPressed: stopPress.pressed + }) === 'stopping' + const queueDelivery = useMemo( + () => ({ capability: queueCapability, enabled: queueFollowUps || stopping }), + [queueCapability, queueFollowUps, stopping] + ) const outboxController = useStructuredAgentSessionOutbox({ sessionId, target, fence: transportState.fence, submissions: transportState.submissions, composerScopeKey, - queueDelivery: { capability: queueCapability, enabled: queueFollowUps }, + queueDelivery, queuedMessageIds }) @@ -143,7 +160,6 @@ export function useStructuredAgentSession(args: { enabled: providerVisible }) - const stopPress = useStructuredAgentSessionStopPress(sessionId) const prompts = pendingStructuredSessionPrompts(transportState.journalItems) const { outbox } = outboxController // A host that takes a Stop naming no turn gets Stop from the send until the work settles; every @@ -158,12 +174,8 @@ export function useStructuredAgentSession(args: { // A queued send is a card, never a transcript bubble. const isWorking = transportState.isWorking const transcriptOutbox = useMemo( - () => - outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, { - capability: queueCapability, - enabled: queueFollowUps - }), - [isWorking, outbox, queueCapability, queueFollowUps, queuedMessageIds] + () => outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, queueDelivery), + [isWorking, outbox, queueDelivery, queuedMessageIds] ) const messages = useStructuredAgentSessionMessages( transportState.journalItems, From 3bdb4de0312dd673aaadb620c37903e68fc16595 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:12:48 -0700 Subject: [PATCH 105/182] fix(native-chat): a message queued while Stopping is a card at once, not after the stop A person's Stop holds the session's lane until Codex answers its interrupt, and a send was admitted only behind it. By then the turn read ended, so a send that asked to be queued went out plain: no card for the whole of Stopping, then a bubble and a new turn. While the host reads that a person's Stop is ending the work, a text send that asks to be queued is admitted without waiting for the lane: the same ledger and lease admission, and a plan that only writes the card through the journal's ordered writer. The card runs when the stop lands; the Stop's pause holds only cards queued before it. Anything else, including a Stop that settled by the time the send runs, takes the lane as before. The Codex test fake now drops the active turn when it takes an interrupt, as Codex does before it answers, so a turn/start after the answer opens a new turn. --- src/main/codex/codex-turn-lifecycle-fake.ts | 36 ++++-- ...structured-agent-session-host-mutations.ts | 77 +++++++++--- .../structured-agent-session-host.ts | 1 + ...ructured-agent-session-mutation-context.ts | 41 +++++-- ...tructured-agent-session-queued-messages.ts | 18 ++- ...gent-session-codex-interrupt-order.test.ts | 111 +++++++++++++++--- 6 files changed, 223 insertions(+), 61 deletions(-) diff --git a/src/main/codex/codex-turn-lifecycle-fake.ts b/src/main/codex/codex-turn-lifecycle-fake.ts index dda6060ee3a6..e0747f38b0b0 100644 --- a/src/main/codex/codex-turn-lifecycle-fake.ts +++ b/src/main/codex/codex-turn-lifecycle-fake.ts @@ -29,7 +29,10 @@ export type CodexTurnLifecycleFake = { /** The thread runs the picked turn, whose `turn/started` has yet to be read: an interrupt of it * is taken, and that `turn/started` comes ahead of the answer. */ run: () => void - /** Codex ends the picked or running turn on its own. */ + /** Codex takes an interrupt of the running turn: it drops it as its active turn before it + * answers, and that turn's `turn/completed` comes later (`end`). */ + takeInterrupt: () => string + /** Codex ends the picked or running turn on its own, or the one whose interrupt it took. */ end: (status: 'completed' | 'interrupted' | 'failed', errorMessage?: string) => void /** Codex fails the picked turn before starting it, which reports an `error` and no turn end. */ failUnopened: (message: string) => void @@ -59,15 +62,32 @@ export function codexTurnLifecycleFake( let lastTurn: string | null = null let held: Promise | null = null let pickedRuns = false + // The turn whose interrupt Codex took and whose `turn/completed` is still to come. + let aborting: string | null = null const finish = (turnId: string, status: string, errorMessage?: string): void => { - picked = null - active = null - pickedRuns = false + // A taken interrupt's turn is no longer active: a turn picked since stays picked. + if (aborting === turnId) { + aborting = null + } else { + picked = null + active = null + pickedRuns = false + } notify()('turn/completed', { threadId, turn: { id: turnId, status, ...(errorMessage ? { error: { message: errorMessage } } : {}) } }) } + + const takeInterrupt = (): string => { + if (!active) { + throw new Error('no running turn to interrupt') + } + aborting = active + active = null + picked = null + return aborting + } const open = (): void => { if (!picked) { throw new Error('no picked turn to start') @@ -117,10 +137,10 @@ export function codexTurnLifecycleFake( `expected active turn id ${String(turnId)} but found ${active}` ) } - const aborted = active + const aborted = takeInterrupt() // Answered, then the turn's end on the next read; a turn that ends meanwhile is not ended twice. setTimeout(() => { - if (active === aborted) { + if (aborting === aborted) { finish(aborted, 'interrupted') } }, 0) @@ -135,6 +155,7 @@ export function codexTurnLifecycleFake( return () => release() }, start: () => open(), + takeInterrupt, run: () => { if (!picked || active) { throw new Error('no picked turn to run') @@ -142,7 +163,8 @@ export function codexTurnLifecycleFake( pickedRuns = true }, end: (status, errorMessage) => { - const turnId = active ?? picked + // A taken interrupt's turn/completed comes ahead of anything a later turn sends. + const turnId = aborting ?? active ?? picked if (!turnId) { throw new Error('no turn to end') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 3fbb96ddaf87..11e1394888f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -22,8 +22,14 @@ import type { AgentSessionPromptRequest } from './structured-agent-session-turns import { threadGoalPlan } from './structured-agent-session-thread-goal' import { mutateStructuredAgentSession, + mutateStructuredAgentSessionOffLane, type StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' +import { + maybeQueueStructuredAgentSessionSend, + queuedMessageBodyIsTextOnly +} from './structured-agent-session-queued-messages' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { openForProviderWrite, openWithAgent, @@ -64,23 +70,60 @@ export function sendStructuredAgentSessionTurn( } ): Promise> { const plan = sendPlan(params) - return mutateStructuredAgentSession( - context, - caller, - params.envelope, - { - ...plan, - run: (ctx) => - runQueueableStructuredAgentSessionSend( - context, - ctx, - params, - async () => - structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) ?? - (await plan.run(ctx)) - ) - }, - sendPreparation(context, params.envelope) + const preparation = sendPreparation(context, params.envelope) + const queueable = (ctx: AgentSessionTurnContext) => + runQueueableStructuredAgentSessionSend( + context, + ctx, + params, + async () => + structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) ?? + (await plan.run(ctx)) + ) + if (!sendQueuesAfterStop(context, params)) { + return mutateStructuredAgentSession( + context, + caller, + params.envelope, + { ...plan, run: queueable }, + preparation + ) + } + // A message sent while a person's Stop ends the work is a card that runs once the stop lands. Its + // write has no order to keep with the Stop, which holds the lane until its provider answers. + return mutateStructuredAgentSessionOffLane(context, caller, params.envelope, { + ...plan, + run: async (ctx) => { + // Read again once admitted, in the same tick as the write: the Stop may have settled since. + const queued = sendQueuesAfterStop(context, params) + ? await maybeQueueStructuredAgentSessionSend(context, ctx, params, true) + : null + return ( + queued ?? + // Anything else takes the lane, at the fence it stands at there, as every send does. + context.serialize(ctx.sessionId, async () => { + const prepared = await preparation() + const fence = context.deps.store.getRecord(ctx.sessionId)?.lease.runtimeFence + return prepared.ok ? queueable({ ...ctx, fence: fence ?? ctx.fence }) : prepared + }) + ) + } + }) +} + +/** A text send asking to be queued, while the host reads that a person's Stop is ending the work. */ +function sendQueuesAfterStop( + context: StructuredAgentSessionMutationContext, + params: { + envelope: AgentSessionMutationEnvelope + body: AgentJournalMessageItem + delivery?: 'queue-if-active' + } +): boolean { + return ( + params.delivery === 'queue-if-active' && + queuedMessageBodyIsTextOnly(params.body) && + context.readStopping?.(params.envelope.sessionId) === true ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 0536f619c220..efa2811b3582 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -288,6 +288,7 @@ export class StructuredAgentSessionHost { wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), + readStopping: this.clientDelivery.readStopping, now: () => this.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index e77975dc5c22..17996bb2cf76 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -44,6 +44,9 @@ export type StructuredAgentSessionMutationContext = { * journal commit (a conversation command). Draft-table changes need no call: * the draft store notifies through the journal's own commit listener. */ wakeQueuedDrain?: (sessionId: string) => void + /** The host's reading that a person's Stop is still ending the session's work, as the status + * feed projects it from the journal as it stands. */ + readStopping?: (sessionId: string) => boolean now: () => number } @@ -56,18 +59,30 @@ export function mutateStructuredAgentSession( prepareSession?: AgentSessionMutationRequest['prepareSession'] ): Promise> { return context.serialize(envelope.sessionId, () => - admitAndRunAgentSessionMutation({ - store: context.deps.store, - adapter: context.deps.adapter, - logger: context.deps.logger, - callerKey: caller.callerKey, - envelope, - plan, - journal: () => context.sessions.get(envelope.sessionId)?.journal, - prepareSession, - publish: (journal) => context.publish(envelope.sessionId, journal), - providerChildPhase: () => context.sessions.get(envelope.sessionId)?.child?.phase, - now: () => context.now() - }) + mutateStructuredAgentSessionOffLane(context, caller, envelope, plan, prepareSession) ) } + +/** Admits the envelope and runs the plan without the session's serialize: for a plan that only + * writes the journal, through its own ordered writer, and touches nothing the lane orders. */ +export function mutateStructuredAgentSessionOffLane( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan, + prepareSession?: AgentSessionMutationRequest['prepareSession'] +): Promise> { + return admitAndRunAgentSessionMutation({ + store: context.deps.store, + adapter: context.deps.adapter, + logger: context.deps.logger, + callerKey: caller.callerKey, + envelope, + plan, + journal: () => context.sessions.get(envelope.sessionId)?.journal, + prepareSession, + publish: (journal) => context.publish(envelope.sessionId, journal), + providerChildPhase: () => context.sessions.get(envelope.sessionId)?.child?.phase, + now: () => context.now() + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index ac1fdbb0617c..8d8173629cfb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -194,7 +194,10 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' - } + }, + /** Sent while a person's Stop ends the work: queued to run when the stop lands, whatever the + * session reads, short of a hold that refuses any send. */ + afterStop = false ): Promise< | { ok: true; value: AgentSessionSendResult } | { ok: false; refusal: AgentSessionWireRefusal } @@ -215,13 +218,16 @@ export async function maybeQueueStructuredAgentSessionSend( return null } // A newer Orca's journal takes no new draft: the immediate path refuses the send. + const hold = { + journal: ctx.journal, + record: context.deps.store.getRecord(ctx.sessionId), + fence: ctx.fence + } if ( ctx.journal.isReadOnly || - !shouldQueueStructuredAgentSessionSend({ - journal: ctx.journal, - record: context.deps.store.getRecord(ctx.sessionId), - fence: ctx.fence - }) + (afterStop + ? structuredQueueHold(hold) === 'blocked' + : !shouldQueueStructuredAgentSessionSend(hold)) ) { return null } diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index 5d122042af90..5f14fb8cb199 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -8,12 +8,12 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - CodexAppServerRequestError, - type CodexAppServerConnection, - type CodexAppServerConnectionHandlers, - type openCodexAppServerConnection +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection } from '../codex/codex-app-server-connection' +import { readCodexTurnId } from '../codex/codex-structured-thread-facts' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import type { AgentJournalTurnItem } from '../../shared/agent-session-journal-types' @@ -57,7 +57,8 @@ let turns: ReturnType let statuses: AgentSessionStatusSummary[] let opened: Set let steers: number -let aborted: Set +/** The turn each turn/start answered into, in order. */ +let startedTurns: string[] /** When set, Codex answers the interrupt only once it resolves. */ let interruptAnswer: Promise | null let operations = 0 @@ -87,6 +88,34 @@ async function send(text: string): Promise { return sent.value.clientMessageId } +/** A send asking to be queued while the agent works, under `clientOperationId` when it retries. */ +async function sendQueued(text: string, clientOperationId?: string) { + const body = hostTestMessage(text) + const delivery = 'queue-if-active' as const + const fields = { body, delivery } + const sent = await host.send(CALLER, { + envelope: { + ...envelope('agentSession.send', fields), + ...(clientOperationId ? { clientOperationId } : {}) + }, + body, + delivery, + userSend: true + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value +} + +async function queuedCards() { + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return page.page.queuedMessages ?? [] +} + async function stop(turnId?: string): Promise { const stopped = await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', turnId === undefined ? {} : { turnId }), @@ -155,7 +184,7 @@ beforeEach(async () => { interrupts = 0 opened = new Set() steers = 0 - aborted = new Set() + startedTurns = [] interruptAnswer = null turns = codexTurnLifecycleFake(THREAD, () => (method, params) => { if (method === 'turn/started') { @@ -180,19 +209,12 @@ beforeEach(async () => { } if (method === 'turn/start') { answers += 1 - return turns.routes['turn/start']() + const answer = await turns.routes['turn/start']() + startedTurns.push(readCodexTurnId(answer) ?? '') + return answer } if (method === 'turn/steer') { steers += 1 - // A turn Codex answered an interrupt for has aborted: Codex refuses to steer it. - if (turns.turnId === null || aborted.has(turns.turnId)) { - throw new CodexAppServerRequestError( - 'turn/steer', - -32600, - 'codex app-server turn/steer failed: no active turn to steer', - 'no active turn to steer' - ) - } return turns.routes['turn/steer'](params) } if (method === 'turn/interrupt') { @@ -203,7 +225,8 @@ beforeEach(async () => { turns.start() } await interruptAnswer - aborted.add(turns.turnId ?? '') + // Codex drops the turn as its active one before it answers; turn/completed comes later. + turns.takeInterrupt() return {} } return {} @@ -305,6 +328,58 @@ describe('a send behind a Codex Stop answered before its turn ends', () => { await vi.waitFor(() => expect(answers).toBe(2)) expect(steers).toBe(0) + // Its own turn, not folded into the aborted one, and the message is not lost. + expect(startedTurns).toEqual(['turn-1', 'turn-2']) + turns.end('interrupted') + const clientMessageId = await sent + const submission = (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) + expect(submission?.dispatchState).not.toBe('rejected') + }) +}) + +// A message sent asking to be queued while a person's Stop ends the turn is a card at once, while +// the Stop still waits on Codex, and runs as its own turn once the stopped turn has ended. +describe('a queued send while a Codex Stop waits on its answer', () => { + it('is a card at once, held by no pause, and opens its own turn after the stop', async () => { + const first = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(first) + const answer = Promise.withResolvers() + interruptAnswer = answer.promise + let stopped = false + const stopping = stop().then(() => { + stopped = true + }) + await vi.waitFor(() => expect(statuses.at(-1)?.stopping).toBe(true)) + + const queued = await sendQueued('run this after the stop') + + expect(stopped).toBe(false) + expect(queued).toMatchObject({ queued: { state: 'waiting' } }) + const messageId = 'queued' in queued ? queued.queued.messageId : '' + expect(await queuedCards()).toEqual([expect.objectContaining({ messageId, state: 'waiting' })]) + expect((await queuedCards())[0]).not.toHaveProperty('paused') + // A retry of the same operation answers the same card, on the lane or off it. + expect(await sendQueued('run this after the stop', messageId)).toEqual(queued) + + answer.resolve() + await stopping turns.end('interrupted') + + await vi.waitFor(() => expect(answers).toBe(2)) + expect(startedTurns).toEqual(['turn-1', 'turn-2']) + expect(steers).toBe(0) + // Retried once handed off, on the lane, it answers with the card's hand-off, as any queued send. + expect(await sendQueued('run this after the stop', messageId)).toMatchObject({ + submission: { queuedMessageId: messageId } + }) + const handOff = (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.queuedMessageId === messageId + ) + expect(handOff?.dispatchState).not.toBe('rejected') + expect(await queuedCards()).toEqual([]) }) }) From a2ba0c564f59d3a1aca5cee07399e5741c409320 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:29:05 -0700 Subject: [PATCH 106/182] fix(native-chat): a Codex Stop that ends the child before any turn opened withdraws its send A Stop on a Codex turn that was answered but never opened ends the Codex process. That end settled the send as in doubt (unknown, recovered), and the client's outbox holds every later send behind a send in doubt until the person presses Retry, which re-sends the very message they stopped. The chat looked stuck. Codex records a prompt only once its turn has started, so a send whose turn never opened never ran. When the Stop's refusal says so (turnMayOpen), the child end now settles the unanswered sends as withdrawn, the verdict Codex's own interrupted-turn end already gives an unechoed send. The flag rides on the owed wind-down, so a retry after a failed child end withdraws them too. Claude's child end still leaves its unanswered send in doubt. --- .../structured-agent-session-chat-stop.ts | 3 +- ...gent-session-dead-generation-settlement.ts | 8 +- .../structured-agent-session-host-lifetime.ts | 22 +++++- ...tructured-agent-session-host-stop-event.ts | 7 +- .../structured-agent-session-host-types.ts | 3 + .../structured-agent-session-turns-cancel.ts | 4 +- ...-session-codex-turn-end-settlement.test.ts | 51 ++++++++++++ ...gent-session-outbox-withdrawn-send.test.ts | 78 +++++++++++++++++++ 8 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 src/shared/structured-agent-session-outbox-withdrawn-send.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index b55ebbda2d79..023a9eee0572 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -60,7 +60,8 @@ export function mutateWithChatStop( let eventAfterEnd: Promise | undefined const named = turnId !== undefined ? { turnId } : {} // Its own step wrote the Stop's event first. - const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) + const stopChild = (options: { noTurnOpened?: true } = {}) => + context.stopAgent(sessionId, { recorded: 'user-stop', ...options }) // The same for every client: once the Stop takes effect its event is written, and the queue's // pause follows from it. The cards stay published; no text rides the answer. const stop = (ctx: AgentSessionTurnContext): Promise => 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 39ff1c7ba514..3f90c968bda0 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 @@ -17,6 +17,7 @@ import { cancelledJournalPromptBody } from '../agent-session-journal/journal-pro import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { agentSessionFailureWords, + type AgentJournalDispatchRejection, type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' @@ -116,6 +117,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { settlementId: string verdict: StructuredAgentSessionTurnVerdict pendingSubmissionReason: string + /** Settles the unacknowledged sends as this rejection instead of in doubt. */ + withdrawPending?: AgentJournalDispatchRejection showUnexpectedExitOutcome?: boolean /** Why the provider stopped, as the adapter told it; the row's sentence is this fact's. */ exitFailure?: SubmissionRejectionFact @@ -138,8 +141,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { const startupFailure = input.exitedDuringStartup ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) : null - await (startupFailure - ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) + const rejection = startupFailure ?? input.withdrawPending + await (rejection + ? input.journal.rejectPendingSubmissions(input.fence, rejection) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 2c312ef17e84..453967498241 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -106,7 +106,8 @@ function owedProviderChildWindDown( function owedStop( session: StructuredAgentSessionHostSession, cause: StructuredAgentSessionStopCause, - retry: boolean + retry: boolean, + noTurnOpened: boolean ): StructuredAgentSessionOwedWindDown | undefined { const owed = owedProviderChildWindDown(session) if (!owed) { @@ -118,7 +119,8 @@ function owedStop( generation: owed.generation, fence: owed.fence, cause, - requestedAt: continues ? asked.requestedAt : session.journal.cursor() + requestedAt: continues ? asked.requestedAt : session.journal.cursor(), + ...(noTurnOpened ? { noTurnOpened: true as const } : {}) } } @@ -147,7 +149,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( const asked = 'recorded' in ending ? ending.recorded : ending.cause // A retry finishes the stop that ended the child, so the child's end keeps that stop's cause. const cause = session.child ? asked : (session.owesProviderChildWindDown?.cause ?? asked) - const owed = owedStop(session, cause, ending.retry === true) + const noTurnOpened = 'recorded' in ending && ending.noTurnOpened === true + const owed = owedStop(session, cause, ending.retry === true, noTurnOpened) session.owesProviderChildWindDown = owed const stopping = session.child const eviction: StructuredAgentSessionEvictionContext = { @@ -190,6 +193,13 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( fence, settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', + ...(noTurnOpened + ? { + withdrawPending: agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + } + : {}), // Only a turn no adapter settled: one with no close, or whose settle threw. Whether it was // a person's Stop is its event's to say (`turnEndAfterStop`). verdict: { state: 'interrupted', completedAt: context.now() }, @@ -261,7 +271,11 @@ export async function finishOwedStructuredAgentSessionWindDownUnderSerialize( context, sessionId, owed.cause === 'user-stop' - ? { recorded: 'user-stop', retry: true } + ? { + recorded: 'user-stop', + retry: true, + ...(owed.noTurnOpened ? { noTurnOpened: true } : {}) + } : { cause: owed.cause, retry: true } ) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts index 70cfd07bb7a6..3a5daade6638 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts @@ -14,7 +14,12 @@ import { sentSinceStop } from './structured-agent-session-queued-stop' * own step (`recorded` names its reason); any other stop names the reason its event records, with * the host's text for it. Quit writes none: its resume marker's trigger records why. */ export type StructuredAgentSessionStopEnding = ( - | { recorded: 'user-stop' } + | { + recorded: 'user-stop' + /** The provider said no turn of this child opened: the sends it leaves unanswered never ran, + * so the Stop withdraws them. */ + noTurnOpened?: true + } | { cause: Exclude reason?: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 6552f8a26016..1bc532361e8f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -45,6 +45,9 @@ export type StructuredAgentSessionOwedWindDown = StructuredAgentSessionProviderC readonly requestedAt: AgentJournalCursor /** Where it stood once the newest pass failed: a message accepted by then waited through a retry. */ readonly failedAt?: AgentJournalCursor + /** A person's Stop the provider answered with no turn of this child open: a retry withdraws + * the unanswered sends as that Stop would have. */ + readonly noTurnOpened?: true } /** The provider process behind a conversation. Written only in diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 187b934400df..5a5c3fdd8201 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -168,7 +168,7 @@ type PerformCancelInput = { prompt?: { itemId: string; expectedRevision: number } /** Ends the provider child, for a running command the provider did not take the Stop on, or a * turn whose interrupt failed. */ - stopChild?: () => Promise + stopChild?: (options?: { noTurnOpened?: true }) => Promise /** A child end after a failed interrupt that threw. */ onStopChildError?: (error: unknown) => void /** After that throw: whether the host let go of the child, its exit proven before a later @@ -306,7 +306,7 @@ async function cancelAndNote( // The interrupt failed and the turn runs on: only the child's end stops it. let ended: boolean try { - await input.stopChild() + await input.stopChild(refusal?.turnMayOpen ? { noTurnOpened: true } : {}) ended = true } catch (error) { input.onStopChildError?.(error) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 7502ab93b77c..5b4f819ace43 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -78,6 +78,9 @@ let answers: number let steers: number let interrupts: number let childCloses: number +let connections: number +/** Closes that fail before one goes through, as a kill that times out. */ +let failingCloses: number let turns: ReturnType let operations = 0 @@ -176,6 +179,8 @@ beforeEach(async () => { steers = 0 interrupts = 0 childCloses = 0 + connections = 0 + failingCloses = 0 turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) @@ -185,6 +190,7 @@ beforeEach(async () => { connectionHandlers = {} ) => { handlers = connectionHandlers + connections += 1 const connection: CodexAppServerConnection = { pid: 4321, closed: false, @@ -213,6 +219,10 @@ beforeEach(async () => { respond: () => {}, respondWithError: () => {}, close: async () => { + if (failingCloses > 0) { + failingCloses -= 1 + throw new Error('the kill timed out') + } childCloses += 1 return true } @@ -566,6 +576,47 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { expect((await settled()).owesWork).toBe(false) expect(turnRow((await host.journalSnapshot(SESSION)).items)).toBeUndefined() }) + + // Codex records a prompt only once its turn starts, so a send whose turn never opened never ran: + // the Stop takes it back, and no send in doubt holds the client's queue. + async function stoppedBeforeItsTurnOpened(): Promise { + const release = turns.holdNextAnswer() + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + release() + expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + return sent + } + + async function nextSendStartsANewChild(): Promise { + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params) + ) + await send('try again') + await vi.waitFor(() => expect(answers).toBe(2)) + expect(connections).toBe(2) + } + + it('withdraws the send whose turn never opened, and the next send starts a new child', async () => { + const sent = await stoppedBeforeItsTurnOpened() + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + await nextSendStartsANewChild() + }) + + it('withdraws it too when the child end fails and a later retry lands it', async () => { + failingCloses = 1 + const sent = await stoppedBeforeItsTurnOpened() + expect(childCloses).toBe(0) + + await nextSendStartsANewChild() + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + }) }) describe('a cold send with no Stop behind it', () => { diff --git a/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts b/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts new file mode 100644 index 000000000000..ff127c8baabf --- /dev/null +++ b/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts @@ -0,0 +1,78 @@ +// A send the host withdrew (a Stop took it back before it ran) leaves the outbox with no notice, so +// the next queued send goes out. One the host left in doubt holds the queue until its Retry. + +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { + createStructuredAgentSessionOutboxEntry, + reconcileStructuredAgentSessionOutbox, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' +import { admitStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-admission' + +function entry( + clientMessageId: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: 'session-1', + text: clientMessageId, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +function submission(patch: Partial): AgentJournalSubmission { + return { + clientMessageId: 'stopped', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + ...patch + } +} + +const OUTBOX = [entry('stopped', { state: 'dispatching', lastAttemptAt: 1 }), entry('next')] + +describe('the send a Stop ended before its turn opened', () => { + it('leaves the outbox when withdrawn, and the next send goes out', () => { + const withdrawn = submission({ + dispatchState: 'rejected', + recovered: true, + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) + + const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [withdrawn]) + + expect(reconciled.map((each) => each.clientMessageId)).toEqual(['next']) + expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ + state: 'dispatch', + entry: { clientMessageId: 'next' } + }) + }) + + it('holds the queue when left in doubt', () => { + const inDoubt = submission({ + dispatchState: 'unknown', + recovered: true, + reason: 'provider_closed_before_acknowledgement' + }) + + const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [inDoubt]) + + expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ + state: 'blocked', + entry: { clientMessageId: 'stopped' } + }) + }) +}) From a6b8185302bcd588d04e76758666c4a0426beb90 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:40:59 -0700 Subject: [PATCH 107/182] fix(native-chat): derive the withdrawal of a Codex send whose turn never opened Replaces the flag the Stop carried to the child's end, and its copy on the owed wind-down, with a reading of the journal at the settlement that lands. A Codex child's unanswered send is withdrawn when a person's Stop is in force since it was sent and no turn row ran, or was written, after it; any other end (a turn that opened, a host's close, a crash, Claude) still leaves it in doubt. A retried wind-down reads the same rows, so it withdraws the same sends. --- .../agent-session-journal/journal-store.ts | 4 ++ .../structured-agent-session-chat-stop.ts | 3 +- ...gent-session-dead-generation-settlement.ts | 24 +++---- .../structured-agent-session-host-lifetime.ts | 22 ++---- ...tructured-agent-session-host-stop-event.ts | 7 +- .../structured-agent-session-host-types.ts | 3 - .../structured-agent-session-turns-cancel.ts | 4 +- ...-agent-session-unopened-send-withdrawal.ts | 72 +++++++++++++++++++ ...-session-codex-turn-end-settlement.test.ts | 45 ++++++++++-- 9 files changed, 135 insertions(+), 49 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts 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 5a4880155dec..67a2d9d04026 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -155,6 +155,10 @@ export class AgentSessionJournal { return this.state.epoch } + get agent(): AgentSessionJournalIdentity['agent'] { + return this.identity.agent + } + /** Whether a row at this sequence was on disk when this handle opened, so an earlier handle * wrote it. Sequences restart with each epoch, so a row of a later epoch never was. */ wroteBeforeOpen(sequence: number | undefined): boolean { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 023a9eee0572..b55ebbda2d79 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -60,8 +60,7 @@ export function mutateWithChatStop( let eventAfterEnd: Promise | undefined const named = turnId !== undefined ? { turnId } : {} // Its own step wrote the Stop's event first. - const stopChild = (options: { noTurnOpened?: true } = {}) => - context.stopAgent(sessionId, { recorded: 'user-stop', ...options }) + const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) // The same for every client: once the Stop takes effect its event is written, and the queue's // pause follows from it. The cards stay published; no text rides the answer. const stop = (ctx: AgentSessionTurnContext): Promise => 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 3f90c968bda0..116e198d00ba 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 @@ -17,7 +17,6 @@ import { cancelledJournalPromptBody } from '../agent-session-journal/journal-pro import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { agentSessionFailureWords, - type AgentJournalDispatchRejection, type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' @@ -38,23 +37,20 @@ import { exitedRootTurnScope, runningRootTurnScope } from './structured-agent-session-exit-turn-scope' +import { + withdrawCodexSendsNoTurnOpenedFor, + type UnopenedSendJournal +} from './structured-agent-session-unopened-send-withdrawal' /** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the * same cap. */ export const MAX_UNEXPECTED_EXIT_REASON_CHARS = MAX_PROVIDER_DIAGNOSTIC_CHARS -type DeadGenerationSubmission = Pick< - ReturnType[number], - 'clientMessageId' | 'dispatchState' | 'recovered' | 'handoverRecorded' | 'handedOverAt' -> - -export type DeadGenerationJournal = { +export type DeadGenerationJournal = UnopenedSendJournal & { appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] - snapshot: () => Pick, 'items'> pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] - submissions?: () => DeadGenerationSubmission[] } export type StructuredAgentSessionUnfinishedWork = { @@ -117,8 +113,6 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { settlementId: string verdict: StructuredAgentSessionTurnVerdict pendingSubmissionReason: string - /** Settles the unacknowledged sends as this rejection instead of in doubt. */ - withdrawPending?: AgentJournalDispatchRejection showUnexpectedExitOutcome?: boolean /** Why the provider stopped, as the adapter told it; the row's sentence is this fact's. */ exitFailure?: SubmissionRejectionFact @@ -141,9 +135,11 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { const startupFailure = input.exitedDuringStartup ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) : null - const rejection = startupFailure ?? input.withdrawPending - await (rejection - ? input.journal.rejectPendingSubmissions(input.fence, rejection) + if (!startupFailure) { + await withdrawCodexSendsNoTurnOpenedFor(input.journal, input.fence) + } + await (startupFailure + ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) const items = input.journal.snapshot().items const mutations: JournalLifecycleMutationInput[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 453967498241..2c312ef17e84 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -106,8 +106,7 @@ function owedProviderChildWindDown( function owedStop( session: StructuredAgentSessionHostSession, cause: StructuredAgentSessionStopCause, - retry: boolean, - noTurnOpened: boolean + retry: boolean ): StructuredAgentSessionOwedWindDown | undefined { const owed = owedProviderChildWindDown(session) if (!owed) { @@ -119,8 +118,7 @@ function owedStop( generation: owed.generation, fence: owed.fence, cause, - requestedAt: continues ? asked.requestedAt : session.journal.cursor(), - ...(noTurnOpened ? { noTurnOpened: true as const } : {}) + requestedAt: continues ? asked.requestedAt : session.journal.cursor() } } @@ -149,8 +147,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( const asked = 'recorded' in ending ? ending.recorded : ending.cause // A retry finishes the stop that ended the child, so the child's end keeps that stop's cause. const cause = session.child ? asked : (session.owesProviderChildWindDown?.cause ?? asked) - const noTurnOpened = 'recorded' in ending && ending.noTurnOpened === true - const owed = owedStop(session, cause, ending.retry === true, noTurnOpened) + const owed = owedStop(session, cause, ending.retry === true) session.owesProviderChildWindDown = owed const stopping = session.child const eviction: StructuredAgentSessionEvictionContext = { @@ -193,13 +190,6 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( fence, settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', - ...(noTurnOpened - ? { - withdrawPending: agentSessionFailureWords(agentSessionFailureFact('cancelled'), { - surface: 'rejection' - }) - } - : {}), // Only a turn no adapter settled: one with no close, or whose settle threw. Whether it was // a person's Stop is its event's to say (`turnEndAfterStop`). verdict: { state: 'interrupted', completedAt: context.now() }, @@ -271,11 +261,7 @@ export async function finishOwedStructuredAgentSessionWindDownUnderSerialize( context, sessionId, owed.cause === 'user-stop' - ? { - recorded: 'user-stop', - retry: true, - ...(owed.noTurnOpened ? { noTurnOpened: true } : {}) - } + ? { recorded: 'user-stop', retry: true } : { cause: owed.cause, retry: true } ) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts index 3a5daade6638..70cfd07bb7a6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts @@ -14,12 +14,7 @@ import { sentSinceStop } from './structured-agent-session-queued-stop' * own step (`recorded` names its reason); any other stop names the reason its event records, with * the host's text for it. Quit writes none: its resume marker's trigger records why. */ export type StructuredAgentSessionStopEnding = ( - | { - recorded: 'user-stop' - /** The provider said no turn of this child opened: the sends it leaves unanswered never ran, - * so the Stop withdraws them. */ - noTurnOpened?: true - } + | { recorded: 'user-stop' } | { cause: Exclude reason?: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 1bc532361e8f..6552f8a26016 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -45,9 +45,6 @@ export type StructuredAgentSessionOwedWindDown = StructuredAgentSessionProviderC readonly requestedAt: AgentJournalCursor /** Where it stood once the newest pass failed: a message accepted by then waited through a retry. */ readonly failedAt?: AgentJournalCursor - /** A person's Stop the provider answered with no turn of this child open: a retry withdraws - * the unanswered sends as that Stop would have. */ - readonly noTurnOpened?: true } /** The provider process behind a conversation. Written only in diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 5a5c3fdd8201..187b934400df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -168,7 +168,7 @@ type PerformCancelInput = { prompt?: { itemId: string; expectedRevision: number } /** Ends the provider child, for a running command the provider did not take the Stop on, or a * turn whose interrupt failed. */ - stopChild?: (options?: { noTurnOpened?: true }) => Promise + stopChild?: () => Promise /** A child end after a failed interrupt that threw. */ onStopChildError?: (error: unknown) => void /** After that throw: whether the host let go of the child, its exit proven before a later @@ -306,7 +306,7 @@ async function cancelAndNote( // The interrupt failed and the turn runs on: only the child's end stops it. let ended: boolean try { - await input.stopChild(refusal?.turnMayOpen ? { noTurnOpened: true } : {}) + await input.stopChild() ended = true } catch (error) { input.onStopChildError?.(error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts new file mode 100644 index 000000000000..7e1a6a629ce3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts @@ -0,0 +1,72 @@ +// Which unanswered sends a dying Codex child takes with it as never sent, read from the journal at +// the settlement that lands, so a retried wind-down reads the same rows. + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentType } from '../../../shared/agent-status-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +/** What the derivation reads; absent members (a narrow double) withdraw nothing. */ +export type UnopenedSendJournal = { + agent?: AgentType + queuedMessages?: Pick + snapshot: () => Pick, 'items'> + submissions?: () => Pick< + AgentJournalSubmission, + | 'clientMessageId' + | 'dispatchState' + | 'recovered' + | 'handoverRecorded' + | 'handedOverAt' + | 'acceptedSequence' + >[] + resolveDispatch?: AgentSessionJournal['resolveDispatch'] +} + +/** + * Withdraws the sends a Codex child left unanswered when a person's Stop, in force since they were + * sent, ends it and no turn opened for them: no turn row runs, or was written, after the send. + * Codex records a prompt only once its turn starts (core tasks/regular.rs:50, + * session/turn.rs:886-902), so they never ran. Any other end leaves them in doubt. + */ +export async function withdrawCodexSendsNoTurnOpenedFor( + journal: UnopenedSendJournal, + fence: number +): Promise { + const stop = journal.agent === 'codex' ? journal.queuedMessages?.userStopInForce() : null + if (!stop || !journal.submissions || !journal.resolveDispatch) { + return + } + const turns = journal.snapshot().items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn ? [{ running: turn.state === 'running', sequence: item.sequence }] : [] + }) + const opened = (acceptedSequence: number): boolean => + turns.some((turn) => turn.running || turn.sequence > acceptedSequence) + const unopened = journal + .submissions() + .filter( + (entry) => + !isQueuedAgentJournalSubmission(entry) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true)) && + entry.acceptedSequence !== undefined && + entry.acceptedSequence < stop.sequence && + !opened(entry.acceptedSequence) + ) + const withdrawn = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + for (const entry of unopened) { + await journal.resolveDispatch({ + clientMessageId: entry.clientMessageId, + state: 'rejected', + ...withdrawn, + fence, + recovered: true + }) + } +} diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 5b4f819ace43..66c25017b296 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -10,10 +10,11 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - CodexAppServerConnection, - CodexAppServerConnectionHandlers, - openCodexAppServerConnection +import { + CodexAppServerRequestError, + type CodexAppServerConnection, + type CodexAppServerConnectionHandlers, + type openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' import { settledWithin } from '../codex/codex-structured-dispatch-test-support' @@ -81,6 +82,8 @@ let childCloses: number let connections: number /** Closes that fail before one goes through, as a kill that times out. */ let failingCloses: number +/** Codex fails every interrupt it is sent, as one it could not submit (-32603). */ +let interruptsFail: boolean let turns: ReturnType let operations = 0 @@ -181,6 +184,7 @@ beforeEach(async () => { childCloses = 0 connections = 0 failingCloses = 0 + interruptsFail = false turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) @@ -209,6 +213,15 @@ beforeEach(async () => { steers += 1 return turns.routes['turn/steer'](params) } + if (method === 'turn/interrupt' && interruptsFail) { + interrupts += 1 + throw new CodexAppServerRequestError( + 'turn/interrupt', + -32603, + 'codex app-server turn/interrupt failed: could not submit', + 'could not submit' + ) + } if (method === 'turn/interrupt') { interrupts += 1 return turns.routes['turn/interrupt'](params) @@ -617,6 +630,19 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { expect(childCloses).toBe(1) expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') }) + + // A turn that opened may hold the send: the child's end leaves it in doubt, as before. + it('leaves a send in doubt when its turn opened before the child end', async () => { + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + interruptsFail = true + + await stop() + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') + }) }) describe('a cold send with no Stop behind it', () => { @@ -636,6 +662,17 @@ describe('a cold send with no Stop behind it', () => { expect(childCloses).toBe(1) }) + // Only a person's Stop takes a send back: a host's close leaves it in doubt, as before. + it('leaves its send in doubt when the host closes the chat', async () => { + await answeredColdSend() + const sent = (await settled()).submissions[0]?.clientMessageId ?? '' + + await host.close(SESSION, 'evict') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') + }) + it('never delays quitting the app', async () => { await answeredColdSend() From 910989fd1257d5850f0bf631f4bda13f8977fa14 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:49:13 -0700 Subject: [PATCH 108/182] fix(native-chat): a card queued while Stopping runs past the cards the Stop holds A card queued before a person's Stop waits under its pause until Resume. One queued after it, as a message sent while Stopping now is, was stuck behind them too, since the queue never reorders. Such a card was asked for after the Stop, so it runs when the stop lands, past the cards held only by that Stop's pause; a returned card and the restart and /clear pauses still hold everything behind them. Also: the host's own Stopping reading is gated on working, as the published flag is, so a failed Stop's mark never reads Stopping on an idle session; a send that falls back to the lane re-reads the conversation's journal there; and the end-to-end test asserts the queue's pause rather than a per-card field. --- .../queued-message-pause.test.ts | 8 ++-- .../queued-message-pause.ts | 24 +++++++--- ...tructured-agent-session-client-delivery.ts | 7 ++- ...structured-agent-session-host-mutations.ts | 12 ++++- ...ed-agent-session-queued-pause-lift.test.ts | 9 ++-- ...ured-agent-session-queued-stop-row.test.ts | 17 +++---- ...gent-session-codex-interrupt-order.test.ts | 45 +++++++++++++++++-- 7 files changed, 89 insertions(+), 33 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index b8e45e0ef590..fb49694cded1 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -447,7 +447,8 @@ describe('which cards a pause holds', () => { ]) }) - it("the queue's own consume refuses a newer card while an older one is held: nothing overtakes", async () => { + // A card asked for after a person's Stop runs when the stop lands, past the cards that Stop holds. + it("the queue's own consume takes a card queued after a Stop past the cards only it holds", async () => { const journal = await open() await queueDraft(journal, 'held') await userStop(journal) @@ -470,8 +471,9 @@ describe('which cards a pause holds', () => { yieldsToPause: { hostInstance: HOST } } ) - ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) - expect(journal.queuedMessages.get('newer')?.state).toBe('waiting') + ).resolves.toBeDefined() + expect(journal.queuedMessages.get('held')?.state).toBe('waiting') + expect(journal.queuedMessages.get('newer')?.state).not.toBe('waiting') }) it("'cleared' holds the carried cards, not one typed after them", async () => { diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 71a17d871ef9..cc4e371f752b 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -161,8 +161,8 @@ function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { } // Product decision: a card queued AFTER a Stop is a new instruction and is not held; only cards -// queued before it, and a steer it withdrew, wait. It still never jumps ahead of a held card: the -// drain stops at the first one. true instead holds every waiting card, whenever it was queued. +// queued before it, and a steer it withdrew, wait. true instead holds every waiting card, whenever +// it was queued. const PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT = false /** THE rule for which cards are held: by ANY pause in force, named by the first that holds it, so @@ -179,18 +179,30 @@ export function queuePauseHolding( } /** The card the queue sends next: the oldest waiting one with no hold of its own, unless a - * returned card or a held one comes first. The queue never reorders, so a newer card never - * overtakes a held one. The drain's pick and its consume both read this. */ + * returned card or a held one comes first. The one exception: a card queued after a person's Stop + * passes the cards only that Stop holds, since it was asked for after the Stop and runs when the + * stop lands. The drain's pick and its consume both read this. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] ): T | null { + const stop = pauses.find((pause) => pause.reason === 'stopped') + const heldOnlyByStop = (card: QueueCard): boolean => + pauses.every((pause) => pause === stop || !queuePauseHolding([pause], card)) + let passedStopHeld = false for (const card of cards) { - if (card.state === 'returned' || queuePauseHolding(pauses, card)) { + if (card.state === 'returned') { return null } + if (queuePauseHolding(pauses, card)) { + if (!stop || !heldOnlyByStop(card)) { + return null + } + passedStopHeld = true + continue + } if (card.state === 'waiting' && card.holdReason === null) { - return card + return !passedStopHeld || (stop !== undefined && !queuedBeforePause(stop, card)) ? card : null } } return null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 39e60615ff7e..b656d2b0e023 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -85,8 +85,11 @@ export class StructuredAgentSessionClientDelivery { publishChildWork = (sessionId: string, evidence: AgentChildWorkEvidence[]): void => this.statusFeed.publishChildWork(sessionId, evidence) - readStopping = (sessionId: string): boolean => - this.statusFeed.journalProjection(sessionId)?.stopping === true + /** What the feed publishes as `stopping`: only a working session is still being stopped. */ + readStopping = (sessionId: string): boolean => { + const projection = this.statusFeed.journalProjection(sessionId) + return projection?.stopping === true && projection.state.summary.status === 'working' + } readChildWork = (sessionId: string): AgentChildWorkView[] | undefined => this.statusFeed.readChildWork(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 11e1394888f5..3d9d2198cde4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -30,6 +30,7 @@ import { queuedMessageBodyIsTextOnly } from './structured-agent-session-queued-messages' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { openForProviderWrite, openWithAgent, @@ -100,11 +101,18 @@ export function sendStructuredAgentSessionTurn( : null return ( queued ?? - // Anything else takes the lane, at the fence it stands at there, as every send does. + // Anything else takes the lane, on the conversation and fence it stands at there, as every + // send does: a close and reopen while it waited replaced the journal admission read. context.serialize(ctx.sessionId, async () => { const prepared = await preparation() + const journal = context.sessions.get(ctx.sessionId)?.journal + if (!prepared.ok || !journal) { + return prepared.ok + ? { ok: false as const, refusal: AGENT_SESSION_NOT_ATTACHED } + : prepared + } const fence = context.deps.store.getRecord(ctx.sessionId)?.lease.runtimeFence - return prepared.ok ? queueable({ ...ctx, fence: fence ?? ctx.fence }) : prepared + return queueable({ ...ctx, journal, fence: fence ?? ctx.fence }) }) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index c5baeed25def..ce6f1ee9e671 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -107,17 +107,14 @@ describe("a Stop's queue pause", () => { await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) - it("a draft typed while the stopped turn winds down waits with the rest: the pause is the queue's", async () => { + it('a draft typed while the stopped turn winds down runs after it; the card the Stop holds waits', async () => { const working = await rig.workingSend() const olderId = await queuedDraft('paused by the stop') await rig.stop() const typedId = await queuedDraft('typed while stopping') await rig.settleAccepted(working, 'stopped') - await expectPaused(olderId, typedId) - expect(await rig.drafts()).toEqual([ - { messageId: olderId, state: 'waiting' }, - { messageId: typedId, state: 'waiting' } - ]) + await handedOver(typedId) + await expectPaused(olderId) }) it('Send-now sends only its own card; the rest stay paused until that turn starts, then drain after it', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index d1db51426c50..ba17a264c455 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -105,7 +105,8 @@ describe("Stop's event", () => { await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) }) - it('a card queued after the Stop waits behind the cards it holds, then all send in order', async () => { + // Asked for after the Stop, it runs past the cards the Stop holds; they wait for Resume. + it('a card queued after the Stop runs past the cards it holds, which wait for Resume', async () => { const working = await rig.workingSend() const held = await queuedDraft('queued before the stop') await rig.stop() @@ -113,18 +114,12 @@ describe("Stop's event", () => { const mail = await mailTurn() const later = await queuedDraft('queued during the mail turn') await rig.settleAccepted(mail, 'mail') - // The queue never reorders: the newer card waits behind the held one, with no caption of its own. - await expectHeld('stopped', held, later) - expect(await rig.drafts()).toEqual([ - { messageId: held, state: 'waiting' }, - { messageId: later, state: 'waiting' } - ]) + await eventually(async () => expect((await rig.handoff(later))?.handedOverAt).toBeDefined()) + await rig.settleAccepted(await rig.handoffId(later), 'later') + // The queue's own send is no person's turn: the cards the Stop holds still wait. + await expectHeld('stopped', held) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) - expect(await rig.handoff(later)).toBeUndefined() - await eventually(async () => expect((await rig.handoff(held))?.handedOverAt).toBeDefined()) - await rig.settleAccepted(await rig.handoffId(held), 'held') - await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) }) it("a person's accepted turn lifts it; a host turn and a later Stop do not", async () => { diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index 5f14fb8cb199..46d084d91bbd 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -108,12 +108,16 @@ async function sendQueued(text: string, clientOperationId?: string) { return sent.value } -async function queuedCards() { +async function queuePage() { const page = await host.history({ sessionId: SESSION, direction: 'tail' }) if (!page.ok) { throw new Error('history refused') } - return page.page.queuedMessages ?? [] + return { cards: page.page.queuedMessages ?? [], pause: page.page.queuePause ?? null } +} + +async function queuedCards() { + return (await queuePage()).cards } async function stop(turnId?: string): Promise { @@ -361,7 +365,8 @@ describe('a queued send while a Codex Stop waits on its answer', () => { expect(queued).toMatchObject({ queued: { state: 'waiting' } }) const messageId = 'queued' in queued ? queued.queued.messageId : '' expect(await queuedCards()).toEqual([expect.objectContaining({ messageId, state: 'waiting' })]) - expect((await queuedCards())[0]).not.toHaveProperty('paused') + // The Stop's pause holds no card queued after it, so it offers no Resume. + expect((await queuePage()).pause).toBeNull() // A retry of the same operation answers the same card, on the lane or off it. expect(await sendQueued('run this after the stop', messageId)).toEqual(queued) @@ -383,3 +388,37 @@ describe('a queued send while a Codex Stop waits on its answer', () => { expect(await queuedCards()).toEqual([]) }) }) + +// A card queued before the Stop waits for Resume; one queued while it stops runs when it lands. +describe('a queued send while Stopping, behind a card the Stop paused', () => { + it('runs as its own turn after the stop, and the paused card waits for Resume', async () => { + const first = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(first) + const before = await sendQueued('queued before the stop') + const paused = 'queued' in before ? before.queued.messageId : '' + const answer = Promise.withResolvers() + interruptAnswer = answer.promise + const stopping = stop() + await vi.waitFor(() => expect(statuses.at(-1)?.stopping).toBe(true)) + + const during = await sendQueued('sent while stopping') + const runsNext = 'queued' in during ? during.queued.messageId : '' + answer.resolve() + await stopping + turns.end('interrupted') + + await vi.waitFor(() => expect(answers).toBe(2)) + expect(startedTurns).toEqual(['turn-1', 'turn-2']) + expect(steers).toBe(0) + const handOffs = (await host.journalSnapshot(SESSION)).submissions.flatMap((entry) => + entry.queuedMessageId ? [entry.queuedMessageId] : [] + ) + expect(handOffs).toEqual([runsNext]) + expect(await queuePage()).toEqual({ + cards: [expect.objectContaining({ messageId: paused, state: 'waiting' })], + pause: expect.objectContaining({ reason: 'stopped' }) + }) + }) +}) From 78da557be65ee32185b59db080d45bbe375fea28 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:58:34 -0700 Subject: [PATCH 109/182] fix(native-chat): a paused queue labels only the cards it holds Since a card queued after a person's Stop runs past the cards the Stop holds, labelling every card "paused" while the queue's pause is published misreads that card. The host now marks each card its pause holds (heldByPause, a new optional field), and the desktop and phone label only those. An older host marks none, so a client keeps today's labels; an older client ignores the field. Adds a test of the desktop's own send through the real outbox: while the chat reads Stopping, the request asks the host to queue it and no bubble is drawn, on a host that advertises the queue. --- ...le-structured-queued-message-cards.test.ts | 10 +++ .../mobile-structured-queued-message-cards.ts | 5 +- ...ctured-agent-session-queued-publication.ts | 27 ++++-- ...gent-session-codex-interrupt-order.test.ts | 7 +- ...uctured-agent-session-queued-cards.test.ts | 18 ++++ .../structured-agent-session-queued-cards.ts | 5 +- ...gent-session.queue-while-stopping.test.tsx | 89 +++++++++++++++++++ .../agent-session-queued-message-wire.ts | 4 + 8 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 44d1e0b9729f..19b5efb2e1c3 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -66,6 +66,16 @@ describe('mobileQueuedMessageCards', () => { expect(card?.needsAttention).toBe(false) }) + // A card queued after a person's Stop is not the pause's: it waits as any card does. + it('captions a card the host says its pause lets through as any waiting card', () => { + const cards = mobileQueuedMessageCards( + [draft({ messageId: 'held', heldByPause: true }), draft({ messageId: 'b', position: 2 })], + [], + { pendingPrompt: true, queuePaused: true } + ) + expect(cards.map((card) => card.caption)).toEqual([null, 'Waiting for your answer']) + }) + it("keeps a card's own failed send and reads a prompt's wait as queued under a paused queue", () => { const cards = mobileQueuedMessageCards( [ diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index f3edb13e53c8..002cf38bc784 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -103,6 +103,9 @@ export function mobileQueuedMessageCards( : [] ) ) + // A host that names the cards its pause holds lets the rest wait as usual; an older host names + // none, and its pause holds every card. + const pauseNamesCards = queuedMessages.some((draft) => draft.heldByPause === true) let behindReturned = false const cards: MobileQueuedMessageCard[] = [] for (const draft of queuedMessages) { @@ -117,7 +120,7 @@ export function mobileQueuedMessageCards( ? pausedCaption(draft.pausedReason) : behindReturned ? 'Waiting — a message ahead needs attention' - : facts.queuePaused + : facts.queuePaused && (!pauseNamesCards || draft.heldByPause === true) ? // The pause row says why and offers Resume; the card promises no send time. null : facts.pendingPrompt diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index 1fa46616da18..fb564156df96 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -9,7 +9,11 @@ import { type AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { resumableQueuePause } from '../agent-session-journal/queued-message-pause' +import { + queuePauseHolding, + resumableQueuePause, + type DerivedQueuePause +} from '../agent-session-journal/queued-message-pause' import { structuredQueuePauses } from './structured-agent-session-queued-pause' export type QueuePublication = { @@ -19,7 +23,10 @@ export type QueuePublication = { /** Waiting and returned rows only. `paused` is a per-card hold (a failed * conversion); a Stop or a restart pauses the queue, published once beside it. */ -function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { +function computePublishedQueuedMessages( + journal: AgentSessionJournal, + pauses: readonly DerivedQueuePause[] +): AgentSessionQueuedMessage[] { const published: AgentSessionQueuedMessage[] = [] for (const row of journal.queuedMessages.list()) { if (row.state !== 'waiting' && row.state !== 'returned') { @@ -27,6 +34,7 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess } const held = row.state === 'waiting' && row.holdReason !== null published.push({ + ...(queuePauseHolding(pauses, row) ? { heldByPause: true as const } : {}), messageId: row.messageId, position: row.position, body: row.body, @@ -53,13 +61,17 @@ type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessa const listMemos = new WeakMap() const publications = new WeakMap() -function readPublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { - const key = String(journal.queuedMessages.revision()) +function readPublishedQueuedMessages( + journal: AgentSessionJournal, + pauses: readonly DerivedQueuePause[] +): AgentSessionQueuedMessage[] { + // The pauses turn on rows outside the draft table (a Stop, a person's turn), so they key it too. + const key = `${journal.queuedMessages.revision()}:${JSON.stringify(pauses)}` const memo = listMemos.get(journal) if (memo && memo.key === key) { return memo.list } - const list = computePublishedQueuedMessages(journal) + const list = computePublishedQueuedMessages(journal, pauses) // Belt for the identity dedup: equal recomputed content keeps the previous reference. const serialized = JSON.stringify(list) if (memo && memo.serialized === serialized) { @@ -80,11 +92,12 @@ export function sameQueuePause( } export function readQueuePublication(journal: AgentSessionJournal): QueuePublication { - const queuedMessages = readPublishedQueuedMessages(journal) // Read per emit: the pause also turns on submissions (a person's turn starting). Shown only // over a card Resume would send, so its header never offers to send nothing; deleting a // blocking returned card shows it again. - const pause = resumableQueuePause(structuredQueuePauses(journal), journal.queuedMessages.list()) + const pauses = structuredQueuePauses(journal) + const queuedMessages = readPublishedQueuedMessages(journal, pauses) + const pause = resumableQueuePause(pauses, journal.queuedMessages.list()) const queuePause = pause ? { reason: pause.reason } : null const previous = publications.get(journal) if ( diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index 46d084d91bbd..f00f1a56a891 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -405,6 +405,11 @@ describe('a queued send while Stopping, behind a card the Stop paused', () => { const during = await sendQueued('sent while stopping') const runsNext = 'queued' in during ? during.queued.messageId : '' + // The pause names the one card it holds; the card queued after the Stop is not one. + expect((await queuePage()).cards.map((card) => [card.messageId, card.heldByPause])).toEqual([ + [paused, true], + [runsNext, undefined] + ]) answer.resolve() await stopping turns.end('interrupted') @@ -417,7 +422,7 @@ describe('a queued send while Stopping, behind a card the Stop paused', () => { ) expect(handOffs).toEqual([runsNext]) expect(await queuePage()).toEqual({ - cards: [expect.objectContaining({ messageId: paused, state: 'waiting' })], + cards: [expect.objectContaining({ messageId: paused, state: 'waiting', heldByPause: true })], pause: expect.objectContaining({ reason: 'stopped' }) }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 995d80247699..7d0e3c22ac7b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -137,6 +137,24 @@ describe('queued message cards', () => { expect(cards.map((card) => card.messageId)).toEqual(['kept', 'refused']) }) + // A card queued after a person's Stop runs when the stop lands, past the cards its pause holds. + it('a paused queue labels only the cards its host says it holds', () => { + const cards = projectQueuedMessageCards( + [draft('held', 1, { heldByPause: true }), draft('queued while stopping', 2)], + [], + { hasPendingPrompt: false, queuePaused: true } + ) + expect(cards.map((card) => card.hold)).toEqual(['queue-paused', 'turn']) + }) + + it('a paused queue from a host that names no held card labels every card, as before', () => { + const cards = projectQueuedMessageCards([draft('a', 1), draft('b', 2)], [], { + hasPendingPrompt: false, + queuePaused: true + }) + expect(cards.map((card) => card.hold)).toEqual(['queue-paused', 'queue-paused']) + }) + it('a paused queue outranks a pending prompt: an answer does not drain it', () => { const cards = projectQueuedMessageCards( [ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index e9d1f394b9af..eb84a65e5191 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts @@ -61,6 +61,9 @@ export function projectQueuedMessageCards( const ordered = [...(queuedMessages ?? [])] .sort((left, right) => left.position - right.position) .filter((message) => message.state === 'returned' || !handedOff.has(message.messageId)) + // A host that names the cards its pause holds lets the rest read as waiting their turn; an older + // host names none, and its pause holds every card. + const pauseNamesCards = ordered.some((message) => message.heldByPause === true) let behindReturned = false return ordered.map((message) => { const hold: QueuedMessageCardHold = @@ -70,7 +73,7 @@ export function projectQueuedMessageCards( ? 'paused' : behindReturned ? 'behind-returned' - : session.queuePaused + : session.queuePaused && (!pauseNamesCards || message.heldByPause === true) ? 'queue-paused' : session.hasPendingPrompt ? 'awaiting-answer' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx new file mode 100644 index 000000000000..1f84eb7a8ed5 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom + +// A message sent while the chat reads Stopping, through the chat's own send and its real outbox: +// the request asks the host to queue it, whatever the queueing setting, and the transcript draws +// no bubble of it inside the turn the Stop is ending. + +import { cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' + +const mocks = vi.hoisted(() => ({ call: vi.fn() })) +let items: AgentJournalRenderItem[] = [] +const submissions: AgentJournalSubmission[] = [] + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false) +})) + +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { fence: 3, items, submissions, status: 'ready', error: null, hasOlder: false }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) + +import { + AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { clearNativeChatDraftCacheForTests } from './native-chat-draft-cache' +import { useStructuredAgentSession } from './use-structured-agent-session' + +const RUNNING_TURN: AgentJournalRenderItem = { + itemId: 'turn-1', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'turn', turnId: 'provider-turn', state: 'running' } +} + +function sends(): { delivery?: string; body?: unknown }[] { + return mocks.call.mock.calls + .filter(([, method]) => method === 'agentSession.send') + .map(([, , params]) => params) +} + +beforeEach(() => { + localStorage.clear() + clearNativeChatDraftCacheForTests() + items = [RUNNING_TURN] + setLocalRuntimeCapabilitiesForTests([ + AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY + ]) + // The host has not answered yet: the send is still on its way, as while a Stop holds the lane. + mocks.call.mockImplementation(() => new Promise(() => {})) +}) + +afterEach(() => { + cleanup() + setLocalRuntimeCapabilitiesForTests(null) + mocks.call.mockReset() +}) + +it('asks the host to queue a send made while the host reads Stopping, and draws no bubble', async () => { + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'codex', + target: { kind: 'local' }, + isVisible: true, + composerScopeKey: 'scope-1', + queueFollowUps: false, + hostStopping: true + }) + ) + + expect(result.current.send('run this after the stop')).toBe(true) + + await waitFor(() => expect(sends()).toHaveLength(1)) + expect(sends()[0]?.delivery).toBe('queue-if-active') + expect(JSON.stringify(result.current.messages)).not.toContain('run this after the stop') +}) diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index f7659c95873b..05539eef5c0e 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -37,6 +37,10 @@ export type AgentSessionQueuedMessage = { * marker as a plain hold, so a newer host can add one. The queue-level * pause is `queuePause`, published beside the list. */ pausedReason?: AgentSessionQueuedMessagePausedReason + /** The queue's pause holds this card: Resume sends it. A card the pause lets through (one + * queued after a person's Stop) has none, though `queuePause` is set. Absent on every card from + * an older host, whose pause holds them all. */ + heldByPause?: true /** A returned card's refusal: the `reason` and `rejection` pair its submission settled with. * Only a failure returns a card; a draft a Stop or restart took back waits again. Clients classify it from `returnedRejection` (falling back to `returnedReason` when a host * wrote no fact) exactly as they classify a rejected submission's `rejection`, e.g. From 497098d6ef4951bea180d128ba9352b02b218fd2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:59:52 -0700 Subject: [PATCH 110/182] revert(native-chat): defer the per-card queue pause label to the queue's rollout The heldByPause field and its labels are visible only where the host advertises the queued-messages capability, which shipped hosts do not yet do. Deferred to that rollout; the real-outbox send test stays. --- ...le-structured-queued-message-cards.test.ts | 10 ------- .../mobile-structured-queued-message-cards.ts | 5 +--- ...ctured-agent-session-queued-publication.ts | 27 +++++-------------- ...gent-session-codex-interrupt-order.test.ts | 7 +---- ...uctured-agent-session-queued-cards.test.ts | 18 ------------- .../structured-agent-session-queued-cards.ts | 5 +--- .../agent-session-queued-message-wire.ts | 4 --- 7 files changed, 10 insertions(+), 66 deletions(-) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 19b5efb2e1c3..44d1e0b9729f 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -66,16 +66,6 @@ describe('mobileQueuedMessageCards', () => { expect(card?.needsAttention).toBe(false) }) - // A card queued after a person's Stop is not the pause's: it waits as any card does. - it('captions a card the host says its pause lets through as any waiting card', () => { - const cards = mobileQueuedMessageCards( - [draft({ messageId: 'held', heldByPause: true }), draft({ messageId: 'b', position: 2 })], - [], - { pendingPrompt: true, queuePaused: true } - ) - expect(cards.map((card) => card.caption)).toEqual([null, 'Waiting for your answer']) - }) - it("keeps a card's own failed send and reads a prompt's wait as queued under a paused queue", () => { const cards = mobileQueuedMessageCards( [ diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index 002cf38bc784..f3edb13e53c8 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -103,9 +103,6 @@ export function mobileQueuedMessageCards( : [] ) ) - // A host that names the cards its pause holds lets the rest wait as usual; an older host names - // none, and its pause holds every card. - const pauseNamesCards = queuedMessages.some((draft) => draft.heldByPause === true) let behindReturned = false const cards: MobileQueuedMessageCard[] = [] for (const draft of queuedMessages) { @@ -120,7 +117,7 @@ export function mobileQueuedMessageCards( ? pausedCaption(draft.pausedReason) : behindReturned ? 'Waiting — a message ahead needs attention' - : facts.queuePaused && (!pauseNamesCards || draft.heldByPause === true) + : facts.queuePaused ? // The pause row says why and offers Resume; the card promises no send time. null : facts.pendingPrompt diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index fb564156df96..1fa46616da18 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -9,11 +9,7 @@ import { type AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { - queuePauseHolding, - resumableQueuePause, - type DerivedQueuePause -} from '../agent-session-journal/queued-message-pause' +import { resumableQueuePause } from '../agent-session-journal/queued-message-pause' import { structuredQueuePauses } from './structured-agent-session-queued-pause' export type QueuePublication = { @@ -23,10 +19,7 @@ export type QueuePublication = { /** Waiting and returned rows only. `paused` is a per-card hold (a failed * conversion); a Stop or a restart pauses the queue, published once beside it. */ -function computePublishedQueuedMessages( - journal: AgentSessionJournal, - pauses: readonly DerivedQueuePause[] -): AgentSessionQueuedMessage[] { +function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { const published: AgentSessionQueuedMessage[] = [] for (const row of journal.queuedMessages.list()) { if (row.state !== 'waiting' && row.state !== 'returned') { @@ -34,7 +27,6 @@ function computePublishedQueuedMessages( } const held = row.state === 'waiting' && row.holdReason !== null published.push({ - ...(queuePauseHolding(pauses, row) ? { heldByPause: true as const } : {}), messageId: row.messageId, position: row.position, body: row.body, @@ -61,17 +53,13 @@ type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessa const listMemos = new WeakMap() const publications = new WeakMap() -function readPublishedQueuedMessages( - journal: AgentSessionJournal, - pauses: readonly DerivedQueuePause[] -): AgentSessionQueuedMessage[] { - // The pauses turn on rows outside the draft table (a Stop, a person's turn), so they key it too. - const key = `${journal.queuedMessages.revision()}:${JSON.stringify(pauses)}` +function readPublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { + const key = String(journal.queuedMessages.revision()) const memo = listMemos.get(journal) if (memo && memo.key === key) { return memo.list } - const list = computePublishedQueuedMessages(journal, pauses) + const list = computePublishedQueuedMessages(journal) // Belt for the identity dedup: equal recomputed content keeps the previous reference. const serialized = JSON.stringify(list) if (memo && memo.serialized === serialized) { @@ -92,12 +80,11 @@ export function sameQueuePause( } export function readQueuePublication(journal: AgentSessionJournal): QueuePublication { + const queuedMessages = readPublishedQueuedMessages(journal) // Read per emit: the pause also turns on submissions (a person's turn starting). Shown only // over a card Resume would send, so its header never offers to send nothing; deleting a // blocking returned card shows it again. - const pauses = structuredQueuePauses(journal) - const queuedMessages = readPublishedQueuedMessages(journal, pauses) - const pause = resumableQueuePause(pauses, journal.queuedMessages.list()) + const pause = resumableQueuePause(structuredQueuePauses(journal), journal.queuedMessages.list()) const queuePause = pause ? { reason: pause.reason } : null const previous = publications.get(journal) if ( diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index f00f1a56a891..46d084d91bbd 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -405,11 +405,6 @@ describe('a queued send while Stopping, behind a card the Stop paused', () => { const during = await sendQueued('sent while stopping') const runsNext = 'queued' in during ? during.queued.messageId : '' - // The pause names the one card it holds; the card queued after the Stop is not one. - expect((await queuePage()).cards.map((card) => [card.messageId, card.heldByPause])).toEqual([ - [paused, true], - [runsNext, undefined] - ]) answer.resolve() await stopping turns.end('interrupted') @@ -422,7 +417,7 @@ describe('a queued send while Stopping, behind a card the Stop paused', () => { ) expect(handOffs).toEqual([runsNext]) expect(await queuePage()).toEqual({ - cards: [expect.objectContaining({ messageId: paused, state: 'waiting', heldByPause: true })], + cards: [expect.objectContaining({ messageId: paused, state: 'waiting' })], pause: expect.objectContaining({ reason: 'stopped' }) }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 7d0e3c22ac7b..995d80247699 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -137,24 +137,6 @@ describe('queued message cards', () => { expect(cards.map((card) => card.messageId)).toEqual(['kept', 'refused']) }) - // A card queued after a person's Stop runs when the stop lands, past the cards its pause holds. - it('a paused queue labels only the cards its host says it holds', () => { - const cards = projectQueuedMessageCards( - [draft('held', 1, { heldByPause: true }), draft('queued while stopping', 2)], - [], - { hasPendingPrompt: false, queuePaused: true } - ) - expect(cards.map((card) => card.hold)).toEqual(['queue-paused', 'turn']) - }) - - it('a paused queue from a host that names no held card labels every card, as before', () => { - const cards = projectQueuedMessageCards([draft('a', 1), draft('b', 2)], [], { - hasPendingPrompt: false, - queuePaused: true - }) - expect(cards.map((card) => card.hold)).toEqual(['queue-paused', 'queue-paused']) - }) - it('a paused queue outranks a pending prompt: an answer does not drain it', () => { const cards = projectQueuedMessageCards( [ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index eb84a65e5191..e9d1f394b9af 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts @@ -61,9 +61,6 @@ export function projectQueuedMessageCards( const ordered = [...(queuedMessages ?? [])] .sort((left, right) => left.position - right.position) .filter((message) => message.state === 'returned' || !handedOff.has(message.messageId)) - // A host that names the cards its pause holds lets the rest read as waiting their turn; an older - // host names none, and its pause holds every card. - const pauseNamesCards = ordered.some((message) => message.heldByPause === true) let behindReturned = false return ordered.map((message) => { const hold: QueuedMessageCardHold = @@ -73,7 +70,7 @@ export function projectQueuedMessageCards( ? 'paused' : behindReturned ? 'behind-returned' - : session.queuePaused && (!pauseNamesCards || message.heldByPause === true) + : session.queuePaused ? 'queue-paused' : session.hasPendingPrompt ? 'awaiting-answer' diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index 05539eef5c0e..f7659c95873b 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -37,10 +37,6 @@ export type AgentSessionQueuedMessage = { * marker as a plain hold, so a newer host can add one. The queue-level * pause is `queuePause`, published beside the list. */ pausedReason?: AgentSessionQueuedMessagePausedReason - /** The queue's pause holds this card: Resume sends it. A card the pause lets through (one - * queued after a person's Stop) has none, though `queuePause` is set. Absent on every card from - * an older host, whose pause holds them all. */ - heldByPause?: true /** A returned card's refusal: the `reason` and `rejection` pair its submission settled with. * Only a failure returns a card; a draft a Stop or restart took back waits again. Clients classify it from `returnedRejection` (falling back to `returnedReason` when a host * wrote no fact) exactly as they classify a rejected submission's `rejection`, e.g. From 84f7a5f19c2413140bebd7d13c68a4ef52603d48 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:09:40 -0700 Subject: [PATCH 111/182] fix(native-chat): while Stopping, say and show what a send does where the queue is dark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shipped hosts do not advertise the queued-messages capability, so a message sent while Stopping goes out plain: the host holds it until the stopped turn ends and then runs it as its own turn. The composer still said "Queue a message to run after the stop", and the message was drawn inside the turn being stopped. Now the placeholder reads "Send a message to run after the stop" where the host does not queue sends, and "Queue a message…" only where it does (desktop and phone, all six catalogs). A send this client made that the host has not recorded yet is drawn after the Stopping line while the chat reads Stopping, as a message held behind a running command already is; once the host hands it over it opens its own turn. Client presentation only. --- .../src/session/MobileNativeChatView.test.ts | 26 +++++++----- mobile/src/session/MobileNativeChatView.tsx | 3 +- ...e-native-chat-composer-placeholder.test.ts | 12 ++++-- ...mobile-native-chat-composer-placeholder.ts | 10 ++++- .../use-mobile-native-chat-turn-disclosure.ts | 9 +++- .../use-mobile-structured-agent-session.ts | 10 ++++- ...-mobile-structured-turn-indicator.test.tsx | 7 +++- .../native-chat/NativeChatComposer.test.tsx | 6 +-- .../native-chat/NativeChatComposer.tsx | 4 +- .../native-chat/NativeChatComposerField.tsx | 14 ++++--- ...ChatMessageList.stopping-turn-bar.test.tsx | 42 +++++++++++++++++++ .../native-chat/NativeChatMessageList.tsx | 4 +- ...iveChatStructuredSession.stopping.test.tsx | 17 +++++++- ...tiveChatStructuredSession.test-harness.tsx | 5 ++- .../native-chat-composer-placeholder.test.ts | 8 +++- .../native-chat-composer-target.ts | 14 ++++++- .../native-chat/native-chat-composer-types.ts | 6 ++- .../native-chat-structured-stop-controls.ts | 7 ++-- .../native-chat-transcript-slots.ts | 6 ++- ...gent-session.queue-while-stopping.test.tsx | 30 ++++++++++--- .../use-structured-agent-session.ts | 2 + .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + src/shared/native-chat-turn-membership.ts | 17 +++++--- src/shared/native-chat-turn-status.ts | 7 +++- src/shared/native-chat-types.ts | 2 + ...ctured-agent-session-message-projection.ts | 2 +- 32 files changed, 216 insertions(+), 61 deletions(-) diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index c7ef47381eac..80f9157c611b 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -165,16 +165,22 @@ describe('MobileNativeChatView', () => { expect(stop.props.disabled).toBe(true) }) - it('tells the composer a message sent now runs after the stop', async () => { - await render({ - structuredActivityUi: true, - agentWorking: true, - canStop: true, - turnIndicator: { thinking: false, activityText: null, stopping: true } - }) - const composer = renderer!.root.find((node) => node.type === 'Composer') - expect(composer.props.placeholder).toBe('Queue a message to run after the stop') - }) + it.each([ + ['queue', 'Queue a message to run after the stop'], + ['send', 'Send a message to run after the stop'] + ] as const)( + 'tells the composer a message sent now runs after the stop (%s)', + async (afterStop, placeholder) => { + await render({ + structuredActivityUi: true, + agentWorking: true, + canStop: true, + turnIndicator: { thinking: false, activityText: null, stopping: true, afterStop } + }) + const composer = renderer!.root.find((node) => node.type === 'Composer') + expect(composer.props.placeholder).toBe(placeholder) + } + ) // A Stop the provider took and never answered ends only at a repeat Stop. it('keeps Stop for the repeat that escalates while the host alone says Stopping', async () => { diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 756abc9cdec3..2adb56bda4cb 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -282,6 +282,7 @@ export function MobileNativeChatView({ turnJournal, thinking: turnIndicator?.thinking === true, activityText: turnIndicator?.activityText ?? null, + stopping, scopeKey: sendSurfaceId }) const hasPendingStructuredInteraction = @@ -457,7 +458,7 @@ export function MobileNativeChatView({ onMicPressIn={onMicPressIn} onMicPressOut={onMicPressOut} disabled={lockReason !== null} - placeholder={mobileNativeChatComposerPlaceholder(lockReason, stopping)} + placeholder={mobileNativeChatComposerPlaceholder(lockReason, turnIndicator?.afterStop)} filePaths={filePaths} onNeedFiles={onNeedFiles} /> diff --git a/mobile/src/session/mobile-native-chat-composer-placeholder.test.ts b/mobile/src/session/mobile-native-chat-composer-placeholder.test.ts index 48c05dce55c7..b6ec977d10df 100644 --- a/mobile/src/session/mobile-native-chat-composer-placeholder.test.ts +++ b/mobile/src/session/mobile-native-chat-composer-placeholder.test.ts @@ -3,17 +3,21 @@ import { mobileNativeChatComposerPlaceholder } from './mobile-native-chat-compos describe("the phone chat composer's placeholder", () => { it('says a message runs after the stop while the chat reads Stopping', () => { - expect(mobileNativeChatComposerPlaceholder(null, true)).toBe( + expect(mobileNativeChatComposerPlaceholder(null, 'queue')).toBe( 'Queue a message to run after the stop' ) + // Where the host does not queue sends, it holds the message until the stop lands. + expect(mobileNativeChatComposerPlaceholder(null, 'send')).toBe( + 'Send a message to run after the stop' + ) }) it('reads as usual otherwise', () => { - expect(mobileNativeChatComposerPlaceholder(null, false)).toBe('Message, @files, /commands') + expect(mobileNativeChatComposerPlaceholder(null, undefined)).toBe('Message, @files, /commands') }) it('says why the composer is locked first', () => { - expect(mobileNativeChatComposerPlaceholder('disconnected', true)).toBe('Reconnecting…') - expect(mobileNativeChatComposerPlaceholder('waiting', true)).toBe('Waiting for terminal…') + expect(mobileNativeChatComposerPlaceholder('disconnected', 'queue')).toBe('Reconnecting…') + expect(mobileNativeChatComposerPlaceholder('waiting', 'queue')).toBe('Waiting for terminal…') }) }) diff --git a/mobile/src/session/mobile-native-chat-composer-placeholder.ts b/mobile/src/session/mobile-native-chat-composer-placeholder.ts index 04a8ce80847b..05d98b2eab47 100644 --- a/mobile/src/session/mobile-native-chat-composer-placeholder.ts +++ b/mobile/src/session/mobile-native-chat-composer-placeholder.ts @@ -5,7 +5,8 @@ import type { MobileNativeChatInputLockReason } from './MobileNativeChatView' * Stop the chat reads as stopping, else the usual prompt. */ export function mobileNativeChatComposerPlaceholder( lockReason: MobileNativeChatInputLockReason | null, - stopping: boolean + /** While stopping: whether a message sent now is queued, or sent for the host to hold. */ + afterStop: 'queue' | 'send' | undefined ): string { if (lockReason === 'disconnected') { return 'Reconnecting…' @@ -13,5 +14,10 @@ export function mobileNativeChatComposerPlaceholder( if (lockReason === 'waiting') { return 'Waiting for terminal…' } - return stopping ? NATIVE_CHAT_TURN_STATUS_COPY.queueAfterStop : 'Message, @files, /commands' + if (afterStop) { + return afterStop === 'queue' + ? NATIVE_CHAT_TURN_STATUS_COPY.queueAfterStop + : NATIVE_CHAT_TURN_STATUS_COPY.sendAfterStop + } + return 'Message, @files, /commands' } diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index 6e1d4df395e9..38fc476fae9c 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -41,6 +41,7 @@ export function useMobileNativeChatTurnDisclosure({ turnJournal = null, thinking = false, activityText = null, + stopping = false, scopeKey }: { messages: readonly NativeChatMessage[] @@ -55,6 +56,8 @@ export function useMobileNativeChatTurnDisclosure({ thinking?: boolean /** What the provider says the live turn is doing; outranks the other labels. */ activityText?: string | null + /** A person's Stop is ending the live turn: the sends the host holds draw after its status. */ + stopping?: boolean /** Host/worktree/tab identity for timing and disclosure isolation. */ scopeKey: string }): { @@ -83,7 +86,9 @@ export function useMobileNativeChatTurnDisclosure({ }, [enabled, messages, turnJournal]) // A message waiting behind the live turn draws after that turn's live status, not in the list. const waiting = useMemo(() => { - const ids = enabled ? nativeChatMessagesWaitingBehindLiveTurn(rows, turnJournal?.items) : null + const ids = enabled + ? nativeChatMessagesWaitingBehindLiveTurn(rows, turnJournal?.items, stopping) + : null if (!ids?.size) { return { listMessages: rows, waitingRows: [], indexById: null } } @@ -92,7 +97,7 @@ export function useMobileNativeChatTurnDisclosure({ waitingRows: rows.flatMap((item, index) => (ids.has(item.id) ? [{ item, index }] : [])), indexById: new Map(rows.map((message, index) => [message.id, index])) } - }, [enabled, rows, turnJournal]) + }, [enabled, rows, stopping, turnJournal]) const turnStatuses = useMobileNativeChatTurnStatus({ turnKeys, liveTurnKey, diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index e30c34bbafb9..da41600adb06 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -185,8 +185,14 @@ export function useMobileStructuredAgentSession(args: { }) === 'stopping' const stopRequestInFlight = isWorking && stopPress.pressed const turnIndicator = useMemo( - () => ({ thinking, activityText, stopping, stopRequestInFlight }), - [thinking, activityText, stopping, stopRequestInFlight] + () => ({ + thinking, + activityText, + stopping, + stopRequestInFlight, + ...(stopping ? { afterStop: queueCapable ? ('queue' as const) : ('send' as const) } : {}) + }), + [thinking, activityText, stopping, stopRequestInFlight, queueCapable] ) const status = state.status === 'idle' ? 'idle' : state.status const approvalPrompt = useMemo( diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index 8e87d59b099a..c2cdf5d5114b 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -179,7 +179,12 @@ describe('useMobileStructuredAgentSession turn indicator', () => { expect(hook?.turnIndicator.stopping).toBe(false) act(() => hook?.cancel()) - expect(hook?.turnIndicator).toMatchObject({ stopping: true, stopRequestInFlight: true }) + // This host does not queue sends: a message sent now is held by the host until the stop lands. + expect(hook?.turnIndicator).toMatchObject({ + stopping: true, + stopRequestInFlight: true, + afterStop: 'send' + }) await act(async () => { const cancelled = { ok: true, value: { cancelled: true } } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index aa1eca3f3134..9329c1da12e9 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -28,7 +28,7 @@ const mocks = vi.hoisted(() => ({ attachDisabled?: boolean sendButtonDisabled?: boolean isStopping?: boolean - queuesAfterStop?: boolean + afterStop?: 'queue' | 'send' autocomplete?: { mode: string; items?: { kind: string; name: string }[] } } | null, modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'unknown', @@ -264,8 +264,8 @@ describe('NativeChatComposer', () => { expect(mocks.fieldProps).toMatchObject({ sendButtonDisabled: true, isStopping: true }) // Answered: Stop is back, and the placeholder says a message sent now runs after the stop. - rerender() - expect(mocks.fieldProps).toMatchObject({ sendButtonDisabled: false, queuesAfterStop: true }) + rerender() + expect(mocks.fieldProps).toMatchObject({ sendButtonDisabled: false, afterStop: 'send' }) }) it('associates a delayed submit with its optimistic cache entry', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d69699921756..ce8776a63ae5 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -59,7 +59,7 @@ const NativeChatComposerPane = forwardRef { expect(screen.getAllByText(/Stopping/)).toHaveLength(1) }) }) + +// Where the host does not queue sends, one made while Stopping is held until the turn ends: it is +// drawn after the Stopping line, not inside the turn being stopped. +describe('a message sent while Stopping, before the host has handed it over', () => { + const pending = { + id: 'pending-send', + role: 'user' as const, + outgoing: true as const, + blocks: [{ type: 'text' as const, text: 'Run this after the stop' }], + timestamp: Date.now(), + source: 'transcript' as const + } + + function renderList(stopping: boolean): void { + render( + + ) + } + + it('draws after the Stopping line', () => { + renderList(true) + + const stopping = screen.getByText('Stopping…') + const sent = screen.getByText('Run this after the stop') + expect(stopping.compareDocumentPosition(sent)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) + + it('stays in the turn while nothing is stopping', () => { + renderList(false) + + const sent = screen.getByText('Run this after the stop') + const working = screen.getByText('Working…') + expect(sent.compareDocumentPosition(working)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 160ee088dd8f..fbbd0e5deb71 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -218,8 +218,8 @@ export function NativeChatMessageList({ ) // A message waiting behind the live turn draws after that turn's live activity, not inside it. const { slots, waitingSlots } = useMemo( - () => splitNativeChatSlotsWaitingBehindLiveTurn(allSlots, journalItems), - [allSlots, journalItems] + () => splitNativeChatSlotsWaitingBehindLiveTurn(allSlots, journalItems, stopping), + [allSlots, journalItems, stopping] ) const transcriptWindow = useNativeChatTranscriptWindow({ scrollRef, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx index 16abfc493e62..2c994866d378 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx @@ -85,13 +85,25 @@ describe("the chat pane while a person's Stop ends the turn", () => { // A Stop the provider took and never answered (a Codex command) ends only at a second Stop. await waitFor(() => expect(mocks.messageListProps).toMatchObject({ stopping: true })) - expect(mocks.composerProps).toMatchObject({ isStopping: false, queuesAfterStop: true }) + // The queue is dark here: the message is sent, and the host holds it until the stop lands. + expect(mocks.composerProps).toMatchObject({ isStopping: false, afterStop: 'send' }) // Nothing steers into a turn a Stop is ending. expect(mocks.composerProps?.steerQueued).toBeUndefined() mocks.composerProps?.onStop?.() expect(mocks.stop).toHaveBeenCalledOnce() }) + it('says a message is queued to run after the stop only where the host queues sends', async () => { + mocks.turnId = 'turn-1' + mocks.queueCapable = true + renderPane() + await waitFor(() => expect(hostStatus.emit).not.toBeNull()) + + hostSays(true) + + await waitFor(() => expect(mocks.composerProps).toMatchObject({ afterStop: 'queue' })) + }) + it("holds a queued card's Steer while the host says Stopping", async () => { mocks.turnId = 'turn-1' mocks.queuedCards = [ @@ -123,7 +135,8 @@ describe("the chat pane while a person's Stop ends the turn", () => { mocks.turnId = 'turn-1' renderPane() - expect(mocks.composerProps).toMatchObject({ isStopping: false, queuesAfterStop: false }) + expect(mocks.composerProps).toMatchObject({ isStopping: false }) + expect(mocks.composerProps?.afterStop).toBeUndefined() expect(mocks.composerProps?.steerQueued).toBe(mocks.queuedSteerNewest) mocks.composerProps?.onStop?.() expect(mocks.stop).toHaveBeenCalledOnce() diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index e9f0e14e17be..eccc0fc09ec3 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -88,7 +88,7 @@ export function createStructuredSessionMocks() { structuredTransport?: Record isWorking?: boolean isStopping?: boolean - queuesAfterStop?: boolean + afterStop?: 'queue' | 'send' steerQueued?: () => boolean onStop?: () => void }>(), @@ -109,6 +109,7 @@ export function createStructuredSessionMocks() { // Unset: Stop follows the turn, as against an older host. canStop: nullable(), stopPressed: false, + queueCapable: false, supportsBackgroundTaskStop: false, supportsBackgroundTaskStopAll: true, backgroundTasks: [] as AgentSessionBackgroundTask[], @@ -195,6 +196,7 @@ export function createStructuredSessionMocks() { turnId: mocks.turnId, canStop: mocks.canStop ?? mocks.turnId !== null, stopPressed: mocks.stopPressed, + queueCapable: mocks.queueCapable, stop: mocks.stop, queuedMessages: { cards: mocks.queuedCards, @@ -329,6 +331,7 @@ export function createStructuredSessionMocks() { mocks.turnId = null mocks.canStop = null mocks.stopPressed = false + mocks.queueCapable = false mocks.supportsBackgroundTaskStop = false mocks.supportsBackgroundTaskStopAll = true mocks.stopBackgroundTask.mockReset() diff --git a/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts index 730d4d95ef3c..0fbb8a9cec79 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-placeholder.test.ts @@ -7,9 +7,13 @@ import { nativeChatComposerPlaceholder } from './native-chat-composer-target' describe("the composer's placeholder", () => { it('says a message runs after the stop while the chat reads Stopping', () => { - expect(nativeChatComposerPlaceholder(true, true, true)).toBe( + expect(nativeChatComposerPlaceholder(true, true, 'queue')).toBe( 'Queue a message to run after the stop' ) + // Where the host does not queue sends, it holds the message until the stop lands. + expect(nativeChatComposerPlaceholder(true, true, 'send')).toBe( + 'Send a message to run after the stop' + ) }) it('reads as usual otherwise', () => { @@ -18,7 +22,7 @@ describe("the composer's placeholder", () => { // A held input or a lost terminal says why first: nothing can be queued from here then. it('keeps the reasons nothing can be sent ahead of it', () => { - expect(nativeChatComposerPlaceholder(true, false, true)).toBe( + expect(nativeChatComposerPlaceholder(true, false, 'queue')).toBe( 'Input is held by another device.' ) }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-target.ts b/src/renderer/src/components/native-chat/native-chat-composer-target.ts index 1b02f5d7cdcf..ff4e64bc6abb 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-target.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-target.ts @@ -12,10 +12,14 @@ export type NativeChatResolvedTarget = { * pathological clipboard can't stall the round-trip. */ export const NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES = 16 * 1024 * 1024 +/** How a message sent while the chat reads Stopping goes out: as a queued card, or sent for the + * host to hold until the stop lands. */ +export type NativeChatAfterStopSend = 'queue' | 'send' + export function nativeChatComposerPlaceholder( hasPty: boolean, canSend: boolean, - queuesAfterStop = false + afterStop?: NativeChatAfterStopSend ): string { if (!hasPty) { return translate( @@ -26,12 +30,18 @@ export function nativeChatComposerPlaceholder( if (!canSend) { return translate('components.native-chat.composer.locked', 'Input is held by another device.') } - if (queuesAfterStop) { + if (afterStop === 'queue') { return translate( 'components.native-chat.status.queueAfterStop', NATIVE_CHAT_TURN_STATUS_COPY.queueAfterStop ) } + if (afterStop === 'send') { + return translate( + 'components.native-chat.status.sendAfterStop', + NATIVE_CHAT_TURN_STATUS_COPY.sendAfterStop + ) + } return translate('components.native-chat.composer.placeholder', 'Send a message…') } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index ec3512353c2d..d7c93b2000ee 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -9,6 +9,7 @@ import type { } from '../../../../shared/native-chat-session-options' import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import type { NativeChatAfterStopSend } from './native-chat-composer-target' export type NativeChatOptionPickerRequest = { id: string @@ -59,8 +60,9 @@ export type NativeChatComposerProps = { isWorking?: boolean /** This client's Stop request is in flight: the Stop control is disabled and says so. */ isStopping?: boolean - /** The chat reads Stopping: the placeholder says a message runs after the stop. */ - queuesAfterStop?: boolean + /** The chat reads Stopping: the placeholder says a message runs after the stop, queued as a + * card where the host holds sends as cards (`queue`), else sent and held by the host (`send`). */ + afterStop?: NativeChatAfterStopSend /** Interrupt the hosted agent, usually by sending ESC into the PTY. */ onStop?: () => void /** Render an optimistic echo until the real transcript turn lands. */ diff --git a/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts b/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts index b4c370c71cde..f3ff1926be35 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts @@ -1,9 +1,10 @@ import { agentStopDisplayStatus } from '../../../../shared/agent-stop-display-status' import type { useStructuredAgentSession } from './use-structured-agent-session' +import type { NativeChatAfterStopSend } from './native-chat-composer-target' type StopController = Pick< ReturnType, - 'canStop' | 'stopPressed' | 'stop' | 'queuedMessages' + 'canStop' | 'stopPressed' | 'stop' | 'queuedMessages' | 'queueCapable' > /** @@ -21,7 +22,7 @@ export function nativeChatStructuredStopControls( isStopping: boolean onStop: () => void steerQueued: (() => boolean) | undefined - queuesAfterStop: boolean + afterStop: NativeChatAfterStopSend | undefined } } { const stopping = @@ -37,7 +38,7 @@ export function nativeChatStructuredStopControls( isStopping: stopInFlight, onStop: () => void (stopInFlight || controller.stop()), steerQueued: stopping ? undefined : controller.queuedMessages.steerNewest, - queuesAfterStop: stopping + afterStop: stopping ? (controller.queueCapable ? 'queue' : 'send') : undefined } } } diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index a6ae10cc2066..fdd401fc5f9e 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -246,11 +246,13 @@ export function nativeChatSlotIndexOf( * that the journal holds no place for: they draw after the live activity, not inside it. */ export function splitNativeChatSlotsWaitingBehindLiveTurn( slots: readonly NativeChatTranscriptSlot[], - journalItems: readonly AgentJournalRenderItem[] | undefined + journalItems: readonly AgentJournalRenderItem[] | undefined, + stopping = false ): { slots: NativeChatTranscriptSlot[]; waitingSlots: NativeChatTranscriptSlot[] } { const waiting = nativeChatMessagesWaitingBehindLiveTurn( slots.flatMap((slot) => (slot.kind === 'message' ? [slot.message] : [])), - journalItems + journalItems, + stopping ) const isWaiting = (slot: NativeChatTranscriptSlot): boolean => slot.kind === 'message' && diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx index 1f84eb7a8ed5..c1e787966bca 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx @@ -1,8 +1,9 @@ // @vitest-environment happy-dom -// A message sent while the chat reads Stopping, through the chat's own send and its real outbox: -// the request asks the host to queue it, whatever the queueing setting, and the transcript draws -// no bubble of it inside the turn the Stop is ending. +// A message sent while the chat reads Stopping, through the chat's own send and its real outbox. +// Where the host queues sends, the request asks it to, whatever the queueing setting, and the +// transcript draws no bubble of it. Where it does not, the send goes out plain, for the host to hold +// until the stop lands, and is drawn as this client's outgoing message, after the Stopping line. import { cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' @@ -68,8 +69,8 @@ afterEach(() => { mocks.call.mockReset() }) -it('asks the host to queue a send made while the host reads Stopping, and draws no bubble', async () => { - const { result } = renderHook(() => +function renderStopping() { + return renderHook(() => useStructuredAgentSession({ sessionId: 'session-1', agent: 'codex', @@ -80,6 +81,10 @@ it('asks the host to queue a send made while the host reads Stopping, and draws hostStopping: true }) ) +} + +it('asks the host to queue a send made while the host reads Stopping, and draws no bubble', async () => { + const { result } = renderStopping() expect(result.current.send('run this after the stop')).toBe(true) @@ -87,3 +92,18 @@ it('asks the host to queue a send made while the host reads Stopping, and draws expect(sends()[0]?.delivery).toBe('queue-if-active') expect(JSON.stringify(result.current.messages)).not.toContain('run this after the stop') }) + +it('sends plain where the host does not queue sends, drawn as an outgoing message', async () => { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY]) + const { result } = renderStopping() + + expect(result.current.send('run this after the stop')).toBe(true) + + await waitFor(() => expect(sends()).toHaveLength(1)) + expect(sends()[0]).not.toHaveProperty('delivery') + expect( + result.current.messages.find((message) => + JSON.stringify(message.blocks).includes('run this after the stop') + ) + ).toMatchObject({ role: 'user', outgoing: true }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 6ee83db75cc1..1bdff1f8d890 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -259,6 +259,8 @@ export function useStructuredAgentSession(args: { : Promise.resolve(null) }, queuedMessages: queuedController, + /** The host holds a send made while the agent works as a queued card. */ + queueCapable, cancel: async (turnId: string, prompt?: StructuredPromptCancelTarget) => { // Capability negotiation must complete before mutate fingerprints the payload: // older hosts reject the strict prompt field. diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 979b8bef5eec..fe7355ffdff7 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2811,6 +2811,7 @@ "interruptedAfter": "Interrupted after {{value0}}", "queueAfterStop": "Queue a message to run after the stop", "responding": "Agent is responding", + "sendAfterStop": "Send a message to run after the stop", "toggleDetails": "Toggle turn details", "workedFor": "Worked for {{value0}}", "workingFor": "Working for {{value0}}" diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7d02077737ae..e5af368b653f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17801,6 +17801,7 @@ "working": "Working…", "stopping": "Stopping…", "queueAfterStop": "Queue a message to run after the stop", + "sendAfterStop": "Send a message to run after the stop", "thinking": "Thinking", "workingFor": "Working for {{value0}}", "workedFor": "Worked for {{value0}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f1205b54014d..35e36d3ca93e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15121,6 +15121,7 @@ "responding": "El agente está respondiendo", "stopping": "Deteniendo…", "queueAfterStop": "Pon en cola un mensaje para que se ejecute después de detenerse", + "sendAfterStop": "Envía un mensaje para que se ejecute después de detenerse", "interruptedAfter": "Interrumpido tras {{value0}}", "failedAfter": "Falló tras {{value0}}" }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 3729fb99eee4..fcb94e2f23e5 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17662,6 +17662,7 @@ "working": "Fonctionnement…", "stopping": "Arrêt en cours…", "queueAfterStop": "Mettez un message en file d'attente pour l'exécuter après l'arrêt", + "sendAfterStop": "Envoyez un message pour l'exécuter après l'arrêt", "thinking": "Réflexion", "workingFor": "En cours depuis {{value0}}", "workedFor": "A travaillé pendant {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c02c408cfd9f..b78e8ec5de2a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17598,6 +17598,7 @@ "working": "処理中…", "stopping": "停止中…", "queueAfterStop": "停止後に実行するメッセージをキューに追加", + "sendAfterStop": "停止後に実行するメッセージを送信", "thinking": "考え", "workingFor": "{{value0}} 処理中", "workedFor": "{{value0}} で完了", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f1b4ad6803fe..3b326303b79d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17598,6 +17598,7 @@ "working": "작업 중…", "stopping": "중지하는 중…", "queueAfterStop": "중지 후에 실행할 메시지를 대기열에 추가", + "sendAfterStop": "중지 후에 실행할 메시지 보내기", "thinking": "생각", "workingFor": "{{value0}} 작업 중", "workedFor": "{{value0}} 작업 완료", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index eaac07c405d3..865270d2feb0 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17563,6 +17563,7 @@ "working": "处理中…", "stopping": "正在停止…", "queueAfterStop": "将消息加入队列,在停止后运行", + "sendAfterStop": "发送消息,在停止后运行", "thinking": "思维", "workingFor": "已工作 {{value0}}", "workedFor": "工作了 {{value0}}", diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index f00923996c40..0731f8e6d0c6 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -223,16 +223,21 @@ function anchoringUserItems(recordKeys: ReadonlyMap): Rea /** * The rows accepted but not yet handed over while a conversation command's turn runs, as a message * sent during `/compact` is: the host hands nothing over until the command ends, so they draw after - * that turn's live activity. Any other running turn takes a send within moments, so it stays put. + * that turn's live activity. Any other running turn takes a send within moments, so it stays put, + * unless a person's Stop is ending it (`stopping`): the host holds every send until it ends, so + * those, and sends it has not answered yet, draw after the live activity too. */ export function nativeChatMessagesWaitingBehindLiveTurn( - messages: readonly { id: string; queued?: true }[], - items: readonly AgentJournalRenderItem[] | null | undefined + messages: readonly { id: string; queued?: true; outgoing?: true }[], + items: readonly AgentJournalRenderItem[] | null | undefined, + stopping = false ): ReadonlySet { - const queued = messages.filter((message) => message.queued === true) + const waits = (message: (typeof messages)[number]): boolean => + message.queued === true || (stopping && message.outgoing === true) + const waiting = messages.filter(waits) return new Set( - queued.length > 0 && items && commandTurnRunning(items) - ? queued.map((message) => message.id) + waiting.length > 0 && items && (stopping || commandTurnRunning(items)) + ? waiting.map((message) => message.id) : [] ) } diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index d22029be9996..31891b7ba49d 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -10,8 +10,10 @@ export const NATIVE_CHAT_TURN_STATUS_COPY = { thinking: 'Thinking', working: 'Working…', stopping: 'Stopping…', - /** The composer's placeholder while the chat reads Stopping. */ + /** The composer's placeholder while the chat reads Stopping, where the host queues sends. */ queueAfterStop: 'Queue a message to run after the stop', + /** The same where it does not: the host holds the send until the stop lands. */ + sendAfterStop: 'Send a message to run after the stop', workingFor: 'Working for {{value0}}', workedFor: 'Worked for {{value0}}', interruptedAfter: 'Interrupted after {{value0}}', @@ -82,6 +84,9 @@ export type NativeChatLiveTurnIndicator = { stopping?: boolean /** This client's own Stop request is in flight: only then does its Stop control hold. */ stopRequestInFlight?: boolean + /** While stopping: a message sent now is queued as a card where the host queues sends, else sent + * for the host to hold until the stop lands. */ + afterStop?: 'queue' | 'send' } export type NativeChatActiveTurnLabel = diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index db7b7024cdc1..565f81fdc2e2 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -215,6 +215,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { /** Shown as not sent, waiting for the user's Retry: in no turn, so a newer turn's bar and clock * never land on it, and drawn after the conversation. */ unsent?: true + /** This client's send the host has not recorded yet. */ + outgoing?: true /** Set only by the structured projection, on rows the journal holds, and ranks * them ahead of time. Terminal-backed messages never carry it, and worker reads strip it. */ journalPosition?: AgentJournalPosition diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 5515b6478a22..d73cba42a218 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -65,7 +65,7 @@ export function projectStructuredAgentSessionMessages( blocks: entry.body.blocks, ...(entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry) ? { unsent: true as const } - : {}), + : { outgoing: true as const }), // A send the journal recorded before refusing it keeps its place there. ...(recorded ? { journalPosition: agentJournalItemPosition(recorded) } : {}) } From 70601f5cb325b4e7bdbfc36fe54befb4af3254ab Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:19:22 -0700 Subject: [PATCH 112/182] fix(native-chat): keep a send in doubt when a turn was open for it The derived withdrawal read a turn as open for a send only if it still ran or was written after the send. A send steered into a running Codex turn whose interrupt failed met neither once the adapter's end settled that turn ahead of the host's settle, so it read withdrawn, though Codex drains a steer into the running turn and may hold it. A turn that ended after the send was handed over was open for it too: such a send stays in doubt, as before. Pins that case, and that a send made after the Stop, to a child that then dies before its turn opens, stays in doubt. --- ...-agent-session-unopened-send-withdrawal.ts | 30 +++++++++++----- ...-session-codex-turn-end-settlement.test.ts | 35 +++++++++++++++++-- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts index 7e1a6a629ce3..fb435cc5266c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts @@ -14,7 +14,7 @@ export type UnopenedSendJournal = { agent?: AgentType queuedMessages?: Pick snapshot: () => Pick, 'items'> - submissions?: () => Pick< + submissions?: () => (Pick< AgentJournalSubmission, | 'clientMessageId' | 'dispatchState' @@ -22,15 +22,17 @@ export type UnopenedSendJournal = { | 'handoverRecorded' | 'handedOverAt' | 'acceptedSequence' - >[] + > & + Partial>)[] resolveDispatch?: AgentSessionJournal['resolveDispatch'] } /** * Withdraws the sends a Codex child left unanswered when a person's Stop, in force since they were - * sent, ends it and no turn opened for them: no turn row runs, or was written, after the send. - * Codex records a prompt only once its turn starts (core tasks/regular.rs:50, - * session/turn.rs:886-902), so they never ran. Any other end leaves them in doubt. + * sent, ends it and no turn was open for them: none runs, was written after the send, or ended + * after it was handed over. Codex records a prompt only once its turn starts (core + * tasks/regular.rs:50, session/turn.rs:886-902), so they never ran. A send steered into an open + * turn may have been recorded there, so it, and any other end, stays in doubt. */ export async function withdrawCodexSendsNoTurnOpenedFor( journal: UnopenedSendJournal, @@ -42,10 +44,20 @@ export async function withdrawCodexSendsNoTurnOpenedFor( } const turns = journal.snapshot().items.flatMap((item) => { const turn = readAgentJournalTurn(item.body) - return turn ? [{ running: turn.state === 'running', sequence: item.sequence }] : [] + return turn + ? [ + { + running: turn.state === 'running', + sequence: item.sequence, + completedAt: turn.completedAt ?? 0 + } + ] + : [] }) - const opened = (acceptedSequence: number): boolean => - turns.some((turn) => turn.running || turn.sequence > acceptedSequence) + const opened = (acceptedSequence: number, sentAt: number): boolean => + turns.some( + (turn) => turn.running || turn.sequence > acceptedSequence || turn.completedAt >= sentAt + ) const unopened = journal .submissions() .filter( @@ -55,7 +67,7 @@ export async function withdrawCodexSendsNoTurnOpenedFor( (entry.dispatchState === 'unknown' && entry.recovered !== true)) && entry.acceptedSequence !== undefined && entry.acceptedSequence < stop.sequence && - !opened(entry.acceptedSequence) + !opened(entry.acceptedSequence, entry.handedOverAt ?? entry.submittedAt ?? 0) ) const withdrawn = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 66c25017b296..b07da933e106 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -602,14 +602,15 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { return sent } - async function nextSendStartsANewChild(): Promise { + async function nextSendStartsANewChild(): Promise { turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) ) - await send('try again') + const next = await send('try again') await vi.waitFor(() => expect(answers).toBe(2)) expect(connections).toBe(2) + return next } it('withdraws the send whose turn never opened, and the next send starts a new child', async () => { @@ -643,6 +644,36 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { expect(childCloses).toBe(1) expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') }) + + // Codex drains a steer into the running turn, so it may hold one it never echoed. + it('leaves a send steered into the open turn in doubt when the child end follows', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + const steered = await send('and check the tests') + await vi.waitFor(() => expect(steers).toBe(1)) + interruptsFail = true + + await stop('turn-1') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, steered)).toBe('unknown') + }) + + // Sent after the Stop, to a child that then dies before its turn opens: the Stop, still in force, + // was never this send's. + it('leaves a send made after the Stop in doubt when its child dies', async () => { + await stoppedBeforeItsTurnOpened() + const next = await nextSendStartsANewChild() + + handlers?.onExit?.(new Error('codex app-server exited')) + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, next)).not.toBe('pending') + ) + expect(verdictOf((await settled()).submissions, next)).toBe('unknown') + }) }) describe('a cold send with no Stop behind it', () => { From 980bf93e5a023f6d3aae8f308d99219b78ead77d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:22:43 -0700 Subject: [PATCH 113/182] fix(native-chat): restore the per-card queue pause label Kept after all: a paused queue labels only the cards it holds (heldByPause), which is visible only where the host advertises the queued-messages capability. --- ...le-structured-queued-message-cards.test.ts | 10 +++++++ .../mobile-structured-queued-message-cards.ts | 5 +++- ...ctured-agent-session-queued-publication.ts | 27 ++++++++++++++----- ...gent-session-codex-interrupt-order.test.ts | 7 ++++- ...uctured-agent-session-queued-cards.test.ts | 18 +++++++++++++ .../structured-agent-session-queued-cards.ts | 5 +++- .../agent-session-queued-message-wire.ts | 4 +++ 7 files changed, 66 insertions(+), 10 deletions(-) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 44d1e0b9729f..19b5efb2e1c3 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -66,6 +66,16 @@ describe('mobileQueuedMessageCards', () => { expect(card?.needsAttention).toBe(false) }) + // A card queued after a person's Stop is not the pause's: it waits as any card does. + it('captions a card the host says its pause lets through as any waiting card', () => { + const cards = mobileQueuedMessageCards( + [draft({ messageId: 'held', heldByPause: true }), draft({ messageId: 'b', position: 2 })], + [], + { pendingPrompt: true, queuePaused: true } + ) + expect(cards.map((card) => card.caption)).toEqual([null, 'Waiting for your answer']) + }) + it("keeps a card's own failed send and reads a prompt's wait as queued under a paused queue", () => { const cards = mobileQueuedMessageCards( [ diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index f3edb13e53c8..002cf38bc784 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -103,6 +103,9 @@ export function mobileQueuedMessageCards( : [] ) ) + // A host that names the cards its pause holds lets the rest wait as usual; an older host names + // none, and its pause holds every card. + const pauseNamesCards = queuedMessages.some((draft) => draft.heldByPause === true) let behindReturned = false const cards: MobileQueuedMessageCard[] = [] for (const draft of queuedMessages) { @@ -117,7 +120,7 @@ export function mobileQueuedMessageCards( ? pausedCaption(draft.pausedReason) : behindReturned ? 'Waiting — a message ahead needs attention' - : facts.queuePaused + : facts.queuePaused && (!pauseNamesCards || draft.heldByPause === true) ? // The pause row says why and offers Resume; the card promises no send time. null : facts.pendingPrompt diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index 1fa46616da18..fb564156df96 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -9,7 +9,11 @@ import { type AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { resumableQueuePause } from '../agent-session-journal/queued-message-pause' +import { + queuePauseHolding, + resumableQueuePause, + type DerivedQueuePause +} from '../agent-session-journal/queued-message-pause' import { structuredQueuePauses } from './structured-agent-session-queued-pause' export type QueuePublication = { @@ -19,7 +23,10 @@ export type QueuePublication = { /** Waiting and returned rows only. `paused` is a per-card hold (a failed * conversion); a Stop or a restart pauses the queue, published once beside it. */ -function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { +function computePublishedQueuedMessages( + journal: AgentSessionJournal, + pauses: readonly DerivedQueuePause[] +): AgentSessionQueuedMessage[] { const published: AgentSessionQueuedMessage[] = [] for (const row of journal.queuedMessages.list()) { if (row.state !== 'waiting' && row.state !== 'returned') { @@ -27,6 +34,7 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess } const held = row.state === 'waiting' && row.holdReason !== null published.push({ + ...(queuePauseHolding(pauses, row) ? { heldByPause: true as const } : {}), messageId: row.messageId, position: row.position, body: row.body, @@ -53,13 +61,17 @@ type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessa const listMemos = new WeakMap() const publications = new WeakMap() -function readPublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { - const key = String(journal.queuedMessages.revision()) +function readPublishedQueuedMessages( + journal: AgentSessionJournal, + pauses: readonly DerivedQueuePause[] +): AgentSessionQueuedMessage[] { + // The pauses turn on rows outside the draft table (a Stop, a person's turn), so they key it too. + const key = `${journal.queuedMessages.revision()}:${JSON.stringify(pauses)}` const memo = listMemos.get(journal) if (memo && memo.key === key) { return memo.list } - const list = computePublishedQueuedMessages(journal) + const list = computePublishedQueuedMessages(journal, pauses) // Belt for the identity dedup: equal recomputed content keeps the previous reference. const serialized = JSON.stringify(list) if (memo && memo.serialized === serialized) { @@ -80,11 +92,12 @@ export function sameQueuePause( } export function readQueuePublication(journal: AgentSessionJournal): QueuePublication { - const queuedMessages = readPublishedQueuedMessages(journal) // Read per emit: the pause also turns on submissions (a person's turn starting). Shown only // over a card Resume would send, so its header never offers to send nothing; deleting a // blocking returned card shows it again. - const pause = resumableQueuePause(structuredQueuePauses(journal), journal.queuedMessages.list()) + const pauses = structuredQueuePauses(journal) + const queuedMessages = readPublishedQueuedMessages(journal, pauses) + const pause = resumableQueuePause(pauses, journal.queuedMessages.list()) const queuePause = pause ? { reason: pause.reason } : null const previous = publications.get(journal) if ( diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index 46d084d91bbd..f00f1a56a891 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -405,6 +405,11 @@ describe('a queued send while Stopping, behind a card the Stop paused', () => { const during = await sendQueued('sent while stopping') const runsNext = 'queued' in during ? during.queued.messageId : '' + // The pause names the one card it holds; the card queued after the Stop is not one. + expect((await queuePage()).cards.map((card) => [card.messageId, card.heldByPause])).toEqual([ + [paused, true], + [runsNext, undefined] + ]) answer.resolve() await stopping turns.end('interrupted') @@ -417,7 +422,7 @@ describe('a queued send while Stopping, behind a card the Stop paused', () => { ) expect(handOffs).toEqual([runsNext]) expect(await queuePage()).toEqual({ - cards: [expect.objectContaining({ messageId: paused, state: 'waiting' })], + cards: [expect.objectContaining({ messageId: paused, state: 'waiting', heldByPause: true })], pause: expect.objectContaining({ reason: 'stopped' }) }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 995d80247699..7d0e3c22ac7b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -137,6 +137,24 @@ describe('queued message cards', () => { expect(cards.map((card) => card.messageId)).toEqual(['kept', 'refused']) }) + // A card queued after a person's Stop runs when the stop lands, past the cards its pause holds. + it('a paused queue labels only the cards its host says it holds', () => { + const cards = projectQueuedMessageCards( + [draft('held', 1, { heldByPause: true }), draft('queued while stopping', 2)], + [], + { hasPendingPrompt: false, queuePaused: true } + ) + expect(cards.map((card) => card.hold)).toEqual(['queue-paused', 'turn']) + }) + + it('a paused queue from a host that names no held card labels every card, as before', () => { + const cards = projectQueuedMessageCards([draft('a', 1), draft('b', 2)], [], { + hasPendingPrompt: false, + queuePaused: true + }) + expect(cards.map((card) => card.hold)).toEqual(['queue-paused', 'queue-paused']) + }) + it('a paused queue outranks a pending prompt: an answer does not drain it', () => { const cards = projectQueuedMessageCards( [ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index e9d1f394b9af..eb84a65e5191 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts @@ -61,6 +61,9 @@ export function projectQueuedMessageCards( const ordered = [...(queuedMessages ?? [])] .sort((left, right) => left.position - right.position) .filter((message) => message.state === 'returned' || !handedOff.has(message.messageId)) + // A host that names the cards its pause holds lets the rest read as waiting their turn; an older + // host names none, and its pause holds every card. + const pauseNamesCards = ordered.some((message) => message.heldByPause === true) let behindReturned = false return ordered.map((message) => { const hold: QueuedMessageCardHold = @@ -70,7 +73,7 @@ export function projectQueuedMessageCards( ? 'paused' : behindReturned ? 'behind-returned' - : session.queuePaused + : session.queuePaused && (!pauseNamesCards || message.heldByPause === true) ? 'queue-paused' : session.hasPendingPrompt ? 'awaiting-answer' diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index f7659c95873b..05539eef5c0e 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -37,6 +37,10 @@ export type AgentSessionQueuedMessage = { * marker as a plain hold, so a newer host can add one. The queue-level * pause is `queuePause`, published beside the list. */ pausedReason?: AgentSessionQueuedMessagePausedReason + /** The queue's pause holds this card: Resume sends it. A card the pause lets through (one + * queued after a person's Stop) has none, though `queuePause` is set. Absent on every card from + * an older host, whose pause holds them all. */ + heldByPause?: true /** A returned card's refusal: the `reason` and `rejection` pair its submission settled with. * Only a failure returns a card; a draft a Stop or restart took back waits again. Clients classify it from `returnedRejection` (falling back to `returnedReason` when a host * wrote no fact) exactly as they classify a rejected submission's `rejection`, e.g. From 6186504a2b17233b63663a195655f0cc50095c27 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:41:39 -0700 Subject: [PATCH 114/182] fix(native-chat): draw only a send made while Stopping after the Stopping line Every send the host had not recorded yet was drawn after the Stopping line, including one made just before the Stop, which the host steers into the turn; it then jumped up into that turn once recorded. The outbox now marks a send made while the chat reads Stopping, and only those wait after the line. --- ...ChatMessageList.stopping-turn-bar.test.tsx | 21 +++++++++++- ...structured-agent-session-outbox-storage.ts | 6 ++-- .../use-structured-agent-session-outbox.ts | 15 ++++++-- ...gent-session.queue-while-stopping.test.tsx | 34 +++++++++++++++++-- .../use-structured-agent-session.ts | 3 +- src/shared/native-chat-turn-membership.ts | 9 ++--- src/shared/native-chat-types.ts | 4 +-- ...ctured-agent-session-message-projection.ts | 4 ++- ...tructured-agent-session-outbox-delivery.ts | 6 ++-- src/shared/structured-agent-session-outbox.ts | 2 ++ 10 files changed, 86 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx index 0655b1f0c73d..ec7e00acd2e3 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx @@ -73,7 +73,7 @@ describe('a message sent while Stopping, before the host has handed it over', () const pending = { id: 'pending-send', role: 'user' as const, - outgoing: true as const, + sentWhileStopping: true as const, blocks: [{ type: 'text' as const, text: 'Run this after the stop' }], timestamp: Date.now(), source: 'transcript' as const @@ -107,4 +107,23 @@ describe('a message sent while Stopping, before the host has handed it over', () const working = screen.getByText('Working…') expect(sent.compareDocumentPosition(working)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) }) + + // Sent just before the Stop, the host steers it into the turn: it stays there, never jumping. + it('keeps a send made before the Stop in the turn', () => { + const { sentWhileStopping: _made, ...beforeStop } = pending + render( + + ) + + const sent = screen.getByText('Run this after the stop') + const stopping = screen.getByText('Stopping…') + expect(sent.compareDocumentPosition(stopping)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index ce270d60ddba..8990db6ee3c3 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -180,7 +180,8 @@ export function appendStructuredAgentSessionOutboxMessage( sessionId: string, text: string, attachments: readonly StructuredAgentSessionAttachment[] = [], - source?: 'launch' + source?: 'launch', + sentWhileStopping = false ): StructuredAgentSessionOutboxEntry | null { const entry = { ...createStructuredAgentSessionOutboxEntry({ @@ -190,7 +191,8 @@ export function appendStructuredAgentSessionOutboxMessage( attachments, queuedAt: Date.now() }), - ...(source ? { source } : {}) + ...(source ? { source } : {}), + ...(sentWhileStopping ? { sentWhileStopping: true as const } : {}) } return commitStructuredAgentSessionOutbox( sessionId, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 51bfc0489397..0f852f93a8c8 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -70,6 +70,8 @@ export function useStructuredAgentSessionOutbox(args: { * its entry here under the draft's own id; once the host visibly holds the draft, the * entry retires so the same text can never come back twice. */ queuedMessageIds?: readonly string[] + /** The chat reads Stopping: a send made now is marked as made while stopping. */ + stopping?: boolean }) { const { composerScopeKey, @@ -77,6 +79,7 @@ export function useStructuredAgentSessionOutbox(args: { queueDelivery = NO_QUEUE_DELIVERY, queuedMessageIds, sessionId, + stopping: sentWhileStopping = false, submissions, target } = args @@ -265,14 +268,22 @@ export function useStructuredAgentSessionOutbox(args: { return false } // Whether it asks to be queued is decided when it first goes out. - if (!appendStructuredAgentSessionOutboxMessage(sessionId, text, attachments)) { + if ( + !appendStructuredAgentSessionOutboxMessage( + sessionId, + text, + attachments, + undefined, + sentWhileStopping + ) + ) { setError('Message could not be saved to the outbox') return false } setError(null) return true }, - [sessionId] + [sentWhileStopping, sessionId] ) const { withdrawUnsent } = useStructuredAgentSessionOutboxOwnership({ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx index c1e787966bca..2a1102b30d86 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx @@ -3,7 +3,7 @@ // A message sent while the chat reads Stopping, through the chat's own send and its real outbox. // Where the host queues sends, the request asks it to, whatever the queueing setting, and the // transcript draws no bubble of it. Where it does not, the send goes out plain, for the host to hold -// until the stop lands, and is drawn as this client's outgoing message, after the Stopping line. +// until the stop lands, and is marked as sent while stopping, so it draws after the Stopping line. import { cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' @@ -93,7 +93,7 @@ it('asks the host to queue a send made while the host reads Stopping, and draws expect(JSON.stringify(result.current.messages)).not.toContain('run this after the stop') }) -it('sends plain where the host does not queue sends, drawn as an outgoing message', async () => { +it('sends plain where the host does not queue sends, marked as sent while stopping', async () => { setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY]) const { result } = renderStopping() @@ -105,5 +105,33 @@ it('sends plain where the host does not queue sends, drawn as an outgoing messag result.current.messages.find((message) => JSON.stringify(message.blocks).includes('run this after the stop') ) - ).toMatchObject({ role: 'user', outgoing: true }) + ).toMatchObject({ role: 'user', sentWhileStopping: true }) +}) + +// Sent before the Stop, the host steers it into the turn: it is not one held behind the Stop. +it('does not mark a send made before the chat read Stopping', async () => { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY]) + const { result, rerender } = renderHook( + ({ hostStopping }: { hostStopping: boolean }) => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'codex', + target: { kind: 'local' }, + isVisible: true, + composerScopeKey: 'scope-1', + queueFollowUps: false, + hostStopping + }), + { initialProps: { hostStopping: false } } + ) + expect(result.current.send('sent before the stop')).toBe(true) + + rerender({ hostStopping: true }) + + await waitFor(() => expect(sends()).toHaveLength(1)) + const sent = result.current.messages.find((message) => + JSON.stringify(message.blocks).includes('sent before the stop') + ) + expect(sent).toBeDefined() + expect(sent).not.toHaveProperty('sentWhileStopping') }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 1bdff1f8d890..212cd9ed8dc7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -140,7 +140,8 @@ export function useStructuredAgentSession(args: { submissions: transportState.submissions, composerScopeKey, queueDelivery, - queuedMessageIds + queuedMessageIds, + stopping }) const threadGoal = useStructuredAgentSessionThreadGoal({ diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index 0731f8e6d0c6..0ac37087763e 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -224,16 +224,17 @@ function anchoringUserItems(recordKeys: ReadonlyMap): Rea * The rows accepted but not yet handed over while a conversation command's turn runs, as a message * sent during `/compact` is: the host hands nothing over until the command ends, so they draw after * that turn's live activity. Any other running turn takes a send within moments, so it stays put, - * unless a person's Stop is ending it (`stopping`): the host holds every send until it ends, so - * those, and sends it has not answered yet, draw after the live activity too. + * unless a person's Stop is ending it (`stopping`): the host holds a send made then until it ends, + * so those, and this client's own sends made then that it has not recorded yet, draw after the + * live activity too. A send made before the Stop stays where it is. */ export function nativeChatMessagesWaitingBehindLiveTurn( - messages: readonly { id: string; queued?: true; outgoing?: true }[], + messages: readonly { id: string; queued?: true; sentWhileStopping?: true }[], items: readonly AgentJournalRenderItem[] | null | undefined, stopping = false ): ReadonlySet { const waits = (message: (typeof messages)[number]): boolean => - message.queued === true || (stopping && message.outgoing === true) + message.queued === true || (stopping && message.sentWhileStopping === true) const waiting = messages.filter(waits) return new Set( waiting.length > 0 && items && (stopping || commandTurnRunning(items)) diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 565f81fdc2e2..c72c219f7586 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -215,8 +215,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { /** Shown as not sent, waiting for the user's Retry: in no turn, so a newer turn's bar and clock * never land on it, and drawn after the conversation. */ unsent?: true - /** This client's send the host has not recorded yet. */ - outgoing?: true + /** This client's send, made while the chat read Stopping, that the host has not recorded yet. */ + sentWhileStopping?: true /** Set only by the structured projection, on rows the journal holds, and ranks * them ahead of time. Terminal-backed messages never carry it, and worker reads strip it. */ journalPosition?: AgentJournalPosition diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index d73cba42a218..0a097c8d7085 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -65,7 +65,9 @@ export function projectStructuredAgentSessionMessages( blocks: entry.body.blocks, ...(entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry) ? { unsent: true as const } - : { outgoing: true as const }), + : entry.sentWhileStopping + ? { sentWhileStopping: true as const } + : {}), // A send the journal recorded before refusing it keeps its place there. ...(recorded ? { journalPosition: agentJournalItemPosition(recorded) } : {}) } diff --git a/src/shared/structured-agent-session-outbox-delivery.ts b/src/shared/structured-agent-session-outbox-delivery.ts index 28ced3d2fee7..ab3e8be4b2d2 100644 --- a/src/shared/structured-agent-session-outbox-delivery.ts +++ b/src/shared/structured-agent-session-outbox-delivery.ts @@ -55,11 +55,13 @@ export function structuredAgentSessionEntryAttempt( export function parseStructuredAgentSessionOutboxQueueFields(entry: { sentDelivery?: unknown outlivedStop?: unknown -}): Pick { + sentWhileStopping?: unknown +}): Pick { return { ...(entry.sentDelivery === 'queue-if-active' || entry.sentDelivery === null ? { sentDelivery: entry.sentDelivery } : {}), - ...(entry.outlivedStop === true ? { outlivedStop: true as const } : {}) + ...(entry.outlivedStop === true ? { outlivedStop: true as const } : {}), + ...(entry.sentWhileStopping === true ? { sentWhileStopping: true as const } : {}) } } diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index d7ee2f59d3dd..b4b9d1b711d6 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -40,6 +40,8 @@ export type StructuredAgentSessionOutboxEntry = { * of this id asks the same (structured-agent-session-outbox-delivery). On a request's own copy, * what that request carries. */ sentDelivery?: 'queue-if-active' | null + /** Sent while the chat read Stopping: until the host records it, it is drawn after that turn. */ + sentWhileStopping?: true /** Why the last attempt did not go through. Lives on the message so it goes when the message * is sent again or delivered, instead of outliving it as a separate error. On a `queued` entry * it is also the hold (structured-agent-session-outbox-admission). */ From 8e3240ea389654c83bf9a3aaad2038d33cb0b878 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:49:48 -0700 Subject: [PATCH 115/182] fix(native-chat): withdraw a Codex send by whether it started its own turn, not by timing Whether a turn was open for a send was read from end times: a turn that ended after the send's handover counted. A send made while a Stop ended the turn is handed over once that turn reads ended, yet Codex's own end for it can arrive later, so such a send whose own turn never opened read in doubt again, and the chat's queue held behind it. The handover already records where the send went: its message joins the turn running then (a steer) or belongs to no turn (it starts its own). Only a send that started its own turn, with none opened since, is withdrawn; one that joined a running turn, or has no recorded place, stays in doubt. The Codex test fake takes an answered interrupt as Codex does, dropping the turn before its end arrives. --- src/main/codex/codex-turn-lifecycle-fake.ts | 36 ++++++++++++---- ...-agent-session-unopened-send-withdrawal.ts | 41 +++++++++---------- ...-session-codex-turn-end-settlement.test.ts | 29 +++++++++++++ 3 files changed, 77 insertions(+), 29 deletions(-) diff --git a/src/main/codex/codex-turn-lifecycle-fake.ts b/src/main/codex/codex-turn-lifecycle-fake.ts index dda6060ee3a6..e0747f38b0b0 100644 --- a/src/main/codex/codex-turn-lifecycle-fake.ts +++ b/src/main/codex/codex-turn-lifecycle-fake.ts @@ -29,7 +29,10 @@ export type CodexTurnLifecycleFake = { /** The thread runs the picked turn, whose `turn/started` has yet to be read: an interrupt of it * is taken, and that `turn/started` comes ahead of the answer. */ run: () => void - /** Codex ends the picked or running turn on its own. */ + /** Codex takes an interrupt of the running turn: it drops it as its active turn before it + * answers, and that turn's `turn/completed` comes later (`end`). */ + takeInterrupt: () => string + /** Codex ends the picked or running turn on its own, or the one whose interrupt it took. */ end: (status: 'completed' | 'interrupted' | 'failed', errorMessage?: string) => void /** Codex fails the picked turn before starting it, which reports an `error` and no turn end. */ failUnopened: (message: string) => void @@ -59,15 +62,32 @@ export function codexTurnLifecycleFake( let lastTurn: string | null = null let held: Promise | null = null let pickedRuns = false + // The turn whose interrupt Codex took and whose `turn/completed` is still to come. + let aborting: string | null = null const finish = (turnId: string, status: string, errorMessage?: string): void => { - picked = null - active = null - pickedRuns = false + // A taken interrupt's turn is no longer active: a turn picked since stays picked. + if (aborting === turnId) { + aborting = null + } else { + picked = null + active = null + pickedRuns = false + } notify()('turn/completed', { threadId, turn: { id: turnId, status, ...(errorMessage ? { error: { message: errorMessage } } : {}) } }) } + + const takeInterrupt = (): string => { + if (!active) { + throw new Error('no running turn to interrupt') + } + aborting = active + active = null + picked = null + return aborting + } const open = (): void => { if (!picked) { throw new Error('no picked turn to start') @@ -117,10 +137,10 @@ export function codexTurnLifecycleFake( `expected active turn id ${String(turnId)} but found ${active}` ) } - const aborted = active + const aborted = takeInterrupt() // Answered, then the turn's end on the next read; a turn that ends meanwhile is not ended twice. setTimeout(() => { - if (active === aborted) { + if (aborting === aborted) { finish(aborted, 'interrupted') } }, 0) @@ -135,6 +155,7 @@ export function codexTurnLifecycleFake( return () => release() }, start: () => open(), + takeInterrupt, run: () => { if (!picked || active) { throw new Error('no picked turn to run') @@ -142,7 +163,8 @@ export function codexTurnLifecycleFake( pickedRuns = true }, end: (status, errorMessage) => { - const turnId = active ?? picked + // A taken interrupt's turn/completed comes ahead of anything a later turn sends. + const turnId = aborting ?? active ?? picked if (!turnId) { throw new Error('no turn to end') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts index fb435cc5266c..25fcbbfebc47 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts @@ -4,6 +4,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { AgentType } from '../../../shared/agent-status-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' @@ -14,7 +15,7 @@ export type UnopenedSendJournal = { agent?: AgentType queuedMessages?: Pick snapshot: () => Pick, 'items'> - submissions?: () => (Pick< + submissions?: () => Pick< AgentJournalSubmission, | 'clientMessageId' | 'dispatchState' @@ -22,17 +23,17 @@ export type UnopenedSendJournal = { | 'handoverRecorded' | 'handedOverAt' | 'acceptedSequence' - > & - Partial>)[] + >[] resolveDispatch?: AgentSessionJournal['resolveDispatch'] } /** * Withdraws the sends a Codex child left unanswered when a person's Stop, in force since they were - * sent, ends it and no turn was open for them: none runs, was written after the send, or ended - * after it was handed over. Codex records a prompt only once its turn starts (core - * tasks/regular.rs:50, session/turn.rs:886-902), so they never ran. A send steered into an open - * turn may have been recorded there, so it, and any other end, stays in doubt. + * sent, ends it: each one that started its own turn (handed over with no turn running, so its + * message belongs to no turn) when no turn has opened since. Codex records a prompt only once its + * turn starts (core tasks/regular.rs:50, session/turn.rs:886-902), so they never ran. A send that + * joined a running turn may be in it, so it, a send with no recorded place, and any other end stay + * in doubt. */ export async function withdrawCodexSendsNoTurnOpenedFor( journal: UnopenedSendJournal, @@ -42,22 +43,17 @@ export async function withdrawCodexSendsNoTurnOpenedFor( if (!stop || !journal.submissions || !journal.resolveDispatch) { return } - const turns = journal.snapshot().items.flatMap((item) => { + const { items } = journal.snapshot() + const turns = items.flatMap((item) => { const turn = readAgentJournalTurn(item.body) - return turn - ? [ - { - running: turn.state === 'running', - sequence: item.sequence, - completedAt: turn.completedAt ?? 0 - } - ] - : [] + return turn ? [{ running: turn.state === 'running', sequence: item.sequence }] : [] }) - const opened = (acceptedSequence: number, sentAt: number): boolean => - turns.some( - (turn) => turn.running || turn.sequence > acceptedSequence || turn.completedAt >= sentAt - ) + // Its message's place, recorded at handover: the turn it joined, or the conversation. + const startedOwnTurn = (clientMessageId: string): boolean => + items.find((item) => item.itemId === agentJournalSubmissionKey(clientMessageId))?.turnScope + ?.kind === 'thread' + const opened = (acceptedSequence: number): boolean => + turns.some((turn) => turn.running || turn.sequence > acceptedSequence) const unopened = journal .submissions() .filter( @@ -67,7 +63,8 @@ export async function withdrawCodexSendsNoTurnOpenedFor( (entry.dispatchState === 'unknown' && entry.recovered !== true)) && entry.acceptedSequence !== undefined && entry.acceptedSequence < stop.sequence && - !opened(entry.acceptedSequence, entry.handedOverAt ?? entry.submittedAt ?? 0) + startedOwnTurn(entry.clientMessageId) && + !opened(entry.acceptedSequence) ) const withdrawn = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index b07da933e106..500d795ed0cc 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -84,6 +84,8 @@ let connections: number let failingCloses: number /** Codex fails every interrupt it is sent, as one it could not submit (-32603). */ let interruptsFail: boolean +/** Codex takes the interrupt and answers it; the test sends that turn's end later. */ +let interruptEndHeld: boolean let turns: ReturnType let operations = 0 @@ -185,6 +187,7 @@ beforeEach(async () => { connections = 0 failingCloses = 0 interruptsFail = false + interruptEndHeld = false turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) @@ -222,6 +225,11 @@ beforeEach(async () => { 'could not submit' ) } + if (method === 'turn/interrupt' && interruptEndHeld) { + interrupts += 1 + turns.takeInterrupt() + return {} + } if (method === 'turn/interrupt') { interrupts += 1 return turns.routes['turn/interrupt'](params) @@ -645,6 +653,27 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') }) + // Handed over once the stopped turn read ended, it started its own turn, however late that + // turn's own end then arrives; a second Stop finds its turn never opened. + it('withdraws a send made after a Stop that started its own turn, which never opened', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + interruptEndHeld = true + await stop() + interruptEndHeld = false + const next = await send('run this after the stop') + await vi.waitFor(() => expect(answers).toBe(2)) + // The stopped turn's own end arrives after the next send was handed over. + turns.end('interrupted') + + expect(await settledWithin(stop(), CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, next)).toBe('withdrawn') + }) + // Codex drains a steer into the running turn, so it may hold one it never echoed. it('leaves a send steered into the open turn in doubt when the child end follows', async () => { const opening = await send('look around') From a66d22b2ee8d5717c19d87f122e40dbb305713e0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:55:38 -0700 Subject: [PATCH 116/182] refactor(native-chat): move queue-while-stopping to its own follow-up The queued-messages capability is off on every shipped host (#21062), so the parts of this PR that act only when it is on move to a follow-up stacked on this one: admitting a queued card while a Stop holds the session's lane, a card queued after a Stop running past the cards it holds, the per-card pause mark, and asking the host to queue a send made while Stopping. This PR keeps the Stopping state, the host's steer hold, the rule that never steers a turn whose interrupt Codex answered, and what a send while Stopping looks like where the queue is off. --- ...le-structured-queued-message-cards.test.ts | 10 -- .../mobile-structured-queued-message-cards.ts | 5 +- ...e-structured-agent-session-queued.test.tsx | 26 ---- .../queued-message-pause.test.ts | 8 +- .../queued-message-pause.ts | 24 +--- ...structured-agent-session-host-mutations.ts | 85 +++---------- .../structured-agent-session-host.ts | 1 - ...ructured-agent-session-mutation-context.ts | 41 ++---- ...tructured-agent-session-queued-messages.ts | 18 +-- ...ed-agent-session-queued-pause-lift.test.ts | 9 +- ...ctured-agent-session-queued-publication.ts | 27 ++-- ...ured-agent-session-queued-stop-row.test.ts | 17 ++- ...gent-session-codex-interrupt-order.test.ts | 117 ------------------ ...uctured-agent-session-queued-cards.test.ts | 18 --- .../structured-agent-session-queued-cards.ts | 5 +- ...gent-session.queue-while-stopping.test.tsx | 30 +---- ...tured-agent-session.queued-gating.test.tsx | 64 +--------- .../use-structured-agent-session.ts | 6 +- .../agent-session-queued-message-wire.ts | 4 - 19 files changed, 82 insertions(+), 433 deletions(-) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 19b5efb2e1c3..44d1e0b9729f 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -66,16 +66,6 @@ describe('mobileQueuedMessageCards', () => { expect(card?.needsAttention).toBe(false) }) - // A card queued after a person's Stop is not the pause's: it waits as any card does. - it('captions a card the host says its pause lets through as any waiting card', () => { - const cards = mobileQueuedMessageCards( - [draft({ messageId: 'held', heldByPause: true }), draft({ messageId: 'b', position: 2 })], - [], - { pendingPrompt: true, queuePaused: true } - ) - expect(cards.map((card) => card.caption)).toEqual([null, 'Waiting for your answer']) - }) - it("keeps a card's own failed send and reads a prompt's wait as queued under a paused queue", () => { const cards = mobileQueuedMessageCards( [ diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index 002cf38bc784..f3edb13e53c8 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -103,9 +103,6 @@ export function mobileQueuedMessageCards( : [] ) ) - // A host that names the cards its pause holds lets the rest wait as usual; an older host names - // none, and its pause holds every card. - const pauseNamesCards = queuedMessages.some((draft) => draft.heldByPause === true) let behindReturned = false const cards: MobileQueuedMessageCard[] = [] for (const draft of queuedMessages) { @@ -120,7 +117,7 @@ export function mobileQueuedMessageCards( ? pausedCaption(draft.pausedReason) : behindReturned ? 'Waiting — a message ahead needs attention' - : facts.queuePaused && (!pauseNamesCards || draft.heldByPause === true) + : facts.queuePaused ? // The pause row says why and offers Resume; the card promises no send time. null : facts.pendingPrompt diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index c1bc7d40a75c..277beb17d75a 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -173,32 +173,6 @@ describe('mobile structured queued messages', () => { ) }) - // Nothing is left to steer into while a Stop ends the turn: the send runs after it, as a card. - it('queues a send made while this phone reads Stopping', async () => { - sendRequest.mockImplementation(async (method) => { - if (method === 'agentSession.cancel') { - return new Promise(() => {}) - } - if (method === 'agentSession.send') { - return mutationOk({ - clientMessageId: 'client-1', - queued: { messageId: 'client-1', position: 1, state: 'waiting' } - }) - } - return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) - }) - await mountSession(CAPABLE, snapshotEvent({ runningTurn: true })) - act(() => hook!.cancel()) - await vi.waitFor(() => expect(hook!.turnIndicator.stopping).toBe(true)) - - await act(async () => { - expect(await hook!.sendWithOutcome('run this after the stop')).toBe('queued') - }) - - expect(calls('agentSession.send')).toHaveLength(1) - expect(requestOf('agentSession.send').params.delivery).toBe('queue-if-active') - }) - it('keeps today’s request exactly against an incapable host', async () => { sendRequest.mockImplementation(async (method) => { if (method === 'agentSession.send') { diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index fb49694cded1..b8e45e0ef590 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -447,8 +447,7 @@ describe('which cards a pause holds', () => { ]) }) - // A card asked for after a person's Stop runs when the stop lands, past the cards that Stop holds. - it("the queue's own consume takes a card queued after a Stop past the cards only it holds", async () => { + it("the queue's own consume refuses a newer card while an older one is held: nothing overtakes", async () => { const journal = await open() await queueDraft(journal, 'held') await userStop(journal) @@ -471,9 +470,8 @@ describe('which cards a pause holds', () => { yieldsToPause: { hostInstance: HOST } } ) - ).resolves.toBeDefined() - expect(journal.queuedMessages.get('held')?.state).toBe('waiting') - expect(journal.queuedMessages.get('newer')?.state).not.toBe('waiting') + ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) + expect(journal.queuedMessages.get('newer')?.state).toBe('waiting') }) it("'cleared' holds the carried cards, not one typed after them", async () => { diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index cc4e371f752b..71a17d871ef9 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -161,8 +161,8 @@ function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { } // Product decision: a card queued AFTER a Stop is a new instruction and is not held; only cards -// queued before it, and a steer it withdrew, wait. true instead holds every waiting card, whenever -// it was queued. +// queued before it, and a steer it withdrew, wait. It still never jumps ahead of a held card: the +// drain stops at the first one. true instead holds every waiting card, whenever it was queued. const PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT = false /** THE rule for which cards are held: by ANY pause in force, named by the first that holds it, so @@ -179,30 +179,18 @@ export function queuePauseHolding( } /** The card the queue sends next: the oldest waiting one with no hold of its own, unless a - * returned card or a held one comes first. The one exception: a card queued after a person's Stop - * passes the cards only that Stop holds, since it was asked for after the Stop and runs when the - * stop lands. The drain's pick and its consume both read this. */ + * returned card or a held one comes first. The queue never reorders, so a newer card never + * overtakes a held one. The drain's pick and its consume both read this. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] ): T | null { - const stop = pauses.find((pause) => pause.reason === 'stopped') - const heldOnlyByStop = (card: QueueCard): boolean => - pauses.every((pause) => pause === stop || !queuePauseHolding([pause], card)) - let passedStopHeld = false for (const card of cards) { - if (card.state === 'returned') { + if (card.state === 'returned' || queuePauseHolding(pauses, card)) { return null } - if (queuePauseHolding(pauses, card)) { - if (!stop || !heldOnlyByStop(card)) { - return null - } - passedStopHeld = true - continue - } if (card.state === 'waiting' && card.holdReason === null) { - return !passedStopHeld || (stop !== undefined && !queuedBeforePause(stop, card)) ? card : null + return card } } return null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 3d9d2198cde4..3fbb96ddaf87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -22,15 +22,8 @@ import type { AgentSessionPromptRequest } from './structured-agent-session-turns import { threadGoalPlan } from './structured-agent-session-thread-goal' import { mutateStructuredAgentSession, - mutateStructuredAgentSessionOffLane, type StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' -import { - maybeQueueStructuredAgentSessionSend, - queuedMessageBodyIsTextOnly -} from './structured-agent-session-queued-messages' -import type { AgentSessionTurnContext } from './structured-agent-session-turns' -import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { openForProviderWrite, openWithAgent, @@ -71,67 +64,23 @@ export function sendStructuredAgentSessionTurn( } ): Promise> { const plan = sendPlan(params) - const preparation = sendPreparation(context, params.envelope) - const queueable = (ctx: AgentSessionTurnContext) => - runQueueableStructuredAgentSessionSend( - context, - ctx, - params, - async () => - structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) ?? - (await plan.run(ctx)) - ) - if (!sendQueuesAfterStop(context, params)) { - return mutateStructuredAgentSession( - context, - caller, - params.envelope, - { ...plan, run: queueable }, - preparation - ) - } - // A message sent while a person's Stop ends the work is a card that runs once the stop lands. Its - // write has no order to keep with the Stop, which holds the lane until its provider answers. - return mutateStructuredAgentSessionOffLane(context, caller, params.envelope, { - ...plan, - run: async (ctx) => { - // Read again once admitted, in the same tick as the write: the Stop may have settled since. - const queued = sendQueuesAfterStop(context, params) - ? await maybeQueueStructuredAgentSessionSend(context, ctx, params, true) - : null - return ( - queued ?? - // Anything else takes the lane, on the conversation and fence it stands at there, as every - // send does: a close and reopen while it waited replaced the journal admission read. - context.serialize(ctx.sessionId, async () => { - const prepared = await preparation() - const journal = context.sessions.get(ctx.sessionId)?.journal - if (!prepared.ok || !journal) { - return prepared.ok - ? { ok: false as const, refusal: AGENT_SESSION_NOT_ATTACHED } - : prepared - } - const fence = context.deps.store.getRecord(ctx.sessionId)?.lease.runtimeFence - return queueable({ ...ctx, journal, fence: fence ?? ctx.fence }) - }) - ) - } - }) -} - -/** A text send asking to be queued, while the host reads that a person's Stop is ending the work. */ -function sendQueuesAfterStop( - context: StructuredAgentSessionMutationContext, - params: { - envelope: AgentSessionMutationEnvelope - body: AgentJournalMessageItem - delivery?: 'queue-if-active' - } -): boolean { - return ( - params.delivery === 'queue-if-active' && - queuedMessageBodyIsTextOnly(params.body) && - context.readStopping?.(params.envelope.sessionId) === true + return mutateStructuredAgentSession( + context, + caller, + params.envelope, + { + ...plan, + run: (ctx) => + runQueueableStructuredAgentSessionSend( + context, + ctx, + params, + async () => + structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) ?? + (await plan.run(ctx)) + ) + }, + sendPreparation(context, params.envelope) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index efa2811b3582..0536f619c220 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -288,7 +288,6 @@ export class StructuredAgentSessionHost { wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), - readStopping: this.clientDelivery.readStopping, now: () => this.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 17996bb2cf76..e77975dc5c22 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -44,9 +44,6 @@ export type StructuredAgentSessionMutationContext = { * journal commit (a conversation command). Draft-table changes need no call: * the draft store notifies through the journal's own commit listener. */ wakeQueuedDrain?: (sessionId: string) => void - /** The host's reading that a person's Stop is still ending the session's work, as the status - * feed projects it from the journal as it stands. */ - readStopping?: (sessionId: string) => boolean now: () => number } @@ -59,30 +56,18 @@ export function mutateStructuredAgentSession( prepareSession?: AgentSessionMutationRequest['prepareSession'] ): Promise> { return context.serialize(envelope.sessionId, () => - mutateStructuredAgentSessionOffLane(context, caller, envelope, plan, prepareSession) + admitAndRunAgentSessionMutation({ + store: context.deps.store, + adapter: context.deps.adapter, + logger: context.deps.logger, + callerKey: caller.callerKey, + envelope, + plan, + journal: () => context.sessions.get(envelope.sessionId)?.journal, + prepareSession, + publish: (journal) => context.publish(envelope.sessionId, journal), + providerChildPhase: () => context.sessions.get(envelope.sessionId)?.child?.phase, + now: () => context.now() + }) ) } - -/** Admits the envelope and runs the plan without the session's serialize: for a plan that only - * writes the journal, through its own ordered writer, and touches nothing the lane orders. */ -export function mutateStructuredAgentSessionOffLane( - context: StructuredAgentSessionMutationContext, - caller: StructuredAgentSessionCaller, - envelope: AgentSessionMutationEnvelope, - plan: MutationPlan, - prepareSession?: AgentSessionMutationRequest['prepareSession'] -): Promise> { - return admitAndRunAgentSessionMutation({ - store: context.deps.store, - adapter: context.deps.adapter, - logger: context.deps.logger, - callerKey: caller.callerKey, - envelope, - plan, - journal: () => context.sessions.get(envelope.sessionId)?.journal, - prepareSession, - publish: (journal) => context.publish(envelope.sessionId, journal), - providerChildPhase: () => context.sessions.get(envelope.sessionId)?.child?.phase, - now: () => context.now() - }) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 8d8173629cfb..ac1fdbb0617c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -194,10 +194,7 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' - }, - /** Sent while a person's Stop ends the work: queued to run when the stop lands, whatever the - * session reads, short of a hold that refuses any send. */ - afterStop = false + } ): Promise< | { ok: true; value: AgentSessionSendResult } | { ok: false; refusal: AgentSessionWireRefusal } @@ -218,16 +215,13 @@ export async function maybeQueueStructuredAgentSessionSend( return null } // A newer Orca's journal takes no new draft: the immediate path refuses the send. - const hold = { - journal: ctx.journal, - record: context.deps.store.getRecord(ctx.sessionId), - fence: ctx.fence - } if ( ctx.journal.isReadOnly || - (afterStop - ? structuredQueueHold(hold) === 'blocked' - : !shouldQueueStructuredAgentSessionSend(hold)) + !shouldQueueStructuredAgentSessionSend({ + journal: ctx.journal, + record: context.deps.store.getRecord(ctx.sessionId), + fence: ctx.fence + }) ) { return null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index ce6f1ee9e671..c5baeed25def 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -107,14 +107,17 @@ describe("a Stop's queue pause", () => { await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) - it('a draft typed while the stopped turn winds down runs after it; the card the Stop holds waits', async () => { + it("a draft typed while the stopped turn winds down waits with the rest: the pause is the queue's", async () => { const working = await rig.workingSend() const olderId = await queuedDraft('paused by the stop') await rig.stop() const typedId = await queuedDraft('typed while stopping') await rig.settleAccepted(working, 'stopped') - await handedOver(typedId) - await expectPaused(olderId) + await expectPaused(olderId, typedId) + expect(await rig.drafts()).toEqual([ + { messageId: olderId, state: 'waiting' }, + { messageId: typedId, state: 'waiting' } + ]) }) it('Send-now sends only its own card; the rest stay paused until that turn starts, then drain after it', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index fb564156df96..1fa46616da18 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -9,11 +9,7 @@ import { type AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { - queuePauseHolding, - resumableQueuePause, - type DerivedQueuePause -} from '../agent-session-journal/queued-message-pause' +import { resumableQueuePause } from '../agent-session-journal/queued-message-pause' import { structuredQueuePauses } from './structured-agent-session-queued-pause' export type QueuePublication = { @@ -23,10 +19,7 @@ export type QueuePublication = { /** Waiting and returned rows only. `paused` is a per-card hold (a failed * conversion); a Stop or a restart pauses the queue, published once beside it. */ -function computePublishedQueuedMessages( - journal: AgentSessionJournal, - pauses: readonly DerivedQueuePause[] -): AgentSessionQueuedMessage[] { +function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { const published: AgentSessionQueuedMessage[] = [] for (const row of journal.queuedMessages.list()) { if (row.state !== 'waiting' && row.state !== 'returned') { @@ -34,7 +27,6 @@ function computePublishedQueuedMessages( } const held = row.state === 'waiting' && row.holdReason !== null published.push({ - ...(queuePauseHolding(pauses, row) ? { heldByPause: true as const } : {}), messageId: row.messageId, position: row.position, body: row.body, @@ -61,17 +53,13 @@ type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessa const listMemos = new WeakMap() const publications = new WeakMap() -function readPublishedQueuedMessages( - journal: AgentSessionJournal, - pauses: readonly DerivedQueuePause[] -): AgentSessionQueuedMessage[] { - // The pauses turn on rows outside the draft table (a Stop, a person's turn), so they key it too. - const key = `${journal.queuedMessages.revision()}:${JSON.stringify(pauses)}` +function readPublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { + const key = String(journal.queuedMessages.revision()) const memo = listMemos.get(journal) if (memo && memo.key === key) { return memo.list } - const list = computePublishedQueuedMessages(journal, pauses) + const list = computePublishedQueuedMessages(journal) // Belt for the identity dedup: equal recomputed content keeps the previous reference. const serialized = JSON.stringify(list) if (memo && memo.serialized === serialized) { @@ -92,12 +80,11 @@ export function sameQueuePause( } export function readQueuePublication(journal: AgentSessionJournal): QueuePublication { + const queuedMessages = readPublishedQueuedMessages(journal) // Read per emit: the pause also turns on submissions (a person's turn starting). Shown only // over a card Resume would send, so its header never offers to send nothing; deleting a // blocking returned card shows it again. - const pauses = structuredQueuePauses(journal) - const queuedMessages = readPublishedQueuedMessages(journal, pauses) - const pause = resumableQueuePause(pauses, journal.queuedMessages.list()) + const pause = resumableQueuePause(structuredQueuePauses(journal), journal.queuedMessages.list()) const queuePause = pause ? { reason: pause.reason } : null const previous = publications.get(journal) if ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index ba17a264c455..d1db51426c50 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -105,8 +105,7 @@ describe("Stop's event", () => { await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) }) - // Asked for after the Stop, it runs past the cards the Stop holds; they wait for Resume. - it('a card queued after the Stop runs past the cards it holds, which wait for Resume', async () => { + it('a card queued after the Stop waits behind the cards it holds, then all send in order', async () => { const working = await rig.workingSend() const held = await queuedDraft('queued before the stop') await rig.stop() @@ -114,12 +113,18 @@ describe("Stop's event", () => { const mail = await mailTurn() const later = await queuedDraft('queued during the mail turn') await rig.settleAccepted(mail, 'mail') - await eventually(async () => expect((await rig.handoff(later))?.handedOverAt).toBeDefined()) - await rig.settleAccepted(await rig.handoffId(later), 'later') - // The queue's own send is no person's turn: the cards the Stop holds still wait. - await expectHeld('stopped', held) + // The queue never reorders: the newer card waits behind the held one, with no caption of its own. + await expectHeld('stopped', held, later) + expect(await rig.drafts()).toEqual([ + { messageId: held, state: 'waiting' }, + { messageId: later, state: 'waiting' } + ]) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) + expect(await rig.handoff(later)).toBeUndefined() + await eventually(async () => expect((await rig.handoff(held))?.handedOverAt).toBeDefined()) + await rig.settleAccepted(await rig.handoffId(held), 'held') + await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) }) it("a person's accepted turn lifts it; a host turn and a later Stop do not", async () => { diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index f00f1a56a891..9c5927797570 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -88,38 +88,6 @@ async function send(text: string): Promise { return sent.value.clientMessageId } -/** A send asking to be queued while the agent works, under `clientOperationId` when it retries. */ -async function sendQueued(text: string, clientOperationId?: string) { - const body = hostTestMessage(text) - const delivery = 'queue-if-active' as const - const fields = { body, delivery } - const sent = await host.send(CALLER, { - envelope: { - ...envelope('agentSession.send', fields), - ...(clientOperationId ? { clientOperationId } : {}) - }, - body, - delivery, - userSend: true - }) - if (!sent.ok) { - throw new Error(JSON.stringify(sent.refusal)) - } - return sent.value -} - -async function queuePage() { - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - if (!page.ok) { - throw new Error('history refused') - } - return { cards: page.page.queuedMessages ?? [], pause: page.page.queuePause ?? null } -} - -async function queuedCards() { - return (await queuePage()).cards -} - async function stop(turnId?: string): Promise { const stopped = await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', turnId === undefined ? {} : { turnId }), @@ -342,88 +310,3 @@ describe('a send behind a Codex Stop answered before its turn ends', () => { expect(submission?.dispatchState).not.toBe('rejected') }) }) - -// A message sent asking to be queued while a person's Stop ends the turn is a card at once, while -// the Stop still waits on Codex, and runs as its own turn once the stopped turn has ended. -describe('a queued send while a Codex Stop waits on its answer', () => { - it('is a card at once, held by no pause, and opens its own turn after the stop', async () => { - const first = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(first) - const answer = Promise.withResolvers() - interruptAnswer = answer.promise - let stopped = false - const stopping = stop().then(() => { - stopped = true - }) - await vi.waitFor(() => expect(statuses.at(-1)?.stopping).toBe(true)) - - const queued = await sendQueued('run this after the stop') - - expect(stopped).toBe(false) - expect(queued).toMatchObject({ queued: { state: 'waiting' } }) - const messageId = 'queued' in queued ? queued.queued.messageId : '' - expect(await queuedCards()).toEqual([expect.objectContaining({ messageId, state: 'waiting' })]) - // The Stop's pause holds no card queued after it, so it offers no Resume. - expect((await queuePage()).pause).toBeNull() - // A retry of the same operation answers the same card, on the lane or off it. - expect(await sendQueued('run this after the stop', messageId)).toEqual(queued) - - answer.resolve() - await stopping - turns.end('interrupted') - - await vi.waitFor(() => expect(answers).toBe(2)) - expect(startedTurns).toEqual(['turn-1', 'turn-2']) - expect(steers).toBe(0) - // Retried once handed off, on the lane, it answers with the card's hand-off, as any queued send. - expect(await sendQueued('run this after the stop', messageId)).toMatchObject({ - submission: { queuedMessageId: messageId } - }) - const handOff = (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.queuedMessageId === messageId - ) - expect(handOff?.dispatchState).not.toBe('rejected') - expect(await queuedCards()).toEqual([]) - }) -}) - -// A card queued before the Stop waits for Resume; one queued while it stops runs when it lands. -describe('a queued send while Stopping, behind a card the Stop paused', () => { - it('runs as its own turn after the stop, and the paused card waits for Resume', async () => { - const first = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(first) - const before = await sendQueued('queued before the stop') - const paused = 'queued' in before ? before.queued.messageId : '' - const answer = Promise.withResolvers() - interruptAnswer = answer.promise - const stopping = stop() - await vi.waitFor(() => expect(statuses.at(-1)?.stopping).toBe(true)) - - const during = await sendQueued('sent while stopping') - const runsNext = 'queued' in during ? during.queued.messageId : '' - // The pause names the one card it holds; the card queued after the Stop is not one. - expect((await queuePage()).cards.map((card) => [card.messageId, card.heldByPause])).toEqual([ - [paused, true], - [runsNext, undefined] - ]) - answer.resolve() - await stopping - turns.end('interrupted') - - await vi.waitFor(() => expect(answers).toBe(2)) - expect(startedTurns).toEqual(['turn-1', 'turn-2']) - expect(steers).toBe(0) - const handOffs = (await host.journalSnapshot(SESSION)).submissions.flatMap((entry) => - entry.queuedMessageId ? [entry.queuedMessageId] : [] - ) - expect(handOffs).toEqual([runsNext]) - expect(await queuePage()).toEqual({ - cards: [expect.objectContaining({ messageId: paused, state: 'waiting', heldByPause: true })], - pause: expect.objectContaining({ reason: 'stopped' }) - }) - }) -}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 7d0e3c22ac7b..995d80247699 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -137,24 +137,6 @@ describe('queued message cards', () => { expect(cards.map((card) => card.messageId)).toEqual(['kept', 'refused']) }) - // A card queued after a person's Stop runs when the stop lands, past the cards its pause holds. - it('a paused queue labels only the cards its host says it holds', () => { - const cards = projectQueuedMessageCards( - [draft('held', 1, { heldByPause: true }), draft('queued while stopping', 2)], - [], - { hasPendingPrompt: false, queuePaused: true } - ) - expect(cards.map((card) => card.hold)).toEqual(['queue-paused', 'turn']) - }) - - it('a paused queue from a host that names no held card labels every card, as before', () => { - const cards = projectQueuedMessageCards([draft('a', 1), draft('b', 2)], [], { - hasPendingPrompt: false, - queuePaused: true - }) - expect(cards.map((card) => card.hold)).toEqual(['queue-paused', 'queue-paused']) - }) - it('a paused queue outranks a pending prompt: an answer does not drain it', () => { const cards = projectQueuedMessageCards( [ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index eb84a65e5191..e9d1f394b9af 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts @@ -61,9 +61,6 @@ export function projectQueuedMessageCards( const ordered = [...(queuedMessages ?? [])] .sort((left, right) => left.position - right.position) .filter((message) => message.state === 'returned' || !handedOff.has(message.messageId)) - // A host that names the cards its pause holds lets the rest read as waiting their turn; an older - // host names none, and its pause holds every card. - const pauseNamesCards = ordered.some((message) => message.heldByPause === true) let behindReturned = false return ordered.map((message) => { const hold: QueuedMessageCardHold = @@ -73,7 +70,7 @@ export function projectQueuedMessageCards( ? 'paused' : behindReturned ? 'behind-returned' - : session.queuePaused && (!pauseNamesCards || message.heldByPause === true) + : session.queuePaused ? 'queue-paused' : session.hasPendingPrompt ? 'awaiting-answer' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx index 2a1102b30d86..397c3a422bfe 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queue-while-stopping.test.tsx @@ -1,9 +1,8 @@ // @vitest-environment happy-dom -// A message sent while the chat reads Stopping, through the chat's own send and its real outbox. -// Where the host queues sends, the request asks it to, whatever the queueing setting, and the -// transcript draws no bubble of it. Where it does not, the send goes out plain, for the host to hold -// until the stop lands, and is marked as sent while stopping, so it draws after the Stopping line. +// A message sent while the chat reads Stopping, through the chat's own send and its real outbox: +// where the host does not queue sends, it goes out plain, for the host to hold until the stop lands, +// and is marked as sent while stopping, so it draws after the Stopping line. import { cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' @@ -29,10 +28,7 @@ vi.mock('./use-structured-agent-session-read', () => ({ }) })) -import { - AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, - AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' +import { AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { clearNativeChatDraftCacheForTests } from './native-chat-draft-cache' import { useStructuredAgentSession } from './use-structured-agent-session' @@ -55,10 +51,8 @@ beforeEach(() => { localStorage.clear() clearNativeChatDraftCacheForTests() items = [RUNNING_TURN] - setLocalRuntimeCapabilitiesForTests([ - AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, - AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY - ]) + // A host that does not queue sends, as every shipped host does not yet. + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY]) // The host has not answered yet: the send is still on its way, as while a Stop holds the lane. mocks.call.mockImplementation(() => new Promise(() => {})) }) @@ -83,18 +77,7 @@ function renderStopping() { ) } -it('asks the host to queue a send made while the host reads Stopping, and draws no bubble', async () => { - const { result } = renderStopping() - - expect(result.current.send('run this after the stop')).toBe(true) - - await waitFor(() => expect(sends()).toHaveLength(1)) - expect(sends()[0]?.delivery).toBe('queue-if-active') - expect(JSON.stringify(result.current.messages)).not.toContain('run this after the stop') -}) - it('sends plain where the host does not queue sends, marked as sent while stopping', async () => { - setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY]) const { result } = renderStopping() expect(result.current.send('run this after the stop')).toBe(true) @@ -110,7 +93,6 @@ it('sends plain where the host does not queue sends, marked as sent while stoppi // Sent before the Stop, the host steers it into the turn: it is not one held behind the Stop. it('does not mark a send made before the chat read Stopping', async () => { - setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY]) const { result, rerender } = renderHook( ({ hostStopping }: { hostStopping: boolean }) => useStructuredAgentSession({ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index be6f67f6e2af..ebfd23060bbf 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -7,7 +7,7 @@ // and no draft text ever rides an answer. A host-held draft is a card above // the composer, never a transcript bubble. -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' @@ -92,7 +92,7 @@ function draft(id: string): AgentSessionQueuedMessage { } } -function render(queueFollowUps?: boolean, hostStopping?: boolean) { +function render(queueFollowUps?: boolean) { return renderHook(() => useStructuredAgentSession({ sessionId: 'session-1', @@ -100,8 +100,7 @@ function render(queueFollowUps?: boolean, hostStopping?: boolean) { target: { kind: 'local' }, isVisible: true, composerScopeKey: 'scope-1', - ...(queueFollowUps === undefined ? {} : { queueFollowUps }), - ...(hostStopping === undefined ? {} : { hostStopping }) + ...(queueFollowUps === undefined ? {} : { queueFollowUps }) }) ) } @@ -159,45 +158,6 @@ describe('against a capable host', () => { }) }) - // Nothing is left to steer into while a Stop ends the turn: a send runs after it, as a card. - it('queues a send while the host reads stopping, with the setting off', () => { - render(false, true) - expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({ - capability: 'supported', - enabled: true - }) - }) - - it("queues a send while this client's own Stop is in flight, with the setting off", async () => { - const cancel = Promise.withResolvers() - mocks.call.mockImplementation(async (_target, method) => - method === 'agentSession.cancel' ? cancel.promise : null - ) - const { result } = render(false) - let stopped: Promise = Promise.resolve() - act(() => { - stopped = result.current.stop() - }) - await waitFor(() => expect(mocks.outboxArgs.at(-1)?.queueDelivery?.enabled).toBe(true)) - await act(async () => { - cancel.resolve({ - ok: true, - replayed: false, - fence: 3, - cursor: { epoch: 'e', sequence: 1 }, - value: { cancelled: true } - }) - await stopped - }) - expect(mocks.outboxArgs.at(-1)?.queueDelivery?.enabled).toBe(false) - }) - - it('sends immediately with the setting off once the session is not working', () => { - items = [] - render(false, true) - expect(mocks.outboxArgs.at(-1)?.queueDelivery?.enabled).toBe(false) - }) - it('Stop is a plain cancel: drafts stay as cards and no text lands in the composer', async () => { queuedMessages = [{ ...draft('draft-1'), paused: true }] const { result } = render(false) @@ -312,24 +272,6 @@ describe('against a capable host', () => { expect(JSON.stringify(idleUnheld.result.current.messages)).toContain('awaiting the answer') }) - it('draws a send made while stopping as no bubble in the turn the Stop ends, with the setting off', () => { - outboxEntries = [ - createStructuredAgentSessionOutboxEntry({ - clientMessageId: 'while-stopping', - sessionId: 'session-1', - text: 'run this after the stop', - attachments: [], - queuedAt: 1 - }) - ] - const stopping = render(false, true) - expect(JSON.stringify(stopping.result.current.messages)).not.toContain( - 'run this after the stop' - ) - const working = render(false, false) - expect(JSON.stringify(working.result.current.messages)).toContain('run this after the stop') - }) - it('shows host-held drafts as cards, never as transcript bubbles', () => { queuedMessages = [draft('draft-1')] const { result } = render() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 212cd9ed8dc7..e139125d2efe 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -121,8 +121,6 @@ export function useStructuredAgentSession(args: { [transportState.queuedMessages] ) const stopPress = useStructuredAgentSessionStopPress(sessionId) - // While a Stop ends the turn there is nothing to steer into: a send is queued to run after it, - // whatever the setting. const stopping = agentStopDisplayStatus({ working: transportState.isWorking, @@ -130,8 +128,8 @@ export function useStructuredAgentSession(args: { stopPressed: stopPress.pressed }) === 'stopping' const queueDelivery = useMemo( - () => ({ capability: queueCapability, enabled: queueFollowUps || stopping }), - [queueCapability, queueFollowUps, stopping] + () => ({ capability: queueCapability, enabled: queueFollowUps }), + [queueCapability, queueFollowUps] ) const outboxController = useStructuredAgentSessionOutbox({ sessionId, diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index 05539eef5c0e..f7659c95873b 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -37,10 +37,6 @@ export type AgentSessionQueuedMessage = { * marker as a plain hold, so a newer host can add one. The queue-level * pause is `queuePause`, published beside the list. */ pausedReason?: AgentSessionQueuedMessagePausedReason - /** The queue's pause holds this card: Resume sends it. A card the pause lets through (one - * queued after a person's Stop) has none, though `queuePause` is set. Absent on every card from - * an older host, whose pause holds them all. */ - heldByPause?: true /** A returned card's refusal: the `reason` and `rejection` pair its submission settled with. * Only a failure returns a card; a draft a Stop or restart took back waits again. Clients classify it from `returnedRejection` (falling back to `returnedReason` when a host * wrote no fact) exactly as they classify a rejected submission's `rejection`, e.g. From 5b6005712806125c51b4aaf7d3b6cb02e04ce495 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:20:41 -0700 Subject: [PATCH 117/182] fix(native-chat): leave no Stop row when the Stop took back a send that never ran A Stop on a Codex send whose turn never opened ends the child, and the child's end takes the send back into the composer. The Stop still wrote "Cancellation requested." at the conversation level, so with the send gone it sat under the previous finished turn and read as if that turn had been stopped. A Stop that found no turn running and whose child end took back every send it found now writes no row; a Stop of a running turn, or one that leaves a send in doubt, still does. --- ...ed-agent-session-conversation-stop.test.ts | 13 +- ...t-session-stop-note-withdrawn-send.test.ts | 118 ++++++++++++++++++ .../structured-agent-session-turns-cancel.ts | 32 ++++- ...-session-codex-turn-end-settlement.test.ts | 47 +++++-- 4 files changed, 192 insertions(+), 18 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index aab219c779d6..6f03cc1345cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -240,7 +240,9 @@ describe('a Stop that names no turn', () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect(await statusRows()).toEqual(['Cancellation requested.']) + // Its turn never opened, so the child's end took the send back: no row stays to report on it. + expect((await submission(id))?.dispatchState).toBe('rejected') + expect(await statusRows()).toEqual([]) }) it('says the agent did not stop, in its words, when it refused because its turn is not running', async () => { @@ -271,7 +273,9 @@ describe('a Stop that names no turn', () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect(await statusRows()).toEqual(['Cancellation requested.']) + // Its turn never opened, so the child's end took the send back: no row stays to report on it. + expect((await submission(id))?.dispatchState).toBe('rejected') + expect(await statusRows()).toEqual([]) }) it('says the agent did not stop, in its words, when it could not interrupt and the child end is unproven', async () => { @@ -295,7 +299,7 @@ describe('a Stop that names no turn', () => { expect(await statusRows()).toEqual(["Codex didn't stop: failed to interrupt turn."]) }) - it('reads as requested when the child exit was proven and only a later cleanup step failed', async () => { + it('counts as stopped when the child exit was proven and only a later cleanup step failed', async () => { const { id, result } = send('hello') await result await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) @@ -312,7 +316,8 @@ describe('a Stop that names no turn', () => { fields: expect.objectContaining({ scope: 'stop-child', sessionId: SESSION }) }) ) - expect(await statusRows()).toEqual(['Cancellation requested.']) + expect((await submission(id))?.dispatchState).toBe('rejected') + expect(await statusRows()).toEqual([]) }) it('says the Stop is unconfirmed, not that nothing ran, when neither the interrupt nor the child end is proven', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts new file mode 100644 index 000000000000..33563091259b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts @@ -0,0 +1,118 @@ +// A Stop that ends the child after its interrupt failed: its row reports on the run it stopped, so +// one whose child end took back the send it found, with no turn running, leaves none. Its message +// is back in the composer, and a row would sit under the turn before as if that turn were stopped. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +let root: string | null = null +const journals = createTrackedJournalOpener() + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +async function stopEndingTheChild(options: { turnRunning: boolean; takesSendBack: boolean }) { + root = await mkdtemp(join(tmpdir(), 'orca-stop-note-withdrawn-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + if (options.turnRunning) { + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'send-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'look around' }] }, + fence: 1 + }) + const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), + sessionId: 'session-1', + journal, + fence: 1, + adapter: { + acquire: vi.fn(), + dispatch: vi.fn(), + closeSession: vi.fn(), + cancelTurn: vi.fn(async () => ({ + cancelled: false, + refusal: { detail: { text: 'failed to interrupt turn', audience: 'person' as const } } + })), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + now: () => 1 + } + const result = await performCancel(ctx, { + clientOperationId: 'stop-1', + stopChild: async () => { + if (options.takesSendBack) { + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }), + fence: 1, + recovered: true + }) + } + } + }) + const rows = journal + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + return { cancelled: result.ok && result.value.cancelled, rows } +} + +describe('a Stop that ends the child after its interrupt failed', () => { + it('leaves no row when the child end took back the send, with no turn running', async () => { + expect(await stopEndingTheChild({ turnRunning: false, takesSendBack: true })).toEqual({ + cancelled: true, + rows: [] + }) + }) + + it('says cancellation was requested when the send stays in doubt', async () => { + expect(await stopEndingTheChild({ turnRunning: false, takesSendBack: false })).toEqual({ + cancelled: true, + rows: ['Cancellation requested.'] + }) + }) + + it('says cancellation was requested on the turn it stopped, whatever became of the send', async () => { + expect(await stopEndingTheChild({ turnRunning: true, takesSendBack: true })).toEqual({ + cancelled: true, + rows: ['Cancellation requested.'] + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 187b934400df..dfd8f4d9aa6f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -2,7 +2,11 @@ import { agentChildWorkStopTargets } from '../../../shared/agent-child-work-stop import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalStatusItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' @@ -54,6 +58,20 @@ function stillRunsStoppedTurn( return stoppedTurnId === null || ctx.journal.activeTurnId() === stoppedTurnId } +/** Whether the child's end took back every send the Stop found in flight, none of them having run + * (`withdrawCodexSendsNoTurnOpenedFor`). */ +function tookBackEverySend( + ctx: Pick, + inFlight: readonly AgentJournalSubmission[] +): boolean { + const rejected = new Set( + ctx.journal + .submissions() + .flatMap((entry) => (entry.dispatchState === 'rejected' ? [entry.clientMessageId] : [])) + ) + return inFlight.length > 0 && inFlight.every((entry) => rejected.has(entry.clientMessageId)) +} + /** The row for a Stop the provider declined, in its words when it gave any. One that could not * reach a turn still able to open says the Stop is unconfirmed, never that nothing ran. */ function stopRefusedNote( @@ -220,6 +238,10 @@ async function cancelAndNote( const endsSession = input.endSession !== undefined && ctx.adapter.stopEndsSession?.(ctx.sessionId) === true const stoppedAt = Date.now() + // Read before the cancel: the sends a child end may take back. + const sentBeforeStop = ctx.journal + .submissions() + .filter((entry) => !isQueuedAgentJournalSubmission(entry) && entry.dispatchState === 'pending') // The provider's own answer; unset when its cancel threw, leaving the effect unknown. let taken: boolean | undefined // The provider could not interrupt the turn, or its cancel threw: the turn may run on. @@ -315,7 +337,13 @@ async function cancelAndNote( } if (ended) { cancelled = true - note = { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } + // A child end that took back every send it found, with no turn running, ended a run that + // never started: its message is back in the composer, and a row would sit under the turn + // before as if that turn were stopped. + note = + stoppedTurnId === null && tookBackEverySend(ctx, sentBeforeStop) + ? null + : { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } } else if (taken !== undefined) { // The turn was just read running, so a named Stop says it was refused rather than nothing. note = stopRefusedNote(ctx, refusal) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 500d795ed0cc..b3048bdb7b20 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -170,6 +170,12 @@ async function settled(): Promise<{ } } +async function statusRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) +} + function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessageId: string) { const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) return submission?.dispatchState === 'rejected' @@ -503,18 +509,12 @@ describe('a Stop sent after Codex answered a cold send, before it opened the tur }) describe('a Stop in that window that the turn never opens for', () => { - async function statusRows(): Promise { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' ? [item.body.text] : [] - ) - } - - async function waitingStop(): Promise<{ stopping: Promise }> { - await send('look around') + async function waitingStop(): Promise<{ stopping: Promise; sent: string }> { + const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) const stopping = stop() expect(await settledWithin(stopping, 200)).toBe('held') - return { stopping } + return { stopping, sent } } it('says Codex had no turn running when that turn ends first', async () => { @@ -529,7 +529,7 @@ describe('a Stop in that window that the turn never opens for', () => { }) it('lets a chat closed behind it close within its bound and one eviction', async () => { - const { stopping } = await waitingStop() + const { stopping, sent } = await waitingStop() const closing = host.close(SESSION, 'evict') @@ -539,8 +539,9 @@ describe('a Stop in that window that the turn never opens for', () => { expect(await settledWithin(stopping, 0)).not.toBe('held') expect(childCloses).toBe(1) expect(interrupts).toBe(1) - // Its wait ran out with the turn still able to open, so the Stop ended the child. - expect(await statusRows()).toContain('Cancellation requested.') + // Its wait ran out with the turn still able to open, so the Stop ended the child and took the + // send back. + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') }) it('lets the app quit behind it within the eviction budget, and still close the child', async () => { @@ -629,6 +630,27 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { await nextSendStartsANewChild() }) + // Nothing it stopped stays in the transcript, so a Stop row would read as stopping the turn before. + it('leaves no Stop row under the turn before once it takes the send back', async () => { + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + turns.end('completed') + const release = turns.holdNextAnswer() + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(2)) + const stopping = stop() + release() + expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + expect(await statusRows()).toEqual([]) + expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ + state: 'completed' + }) + }) + it('withdraws it too when the child end fails and a later retry lands it', async () => { failingCloses = 1 const sent = await stoppedBeforeItsTurnOpened() @@ -651,6 +673,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { expect(childCloses).toBe(1) expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') + expect(await statusRows()).toEqual(['Cancellation requested.']) }) // Handed over once the stopped turn read ended, it started its own turn, however late that From 9f37b399fa56f7a4d3ba4a064d3b96283473c8a9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:26:58 -0700 Subject: [PATCH 118/182] feat(native-chat): keep a message a Stop took back on screen, with one stop row after it A Stop that took back a message before the agent started it used to remove the message from the transcript and paste its text back into the sender's composer. Other devices just lost it, and with the message gone there was nowhere for a stop row to sit, so it either landed under the previous finished turn or was left out. The message now stays where it was sent, on every client, followed by one row, "Stopped before the agent started" (one row after several taken back together). A message whose turn had opened stays in that turn, whose "Interrupted" header already says it was stopped. A queued card's hand-off is still held by the card. Nothing the host recorded refills the composer any more. Text this client's own Stop took before the host ever had it still comes back, and only into an empty composer. Derived from facts the journal already holds (the rejected submission's cancelled verdict and its user item); nothing new is stored or sent. --- ...-native-chat-stopped-before-start.test.tsx | 167 +++++++++++++++ ...t-session-stop-note-withdrawn-send.test.ts | 5 +- .../structured-agent-session-turns-cancel.ts | 4 +- ...-session-codex-turn-end-settlement.test.ts | 13 +- ...tMessageList.stopped-before-start.test.tsx | 200 ++++++++++++++++++ .../native-chat/NativeChatNoticeRow.tsx | 12 ++ ...session-withdrawn-message-restore.test.tsx | 121 +++++------ ...agent-session-withdrawn-message-restore.ts | 63 ++---- ...ructured-agent-session-outbox-ownership.ts | 2 +- ...ent-session-outbox.draft-hand-off.test.tsx | 4 +- .../use-structured-agent-session-outbox.ts | 7 +- .../use-structured-agent-session.ts | 2 +- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- .../native-chat-stopped-before-start.ts | 4 + src/shared/native-chat-types.ts | 3 + ...ctured-agent-session-dispatch-rejection.ts | 2 +- ...ge-projection-stopped-before-start.test.ts | 151 +++++++++++++ ...ctured-agent-session-message-projection.ts | 52 ++++- ...ed-agent-session-outbox-stop-withdrawal.ts | 2 +- 24 files changed, 703 insertions(+), 129 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx create mode 100644 src/shared/native-chat-stopped-before-start.ts create mode 100644 src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts diff --git a/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx new file mode 100644 index 000000000000..83933e0f86e1 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx @@ -0,0 +1,167 @@ +// The phone draws a send a Stop took back before the agent started it where it was sent, with the +// one row after it, and gives neither a turn of its own. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from '../../../src/shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { selectStructuredAgentSettledTurns } from '../../../src/shared/structured-agent-session-turn-timing' + +vi.mock('react-native', async () => { + const React = await import('react') + const Text = ({ children, ...props }: { children?: React.ReactNode }): React.ReactNode => + React.createElement('Text', props, children) + return { + ActivityIndicator: 'ActivityIndicator', + Animated: { + Text, + Value: class { + setValue(): void {} + }, + loop: (animation: unknown) => animation, + sequence: () => ({ start: vi.fn(), stop: vi.fn() }), + timing: () => ({ start: vi.fn(), stop: vi.fn() }) + }, + Image: 'Image', + Pressable: 'Pressable', + Text, + View: ({ children, ...props }: { children?: React.ReactNode }) => + React.createElement('View', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } + } +}) +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + ChevronDown: 'ChevronDown', + Copy: 'Copy', + SquareChevronRight: 'SquareChevronRight', + SquareTerminal: 'SquareTerminal', + Wrench: 'Wrench', + ChevronRight: 'ChevronRight' +})) +vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: () => null })) + +import { MobileMarkdown } from '../components/MobileMarkdown' +import { MobileNativeChatMessage } from './MobileNativeChatMessage' +import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' + +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +let sequence = 0 + +function item( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = THREAD +): AgentJournalRenderItem { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } +} + +const sent = (id: string, text: string) => + item(agentJournalSubmissionKey(id), { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] + }) + +function submission( + clientMessageId: string, + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + ...overrides + } +} + +const items = [ + sent('warm-up', 'warm up'), + item('t1', { + kind: 'turn', + turnId: 't1', + state: 'completed', + outcome: 'success', + userItemId: agentJournalSubmissionKey('warm-up'), + startedAt: 1_000, + completedAt: 4_000 + }), + item( + 't1-answer', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Warmed up.' }] }, + { kind: 'turn', turnItemId: 't1' } + ), + sent('never-ran', 'look around') +] +const submissions = [ + submission('warm-up'), + submission('never-ran', { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }) +] + +type Disclosure = ReturnType +let disclosure: Disclosure | null = null + +function Harness({ messages }: { messages: readonly NativeChatMessage[] }): null { + disclosure = useMobileNativeChatTurnDisclosure({ + messages, + enabled: true, + isWorking: false, + settledTurns: selectStructuredAgentSettledTurns(items, submissions), + turnJournal: { items, submissions }, + workingStartedAt: null, + scopeKey: 'host\0worktree\0tab-a' + }) + return null +} + +describe('a send a Stop took back before the agent started it, on the phone', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('stays where it was sent, with the stop row after it and no turn status of its own', () => { + const messages = projectStructuredAgentSessionMessages(items, [], submissions) + expect(messages.map((message) => message.id)).toEqual([ + agentJournalSubmissionKey('warm-up'), + 't1-answer', + agentJournalSubmissionKey('never-ran'), + `stopped-before-start:${agentJournalSubmissionKey('never-ran')}` + ]) + + act(() => { + renderer = create(createElement(Harness, { messages })) + }) + const statuses = messages.map( + (message, index) => disclosure?.resolveRow(index, message).turnStatus ?? null + ) + expect(statuses[0]?.workedSeconds).toBe(3) + expect(statuses.slice(2)).toEqual([null, null]) + + act(() => renderer?.unmount()) + act(() => { + renderer = create(createElement(MobileNativeChatMessage, { message: messages[3]! })) + }) + expect(renderer!.root.findAllByType(MobileMarkdown).map((node) => node.props.content)).toEqual([ + 'Stopped before the agent started' + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts index 33563091259b..c3eb1da5a716 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts @@ -1,6 +1,7 @@ // A Stop that ends the child after its interrupt failed: its row reports on the run it stopped, so -// one whose child end took back the send it found, with no turn running, leaves none. Its message -// is back in the composer, and a row would sit under the turn before as if that turn were stopped. +// one whose child end took back the send it found, with no turn running, leaves none. A client +// draws that send where it was sent with its own row, and a second row would sit under the turn +// before on an older client, as if that turn were stopped. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 9fc1953e205b..94da26755d28 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -321,8 +321,8 @@ async function cancelAndNote( if (ended) { cancelled = true // A child end that took back every send it found, with no turn running, ended a run that - // never started: its message is back in the composer, and a row would sit under the turn - // before as if that turn were stopped. + // never started: a client draws that send where it was sent, with its own row saying so. A + // row here would say it twice, and an older client draws it under the turn before. note = stoppedTurnId === null && tookBackEverySend(ctx, sentBeforeStop) ? null diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index b3048bdb7b20..bcc3974570d2 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -25,6 +25,8 @@ import type { AgentJournalSubmission } from '../../shared/agent-session-journal- import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../shared/native-chat-stopped-before-start' +import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, @@ -646,9 +648,14 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') expect(await statusRows()).toEqual([]) - expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ - state: 'completed' - }) + const snapshot = await host.journalSnapshot(SESSION) + expect(turnRow(snapshot.items)).toMatchObject({ state: 'completed' }) + // Every client draws the send where it was sent, then the one row saying it never started. + const drawn = projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions) + expect(drawn.slice(-2).map((message) => [message.role, message.blocks])).toEqual([ + ['user', [{ type: 'text', text: 'look around' }]], + ['system', [expect.objectContaining({ text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT })]] + ]) }) it('withdraws it too when the child end fails and a later retry lands it', async () => { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx new file mode 100644 index 000000000000..1ad627615d4b --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx @@ -0,0 +1,200 @@ +// @vitest-environment happy-dom + +// A send a Stop took back before the agent started it stays where it was sent, with one row after +// it, never under the turn before, and with no header of its own. + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' +import { selectStructuredAgentSettledTurns } from '../../../../shared/structured-agent-session-turn-timing' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const STOP_ROW = 'Stopped before the agent started' +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +let sequence = 0 + +function item( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = THREAD +): AgentJournalRenderItem { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } +} + +const sent = (id: string, text: string) => + item(agentJournalSubmissionKey(id), { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] + }) + +function turn( + itemId: string, + userId: string, + seconds: number, + end: { state: 'completed' | 'interrupted'; outcome: 'success' | 'cancellation' } +) { + return item(itemId, { + kind: 'turn', + turnId: itemId, + ...end, + userItemId: agentJournalSubmissionKey(userId), + startedAt: 1_000, + completedAt: 1_000 + seconds * 1_000 + }) +} + +/** "warm up", answered and settled in 3s. */ +function warmUp(): AgentJournalRenderItem[] { + return [ + sent('warm-up', 'warm up'), + turn('t1', 'warm-up', 3, { state: 'completed', outcome: 'success' }), + item( + 't1-answer', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Warmed up.' }] }, + { kind: 'turn', turnItemId: 't1' } + ) + ] +} + +function submission( + clientMessageId: string, + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + ...overrides + } +} + +const stopped = (id: string) => + submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }) + +function journalList( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + live: { isWorking: boolean; workingStartedAt: number | null } = { + isWorking: false, + workingStartedAt: null + } +): React.JSX.Element { + return ( + + ) +} + +function renderJournal(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { + render(journalList(items, submissions)) +} + +function follows(later: HTMLElement, earlier: HTMLElement): boolean { + return Boolean(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) +} + +describe('a send a Stop took back before the agent started it', () => { + it('stays after the turn before it, with the stop row after it and no header of its own', () => { + renderJournal( + [...warmUp(), sent('never-ran', 'look around')], + [submission('warm-up'), stopped('never-ran')] + ) + + const message = screen.getByText('look around') + const row = screen.getByText(STOP_ROW) + expect(follows(message, screen.getByText('Warmed up.'))).toBe(true) + expect(follows(row, message)).toBe(true) + expect(screen.getAllByText(/^(Worked|Working) for/)).toHaveLength(1) + expect(screen.getByText('Worked for 3s')).toBeInTheDocument() + }) + + it('keeps no clock it showed while the send waited on its turn', () => { + const items = [...warmUp(), sent('never-ran', 'look around')] + const { rerender } = render( + journalList( + items, + [submission('warm-up'), submission('never-ran', { dispatchState: 'pending' })], + { + isWorking: true, + workingStartedAt: Date.now() - 5_000 + } + ) + ) + expect(screen.getByText('Working for 5s')).toBeInTheDocument() + + rerender(journalList(items, [submission('warm-up'), stopped('never-ran')])) + + expect(screen.getAllByText(/^(Worked|Working) for/).map((node) => node.textContent)).toEqual([ + 'Worked for 3s' + ]) + expect(follows(screen.getByText(STOP_ROW), screen.getByText('look around'))).toBe(true) + }) + + it('draws one stop row after sends taken back together', () => { + renderJournal( + [...warmUp(), sent('first', 'first send'), sent('second', 'second send')], + [submission('warm-up'), stopped('first'), stopped('second')] + ) + + expect(screen.getAllByText(STOP_ROW)).toHaveLength(1) + expect(follows(screen.getByText(STOP_ROW), screen.getByText('second send'))).toBe(true) + }) + + it('leaves a send whose turn opened to that turn, which says it was interrupted', () => { + renderJournal( + [ + ...warmUp(), + sent('opened', 'look around'), + turn('t2', 'opened', 2, { state: 'interrupted', outcome: 'cancellation' }) + ], + [submission('warm-up'), stopped('opened')] + ) + + expect(screen.getByText('look around')).toBeInTheDocument() + expect(screen.getByText('Interrupted after 2s')).toBeInTheDocument() + expect(screen.queryByText(STOP_ROW)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index 8b05f0d2d61d..a47c52b754ef 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' import type { NativeChatTextBlock } from '../../../../shared/native-chat-types' +import { NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION } from '../../../../shared/native-chat-stopped-before-start' import { ProviderFrameRow } from './NativeChatTranscriptChrome' export function NativeChatNoticeRow({ @@ -31,6 +32,17 @@ export function NativeChatNoticeRow({ ) } + if (block.presentation === NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION) { + // The line a Stop's own row draws, in this client's words. + return ( +

+ {translate( + 'components.native-chat.notices.stoppedBeforeStart', + 'Stopped before the agent started' + )} +

+ ) + } if (block.presentation === 'plan-document') { return ( diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx index 150b520bcd32..3fbfe80030f9 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx @@ -1,5 +1,6 @@ -// A message a Stop withdrew never ran, and nothing sends it again. Its sender gets the text and -// images back in the composer, once, after whatever is typed there. +// A message a Stop withdrew never ran, and nothing sends it again. One the host held stays in the +// transcript, so the composer keeps what is typed there. One this client's own Stop took before +// the host held it comes back to the composer, and only to an empty one. // @vitest-environment happy-dom @@ -57,7 +58,6 @@ import { appendNativeChatDraftCache, clearNativeChatDraftCacheForTests, readNativeChatDraftCache, - subscribeToNativeChatDraftAppend, writeNativeChatDraftCache } from './native-chat-draft-cache' import { @@ -151,10 +151,6 @@ async function sendToHost( return result.current.outbox.at(-1)!.clientMessageId } -function occurrences(text: string, of: string): number { - return text.split(of).length - 1 -} - afterEach(() => { cleanup() setLocalRuntimeCapabilitiesForTests(null) @@ -178,30 +174,24 @@ describe('a message the host withdrew at a Stop', () => { it.each([ ['a Stop during the start withdrew it from the host queue', {}], ['Claude cancelled it as a queued follow-up', { handedOverAt: 12 }] - ])( - 'comes back to the composer with its images, after the draft, when %s', - async (_case, handover) => { - answerSendsPending() - writeNativeChatDraftCache(PANE, 'already typed') - appendNativeChatAttachmentCache(PANE, [{ id: 'typed', path: '/tmp/typed.png' }]) - const { result, rerender } = renderOutbox() - const id = await sendToHost(result, 'hello', [ - { path: '/tmp/shot.png', previewUri: '/tmp/shot.png' } - ]) - rerender({ submissions: [submission(id, handover)] }) - - rerender({ submissions: [withdrawn(id, handover)] }) - - await waitFor(() => expect(result.current.outbox).toEqual([])) - expect(readNativeChatDraftCache(PANE)).toBe('already typed\n\nhello') - expect(readNativeChatAttachmentCache(PANE)).toEqual([ - { id: 'typed', path: '/tmp/typed.png' }, - { id: expect.any(String), path: '/tmp/shot.png' } - ]) - } - ) + ])('leaves the outbox and leaves the composer as it is, when %s', async (_case, handover) => { + answerSendsPending() + writeNativeChatDraftCache(PANE, 'already typed') + appendNativeChatAttachmentCache(PANE, [{ id: 'typed', path: '/tmp/typed.png' }]) + const { result, rerender } = renderOutbox() + const id = await sendToHost(result, 'hello', [ + { path: '/tmp/shot.png', previewUri: '/tmp/shot.png' } + ]) + rerender({ submissions: [submission(id, handover)] }) + + rerender({ submissions: [withdrawn(id, handover)] }) + + await waitFor(() => expect(result.current.outbox).toEqual([])) + expect(readNativeChatDraftCache(PANE)).toBe('already typed') + expect(readNativeChatAttachmentCache(PANE)).toEqual([{ id: 'typed', path: '/tmp/typed.png' }]) + }) - it('comes back when the host wrote the withdrawal as a typed fact in a sentence', async () => { + it('leaves an empty composer empty when the host wrote the withdrawal as a typed fact', async () => { answerSendsPending() const { result, rerender } = renderOutbox() const id = await sendToHost(result, 'hello') @@ -216,10 +206,10 @@ describe('a message the host withdrew at a Stop', () => { }) await waitFor(() => expect(result.current.outbox).toEqual([])) - expect(readNativeChatDraftCache(PANE)).toBe('hello') + expect(readNativeChatDraftCache(PANE)).toBe('') }) - it('comes back once, whatever replays the journal or shows the chat again', async () => { + it('is never put back by a replay or a second view of the chat', async () => { answerSendsPending() const first = renderOutbox() const id = await sendToHost(first.result, 'hello') @@ -235,28 +225,7 @@ describe('a message the host withdrew at a Stop', () => { await waitFor(() => expect(readOutbox(SESSION)).toEqual([])) const restored = readNativeChatDraftCache(PANE) + readNativeChatDraftCache(OTHER_PANE) - expect(occurrences(restored, 'hello')).toBe(1) - }) - - it('is back in the composer before it leaves storage, so a crash between repeats it', async () => { - answerSendsPending() - const { result, rerender } = renderOutbox() - const id = await sendToHost(result, 'hello') - let storedAtRestore: string[] = [] - const unsubscribe = subscribeToNativeChatDraftAppend(PANE, () => { - storedAtRestore = readOutbox(SESSION).map((entry) => entry.clientMessageId) - }) - const beforeDrop = readOutbox(SESSION, { recoverDispatching: false }) - - rerender({ submissions: [withdrawn(id)] }) - unsubscribe() - - expect(storedAtRestore).toEqual([id]) - // The drop never reached storage: the next mount gives the text back again rather than losing it. - cleanup() - writeOutbox(SESSION, beforeDrop) - renderOutbox().rerender({ submissions: [withdrawn(id)] }) - expect(readNativeChatDraftCache(PANE)).toBe('hello\n\nhello') + expect(restored).toBe('') }) it('keeps a message refused for any other reason on its Retry, and gives nothing back', async () => { @@ -287,7 +256,7 @@ describe('a message the host withdrew at a Stop', () => { }) describe('a message a Stop took out of the outbox before the host held it', () => { - it('comes back to the composer when the Stop follows the send at once', async () => { + it('comes back to an empty composer when the Stop follows the send at once', async () => { mocks.call.mockImplementation(() => new Promise(() => {})) const { result } = renderOutbox() act(() => expect(result.current.send('first')).toBe(true)) @@ -302,7 +271,7 @@ describe('a message a Stop took out of the outbox before the host held it', () = expect(readNativeChatDraftCache(PANE)).toBe('second') }) - it('gives back a send already on its way only when the host withdraws it', async () => { + it('never gives back a send already on its way, even when the host withdraws it', async () => { answerSendsPending() const reply = Promise.withResolvers() const answer = mocks.call.getMockImplementation()! @@ -325,7 +294,41 @@ describe('a message a Stop took out of the outbox before the host held it', () = rerender({ submissions: [withdrawn(id)] }) await waitFor(() => expect(result.current.outbox).toEqual([])) - expect(readNativeChatDraftCache(PANE)).toBe('hello') + expect(readNativeChatDraftCache(PANE)).toBe('') + }) + + it.each([ + ['text', () => writeNativeChatDraftCache(PANE, 'typed since')], + [ + 'an image', + () => appendNativeChatAttachmentCache(PANE, [{ id: 'typed', path: '/tmp/typed.png' }]) + ] + ])('leaves a composer holding %s as it is', async (_holding, fill) => { + mocks.call.mockImplementation(() => new Promise(() => {})) + const { result } = renderOutbox() + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + act(() => expect(result.current.send('second')).toBe(true)) + const before = { + draft: readNativeChatDraftCache(PANE), + images: readNativeChatAttachmentCache(PANE) + } + fill() + const filled = { + draft: readNativeChatDraftCache(PANE), + images: readNativeChatAttachmentCache(PANE) + } + expect(filled).not.toEqual(before) + + act(() => result.current.withdrawUnsent()) + + expect(result.current.outbox.map((entry) => entry.body.blocks)).toEqual([ + [{ type: 'text', text: 'first' }] + ]) + expect({ + draft: readNativeChatDraftCache(PANE), + images: readNativeChatAttachmentCache(PANE) + }).toEqual(filled) }) it('leaves a message waiting on Retry where it is, and gives back only what it withdrew', async () => { @@ -391,7 +394,7 @@ describe('Stop through the chat', () => { ) } - it('gives back what the host withdrew into the pane that pressed it', async () => { + it('keeps what the host withdrew out of the composer of the pane that pressed it', async () => { mocks.call.mockImplementation(async (_target, method) => method === 'agentSession.cancel' ? { ok: true, value: { cancelled: true } } : null ) @@ -404,7 +407,7 @@ describe('Stop through the chat', () => { rerender() await waitFor(() => expect(result.current.outbox).toEqual([])) - expect(readNativeChatDraftCache(PANE)).toBe('hello') + expect(readNativeChatDraftCache(PANE)).toBe('') }) it('says a failed Stop in a toast and gives nothing back', async () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index 40e69565ef07..2a634b71e7c1 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -1,33 +1,29 @@ import { useMemo } from 'react' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { appendNativeChatDraftCache } from './native-chat-draft-cache' -import { getStructuredAgentSessionOutbox } from './structured-agent-session-outbox-storage' -import { appendNativeChatAttachmentCache } from './use-native-chat-composer-attachments' +import { appendNativeChatDraftCache, readNativeChatDraftCache } from './native-chat-draft-cache' +import { + appendNativeChatAttachmentCache, + readNativeChatAttachmentCache +} from './use-native-chat-composer-attachments' /** - * Gives the sender back what a Stop withdrew: its text and images go into this pane's composer, - * after whatever is there. Called before the entries leave storage, so a failure between the two - * repeats the text rather than losing it. Only this client's outbox holds them, so no other viewer - * gets them. + * Gives the sender back what its own Stop took out of the outbox before the host held it, into + * an empty composer only: the host never had it, so the transcript cannot show it. A composer + * holding text or images keeps what is there. What the host withdrew stays in the transcript. */ -function restoreWithdrawnMessages( - sessionId: string, +function restoreUnsentMessages( composerScopeKey: string | undefined, withdrawn: readonly StructuredAgentSessionOutboxEntry[] ): void { - if (!composerScopeKey || withdrawn.length === 0) { + if ( + !composerScopeKey || + withdrawn.length === 0 || + readNativeChatDraftCache(composerScopeKey) !== '' || + readNativeChatAttachmentCache(composerScopeKey).length > 0 + ) { return } - // What the outbox no longer holds was already given back by whichever view dropped it first. - const held = new Set( - getStructuredAgentSessionOutbox(sessionId).map((entry) => entry.clientMessageId) - ) for (const entry of withdrawn) { - if (!held.has(entry.clientMessageId)) { - continue - } const blocks = entry.body.blocks appendNativeChatDraftCache( composerScopeKey, @@ -45,39 +41,14 @@ function restoreWithdrawnMessages( } export function useStructuredAgentSessionWithdrawnRestore( - sessionId: string, /** Absent where no composer shows this session; the entries are then only dropped. */ composerScopeKey: string | undefined ): { - /** The entries the host settled as withdrawn by a Stop. */ - byHost: ( - entries: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] - ) => void /** Entries a Stop took out of the outbox here, before the host held them. */ byStop: (entries: readonly StructuredAgentSessionOutboxEntry[]) => void } { return useMemo( - () => ({ - byHost: (entries, submissions) => { - // A hand-off of a queued draft is never restored: the Stop that withdrew it put the draft - // back as a card, which carries the text. - const withdrawn = new Set( - submissions - .filter( - (submission) => - dispatchWasWithdrawn(submission) && submission.queuedMessageId === undefined - ) - .map((submission) => submission.clientMessageId) - ) - restoreWithdrawnMessages( - sessionId, - composerScopeKey, - entries.filter((entry) => withdrawn.has(entry.clientMessageId)) - ) - }, - byStop: (entries) => restoreWithdrawnMessages(sessionId, composerScopeKey, entries) - }), - [composerScopeKey, sessionId] + () => ({ byStop: (entries) => restoreUnsentMessages(composerScopeKey, entries) }), + [composerScopeKey] ) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts index a5928b057e19..0a425f022783 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts @@ -1,5 +1,5 @@ // Who owns an outbox entry's text when it leaves this client's queue without a -// send answer. A Stop hands unsent text back to the composer — a local move; no +// send answer. A Stop hands unsent text back to an empty composer — a local move; no // text crosses a wire. A host that visibly holds the entry as a queued draft // (same id) owns it: those entries retire with no local restore, so the same // words can never come back twice. diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx index ff13958f160c..7e6fc7f314d6 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx @@ -135,12 +135,12 @@ describe('an outbox entry the host handed off as a queued draft', () => { expect(readNativeChatDraftCache('scope')).toBe('') }) - it('a withdrawn immediate send still comes back to the composer', async () => { + it('a withdrawn immediate send leaves the outbox and stays in the transcript, not the composer', async () => { seed('plain', false) const view = renderOutbox() view.rerender({ submissions: [submission('plain', WITHDRAWN)] }) await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) - expect(readNativeChatDraftCache('scope')).toBe('follow-up') + expect(readNativeChatDraftCache('scope')).toBe('') }) it('a withdrawn hand-off is never restored, even one under the id of the entry', async () => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 0f852f93a8c8..70a6663e753a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -61,7 +61,7 @@ export function useStructuredAgentSessionOutbox(args: { target: RuntimeClientTarget fence: number | null submissions: readonly AgentJournalSubmission[] - /** The composer that gets back what a Stop withdrew from this client's outbox. */ + /** The composer that gets back, when empty, what a Stop took from this client's outbox. */ composerScopeKey?: string /** The host's queued-messages capability and the user's setting; a send stamped * `delivery: 'queue-if-active'` is held as a draft only while the agent is working. */ @@ -86,7 +86,7 @@ export function useStructuredAgentSessionOutbox(args: { const { capability: queueCapability, enabled: queueEnabled } = queueDelivery // What resends and drops a send in flight besides a Retry or a new send; see the hook. const owner = useStructuredAgentSessionOutboxOwnerChange(target, fence) - const restoreWithdrawn = useStructuredAgentSessionWithdrawnRestore(sessionId, composerScopeKey) + const restoreWithdrawn = useStructuredAgentSessionWithdrawnRestore(composerScopeKey) // The outbox lives in the session's store, shared with every other writer; this view holds it // open and drains it. Loading maps what a previous owner left mid-send. const load = useCallback( @@ -155,7 +155,6 @@ export function useStructuredAgentSessionOutbox(args: { next.some((entry, index) => entry !== current[index]) || next.length !== current.length ) { - restoreWithdrawn.byHost(current, submissions) commitStructuredAgentSessionOutbox(sessionId, next) } // Keyed on the entry actually in flight, which is no longer always the head: the journal @@ -175,7 +174,7 @@ export function useStructuredAgentSessionOutbox(args: { ) { setError(null) } - }, [restoreWithdrawn, sessionId, submissions]) + }, [sessionId, submissions]) // The one place that owns the refs, the React state and the storage write. const applyDisposition = useCallback( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index e139125d2efe..968e8345a7b7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -246,7 +246,7 @@ export function useStructuredAgentSession(args: { stopPressed: stopPress.pressed, stop: () => { if (stopsConversation) { - // Unsent text this client still owns goes back to its composer — a local move. + // Unsent text this client still owns goes back to an empty composer — a local move. // Host-held drafts are never withdrawn by a Stop: the host pauses them and // they stay visible as cards, on every device, until the user acts on one. outboxController.withdrawUnsent() diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e5af368b653f..26e6fd276869 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17654,7 +17654,8 @@ "notices": { "compaction": "Context compacted", "details": "Details", - "plan": "Plan" + "plan": "Plan", + "stoppedBeforeStart": "Stopped before the agent started" }, "composer": { "imageUnsupported": "Image paste is not supported for this agent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 35e36d3ca93e..6ba9c404a6c9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15054,6 +15054,9 @@ "startNewChat": "Inicie un chat nuevo para continuar.", "tryAgain": "Vuelva a intentarlo." }, + "notices": { + "stoppedBeforeStart": "Detenido antes de que el agente empezara" + }, "composer": { "imageUnsupported": "Este agente no admite pegar imágenes.", "send": "Enviar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index fcb94e2f23e5..93b43ca282f8 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17761,7 +17761,8 @@ "notices": { "compaction": "Contexte compacté", "details": "Détails", - "plan": "Plan" + "plan": "Plan", + "stoppedBeforeStart": "Arrêté avant le démarrage de l'agent" }, "providerFrame": { "byteLength": "{{value0}} octets" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b78e8ec5de2a..8ce38c8eaa62 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17697,7 +17697,8 @@ "notices": { "compaction": "圧縮されたコンテキスト", "details": "詳細", - "plan": "プラン" + "plan": "プラン", + "stoppedBeforeStart": "Agent の開始前に停止しました" }, "providerFrame": { "byteLength": "{{value0}}バイト" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3b326303b79d..366a6c949164 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17697,7 +17697,8 @@ "notices": { "compaction": "컨텍스트가 압축됨", "details": "세부", - "plan": "계획" + "plan": "계획", + "stoppedBeforeStart": "에이전트가 시작되기 전에 중지됨" }, "providerFrame": { "byteLength": "{{value0}}바이트" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 865270d2feb0..3412aa61aac3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17662,7 +17662,8 @@ "notices": { "compaction": "上下文压缩", "details": "细节", - "plan": "计划" + "plan": "计划", + "stoppedBeforeStart": "在智能体开始前已停止" }, "providerFrame": { "byteLength": "{{value0}} 字节" diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts new file mode 100644 index 000000000000..689c671eb169 --- /dev/null +++ b/src/shared/native-chat-stopped-before-start.ts @@ -0,0 +1,4 @@ +/** The row after a send a Stop took back before the agent started it; a client words it by this. */ +export const NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION = 'stopped-before-start' +/** Its words where a client has no catalog of its own (the phone). */ +export const NATIVE_CHAT_STOPPED_BEFORE_START_TEXT = 'Stopped before the agent started' diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index c72c219f7586..c31ad1ecf092 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -217,6 +217,9 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { unsent?: true /** This client's send, made while the chat read Stopping, that the host has not recorded yet. */ sentWhileStopping?: true + /** A send a Stop took back before the agent started it, or the row saying so after it: drawn + * where it was sent, with no turn of its own to time. */ + stoppedBeforeStart?: true /** Set only by the structured projection, on rows the journal holds, and ranks * them ahead of time. Terminal-backed messages never carry it, and worker reads strip it. */ journalPosition?: AgentJournalPosition diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 57b8e745ca52..13d293256989 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -168,7 +168,7 @@ export function classifyDispatchRejection( return { category: KIND_CATEGORY[kind], verdict: KIND_VERDICT[kind], kind } } -/** A Stop withdrew it before it ran: it will not land, and only its sender can send it again. */ +/** A Stop withdrew it before it ran: it will not land, and only a person can send it again. */ export function dispatchWasWithdrawn( submission: Pick | undefined ): boolean { diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts new file mode 100644 index 000000000000..ec49323fab42 --- /dev/null +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -0,0 +1,151 @@ +// A send a Stop took back before the agent started it stays where it was sent, with one row after +// it saying so, on every client: the journal already holds the send and why it was refused. + +import { describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' +import { + NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION, + NATIVE_CHAT_STOPPED_BEFORE_START_TEXT +} from './native-chat-stopped-before-start' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_WRITE_FAILED +} from './structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +let sequence = 0 + +function entry(itemId: string, body: AgentJournalItemBody): AgentJournalRenderItem { + sequence += 1 + return { + itemId, + revision: 0, + sequence, + observedAt: sequence, + body, + turnScope: { kind: 'thread' } + } +} + +const sent = (id: string, text: string) => + entry(agentJournalSubmissionKey(id), { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] + }) + +function submission( + clientMessageId: string, + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + ...overrides + } +} + +const stopped = (id: string, overrides: Partial = {}) => + submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED, ...overrides }) + +/** Each row as id, role and whether it reads as stopped before it started. */ +function rows(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { + return projectStructuredAgentSessionMessages(items, [], submissions).map((message) => ({ + id: message.id, + role: message.role, + ...(message.stoppedBeforeStart ? { stoppedBeforeStart: true } : {}) + })) +} + +const stopRow = (id: string) => ({ + id: `stopped-before-start:${agentJournalSubmissionKey(id)}`, + role: 'system', + stoppedBeforeStart: true +}) + +describe('a send a Stop took back before the agent started it', () => { + it('stays where it was sent, with one row after it', () => { + const items = [sent('warm-up', 'warm up'), sent('never-ran', 'look around')] + + expect(rows(items, [submission('warm-up'), stopped('never-ran')])).toEqual([ + { id: agentJournalSubmissionKey('warm-up'), role: 'user' }, + { id: agentJournalSubmissionKey('never-ran'), role: 'user', stoppedBeforeStart: true }, + stopRow('never-ran') + ]) + const [, , row] = projectStructuredAgentSessionMessages(items, [], [stopped('never-ran')]) + expect(row?.blocks).toEqual([ + { + type: 'text', + text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT, + presentation: NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION + } + ]) + expect(row?.journalPosition).toEqual( + projectStructuredAgentSessionMessages(items, [], [stopped('never-ran')])[1]?.journalPosition + ) + }) + + it('reads the same from the typed fact as from the older marker', () => { + const items = [sent('never-ran', 'look around')] + const typed = stopped('never-ran', { + reason: 'This message was withdrawn before the agent started it.', + rejection: { kind: 'cancelled' } + }) + + expect(rows(items, [typed])).toEqual(rows(items, [stopped('never-ran')])) + }) + + it('shares one row with the sends taken back right before it', () => { + const items = [sent('first', 'one'), sent('second', 'two')] + + expect(rows(items, [stopped('first'), stopped('second')])).toEqual([ + { id: agentJournalSubmissionKey('first'), role: 'user', stoppedBeforeStart: true }, + { id: agentJournalSubmissionKey('second'), role: 'user', stoppedBeforeStart: true }, + stopRow('second') + ]) + }) + + it('stays in the turn that opened for it, whose end already says it was stopped', () => { + const items = [ + sent('opened', 'look around'), + entry('turn-1', { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + outcome: 'cancellation', + userItemId: agentJournalSubmissionKey('opened') + }) + ] + + expect(rows(items, [stopped('opened')])).toEqual([ + { id: agentJournalSubmissionKey('opened'), role: 'user' } + ]) + }) + + it('leaves a queued card to hold its own text', () => { + const items = [sent('card-send', 'from a card')] + + expect(rows(items, [stopped('card-send', { queuedMessageId: 'card-1' })])).toEqual([]) + }) + + it('keeps any other refused send out of the conversation', () => { + const items = [sent('refused', 'hello')] + + expect( + rows(items, [ + submission('refused', { dispatchState: 'rejected', reason: DISPATCH_REJECTED_WRITE_FAILED }) + ]) + ).toEqual([]) + }) +}) diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 0a097c8d7085..c7f80d34cd65 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -3,12 +3,46 @@ import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { agentJournalItemPosition } from './agent-session-journal-position' import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' +import { + NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION, + NATIVE_CHAT_STOPPED_BEFORE_START_TEXT +} from './native-chat-stopped-before-start' +import { structuredAgentTurnAnchors } from './native-chat-turn-membership' import type { NativeChatMessage } from './native-chat-types' +import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission' import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off' import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' +/** One row after each run of sends a Stop took back, placed with the last of them. */ +function withStopRowsAfterStoppedSends( + messages: readonly NativeChatMessage[] +): NativeChatMessage[] { + return messages.flatMap((message, index) => + message.stoppedBeforeStart && messages[index + 1]?.stoppedBeforeStart !== true + ? [ + message, + { + id: `stopped-before-start:${message.id}`, + role: 'system' as const, + source: 'transcript' as const, + timestamp: message.timestamp, + blocks: [ + { + type: 'text' as const, + text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT, + presentation: NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION + } + ], + stoppedBeforeStart: true as const, + ...(message.journalPosition ? { journalPosition: message.journalPosition } : {}) + } + ] + : [message] + ) +} + export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], @@ -16,12 +50,24 @@ export function projectStructuredAgentSessionMessages( projectItems = projectStructuredItemsToNativeChat ): NativeChatMessage[] { const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions) - // Refused sends are ledger evidence, not conversation history; local drafts remain in the outbox. + // A send a Stop took back before the agent started it stays where it was sent, as the + // conversation's own history. A queued card's hand-off is left out: the card holds its text. + const stoppedBeforeStart = new Set( + submissions + .filter( + (submission) => dispatchWasWithdrawn(submission) && submission.queuedMessageId === undefined + ) + .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + ) + // Other refused sends are ledger evidence, not conversation history; local drafts remain in the outbox. const rejected = new Set( submissions .filter((submission) => submission.dispatchState === 'rejected') .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + .filter((itemId) => !stoppedBeforeStart.has(itemId)) ) + // A turn opened for it ends saying it was stopped, so such a send stays in that turn. + const anchored = new Set(structuredAgentTurnAnchors(items, submissions).values()) const visibleItems: AgentJournalRenderItem[] = [] const refused = new Map() for (const item of items) { @@ -44,13 +90,15 @@ export function projectStructuredAgentSessionMessages( for (const message of projectItems(visibleItems)) { if (queued.has(message.id)) { held.push({ ...message, queued: true }) + } else if (stoppedBeforeStart.has(message.id) && !anchored.has(message.id)) { + delivered.push({ ...message, stoppedBeforeStart: true }) } else { delivered.push(message) } } return [ // After the held sends leave: they are drawn after the conversation, never inside a run. - ...collapseProviderRetryRuns(delivered), + ...withStopRowsAfterStoppedSends(collapseProviderRetryRuns(delivered)), ...held, ...optimistic .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) diff --git a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts index 5606ae0c9ceb..c418d0ed8386 100644 --- a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts +++ b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts @@ -50,7 +50,7 @@ function markedOutlivingStop( /** * What a Stop leaves in the outbox: nothing the journal does not already hold may go out after it, - * so every such entry goes, as a message the host withdraws leaves the chat. The send on its way + * so every such entry leaves it, as any send a Stop withdraws does. The send on its way * stays: it reaches the host ahead of the Stop, and it comes back from the host's answer, since * the agent may already have it. One waiting on Retry keeps it, and so does an issued queue send * that has gone out (a queued receipt or hand-off retires it against the published card), and it From f0e502effcbcfac0e266b0d55cd3ab35d26a1166 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:05:24 -0700 Subject: [PATCH 119/182] fix(native-chat): one stop row per stopped send, drawn where the Stop took it, and nothing lost - A message this window's Stop took before the host had it, which the composer cannot take (it holds text or images, or no composer shows the chat), now stays in the outbox as not sent, on its Retry, instead of vanishing. - A Codex send whose turn opened but was never echoed belongs to that turn: its interrupted end is the stop, so no second row is drawn. A send taken back after a later turn started still gets its own row. - A steer the Stop took back stays in the turn it joined, with no row of its own. - A queued send taken back before its hand-over is drawn after the turn it waited behind, not inside it. - The conversation outline the host serves still leaves out sends a Stop took back, and the rail gives them no tick, so what the host publishes is unchanged. - The row is drawn as the Stop's own status rows are, and never folds behind a settled turn on a host that states no turn scopes. --- ...-session-codex-turn-end-settlement.test.ts | 16 ++ ...tMessageList.stopped-before-start.test.tsx | 70 ++++++++- .../native-chat/NativeChatMessageRow.tsx | 8 +- .../native-chat/NativeChatNoticeRow.tsx | 12 -- .../native-chat-message-rail-items.ts | 7 +- .../native-chat-rail-outline-parity.test.ts | 26 ++++ .../native-chat-stopped-before-start-row.ts | 20 +++ .../native-chat-transcript-slots.ts | 6 +- ...session-withdrawn-message-restore.test.tsx | 71 ++++++--- ...agent-session-withdrawn-message-restore.ts | 18 ++- ...ructured-agent-session-outbox-ownership.ts | 17 ++- ...d-agent-session-outbox-withdrawal.test.tsx | 15 +- .../agent-session-conversation-outline.ts | 9 ++ .../native-chat-stopped-before-start.ts | 81 ++++++++++ src/shared/native-chat-turn-membership.ts | 38 ++++- src/shared/native-chat-types.ts | 4 +- ...ge-projection-stopped-before-start.test.ts | 143 +++++++++++++++--- ...ctured-agent-session-message-projection.ts | 67 ++++---- 18 files changed, 513 insertions(+), 115 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-stopped-before-start-row.ts diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index bcc3974570d2..a67123f8e8bb 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -25,7 +25,9 @@ import type { AgentJournalSubmission } from '../../shared/agent-session-journal- import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../shared/native-chat-stopped-before-start' +import { structuredAgentTurnAnchors } from '../../shared/native-chat-turn-membership' import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' import { HOST_TEST_SESSION as SESSION, @@ -298,6 +300,20 @@ describe('a Codex send its turn ended without taking it', () => { const after = await settled() expect(verdictOf(after.submissions, sent)).toBe('withdrawn') expect(after.owesWork).toBe(false) + // Codex opened its turn before echoing it, so that turn, ended interrupted, is the send's: it + // carries the stop, and no row of its own follows the send. + const snapshot = await host.journalSnapshot(SESSION) + const turnRecord = snapshot.items.find( + (item) => readAgentJournalTurn(item.body)?.turnId === 'turn-1' + ) + expect( + structuredAgentTurnAnchors(snapshot.items, snapshot.submissions).get(turnRecord!.itemId) + ).toBe(agentJournalSubmissionKey(sent)) + expect( + projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions).map( + (message) => message.blocks.map((block) => ('text' in block ? block.text : block.type)) + ) + ).not.toContainEqual([NATIVE_CHAT_STOPPED_BEFORE_START_TEXT]) // Codex echoing it late changes nothing: a settled answer stands. await host.settleLateDispatch({ diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx index 1ad627615d4b..0b66daad5e24 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx @@ -93,8 +93,8 @@ function submission( } } -const stopped = (id: string) => - submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }) +const stopped = (id: string, overrides: Partial = {}) => + submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED, ...overrides }) function journalList( items: AgentJournalRenderItem[], @@ -197,4 +197,70 @@ describe('a send a Stop took back before the agent started it', () => { expect(screen.getByText('Interrupted after 2s')).toBeInTheDocument() expect(screen.queryByText(STOP_ROW)).toBeNull() }) + + // Codex reports the turn open before it echoes the send, so the record names the provider's key. + it("leaves it to that turn before the provider echoed it, with only that turn's stop rows", () => { + renderJournal( + [ + ...warmUp(), + sent('opened', 'look around'), + item('t2', { + kind: 'turn', + turnId: 't2', + state: 'interrupted', + outcome: 'cancellation', + userItemId: 'codex:thread-1:t2:0', + startedAt: 10_000, + completedAt: 12_000 + }), + item( + 'stop:t2', + { kind: 'status', text: 'Cancellation requested.' }, + { kind: 'turn', turnItemId: 't2' } + ) + ], + [submission('warm-up'), stopped('opened', { resolvedAt: 12_000 })] + ) + + const message = screen.getByText('look around') + expect(follows(screen.getByText('Interrupted after 2s'), message)).toBe(true) + expect(screen.queryByText(STOP_ROW)).toBeNull() + }) + + it("draws its row as the Stop's own status rows are drawn", () => { + renderJournal( + [ + ...warmUp(), + item('stop:t1', { kind: 'status', text: 'Cancellation requested.' }), + sent('never-ran', 'look around') + ], + [submission('warm-up'), stopped('never-ran')] + ) + + const rowOf = (text: string) => screen.getByText(text).closest('.group') + expect(rowOf(STOP_ROW)?.className).toBe(rowOf('Cancellation requested.')?.className) + expect(rowOf(STOP_ROW)?.className).toBeTruthy() + }) + + it('keeps its row on screen under a settled turn on a host that states no turn scopes', () => { + const unscoped = (entry: AgentJournalRenderItem): AgentJournalRenderItem => { + const { turnScope: _none, ...rest } = entry + return rest + } + renderJournal( + [ + ...warmUp(), + sent('steered', 'and check the tests'), + item('t1-more', { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'More warming up.' }] + }) + ].map(unscoped), + [submission('warm-up'), stopped('steered')] + ) + + expect(screen.getByText('and check the tests')).toBeInTheDocument() + expect(screen.getByText(STOP_ROW)).toBeInTheDocument() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 234934664f4a..afedfc3e4e17 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -14,6 +14,7 @@ import { deriveNativeChatRowContent } from '../../../../shared/native-chat-row-c import { NativeChatToolRun } from './NativeChatToolRun' import { NativeChatCodeBlock } from './NativeChatCodeBlock' import { NativeChatNoticeRow } from './NativeChatNoticeRow' +import { nativeChatBlocksInOwnWords } from './native-chat-stopped-before-start-row' import { NativeChatCopyButton } from './NativeChatCopyButton' import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' import { @@ -82,14 +83,15 @@ export const MessageRow = memo(function MessageRow({ runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element | null { const rowRef = useRef(null) + const blocks = nativeChatBlocksInOwnWords(message.blocks) // One pass per block set, shared with the list that decides whether this row // occupies a slot — so "draws nothing" means the same thing to both. const { backgroundTasks, hasImages, markdown, prose, subagentGroups, tools } = - deriveNativeChatRowContent(message.blocks) + deriveNativeChatRowContent(blocks) const isUser = message.role === 'user' const isReasoning = message.role === 'reasoning' const isSystem = message.role === 'system' - const providerFrame = message.blocks.find((block) => block.type === 'text' && block.providerFrame) + const providerFrame = blocks.find((block) => block.type === 'text' && block.providerFrame) const scrollToTop = useCallback(() => { if (rowRef.current) { @@ -117,7 +119,7 @@ export const MessageRow = memo(function MessageRow({ } const notice = isSystem - ? message.blocks.find( + ? blocks.find( (block) => block.type === 'text' && (block.presentation !== undefined || block.tone !== undefined) ) diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index a47c52b754ef..8b05f0d2d61d 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -6,7 +6,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' import type { NativeChatTextBlock } from '../../../../shared/native-chat-types' -import { NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION } from '../../../../shared/native-chat-stopped-before-start' import { ProviderFrameRow } from './NativeChatTranscriptChrome' export function NativeChatNoticeRow({ @@ -32,17 +31,6 @@ export function NativeChatNoticeRow({ ) } - if (block.presentation === NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION) { - // The line a Stop's own row draws, in this client's words. - return ( -

- {translate( - 'components.native-chat.notices.stoppedBeforeStart', - 'Stopped before the agent started' - )} -

- ) - } if (block.presentation === 'plan-document') { return ( diff --git a/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts b/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts index 1ad2ba71a397..deebb90dbac7 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts @@ -39,7 +39,12 @@ export function buildNativeChatRailItems( ): readonly NativeChatRailItem[] { const items: NativeChatRailItem[] = [] for (const [slotIndex, slot] of slots.entries()) { - if (slot.kind !== 'message' || slot.message.role !== 'user') { + // A send a Stop took back is no tick, as the host's outline of older history leaves it out. + if ( + slot.kind !== 'message' || + slot.message.role !== 'user' || + slot.message.stoppedBeforeStart === true + ) { continue } const preview = nativeChatUserMessagePreview(slot.message.blocks) diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index e5254b63e942..497e546beeb6 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -9,6 +9,7 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { buildNativeChatRailItems } from './native-chat-message-rail-items' import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' @@ -116,6 +117,31 @@ describe('conversation outline parity with the loaded rail', () => { ]) }) + // The host serves this outline to clients of every version, so it lists what it always listed. + it('leaves out a send a Stop took back, which the transcript still draws', () => { + const stopped: AgentJournalSubmission = { + ...REJECTED, + clientMessageId: 'client-stopped', + reason: DISPATCH_REJECTED_CANCELLED + } + const journal = [ + ...JOURNAL, + user(13, [{ type: 'text', text: 'never ran' }], agentJournalSubmissionKey('client-stopped')) + ] + const outline = projectAgentSessionConversationOutline(journal, [REJECTED, stopped]) + const loaded = loadedRailItems(journal, [REJECTED, stopped]) + + expect(outline.map((entry) => entry.itemId)).toEqual(loaded.map((item) => item.id)) + expect(outline.map((entry) => entry.itemId)).not.toContain( + agentJournalSubmissionKey('client-stopped') + ) + expect( + projectStructuredAgentSessionMessages(journal, [], [REJECTED, stopped]).map( + (message) => message.id + ) + ).toContain(agentJournalSubmissionKey('client-stopped')) + }) + it('carries each entry its creation sequence and image count', () => { const outline = projectAgentSessionConversationOutline(JOURNAL, [REJECTED]) expect(outline).toEqual([ diff --git a/src/renderer/src/components/native-chat/native-chat-stopped-before-start-row.ts b/src/renderer/src/components/native-chat/native-chat-stopped-before-start-row.ts new file mode 100644 index 000000000000..4a3fb9daf38e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-stopped-before-start-row.ts @@ -0,0 +1,20 @@ +import { translate } from '@/i18n/i18n' +import { isStoppedBeforeStartBlock } from '../../../../shared/native-chat-stopped-before-start' +import type { NativeChatBlock } from '../../../../shared/native-chat-types' + +/** The row after a send a Stop took back, in this client's words, drawn as any other status line. */ +export function nativeChatBlocksInOwnWords(blocks: NativeChatBlock[]): NativeChatBlock[] { + return blocks.some(isStoppedBeforeStartBlock) + ? blocks.map((block) => + isStoppedBeforeStartBlock(block) + ? { + type: 'text', + text: translate( + 'components.native-chat.notices.stoppedBeforeStart', + 'Stopped before the agent started' + ) + } + : block + ) + : blocks +} diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index fdd401fc5f9e..27cbe7864650 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -21,6 +21,7 @@ import { type NativeChatTurnFoldRow } from '../../../../shared/native-chat-turn-fold' import { nativeChatRowRendersContent } from '../../../../shared/native-chat-row-content' +import { isStoppedBeforeStartBlock } from '../../../../shared/native-chat-stopped-before-start' import { estimateNativeChatRowHeight, nativeChatRowContentMetrics @@ -125,7 +126,10 @@ export function buildNativeChatTranscriptSlots( // The raw blocks, not the renderable ones: a childless roster draws no row // and its plain-text twin is then the only record the spawn happened. outlivesTurn: message.blocks.some( - (block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block) + (block) => + isSubagentGroupBlock(block) || + isBackgroundTaskBlock(block) || + isStoppedBeforeStartBlock(block) ), reportsFailure: message.blocks.some((block) => block.type === 'text' && block.tone === 'error'), reportsCompaction: message.blocks.some( diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx index 3fbfe80030f9..6303b0929f6d 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx @@ -303,32 +303,67 @@ describe('a message a Stop took out of the outbox before the host held it', () = 'an image', () => appendNativeChatAttachmentCache(PANE, [{ id: 'typed', path: '/tmp/typed.png' }]) ] - ])('leaves a composer holding %s as it is', async (_holding, fill) => { + ])( + 'leaves a composer holding %s as it is, and keeps the message as not sent', + async (_holding, fill) => { + mocks.call.mockImplementation(() => new Promise(() => {})) + const { result } = renderOutbox() + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + act(() => + expect( + result.current.send('second', [{ path: '/tmp/shot.png', previewUri: '/tmp/shot.png' }]) + ).toBe(true) + ) + const before = { + draft: readNativeChatDraftCache(PANE), + images: readNativeChatAttachmentCache(PANE) + } + fill() + const filled = { + draft: readNativeChatDraftCache(PANE), + images: readNativeChatAttachmentCache(PANE) + } + expect(filled).not.toEqual(before) + + act(() => result.current.withdrawUnsent()) + + expect({ + draft: readNativeChatDraftCache(PANE), + images: readNativeChatAttachmentCache(PANE) + }).toEqual(filled) + // Its only copy, so it stays as not sent, text and image, on its Retry. + expect( + readOutbox(SESSION, { recoverDispatching: false }).map((entry) => ({ + state: entry.state, + blocks: entry.body.blocks + })) + ).toEqual([ + { state: 'dispatching', blocks: [{ type: 'text', text: 'first' }] }, + { + state: 'rejected', + blocks: [ + { type: 'text', text: 'second' }, + { type: 'image-ref', path: '/tmp/shot.png' } + ] + } + ]) + } + ) + + it('keeps the message as not sent where no composer shows the chat', async () => { mocks.call.mockImplementation(() => new Promise(() => {})) - const { result } = renderOutbox() + const { result } = renderOutbox(null) act(() => expect(result.current.send('first')).toBe(true)) await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) act(() => expect(result.current.send('second')).toBe(true)) - const before = { - draft: readNativeChatDraftCache(PANE), - images: readNativeChatAttachmentCache(PANE) - } - fill() - const filled = { - draft: readNativeChatDraftCache(PANE), - images: readNativeChatAttachmentCache(PANE) - } - expect(filled).not.toEqual(before) act(() => result.current.withdrawUnsent()) - expect(result.current.outbox.map((entry) => entry.body.blocks)).toEqual([ - [{ type: 'text', text: 'first' }] + expect(result.current.outbox.map((entry) => [entry.state, entry.body.blocks])).toEqual([ + ['dispatching', [{ type: 'text', text: 'first' }]], + ['rejected', [{ type: 'text', text: 'second' }]] ]) - expect({ - draft: readNativeChatDraftCache(PANE), - images: readNativeChatAttachmentCache(PANE) - }).toEqual(filled) }) it('leaves a message waiting on Retry where it is, and gives back only what it withdrew', async () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index 2a634b71e7c1..a890751ea6a8 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -8,20 +8,20 @@ import { /** * Gives the sender back what its own Stop took out of the outbox before the host held it, into - * an empty composer only: the host never had it, so the transcript cannot show it. A composer - * holding text or images keeps what is there. What the host withdrew stays in the transcript. + * an empty composer only: the host never had it, so the transcript cannot show it. Returns whether + * they went in; a composer holding text or images keeps what is there, and the caller keeps the + * entries. What the host withdrew stays in the transcript. */ function restoreUnsentMessages( composerScopeKey: string | undefined, withdrawn: readonly StructuredAgentSessionOutboxEntry[] -): void { +): boolean { if ( !composerScopeKey || - withdrawn.length === 0 || readNativeChatDraftCache(composerScopeKey) !== '' || readNativeChatAttachmentCache(composerScopeKey).length > 0 ) { - return + return false } for (const entry of withdrawn) { const blocks = entry.body.blocks @@ -38,14 +38,16 @@ function restoreUnsentMessages( ) ) } + return true } export function useStructuredAgentSessionWithdrawnRestore( - /** Absent where no composer shows this session; the entries are then only dropped. */ + /** Absent where no composer shows this session; the caller then keeps the entries. */ composerScopeKey: string | undefined ): { - /** Entries a Stop took out of the outbox here, before the host held them. */ - byStop: (entries: readonly StructuredAgentSessionOutboxEntry[]) => void + /** Entries a Stop took out of the outbox here, before the host held them; false when the + * composer could not take them. */ + byStop: (entries: readonly StructuredAgentSessionOutboxEntry[]) => boolean } { return useMemo( () => ({ byStop: (entries) => restoreUnsentMessages(composerScopeKey, entries) }), diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts index 0a425f022783..7a724b884780 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts @@ -1,6 +1,6 @@ // Who owns an outbox entry's text when it leaves this client's queue without a // send answer. A Stop hands unsent text back to an empty composer — a local move; no -// text crosses a wire. A host that visibly holds the entry as a queued draft +// text crosses a wire. Text it cannot hand back stays here as not sent, on its Retry. A host that visibly holds the entry as a queued draft // (same id) owns it: those entries retire with no local restore, so the same // words can never come back twice. @@ -40,9 +40,18 @@ export function useStructuredAgentSessionOutboxOwnership(args: { return } // By id: a kept entry comes back marked, as a new object. - const kept = new Set(next.map((entry) => entry.clientMessageId)) - restoreWithdrawn.byStop(current.filter((entry) => !kept.has(entry.clientMessageId))) - commitStructuredAgentSessionOutbox(sessionId, next) + const kept = new Map(next.map((entry) => [entry.clientMessageId, entry])) + const withdrawn = current.filter((entry) => !kept.has(entry.clientMessageId)) + // The outbox is the only copy of what the host never had, so nothing leaves it unplaced. + const restored = restoreWithdrawn.byStop(withdrawn) + commitStructuredAgentSessionOutbox( + sessionId, + restored + ? next + : current.map( + (entry) => kept.get(entry.clientMessageId) ?? { ...entry, state: 'rejected' as const } + ) + ) }, [inFlightIdRef, restoreWithdrawn, sessionId, submissions]) // Drop host-owned entries without a restore: the published card is the text now. diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx index 1c7d69f1aa70..e3e5d9f53baa 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx @@ -105,9 +105,18 @@ describe('a Stop withdrawing what the host does not hold', () => { ) await act(async () => new Promise((resolve) => setTimeout(resolve, 50))) - // The host's answer to it, not this Stop, decides whether it comes back. - expect(result.current.outbox.map((candidate) => candidate.clientMessageId)).toEqual([firstId]) - expect(readOutbox('session-1').map((candidate) => candidate.clientMessageId)).toEqual([firstId]) + // The host's answer to it, not this Stop, decides whether it comes back. With no composer to + // take it, the one behind it stays as not sent, on its Retry, and never goes out by itself. + const states = (entries: readonly { clientMessageId: string; state: string }[]) => + entries.map((candidate) => [candidate.clientMessageId === firstId, candidate.state]) + expect(states(result.current.outbox)).toEqual([ + [true, 'dispatching'], + [false, 'rejected'] + ]) + expect(states(readOutbox('session-1', { recoverDispatching: false }))).toEqual([ + [true, 'dispatching'], + [false, 'rejected'] + ]) expect(sentTexts()).toEqual(['first']) }) diff --git a/src/shared/agent-session-conversation-outline.ts b/src/shared/agent-session-conversation-outline.ts index 2b6bb846adb8..a5b7696514c9 100644 --- a/src/shared/agent-session-conversation-outline.ts +++ b/src/shared/agent-session-conversation-outline.ts @@ -13,6 +13,8 @@ import type { } from './agent-session-journal-types' import type { NativeChatBlock } from './native-chat-types' import { deriveNativeChatRowContent, nativeChatRowRendersContent } from './native-chat-row-content' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' import { projectNativeChatTranscriptMessages } from './native-chat-transcript-projection' @@ -87,6 +89,12 @@ export function projectAgentSessionConversationOutline( sequences.set(item.itemId, item.sequence) } } + // Served to clients of every version, so a send a Stop took back stays out, as it always was. + const stopped = new Set( + submissions + .filter((submission) => dispatchWasWithdrawn(submission)) + .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + ) const entries: AgentSessionConversationOutlineEntry[] = [] const transcript = projectNativeChatTranscriptMessages( projectStructuredAgentSessionMessages(items, [], submissions) @@ -95,6 +103,7 @@ export function projectAgentSessionConversationOutline( const sequence = sequences.get(message.id) if ( sequence === undefined || + stopped.has(message.id) || message.role !== 'user' || !nativeChatRowRendersContent(message.blocks) ) { diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts index 689c671eb169..daf900f455da 100644 --- a/src/shared/native-chat-stopped-before-start.ts +++ b/src/shared/native-chat-stopped-before-start.ts @@ -1,4 +1,85 @@ +// A send a Stop took back before the agent started it: where it is drawn, and the one row after it. + +import { isRootAgentJournalItem } from './agent-session-journal-producer' +import { + agentJournalItemPosition, + compareAgentJournalItems +} from './agent-session-journal-position' +import type { AgentJournalPosition, AgentJournalRenderItem } from './agent-session-journal-types' +import { readAgentJournalTurn } from './agent-session-turn-record' +import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' + /** The row after a send a Stop took back before the agent started it; a client words it by this. */ export const NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION = 'stopped-before-start' /** Its words where a client has no catalog of its own (the phone). */ export const NATIVE_CHAT_STOPPED_BEFORE_START_TEXT = 'Stopped before the agent started' + +/** That row: it outlives any turn it sits in, as the send it follows does. */ +export function isStoppedBeforeStartBlock(block: NativeChatBlock): boolean { + return ( + block.type === 'text' && block.presentation === NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION + ) +} + +/** + * Where a send a Stop took back before its hand-over is drawn: where the Stop took it back, after + * the rest of the turn it waited behind, not where it was accepted inside that turn. + */ +export function stoppedBeforeHandOverPosition( + items: readonly AgentJournalRenderItem[], + item: AgentJournalRenderItem +): AgentJournalPosition { + let waitedBehind: AgentJournalRenderItem | undefined + for (const candidate of items) { + if ( + compareAgentJournalItems(candidate, item) < 0 && + isRootAgentJournalItem(candidate) && + readAgentJournalTurn(candidate.body) && + (!waitedBehind || compareAgentJournalItems(candidate, waitedBehind) > 0) + ) { + waitedBehind = candidate + } + } + let last = item + for (const candidate of items) { + if ( + waitedBehind && + candidate.turnScope?.kind === 'turn' && + candidate.turnScope.turnItemId === waitedBehind.itemId && + compareAgentJournalItems(candidate, last) > 0 + ) { + last = candidate + } + } + const position = agentJournalItemPosition(last) + // Just after that turn's last row, ahead of whatever the journal wrote next. + return last === item ? position : { sequence: position.sequence, index: position.index + 0.5 } +} + +/** One row after each run of sends a Stop took back (`stopped`, by id), placed with the last. */ +export function withStopRowsAfterStoppedSends( + messages: readonly NativeChatMessage[], + stopped: ReadonlySet +): NativeChatMessage[] { + return messages.flatMap((message, index) => + stopped.has(message.id) && !stopped.has(messages[index + 1]?.id ?? '') + ? [ + message, + { + id: `stopped-before-start:${message.id}`, + role: 'system' as const, + source: 'transcript' as const, + timestamp: message.timestamp, + blocks: [ + { + type: 'text' as const, + text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT, + presentation: NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION + } + ], + ...(message.journalPosition ? { journalPosition: message.journalPosition } : {}) + } + ] + : [message] + ) +} diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index 0ac37087763e..5c290ccf8fbb 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -26,6 +26,7 @@ import { } from './native-chat-turn-grouping' import type { NativeChatRole } from './native-chat-types' import { isStructuredAgentSessionCommandTurn } from './structured-agent-session-command-entry' +import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' import { liveStructuredAgentSessionTurnScope } from './structured-agent-session-live-turn' /** Whether the host writing this journal states each row's turn. Only a host that runs `/compact` @@ -44,7 +45,8 @@ export type NativeChatTurnJournal = { * entry, directly or through a submission's adopted provider item; a record that names no entry * falls back to the nearest user entry before it, which only older hosts write. A key nothing * resolves yet is the send still in flight ahead of the record — Codex reports a turn open before - * it echoes the send — and with none, the turn anchors on its own record. + * it echoes the send — or one a Stop took back before that echo; with none, the turn anchors on + * its own record. */ export function structuredAgentTurnAnchors( items: readonly AgentJournalRenderItem[], @@ -68,26 +70,58 @@ export function structuredAgentTurnAnchors( .filter((submission) => submission.dispatchState === 'pending' && !submission.providerItemId) .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) + // A send a Stop took back after its turn opened but before the provider echoed it: the record + // still names the provider's key, and the send is no longer in flight. It opened the turn that + // started before the Stop took it back. + const stoppedBeforeEcho = new Map( + submissions.flatMap((submission) => + !submission.providerItemId && + submission.queuedMessageId === undefined && + submission.resolvedAt !== null && + dispatchWasWithdrawn(submission) + ? [[agentJournalSubmissionKey(submission.clientMessageId), submission.resolvedAt] as const] + : [] + ) + ) const anchors = new Map() let precedingUserItemId: string | null = null let inFlightSinceLastTurn: string | null = null + let stoppedSinceLastTurn: { itemId: string; resolvedAt: number } | null = null for (const item of items) { if (userItemIds.has(item.itemId)) { precedingUserItemId = item.itemId if (inFlightSinceLastTurn === null && inFlight.has(item.itemId)) { inFlightSinceLastTurn = item.itemId } + const resolvedAt = stoppedBeforeEcho.get(item.itemId) + if (stoppedSinceLastTurn === null && resolvedAt !== undefined) { + stoppedSinceLastTurn = { itemId: item.itemId, resolvedAt } + } continue } const turn = readAgentJournalTurn(item.body) if (!turn || !isRootAgentJournalItem(item)) { continue } + const stoppedOpener = + stoppedSinceLastTurn !== null && + turn.startedAt !== undefined && + turn.startedAt <= stoppedSinceLastTurn.resolvedAt + ? stoppedSinceLastTurn.itemId + : null anchors.set( item.itemId, - anchorOf(item.itemId, turn, userItemIds, aliases, precedingUserItemId, inFlightSinceLastTurn) + anchorOf( + item.itemId, + turn, + userItemIds, + aliases, + precedingUserItemId, + inFlightSinceLastTurn ?? stoppedOpener + ) ) inFlightSinceLastTurn = null + stoppedSinceLastTurn = null } return anchors } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index c31ad1ecf092..1cb39d568983 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -217,8 +217,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { unsent?: true /** This client's send, made while the chat read Stopping, that the host has not recorded yet. */ sentWhileStopping?: true - /** A send a Stop took back before the agent started it, or the row saying so after it: drawn - * where it was sent, with no turn of its own to time. */ + /** A send a Stop took back before the agent started it: drawn where it was sent, but no rail + * tick, as the conversation outline the host serves leaves it out. */ stoppedBeforeStart?: true /** Set only by the structured projection, on rows the journal holds, and ranks * them ahead of time. Terminal-backed messages never carry it, and worker reads strip it. */ diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts index ec49323fab42..e6e449c7a372 100644 --- a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -6,7 +6,8 @@ import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import type { AgentJournalItemBody, AgentJournalRenderItem, - AgentJournalSubmission + AgentJournalSubmission, + AgentJournalTurnScope } from './agent-session-journal-types' import { NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION, @@ -17,21 +18,26 @@ import { DISPATCH_REJECTED_WRITE_FAILED } from './structured-agent-session-dispatch-rejection' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +import { projectNativeChatTranscriptMessages } from './native-chat-transcript-projection' let sequence = 0 -function entry(itemId: string, body: AgentJournalItemBody): AgentJournalRenderItem { +function entry( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = { kind: 'thread' } +): AgentJournalRenderItem { sequence += 1 - return { - itemId, - revision: 0, - sequence, - observedAt: sequence, - body, - turnScope: { kind: 'thread' } - } + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } } +const said = (itemId: string, text: string, turnItemId: string) => + entry( + itemId, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }, + { kind: 'turn', turnItemId } + ) + const sent = (id: string, text: string) => entry(agentJournalSubmissionKey(id), { kind: 'message', @@ -59,19 +65,17 @@ function submission( const stopped = (id: string, overrides: Partial = {}) => submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED, ...overrides }) -/** Each row as id, role and whether it reads as stopped before it started. */ +/** Each row as id and role, in the order the transcript draws them. */ function rows(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { - return projectStructuredAgentSessionMessages(items, [], submissions).map((message) => ({ - id: message.id, - role: message.role, - ...(message.stoppedBeforeStart ? { stoppedBeforeStart: true } : {}) - })) + return projectNativeChatTranscriptMessages( + projectStructuredAgentSessionMessages(items, [], submissions) + ).map((message) => ({ id: message.id, role: message.role })) } +const user = (id: string) => ({ id: agentJournalSubmissionKey(id), role: 'user' }) const stopRow = (id: string) => ({ id: `stopped-before-start:${agentJournalSubmissionKey(id)}`, - role: 'system', - stoppedBeforeStart: true + role: 'system' }) describe('a send a Stop took back before the agent started it', () => { @@ -79,8 +83,8 @@ describe('a send a Stop took back before the agent started it', () => { const items = [sent('warm-up', 'warm up'), sent('never-ran', 'look around')] expect(rows(items, [submission('warm-up'), stopped('never-ran')])).toEqual([ - { id: agentJournalSubmissionKey('warm-up'), role: 'user' }, - { id: agentJournalSubmissionKey('never-ran'), role: 'user', stoppedBeforeStart: true }, + user('warm-up'), + user('never-ran'), stopRow('never-ran') ]) const [, , row] = projectStructuredAgentSessionMessages(items, [], [stopped('never-ran')]) @@ -110,8 +114,8 @@ describe('a send a Stop took back before the agent started it', () => { const items = [sent('first', 'one'), sent('second', 'two')] expect(rows(items, [stopped('first'), stopped('second')])).toEqual([ - { id: agentJournalSubmissionKey('first'), role: 'user', stoppedBeforeStart: true }, - { id: agentJournalSubmissionKey('second'), role: 'user', stoppedBeforeStart: true }, + user('first'), + user('second'), stopRow('second') ]) }) @@ -128,8 +132,99 @@ describe('a send a Stop took back before the agent started it', () => { }) ] - expect(rows(items, [stopped('opened')])).toEqual([ - { id: agentJournalSubmissionKey('opened'), role: 'user' } + expect(rows(items, [stopped('opened')])).toEqual([user('opened')]) + }) + + // Codex reports the turn open before it echoes the send, so the record names the provider's key. + it('stays in the turn that opened for it before the provider echoed it', () => { + const items = [ + sent('opened', 'look around'), + entry('turn-1', { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + outcome: 'cancellation', + userItemId: 'codex:thread-1:turn-1:0', + startedAt: 5 + }) + ] + + expect(rows(items, [stopped('opened', { resolvedAt: 10 })])).toEqual([user('opened')]) + }) + + // A steer joins the running turn, whose interrupted end already says the Stop took it. + it('stays in the turn it was steered into, with no row of its own', () => { + const items = [ + sent('first', 'look around'), + entry('t1', { + kind: 'turn', + turnId: 't1', + state: 'interrupted', + outcome: 'cancellation', + userItemId: agentJournalSubmissionKey('first') + }), + entry( + agentJournalSubmissionKey('steer'), + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'and check the tests' }] }, + { kind: 'turn', turnItemId: 't1' } + ) + ] + + expect(rows(items, [submission('first'), stopped('steer')])).toEqual([ + user('first'), + user('steer') + ]) + }) + + it('keeps its row when a turn starts only after the Stop took it back', () => { + const items = [ + sent('never-ran', 'look around'), + entry('wake', { + kind: 'turn', + turnId: 'wake', + state: 'completed', + userItemId: 'claude:wake', + startedAt: 20 + }), + said('wake-note', 'Checking the build.', 'wake') + ] + + expect(rows(items, [stopped('never-ran', { resolvedAt: 10 })])).toEqual([ + user('never-ran'), + stopRow('never-ran'), + { id: 'wake-note', role: 'assistant' } + ]) + }) + + it('is drawn after the turn it waited behind when it was never handed over', () => { + const items = [ + sent('first', 'first prompt'), + entry('t1', { + kind: 'turn', + turnId: 't1', + state: 'interrupted', + outcome: 'cancellation', + userItemId: agentJournalSubmissionKey('first') + }), + said('before', 'working on it', 't1'), + sent('queued', 'queued while t1 ran'), + said('after', 'more t1 output', 't1'), + entry( + 'note', + { kind: 'status', text: 'Cancellation requested.' }, + { kind: 'turn', turnItemId: 't1' } + ) + ] + + expect( + rows(items, [submission('first'), stopped('queued', { handoverRecorded: true })]) + ).toEqual([ + user('first'), + { id: 'before', role: 'assistant' }, + { id: 'after', role: 'assistant' }, + { id: 'note', role: 'system' }, + user('queued'), + stopRow('queued') ]) }) diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index c7f80d34cd65..9c6bf4b532e6 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -4,9 +4,10 @@ import { agentJournalItemPosition } from './agent-session-journal-position' import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' import { - NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION, - NATIVE_CHAT_STOPPED_BEFORE_START_TEXT + stoppedBeforeHandOverPosition, + withStopRowsAfterStoppedSends } from './native-chat-stopped-before-start' +import { compareNativeChatTranscriptMessages } from './native-chat-transcript-projection' import { structuredAgentTurnAnchors } from './native-chat-turn-membership' import type { NativeChatMessage } from './native-chat-types' import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' @@ -15,34 +16,6 @@ import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-sess import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off' import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' -/** One row after each run of sends a Stop took back, placed with the last of them. */ -function withStopRowsAfterStoppedSends( - messages: readonly NativeChatMessage[] -): NativeChatMessage[] { - return messages.flatMap((message, index) => - message.stoppedBeforeStart && messages[index + 1]?.stoppedBeforeStart !== true - ? [ - message, - { - id: `stopped-before-start:${message.id}`, - role: 'system' as const, - source: 'transcript' as const, - timestamp: message.timestamp, - blocks: [ - { - type: 'text' as const, - text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT, - presentation: NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION - } - ], - stoppedBeforeStart: true as const, - ...(message.journalPosition ? { journalPosition: message.journalPosition } : {}) - } - ] - : [message] - ) -} - export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], @@ -52,12 +25,12 @@ export function projectStructuredAgentSessionMessages( const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions) // A send a Stop took back before the agent started it stays where it was sent, as the // conversation's own history. A queued card's hand-off is left out: the card holds its text. - const stoppedBeforeStart = new Set( + const stoppedBeforeStart = new Map( submissions .filter( (submission) => dispatchWasWithdrawn(submission) && submission.queuedMessageId === undefined ) - .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + .map((submission) => [agentJournalSubmissionKey(submission.clientMessageId), submission]) ) // Other refused sends are ledger evidence, not conversation history; local drafts remain in the outbox. const rejected = new Set( @@ -66,8 +39,8 @@ export function projectStructuredAgentSessionMessages( .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) .filter((itemId) => !stoppedBeforeStart.has(itemId)) ) - // A turn opened for it ends saying it was stopped, so such a send stays in that turn. const anchored = new Set(structuredAgentTurnAnchors(items, submissions).values()) + const itemsById = new Map(items.map((item) => [item.itemId, item])) const visibleItems: AgentJournalRenderItem[] = [] const refused = new Map() for (const item of items) { @@ -87,18 +60,42 @@ export function projectStructuredAgentSessionMessages( ) const delivered: NativeChatMessage[] = [] const held: NativeChatMessage[] = [] + const shownStopped = new Set() + let moved = false for (const message of projectItems(visibleItems)) { if (queued.has(message.id)) { held.push({ ...message, queued: true }) - } else if (stoppedBeforeStart.has(message.id) && !anchored.has(message.id)) { + } else if ( + stoppedBeforeStart.has(message.id) && + (anchored.has(message.id) || itemsById.get(message.id)?.turnScope?.kind === 'turn') + ) { + // A turn opened for it, or it joined one (a steer): that turn's interrupted end is its stop. delivered.push({ ...message, stoppedBeforeStart: true }) + } else if (stoppedBeforeStart.has(message.id)) { + const submission = stoppedBeforeStart.get(message.id) + const item = itemsById.get(message.id) + // Never handed over, so it waited behind the turn it was accepted in. + const waited = + submission?.handoverRecorded === true && submission.handedOverAt === undefined && item + moved ||= Boolean(waited) + shownStopped.add(message.id) + delivered.push({ + ...message, + stoppedBeforeStart: true, + ...(waited ? { journalPosition: stoppedBeforeHandOverPosition(items, item) } : {}) + }) } else { delivered.push(message) } } return [ // After the held sends leave: they are drawn after the conversation, never inside a run. - ...withStopRowsAfterStoppedSends(collapseProviderRetryRuns(delivered)), + ...withStopRowsAfterStoppedSends( + moved + ? Array.from(collapseProviderRetryRuns(delivered)).sort(compareNativeChatTranscriptMessages) + : collapseProviderRetryRuns(delivered), + shownStopped + ), ...held, ...optimistic .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) From 10d34da9205ee0f8f21c6018a9eb091a16c80a85 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:37:49 -0700 Subject: [PATCH 120/182] fix(native-chat): draw a stopped follow-up after the turn that opened while it waited A follow-up sent just before the agent opened its turn, then stopped, drew its "Stopped before the agent started" row above that turn's own "Cancellation requested." whenever the turn was expanded. A send a Stop took back is now drawn after the rest of every turn it waited on: the one it was accepted behind, and any that opened after it and before the Stop took it back. Also corrects the stoppedBeforeStart doc to what sets it. --- .../native-chat-stopped-before-start.ts | 45 +++++++++++------- src/shared/native-chat-types.ts | 4 +- ...ge-projection-stopped-before-start.test.ts | 47 ++++++++++++++++++- ...ctured-agent-session-message-projection.ts | 20 ++++---- 4 files changed, 88 insertions(+), 28 deletions(-) diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts index daf900f455da..921db23ae4f0 100644 --- a/src/shared/native-chat-stopped-before-start.ts +++ b/src/shared/native-chat-stopped-before-start.ts @@ -22,32 +22,43 @@ export function isStoppedBeforeStartBlock(block: NativeChatBlock): boolean { } /** - * Where a send a Stop took back before its hand-over is drawn: where the Stop took it back, after - * the rest of the turn it waited behind, not where it was accepted inside that turn. + * Where a send a Stop took back is drawn: after the rest of every turn it waited on, where the Stop + * took it back. That is the turn it was accepted behind (the newest one before it), and any turn + * that opened after it and before the Stop took it back (`resolvedAt`). Otherwise where it was sent. */ -export function stoppedBeforeHandOverPosition( +export function stoppedSendPosition( items: readonly AgentJournalRenderItem[], - item: AgentJournalRenderItem + item: AgentJournalRenderItem, + resolvedAt: number | null ): AgentJournalPosition { - let waitedBehind: AgentJournalRenderItem | undefined + let acceptedBehind: AgentJournalRenderItem | undefined + const waitedOn = new Set() for (const candidate of items) { - if ( - compareAgentJournalItems(candidate, item) < 0 && - isRootAgentJournalItem(candidate) && - readAgentJournalTurn(candidate.body) && - (!waitedBehind || compareAgentJournalItems(candidate, waitedBehind) > 0) + const turn = isRootAgentJournalItem(candidate) ? readAgentJournalTurn(candidate.body) : null + if (!turn) { + continue + } + if (compareAgentJournalItems(candidate, item) < 0) { + if (!acceptedBehind || compareAgentJournalItems(candidate, acceptedBehind) > 0) { + acceptedBehind = candidate + } + } else if ( + resolvedAt !== null && + turn.startedAt !== undefined && + turn.startedAt <= resolvedAt ) { - waitedBehind = candidate + waitedOn.add(candidate.itemId) } } + if (acceptedBehind) { + waitedOn.add(acceptedBehind.itemId) + } let last = item for (const candidate of items) { - if ( - waitedBehind && - candidate.turnScope?.kind === 'turn' && - candidate.turnScope.turnItemId === waitedBehind.itemId && - compareAgentJournalItems(candidate, last) > 0 - ) { + const ofTurn = + waitedOn.has(candidate.itemId) || + (candidate.turnScope?.kind === 'turn' && waitedOn.has(candidate.turnScope.turnItemId)) + if (ofTurn && compareAgentJournalItems(candidate, last) > 0) { last = candidate } } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 1cb39d568983..3c821766e379 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -217,8 +217,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { unsent?: true /** This client's send, made while the chat read Stopping, that the host has not recorded yet. */ sentWhileStopping?: true - /** A send a Stop took back before the agent started it: drawn where it was sent, but no rail - * tick, as the conversation outline the host serves leaves it out. */ + /** A send a Stop took back (its submission withdrawn): no rail tick, as the conversation + * outline the host serves leaves it out. */ stoppedBeforeStart?: true /** Set only by the structured projection, on rows the journal holds, and ranks * them ahead of time. Terminal-backed messages never carry it, and worker reads strip it. */ diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts index e6e449c7a372..0fb1575274bf 100644 --- a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -18,7 +18,12 @@ import { DISPATCH_REJECTED_WRITE_FAILED } from './structured-agent-session-dispatch-rejection' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' -import { projectNativeChatTranscriptMessages } from './native-chat-transcript-projection' +import { + projectNativeChatTranscript, + projectNativeChatTranscriptMessages +} from './native-chat-transcript-projection' +import { nativeChatRowsInDrawOrder } from './native-chat-turn-grouping' +import { nativeChatTurnMembership } from './native-chat-turn-membership' let sequence = 0 @@ -228,6 +233,46 @@ describe('a send a Stop took back before the agent started it', () => { ]) }) + // A follow-up made before Codex opened the turn, then stopped: the turn opened for the first + // send, and the follow-up waited on it, so that turn's own rows come before the follow-up's. + it("is drawn after a turn that opened while it waited, with that turn's rows above it", () => { + const items = [ + sent('first', 'look around'), + sent('follow-up', 'and check the tests'), + entry('turn-1', { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + outcome: 'cancellation', + userItemId: 'codex:thread-1:turn-1:0', + startedAt: 5 + }), + entry( + 'stop:turn-1', + { kind: 'status', text: 'Cancellation requested.' }, + { kind: 'turn', turnItemId: 'turn-1' } + ) + ] + const submissions = [ + stopped('first', { resolvedAt: 10 }), + stopped('follow-up', { resolvedAt: 10 }) + ] + const journal = { items, submissions } + const { conversation } = projectNativeChatTranscript( + projectStructuredAgentSessionMessages(items, [], submissions), + undefined, + journal + ) + const { drawOrder } = nativeChatTurnMembership(conversation, journal) + + expect(nativeChatRowsInDrawOrder(conversation, drawOrder).map((row) => row.id)).toEqual([ + agentJournalSubmissionKey('first'), + 'stop:turn-1', + agentJournalSubmissionKey('follow-up'), + `stopped-before-start:${agentJournalSubmissionKey('follow-up')}` + ]) + }) + it('leaves a queued card to hold its own text', () => { const items = [sent('card-send', 'from a card')] diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 9c6bf4b532e6..e83fca047660 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -1,10 +1,13 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' -import { agentJournalItemPosition } from './agent-session-journal-position' +import { + agentJournalItemPosition, + compareAgentJournalPositions +} from './agent-session-journal-position' import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' import { - stoppedBeforeHandOverPosition, + stoppedSendPosition, withStopRowsAfterStoppedSends } from './native-chat-stopped-before-start' import { compareNativeChatTranscriptMessages } from './native-chat-transcript-projection' @@ -72,17 +75,18 @@ export function projectStructuredAgentSessionMessages( // A turn opened for it, or it joined one (a steer): that turn's interrupted end is its stop. delivered.push({ ...message, stoppedBeforeStart: true }) } else if (stoppedBeforeStart.has(message.id)) { - const submission = stoppedBeforeStart.get(message.id) const item = itemsById.get(message.id) - // Never handed over, so it waited behind the turn it was accepted in. - const waited = - submission?.handoverRecorded === true && submission.handedOverAt === undefined && item - moved ||= Boolean(waited) + const position = item + ? stoppedSendPosition(items, item, stoppedBeforeStart.get(message.id)?.resolvedAt ?? null) + : undefined + const placed = + item && position && compareAgentJournalPositions(position, agentJournalItemPosition(item)) + moved ||= Boolean(placed) shownStopped.add(message.id) delivered.push({ ...message, stoppedBeforeStart: true, - ...(waited ? { journalPosition: stoppedBeforeHandOverPosition(items, item) } : {}) + ...(placed ? { journalPosition: position } : {}) }) } else { delivered.push(message) From 3b1ca27f41ef55960c74a6b3c2f7274e22215d0d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:56:14 -0700 Subject: [PATCH 121/182] fix(native-chat): place a stopped send by journal order, not by turn start time The turn a stopped send waited on was picked by comparing the turn's start time with when the Stop took the send back. A Codex resume rewrites every finished turn's start time to Codex's whole seconds, so after a restart (or a child respawn) a turn opened for a later send could read as started first, and the stopped send dropped below that whole later exchange. A turn is now waited on when something sent before the stopped send opened it, read from the journal's own order only. Covered end to end through a restart whose resume reports whole-second start times. --- ...-session-codex-stopped-send-resume.test.ts | 250 ++++++++++++++++++ .../native-chat-stopped-before-start.ts | 34 +-- ...ctured-agent-session-message-projection.ts | 7 +- 3 files changed, 263 insertions(+), 28 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-codex-stopped-send-resume.test.ts diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-resume.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-resume.test.ts new file mode 100644 index 000000000000..ea521d31b52f --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-resume.test.ts @@ -0,0 +1,250 @@ +// Where a send a Stop took back is drawn must survive a restart. Codex's resume rewrites every +// finished turn's start time to its own whole seconds, so a turn opened for a later send can read +// as starting before the Stop took the first one back. The order is decided by the journal alone. +// Driven through the shipped host, journal and Codex adapter; only the Codex child is fake. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' +import { settledWithin } from '../codex/codex-structured-dispatch-test-support' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../shared/native-chat-stopped-before-start' +import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection' +import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const CALLER = { callerKey: 'codex-stopped-send-resume-test' } +const MODEL = { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true +} + +let root: string +let host: StructuredAgentSessionHost +let fence: number +let handlers: CodexAppServerConnectionHandlers | undefined +let answers: number +let resumes: number +/** What `thread/resume` reports as the thread's turns, as Codex does: times in whole seconds. */ +let resumeTurns: unknown[] | null +let turns: ReturnType +let operations = 0 + +/** The durable ledger stamps its own clock and refuses an id far from it. */ +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +function stop(): Promise { + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) +} + +function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessageId: string) { + const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) + return submission?.dispatchState === 'rejected' + ? classifyDispatchRejection(submission).category + : submission?.dispatchState +} + +async function submissions(): Promise { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).submissions +} + +/** Each row's text, in the order every client draws the transcript. */ +async function drawn(): Promise { + const snapshot = await host.journalSnapshot(SESSION) + return projectNativeChatTranscriptMessages( + projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions) + ).flatMap((message) => + message.role === 'user' || message.role === 'system' + ? [message.blocks.map((block) => ('text' in block ? block.text : '')).join('')] + : [] + ) +} + +const openConnection: typeof openCodexAppServerConnection = async ( + _launch, + connectionHandlers = {} +) => { + handlers = connectionHandlers + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method, params) => { + if (method === 'thread/resume') { + resumes += 1 + return { thread: { id: THREAD, ...(resumeTurns ? { turns: resumeTurns } : {}) } } + } + if (method === 'thread/start') { + return { thread: { id: THREAD } } + } + if (method === 'model/list') { + return { data: [MODEL], nextCursor: null } + } + if (method === 'turn/start') { + answers += 1 + return turns.routes['turn/start']() + } + if (method === 'turn/steer') { + return turns.routes['turn/steer'](params) + } + if (method === 'turn/interrupt') { + return turns.routes['turn/interrupt'](params) + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } + return connection +} + +async function startHost(): Promise { + host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) +} + +async function attach(expected: number | null): Promise { + const params = hostTestAttachParams(expected, { providerHandle: undefined }) + params.envelope.clientOperationId = operationId() + const attached = await host.attach(CALLER, params) + if (attached.ok) { + fence = attached.value.fence + return + } + const refusal = JSON.stringify(attached.refusal) + const current = /"currentFence":(\d+)/.exec(refusal)?.[1] + if (current === undefined) { + throw new Error(refusal) + } + await attach(Number(current)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-stopped-send-resume-')) + answers = 0 + resumes = 0 + resumeTurns = null + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params) + ) + await startHost() + await attach(null) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe('a send a Stop took back, then a send made while it read Stopping', () => { + it('keeps the stopped send above the later exchange after a restart resumes the thread', async () => { + const first = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + // Codex has answered but not opened the turn, so the Stop waits for it to open. + const stopping = stop() + expect(await settledWithin(stopping, 500)).toBe('held') + const second = await send('sent while stopping') + turns.start() + await settledWithin(stopping, 3_000) + await vi.waitFor(async () => expect(verdictOf(await submissions(), first)).toBe('withdrawn')) + await vi.waitFor(() => expect(answers).toBe(2)) + turns.start() + turns.echo(second) + turns.end('completed') + await vi.waitFor(async () => expect(verdictOf(await submissions(), second)).toBe('accepted')) + const live = await drawn() + expect(live).toEqual([ + 'look around', + NATIVE_CHAT_STOPPED_BEFORE_START_TEXT, + 'sent while stopping' + ]) + + // Restart: the resume reports each finished turn with its start in Codex's whole seconds. + resumeTurns = (await host.journalSnapshot(SESSION)).items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn?.startedAt !== undefined && turn.turnId.startsWith('turn-') + ? [ + { + id: turn.turnId, + status: 'completed', + startedAt: Math.floor(turn.startedAt / 1000), + completedAt: Math.ceil((turn.completedAt ?? turn.startedAt) / 1000) + 1, + items: [] + } + ] + : [] + }) + await stopStructuredAgentSessionRuntime() + await startHost() + await attach(fence) + await vi.waitFor(() => expect(resumes).toBeGreaterThan(0)) + await vi.waitFor(async () => { + const restored = (await host.journalSnapshot(SESSION)).items + .map((item) => readAgentJournalTurn(item.body)?.startedAt) + .find((startedAt) => startedAt !== undefined) + expect((restored ?? 1) % 1000).toBe(0) + }) + + expect(await drawn()).toEqual(live) + }) +}) diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts index 921db23ae4f0..87056aa276c2 100644 --- a/src/shared/native-chat-stopped-before-start.ts +++ b/src/shared/native-chat-stopped-before-start.ts @@ -1,12 +1,10 @@ // A send a Stop took back before the agent started it: where it is drawn, and the one row after it. -import { isRootAgentJournalItem } from './agent-session-journal-producer' import { agentJournalItemPosition, compareAgentJournalItems } from './agent-session-journal-position' import type { AgentJournalPosition, AgentJournalRenderItem } from './agent-session-journal-types' -import { readAgentJournalTurn } from './agent-session-turn-record' import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' /** The row after a send a Stop took back before the agent started it; a client words it by this. */ @@ -23,36 +21,24 @@ export function isStoppedBeforeStartBlock(block: NativeChatBlock): boolean { /** * Where a send a Stop took back is drawn: after the rest of every turn it waited on, where the Stop - * took it back. That is the turn it was accepted behind (the newest one before it), and any turn - * that opened after it and before the Stop took it back (`resolvedAt`). Otherwise where it was sent. + * took it back, else where it was sent. A turn it waited on was opened by something sent before it: + * the turn it was accepted behind, or one that opened for an earlier send while it waited. Read in + * journal order only (`anchors`, each root turn record's opener), never by clock: a resume rewrites + * a turn's start time to the provider's whole seconds. */ export function stoppedSendPosition( items: readonly AgentJournalRenderItem[], item: AgentJournalRenderItem, - resolvedAt: number | null + anchors: ReadonlyMap ): AgentJournalPosition { - let acceptedBehind: AgentJournalRenderItem | undefined + const byId = new Map(items.map((candidate) => [candidate.itemId, candidate])) const waitedOn = new Set() - for (const candidate of items) { - const turn = isRootAgentJournalItem(candidate) ? readAgentJournalTurn(candidate.body) : null - if (!turn) { - continue - } - if (compareAgentJournalItems(candidate, item) < 0) { - if (!acceptedBehind || compareAgentJournalItems(candidate, acceptedBehind) > 0) { - acceptedBehind = candidate - } - } else if ( - resolvedAt !== null && - turn.startedAt !== undefined && - turn.startedAt <= resolvedAt - ) { - waitedOn.add(candidate.itemId) + for (const [turnItemId, anchorId] of anchors) { + const opener = byId.get(anchorId) + if (anchorId !== item.itemId && opener && compareAgentJournalItems(opener, item) < 0) { + waitedOn.add(turnItemId) } } - if (acceptedBehind) { - waitedOn.add(acceptedBehind.itemId) - } let last = item for (const candidate of items) { const ofTurn = diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index e83fca047660..e1519a0863ae 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -42,7 +42,8 @@ export function projectStructuredAgentSessionMessages( .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) .filter((itemId) => !stoppedBeforeStart.has(itemId)) ) - const anchored = new Set(structuredAgentTurnAnchors(items, submissions).values()) + const anchors = structuredAgentTurnAnchors(items, submissions) + const anchored = new Set(anchors.values()) const itemsById = new Map(items.map((item) => [item.itemId, item])) const visibleItems: AgentJournalRenderItem[] = [] const refused = new Map() @@ -76,9 +77,7 @@ export function projectStructuredAgentSessionMessages( delivered.push({ ...message, stoppedBeforeStart: true }) } else if (stoppedBeforeStart.has(message.id)) { const item = itemsById.get(message.id) - const position = item - ? stoppedSendPosition(items, item, stoppedBeforeStart.get(message.id)?.resolvedAt ?? null) - : undefined + const position = item ? stoppedSendPosition(items, item, anchors) : undefined const placed = item && position && compareAgentJournalPositions(position, agentJournalItemPosition(item)) moved ||= Boolean(placed) From 8d27b29b109f4f4c177dccd85976b1f3d90b9db1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:03:14 -0700 Subject: [PATCH 122/182] test(native-chat): pin a stopped send held behind the first send's answer A second send made while Codex still held the first send's turn/start answer, then a Stop: the first turn opens after the second was accepted and streams after the Stop. The stopped second send and its row are drawn after that turn's output, never between the turn's header and its output. Fails on the two earlier placement rules. The restart case moves into the same file. --- ...-session-codex-stopped-send-order.test.ts} | 78 ++++++++++++++++--- 1 file changed, 66 insertions(+), 12 deletions(-) rename src/main/runtime/{structured-agent-session-codex-stopped-send-resume.test.ts => structured-agent-session-codex-stopped-send-order.test.ts} (73%) diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-resume.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts similarity index 73% rename from src/main/runtime/structured-agent-session-codex-stopped-send-resume.test.ts rename to src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts index ea521d31b52f..e2cbed955f75 100644 --- a/src/main/runtime/structured-agent-session-codex-stopped-send-resume.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -1,6 +1,6 @@ -// Where a send a Stop took back is drawn must survive a restart. Codex's resume rewrites every -// finished turn's start time to its own whole seconds, so a turn opened for a later send can read -// as starting before the Stop took the first one back. The order is decided by the journal alone. +// Where a send a Stop took back is drawn: after the turn it waited on, never above that turn's own +// rows, and never below a later exchange, a restart included. Codex's resume rewrites every +// finished turn's start time to its own whole seconds, so the order is decided by the journal alone. // Driven through the shipped host, journal and Codex adapter; only the Codex child is fake. import { mkdtemp, rm } from 'node:fs/promises' @@ -18,7 +18,9 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../shared/native-chat-stopped-before-start' -import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection' +import { projectNativeChatTranscript } from '../../shared/native-chat-transcript-projection' +import { nativeChatRowsInDrawOrder } from '../../shared/native-chat-turn-grouping' +import { nativeChatTurnMembership } from '../../shared/native-chat-turn-membership' import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' import { @@ -53,6 +55,8 @@ let resumes: number /** What `thread/resume` reports as the thread's turns, as Codex does: times in whole seconds. */ let resumeTurns: unknown[] | null let turns: ReturnType +/** Codex takes the interrupt and answers it; the test streams more and sends the end later. */ +let interruptEndHeld: boolean let operations = 0 /** The durable ledger stamps its own clock and refuses an id far from it. */ @@ -96,16 +100,20 @@ async function submissions(): Promise { return (await host.journalSnapshot(SESSION)).submissions } -/** Each row's text, in the order every client draws the transcript. */ +/** Each row's text, in the order every client draws the transcript, turns grouped. */ async function drawn(): Promise { - const snapshot = await host.journalSnapshot(SESSION) - return projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions) - ).flatMap((message) => - message.role === 'user' || message.role === 'system' - ? [message.blocks.map((block) => ('text' in block ? block.text : '')).join('')] - : [] + await host.flushStreamedEvents(SESSION) + const { items, submissions } = await host.journalSnapshot(SESSION) + const journal = { items, submissions } + const { conversation } = projectNativeChatTranscript( + projectStructuredAgentSessionMessages(items, [], submissions), + undefined, + journal ) + return nativeChatRowsInDrawOrder( + conversation, + nativeChatTurnMembership(conversation, journal).drawOrder + ).map((message) => message.blocks.map((block) => ('text' in block ? block.text : '')).join('')) } const openConnection: typeof openCodexAppServerConnection = async ( @@ -134,6 +142,10 @@ const openConnection: typeof openCodexAppServerConnection = async ( if (method === 'turn/steer') { return turns.routes['turn/steer'](params) } + if (method === 'turn/interrupt' && interruptEndHeld) { + turns.takeInterrupt() + return {} + } if (method === 'turn/interrupt') { return turns.routes['turn/interrupt'](params) } @@ -183,6 +195,7 @@ beforeEach(async () => { answers = 0 resumes = 0 resumeTurns = null + interruptEndHeld = false turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) @@ -196,6 +209,47 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) +describe("a send made while Codex held the first send's answer, then a Stop", () => { + it("is drawn after the first send's interrupted turn, which streamed after the Stop", async () => { + const release = turns.holdNextAnswer() + const first = await send('first') + await vi.waitFor(() => expect(answers).toBe(1)) + // Both wait on the session behind the held answer: the second send, then the Stop. + const second = send('second') + const stopping = stop() + interruptEndHeld = true + release() + const secondId = await second + await vi.waitFor(async () => expect(verdictOf(await submissions(), secondId)).toBe('withdrawn')) + turns.start() + turns.echo(first) + // The turn opened after the second send was accepted, and streams after the Stop was pressed. + handlers?.onNotification?.('item/completed', { + threadId: THREAD, + turn: { id: 'turn-1' }, + item: { type: 'agentMessage', id: 'streamed', text: 'streamed after the Stop' } + }) + await settledWithin(stopping, 3_000) + turns.end('interrupted') + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).items + .map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-1')?.state + ).toBe('interrupted') + ) + + // The Stop's own note is the host's to place; the order here is the stopped send's. + const rows = (await drawn()).filter((text) => text !== 'Cancellation requested.') + expect(rows.slice(rows.indexOf('first'))).toEqual([ + 'first', + 'streamed after the Stop', + 'second', + NATIVE_CHAT_STOPPED_BEFORE_START_TEXT + ]) + }) +}) + describe('a send a Stop took back, then a send made while it read Stopping', () => { it('keeps the stopped send above the later exchange after a restart resumes the thread', async () => { const first = await send('look around') From 7a429f3ef6ecafbfc3c653eb05373fbbe8001caa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:06:19 -0700 Subject: [PATCH 123/182] docs(native-chat): say why the pre-echo anchor still compares times --- src/shared/native-chat-turn-membership.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index 5c290ccf8fbb..a26358cd28ca 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -103,6 +103,7 @@ export function structuredAgentTurnAnchors( if (!turn || !isRootAgentJournalItem(item)) { continue } + // Times, not journal order: a client sees when a send was taken back, never that row's place. const stoppedOpener = stoppedSinceLastTurn !== null && turn.startedAt !== undefined && From e5671a8e18cd450bfdd3ee126764a4c9f1b3bd76 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:12:48 -0700 Subject: [PATCH 124/182] docs(native-chat): mark the pre-echo anchor's time compare temporary (STA-9337) --- src/shared/native-chat-turn-membership.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index a26358cd28ca..e60fc84e2ae2 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -104,6 +104,7 @@ export function structuredAgentTurnAnchors( continue } // Times, not journal order: a client sees when a send was taken back, never that row's place. + // Temporary: STA-9337 moves this to journal order. const stoppedOpener = stoppedSinceLastTurn !== null && turn.startedAt !== undefined && From 7b5aeebe43f1753a653b9a46ff89616bed03bfac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:23:51 -0700 Subject: [PATCH 125/182] docs(native-chat): say the placement rule reads journal position, not send time --- src/shared/native-chat-stopped-before-start.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts index 87056aa276c2..1d2c77579872 100644 --- a/src/shared/native-chat-stopped-before-start.ts +++ b/src/shared/native-chat-stopped-before-start.ts @@ -21,10 +21,10 @@ export function isStoppedBeforeStartBlock(block: NativeChatBlock): boolean { /** * Where a send a Stop took back is drawn: after the rest of every turn it waited on, where the Stop - * took it back, else where it was sent. A turn it waited on was opened by something sent before it: - * the turn it was accepted behind, or one that opened for an earlier send while it waited. Read in - * journal order only (`anchors`, each root turn record's opener), never by clock: a resume rewrites - * a turn's start time to the provider's whole seconds. + * took it back, else at its own row. A turn it waited on is one whose opener (`anchors`) the journal + * places before the send's row: the turn it was accepted behind, or one that opened for an earlier + * row while it waited. A handed-over send's row is its handover, not when it was sent. Journal order + * only, never a clock: a resume rewrites a turn's start time to the provider's whole seconds. */ export function stoppedSendPosition( items: readonly AgentJournalRenderItem[], From 006a0511d76e19629d3a80274902f050db6ea8b5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:27:33 -0700 Subject: [PATCH 126/182] fix(native-chat): keep sends a Stop took back in the order they were sent Two sends queued during /compact, the first handed over and the second not, then a Stop that took both back: the handed-over one sat at its handover row and the other at its acceptance, so the second drew above the first. Among sends a Stop took back, a later-sent one is now drawn no earlier than just after an earlier one. Sent order is submittedAt, the host's accept time and the order every client already keeps submissions in, because a client never sees a handed-over send's acceptance position (STA-9337). --- .../native-chat-stopped-before-start.ts | 49 ++++++++++++++- ...ge-projection-stopped-before-start.test.ts | 63 +++++++++++++++++++ ...ctured-agent-session-message-projection.ts | 2 + 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts index 1d2c77579872..4230d0c36951 100644 --- a/src/shared/native-chat-stopped-before-start.ts +++ b/src/shared/native-chat-stopped-before-start.ts @@ -1,10 +1,16 @@ // A send a Stop took back before the agent started it: where it is drawn, and the one row after it. +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { agentJournalItemPosition, - compareAgentJournalItems + compareAgentJournalItems, + compareAgentJournalPositions } from './agent-session-journal-position' -import type { AgentJournalPosition, AgentJournalRenderItem } from './agent-session-journal-types' +import type { + AgentJournalPosition, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' /** The row after a send a Stop took back before the agent started it; a client words it by this. */ @@ -53,6 +59,45 @@ export function stoppedSendPosition( return last === item ? position : { sequence: position.sequence, index: position.index + 0.5 } } +/** + * Sends a Stop took back (`stopped`, by id) keep the order they were sent in: a later one is drawn + * no earlier than just after an earlier one. Sent order is `submittedAt`, the host's accept time, + * ties kept in list order as the client reducer keeps them; a client never sees a handed-over + * send's acceptance position (STA-9337). Returns whether it moved any. + */ +export function keepStoppedSendsInSendOrder( + messages: NativeChatMessage[], + submissions: readonly AgentJournalSubmission[], + stopped: ReadonlySet +): boolean { + const indexById = new Map(messages.map((message, index) => [message.id, index])) + const sent = submissions + .map((submission, order) => ({ + index: indexById.get(agentJournalSubmissionKey(submission.clientMessageId)), + submittedAt: submission.submittedAt, + order + })) + .filter((entry) => entry.index !== undefined && stopped.has(messages[entry.index]!.id)) + .sort((left, right) => left.submittedAt - right.submittedAt || left.order - right.order) + let floor: AgentJournalPosition | undefined + let moved = false + for (const { index } of sent) { + const message = messages[index!]! + const position = message.journalPosition + if (!position) { + continue + } + if (floor && compareAgentJournalPositions(position, floor) <= 0) { + floor = { sequence: floor.sequence, index: floor.index + 1 / 1024 } + messages[index!] = { ...message, journalPosition: floor } + moved = true + } else { + floor = position + } + } + return moved +} + /** One row after each run of sends a Stop took back (`stopped`, by id), placed with the last. */ export function withStopRowsAfterStoppedSends( messages: readonly NativeChatMessage[], diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts index 0fb1575274bf..f0658ddbe345 100644 --- a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -273,6 +273,69 @@ describe('a send a Stop took back before the agent started it', () => { ]) }) + // Two sends queued during /compact: the first was handed over (its row moved to the handover), + // the second never was, and one Stop took both back. + it('keeps two sends it took back in the order they were sent', () => { + const compactTurn = 'compact-turn' + const items = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') + }), + sent('queued-second', 'queued second'), + said('compacted', 'Context compacted', compactTurn), + sent('queued-first', 'queued first') + ] + + expect( + rows(items, [ + submission('compact', { submittedAt: 1 }), + stopped('queued-first', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), + stopped('queued-second', { submittedAt: 3, handoverRecorded: true }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-first'), + user('queued-second'), + stopRow('queued-second') + ]) + }) + + // The first taken back by one Stop, a later send by another: each stays where it was sent. + it('keeps a send a later Stop took back below one an earlier Stop took', () => { + const compactTurn = 'compact-turn' + const items = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') + }), + sent('queued', 'queued during compact'), + said('compacted', 'Context compacted', compactTurn), + sent('later', 'sent after the first Stop') + ] + + expect( + rows(items, [ + submission('compact', { submittedAt: 1 }), + stopped('queued', { submittedAt: 2, handoverRecorded: true }), + stopped('later', { submittedAt: 9, handoverRecorded: true, handedOverAt: 10 }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued'), + user('later'), + stopRow('later') + ]) + }) + it('leaves a queued card to hold its own text', () => { const items = [sent('card-send', 'from a card')] diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index e1519a0863ae..ae7c176898af 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -7,6 +7,7 @@ import { import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' import { + keepStoppedSendsInSendOrder, stoppedSendPosition, withStopRowsAfterStoppedSends } from './native-chat-stopped-before-start' @@ -91,6 +92,7 @@ export function projectStructuredAgentSessionMessages( delivered.push(message) } } + moved = keepStoppedSendsInSendOrder(delivered, submissions, shownStopped) || moved return [ // After the held sends leave: they are drawn after the conversation, never inside a run. ...withStopRowsAfterStoppedSends( From e39e91f0bd5c939751478093e7fc8c1f21b73b30 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:47:42 -0700 Subject: [PATCH 127/182] fix(native-chat): draw a stopped send below everything sent before it A send a Stop took back that was never handed over could still draw above an earlier send's whole exchange when that earlier send was not itself taken back: three queued during /compact, the first ran and finished, the second was left in doubt, and the Stop took back only the third. A stopped send is now drawn no earlier than the latest loaded row of anything sent before it, and the turns it waited on are read from that point, so the earlier exchange stays above it. --- .../native-chat-stopped-before-start.ts | 45 +++++++++++++---- ...ge-projection-stopped-before-start.test.ts | 48 +++++++++++++++++++ ...ctured-agent-session-message-projection.ts | 6 ++- 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts index 4230d0c36951..7a0ac6a06195 100644 --- a/src/shared/native-chat-stopped-before-start.ts +++ b/src/shared/native-chat-stopped-before-start.ts @@ -27,25 +27,27 @@ export function isStoppedBeforeStartBlock(block: NativeChatBlock): boolean { /** * Where a send a Stop took back is drawn: after the rest of every turn it waited on, where the Stop - * took it back, else at its own row. A turn it waited on is one whose opener (`anchors`) the journal - * places before the send's row: the turn it was accepted behind, or one that opened for an earlier - * row while it waited. A handed-over send's row is its handover, not when it was sent. Journal order - * only, never a clock: a resume rewrites a turn's start time to the provider's whole seconds. + * took it back, else at its own row. It is drawn no earlier than `sentBefore`, the latest loaded row + * of anything sent before it (a handed-over send's row is its handover, which can follow a send + * still queued). A turn it waited on is one whose opener (`anchors`) the journal places before + * that point. Journal order only for turns, never a clock: a resume rewrites a turn's start time. */ export function stoppedSendPosition( items: readonly AgentJournalRenderItem[], item: AgentJournalRenderItem, - anchors: ReadonlyMap + anchors: ReadonlyMap, + sentBefore?: AgentJournalRenderItem ): AgentJournalPosition { + const from = sentBefore && compareAgentJournalItems(sentBefore, item) > 0 ? sentBefore : item const byId = new Map(items.map((candidate) => [candidate.itemId, candidate])) const waitedOn = new Set() for (const [turnItemId, anchorId] of anchors) { const opener = byId.get(anchorId) - if (anchorId !== item.itemId && opener && compareAgentJournalItems(opener, item) < 0) { + if (anchorId !== item.itemId && opener && compareAgentJournalItems(opener, from) < 0) { waitedOn.add(turnItemId) } } - let last = item + let last = from for (const candidate of items) { const ofTurn = waitedOn.has(candidate.itemId) || @@ -55,10 +57,37 @@ export function stoppedSendPosition( } } const position = agentJournalItemPosition(last) - // Just after that turn's last row, ahead of whatever the journal wrote next. + // Just after that row, ahead of whatever the journal wrote next. return last === item ? position : { sequence: position.sequence, index: position.index + 0.5 } } +/** For each submission, the latest loaded row of the ones sent before it, in `submittedAt` order + * with ties kept in list order as the client reducer keeps them (see `keepStoppedSendsInSendOrder`). */ +export function latestRowsSentBefore( + submissions: readonly AgentJournalSubmission[], + itemsById: ReadonlyMap +): ReadonlyMap { + const inSendOrder = submissions + .map((submission, order) => ({ submission, order })) + .sort( + (left, right) => + left.submission.submittedAt - right.submission.submittedAt || left.order - right.order + ) + const before = new Map() + let latest: AgentJournalRenderItem | undefined + for (const { submission } of inSendOrder) { + const key = agentJournalSubmissionKey(submission.clientMessageId) + if (latest) { + before.set(key, latest) + } + const row = itemsById.get(key) + if (row && (!latest || compareAgentJournalItems(row, latest) > 0)) { + latest = row + } + } + return before +} + /** * Sends a Stop took back (`stopped`, by id) keep the order they were sent in: a later one is drawn * no earlier than just after an earlier one. Sent order is `submittedAt`, the host's accept time, diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts index f0658ddbe345..2a12afc720ab 100644 --- a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -305,6 +305,54 @@ describe('a send a Stop took back before the agent started it', () => { ]) }) + // Three queued during /compact: the first ran and finished, the second was left in doubt, and the + // Stop took back only the third, which was never handed over. + it('stays below everything sent before it, a finished exchange included', () => { + const compactTurn = 'compact-turn' + const items = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') + }), + sent('queued-b', 'queued B'), + said('compacted', 'Context compacted', compactTurn), + sent('queued-zero', 'queued zero'), + sent('queued-a', 'queued A'), + entry('turn-2', { + kind: 'turn', + turnId: 'turn-2', + state: 'completed', + userItemId: agentJournalSubmissionKey('queued-zero') + }), + said('answer', 'answer to zero', 'turn-2') + ] + + expect( + rows(items, [ + submission('compact', { submittedAt: 1 }), + submission('queued-zero', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), + submission('queued-a', { + submittedAt: 3, + dispatchState: 'unknown', + handoverRecorded: true, + handedOverAt: 6 + }), + stopped('queued-b', { submittedAt: 4, handoverRecorded: true }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-zero'), + user('queued-a'), + { id: 'answer', role: 'assistant' }, + user('queued-b'), + stopRow('queued-b') + ]) + }) + // The first taken back by one Stop, a later send by another: each stays where it was sent. it('keeps a send a later Stop took back below one an earlier Stop took', () => { const compactTurn = 'compact-turn' diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index ae7c176898af..43199e76cff8 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -8,6 +8,7 @@ import { isQueuedAgentJournalSubmission } from './agent-session-queued-submissio import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' import { keepStoppedSendsInSendOrder, + latestRowsSentBefore, stoppedSendPosition, withStopRowsAfterStoppedSends } from './native-chat-stopped-before-start' @@ -66,6 +67,7 @@ export function projectStructuredAgentSessionMessages( const delivered: NativeChatMessage[] = [] const held: NativeChatMessage[] = [] const shownStopped = new Set() + const sentBefore = latestRowsSentBefore(submissions, itemsById) let moved = false for (const message of projectItems(visibleItems)) { if (queued.has(message.id)) { @@ -78,7 +80,9 @@ export function projectStructuredAgentSessionMessages( delivered.push({ ...message, stoppedBeforeStart: true }) } else if (stoppedBeforeStart.has(message.id)) { const item = itemsById.get(message.id) - const position = item ? stoppedSendPosition(items, item, anchors) : undefined + const position = item + ? stoppedSendPosition(items, item, anchors, sentBefore.get(message.id)) + : undefined const placed = item && position && compareAgentJournalPositions(position, agentJournalItemPosition(item)) moved ||= Boolean(placed) From d6a54a9b36016575fdfde400aad86eb1170917b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:01:08 -0700 Subject: [PATCH 128/182] fix(native-chat): count a turn opened by the floor row as one the stopped send waited on When the latest row sent before a stopped send was itself a turn's opener, that turn compared equal to the floor and was not counted, so the stopped send landed inside the earlier send's exchange: two queued during /compact, the first handed over and streaming, then a Stop took back the second. A turn whose opener sits at or before that point now counts, and the send draws after the whole exchange. --- .../native-chat-stopped-before-start.ts | 6 +- ...ge-projection-stopped-before-start.test.ts | 86 +++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts index 7a0ac6a06195..dccca817a1f9 100644 --- a/src/shared/native-chat-stopped-before-start.ts +++ b/src/shared/native-chat-stopped-before-start.ts @@ -29,8 +29,8 @@ export function isStoppedBeforeStartBlock(block: NativeChatBlock): boolean { * Where a send a Stop took back is drawn: after the rest of every turn it waited on, where the Stop * took it back, else at its own row. It is drawn no earlier than `sentBefore`, the latest loaded row * of anything sent before it (a handed-over send's row is its handover, which can follow a send - * still queued). A turn it waited on is one whose opener (`anchors`) the journal places before - * that point. Journal order only for turns, never a clock: a resume rewrites a turn's start time. + * still queued). A turn it waited on is one whose opener (`anchors`) the journal places at or + * before that point. Journal order only for turns, never a clock: a resume rewrites a turn's start time. */ export function stoppedSendPosition( items: readonly AgentJournalRenderItem[], @@ -43,7 +43,7 @@ export function stoppedSendPosition( const waitedOn = new Set() for (const [turnItemId, anchorId] of anchors) { const opener = byId.get(anchorId) - if (anchorId !== item.itemId && opener && compareAgentJournalItems(opener, from) < 0) { + if (anchorId !== item.itemId && opener && compareAgentJournalItems(opener, from) <= 0) { waitedOn.add(turnItemId) } } diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts index 2a12afc720ab..c57262fe630d 100644 --- a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -353,6 +353,92 @@ describe('a send a Stop took back before the agent started it', () => { ]) }) + // Two queued during /compact: the first was handed over and its turn opened and streamed, then + // the Stop took back the second, which was never handed over. + it("stays below the earlier send's whole exchange when that send opened the turn", () => { + const compactTurn = 'compact-turn' + const items = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') + }), + sent('queued-second', 'queued second'), + said('compacted', 'Context compacted', compactTurn), + sent('queued-first', 'queued first'), + entry('turn-2', { + kind: 'turn', + turnId: 'turn-2', + state: 'interrupted', + outcome: 'cancellation', + userItemId: agentJournalSubmissionKey('queued-first') + }), + said('working', 'working on the first', 'turn-2'), + entry( + 'stop:turn-2', + { kind: 'status', text: 'Cancellation requested.' }, + { kind: 'turn', turnItemId: 'turn-2' } + ) + ] + + expect( + rows(items, [ + submission('compact', { submittedAt: 1 }), + submission('queued-first', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), + stopped('queued-second', { submittedAt: 3, handoverRecorded: true }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-first'), + { id: 'working', role: 'assistant' }, + { id: 'stop:turn-2', role: 'system' }, + user('queued-second'), + stopRow('queued-second') + ]) + }) + + // As the finished-exchange case without the send left in doubt: the floor is the opener itself. + it('stays below a finished exchange whose opener was the last thing sent before it', () => { + const compactTurn = 'compact-turn' + const items = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') + }), + sent('queued-b', 'queued B'), + said('compacted', 'Context compacted', compactTurn), + sent('queued-zero', 'queued zero'), + entry('turn-2', { + kind: 'turn', + turnId: 'turn-2', + state: 'completed', + userItemId: agentJournalSubmissionKey('queued-zero') + }), + said('answer', 'answer to zero', 'turn-2') + ] + + expect( + rows(items, [ + submission('compact', { submittedAt: 1 }), + submission('queued-zero', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), + stopped('queued-b', { submittedAt: 4, handoverRecorded: true }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-zero'), + { id: 'answer', role: 'assistant' }, + user('queued-b'), + stopRow('queued-b') + ]) + }) + // The first taken back by one Stop, a later send by another: each stays where it was sent. it('keeps a send a later Stop took back below one an earlier Stop took', () => { const compactTurn = 'compact-turn' From ec35c43657210a32312c9e09b9331c3a2a3aedf7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:28:42 -0700 Subject: [PATCH 129/182] feat(native-chat): publish each submission's journal positions A client never learned where in the journal a send was accepted or taken back, so drawing a stopped send had to guess from its own row and from clocks, and the guesses misfired after restarts. Each projected submission now carries `submittedSequence` (its submission row) and `resolvedSequence` (the dispatch row that resolved it; absent while pending): two optional fields, computed on every fold from rows already stored, never stored themselves. Older clients ignore them. The failure-fact schema moves to its own module to keep the journal schema file within its size limit. --- .../journal-dispatch-reducer.ts | 5 + .../journal-submission-fold.ts | 2 + .../journal-submission-positions.test.ts | 110 ++++++++++++++++++ .../agent-session-failure-fact-schema.ts | 10 ++ src/shared/agent-session-journal-schemas.ts | 15 +-- src/shared/agent-session-journal-types.ts | 13 ++- ...ubmission-positions-downgrade.unit.test.ts | 110 ++++++++++++++++++ 7 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts create mode 100644 src/shared/agent-session-failure-fact-schema.ts create mode 100644 tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts 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..a52ff73288c3 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 @@ -31,6 +31,11 @@ export function applyJournalDispatchRow( delete submission.rejection } submission.resolvedAt = row.state === 'pending' ? null : row.ts + if (row.state === 'pending') { + delete submission.resolvedSequence + } else { + submission.resolvedSequence = row.seq + } if (row.state === 'pending') { submission.handedOverAt = row.ts placeHandedOverMessage(state, submission, row) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 2c9c6cfcd040..04323e36bb2f 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -25,6 +25,7 @@ export function applyJournalSubmission( reason: null, submittedAt: row.ts, resolvedAt: null, + submittedSequence: row.seq, ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}), // A malformed stored link is dropped, never the row. ...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0 @@ -90,6 +91,7 @@ export function acceptSubmissionFromProviderItem( submission.providerItemId = providerItemId submission.reason = null submission.resolvedAt = row.ts + submission.resolvedSequence = row.seq delete submission.recovered state.receipts.set(submission.clientMessageId, { clientMessageId: submission.clientMessageId, diff --git a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts new file mode 100644 index 000000000000..298aeef48d7c --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts @@ -0,0 +1,110 @@ +// Each submission carries where the journal wrote its row and the row that resolved it, so a client +// can place a send by journal order instead of guessing from clocks. Derived on every fold: a +// replay of the same rows gives the same values, and a history page carries them. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalMessageItem, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { readAgentSessionHydrationPage } from '../agent-session-wire/agent-session-history-page' +import { createTrackedJournalOpener } from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} +const BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'look around' }] +} + +let root: string | null = null +const journals = createTrackedJournalOpener() + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +/** A send accepted for later handover, handed over, then taken back. */ +async function sendHandedOverThenWithdrawn() { + root = await mkdtemp(join(tmpdir(), 'orca-submission-positions-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const submitted = await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'send-1', + body: BODY, + fence: 1, + handoverRecorded: true + }) + const handedOver = await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'pending', + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const pending = { ...journal.submission('send-1') } + const withdrawn = await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), + fence: 1, + recovered: true + }) + return { journal, submitted, handedOver, pending, withdrawn } +} + +describe("a submission's journal positions", () => { + it('are its own row and the row that resolved it, with none while it is pending', async () => { + const { journal, submitted, handedOver, pending, withdrawn } = + await sendHandedOverThenWithdrawn() + + expect(pending).toMatchObject({ submittedSequence: submitted.sequence }) + expect(pending).not.toHaveProperty('resolvedSequence') + expect(handedOver.sequence).toBeGreaterThan(submitted.sequence) + expect(journal.submission('send-1')).toMatchObject({ + dispatchState: 'rejected', + submittedSequence: submitted.sequence, + resolvedSequence: withdrawn.sequence + }) + }) + + it('come back the same from a replay of the stored rows', async () => { + const { journal, submitted, withdrawn } = await sendHandedOverThenWithdrawn() + await journals.closeAll() + + const replayed = await journals.open({ identity: IDENTITY, stateDirectory: root! }) + + expect(replayed.submission('send-1')).toMatchObject({ + submittedSequence: submitted.sequence, + resolvedSequence: withdrawn.sequence + }) + expect(journal).not.toBe(replayed) + }) + + it('reach a client on the history page', async () => { + const { journal, submitted, withdrawn } = await sendHandedOverThenWithdrawn() + + expect(readAgentSessionHydrationPage(journal).submissions).toEqual([ + expect.objectContaining({ + clientMessageId: 'send-1', + submittedSequence: submitted.sequence, + resolvedSequence: withdrawn.sequence + }) + ]) + }) +}) diff --git a/src/shared/agent-session-failure-fact-schema.ts b/src/shared/agent-session-failure-fact-schema.ts new file mode 100644 index 000000000000..c8e574490660 --- /dev/null +++ b/src/shared/agent-session-failure-fact-schema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +/** A failure fact as a journal row carries it. Open like a row's `state`: a kind, audience or + * refusal detail a newer host writes must not turn the row malformed; the fact reader is where an + * unplaceable one is dropped. */ +export const AgentSessionFailureFactSchema = z.object({ + kind: z.string().min(1), + detail: z.object({ text: z.string(), audience: z.string().min(1) }).optional(), + refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional() +}) diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 035bb7d1d01c..cf5e6ba6cf66 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -15,6 +15,7 @@ import { z } from 'zod' import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema' +import { AgentSessionFailureFactSchema } from './agent-session-failure-fact-schema' import type { AgentJournalItemBody, AgentJournalMessageItem, @@ -201,14 +202,6 @@ const ThreadGoalState = z.union([ z.object({ state: z.string() }).refine((value) => !['set', 'cleared'].includes(value.state)) ]) -/** Open like `state`: a kind, audience or refusal detail a newer host writes must not turn the row - * malformed; the fact reader is where an unplaceable one is dropped. */ -const FailureFact = z.object({ - kind: z.string().min(1), - detail: z.object({ text: z.string(), audience: z.string().min(1) }).optional(), - refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional() -}) - export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ MessageBody, z.object({ @@ -262,7 +255,7 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ .optional(), providerFrame: ProviderFrame.optional(), threadGoal: ThreadGoalState.optional(), - failure: FailureFact.optional() + failure: AgentSessionFailureFactSchema.optional() }), z.object({ kind: z.literal('turn'), @@ -325,10 +318,12 @@ export const AgentJournalSubmissionSchema = z.object({ reason: z.string().nullable(), submittedAt: z.number(), resolvedAt: z.number().nullable(), + submittedSequence: z.number().int().optional(), + resolvedSequence: z.number().int().optional(), recovered: z.literal(true).optional(), handoverRecorded: z.literal(true).optional(), handedOverAt: z.number().optional(), - rejection: FailureFact.optional(), + rejection: AgentSessionFailureFactSchema.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 e5f8125a5c0f..20fbf6f58d71 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -411,6 +411,12 @@ export type AgentJournalSubmission = { rejection?: UnreadAgentSessionFailureFact submittedAt: number resolvedAt: number | null + /** Where the journal wrote this submission's row: its sequence, recomputed on every fold and + * never stored. Absent from hosts that predate it. */ + submittedSequence?: number + /** Where the journal wrote the dispatch row that resolved it, in the same order; absent while it + * is pending, and from hosts that predate it. */ + resolvedSequence?: number /** Set when crash reconciliation resolved the dispatch, not the provider. A live * `unknown` is a send still outstanding; a recovered one outlived its writer. */ recovered?: true @@ -419,13 +425,16 @@ export type AgentJournalSubmission = { handoverRecorded?: true /** When the host handed it to the provider (its `dispatch{pending}` row). */ handedOverAt?: number - /** Host-only: the submission row's sequence, which tells which host process accepted it. */ + /** Host-only: the submission row's sequence, which tells which host process accepted it. Set + * only on a send accepted for later handover. The snapshot still carries it, undeclared to + * clients; no released client reads it, and clients read `submittedSequence` instead. */ acceptedSequence?: number /** The queued draft this submission hands off; absent for a direct send. Read this, never * a draft id compared with `clientMessageId`. */ queuedMessageId?: string /** Host-only: who asked for this turn — a person over the client send RPC, or Orca itself. - * A person's turn is what ends a Stop's queue pause. */ + * A person's turn is what ends a Stop's queue pause. The snapshot still carries it; no released + * client reads it. */ origin?: 'client' | 'host' } diff --git a/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts new file mode 100644 index 000000000000..75a4b4fcaaa7 --- /dev/null +++ b/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts @@ -0,0 +1,110 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'vitest' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import type { AgentSessionJournalIdentity } from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionHistoryPage } from '../../../src/shared/agent-session-wire' +import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { readAgentSessionHydrationPage } from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// A released client that predates the published submission positions: it must fold and draw a +// page carrying them exactly as it draws the same page without them. +const BASELINE_REF = 'v1.4.219' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-positions', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +/** A function the pinned release exports, typed as the caller calls it. */ +function releaseExport(module: Record, name: string): T { + const value = module[name] + if (typeof value !== 'function' && (typeof value !== 'object' || value === null)) { + throw new Error(`the pinned release exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a value the pinned release exports; each caller names the shape it uses, and a changed one fails the test. + return value as T +} + +type OldState = { submissions: Record[]; items: unknown[] } + +/** Strips the published positions, as an older host's page would carry its submissions. */ +function withoutPositions(page: AgentSessionHistoryPage): AgentSessionHistoryPage { + return { + ...page, + submissions: page.submissions.map( + ({ submittedSequence: _submitted, resolvedSequence: _resolved, ...rest }) => rest + ) + } +} + +// Loads a real release checkout, cold extraction and transforms included. +test('a released client folds and draws a page whose submissions carry their journal positions', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-submission-positions-downgrade-')) + const journals = createTrackedJournalOpener() + try { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + for (const id of ['taken-back', 'still-queued']) { + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: id, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + fence: 1, + handoverRecorded: true + }) + } + await journal.resolveDispatch({ + clientMessageId: 'taken-back', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), + fence: 1, + recovered: true + }) + const page = readAgentSessionHydrationPage(journal, 1) + // Anti-vacuous: the page this build publishes does carry both positions. + expect(page.submissions.find((entry) => entry.clientMessageId === 'taken-back')).toEqual( + expect.objectContaining({ + submittedSequence: expect.any(Number), + resolvedSequence: expect.any(Number) + }) + ) + + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const reducer = await importReleaseCheckoutModule( + checkout, + 'src/shared/structured-agent-session-reducer.ts' + ) + const projection = await importReleaseCheckoutModule( + checkout, + 'src/shared/structured-agent-session-message-projection.ts' + ) + const reduce = releaseExport<(state: unknown, action: unknown) => OldState>( + reducer, + 'reduceStructuredAgentSession' + ) + const empty = releaseExport(reducer, 'EMPTY_STRUCTURED_AGENT_SESSION') + const project = releaseExport< + (items: unknown[], outbox: unknown[], submissions: unknown[]) => unknown[] + >(projection, 'projectStructuredAgentSessionMessages') + const draw = (from: AgentSessionHistoryPage) => { + const state = reduce(empty, { type: 'history-page', page: from }) + return { + submissions: state.submissions.map( + ({ submittedSequence: _s, resolvedSequence: _r, ...rest }) => rest + ), + messages: project(state.items, [], state.submissions) + } + } + + expect(draw(page)).toEqual(draw(withoutPositions(page))) + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}, 180_000) From 53565311b79a89e107101a5175470d0d843263d3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:32:58 -0700 Subject: [PATCH 130/182] fix(native-chat): place a stopped send by the journal row that took it back Hosts now publish where each submission and its resolving row sit in the journal (cherry-picked from #25073). The projection places a send withdrawn before it started at that row, keeps stopped sends in their published submission order, and falls back to the send's own row on hosts that publish neither. --- .../native-chat-stopped-before-start.ts | 78 ++++------ src/shared/native-chat-turn-membership.ts | 38 +++-- ...ge-projection-stopped-before-start.test.ts | 142 +++++++++++++++++- ...ctured-agent-session-message-projection.ts | 9 +- 4 files changed, 200 insertions(+), 67 deletions(-) diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts index dccca817a1f9..4aa7c57995c1 100644 --- a/src/shared/native-chat-stopped-before-start.ts +++ b/src/shared/native-chat-stopped-before-start.ts @@ -3,7 +3,6 @@ import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { agentJournalItemPosition, - compareAgentJournalItems, compareAgentJournalPositions } from './agent-session-journal-position' import type { @@ -26,24 +25,31 @@ export function isStoppedBeforeStartBlock(block: NativeChatBlock): boolean { } /** - * Where a send a Stop took back is drawn: after the rest of every turn it waited on, where the Stop - * took it back, else at its own row. It is drawn no earlier than `sentBefore`, the latest loaded row - * of anything sent before it (a handed-over send's row is its handover, which can follow a send - * still queued). A turn it waited on is one whose opener (`anchors`) the journal places at or - * before that point. Journal order only for turns, never a clock: a resume rewrites a turn's start time. + * Where a send a Stop took back is drawn: at the journal row that took it back (`resolvedSequence`), + * past the end of every turn whose opener (`anchors`) the journal wrote before that row. A host + * that predates the published position gives its own row instead, and only turns opened before + * that row count. Journal order only, never a clock. */ export function stoppedSendPosition( items: readonly AgentJournalRenderItem[], item: AgentJournalRenderItem, anchors: ReadonlyMap, - sentBefore?: AgentJournalRenderItem + resolvedSequence: number | undefined ): AgentJournalPosition { - const from = sentBefore && compareAgentJournalItems(sentBefore, item) > 0 ? sentBefore : item + const own = agentJournalItemPosition(item) + // No item sits on the row that took the send back, so nothing compares equal to it. + const takenBack = + resolvedSequence !== undefined ? { sequence: resolvedSequence, index: 0 } : undefined + const from = takenBack && compareAgentJournalPositions(takenBack, own) > 0 ? takenBack : own const byId = new Map(items.map((candidate) => [candidate.itemId, candidate])) const waitedOn = new Set() for (const [turnItemId, anchorId] of anchors) { const opener = byId.get(anchorId) - if (anchorId !== item.itemId && opener && compareAgentJournalItems(opener, from) <= 0) { + if ( + anchorId !== item.itemId && + opener && + compareAgentJournalPositions(agentJournalItemPosition(opener), from) < 0 + ) { waitedOn.add(turnItemId) } } @@ -52,47 +58,19 @@ export function stoppedSendPosition( const ofTurn = waitedOn.has(candidate.itemId) || (candidate.turnScope?.kind === 'turn' && waitedOn.has(candidate.turnScope.turnItemId)) - if (ofTurn && compareAgentJournalItems(candidate, last) > 0) { - last = candidate + const position = agentJournalItemPosition(candidate) + if (ofTurn && compareAgentJournalPositions(position, last) > 0) { + last = { sequence: position.sequence, index: position.index + 0.5 } } } - const position = agentJournalItemPosition(last) - // Just after that row, ahead of whatever the journal wrote next. - return last === item ? position : { sequence: position.sequence, index: position.index + 0.5 } -} - -/** For each submission, the latest loaded row of the ones sent before it, in `submittedAt` order - * with ties kept in list order as the client reducer keeps them (see `keepStoppedSendsInSendOrder`). */ -export function latestRowsSentBefore( - submissions: readonly AgentJournalSubmission[], - itemsById: ReadonlyMap -): ReadonlyMap { - const inSendOrder = submissions - .map((submission, order) => ({ submission, order })) - .sort( - (left, right) => - left.submission.submittedAt - right.submission.submittedAt || left.order - right.order - ) - const before = new Map() - let latest: AgentJournalRenderItem | undefined - for (const { submission } of inSendOrder) { - const key = agentJournalSubmissionKey(submission.clientMessageId) - if (latest) { - before.set(key, latest) - } - const row = itemsById.get(key) - if (row && (!latest || compareAgentJournalItems(row, latest) > 0)) { - latest = row - } - } - return before + return last } /** * Sends a Stop took back (`stopped`, by id) keep the order they were sent in: a later one is drawn - * no earlier than just after an earlier one. Sent order is `submittedAt`, the host's accept time, - * ties kept in list order as the client reducer keeps them; a client never sees a handed-over - * send's acceptance position (STA-9337). Returns whether it moved any. + * no earlier than just after an earlier one. Sent order is the published `submittedSequence`; a + * host that predates it gives `submittedAt`, its accept time, with ties kept in list order as the + * client reducer keeps them. Returns whether it moved any. */ export function keepStoppedSendsInSendOrder( messages: NativeChatMessage[], @@ -100,14 +78,20 @@ export function keepStoppedSendsInSendOrder( stopped: ReadonlySet ): boolean { const indexById = new Map(messages.map((message, index) => [message.id, index])) - const sent = submissions + const taken = submissions .map((submission, order) => ({ index: indexById.get(agentJournalSubmissionKey(submission.clientMessageId)), - submittedAt: submission.submittedAt, + submission, order })) .filter((entry) => entry.index !== undefined && stopped.has(messages[entry.index]!.id)) - .sort((left, right) => left.submittedAt - right.submittedAt || left.order - right.order) + const byJournal = taken.every((entry) => entry.submission.submittedSequence !== undefined) + const sent = taken.sort( + (left, right) => + (byJournal + ? left.submission.submittedSequence! - right.submission.submittedSequence! + : left.submission.submittedAt - right.submission.submittedAt) || left.order - right.order + ) let floor: AgentJournalPosition | undefined let moved = false for (const { index } of sent) { diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index e60fc84e2ae2..5048b9549643 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -71,31 +71,31 @@ export function structuredAgentTurnAnchors( .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) // A send a Stop took back after its turn opened but before the provider echoed it: the record - // still names the provider's key, and the send is no longer in flight. It opened the turn that - // started before the Stop took it back. + // still names the provider's key, and the send is no longer in flight. It opened the turn whose + // record the journal wrote before the row that took it back. const stoppedBeforeEcho = new Map( submissions.flatMap((submission) => !submission.providerItemId && submission.queuedMessageId === undefined && submission.resolvedAt !== null && dispatchWasWithdrawn(submission) - ? [[agentJournalSubmissionKey(submission.clientMessageId), submission.resolvedAt] as const] + ? [[agentJournalSubmissionKey(submission.clientMessageId), submission] as const] : [] ) ) const anchors = new Map() let precedingUserItemId: string | null = null let inFlightSinceLastTurn: string | null = null - let stoppedSinceLastTurn: { itemId: string; resolvedAt: number } | null = null + let stoppedSinceLastTurn: { itemId: string; submission: AgentJournalSubmission } | null = null for (const item of items) { if (userItemIds.has(item.itemId)) { precedingUserItemId = item.itemId if (inFlightSinceLastTurn === null && inFlight.has(item.itemId)) { inFlightSinceLastTurn = item.itemId } - const resolvedAt = stoppedBeforeEcho.get(item.itemId) - if (stoppedSinceLastTurn === null && resolvedAt !== undefined) { - stoppedSinceLastTurn = { itemId: item.itemId, resolvedAt } + const stopped = stoppedBeforeEcho.get(item.itemId) + if (stoppedSinceLastTurn === null && stopped !== undefined) { + stoppedSinceLastTurn = { itemId: item.itemId, submission: stopped } } continue } @@ -103,12 +103,9 @@ export function structuredAgentTurnAnchors( if (!turn || !isRootAgentJournalItem(item)) { continue } - // Times, not journal order: a client sees when a send was taken back, never that row's place. - // Temporary: STA-9337 moves this to journal order. const stoppedOpener = stoppedSinceLastTurn !== null && - turn.startedAt !== undefined && - turn.startedAt <= stoppedSinceLastTurn.resolvedAt + openedBeforeTakenBack(item, turn, stoppedSinceLastTurn.submission) ? stoppedSinceLastTurn.itemId : null anchors.set( @@ -128,6 +125,25 @@ export function structuredAgentTurnAnchors( return anchors } +/** Whether a turn record came before the row that took a send back: by journal order where the + * host publishes that row's place. Temporary: a host that predates `resolvedSequence` is read by + * times, which a resume rewrites to whole seconds; dropped once every supported remote host + * publishes it. */ +function openedBeforeTakenBack( + record: AgentJournalRenderItem, + turn: AgentJournalTurnLifecycle, + submission: Pick +): boolean { + if (submission.resolvedSequence !== undefined) { + return record.sequence < submission.resolvedSequence + } + return ( + turn.startedAt !== undefined && + submission.resolvedAt !== null && + turn.startedAt <= submission.resolvedAt + ) +} + function anchorOf( turnItemId: string, turn: AgentJournalTurnLifecycle, diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts index c57262fe630d..341bf6d1303b 100644 --- a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -181,6 +181,45 @@ describe('a send a Stop took back before the agent started it', () => { ]) }) + // Where the host publishes the row that took it back, journal order decides, not start times. + it('stays in a turn whose record came before the row that took it back', () => { + const items = [ + sent('opened', 'look around'), + entry('turn-1', { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + outcome: 'cancellation', + userItemId: 'codex:thread-1:turn-1:0' + }) + ] + + expect(rows(items, [stopped('opened', { resolvedSequence: sequence + 1 })])).toEqual([ + user('opened') + ]) + }) + + // A turn restored after a restart, whose start reads as before the Stop in whole seconds, but + // whose record the journal wrote after the row that took the send back. + it('keeps its row when the turn record came after the row that took it back', () => { + const items = [sent('never-ran', 'look around')] + sequence += 1 + const takenBackAt = sequence + items.push( + entry('restored', { + kind: 'turn', + turnId: 'turn-9', + state: 'completed', + userItemId: 'codex:thread-1:turn-9:0', + startedAt: 5 + }) + ) + + expect( + rows(items, [stopped('never-ran', { resolvedAt: 10, resolvedSequence: takenBackAt })]) + ).toEqual([user('never-ran'), stopRow('never-ran')]) + }) + it('keeps its row when a turn starts only after the Stop took it back', () => { const items = [ sent('never-ran', 'look around'), @@ -305,6 +344,51 @@ describe('a send a Stop took back before the agent started it', () => { ]) }) + // The host's clock stepped back between the two accepts; the journal's order still holds. + it('keeps two sends in journal order where the host publishes it, whatever the clock says', () => { + const compactTurn = 'compact-turn' + const items = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') + }), + sent('queued-second', 'queued second'), + said('compacted', 'Context compacted', compactTurn), + sent('queued-first', 'queued first') + ] + const accepted = items.find( + (item) => item.itemId === agentJournalSubmissionKey('queued-second') + )!.sequence + + expect( + rows(items, [ + submission('compact', { submittedAt: 1 }), + stopped('queued-first', { + submittedAt: 9, + submittedSequence: accepted - 1, + handoverRecorded: true, + handedOverAt: 5, + resolvedSequence: sequence + 2 + }), + stopped('queued-second', { + submittedAt: 3, + submittedSequence: accepted, + handoverRecorded: true, + resolvedSequence: sequence + 1 + }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-first'), + user('queued-second'), + stopRow('queued-second') + ]) + }) + // Three queued during /compact: the first ran and finished, the second was left in doubt, and the // Stop took back only the third, which was never handed over. it('stays below everything sent before it, a finished exchange included', () => { @@ -340,7 +424,11 @@ describe('a send a Stop took back before the agent started it', () => { handoverRecorded: true, handedOverAt: 6 }), - stopped('queued-b', { submittedAt: 4, handoverRecorded: true }) + stopped('queued-b', { + submittedAt: 4, + handoverRecorded: true, + resolvedSequence: sequence + 1 + }) ]) ).toEqual([ user('compact'), @@ -355,9 +443,11 @@ describe('a send a Stop took back before the agent started it', () => { // Two queued during /compact: the first was handed over and its turn opened and streamed, then // the Stop took back the second, which was never handed over. - it("stays below the earlier send's whole exchange when that send opened the turn", () => { + /** Two queued during /compact; the first handed over, its turn opened and streamed, and the Stop + * took back the second (never handed over) while that turn ran. */ + function streamingTurnThenStop() { const compactTurn = 'compact-turn' - const items = [ + const before = [ sent('compact', '/compact'), entry(compactTurn, { kind: 'turn', @@ -375,19 +465,33 @@ describe('a send a Stop took back before the agent started it', () => { outcome: 'cancellation', userItemId: agentJournalSubmissionKey('queued-first') }), - said('working', 'working on the first', 'turn-2'), + said('working', 'working on the first', 'turn-2') + ] + // The row that took the second back: a dispatch row, so no item sits on it. + sequence += 1 + const takenBackAt = sequence + const after = [ entry( 'stop:turn-2', { kind: 'status', text: 'Cancellation requested.' }, { kind: 'turn', turnItemId: 'turn-2' } ) ] + return { items: [...before, ...after], takenBackAt } + } + + it("stays below the earlier send's whole exchange when that send opened the turn", () => { + const { items, takenBackAt } = streamingTurnThenStop() expect( rows(items, [ submission('compact', { submittedAt: 1 }), submission('queued-first', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), - stopped('queued-second', { submittedAt: 3, handoverRecorded: true }) + stopped('queued-second', { + submittedAt: 3, + handoverRecorded: true, + resolvedSequence: takenBackAt + }) ]) ).toEqual([ user('compact'), @@ -400,6 +504,28 @@ describe('a send a Stop took back before the agent started it', () => { ]) }) + // A host that predates the published position: the send is placed from its own row, so only + // the turn it was accepted behind counts. + it('falls back to its own row on a host that publishes no position', () => { + const { items } = streamingTurnThenStop() + + expect( + rows(items, [ + submission('compact', { submittedAt: 1 }), + submission('queued-first', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), + stopped('queued-second', { submittedAt: 3, handoverRecorded: true }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-second'), + stopRow('queued-second'), + user('queued-first'), + { id: 'working', role: 'assistant' }, + { id: 'stop:turn-2', role: 'system' } + ]) + }) + // As the finished-exchange case without the send left in doubt: the floor is the opener itself. it('stays below a finished exchange whose opener was the last thing sent before it', () => { const compactTurn = 'compact-turn' @@ -427,7 +553,11 @@ describe('a send a Stop took back before the agent started it', () => { rows(items, [ submission('compact', { submittedAt: 1 }), submission('queued-zero', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), - stopped('queued-b', { submittedAt: 4, handoverRecorded: true }) + stopped('queued-b', { + submittedAt: 4, + handoverRecorded: true, + resolvedSequence: sequence + 1 + }) ]) ).toEqual([ user('compact'), diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 43199e76cff8..eeb1a094a120 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -8,7 +8,6 @@ import { isQueuedAgentJournalSubmission } from './agent-session-queued-submissio import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' import { keepStoppedSendsInSendOrder, - latestRowsSentBefore, stoppedSendPosition, withStopRowsAfterStoppedSends } from './native-chat-stopped-before-start' @@ -67,7 +66,6 @@ export function projectStructuredAgentSessionMessages( const delivered: NativeChatMessage[] = [] const held: NativeChatMessage[] = [] const shownStopped = new Set() - const sentBefore = latestRowsSentBefore(submissions, itemsById) let moved = false for (const message of projectItems(visibleItems)) { if (queued.has(message.id)) { @@ -81,7 +79,12 @@ export function projectStructuredAgentSessionMessages( } else if (stoppedBeforeStart.has(message.id)) { const item = itemsById.get(message.id) const position = item - ? stoppedSendPosition(items, item, anchors, sentBefore.get(message.id)) + ? stoppedSendPosition( + items, + item, + anchors, + stoppedBeforeStart.get(message.id)?.resolvedSequence + ) : undefined const placed = item && position && compareAgentJournalPositions(position, agentJournalItemPosition(item)) From dee61b581f6530e03658824ddef4355929fabf75 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:46:16 -0700 Subject: [PATCH 131/182] test(native-chat): pin a submission's resolved position on the provider-echo accept path Also say which rows can resolve a submission, and that the submission schema, not the shared type, omits acceptedSequence. (cherry picked from commit 58a1ade5048669b1aadbac65799a6913152a9981) --- .../journal-submission-positions.test.ts | 36 +++++++++++++++++++ src/shared/agent-session-journal-types.ts | 9 ++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts index 298aeef48d7c..c2b6eacc5bbd 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts @@ -13,6 +13,7 @@ import { type AgentJournalMessageItem, type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { readAgentSessionHydrationPage } from '../agent-session-wire/agent-session-history-page' import { createTrackedJournalOpener } from './journal-host-database-test-support' @@ -96,6 +97,41 @@ describe("a submission's journal positions", () => { expect(journal).not.toBe(replayed) }) + it("move to the provider's echo when that is what accepts a send left in doubt", async () => { + root = await mkdtemp(join(tmpdir(), 'orca-submission-positions-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const submitted = await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: IDENTITY.sessionId, + fields: { body: BODY } + }), + body: BODY, + fence: 1 + }) + const doubted = await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'unknown', + reason: 'host_restarted', + fence: 1, + recovered: true + }) + + const echo = await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + BODY, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + expect(echo.cursor.sequence).toBeGreaterThan(doubted.sequence) + expect(journal.submission('send-1')).toMatchObject({ + dispatchState: 'accepted', + submittedSequence: submitted.sequence, + resolvedSequence: echo.cursor.sequence + }) + }) + it('reach a client on the history page', async () => { const { journal, submitted, withdrawn } = await sendHandedOverThenWithdrawn() diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 20fbf6f58d71..258636297900 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -414,8 +414,9 @@ export type AgentJournalSubmission = { /** Where the journal wrote this submission's row: its sequence, recomputed on every fold and * never stored. Absent from hosts that predate it. */ submittedSequence?: number - /** Where the journal wrote the dispatch row that resolved it, in the same order; absent while it - * is pending, and from hosts that predate it. */ + /** Where the journal wrote the row that resolved it, in the same order: its dispatch row, or the + * provider's echo (an item or lifecycle-batch row) when that accepted it. Absent while it is + * pending, and from hosts that predate it. */ resolvedSequence?: number /** Set when crash reconciliation resolved the dispatch, not the provider. A live * `unknown` is a send still outstanding; a recovered one outlived its writer. */ @@ -426,8 +427,8 @@ export type AgentJournalSubmission = { /** When the host handed it to the provider (its `dispatch{pending}` row). */ handedOverAt?: number /** Host-only: the submission row's sequence, which tells which host process accepted it. Set - * only on a send accepted for later handover. The snapshot still carries it, undeclared to - * clients; no released client reads it, and clients read `submittedSequence` instead. */ + * only on a send accepted for later handover. The snapshot still carries it, but the submission + * schema omits it; no released client reads it, and clients read `submittedSequence` instead. */ acceptedSequence?: number /** The queued draft this submission hands off; absent for a direct send. Read this, never * a draft id compared with `clientMessageId`. */ From 757e22c6fe063047d528fc39c717fc94f4b45466 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:29:21 -0700 Subject: [PATCH 132/182] fix(native-chat): keep the previous placement rules for a host that publishes no positions On a host from before the published journal positions, a stopped send is again drawn after the latest row of anything sent before it, as before the switch, so it no longer draws above the earlier exchange. Temporary until a host version floor. Also pins the published row against tied accept times. --- .../native-chat-stopped-before-start.ts | 57 ++++- ...ge-projection-stopped-before-start.test.ts | 234 ++++++++++-------- ...ctured-agent-session-message-projection.ts | 5 +- 3 files changed, 184 insertions(+), 112 deletions(-) diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts index 4aa7c57995c1..064490907315 100644 --- a/src/shared/native-chat-stopped-before-start.ts +++ b/src/shared/native-chat-stopped-before-start.ts @@ -3,6 +3,7 @@ import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { agentJournalItemPosition, + compareAgentJournalItems, compareAgentJournalPositions } from './agent-session-journal-position' import type { @@ -26,21 +27,27 @@ export function isStoppedBeforeStartBlock(block: NativeChatBlock): boolean { /** * Where a send a Stop took back is drawn: at the journal row that took it back (`resolvedSequence`), - * past the end of every turn whose opener (`anchors`) the journal wrote before that row. A host - * that predates the published position gives its own row instead, and only turns opened before - * that row count. Journal order only, never a clock. + * past the end of every turn whose opener (`anchors`) the journal wrote before that row. Journal + * order only, never a clock. + * Temporary, until a host version floor: a host that predates `resolvedSequence` keeps the earlier + * rule, from the later of its own row and `sentBefore` (see `latestRowsSentBefore`). */ export function stoppedSendPosition( items: readonly AgentJournalRenderItem[], item: AgentJournalRenderItem, anchors: ReadonlyMap, - resolvedSequence: number | undefined + resolvedSequence: number | undefined, + sentBefore?: AgentJournalRenderItem ): AgentJournalPosition { const own = agentJournalItemPosition(item) - // No item sits on the row that took the send back, so nothing compares equal to it. - const takenBack = - resolvedSequence !== undefined ? { sequence: resolvedSequence, index: 0 } : undefined - const from = takenBack && compareAgentJournalPositions(takenBack, own) > 0 ? takenBack : own + const floor = sentBefore ? agentJournalItemPosition(sentBefore) : undefined + // Just after the floor row, so a turn that row opened counts as waited on. + const from = + resolvedSequence !== undefined + ? { sequence: resolvedSequence, index: 0 } + : floor && compareAgentJournalPositions(floor, own) > 0 + ? { sequence: floor.sequence, index: floor.index + 0.5 } + : own const byId = new Map(items.map((candidate) => [candidate.itemId, candidate])) const waitedOn = new Set() for (const [turnItemId, anchorId] of anchors) { @@ -66,11 +73,39 @@ export function stoppedSendPosition( return last } +/** For each submission, the latest loaded row of the ones sent before it, in `submittedAt` order + * with ties kept in list order as the client reducer keeps them. Temporary: read only for a host + * that predates `resolvedSequence`. */ +export function latestRowsSentBefore( + submissions: readonly AgentJournalSubmission[], + itemsById: ReadonlyMap +): ReadonlyMap { + const inSendOrder = submissions + .map((submission, order) => ({ submission, order })) + .sort( + (left, right) => + left.submission.submittedAt - right.submission.submittedAt || left.order - right.order + ) + const before = new Map() + let latest: AgentJournalRenderItem | undefined + for (const { submission } of inSendOrder) { + const key = agentJournalSubmissionKey(submission.clientMessageId) + if (latest) { + before.set(key, latest) + } + const row = itemsById.get(key) + if (row && (!latest || compareAgentJournalItems(row, latest) > 0)) { + latest = row + } + } + return before +} + /** * Sends a Stop took back (`stopped`, by id) keep the order they were sent in: a later one is drawn - * no earlier than just after an earlier one. Sent order is the published `submittedSequence`; a - * host that predates it gives `submittedAt`, its accept time, with ties kept in list order as the - * client reducer keeps them. Returns whether it moved any. + * no earlier than just after an earlier one. Sent order is the published `submittedSequence`. + * Temporary, until a host version floor: a host that predates it gives `submittedAt`, its accept + * time, with ties kept in list order as the client reducer keeps them. Returns whether it moved any. */ export function keepStoppedSendsInSendOrder( messages: NativeChatMessage[], diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts index 341bf6d1303b..cb4832ef603e 100644 --- a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -83,6 +83,14 @@ const stopRow = (id: string) => ({ role: 'system' }) +/** A host that publishes journal positions, and one that predates them (the Temporary fallback). */ +const HOSTS = [ + ['a host that publishes positions', true], + ['an older host', false] +] as const +const publishedPosition = (published: boolean, resolvedSequence: number) => + published ? { resolvedSequence } : {} + describe('a send a Stop took back before the agent started it', () => { it('stays where it was sent, with one row after it', () => { const items = [sent('warm-up', 'warm up'), sent('never-ran', 'look around')] @@ -391,58 +399,59 @@ describe('a send a Stop took back before the agent started it', () => { // Three queued during /compact: the first ran and finished, the second was left in doubt, and the // Stop took back only the third, which was never handed over. - it('stays below everything sent before it, a finished exchange included', () => { - const compactTurn = 'compact-turn' - const items = [ - sent('compact', '/compact'), - entry(compactTurn, { - kind: 'turn', - turnId: compactTurn, - state: 'completed', - userItemId: agentJournalSubmissionKey('compact') - }), - sent('queued-b', 'queued B'), - said('compacted', 'Context compacted', compactTurn), - sent('queued-zero', 'queued zero'), - sent('queued-a', 'queued A'), - entry('turn-2', { - kind: 'turn', - turnId: 'turn-2', - state: 'completed', - userItemId: agentJournalSubmissionKey('queued-zero') - }), - said('answer', 'answer to zero', 'turn-2') - ] - - expect( - rows(items, [ - submission('compact', { submittedAt: 1 }), - submission('queued-zero', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), - submission('queued-a', { - submittedAt: 3, - dispatchState: 'unknown', - handoverRecorded: true, - handedOverAt: 6 + it.each(HOSTS)( + 'stays below everything sent before it, a finished exchange included, on %s', + (_host, published) => { + const compactTurn = 'compact-turn' + const items = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') }), - stopped('queued-b', { - submittedAt: 4, - handoverRecorded: true, - resolvedSequence: sequence + 1 - }) + sent('queued-b', 'queued B'), + said('compacted', 'Context compacted', compactTurn), + sent('queued-zero', 'queued zero'), + sent('queued-a', 'queued A'), + entry('turn-2', { + kind: 'turn', + turnId: 'turn-2', + state: 'completed', + userItemId: agentJournalSubmissionKey('queued-zero') + }), + said('answer', 'answer to zero', 'turn-2') + ] + + expect( + rows(items, [ + submission('compact', { submittedAt: 1 }), + submission('queued-zero', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), + submission('queued-a', { + submittedAt: 3, + dispatchState: 'unknown', + handoverRecorded: true, + handedOverAt: 6 + }), + stopped('queued-b', { + submittedAt: 4, + handoverRecorded: true, + ...publishedPosition(published, sequence + 1) + }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-zero'), + user('queued-a'), + { id: 'answer', role: 'assistant' }, + user('queued-b'), + stopRow('queued-b') ]) - ).toEqual([ - user('compact'), - { id: 'compacted', role: 'assistant' }, - user('queued-zero'), - user('queued-a'), - { id: 'answer', role: 'assistant' }, - user('queued-b'), - stopRow('queued-b') - ]) - }) + } + ) - // Two queued during /compact: the first was handed over and its turn opened and streamed, then - // the Stop took back the second, which was never handed over. /** Two queued during /compact; the first handed over, its turn opened and streamed, and the Stop * took back the second (never handed over) while that turn ran. */ function streamingTurnThenStop() { @@ -480,54 +489,83 @@ describe('a send a Stop took back before the agent started it', () => { return { items: [...before, ...after], takenBackAt } } - it("stays below the earlier send's whole exchange when that send opened the turn", () => { - const { items, takenBackAt } = streamingTurnThenStop() - - expect( - rows(items, [ - submission('compact', { submittedAt: 1 }), - submission('queued-first', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), - stopped('queued-second', { - submittedAt: 3, - handoverRecorded: true, - resolvedSequence: takenBackAt - }) + it.each(HOSTS)( + "stays below the earlier send's whole exchange when that send opened the turn, on %s", + (_host, published) => { + const { items, takenBackAt } = streamingTurnThenStop() + + expect( + rows(items, [ + submission('compact', { submittedAt: 1 }), + submission('queued-first', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), + stopped('queued-second', { + submittedAt: 3, + handoverRecorded: true, + ...publishedPosition(published, takenBackAt) + }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-first'), + { id: 'working', role: 'assistant' }, + { id: 'stop:turn-2', role: 'system' }, + user('queued-second'), + stopRow('queued-second') ]) - ).toEqual([ - user('compact'), - { id: 'compacted', role: 'assistant' }, - user('queued-first'), - { id: 'working', role: 'assistant' }, - { id: 'stop:turn-2', role: 'system' }, - user('queued-second'), - stopRow('queued-second') - ]) - }) - - // A host that predates the published position: the send is placed from its own row, so only - // the turn it was accepted behind counts. - it('falls back to its own row on a host that publishes no position', () => { - const { items } = streamingTurnThenStop() + } + ) - expect( - rows(items, [ - submission('compact', { submittedAt: 1 }), - submission('queued-first', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), - stopped('queued-second', { submittedAt: 3, handoverRecorded: true }) + // As the finished-exchange case without the send left in doubt. On an older host the latest row + // sent before it is that exchange's opener itself. + it.each(HOSTS)( + 'stays below a finished exchange whose opener was the last thing sent before it, on %s', + (_host, published) => { + const compactTurn = 'compact-turn' + const items = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') + }), + sent('queued-b', 'queued B'), + said('compacted', 'Context compacted', compactTurn), + sent('queued-zero', 'queued zero'), + entry('turn-2', { + kind: 'turn', + turnId: 'turn-2', + state: 'completed', + userItemId: agentJournalSubmissionKey('queued-zero') + }), + said('answer', 'answer to zero', 'turn-2') + ] + + expect( + rows(items, [ + submission('compact', { submittedAt: 1 }), + submission('queued-zero', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), + stopped('queued-b', { + submittedAt: 4, + handoverRecorded: true, + ...publishedPosition(published, sequence + 1) + }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-zero'), + { id: 'answer', role: 'assistant' }, + user('queued-b'), + stopRow('queued-b') ]) - ).toEqual([ - user('compact'), - { id: 'compacted', role: 'assistant' }, - user('queued-second'), - stopRow('queued-second'), - user('queued-first'), - { id: 'working', role: 'assistant' }, - { id: 'stop:turn-2', role: 'system' } - ]) - }) + } + ) - // As the finished-exchange case without the send left in doubt: the floor is the opener itself. - it('stays below a finished exchange whose opener was the last thing sent before it', () => { + // Accept times tie at the clock's resolution, and the client can hold the stopped send first: + // only the published row says which exchange came before the Stop. + it('stays below a finished exchange when accept times tie, by the row the host publishes', () => { const compactTurn = 'compact-turn' const items = [ sent('compact', '/compact'), @@ -551,13 +589,9 @@ describe('a send a Stop took back before the agent started it', () => { expect( rows(items, [ - submission('compact', { submittedAt: 1 }), - submission('queued-zero', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), - stopped('queued-b', { - submittedAt: 4, - handoverRecorded: true, - resolvedSequence: sequence + 1 - }) + stopped('queued-b', { handoverRecorded: true, resolvedSequence: sequence + 1 }), + submission('compact'), + submission('queued-zero', { handoverRecorded: true, handedOverAt: 5 }) ]) ).toEqual([ user('compact'), diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index eeb1a094a120..d97843697a07 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -8,6 +8,7 @@ import { isQueuedAgentJournalSubmission } from './agent-session-queued-submissio import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' import { keepStoppedSendsInSendOrder, + latestRowsSentBefore, stoppedSendPosition, withStopRowsAfterStoppedSends } from './native-chat-stopped-before-start' @@ -66,6 +67,7 @@ export function projectStructuredAgentSessionMessages( const delivered: NativeChatMessage[] = [] const held: NativeChatMessage[] = [] const shownStopped = new Set() + const sentBefore = latestRowsSentBefore(submissions, itemsById) let moved = false for (const message of projectItems(visibleItems)) { if (queued.has(message.id)) { @@ -83,7 +85,8 @@ export function projectStructuredAgentSessionMessages( items, item, anchors, - stoppedBeforeStart.get(message.id)?.resolvedSequence + stoppedBeforeStart.get(message.id)?.resolvedSequence, + sentBefore.get(message.id) ) : undefined const placed = From f271e264115cf74964529e2d5b77eeb4fdd73f11 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:36:02 -0700 Subject: [PATCH 133/182] perf(native-chat): place stopped sends in one pass, and skip it in chats with none The message projection runs on every streaming commit. It now builds turn anchors, the item lookup and the older-host floor only when the chat has a send a Stop took back, and places each such send with a binary search over the turns' furthest rows instead of rescanning every item per send. Draws are unchanged. --- .../native-chat-stopped-before-start.ts | 173 ++++++++++++------ ...ctured-agent-session-message-projection.ts | 60 +++--- 2 files changed, 142 insertions(+), 91 deletions(-) diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts index 064490907315..d959fd09f043 100644 --- a/src/shared/native-chat-stopped-before-start.ts +++ b/src/shared/native-chat-stopped-before-start.ts @@ -11,6 +11,7 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import { structuredAgentTurnAnchors } from './native-chat-turn-membership' import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' /** The row after a send a Stop took back before the agent started it; a client words it by this. */ @@ -25,58 +26,123 @@ export function isStoppedBeforeStartBlock(block: NativeChatBlock): boolean { ) } +/** Where the sends a Stop took back are drawn, worked out once per projection. */ +export type StoppedSendPlacement = { + /** Whether it stays in a turn: one opened for it, or one it was steered into. That turn's + * interrupted end is its stop. */ + staysInTurn(itemId: string): boolean + /** Where it is drawn when that is not its own row. */ + movedTo(itemId: string): AgentJournalPosition | undefined +} + /** - * Where a send a Stop took back is drawn: at the journal row that took it back (`resolvedSequence`), - * past the end of every turn whose opener (`anchors`) the journal wrote before that row. Journal - * order only, never a clock. + * A send a Stop took back (`stopped`, by item id) is drawn at the journal row that took it back + * (`resolvedSequence`), past the end of every turn whose opener the journal wrote before that row. + * Journal order only, never a clock. * Temporary, until a host version floor: a host that predates `resolvedSequence` keeps the earlier - * rule, from the later of its own row and `sentBefore` (see `latestRowsSentBefore`). + * rule, from the later of its own row and just after `latestRowsSentBefore`. + */ +export function placeStoppedSends( + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[], + stopped: ReadonlyMap +): StoppedSendPlacement { + const anchors = structuredAgentTurnAnchors(items, submissions) + const anchored = new Set(anchors.values()) + const itemsById = new Map(items.map((item) => [item.itemId, item])) + const pastTurnsOpenedBefore = turnEndsByOpener(items, anchors, itemsById) + const sentBefore = [...stopped.values()].some( + (submission) => submission.resolvedSequence === undefined + ) + ? latestRowsSentBefore(submissions, itemsById) + : undefined + return { + staysInTurn: (itemId) => + anchored.has(itemId) || itemsById.get(itemId)?.turnScope?.kind === 'turn', + movedTo: (itemId) => { + const item = itemsById.get(itemId) + if (!item) { + return undefined + } + const own = agentJournalItemPosition(item) + const resolvedSequence = stopped.get(itemId)?.resolvedSequence + const sentBeforeRow = sentBefore?.get(itemId) + const floor = sentBeforeRow ? agentJournalItemPosition(sentBeforeRow) : undefined + // Just after the floor row, so a turn that row opened counts as waited on. + const from = + resolvedSequence !== undefined + ? { sequence: resolvedSequence, index: 0 } + : floor && compareAgentJournalPositions(floor, own) > 0 + ? { sequence: floor.sequence, index: floor.index + 0.5 } + : own + const position = pastTurnsOpenedBefore(from) + return compareAgentJournalPositions(position, own) !== 0 ? position : undefined + } + } +} + +/** + * For a point in the journal, just after the furthest row of every turn whose opener comes before + * it, or the point itself when none reaches past it. One pass over the items, then a binary search + * per point. A send placed here is never a turn's opener (`staysInTurn`), so no turn is its own. */ -export function stoppedSendPosition( +function turnEndsByOpener( items: readonly AgentJournalRenderItem[], - item: AgentJournalRenderItem, anchors: ReadonlyMap, - resolvedSequence: number | undefined, - sentBefore?: AgentJournalRenderItem -): AgentJournalPosition { - const own = agentJournalItemPosition(item) - const floor = sentBefore ? agentJournalItemPosition(sentBefore) : undefined - // Just after the floor row, so a turn that row opened counts as waited on. - const from = - resolvedSequence !== undefined - ? { sequence: resolvedSequence, index: 0 } - : floor && compareAgentJournalPositions(floor, own) > 0 - ? { sequence: floor.sequence, index: floor.index + 0.5 } - : own - const byId = new Map(items.map((candidate) => [candidate.itemId, candidate])) - const waitedOn = new Set() - for (const [turnItemId, anchorId] of anchors) { - const opener = byId.get(anchorId) - if ( - anchorId !== item.itemId && - opener && - compareAgentJournalPositions(agentJournalItemPosition(opener), from) < 0 - ) { - waitedOn.add(turnItemId) + itemsById: ReadonlyMap +): (from: AgentJournalPosition) => AgentJournalPosition { + const lastOfTurn = new Map() + const reach = (turnItemId: string, position: AgentJournalPosition): void => { + const last = lastOfTurn.get(turnItemId) + if (!last || compareAgentJournalPositions(position, last) > 0) { + lastOfTurn.set(turnItemId, position) + } + } + for (const item of items) { + const position = agentJournalItemPosition(item) + if (anchors.has(item.itemId)) { + reach(item.itemId, position) + } + if (item.turnScope?.kind === 'turn') { + reach(item.turnScope.turnItemId, position) } } - let last = from - for (const candidate of items) { - const ofTurn = - waitedOn.has(candidate.itemId) || - (candidate.turnScope?.kind === 'turn' && waitedOn.has(candidate.turnScope.turnItemId)) - const position = agentJournalItemPosition(candidate) - if (ofTurn && compareAgentJournalPositions(position, last) > 0) { - last = { sequence: position.sequence, index: position.index + 0.5 } + const turns = [...anchors].flatMap(([turnItemId, anchorId]) => { + const opener = itemsById.get(anchorId) + const last = lastOfTurn.get(turnItemId) + return opener && last ? [{ opener: agentJournalItemPosition(opener), last }] : [] + }) + turns.sort((left, right) => compareAgentJournalPositions(left.opener, right.opener)) + // The furthest row of the turns opened up to each one. + const furthest: AgentJournalPosition[] = [] + for (const turn of turns) { + const previous = furthest.at(-1) + furthest.push( + previous && compareAgentJournalPositions(previous, turn.last) > 0 ? previous : turn.last + ) + } + return (from) => { + let low = 0 + let high = turns.length + while (low < high) { + const middle = (low + high) >> 1 + if (compareAgentJournalPositions(turns[middle]!.opener, from) < 0) { + low = middle + 1 + } else { + high = middle + } } + const last = low > 0 ? furthest[low - 1]! : undefined + return last && compareAgentJournalPositions(last, from) > 0 + ? { sequence: last.sequence, index: last.index + 0.5 } + : from } - return last } /** For each submission, the latest loaded row of the ones sent before it, in `submittedAt` order * with ties kept in list order as the client reducer keeps them. Temporary: read only for a host * that predates `resolvedSequence`. */ -export function latestRowsSentBefore( +function latestRowsSentBefore( submissions: readonly AgentJournalSubmission[], itemsById: ReadonlyMap ): ReadonlyMap { @@ -102,24 +168,27 @@ export function latestRowsSentBefore( } /** - * Sends a Stop took back (`stopped`, by id) keep the order they were sent in: a later one is drawn - * no earlier than just after an earlier one. Sent order is the published `submittedSequence`. + * Sends a Stop took back (`stopped`, by item id, in list order) and drawn on their own (`shown`) + * keep the order they were sent in: a later one is drawn no earlier than just after an earlier + * one. Sent order is the published `submittedSequence`. * Temporary, until a host version floor: a host that predates it gives `submittedAt`, its accept * time, with ties kept in list order as the client reducer keeps them. Returns whether it moved any. */ export function keepStoppedSendsInSendOrder( messages: NativeChatMessage[], - submissions: readonly AgentJournalSubmission[], - stopped: ReadonlySet + stopped: ReadonlyMap, + shown: ReadonlySet ): boolean { - const indexById = new Map(messages.map((message, index) => [message.id, index])) - const taken = submissions - .map((submission, order) => ({ - index: indexById.get(agentJournalSubmissionKey(submission.clientMessageId)), - submission, - order - })) - .filter((entry) => entry.index !== undefined && stopped.has(messages[entry.index]!.id)) + const indexById = new Map() + messages.forEach((message, index) => { + if (shown.has(message.id)) { + indexById.set(message.id, index) + } + }) + const taken = [...stopped].flatMap(([itemId, submission], order) => { + const index = indexById.get(itemId) + return index === undefined ? [] : [{ index, submission, order }] + }) const byJournal = taken.every((entry) => entry.submission.submittedSequence !== undefined) const sent = taken.sort( (left, right) => @@ -130,14 +199,14 @@ export function keepStoppedSendsInSendOrder( let floor: AgentJournalPosition | undefined let moved = false for (const { index } of sent) { - const message = messages[index!]! + const message = messages[index]! const position = message.journalPosition if (!position) { continue } if (floor && compareAgentJournalPositions(position, floor) <= 0) { floor = { sequence: floor.sequence, index: floor.index + 1 / 1024 } - messages[index!] = { ...message, journalPosition: floor } + messages[index] = { ...message, journalPosition: floor } moved = true } else { floor = position diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index d97843697a07..4dfce1f502a9 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -1,19 +1,14 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' -import { - agentJournalItemPosition, - compareAgentJournalPositions -} from './agent-session-journal-position' +import { agentJournalItemPosition } from './agent-session-journal-position' import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' import { keepStoppedSendsInSendOrder, - latestRowsSentBefore, - stoppedSendPosition, + placeStoppedSends, withStopRowsAfterStoppedSends } from './native-chat-stopped-before-start' import { compareNativeChatTranscriptMessages } from './native-chat-transcript-projection' -import { structuredAgentTurnAnchors } from './native-chat-turn-membership' import type { NativeChatMessage } from './native-chat-types' import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' @@ -44,9 +39,9 @@ export function projectStructuredAgentSessionMessages( .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) .filter((itemId) => !stoppedBeforeStart.has(itemId)) ) - const anchors = structuredAgentTurnAnchors(items, submissions) - const anchored = new Set(anchors.values()) - const itemsById = new Map(items.map((item) => [item.itemId, item])) + // Runs on every streaming commit, so a chat with no such send pays nothing for placing one. + const placement = + stoppedBeforeStart.size > 0 ? placeStoppedSends(items, submissions, stoppedBeforeStart) : null const visibleItems: AgentJournalRenderItem[] = [] const refused = new Map() for (const item of items) { @@ -67,50 +62,37 @@ export function projectStructuredAgentSessionMessages( const delivered: NativeChatMessage[] = [] const held: NativeChatMessage[] = [] const shownStopped = new Set() - const sentBefore = latestRowsSentBefore(submissions, itemsById) let moved = false for (const message of projectItems(visibleItems)) { if (queued.has(message.id)) { held.push({ ...message, queued: true }) - } else if ( - stoppedBeforeStart.has(message.id) && - (anchored.has(message.id) || itemsById.get(message.id)?.turnScope?.kind === 'turn') - ) { + } else if (!placement || !stoppedBeforeStart.has(message.id)) { + delivered.push(message) + } else if (placement.staysInTurn(message.id)) { // A turn opened for it, or it joined one (a steer): that turn's interrupted end is its stop. delivered.push({ ...message, stoppedBeforeStart: true }) - } else if (stoppedBeforeStart.has(message.id)) { - const item = itemsById.get(message.id) - const position = item - ? stoppedSendPosition( - items, - item, - anchors, - stoppedBeforeStart.get(message.id)?.resolvedSequence, - sentBefore.get(message.id) - ) - : undefined - const placed = - item && position && compareAgentJournalPositions(position, agentJournalItemPosition(item)) - moved ||= Boolean(placed) + } else { + const position = placement.movedTo(message.id) + moved ||= position !== undefined shownStopped.add(message.id) delivered.push({ ...message, stoppedBeforeStart: true, - ...(placed ? { journalPosition: position } : {}) + ...(position ? { journalPosition: position } : {}) }) - } else { - delivered.push(message) } } - moved = keepStoppedSendsInSendOrder(delivered, submissions, shownStopped) || moved + if (shownStopped.size > 0) { + moved = keepStoppedSendsInSendOrder(delivered, stoppedBeforeStart, shownStopped) || moved + } + const conversation = moved + ? Array.from(collapseProviderRetryRuns(delivered)).sort(compareNativeChatTranscriptMessages) + : collapseProviderRetryRuns(delivered) return [ // After the held sends leave: they are drawn after the conversation, never inside a run. - ...withStopRowsAfterStoppedSends( - moved - ? Array.from(collapseProviderRetryRuns(delivered)).sort(compareNativeChatTranscriptMessages) - : collapseProviderRetryRuns(delivered), - shownStopped - ), + ...(shownStopped.size > 0 + ? withStopRowsAfterStoppedSends(conversation, shownStopped) + : conversation), ...held, ...optimistic .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) From b47e69656097f63e5e3ed987c9c76ecd474bacfd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:51:30 -0700 Subject: [PATCH 134/182] fix(native-chat): count a send whose answer was lost when a Stop takes it back The no-row rule counted only pending sends, but the child's end also takes back a send this process left in doubt when Codex's turn/start answer was lost. That case still wrote "Cancellation requested." under the previous turn. Both now read one predicate, so they cannot drift apart. --- .../structured-agent-session-turns-cancel.ts | 6 ++--- ...-agent-session-unopened-send-withdrawal.ts | 19 +++++++++++--- ...-session-codex-turn-end-settlement.test.ts | 26 +++++++++++++++++++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index dfd8f4d9aa6f..6337d65f5537 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -6,7 +6,6 @@ import type { AgentJournalStatusItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' @@ -28,6 +27,7 @@ import { } from './structured-agent-session-turn-stop-notes' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { runningTurnLifecycleRevisions } from './structured-agent-session-stale-turn-verdict' +import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-withdrawal' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' /** Whether the fold reads working. Every write has landed by its call's return, and the open paid @@ -239,9 +239,7 @@ async function cancelAndNote( input.endSession !== undefined && ctx.adapter.stopEndsSession?.(ctx.sessionId) === true const stoppedAt = Date.now() // Read before the cancel: the sends a child end may take back. - const sentBeforeStop = ctx.journal - .submissions() - .filter((entry) => !isQueuedAgentJournalSubmission(entry) && entry.dispatchState === 'pending') + const sentBeforeStop = ctx.journal.submissions().filter(sendStopCanTakeBack) // The provider's own answer; unset when its cancel threw, leaving the effect unknown. let taken: boolean | undefined // The provider could not interrupt the turn, or its cancel threw: the turn may run on. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts index 25fcbbfebc47..64a5296c0086 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts @@ -27,6 +27,21 @@ export type UnopenedSendJournal = { resolveDispatch?: AgentSessionJournal['resolveDispatch'] } +/** A send a dying child can take back: one still being handed over, or one this process left in + * doubt (its answer was lost). Never a queued card's, nor a send an earlier process left behind. */ +export function sendStopCanTakeBack( + entry: Pick< + AgentJournalSubmission, + 'dispatchState' | 'recovered' | 'handoverRecorded' | 'handedOverAt' + > +): boolean { + return ( + !isQueuedAgentJournalSubmission(entry) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true)) + ) +} + /** * Withdraws the sends a Codex child left unanswered when a person's Stop, in force since they were * sent, ends it: each one that started its own turn (handed over with no turn running, so its @@ -58,9 +73,7 @@ export async function withdrawCodexSendsNoTurnOpenedFor( .submissions() .filter( (entry) => - !isQueuedAgentJournalSubmission(entry) && - (entry.dispatchState === 'pending' || - (entry.dispatchState === 'unknown' && entry.recovered !== true)) && + sendStopCanTakeBack(entry) && entry.acceptedSequence !== undefined && entry.acceptedSequence < stop.sequence && startedOwnTurn(entry.clientMessageId) && diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index b3048bdb7b20..cbd0339b6c14 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -651,6 +651,32 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { }) }) + // Its turn/start answer lost, the send is in doubt rather than pending; the child's end takes it + // back all the same, so it writes no row either. + it('leaves no Stop row under the turn before when it takes back a send whose answer was lost', async () => { + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + turns.end('completed') + const answer = turns.routes['turn/start'] + turns.routes['turn/start'] = () => { + turns.routes['turn/start'] = answer + throw new Error('codex app-server request timed out: turn/start') + } + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(2)) + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') + ) + + expect(await settledWithin(stop(), CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + expect(await statusRows()).toEqual([]) + }) + it('withdraws it too when the child end fails and a later retry lands it', async () => { failingCloses = 1 const sent = await stoppedBeforeItsTurnOpened() From 22d2271710c0930540ad394f5cb8c415cee51c6e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:53:41 -0700 Subject: [PATCH 135/182] test(native-chat): pin which sends a Stop's child end can take back A send an earlier process left in doubt is never withdrawn and never holds the row back, and a queued card's send is never counted. --- ...t-session-stop-note-withdrawn-send.test.ts | 32 +++++- ...t-session-unopened-send-withdrawal.test.ts | 107 ++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts index 33563091259b..a3c8853ef67a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts @@ -35,7 +35,11 @@ afterEach(async () => { } }) -async function stopEndingTheChild(options: { turnRunning: boolean; takesSendBack: boolean }) { +async function stopEndingTheChild(options: { + turnRunning: boolean + takesSendBack: boolean + earlierProcessSendInDoubt?: boolean +}) { root = await mkdtemp(join(tmpdir(), 'orca-stop-note-withdrawn-')) const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) if (options.turnRunning) { @@ -45,6 +49,21 @@ async function stopEndingTheChild(options: { turnRunning: boolean; takesSendBack { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } + if (options.earlierProcessSendInDoubt) { + await journal.appendSubmission({ + clientMessageId: 'send-0', + payloadFingerprint: 'send-0', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'before the crash' }] }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'send-0', + state: 'unknown', + reason: 'host_restarted', + fence: 1, + recovered: true + }) + } await journal.appendSubmission({ clientMessageId: 'send-1', payloadFingerprint: 'send-1', @@ -109,6 +128,17 @@ describe('a Stop that ends the child after its interrupt failed', () => { }) }) + // That send was never this child's to take back, so it does not keep the row. + it("leaves no row when the only other send in doubt was an earlier process's", async () => { + expect( + await stopEndingTheChild({ + turnRunning: false, + takesSendBack: true, + earlierProcessSendInDoubt: true + }) + ).toEqual({ cancelled: true, rows: [] }) + }) + it('says cancellation was requested on the turn it stopped, whatever became of the send', async () => { expect(await stopEndingTheChild({ turnRunning: true, takesSendBack: true })).toEqual({ cancelled: true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts new file mode 100644 index 000000000000..d4a78117a1e0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts @@ -0,0 +1,107 @@ +// Which sends a Stop's child end takes back: the same rule decides the withdrawal and whether the +// Stop writes a row, so a send it must leave alone is left alone by both. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + sendStopCanTakeBack, + withdrawCodexSendsNoTurnOpenedFor, + type UnopenedSendJournal +} from './structured-agent-session-unopened-send-withdrawal' + +type Send = Pick< + AgentJournalSubmission, + | 'clientMessageId' + | 'dispatchState' + | 'recovered' + | 'handoverRecorded' + | 'handedOverAt' + | 'acceptedSequence' +> + +/** A send that started its own turn before a person's Stop at sequence 10, no turn having opened. */ +function sendBeforeStop(overrides: Partial = {}): Send { + return { + clientMessageId: 'send-1', + dispatchState: 'pending', + handoverRecorded: true, + handedOverAt: 5, + acceptedSequence: 4, + ...overrides + } +} + +function journalWith(send: Send) { + const resolveDispatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 11 })) + const journal: UnopenedSendJournal = { + agent: 'codex', + queuedMessages: { + userStopInForce: () => ({ sequence: 10, event: { reason: 'user-stop', at: 1 } }) + }, + snapshot: () => ({ + items: [ + { + itemId: agentJournalSubmissionKey(send.clientMessageId), + revision: 0, + sequence: 5, + observedAt: 5, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'look around' }] }, + turnScope: { kind: 'thread' } + } + ] + }), + submissions: () => [send], + resolveDispatch + } + return { journal, resolveDispatch } +} + +describe('a send a Stop can take back', () => { + it('is one still being handed over, or one this process lost the answer to', () => { + expect(sendStopCanTakeBack(sendBeforeStop())).toBe(true) + expect(sendStopCanTakeBack(sendBeforeStop({ dispatchState: 'unknown' }))).toBe(true) + }) + + // An earlier process's send: its child may have started it before the crash. + it('is never one an earlier process left in doubt', () => { + expect(sendStopCanTakeBack(sendBeforeStop({ dispatchState: 'unknown', recovered: true }))).toBe( + false + ) + }) + + // The card holds its text and returns paused; nothing was handed to the agent. + it("is never a queued card's send that was not handed over", () => { + expect(sendStopCanTakeBack(sendBeforeStop({ handedOverAt: undefined }))).toBe(false) + }) +}) + +describe("a Codex child's end under a person's Stop", () => { + it('withdraws a send whose answer this process lost', async () => { + const { journal, resolveDispatch } = journalWith(sendBeforeStop({ dispatchState: 'unknown' })) + + await withdrawCodexSendsNoTurnOpenedFor(journal, 1) + + expect(resolveDispatch).toHaveBeenCalledWith( + expect.objectContaining({ clientMessageId: 'send-1', state: 'rejected' }) + ) + }) + + it('leaves a send an earlier process left in doubt as it is', async () => { + const { journal, resolveDispatch } = journalWith( + sendBeforeStop({ dispatchState: 'unknown', recovered: true }) + ) + + await withdrawCodexSendsNoTurnOpenedFor(journal, 1) + + expect(resolveDispatch).not.toHaveBeenCalled() + }) + + it("leaves a queued card's send as it is", async () => { + const { journal, resolveDispatch } = journalWith(sendBeforeStop({ handedOverAt: undefined })) + + await withdrawCodexSendsNoTurnOpenedFor(journal, 1) + + expect(resolveDispatch).not.toHaveBeenCalled() + }) +}) From ff02198bfe464b36f9ac6ba67e6187efe949e683 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:28:26 -0700 Subject: [PATCH 136/182] fix(native-chat): read the host's Stopping beside main's startup phase Main now reads only the startup phase from the status feed and no longer publishes which child is starting. The chat reads the host's Stopping from its own hook beside it, and the Stopping bridge test mocks the execution-host lookup main's owner resolution now calls. --- .../native-chat/NativeChatStructuredSession.tsx | 14 +++++--------- .../StructuredAgentSessionStatusBridge.tsx | 11 +++++++++++ ...d-agent-session-status-bridge-stopping.test.tsx | 4 +++- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index ecf305899255..4238c730fb88 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -23,10 +23,7 @@ import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSession import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' -import { - useStructuredAgentSessionHostExecutionPhase, - useStructuredAgentSessionHostStopping -} from './StructuredAgentSessionStatusBridge' +import { useStructuredAgentSessionHostExecution } from './StructuredAgentSessionStatusBridge' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' import { nativeChatStructuredStopControls } from './native-chat-structured-stop-controls' import { useAppStore } from '../../store' @@ -49,8 +46,7 @@ export function NativeChatStructuredSession( ) const { sendThroughRelaunch } = provisionalLaunch // The host's own word on whether the provider child has answered startup yet. - const startupPhase = useStructuredAgentSessionHostExecutionPhase(props.sessionId, props.target) - const hostStopping = useStructuredAgentSessionHostStopping(props.sessionId, props.target) + const hostExecution = useStructuredAgentSessionHostExecution(props.sessionId, props.target) const paneKey = useMemo( () => structuredAgentSessionPaneKey(props.tabId, props.sessionId), [props.sessionId, props.tabId] @@ -61,12 +57,12 @@ export function NativeChatStructuredSession( ...props, composerScopeKey: paneKey, queueFollowUps, - hostStopping, - providerStarting: startupPhase === 'starting', + hostStopping: hostExecution.stopping, + providerStarting: hostExecution.phase === 'starting', transportEnabled: provisionalLaunch.transportEnabled, ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) }) - const stopControls = nativeChatStructuredStopControls(controller, hostStopping) + const stopControls = nativeChatStructuredStopControls(controller, hostExecution.stopping) const launchDraftSignal = useNativeChatLaunchDraftSignal({ terminalTabId: props.tabId, agent: props.agent, diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 45cb3ae49f7c..93972eec9a9b 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -88,6 +88,17 @@ export function useStructuredAgentSessionHostStopping( ) } +/** The host's startup phase and its word on a Stop, each re-rendering the chat only on a change. */ +export function useStructuredAgentSessionHostExecution( + sessionId: string, + target: RuntimeClientTarget +): { phase: ReturnType; stopping: boolean } { + return { + phase: useStructuredAgentSessionHostExecutionPhase(sessionId, target), + stopping: useStructuredAgentSessionHostStopping(sessionId, target) + } +} + /** The host's child records for the row, and the legacy roster readers of `subagents` keep. A host * that publishes views is copied verbatim; only an older host's task list is converted here. */ function childWorkFor(summary: AgentSessionStatusSummary): { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx index e47563767e0e..fc1fc8430c30 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-stopping.test.tsx @@ -36,7 +36,9 @@ vi.mock('@/store', async () => { vi.mock('@/lib/worktree-runtime-owner', () => ({ getRuntimeEnvironmentIdForWorktree: (state: { testRuntimeOwner?: string | null }) => - state.testRuntimeOwner ?? null + state.testRuntimeOwner ?? null, + getExecutionHostIdForWorktree: (state: { testRuntimeOwner?: string | null }) => + state.testRuntimeOwner ? `runtime:${state.testRuntimeOwner}` : 'local' })) vi.mock('@/runtime/structured-agent-session-client', () => ({ From 7dcd907356ae8e0ac6ac9850ebb5ce01151c3894 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:55:36 -0700 Subject: [PATCH 137/182] test(native-chat): phone draws the same from merged frames as from a fresh snapshot A long chat whose last send never opened and was taken back at the child's exit, right after a send made while a Stop was ending the turn before it. From every subscribe point, at every later frame and with frames that carry one to three rows, the phone's list built from merged frames must equal the list built from a fresh snapshot of the same journal. --- ...native-chat-merged-snapshot-parity.test.ts | 353 ++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts new file mode 100644 index 000000000000..3c34ec077263 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts @@ -0,0 +1,353 @@ +// What the phone draws from the frames it merged must equal what it draws from a fresh snapshot of +// the same journal, at every frame and whichever frame it subscribed at. Against the host's own +// fold and readers, then the phone's reducer, projection, fold and turn membership. The journal is +// shaped like a long chat whose last send never opened and was taken back at the child's exit, +// right after a send made while a Stop was ending the turn before it. + +import { describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalCursor, + type AgentJournalItemBody, + type AgentJournalTurnScope +} from '../../../src/shared/agent-session-journal-types' +import { + applyJournalRow, + createJournalReducerState, + renderJournalState, + resolveJournalItemId +} from '../../../src/main/native-chat/agent-session-journal/journal-reducer' +import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' +import type { AgentSessionJournal } from '../../../src/main/native-chat/agent-session-journal/journal-store' +import { + readAgentSessionHistory, + readAgentSessionHydrationPage +} from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page' +import { nativeChatRowsInDrawOrder } from '../../../src/shared/native-chat-turn-grouping' +import { + nativeChatMessagesWaitingBehindLiveTurn, + nativeChatTurnMembership +} from '../../../src/shared/native-chat-turn-membership' +import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' +import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' +import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from '../../../src/shared/structured-agent-session-reducer' +import { selectStructuredAgentTurnBars } from '../../../src/shared/structured-agent-session-turn-timing' +import { + buildMobileNativeChatTransientData, + foldMobileNativeChatMessages +} from './mobile-native-chat-render-data' + +const SESSION = 'codex_session' +const THREAD = 'thread-1' +const EPOCH = 'epoch-1' +const HISTORY_TURNS = 8 + +type RowInput = { + [K in JournalRow['kind']]: Omit< + Extract, + 'v' | 'epoch' | 'seq' | 'fence' | 'ts' + > +}[JournalRow['kind']] + +function journalRows(): JournalRow[] { + const rows: JournalRow[] = [] + let fence = 0 + const add = (row: RowInput): void => { + const seq = rows.length + 1 + const envelope = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch: EPOCH, seq, fence } + const stamped = { ...row, ...envelope, ts: 1_000 * seq } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `row` is one arm of the union less the envelope, and the envelope added here is the same for every arm. + rows.push(stamped as JournalRow) + } + const turnItem = (turnId: string): string => `legacy:codex:${SESSION}:turn-lifecycle%3A${turnId}` + const answerItem = (turnId: string): string => `codex:${THREAD}:${turnId}:1` + const inTurn = (turnId: string): AgentJournalTurnScope => ({ + kind: 'turn', + turnItemId: turnItem(turnId) + }) + const answer = (text: string): AgentJournalItemBody => ({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text }] + }) + const send = (id: string): void => { + add({ + kind: 'submission', + clientMessageId: id, + payloadFingerprint: id, + providerHandle: { kind: 'codex', threadId: THREAD }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + handoverRecorded: true, + origin: 'client' + }) + add({ + kind: 'dispatch', + clientMessageId: id, + state: 'pending', + providerItemId: null, + reason: null, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + const stopEvent = (turnId?: string): void => + add({ + kind: 'tombstone', + itemId: 'orca:stop-event', + revision: 1, + stopEvent: { reason: 'user-stop', at: 1, ...(turnId ? { turnId } : {}) } + }) + // A turn the provider opened for `id`; a Stop ends it when `stopped`. + const turn = (id: string, turnId: string, stopped: boolean): void => { + const started = { turnId, startedAt: 1 } + const record = turnItem(turnId) + add({ + kind: 'item', + itemId: record, + revision: 1, + body: { + kind: 'turn', + state: 'running', + userItemId: `codex:${THREAD}:${turnId}:0`, + ...started + }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + add({ + kind: 'item', + itemId: record, + revision: 2, + body: { kind: 'turn', state: 'running', userItemId: `orca:${id}`, ...started }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + add({ + kind: 'dispatch', + clientMessageId: id, + state: 'accepted', + providerItemId: `codex:${THREAD}:${turnId}:0`, + reason: null + }) + for (const revision of [1, 2, 3]) { + add({ + kind: 'item', + itemId: answerItem(turnId), + revision, + body: answer(`part ${revision}`), + turnScope: inTurn(turnId) + }) + } + if (stopped) { + stopEvent(turnId) + } + add({ + kind: 'lifecycle-batch', + settlementId: `turn-completed:${turnId}`, + mutations: [ + { + kind: 'item', + itemId: answerItem(turnId), + revision: 4, + body: answer('done'), + turnScope: inTurn(turnId) + }, + { + kind: 'item', + itemId: record, + revision: 3, + body: { + kind: 'turn', + state: stopped ? 'interrupted' : 'completed', + userItemId: `orca:${id}`, + completedAt: 2, + ...started + }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + }) + if (stopped) { + add({ + kind: 'item', + itemId: `orca:stop%3A${turnId}`, + revision: 1, + body: { kind: 'status', text: 'Cancellation requested.' }, + turnScope: inTurn(turnId) + }) + } + } + + add({ + kind: 'epoch', + reason: 'session_created', + providerHandle: { kind: 'codex', threadId: THREAD } + }) + fence = 1 + for (let index = 0; index < HISTORY_TURNS; index += 1) { + send(`history-${index}`) + turn(`history-${index}`, `t-history-${index}`, index % 2 === 1) + } + // A Stop ends a turn, and a send made while it does runs once it has. + send('stopped') + turn('stopped', 't-stopped', true) + send('sent-while-stopping') + turn('sent-while-stopping', 't-sent-while-stopping', false) + // A send the provider never opened; a turn-less Stop ends the child, whose exit takes it back. + send('never-opened') + stopEvent() + add({ + kind: 'dispatch', + clientMessageId: 'never-opened', + state: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' }, + recovered: true + }) + // The next send starts a new child. + fence = 2 + send('recovery') + turn('recovery', 't-recovery', false) + return rows +} + +const ROWS = journalRows() +const TAKEN_BACK = ROWS.find((row) => row.kind === 'dispatch' && row.state === 'rejected')!.seq + +/** The host's journal after `upTo`, as its readers read it. */ +function journalAt(upTo: number): AgentSessionJournal { + const state = createJournalReducerState(SESSION, EPOCH) + const applied = ROWS.filter((row) => row.seq <= upTo) + for (const row of applied) { + applyJournalRow(state, row) + } + const snapshot = renderJournalState(state) + const journal = { + isReadOnly: false, + snapshot: () => snapshot, + cursor: () => snapshot.cursor, + canonicalItemId: (itemId: string) => resolveJournalItemId(state, itemId), + readSince: (cursor: AgentJournalCursor, limit?: number) => ({ + ok: true as const, + rows: applied.filter((row) => row.seq > cursor.sequence).slice(0, limit), + cursor: snapshot.cursor + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the history readers call only the members above. + return journal as unknown as AgentSessionJournal +} + +const fenceAt = (upTo: number): number => ROWS[upTo - 1]!.fence + +function freshState(upTo: number): StructuredAgentSessionState { + const fence = fenceAt(upTo) + const page = readAgentSessionHydrationPage(journalAt(upTo), fence) + const event = { type: 'snapshot' as const, sessionId: SESSION, page, fence, hostNow: 0 } + return reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { type: 'event', event }, 0) +} + +/** The batches a subscriber at `state`'s cursor gets once the journal reaches `upTo`. */ +function mergeUpTo(state: StructuredAgentSessionState, upTo: number): StructuredAgentSessionState { + const journal = journalAt(upTo) + let next = state + for (;;) { + const cursor = next.cursor! + const result = readAgentSessionHistory(journal, { + sessionId: SESSION, + direction: 'after', + cursor, + limit: 200 + }) + if (!result.ok) { + throw new Error(`the host reset the stream: ${result.reset}`) + } + const { page } = result + if (page.window.nextCursor.sequence <= cursor.sequence) { + return next + } + const batch = { + cursor: page.window.nextCursor, + items: page.items, + removedItemIds: page.removedItemIds, + submissions: page.submissions + } + const event = { + type: 'batch' as const, + sessionId: SESSION, + batch, + fence: fenceAt(upTo), + hostNow: 0 + } + next = reduceStructuredAgentSession(next, { type: 'event', event }, 0) + if (!page.hasNewer) { + return next + } + } +} + +/** What the phone's list draws: its rows in draw order, each with its turn and its turn's bar. */ +function drawn(state: StructuredAgentSessionState): string[] { + const messages = projectStructuredAgentSessionMessages(state.items, [], state.submissions) + const folded = foldMobileNativeChatMessages(messages) + const { data } = buildMobileNativeChatTransientData({ + messages, + folded, + streaming: null, + pending: [] + }) + const membership = nativeChatTurnMembership(data, state) + const rows = nativeChatRowsInDrawOrder(data, membership.drawOrder) + const turnKeys = nativeChatRowsInDrawOrder(membership.turnKeys, membership.drawOrder) + const waiting = nativeChatMessagesWaitingBehindLiveTurn(rows, state.items) + const turnId = activeStructuredAgentSessionTurnId(state.items) + const { settledTurns } = selectStructuredAgentTurnBars(state.items, state.submissions, turnId) + const working = isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) + return [ + `working ${working} live ${membership.liveTurnKey ?? '-'}`, + ...rows.map((row, index) => { + const turnKey = turnKeys[index] + const bar = turnKey === undefined ? '-' : JSON.stringify(settledTurns.get(turnKey) ?? 'none') + return [ + row.id, + row.role, + row.stoppedBeforeStart ? 'stopped' : '', + waiting.has(row.id) ? 'waiting' : '', + turnKey ?? '-', + bar + ].join(' ') + }) + ] +} + +const FRESH = new Map(ROWS.map((row) => [row.seq, drawn(freshState(row.seq))])) + +describe('the phone, from merged frames and from a fresh snapshot', () => { + it('draws the send the exit took back with its stop row, from the frame that took it back', () => { + expect( + FRESH.get(TAKEN_BACK)! + .slice(-2) + .map((line) => line.split(' ').slice(0, 3).join(' ')) + ).toEqual(['orca:never-opened user stopped', 'stopped-before-start:orca:never-opened system ']) + }) + + // Every subscribe point, every later frame, and frames that carry several rows at once. + it.each([1, 2, 3])('draws the same at every frame, %i row(s) per frame', (rowsPerFrame) => { + const last = ROWS.length + const differing: string[] = [] + for (let start = 2; start < last; start += 1) { + let merged = freshState(start) + for (let upTo = start + rowsPerFrame; upTo <= last; upTo += rowsPerFrame) { + merged = mergeUpTo(merged, upTo) + if (JSON.stringify(drawn(merged)) !== JSON.stringify(FRESH.get(upTo))) { + differing.push(`subscribed at ${start}, frame through ${upTo}`) + } + } + } + expect(differing).toEqual([]) + }) +}) From 60ece396edef663136d184060e8043bdf0090c6e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:27:37 -0700 Subject: [PATCH 138/182] test(native-chat): widen the merged-vs-snapshot guard to content, long chats and the phone hooks The guard compared row ids and turn bars only, never left the phone's first window, put each row's writer fence on every batch, and stopped at the fold. It now: - compares each row's content, so a phone that kept an earlier revision of an answer or a status fails it; - adds a chat longer than the phone first loads, compared from the fresh page's first user row on (a turn whose user row the page cut off is keyed differently there); - sends batches at the subscriber's fence and a fresh snapshot when the fence moves, as the host's attach does; - writes the send made while stopping while the turn still runs, adds the provider's echo of each send, and a status row the provider drops; - adds a hooks test: a phone that sent the never-opened message itself, holding its echo and reading the host's Stopping from the status feed, shows the same list after every frame as a phone that opened the chat fresh then. --- ...e-chat-host-journal-frames.test-fixture.ts | 116 +++++ ...chat-merged-snapshot-parity-hooks.test.tsx | 218 +++++++++ ...native-chat-merged-snapshot-parity.test.ts | 419 +++++------------- ...e-native-chat-stop-journal.test-fixture.ts | 246 ++++++++++ 4 files changed, 696 insertions(+), 303 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-host-journal-frames.test-fixture.ts create mode 100644 mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx create mode 100644 mobile/src/session/mobile-native-chat-stop-journal.test-fixture.ts diff --git a/mobile/src/session/mobile-native-chat-host-journal-frames.test-fixture.ts b/mobile/src/session/mobile-native-chat-host-journal-frames.test-fixture.ts new file mode 100644 index 000000000000..d57fb48f531a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-host-journal-frames.test-fixture.ts @@ -0,0 +1,116 @@ +// What the host's own fold and history readers send a phone subscribed to a journal, at any row. + +import type { + AgentJournalCursor, + AgentJournalSnapshot +} from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import { + applyJournalRow, + createJournalReducerState, + renderJournalState +} from '../../../src/main/native-chat/agent-session-journal/journal-reducer' +import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' +import type { AgentSessionJournal } from '../../../src/main/native-chat/agent-session-journal/journal-store' +import { + readAgentSessionHistory, + readAgentSessionHydrationPage +} from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page' + +export type HostJournalFrames = { + /** The host's fold after `upTo`, read through its own history readers. */ + journalAt: (upTo: number) => AgentSessionJournal + /** The lease fence once the host has written `upTo`. */ + fenceAt: (upTo: number) => number + /** The snapshot a phone subscribing at `upTo` gets. */ + snapshotAt: (upTo: number) => AgentSessionSubscribeEvent + /** What the host sends a subscriber at `cursor` and `fence` once the journal reaches `upTo`: the + * rows since as batches carrying the subscriber's fence, or, once the fence moved (a new child), + * a fresh snapshot, as the attach that moved it sends every subscriber. */ + framesTo: ( + subscriber: { cursor: AgentJournalCursor; fence: number }, + upTo: number + ) => AgentSessionSubscribeEvent[] +} + +export function hostJournalFrames( + sessionId: string, + epoch: string, + rows: readonly JournalRow[] +): HostJournalFrames { + // One fold, read at every row: each row's snapshot and the aliases its readers resolve with. + const state = createJournalReducerState(sessionId, epoch) + const snapshots: AgentJournalSnapshot[] = [] + const aliases: ReadonlyMap[] = [] + const fences: number[] = [] + for (const row of rows) { + applyJournalRow(state, row) + // A copy: the fold revises its submissions in place. + snapshots.push(structuredClone(renderJournalState(state))) + aliases.push(new Map(state.aliases)) + fences.push(Math.max(row.fence, fences.at(-1) ?? 0)) + } + const journalAt = (upTo: number): AgentSessionJournal => { + const snapshot = snapshots[upTo - 1]! + const journal = { + isReadOnly: false, + snapshot: () => snapshot, + cursor: () => snapshot.cursor, + // Without a body the store's own resolution reads only the aliases. + canonicalItemId: (itemId: string) => aliases[upTo - 1]!.get(itemId) ?? itemId, + readSince: (cursor: AgentJournalCursor, limit?: number) => ({ + ok: true as const, + rows: rows.slice(cursor.sequence, upTo).slice(0, limit), + cursor: snapshot.cursor + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the history readers call only the members above. + return journal as unknown as AgentSessionJournal + } + const fenceAt = (upTo: number): number => fences[upTo - 1]! + const snapshotAt = (upTo: number): AgentSessionSubscribeEvent => { + const fenceThen = fenceAt(upTo) + const page = readAgentSessionHydrationPage(journalAt(upTo), fenceThen) + return { type: 'snapshot', sessionId: sessionId, page, fence: fenceThen, hostNow: 0 } + } + const framesTo: HostJournalFrames['framesTo'] = (subscriber, upTo) => { + if (fenceAt(upTo) !== subscriber.fence) { + return [snapshotAt(upTo)] + } + const journal = journalAt(upTo) + const frames: AgentSessionSubscribeEvent[] = [] + let cursor = subscriber.cursor + for (;;) { + const result = readAgentSessionHistory(journal, { + sessionId: sessionId, + direction: 'after', + cursor, + limit: 200 + }) + if (!result.ok) { + throw new Error(`the host reset the stream: ${result.reset}`) + } + const { page } = result + if (page.window.nextCursor.sequence <= cursor.sequence) { + return frames + } + frames.push({ + type: 'batch', + sessionId: sessionId, + batch: { + cursor: page.window.nextCursor, + items: page.items, + removedItemIds: page.removedItemIds, + submissions: page.submissions + }, + fence: subscriber.fence, + hostNow: 0 + }) + cursor = page.window.nextCursor + if (!page.hasNewer) { + return frames + } + } + } + return { journalAt, fenceAt, snapshotAt, framesTo } +} diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx new file mode 100644 index 000000000000..83a109bec978 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -0,0 +1,218 @@ +// The phone's own hooks: a phone that sent the never-opened send itself, holding its echo, and +// reading the host's Stopping from the status feed, must show the same list after every frame as a +// phone that opened the chat fresh at that frame. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionStatusEvent, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { + buildMobileNativeChatTransientData, + foldMobileNativeChatMessages +} from './mobile-native-chat-render-data' +import { + NEVER_OPENED, + STOP_JOURNAL_SESSION, + stopJournal +} from './mobile-native-chat-stop-journal.test-fixture' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' +import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() } +})) + +const HOST_SUPPORT = { + promptCancel: true, + questionAnswers: true, + queuedMessages: true, + statusFeed: true, + quietRepeatedStop: true +} + +type Phone = { + renderer: ReactTestRenderer + transcript: (event: AgentSessionSubscribeEvent) => void + status: (stopping: boolean) => void + /** The phone's own send of the never-opened message, answered before its row streams in. */ + send: () => void + read: () => string +} + +const mounted: ReactTestRenderer[] = [] +afterEach(() => { + for (const renderer of mounted.splice(0)) { + act(() => renderer.unmount()) + } +}) + +async function mountPhone(): Promise { + const listeners = new Map void>() + const client: RpcClient = { + sendRequest: async () => ({ + id: 'request', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime' } + }), + subscribe: (method, _params, onData) => { + listeners.set(method, onData) + return () => {} + }, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + let latest: { + drafts: ReturnType + shown: string + } | null = null + function Harness(): null { + const session = useMobileStructuredAgentSession({ + client, + sessionId: STOP_JOURNAL_SESSION, + sourceIdentity: 'host\0workspace', + enabled: true, + connected: true, + agent: 'codex', + hostSupport: HOST_SUPPORT, + onSendError: () => {} + }) + const messages = session.session.messages + const drafts = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'workspace', + tabId: 'tab', + sessionId: STOP_JOURNAL_SESSION, + messages, + transcriptSettled: session.session.status === 'ready' + }) + const { data } = buildMobileNativeChatTransientData({ + messages, + folded: foldMobileNativeChatMessages(messages), + streaming: null, + pending: drafts.pending, + imagePreviewsByMessageId: drafts.imagePreviewsByMessageId + }) + const turns = useMobileNativeChatTurnDisclosure({ + messages: data, + enabled: true, + isWorking: session.isWorking, + workingStartedAt: session.workingStartedAt, + settledTurns: session.settledTurns, + turnJournal: session.turnJournal, + thinking: session.turnIndicator.thinking, + activityText: session.turnIndicator.activityText, + stopping: session.turnIndicator.stopping, + scopeKey: 'host\0workspace\0tab' + }) + const active = + turns.active === null ? 'none' : turns.active.workedSeconds === null ? 'live' : 'worked' + latest = { + drafts, + shown: JSON.stringify({ + working: session.isWorking, + stopping: session.turnIndicator.stopping, + data: data.map((row) => [row.id, row.role, row.stoppedBeforeStart === true, row.blocks]), + list: turns.listMessages.map((row, index) => { + const status = turns.resolveRow(index, row).turnStatus + return [ + row.id, + status === null ? '-' : status.workedSeconds === null ? 'live' : status.workedSeconds + ] + }), + waiting: turns.waitingRows.map((row) => row.item.id), + active + }) + } + return null + } + let renderer: ReactTestRenderer | null = null + await act(async () => { + renderer = create(createElement(Harness)) + }) + mounted.push(renderer!) + await vi.waitFor(() => expect(listeners.get('agentSession.subscribe')).toBeDefined()) + await vi.waitFor(() => expect(listeners.get('agentSession.subscribeStatus')).toBeDefined()) + return { + renderer: renderer!, + transcript: (event) => + act(() => listeners.get('agentSession.subscribe')!(structuredClone(event))), + status: (stopping) => { + const event: AgentSessionStatusEvent = { + type: 'status', + session: { + sessionId: STOP_JOURNAL_SESSION, + workspaceId: 'workspace', + agent: 'codex', + status: stopping ? 'working' : 'idle', + latestPrompt: '', + updatedAt: 0, + ...(stopping ? { stopping: true } : {}) + } + } + act(() => listeners.get('agentSession.subscribeStatus')!(event)) + }, + send: () => { + const origin = latest!.drafts.captureSendOrigin(NEVER_OPENED)! + act(() => latest!.drafts.acceptSend(origin, NEVER_OPENED)) + }, + read: () => latest!.shown + } +} + +describe("the phone's hooks, from merged frames and from a fresh snapshot", () => { + const journal = stopJournal(8) + const last = journal.rows.length + const stoppingAt = (upTo: number): boolean => + journal.stopping.some((window) => upTo >= window.from && upTo <= window.until) + + // Subscribed before the turn a send was made into while stopping, and just before the send. + it.each([journal.neverOpenedSent - 30, journal.neverOpenedSent - 1])( + 'show the same list after every frame, subscribed at row %i', + async (start) => { + const live = await mountPhone() + live.transcript(journal.snapshotAt(start)) + live.status(stoppingAt(start)) + let cursor = { epoch: 'epoch-1', sequence: start } + let fence = journal.fenceAt(start) + const differing: string[] = [] + let stoppingShown = 0 + for (let upTo = start + 1; upTo <= last; upTo += 1) { + if (upTo === journal.neverOpenedSent) { + live.send() + expect(live.read()).toContain('"pending-') + } + for (const event of journal.framesTo({ cursor, fence }, upTo)) { + live.transcript(event) + fence = event.type === 'end' ? fence : (event.fence ?? fence) + } + cursor = { epoch: 'epoch-1', sequence: upTo } + live.status(stoppingAt(upTo)) + stoppingShown += live.read().includes('"stopping":true') ? 1 : 0 + const fresh = await mountPhone() + fresh.transcript(journal.snapshotAt(upTo)) + fresh.status(stoppingAt(upTo)) + if (live.read() !== fresh.read()) { + differing.push(`frame through ${upTo}`) + } + act(() => fresh.renderer.unmount()) + mounted.splice(mounted.indexOf(fresh.renderer), 1) + } + expect(live.read()).toContain(`stopped-before-start:orca:${NEVER_OPENED}`) + // The never-opened send's Stop showed Stopping until the exit took the send back. + expect(stoppingShown).toBeGreaterThan(0) + expect(differing).toEqual([]) + }, + 60_000 + ) +}) diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts index 3c34ec077263..a3389907af7f 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts @@ -1,35 +1,15 @@ -// What the phone draws from the frames it merged must equal what it draws from a fresh snapshot of -// the same journal, at every frame and whichever frame it subscribed at. Against the host's own -// fold and readers, then the phone's reducer, projection, fold and turn membership. The journal is -// shaped like a long chat whose last send never opened and was taken back at the child's exit, -// right after a send made while a Stop was ending the turn before it. +// The phone's list built from the frames it merged must equal the list a fresh snapshot of the same +// journal builds. Covers journal -> host readers -> phone reducer -> projection -> fold -> turn +// membership and bars; the phone's own hooks, its echo and the host's Stopping are covered by +// mobile-native-chat-merged-snapshot-parity-hooks.test.tsx. import { describe, expect, it } from 'vitest' -import { - AGENT_JOURNAL_THREAD_SCOPE, - AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - type AgentJournalCursor, - type AgentJournalItemBody, - type AgentJournalTurnScope -} from '../../../src/shared/agent-session-journal-types' -import { - applyJournalRow, - createJournalReducerState, - renderJournalState, - resolveJournalItemId -} from '../../../src/main/native-chat/agent-session-journal/journal-reducer' -import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' -import type { AgentSessionJournal } from '../../../src/main/native-chat/agent-session-journal/journal-store' -import { - readAgentSessionHistory, - readAgentSessionHydrationPage -} from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import { nativeChatRowsInDrawOrder } from '../../../src/shared/native-chat-turn-grouping' import { nativeChatMessagesWaitingBehindLiveTurn, nativeChatTurnMembership } from '../../../src/shared/native-chat-turn-membership' -import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' @@ -43,255 +23,26 @@ import { buildMobileNativeChatTransientData, foldMobileNativeChatMessages } from './mobile-native-chat-render-data' +import { + NEVER_OPENED, + stopJournal, + type StopJournal +} from './mobile-native-chat-stop-journal.test-fixture' -const SESSION = 'codex_session' -const THREAD = 'thread-1' -const EPOCH = 'epoch-1' -const HISTORY_TURNS = 8 - -type RowInput = { - [K in JournalRow['kind']]: Omit< - Extract, - 'v' | 'epoch' | 'seq' | 'fence' | 'ts' - > -}[JournalRow['kind']] - -function journalRows(): JournalRow[] { - const rows: JournalRow[] = [] - let fence = 0 - const add = (row: RowInput): void => { - const seq = rows.length + 1 - const envelope = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch: EPOCH, seq, fence } - const stamped = { ...row, ...envelope, ts: 1_000 * seq } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `row` is one arm of the union less the envelope, and the envelope added here is the same for every arm. - rows.push(stamped as JournalRow) - } - const turnItem = (turnId: string): string => `legacy:codex:${SESSION}:turn-lifecycle%3A${turnId}` - const answerItem = (turnId: string): string => `codex:${THREAD}:${turnId}:1` - const inTurn = (turnId: string): AgentJournalTurnScope => ({ - kind: 'turn', - turnItemId: turnItem(turnId) - }) - const answer = (text: string): AgentJournalItemBody => ({ - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text }] - }) - const send = (id: string): void => { - add({ - kind: 'submission', - clientMessageId: id, - payloadFingerprint: id, - providerHandle: { kind: 'codex', threadId: THREAD }, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, - handoverRecorded: true, - origin: 'client' - }) - add({ - kind: 'dispatch', - clientMessageId: id, - state: 'pending', - providerItemId: null, - reason: null, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - const stopEvent = (turnId?: string): void => - add({ - kind: 'tombstone', - itemId: 'orca:stop-event', - revision: 1, - stopEvent: { reason: 'user-stop', at: 1, ...(turnId ? { turnId } : {}) } - }) - // A turn the provider opened for `id`; a Stop ends it when `stopped`. - const turn = (id: string, turnId: string, stopped: boolean): void => { - const started = { turnId, startedAt: 1 } - const record = turnItem(turnId) - add({ - kind: 'item', - itemId: record, - revision: 1, - body: { - kind: 'turn', - state: 'running', - userItemId: `codex:${THREAD}:${turnId}:0`, - ...started - }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - add({ - kind: 'item', - itemId: record, - revision: 2, - body: { kind: 'turn', state: 'running', userItemId: `orca:${id}`, ...started }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - add({ - kind: 'dispatch', - clientMessageId: id, - state: 'accepted', - providerItemId: `codex:${THREAD}:${turnId}:0`, - reason: null - }) - for (const revision of [1, 2, 3]) { - add({ - kind: 'item', - itemId: answerItem(turnId), - revision, - body: answer(`part ${revision}`), - turnScope: inTurn(turnId) - }) - } - if (stopped) { - stopEvent(turnId) - } - add({ - kind: 'lifecycle-batch', - settlementId: `turn-completed:${turnId}`, - mutations: [ - { - kind: 'item', - itemId: answerItem(turnId), - revision: 4, - body: answer('done'), - turnScope: inTurn(turnId) - }, - { - kind: 'item', - itemId: record, - revision: 3, - body: { - kind: 'turn', - state: stopped ? 'interrupted' : 'completed', - userItemId: `orca:${id}`, - completedAt: 2, - ...started - }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ] - }) - if (stopped) { - add({ - kind: 'item', - itemId: `orca:stop%3A${turnId}`, - revision: 1, - body: { kind: 'status', text: 'Cancellation requested.' }, - turnScope: inTurn(turnId) - }) - } - } - - add({ - kind: 'epoch', - reason: 'session_created', - providerHandle: { kind: 'codex', threadId: THREAD } - }) - fence = 1 - for (let index = 0; index < HISTORY_TURNS; index += 1) { - send(`history-${index}`) - turn(`history-${index}`, `t-history-${index}`, index % 2 === 1) - } - // A Stop ends a turn, and a send made while it does runs once it has. - send('stopped') - turn('stopped', 't-stopped', true) - send('sent-while-stopping') - turn('sent-while-stopping', 't-sent-while-stopping', false) - // A send the provider never opened; a turn-less Stop ends the child, whose exit takes it back. - send('never-opened') - stopEvent() - add({ - kind: 'dispatch', - clientMessageId: 'never-opened', - state: 'rejected', - providerItemId: null, - reason: DISPATCH_REJECTED_CANCELLED, - rejection: { kind: 'cancelled' }, - recovered: true - }) - // The next send starts a new child. - fence = 2 - send('recovery') - turn('recovery', 't-recovery', false) - return rows -} - -const ROWS = journalRows() -const TAKEN_BACK = ROWS.find((row) => row.kind === 'dispatch' && row.state === 'rejected')!.seq - -/** The host's journal after `upTo`, as its readers read it. */ -function journalAt(upTo: number): AgentSessionJournal { - const state = createJournalReducerState(SESSION, EPOCH) - const applied = ROWS.filter((row) => row.seq <= upTo) - for (const row of applied) { - applyJournalRow(state, row) - } - const snapshot = renderJournalState(state) - const journal = { - isReadOnly: false, - snapshot: () => snapshot, - cursor: () => snapshot.cursor, - canonicalItemId: (itemId: string) => resolveJournalItemId(state, itemId), - readSince: (cursor: AgentJournalCursor, limit?: number) => ({ - ok: true as const, - rows: applied.filter((row) => row.seq > cursor.sequence).slice(0, limit), - cursor: snapshot.cursor - }) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the history readers call only the members above. - return journal as unknown as AgentSessionJournal -} - -const fenceAt = (upTo: number): number => ROWS[upTo - 1]!.fence +type Drawn = { header: string; rows: { id: string; role: string; line: string }[] } -function freshState(upTo: number): StructuredAgentSessionState { - const fence = fenceAt(upTo) - const page = readAgentSessionHydrationPage(journalAt(upTo), fence) - const event = { type: 'snapshot' as const, sessionId: SESSION, page, fence, hostNow: 0 } - return reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { type: 'event', event }, 0) +function apply( + state: StructuredAgentSessionState, + events: readonly AgentSessionSubscribeEvent[] +): StructuredAgentSessionState { + return events.reduce( + (next, event) => reduceStructuredAgentSession(next, { type: 'event', event }, 0), + state + ) } -/** The batches a subscriber at `state`'s cursor gets once the journal reaches `upTo`. */ -function mergeUpTo(state: StructuredAgentSessionState, upTo: number): StructuredAgentSessionState { - const journal = journalAt(upTo) - let next = state - for (;;) { - const cursor = next.cursor! - const result = readAgentSessionHistory(journal, { - sessionId: SESSION, - direction: 'after', - cursor, - limit: 200 - }) - if (!result.ok) { - throw new Error(`the host reset the stream: ${result.reset}`) - } - const { page } = result - if (page.window.nextCursor.sequence <= cursor.sequence) { - return next - } - const batch = { - cursor: page.window.nextCursor, - items: page.items, - removedItemIds: page.removedItemIds, - submissions: page.submissions - } - const event = { - type: 'batch' as const, - sessionId: SESSION, - batch, - fence: fenceAt(upTo), - hostNow: 0 - } - next = reduceStructuredAgentSession(next, { type: 'event', event }, 0) - if (!page.hasNewer) { - return next - } - } -} - -/** What the phone's list draws: its rows in draw order, each with its turn and its turn's bar. */ -function drawn(state: StructuredAgentSessionState): string[] { +/** What the phone's list draws: each row in draw order, its content, its turn and that turn's bar. */ +function drawn(state: StructuredAgentSessionState): Drawn { const messages = projectStructuredAgentSessionMessages(state.items, [], state.submissions) const folded = foldMobileNativeChatMessages(messages) const { data } = buildMobileNativeChatTransientData({ @@ -307,47 +58,109 @@ function drawn(state: StructuredAgentSessionState): string[] { const turnId = activeStructuredAgentSessionTurnId(state.items) const { settledTurns } = selectStructuredAgentTurnBars(state.items, state.submissions, turnId) const working = isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) - return [ - `working ${working} live ${membership.liveTurnKey ?? '-'}`, - ...rows.map((row, index) => { + return { + header: `working ${working} live ${membership.liveTurnKey ?? '-'}`, + rows: rows.map((row, index) => { const turnKey = turnKeys[index] const bar = turnKey === undefined ? '-' : JSON.stringify(settledTurns.get(turnKey) ?? 'none') - return [ - row.id, - row.role, - row.stoppedBeforeStart ? 'stopped' : '', - waiting.has(row.id) ? 'waiting' : '', - turnKey ?? '-', - bar - ].join(' ') + const flags = [row.stoppedBeforeStart ? 'stopped' : '', waiting.has(row.id) ? 'waiting' : ''] + const line = [row.id, row.role, ...flags, turnKey ?? '-', bar, JSON.stringify(row.blocks)] + return { id: row.id, role: row.role, line: line.join(' ') } }) - ] + } +} + +/** + * A fresh page starts part way into a long chat, and a turn whose user row it cut off is keyed + * differently there; so both lists are compared from the fresh page's first user row on. + */ +function comparable(merged: Drawn, fresh: Drawn): { merged: string[]; fresh: string[] } { + const first = fresh.rows.findIndex((row) => row.role === 'user') + const from = first === -1 ? fresh.rows.length : first + const anchor = fresh.rows[from]?.id + const mergedFrom = + anchor === undefined ? merged.rows.length : merged.rows.findIndex((row) => row.id === anchor) + return { + merged: [ + merged.header, + ...merged.rows.slice(mergedFrom === -1 ? 0 : mergedFrom).map((row) => row.line) + ], + fresh: [fresh.header, ...fresh.rows.slice(from).map((row) => row.line)] + } } -const FRESH = new Map(ROWS.map((row) => [row.seq, drawn(freshState(row.seq))])) +/** Every frame from each subscribe point in `starts`, `rowsPerFrame` rows at a time; with how many + * of those frames the merged state held only a window of the chat. */ +function compareFrames( + journal: StopJournal, + starts: readonly number[], + rowsPerFrame: number +): { differing: string[]; windowed: number } { + const last = journal.rows.length + const fresh = new Map() + const freshAt = (upTo: number): Drawn => { + const cached = fresh.get(upTo) + if (cached) { + return cached + } + const drawnThen = drawn(apply(EMPTY_STRUCTURED_AGENT_SESSION, [journal.snapshotAt(upTo)])) + fresh.set(upTo, drawnThen) + return drawnThen + } + const differing: string[] = [] + let windowed = 0 + for (const start of starts) { + let merged = apply(EMPTY_STRUCTURED_AGENT_SESSION, [journal.snapshotAt(start)]) + for (let upTo = Math.min(start + rowsPerFrame, last); upTo <= last;) { + merged = apply( + merged, + journal.framesTo({ cursor: merged.cursor!, fence: merged.fence! }, upTo) + ) + windowed += merged.hasOlder ? 1 : 0 + const compared = comparable(drawn(merged), freshAt(upTo)) + if (JSON.stringify(compared.merged) !== JSON.stringify(compared.fresh)) { + differing.push(`subscribed at ${start}, frame through ${upTo}`) + } + upTo = upTo === last ? last + 1 : Math.min(upTo + rowsPerFrame, last) + } + } + return { differing, windowed } +} + +const range = (from: number, to: number): number[] => + Array.from({ length: to - from }, (_, index) => from + index) + +describe('a short chat: the phone draws the same from merged frames as from a fresh snapshot', () => { + const journal = stopJournal(8) -describe('the phone, from merged frames and from a fresh snapshot', () => { it('draws the send the exit took back with its stop row, from the frame that took it back', () => { - expect( - FRESH.get(TAKEN_BACK)! - .slice(-2) - .map((line) => line.split(' ').slice(0, 3).join(' ')) - ).toEqual(['orca:never-opened user stopped', 'stopped-before-start:orca:never-opened system ']) + const fresh = drawn( + apply(EMPTY_STRUCTURED_AGENT_SESSION, [journal.snapshotAt(journal.takenBack)]) + ) + expect(fresh.rows.slice(-2).map((row) => row.line.split(' ').slice(0, 3).join(' '))).toEqual([ + `orca:${NEVER_OPENED} user stopped`, + `stopped-before-start:orca:${NEVER_OPENED} system ` + ]) }) - // Every subscribe point, every later frame, and frames that carry several rows at once. - it.each([1, 2, 3])('draws the same at every frame, %i row(s) per frame', (rowsPerFrame) => { - const last = ROWS.length - const differing: string[] = [] - for (let start = 2; start < last; start += 1) { - let merged = freshState(start) - for (let upTo = start + rowsPerFrame; upTo <= last; upTo += rowsPerFrame) { - merged = mergeUpTo(merged, upTo) - if (JSON.stringify(drawn(merged)) !== JSON.stringify(FRESH.get(upTo))) { - differing.push(`subscribed at ${start}, frame through ${upTo}`) - } - } - } - expect(differing).toEqual([]) + it.each([1, 2, 3])('from every subscribe point, %i row(s) per frame', (rowsPerFrame) => { + expect(compareFrames(journal, range(2, journal.rows.length), rowsPerFrame).differing).toEqual( + [] + ) }) }) + +describe('a chat longer than the phone first loads', () => { + const journal = stopJournal(70) + const last = journal.rows.length + // Every 37th row of the history, then every row from the stopped turn on. + const lastTurns = journal.neverOpenedSent - 40 + const starts = [...range(2, lastTurns).filter((row) => row % 37 === 0), ...range(lastTurns, last)] + + it('draws the same at every frame from each subscribe point, from a window of the chat', () => { + expect(apply(EMPTY_STRUCTURED_AGENT_SESSION, [journal.snapshotAt(last)]).hasOlder).toBe(true) + const { differing, windowed } = compareFrames(journal, starts, 1) + expect(differing).toEqual([]) + expect(windowed).toBeGreaterThan(1_000) + }, 60_000) +}) diff --git a/mobile/src/session/mobile-native-chat-stop-journal.test-fixture.ts b/mobile/src/session/mobile-native-chat-stop-journal.test-fixture.ts new file mode 100644 index 000000000000..1757688e511c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stop-journal.test-fixture.ts @@ -0,0 +1,246 @@ +// A Codex chat's journal shaped like a long chat whose last send never opened and was taken back +// at the child's exit, right after a send made while a Stop was ending the turn before it. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalItemBody, + type AgentJournalTurnScope +} from '../../../src/shared/agent-session-journal-types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' +import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' +import { + hostJournalFrames, + type HostJournalFrames +} from './mobile-native-chat-host-journal-frames.test-fixture' + +export const STOP_JOURNAL_SESSION = 'codex_session' +export const NEVER_OPENED = 'never-opened' +const THREAD = 'thread-1' +const EPOCH = 'epoch-1' + +type RowInput = { + [K in JournalRow['kind']]: Omit< + Extract, + 'v' | 'epoch' | 'seq' | 'fence' | 'ts' + > +}[JournalRow['kind']] + +/** A person's Stop is ending work from `from` until `until` (journal rows), as the status feed says. */ +export type StopJournalStopping = { from: number; until: number } + +export type StopJournal = HostJournalFrames & { + rows: readonly JournalRow[] + /** The row that took the never-opened send back. */ + takenBack: number + /** The never-opened send's submission row. */ + neverOpenedSent: number + stopping: readonly StopJournalStopping[] +} + +export function stopJournal(historyTurns: number): StopJournal { + const rows: JournalRow[] = [] + const stopping: StopJournalStopping[] = [] + let fence = 0 + const add = (row: RowInput): number => { + const seq = rows.length + 1 + const envelope = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch: EPOCH, seq, fence } + const stamped = { ...row, ...envelope, ts: 1_000 * seq } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `row` is one arm of the union less the envelope, and the envelope added here is the same for every arm. + rows.push(stamped as JournalRow) + return seq + } + const turnItem = (turnId: string): string => + `legacy:codex:${STOP_JOURNAL_SESSION}:turn-lifecycle%3A${turnId}` + const inTurn = (turnId: string): AgentJournalTurnScope => ({ + kind: 'turn', + turnItemId: turnItem(turnId) + }) + const message = (role: 'user' | 'assistant', text: string): AgentJournalItemBody => ({ + kind: 'message', + role, + blocks: [{ type: 'text', text }] + }) + const submit = (id: string): number => + add({ + kind: 'submission', + clientMessageId: id, + payloadFingerprint: id, + providerHandle: { kind: 'codex', threadId: THREAD }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + handoverRecorded: true, + origin: 'client' + }) + const handOver = (id: string): number => + add({ + kind: 'dispatch', + clientMessageId: id, + state: 'pending', + providerItemId: null, + reason: null, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const stopEvent = (turnId?: string): number => + add({ + kind: 'tombstone', + itemId: 'orca:stop-event', + revision: 1, + stopEvent: { reason: 'user-stop', at: 1, ...(turnId ? { turnId } : {}) } + }) + const turnBody = (id: string, turnId: string, state: 'running' | 'completed' | 'interrupted') => + ({ kind: 'turn', turnId, state, userItemId: `orca:${id}`, startedAt: 1 }) as const + /** The provider opens a turn for `id`, echoes the send and streams an answer. */ + const openTurn = (id: string, turnId: string): void => { + const record = turnItem(turnId) + const providerKey = `codex:${THREAD}:${turnId}:0` + const running = { ...turnBody(id, turnId, 'running'), userItemId: providerKey } + add({ + kind: 'item', + itemId: record, + revision: 1, + body: running, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + add({ + kind: 'item', + itemId: record, + revision: 2, + body: turnBody(id, turnId, 'running'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + add({ + kind: 'dispatch', + clientMessageId: id, + state: 'accepted', + providerItemId: providerKey, + reason: null + }) + add({ + kind: 'item', + itemId: providerKey, + revision: 1, + body: message('user', id), + turnScope: inTurn(turnId) + }) + for (const revision of [1, 2, 3]) { + add({ + kind: 'item', + itemId: `codex:${THREAD}:${turnId}:1`, + revision, + body: message('assistant', `${turnId} part ${revision}`), + turnScope: inTurn(turnId) + }) + } + } + const endTurn = (id: string, turnId: string, state: 'completed' | 'interrupted'): void => { + add({ + kind: 'lifecycle-batch', + settlementId: `turn-completed:${turnId}`, + mutations: [ + { + kind: 'item', + itemId: `codex:${THREAD}:${turnId}:1`, + revision: 4, + body: message('assistant', `${turnId} done`), + turnScope: inTurn(turnId) + }, + { + kind: 'item', + itemId: turnItem(turnId), + revision: 3, + body: { ...turnBody(id, turnId, state), completedAt: 2 }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + }) + if (state === 'interrupted') { + add({ + kind: 'item', + itemId: `orca:stop%3A${turnId}`, + revision: 1, + body: { kind: 'status', text: 'Cancellation requested.' }, + turnScope: inTurn(turnId) + }) + } + } + + add({ + kind: 'epoch', + reason: 'session_created', + providerHandle: { kind: 'codex', threadId: THREAD } + }) + fence = 1 + for (let index = 0; index < historyTurns; index += 1) { + const id = `history-${index}` + const turnId = `t-history-${index}` + submit(id) + handOver(id) + openTurn(id, turnId) + if (index < 2) { + // A provider status the turn revises, then drops again (the first turn) or keeps. + const progress = `codex:${THREAD}:${turnId}:progress` + for (const [revision, text] of [ + [1, 'Compacting context'], + [2, 'Context compacted'] + ] as const) { + add({ + kind: 'item', + itemId: progress, + revision, + body: { kind: 'status', text }, + turnScope: inTurn(turnId) + }) + } + if (index === 0) { + add({ kind: 'tombstone', itemId: progress, revision: 3 }) + } + } + if (index % 2 === 1) { + const from = stopEvent(turnId) + endTurn(id, turnId, 'interrupted') + stopping.push({ from, until: rows.length }) + } else { + endTurn(id, turnId, 'completed') + } + } + // A Stop is ending a turn when a send arrives: the host writes it then and hands it over once + // the turn has ended. + submit('stopped') + handOver('stopped') + openTurn('stopped', 't-stopped') + const stoppedAt = stopEvent('t-stopped') + submit('sent-while-stopping') + endTurn('stopped', 't-stopped', 'interrupted') + stopping.push({ from: stoppedAt, until: rows.length }) + handOver('sent-while-stopping') + openTurn('sent-while-stopping', 't-sent-while-stopping') + endTurn('sent-while-stopping', 't-sent-while-stopping', 'completed') + // A send the provider never opened; a turn-less Stop ends the child, whose exit takes it back. + const neverOpenedSent = submit(NEVER_OPENED) + handOver(NEVER_OPENED) + const stopNeverOpened = stopEvent() + const takenBack = add({ + kind: 'dispatch', + clientMessageId: NEVER_OPENED, + state: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' }, + recovered: true + }) + stopping.push({ from: stopNeverOpened, until: takenBack - 1 }) + // The next send starts a new child under a new fence. + fence = 2 + submit('recovery') + handOver('recovery') + openTurn('recovery', 't-recovery') + endTurn('recovery', 't-recovery', 'completed') + + return { + rows, + takenBack, + neverOpenedSent, + stopping, + ...hostJournalFrames(STOP_JOURNAL_SESSION, EPOCH, rows) + } +} From 93aadecec704c7545db4ee71074e928100d8a09f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:46:41 -0700 Subject: [PATCH 139/182] test(native-chat): pin the stop row to every frame from the one that took the send back Both guards compared a merged phone with a fresh one, so a list bug they shared passed, including one that held the stop row back until the next send. Both now check, at every frame from the take-back on, that the list itself shows the stop row: the data test through the list's own turn disclosure, the hooks test on the live and the fresh phone. Also: - the send made while stopping is written after the stopped turn's end, as the QA journal shows, and while the turn still runs, as a second case; - the long chat revises and drops a status near its end, inside the window; - each phone reduces its own copy of every frame; - Stopping is checked on the never-opened send's own Stop; - the headers say what neither guard covers; - the mobile production typecheck leaves *.test-fixture.ts to the tests' program, as it does tests, since these fixtures import desktop host code. --- ...chat-merged-snapshot-parity-hooks.test.tsx | 28 +++-- ...native-chat-merged-snapshot-parity.test.ts | 114 ++++++++++++++---- ...e-native-chat-stop-journal.test-fixture.ts | 26 ++-- mobile/tsconfig.json | 2 +- 4 files changed, 132 insertions(+), 38 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx index 83a109bec978..214c64be95e4 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -1,6 +1,8 @@ // The phone's own hooks: a phone that sent the never-opened send itself, holding its echo, and // reading the host's Stopping from the status feed, must show the same list after every frame as a -// phone that opened the chat fresh at that frame. +// phone that opened the chat fresh at that frame, and the stop row from the frame that took the +// send back. Not covered: the view (MobileNativeChatView, its memos, the FlatList and its rows), +// the controller, lane and overlay wiring, and a transport that reconnects and resubscribes. import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' @@ -27,6 +29,8 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() } })) +const STOP_ROW = `stopped-before-start:orca:${NEVER_OPENED}` + const HOST_SUPPORT = { promptCancel: true, questionAnswers: true, @@ -42,6 +46,8 @@ type Phone = { /** The phone's own send of the never-opened message, answered before its row streams in. */ send: () => void read: () => string + /** The ids the list shows. */ + listed: () => string[] } const mounted: ReactTestRenderer[] = [] @@ -75,6 +81,7 @@ async function mountPhone(): Promise { let latest: { drafts: ReturnType shown: string + listed: string[] } | null = null function Harness(): null { const session = useMobileStructuredAgentSession({ @@ -119,6 +126,7 @@ async function mountPhone(): Promise { turns.active === null ? 'none' : turns.active.workedSeconds === null ? 'live' : 'worked' latest = { drafts, + listed: turns.listMessages.map((row) => row.id), shown: JSON.stringify({ working: session.isWorking, stopping: session.turnIndicator.stopping, @@ -166,7 +174,8 @@ async function mountPhone(): Promise { const origin = latest!.drafts.captureSendOrigin(NEVER_OPENED)! act(() => latest!.drafts.acceptSend(origin, NEVER_OPENED)) }, - read: () => latest!.shown + read: () => latest!.shown, + listed: () => latest!.listed } } @@ -186,7 +195,6 @@ describe("the phone's hooks, from merged frames and from a fresh snapshot", () = let cursor = { epoch: 'epoch-1', sequence: start } let fence = journal.fenceAt(start) const differing: string[] = [] - let stoppingShown = 0 for (let upTo = start + 1; upTo <= last; upTo += 1) { if (upTo === journal.neverOpenedSent) { live.send() @@ -198,19 +206,25 @@ describe("the phone's hooks, from merged frames and from a fresh snapshot", () = } cursor = { epoch: 'epoch-1', sequence: upTo } live.status(stoppingAt(upTo)) - stoppingShown += live.read().includes('"stopping":true') ? 1 : 0 + if (upTo === journal.takenBack - 1) { + // The never-opened send's own Stop, still settling. + expect(live.read()).toContain('"stopping":true') + } const fresh = await mountPhone() fresh.transcript(journal.snapshotAt(upTo)) fresh.status(stoppingAt(upTo)) if (live.read() !== fresh.read()) { differing.push(`frame through ${upTo}`) } + if (upTo >= journal.takenBack && !live.listed().includes(STOP_ROW)) { + differing.push(`no stop row in the merged list through ${upTo}`) + } + if (upTo >= journal.takenBack && !fresh.listed().includes(STOP_ROW)) { + differing.push(`no stop row in the fresh list at ${upTo}`) + } act(() => fresh.renderer.unmount()) mounted.splice(mounted.indexOf(fresh.renderer), 1) } - expect(live.read()).toContain(`stopped-before-start:orca:${NEVER_OPENED}`) - // The never-opened send's Stop showed Stopping until the exit took the send back. - expect(stoppingShown).toBeGreaterThan(0) expect(differing).toEqual([]) }, 60_000 diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts index a3389907af7f..15d45714a828 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts @@ -1,8 +1,14 @@ // The phone's list built from the frames it merged must equal the list a fresh snapshot of the same -// journal builds. Covers journal -> host readers -> phone reducer -> projection -> fold -> turn -// membership and bars; the phone's own hooks, its echo and the host's Stopping are covered by -// mobile-native-chat-merged-snapshot-parity-hooks.test.tsx. +// journal builds, and must show the stop row from the frame that took the send back. Covers journal +// -> host readers -> phone reducer -> projection -> fold -> turn membership, bars and the list's +// disclosure; the phone's own hooks, its echo and the host's Stopping are covered by +// mobile-native-chat-merged-snapshot-parity-hooks.test.tsx. Not covered: +// - a batch that carries a new fence (the background-task publish, rows an attach drains); +// - what the exit's settlement writes after the withdrawal (unknown marks, a lifecycle batch); +// - a queue-capable phone's send while stopping as a queued draft card and its hand-off. +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { describe, expect, it } from 'vitest' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import { nativeChatRowsInDrawOrder } from '../../../src/shared/native-chat-turn-grouping' @@ -28,6 +34,9 @@ import { stopJournal, type StopJournal } from './mobile-native-chat-stop-journal.test-fixture' +import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' + +const STOP_ROW = `stopped-before-start:orca:${NEVER_OPENED}` type Drawn = { header: string; rows: { id: string; role: string; line: string }[] } @@ -35,8 +44,10 @@ function apply( state: StructuredAgentSessionState, events: readonly AgentSessionSubscribeEvent[] ): StructuredAgentSessionState { + // Each phone gets its own copy, as it would off the wire. return events.reduce( - (next, event) => reduceStructuredAgentSession(next, { type: 'event', event }, 0), + (next, event) => + reduceStructuredAgentSession(next, { type: 'event', event: structuredClone(event) }, 0), state ) } @@ -70,6 +81,39 @@ function drawn(state: StructuredAgentSessionState): Drawn { } } +/** The ids the phone's list shows: its rows through the list's own turn disclosure. */ +function listed(state: StructuredAgentSessionState): string[] { + const messages = projectStructuredAgentSessionMessages(state.items, [], state.submissions) + const folded = foldMobileNativeChatMessages(messages) + const { data } = buildMobileNativeChatTransientData({ + messages, + folded, + streaming: null, + pending: [] + }) + const turnId = activeStructuredAgentSessionTurnId(state.items) + const { settledTurns } = selectStructuredAgentTurnBars(state.items, state.submissions, turnId) + let ids: string[] = [] + function List(): null { + const turns = useMobileNativeChatTurnDisclosure({ + messages: data, + enabled: true, + isWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), + settledTurns, + turnJournal: state, + scopeKey: 'host\0workspace\0tab' + }) + ids = turns.listMessages.map((row) => row.id) + return null + } + let renderer: ReactTestRenderer | null = null + act(() => { + renderer = create(createElement(List)) + }) + act(() => renderer!.unmount()) + return ids +} + /** * A fresh page starts part way into a long chat, and a turn whose user row it cut off is keyed * differently there; so both lists are compared from the fresh page's first user row on. @@ -107,6 +151,18 @@ function compareFrames( fresh.set(upTo, drawnThen) return drawnThen } + const freshListing = new Map() + const freshListed = (upTo: number): boolean => { + const cached = freshListing.get(upTo) + if (cached !== undefined) { + return cached + } + const shown = listed( + apply(EMPTY_STRUCTURED_AGENT_SESSION, [journal.snapshotAt(upTo)]) + ).includes(STOP_ROW) + freshListing.set(upTo, shown) + return shown + } const differing: string[] = [] let windowed = 0 for (const start of starts) { @@ -121,6 +177,12 @@ function compareFrames( if (JSON.stringify(compared.merged) !== JSON.stringify(compared.fresh)) { differing.push(`subscribed at ${start}, frame through ${upTo}`) } + if (upTo >= journal.takenBack && !listed(merged).includes(STOP_ROW)) { + differing.push(`subscribed at ${start}, no stop row in the list through ${upTo}`) + } + if (upTo >= journal.takenBack && !freshListed(upTo)) { + differing.push(`fresh at ${upTo}, no stop row in the list`) + } upTo = upTo === last ? last + 1 : Math.min(upTo + rowsPerFrame, last) } } @@ -130,31 +192,37 @@ function compareFrames( const range = (from: number, to: number): number[] => Array.from({ length: to - from }, (_, index) => from + index) -describe('a short chat: the phone draws the same from merged frames as from a fresh snapshot', () => { - const journal = stopJournal(8) +// The QA journal has the host write a send made while stopping after the stopped turn's end; a host +// that writes it while the turn still runs is covered too. +describe.each(['after-end', 'during-turn'] as const)( + 'a short chat, the send while stopping written %s: merged frames draw as a fresh snapshot does', + (sendWhileStopping) => { + const journal = stopJournal(8, sendWhileStopping) - it('draws the send the exit took back with its stop row, from the frame that took it back', () => { - const fresh = drawn( - apply(EMPTY_STRUCTURED_AGENT_SESSION, [journal.snapshotAt(journal.takenBack)]) - ) - expect(fresh.rows.slice(-2).map((row) => row.line.split(' ').slice(0, 3).join(' '))).toEqual([ - `orca:${NEVER_OPENED} user stopped`, - `stopped-before-start:orca:${NEVER_OPENED} system ` - ]) - }) + it('draws the send the exit took back with its stop row, from the frame that took it back', () => { + const fresh = drawn( + apply(EMPTY_STRUCTURED_AGENT_SESSION, [journal.snapshotAt(journal.takenBack)]) + ) + expect(fresh.rows.slice(-2).map((row) => row.line.split(' ').slice(0, 3).join(' '))).toEqual([ + `orca:${NEVER_OPENED} user stopped`, + `stopped-before-start:orca:${NEVER_OPENED} system ` + ]) + }) - it.each([1, 2, 3])('from every subscribe point, %i row(s) per frame', (rowsPerFrame) => { - expect(compareFrames(journal, range(2, journal.rows.length), rowsPerFrame).differing).toEqual( - [] - ) - }) -}) + it.each([1, 2, 3])('from every subscribe point, %i row(s) per frame', (rowsPerFrame) => { + expect(compareFrames(journal, range(2, journal.rows.length), rowsPerFrame).differing).toEqual( + [] + ) + }) + } +) describe('a chat longer than the phone first loads', () => { const journal = stopJournal(70) const last = journal.rows.length - // Every 37th row of the history, then every row from the stopped turn on. - const lastTurns = journal.neverOpenedSent - 40 + // Every 37th row of the history, then every row from the last two history turns on, which revise + // and drop a status the window then holds. + const lastTurns = journal.neverOpenedSent - 70 const starts = [...range(2, lastTurns).filter((row) => row % 37 === 0), ...range(lastTurns, last)] it('draws the same at every frame from each subscribe point, from a window of the chat', () => { diff --git a/mobile/src/session/mobile-native-chat-stop-journal.test-fixture.ts b/mobile/src/session/mobile-native-chat-stop-journal.test-fixture.ts index 1757688e511c..bdf77d41296d 100644 --- a/mobile/src/session/mobile-native-chat-stop-journal.test-fixture.ts +++ b/mobile/src/session/mobile-native-chat-stop-journal.test-fixture.ts @@ -38,7 +38,14 @@ export type StopJournal = HostJournalFrames & { stopping: readonly StopJournalStopping[] } -export function stopJournal(historyTurns: number): StopJournal { +/** Where the host writes a send made while a Stop is ending a turn: after that turn's end (a host + * that holds sends while Stopping, as the QA journal shows), or while the turn still runs. */ +export type SendWhileStoppingWritten = 'after-end' | 'during-turn' + +export function stopJournal( + historyTurns: number, + sendWhileStopping: SendWhileStoppingWritten = 'after-end' +): StopJournal { const rows: JournalRow[] = [] const stopping: StopJournalStopping[] = [] let fence = 0 @@ -176,8 +183,9 @@ export function stopJournal(historyTurns: number): StopJournal { submit(id) handOver(id) openTurn(id, turnId) - if (index < 2) { - // A provider status the turn revises, then drops again (the first turn) or keeps. + // A provider status the turn revises, then drops again or keeps: early on, and near the end. + const revisesStatus = index < 2 || index >= historyTurns - 2 + if (revisesStatus) { const progress = `codex:${THREAD}:${turnId}:progress` for (const [revision, text] of [ [1, 'Compacting context'], @@ -191,7 +199,7 @@ export function stopJournal(historyTurns: number): StopJournal { turnScope: inTurn(turnId) }) } - if (index === 0) { + if (index % 2 === 0) { add({ kind: 'tombstone', itemId: progress, revision: 3 }) } } @@ -203,15 +211,19 @@ export function stopJournal(historyTurns: number): StopJournal { endTurn(id, turnId, 'completed') } } - // A Stop is ending a turn when a send arrives: the host writes it then and hands it over once - // the turn has ended. + // A Stop is ending a turn when a send arrives; the host hands it over once the turn has ended. submit('stopped') handOver('stopped') openTurn('stopped', 't-stopped') const stoppedAt = stopEvent('t-stopped') - submit('sent-while-stopping') + if (sendWhileStopping === 'during-turn') { + submit('sent-while-stopping') + } endTurn('stopped', 't-stopped', 'interrupted') stopping.push({ from: stoppedAt, until: rows.length }) + if (sendWhileStopping === 'after-end') { + submit('sent-while-stopping') + } handOver('sent-while-stopping') openTurn('sent-while-stopping', 't-sent-while-stopping') endTurn('sent-while-stopping', 't-sent-while-stopping', 'completed') diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json index 55e1adc660a4..ac79b0b7c065 100644 --- a/mobile/tsconfig.json +++ b/mobile/tsconfig.json @@ -14,5 +14,5 @@ } }, "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["**/*.test.ts", "**/*.test.tsx"] + "exclude": ["**/*.test.ts", "**/*.test.tsx", "**/*.test-fixture.ts"] } From c08102929e7d8a9b4c89844d40fc2d51958d7998 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:01:28 -0700 Subject: [PATCH 140/182] test(native-chat): check the stop row in the chat view's FlatList, through the real overlay The hooks guard rebuilt the overlay and chat view by hand, so a memo in the real view that held its list back until the next send passed it. The guard now renders the real overlay and chat view from the phone's own hooks, and checks the stop row in the FlatList's data at every frame from the one that took the send back. It also subscribes once before the history's last dropped status, so removals run through the real hooks too. --- ...chat-merged-snapshot-parity-hooks.test.tsx | 120 ++++++++++++++++-- 1 file changed, 109 insertions(+), 11 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx index 214c64be95e4..17ba431fc0c6 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -1,8 +1,9 @@ // The phone's own hooks: a phone that sent the never-opened send itself, holding its echo, and // reading the host's Stopping from the status feed, must show the same list after every frame as a -// phone that opened the chat fresh at that frame, and the stop row from the frame that took the -// send back. Not covered: the view (MobileNativeChatView, its memos, the FlatList and its rows), -// the controller, lane and overlay wiring, and a transport that reconnects and resubscribes. +// phone that opened the chat fresh at that frame; and the real overlay and chat view must hand the +// FlatList the stop row from the frame that took the send back. Not covered: the row components and +// native rendering, the controller and lane that build the overlay's inputs, and a transport that +// reconnects and resubscribes. import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' @@ -12,6 +13,7 @@ import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' +import { MobileNativeChatOverlay } from './MobileNativeChatOverlay' import { buildMobileNativeChatTransientData, foldMobileNativeChatMessages @@ -23,14 +25,67 @@ import { } from './mobile-native-chat-stop-journal.test-fixture' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' +import type { MobileNativeChatController } from './use-mobile-native-chat-controller' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' vi.mock('@react-native-async-storage/async-storage', () => ({ default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() } })) +// The chat view's own test mocks: the FlatList is a host element whose `data` the test reads. +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + FlatList: React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ scrollToEnd: vi.fn(), scrollToOffset: vi.fn() }), []) + return React.createElement('FlatList', props) + }), + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1, absoluteFillObject: {} }, + Text: 'Text', + View: 'View' + } +}) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', () => { + const chain = { runOnJS: () => chain, onStart: () => chain, onUpdate: () => chain } + return { + Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnActivity: 'LiveStatus' })) +vi.mock('./MobileNativeChatComposer', () => ({ MobileNativeChatComposer: 'Composer' })) +vi.mock('./MobileNativeChatQueuedMessages', () => ({ MobileNativeChatQueuedMessages: 'Queued' })) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) const STOP_ROW = `stopped-before-start:orca:${NEVER_OPENED}` +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the overlay reads only these members of the image attachments, none of which this test exercises. +const OVERLAY_IMAGES = { + sendNativeChat: async () => true, + attachImage: async () => {}, + attachments: [], + removeAttachment: () => {}, + isAttaching: false +} as unknown as Parameters[0]['images'] + const HOST_SUPPORT = { promptCancel: true, questionAnswers: true, @@ -46,7 +101,7 @@ type Phone = { /** The phone's own send of the never-opened message, answered before its row streams in. */ send: () => void read: () => string - /** The ids the list shows. */ + /** The ids the overlay's chat view hands its FlatList. */ listed: () => string[] } @@ -81,9 +136,8 @@ async function mountPhone(): Promise { let latest: { drafts: ReturnType shown: string - listed: string[] } | null = null - function Harness(): null { + function Harness(): ReturnType { const session = useMobileStructuredAgentSession({ client, sessionId: STOP_JOURNAL_SESSION, @@ -126,7 +180,6 @@ async function mountPhone(): Promise { turns.active === null ? 'none' : turns.active.workedSeconds === null ? 'live' : 'worked' latest = { drafts, - listed: turns.listMessages.map((row) => row.id), shown: JSON.stringify({ working: session.isWorking, stopping: session.turnIndicator.stopping, @@ -142,7 +195,43 @@ async function mountPhone(): Promise { active }) } - return null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the overlay reads only these controller members, which the controller builds from the same two hooks. + const controller = { + showNativeChat: true, + nativeChatSession: session.session, + nativeChatAgent: 'codex', + nativeChatAgentWorking: session.isWorking, + nativeChatCanStop: false, + nativeChatStructured: true, + nativeChatTurnIndicator: session.turnIndicator, + nativeChatWorkingStartedAt: session.workingStartedAt, + nativeChatSettledTurns: session.settledTurns, + nativeChatTurnJournal: session.turnJournal, + nativeChatStreamLive: session.isWorking, + nativeChatStreamScopeKey: 'host\0workspace\0tab', + chatPending: drafts.pending, + chatImagePreviewsByMessageId: drafts.imagePreviewsByMessageId, + chatComposerText: drafts.composerText, + setChatComposerText: drafts.setComposerText, + getChatComposerEditGeneration: drafts.getComposerEditGeneration, + nativeChatQueued: session.queued + } as unknown as MobileNativeChatController + return createElement(MobileNativeChatOverlay, { + controller, + onOpenFile: () => {}, + images: OVERLAY_IMAGES, + onMicPress: () => {}, + micActive: false, + dictationMode: 'toggle', + onMicPressIn: () => {}, + onMicPressOut: () => {}, + inputLockReason: null, + sendErrorMessage: null, + onClearSendError: () => {}, + sendSurfaceId: 'host\0workspace\0tab', + getSendCompletionGeneration: () => 0, + keyboardInset: 0 + }) } let renderer: ReactTestRenderer | null = null await act(async () => { @@ -175,7 +264,11 @@ async function mountPhone(): Promise { act(() => latest!.drafts.acceptSend(origin, NEVER_OPENED)) }, read: () => latest!.shown, - listed: () => latest!.listed + listed: () => { + const data: unknown = renderer!.root.find((node) => String(node.type) === 'FlatList').props + .data + return Array.isArray(data) ? data.map((row: { id: string }) => row.id) : [] + } } } @@ -185,8 +278,13 @@ describe("the phone's hooks, from merged frames and from a fresh snapshot", () = const stoppingAt = (upTo: number): boolean => journal.stopping.some((window) => upTo >= window.from && upTo <= window.until) - // Subscribed before the turn a send was made into while stopping, and just before the send. - it.each([journal.neverOpenedSent - 30, journal.neverOpenedSent - 1])( + const lastRemoval = journal.rows.findLast( + (row) => row.kind === 'tombstone' && row.stopEvent === undefined + )!.seq + + // Subscribed before the history's last dropped status, before the turn a send was made into + // while stopping, and just before the send. + it.each([lastRemoval - 3, journal.neverOpenedSent - 30, journal.neverOpenedSent - 1])( 'show the same list after every frame, subscribed at row %i', async (start) => { const live = await mountPhone() From 2c0bf2941eedd6edebba178bde0422eb236b37e8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:15:08 -0700 Subject: [PATCH 141/182] test(native-chat): compare the live and fresh phone on the chat view's own FlatList The hooks guard compared a hand-built copy of the overlay and view, so a memo in the real ones keyed on list lengths kept stale rows past it. It now compares, after every frame, what the real chat view hands its FlatList: each row as its renderItem draws it, with its bar, and the footer. The hand-built copy is gone. It also checks that the FlatList's keys never collide and that a changed list never arrives in the same array, and the controller stub's SAFETY note now states what the stub really provides. --- ...chat-merged-snapshot-parity-hooks.test.tsx | 154 ++++++++++-------- 1 file changed, 88 insertions(+), 66 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx index 17ba431fc0c6..b7c55b7a89f2 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -1,37 +1,34 @@ -// The phone's own hooks: a phone that sent the never-opened send itself, holding its echo, and -// reading the host's Stopping from the status feed, must show the same list after every frame as a -// phone that opened the chat fresh at that frame; and the real overlay and chat view must hand the -// FlatList the stop row from the frame that took the send back. Not covered: the row components and +// The phone's own hooks, overlay and chat view: a phone that sent the never-opened send itself, +// holding its echo, and reading the host's Stopping from the status feed, must hand its FlatList the +// same rows, bars and footer after every frame as a phone that opened the chat fresh at that frame, +// and the stop row from the frame that took the send back. Not covered: the row components and // native rendering, the controller and lane that build the overlay's inputs, and a transport that // reconnects and resubscribes. -import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionStatusEvent, AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import type { RpcClient } from '../transport/rpc-client' import { MobileNativeChatOverlay } from './MobileNativeChatOverlay' -import { - buildMobileNativeChatTransientData, - foldMobileNativeChatMessages -} from './mobile-native-chat-render-data' +import { MobileNativeChatView } from './MobileNativeChatView' import { NEVER_OPENED, STOP_JOURNAL_SESSION, stopJournal } from './mobile-native-chat-stop-journal.test-fixture' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' -import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' import type { MobileNativeChatController } from './use-mobile-native-chat-controller' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' vi.mock('@react-native-async-storage/async-storage', () => ({ default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() } })) -// The chat view's own test mocks: the FlatList is a host element whose `data` the test reads. +// The chat view's own test mocks: the FlatList is a host element whose props the test reads. vi.mock('react-native', async () => { const React = await import('react') return { @@ -100,9 +97,50 @@ type Phone = { status: (stopping: boolean) => void /** The phone's own send of the never-opened message, answered before its row streams in. */ send: () => void + /** What the chat view hands its FlatList: each row as drawn, with its bar, then the footer. */ read: () => string - /** The ids the overlay's chat view hands its FlatList. */ + /** The ids the chat view hands its FlatList. */ listed: () => string[] + /** Keys the FlatList would collide on, or a changed list it was handed in the same array. */ + listFaults: string[] +} + +type DrawnRowProps = { + message: NativeChatMessage + turnStatus: { workedSeconds: number | null } | null + activeTurnIsWorking: boolean +} + +/** A row element as the list draws it: its message, its bar, and whether its turn is live. */ +function drawnRow(row: ReactNode): unknown { + if (!isValidElement(row)) { + return row ?? null + } + const { message, turnStatus, activeTurnIsWorking } = row.props + const bar = turnStatus === null ? '-' : (turnStatus.workedSeconds ?? 'live') + return [ + message.id, + message.role, + message.stoppedBeforeStart === true, + message.blocks, + bar, + activeTurnIsWorking + ] +} + +/** The footer: the live status and the rows waiting behind it. */ +function drawnFooter(node: ReactNode): unknown { + if (Array.isArray(node)) { + return node.map(drawnFooter) + } + if (!isValidElement<{ children?: ReactNode; stopping?: boolean }>(node)) { + return node ?? null + } + const element: ReactElement<{ children?: ReactNode; stopping?: boolean }> = node + if (element.type === 'LiveStatus') { + return ['live', element.props.stopping === true] + } + return element.type === 'ChatMessage' ? drawnRow(element) : drawnFooter(element.props.children) } const mounted: ReactTestRenderer[] = [] @@ -133,10 +171,7 @@ async function mountPhone(): Promise { notifyForeground: () => {}, close: () => {} } - let latest: { - drafts: ReturnType - shown: string - } | null = null + let latest: ReturnType | null = null function Harness(): ReturnType { const session = useMobileStructuredAgentSession({ client, @@ -148,54 +183,16 @@ async function mountPhone(): Promise { hostSupport: HOST_SUPPORT, onSendError: () => {} }) - const messages = session.session.messages const drafts = useMobileNativeChatDrafts({ hostId: 'host', worktreeId: 'workspace', tabId: 'tab', sessionId: STOP_JOURNAL_SESSION, - messages, + messages: session.session.messages, transcriptSettled: session.session.status === 'ready' }) - const { data } = buildMobileNativeChatTransientData({ - messages, - folded: foldMobileNativeChatMessages(messages), - streaming: null, - pending: drafts.pending, - imagePreviewsByMessageId: drafts.imagePreviewsByMessageId - }) - const turns = useMobileNativeChatTurnDisclosure({ - messages: data, - enabled: true, - isWorking: session.isWorking, - workingStartedAt: session.workingStartedAt, - settledTurns: session.settledTurns, - turnJournal: session.turnJournal, - thinking: session.turnIndicator.thinking, - activityText: session.turnIndicator.activityText, - stopping: session.turnIndicator.stopping, - scopeKey: 'host\0workspace\0tab' - }) - const active = - turns.active === null ? 'none' : turns.active.workedSeconds === null ? 'live' : 'worked' - latest = { - drafts, - shown: JSON.stringify({ - working: session.isWorking, - stopping: session.turnIndicator.stopping, - data: data.map((row) => [row.id, row.role, row.stoppedBeforeStart === true, row.blocks]), - list: turns.listMessages.map((row, index) => { - const status = turns.resolveRow(index, row).turnStatus - return [ - row.id, - status === null ? '-' : status.workedSeconds === null ? 'live' : status.workedSeconds - ] - }), - waiting: turns.waitingRows.map((row) => row.item.id), - active - }) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the overlay reads only these controller members, which the controller builds from the same two hooks. + latest = drafts + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every member the list's rows, bars and footer read comes from the two hooks above, as the controller passes them; showNativeChat, nativeChatAgent, nativeChatCanStop, nativeChatStructured and the scope key are constants; the overlay's other members stay undefined: the composer's and Stop's handlers, files and options never reach the list, the streaming text is undefined on a structured tab too, and the prompts, which would hide the live status, are absent in this flow. const controller = { showNativeChat: true, nativeChatSession: session.session, @@ -237,6 +234,22 @@ async function mountPhone(): Promise { await act(async () => { renderer = create(createElement(Harness)) }) + const listFaults: string[] = [] + let handed: { data: unknown; ids: string } | null = null + const flatList = (): { data: NativeChatMessage[]; props: ReactTestInstance['props'] } => { + const props = renderer!.root.find((node) => String(node.type) === 'FlatList').props + const data: NativeChatMessage[] = props.data + const ids = data.map((row) => row.id).join(',') + const keys = data.map((row, index) => props.keyExtractor(row, index)) + if (new Set(keys).size !== keys.length) { + listFaults.push(`colliding keys: ${keys.join(',')}`) + } + if (handed && handed.data === data && handed.ids !== ids) { + listFaults.push(`changed rows in the same array: ${ids}`) + } + handed = { data, ids } + return { data, props } + } mounted.push(renderer!) await vi.waitFor(() => expect(listeners.get('agentSession.subscribe')).toBeDefined()) await vi.waitFor(() => expect(listeners.get('agentSession.subscribeStatus')).toBeDefined()) @@ -260,15 +273,23 @@ async function mountPhone(): Promise { act(() => listeners.get('agentSession.subscribeStatus')!(event)) }, send: () => { - const origin = latest!.drafts.captureSendOrigin(NEVER_OPENED)! - act(() => latest!.drafts.acceptSend(origin, NEVER_OPENED)) + const origin = latest!.captureSendOrigin(NEVER_OPENED)! + act(() => latest!.acceptSend(origin, NEVER_OPENED)) }, - read: () => latest!.shown, - listed: () => { - const data: unknown = renderer!.root.find((node) => String(node.type) === 'FlatList').props - .data - return Array.isArray(data) ? data.map((row: { id: string }) => row.id) : [] - } + read: () => { + const view = renderer!.root.findByType(MobileNativeChatView).props + const { data, props } = flatList() + const renderRow: (row: { item: NativeChatMessage; index: number }) => ReactNode = + props.renderItem + return JSON.stringify({ + working: view.agentWorking, + stopping: view.turnIndicator.stopping, + rows: data.map((item, index) => drawnRow(renderRow({ item, index }))), + footer: drawnFooter(props.ListFooterComponent) + }) + }, + listed: () => flatList().data.map((row) => row.id), + listFaults } } @@ -314,6 +335,7 @@ describe("the phone's hooks, from merged frames and from a fresh snapshot", () = if (live.read() !== fresh.read()) { differing.push(`frame through ${upTo}`) } + differing.push(...live.listFaults.splice(0), ...fresh.listFaults.splice(0)) if (upTo >= journal.takenBack && !live.listed().includes(STOP_ROW)) { differing.push(`no stop row in the merged list through ${upTo}`) } From d8422dd79adca6400a726c7c8bcbe47e0b55d854 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:14:22 -0700 Subject: [PATCH 142/182] test(native-chat): read the outbox reconcile from where main moved it --- .../structured-agent-session-outbox-withdrawn-send.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts b/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts index ff127c8baabf..214ed97872aa 100644 --- a/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts +++ b/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts @@ -7,9 +7,9 @@ import { agentSessionFailureWords } from './agent-session-failure-words' import type { AgentJournalSubmission } from './agent-session-journal-types' import { createStructuredAgentSessionOutboxEntry, - reconcileStructuredAgentSessionOutbox, type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { reconcileStructuredAgentSessionOutbox } from './structured-agent-session-outbox-reconcile' import { admitStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-admission' function entry( @@ -52,7 +52,7 @@ describe('the send a Stop ended before its turn opened', () => { ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) }) - const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [withdrawn]) + const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [withdrawn], []) expect(reconciled.map((each) => each.clientMessageId)).toEqual(['next']) expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ @@ -68,7 +68,7 @@ describe('the send a Stop ended before its turn opened', () => { reason: 'provider_closed_before_acknowledgement' }) - const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [inDoubt]) + const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [inDoubt], []) expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ state: 'blocked', From 5be7588c18a31c28c3b69c2b85a5c043a055a1da Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:32:51 -0700 Subject: [PATCH 143/182] test(native-chat): pass the projection's options and mock the phone row's new imports after main's rejected-in-place rows --- ...native-chat-merged-snapshot-parity.test.ts | 30 ++++++++++++++----- ...-native-chat-stopped-before-start.test.tsx | 15 +++++++++- ...t-session-codex-stopped-send-order.test.ts | 2 +- ...-session-codex-turn-end-settlement.test.ts | 10 +++++-- ...tMessageList.stopped-before-start.test.tsx | 4 ++- .../native-chat-rail-outline-parity.test.ts | 8 +++-- ...ge-projection-stopped-before-start.test.ts | 17 ++++++++--- 7 files changed, 67 insertions(+), 19 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts index 15d45714a828..3762d57006e4 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts @@ -11,6 +11,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { describe, expect, it } from 'vitest' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' import { nativeChatRowsInDrawOrder } from '../../../src/shared/native-chat-turn-grouping' import { nativeChatMessagesWaitingBehindLiveTurn, @@ -25,6 +26,7 @@ import { type StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' import { selectStructuredAgentTurnBars } from '../../../src/shared/structured-agent-session-turn-timing' +import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' import { buildMobileNativeChatTransientData, foldMobileNativeChatMessages @@ -52,9 +54,23 @@ function apply( ) } +/** The rows the phone's transcript reads, as its session hook builds them: the journal plus the + * notice a cut turn gets, projected with no other rejected send drawn in place. */ +function phoneTranscript(state: StructuredAgentSessionState) { + const items = withNativeChatCutTurnNotices(state.items, { + agentName: TUI_AGENT_DISPLAY_NAMES.codex + }) + return { + items, + messages: projectStructuredAgentSessionMessages(items, [], state.submissions, { + rejectedInPlace: false + }) + } +} + /** What the phone's list draws: each row in draw order, its content, its turn and that turn's bar. */ function drawn(state: StructuredAgentSessionState): Drawn { - const messages = projectStructuredAgentSessionMessages(state.items, [], state.submissions) + const { items, messages } = phoneTranscript(state) const folded = foldMobileNativeChatMessages(messages) const { data } = buildMobileNativeChatTransientData({ messages, @@ -62,12 +78,12 @@ function drawn(state: StructuredAgentSessionState): Drawn { streaming: null, pending: [] }) - const membership = nativeChatTurnMembership(data, state) + const membership = nativeChatTurnMembership(data, { items, submissions: state.submissions }) const rows = nativeChatRowsInDrawOrder(data, membership.drawOrder) const turnKeys = nativeChatRowsInDrawOrder(membership.turnKeys, membership.drawOrder) - const waiting = nativeChatMessagesWaitingBehindLiveTurn(rows, state.items) + const waiting = nativeChatMessagesWaitingBehindLiveTurn(rows, items) const turnId = activeStructuredAgentSessionTurnId(state.items) - const { settledTurns } = selectStructuredAgentTurnBars(state.items, state.submissions, turnId) + const { settledTurns } = selectStructuredAgentTurnBars(items, state.submissions, turnId) const working = isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) return { header: `working ${working} live ${membership.liveTurnKey ?? '-'}`, @@ -83,7 +99,7 @@ function drawn(state: StructuredAgentSessionState): Drawn { /** The ids the phone's list shows: its rows through the list's own turn disclosure. */ function listed(state: StructuredAgentSessionState): string[] { - const messages = projectStructuredAgentSessionMessages(state.items, [], state.submissions) + const { items, messages } = phoneTranscript(state) const folded = foldMobileNativeChatMessages(messages) const { data } = buildMobileNativeChatTransientData({ messages, @@ -92,7 +108,7 @@ function listed(state: StructuredAgentSessionState): string[] { pending: [] }) const turnId = activeStructuredAgentSessionTurnId(state.items) - const { settledTurns } = selectStructuredAgentTurnBars(state.items, state.submissions, turnId) + const { settledTurns } = selectStructuredAgentTurnBars(items, state.submissions, turnId) let ids: string[] = [] function List(): null { const turns = useMobileNativeChatTurnDisclosure({ @@ -100,7 +116,7 @@ function listed(state: StructuredAgentSessionState): string[] { enabled: true, isWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), settledTurns, - turnJournal: state, + turnJournal: { items, submissions: state.submissions }, scopeKey: 'host\0workspace\0tab' }) ids = turns.listMessages.map((row) => row.id) diff --git a/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx index 83933e0f86e1..fda3b94f18f4 100644 --- a/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx +++ b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx @@ -32,6 +32,7 @@ vi.mock('react-native', async () => { timing: () => ({ start: vi.fn(), stop: vi.fn() }) }, Image: 'Image', + Platform: { OS: 'ios' }, Pressable: 'Pressable', Text, View: ({ children, ...props }: { children?: React.ReactNode }) => @@ -50,6 +51,16 @@ vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' })) vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: () => null })) +vi.mock('../components/MobileSelectableText', async () => { + const React = await import('react') + return { + MobileSelectableText: ({ children }: { children?: React.ReactNode }) => + React.createElement('Text', null, children) + } +}) +vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ + MobileNativeChatMessageActionsSheet: () => null +})) import { MobileMarkdown } from '../components/MobileMarkdown' import { MobileNativeChatMessage } from './MobileNativeChatMessage' @@ -139,7 +150,9 @@ describe('a send a Stop took back before the agent started it, on the phone', () }) it('stays where it was sent, with the stop row after it and no turn status of its own', () => { - const messages = projectStructuredAgentSessionMessages(items, [], submissions) + const messages = projectStructuredAgentSessionMessages(items, [], submissions, { + rejectedInPlace: false + }) expect(messages.map((message) => message.id)).toEqual([ agentJournalSubmissionKey('warm-up'), 't1-answer', diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts index e2cbed955f75..50bf313c85ac 100644 --- a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -106,7 +106,7 @@ async function drawn(): Promise { const { items, submissions } = await host.journalSnapshot(SESSION) const journal = { items, submissions } const { conversation } = projectNativeChatTranscript( - projectStructuredAgentSessionMessages(items, [], submissions), + projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }), undefined, journal ) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index cadad1477265..90af918c9f45 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -325,8 +325,10 @@ describe('a Codex send its turn ended without taking it', () => { structuredAgentTurnAnchors(snapshot.items, snapshot.submissions).get(turnRecord!.itemId) ).toBe(agentJournalSubmissionKey(sent)) expect( - projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions).map( - (message) => message.blocks.map((block) => ('text' in block ? block.text : block.type)) + projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions, { + rejectedInPlace: true + }).map((message) => + message.blocks.map((block) => ('text' in block ? block.text : block.type)) ) ).not.toContainEqual([NATIVE_CHAT_STOPPED_BEFORE_START_TEXT]) @@ -718,7 +720,9 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { const snapshot = await host.journalSnapshot(SESSION) expect(turnRow(snapshot.items)).toMatchObject({ state: 'completed' }) // Every client draws the send where it was sent, then the one row saying it never started. - const drawn = projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions) + const drawn = projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions, { + rejectedInPlace: true + }) expect(drawn.slice(-2).map((message) => [message.role, message.blocks])).toEqual([ ['user', [{ type: 'text', text: 'look around' }]], ['system', [expect.objectContaining({ text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT })]] diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx index 0b66daad5e24..867714bec81c 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx @@ -107,7 +107,9 @@ function journalList( return ( { user(13, [{ type: 'text', text: 'never ran' }], agentJournalSubmissionKey('client-stopped')) ] const outline = projectAgentSessionConversationOutline(journal, [REJECTED, stopped]) - const loaded = loadedRailItems(journal, [REJECTED, stopped]) + // The rejected message is the desktop's own in-place row, as above. + const rejectedId = agentJournalSubmissionKey(REJECTED.clientMessageId) + const loaded = loadedRailItems(journal, [REJECTED, stopped]).filter( + (item) => item.id !== rejectedId + ) expect(outline.map((entry) => entry.itemId)).toEqual(loaded.map((item) => item.id)) expect(outline.map((entry) => entry.itemId)).not.toContain( agentJournalSubmissionKey('client-stopped') ) expect( - projectStructuredAgentSessionMessages(journal, [], [REJECTED, stopped]).map( + projectStructuredAgentSessionMessages(journal, [], [REJECTED, stopped], NO_CARDS).map( (message) => message.id ) ).toContain(agentJournalSubmissionKey('client-stopped')) diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts index cb4832ef603e..56cb26dd915d 100644 --- a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -36,6 +36,9 @@ function entry( return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } } +// A client that draws no other rejected send in place; a send a Stop took back is drawn either way. +const NO_IN_PLACE = { rejectedInPlace: false } + const said = (itemId: string, text: string, turnItemId: string) => entry( itemId, @@ -73,7 +76,7 @@ const stopped = (id: string, overrides: Partial = {}) => /** Each row as id and role, in the order the transcript draws them. */ function rows(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { return projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages(items, [], submissions) + projectStructuredAgentSessionMessages(items, [], submissions, NO_IN_PLACE) ).map((message) => ({ id: message.id, role: message.role })) } @@ -100,7 +103,12 @@ describe('a send a Stop took back before the agent started it', () => { user('never-ran'), stopRow('never-ran') ]) - const [, , row] = projectStructuredAgentSessionMessages(items, [], [stopped('never-ran')]) + const [, , row] = projectStructuredAgentSessionMessages( + items, + [], + [stopped('never-ran')], + NO_IN_PLACE + ) expect(row?.blocks).toEqual([ { type: 'text', @@ -109,7 +117,8 @@ describe('a send a Stop took back before the agent started it', () => { } ]) expect(row?.journalPosition).toEqual( - projectStructuredAgentSessionMessages(items, [], [stopped('never-ran')])[1]?.journalPosition + projectStructuredAgentSessionMessages(items, [], [stopped('never-ran')], NO_IN_PLACE)[1] + ?.journalPosition ) }) @@ -306,7 +315,7 @@ describe('a send a Stop took back before the agent started it', () => { ] const journal = { items, submissions } const { conversation } = projectNativeChatTranscript( - projectStructuredAgentSessionMessages(items, [], submissions), + projectStructuredAgentSessionMessages(items, [], submissions, NO_IN_PLACE), undefined, journal ) From 70d978bd773930c9bfcd55be5a376854b9ad6e30 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:50:14 -0700 Subject: [PATCH 144/182] refactor(native-chat): place a taken-back send by where the host now puts its row #24710 moves a rejected send's row, a Stop's take-back included, to the row that rejected it, in no turn. Draw it at that row, past the end of every turn opened before it, and keep the send-order rule. A send whose turn opened before the provider echoed it is claimed by that turn's record when it was sent before the record and taken back after it, by submittedSequence, and is drawn as the turn's opener. The steer branch goes: the host no longer says which turn a taken-back steer joined, so it is drawn after that turn with its own row. The older-host fallback (floor, clock compare, submittedAt order) is now gated on submittedSequence alone and marked Temporary. resolvedSequence is gone, as in #25073. --- .../journal-dispatch-reducer.ts | 5 - .../journal-submission-fold.ts | 1 - .../journal-submission-positions.test.ts | 75 ++------ src/shared/agent-session-journal-schemas.ts | 1 - src/shared/agent-session-journal-types.ts | 7 +- .../native-chat-stopped-before-start.ts | 76 ++++---- src/shared/native-chat-turn-membership.ts | 88 +++++----- ...ge-projection-stopped-before-start.test.ts | 165 ++++++++++++------ ...ctured-agent-session-message-projection.ts | 10 +- ...ubmission-positions-downgrade.unit.test.ts | 25 ++- 10 files changed, 232 insertions(+), 221 deletions(-) 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 5e4574b60ebf..c7c2a6716546 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 @@ -35,11 +35,6 @@ export function applyJournalDispatchRow( delete submission.rejection } submission.resolvedAt = row.state === 'pending' ? null : row.ts - if (row.state === 'pending') { - delete submission.resolvedSequence - } else { - submission.resolvedSequence = row.seq - } if (row.state === 'pending') { submission.handedOverAt = row.ts placeHandedOverMessage(state, submission, row) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index c04e354fb890..b00bdd36de24 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -113,7 +113,6 @@ export function acceptSubmissionFromProviderItem( submission.providerItemId = providerItemId submission.reason = null submission.resolvedAt = row.ts - submission.resolvedSequence = row.seq delete submission.recovered state.receipts.set(submission.clientMessageId, { clientMessageId: submission.clientMessageId, diff --git a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts index c2b6eacc5bbd..eac42c79113c 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts @@ -1,6 +1,6 @@ -// Each submission carries where the journal wrote its row and the row that resolved it, so a client -// can place a send by journal order instead of guessing from clocks. Derived on every fold: a -// replay of the same rows gives the same values, and a history page carries them. +// Each submission carries where the journal wrote its row. A rejected send's own row moves to its +// rejection, so this is the only journal-order record of where it was sent. Derived on every fold: a +// replay of the same rows gives the same value, and a history page carries it. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -8,12 +8,12 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalMessageItem, type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { readAgentSessionHydrationPage } from '../agent-session-wire/agent-session-history-page' import { createTrackedJournalOpener } from './journal-host-database-test-support' @@ -69,78 +69,39 @@ async function sendHandedOverThenWithdrawn() { return { journal, submitted, handedOver, pending, withdrawn } } -describe("a submission's journal positions", () => { - it('are its own row and the row that resolved it, with none while it is pending', async () => { +describe("a submission's journal position", () => { + it('is its own row, which a take-back does not move', async () => { const { journal, submitted, handedOver, pending, withdrawn } = await sendHandedOverThenWithdrawn() expect(pending).toMatchObject({ submittedSequence: submitted.sequence }) - expect(pending).not.toHaveProperty('resolvedSequence') expect(handedOver.sequence).toBeGreaterThan(submitted.sequence) expect(journal.submission('send-1')).toMatchObject({ dispatchState: 'rejected', - submittedSequence: submitted.sequence, - resolvedSequence: withdrawn.sequence + submittedSequence: submitted.sequence }) + // Anti-vacuous: the send's own row moved to its rejection, so only this field says where it was sent. + expect( + journal.snapshot().items.find((item) => item.itemId === agentJournalSubmissionKey('send-1')) + ?.sequence + ).toBe(withdrawn.sequence) }) - it('come back the same from a replay of the stored rows', async () => { - const { journal, submitted, withdrawn } = await sendHandedOverThenWithdrawn() + it('comes back the same from a replay of the stored rows', async () => { + const { journal, submitted } = await sendHandedOverThenWithdrawn() await journals.closeAll() const replayed = await journals.open({ identity: IDENTITY, stateDirectory: root! }) - expect(replayed.submission('send-1')).toMatchObject({ - submittedSequence: submitted.sequence, - resolvedSequence: withdrawn.sequence - }) + expect(replayed.submission('send-1')).toMatchObject({ submittedSequence: submitted.sequence }) expect(journal).not.toBe(replayed) }) - it("move to the provider's echo when that is what accepts a send left in doubt", async () => { - root = await mkdtemp(join(tmpdir(), 'orca-submission-positions-')) - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const submitted = await journal.appendSubmission({ - clientMessageId: 'send-1', - payloadFingerprint: structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: IDENTITY.sessionId, - fields: { body: BODY } - }), - body: BODY, - fence: 1 - }) - const doubted = await journal.resolveDispatch({ - clientMessageId: 'send-1', - state: 'unknown', - reason: 'host_restarted', - fence: 1, - recovered: true - }) - - const echo = await journal.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, - BODY, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - - expect(echo.cursor.sequence).toBeGreaterThan(doubted.sequence) - expect(journal.submission('send-1')).toMatchObject({ - dispatchState: 'accepted', - submittedSequence: submitted.sequence, - resolvedSequence: echo.cursor.sequence - }) - }) - - it('reach a client on the history page', async () => { - const { journal, submitted, withdrawn } = await sendHandedOverThenWithdrawn() + it('reaches a client on the history page', async () => { + const { journal, submitted } = await sendHandedOverThenWithdrawn() expect(readAgentSessionHydrationPage(journal).submissions).toEqual([ - expect.objectContaining({ - clientMessageId: 'send-1', - submittedSequence: submitted.sequence, - resolvedSequence: withdrawn.sequence - }) + expect.objectContaining({ clientMessageId: 'send-1', submittedSequence: submitted.sequence }) ]) }) }) diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index cf5e6ba6cf66..8a7a51493675 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -319,7 +319,6 @@ export const AgentJournalSubmissionSchema = z.object({ submittedAt: z.number(), resolvedAt: z.number().nullable(), submittedSequence: z.number().int().optional(), - resolvedSequence: z.number().int().optional(), recovered: z.literal(true).optional(), handoverRecorded: z.literal(true).optional(), handedOverAt: z.number().optional(), diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 258636297900..28e470cb869e 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -412,12 +412,9 @@ export type AgentJournalSubmission = { submittedAt: number resolvedAt: number | null /** Where the journal wrote this submission's row: its sequence, recomputed on every fold and - * never stored. Absent from hosts that predate it. */ + * never stored. Needed because a rejected send's own row moves to its rejection, which erases + * where it was sent. Absent from hosts that predate it. */ submittedSequence?: number - /** Where the journal wrote the row that resolved it, in the same order: its dispatch row, or the - * provider's echo (an item or lifecycle-batch row) when that accepted it. Absent while it is - * pending, and from hosts that predate it. */ - resolvedSequence?: number /** Set when crash reconciliation resolved the dispatch, not the provider. A live * `unknown` is a send still outstanding; a recovered one outlived its writer. */ recovered?: true diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts index d959fd09f043..5188a5e7f836 100644 --- a/src/shared/native-chat-stopped-before-start.ts +++ b/src/shared/native-chat-stopped-before-start.ts @@ -26,65 +26,65 @@ export function isStoppedBeforeStartBlock(block: NativeChatBlock): boolean { ) } -/** Where the sends a Stop took back are drawn, worked out once per projection. */ -export type StoppedSendPlacement = { - /** Whether it stays in a turn: one opened for it, or one it was steered into. That turn's - * interrupted end is its stop. */ - staysInTurn(itemId: string): boolean - /** Where it is drawn when that is not its own row. */ - movedTo(itemId: string): AgentJournalPosition | undefined -} +/** Where a send a Stop took back is drawn. `opensTurn`: a turn opened for it, whose interrupted + * end is its stop; `position`: where it is drawn, when that is not its own row. */ +export type StoppedSendPlace = { opensTurn: boolean; position?: AgentJournalPosition } /** - * A send a Stop took back (`stopped`, by item id) is drawn at the journal row that took it back - * (`resolvedSequence`), past the end of every turn whose opener the journal wrote before that row. + * A send a Stop took back (`stopped`, by item id) is drawn at its own row, past the end of every + * turn whose opener the journal wrote before it: a host that publishes `submittedSequence` puts + * that row where the send was taken back. One that opened a turn is drawn as that turn's opener. * Journal order only, never a clock. - * Temporary, until a host version floor: a host that predates `resolvedSequence` keeps the earlier - * rule, from the later of its own row and just after `latestRowsSentBefore`. + * Temporary: a host that predates `submittedSequence` leaves the row where it was sent, so the send + * is also drawn below the latest row sent before it (`latestRowsSentBefore`); dropped once no + * supported remote host lacks the field. */ export function placeStoppedSends( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[], stopped: ReadonlyMap -): StoppedSendPlacement { +): (itemId: string) => StoppedSendPlace { const anchors = structuredAgentTurnAnchors(items, submissions) - const anchored = new Set(anchors.values()) + const turnOpenedBy = new Map([...anchors].map(([turnItemId, anchorId]) => [anchorId, turnItemId])) const itemsById = new Map(items.map((item) => [item.itemId, item])) const pastTurnsOpenedBefore = turnEndsByOpener(items, anchors, itemsById) const sentBefore = [...stopped.values()].some( - (submission) => submission.resolvedSequence === undefined + (submission) => submission.submittedSequence === undefined ) ? latestRowsSentBefore(submissions, itemsById) : undefined - return { - staysInTurn: (itemId) => - anchored.has(itemId) || itemsById.get(itemId)?.turnScope?.kind === 'turn', - movedTo: (itemId) => { - const item = itemsById.get(itemId) - if (!item) { - return undefined - } - const own = agentJournalItemPosition(item) - const resolvedSequence = stopped.get(itemId)?.resolvedSequence - const sentBeforeRow = sentBefore?.get(itemId) - const floor = sentBeforeRow ? agentJournalItemPosition(sentBeforeRow) : undefined - // Just after the floor row, so a turn that row opened counts as waited on. - const from = - resolvedSequence !== undefined - ? { sequence: resolvedSequence, index: 0 } - : floor && compareAgentJournalPositions(floor, own) > 0 - ? { sequence: floor.sequence, index: floor.index + 0.5 } - : own - const position = pastTurnsOpenedBefore(from) - return compareAgentJournalPositions(position, own) !== 0 ? position : undefined + return (itemId) => { + const item = itemsById.get(itemId) + if (!item) { + return { opensTurn: false } + } + const own = agentJournalItemPosition(item) + const record = itemsById.get(turnOpenedBy.get(itemId) ?? '') + if (record) { + // Drawn where its turn opened, as an opener is. + const opened = agentJournalItemPosition(record) + return compareAgentJournalPositions(own, opened) > 0 + ? { opensTurn: true, position: { sequence: opened.sequence, index: opened.index - 0.5 } } + : { opensTurn: true } } + const floorRow = sentBefore?.get(itemId) + const floor = floorRow ? agentJournalItemPosition(floorRow) : undefined + // Just after the floor row, so a turn that row opened counts as waited on. + const from = + floor && compareAgentJournalPositions(floor, own) > 0 + ? { sequence: floor.sequence, index: floor.index + 0.5 } + : own + const position = pastTurnsOpenedBefore(from) + return compareAgentJournalPositions(position, own) !== 0 + ? { opensTurn: false, position } + : { opensTurn: false } } } /** * For a point in the journal, just after the furthest row of every turn whose opener comes before * it, or the point itself when none reaches past it. One pass over the items, then a binary search - * per point. A send placed here is never a turn's opener (`staysInTurn`), so no turn is its own. + * per point. A send placed here opened no turn, so no turn is its own. */ function turnEndsByOpener( items: readonly AgentJournalRenderItem[], @@ -141,7 +141,7 @@ function turnEndsByOpener( /** For each submission, the latest loaded row of the ones sent before it, in `submittedAt` order * with ties kept in list order as the client reducer keeps them. Temporary: read only for a host - * that predates `resolvedSequence`. */ + * that predates `submittedSequence`. */ function latestRowsSentBefore( submissions: readonly AgentJournalSubmission[], itemsById: ReadonlyMap diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index 5048b9549643..3fdbef99ea24 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -71,77 +71,85 @@ export function structuredAgentTurnAnchors( .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) // A send a Stop took back after its turn opened but before the provider echoed it: the record - // still names the provider's key, and the send is no longer in flight. It opened the turn whose - // record the journal wrote before the row that took it back. - const stoppedBeforeEcho = new Map( - submissions.flatMap((submission) => + // still names the provider's key. It opened the first such record the journal wrote after it was + // sent and before it was taken back, in send order. + const takenBack = submissions.filter( + (submission) => !submission.providerItemId && submission.queuedMessageId === undefined && submission.resolvedAt !== null && dispatchWasWithdrawn(submission) - ? [[agentJournalSubmissionKey(submission.clientMessageId), submission] as const] - : [] - ) ) + const itemsById = + takenBack.length > 0 ? new Map(items.map((item) => [item.itemId, item])) : undefined + const stoppedBeforeEcho = takenBack + .flatMap((submission) => { + const sent = itemsById?.get(agentJournalSubmissionKey(submission.clientMessageId)) + return sent ? [{ sent, submission }] : [] + }) + .sort( + (left, right) => + (left.submission.submittedSequence ?? left.submission.submittedAt) - + (right.submission.submittedSequence ?? right.submission.submittedAt) + ) + const claimed = new Set() const anchors = new Map() let precedingUserItemId: string | null = null let inFlightSinceLastTurn: string | null = null - let stoppedSinceLastTurn: { itemId: string; submission: AgentJournalSubmission } | null = null for (const item of items) { if (userItemIds.has(item.itemId)) { precedingUserItemId = item.itemId if (inFlightSinceLastTurn === null && inFlight.has(item.itemId)) { inFlightSinceLastTurn = item.itemId } - const stopped = stoppedBeforeEcho.get(item.itemId) - if (stoppedSinceLastTurn === null && stopped !== undefined) { - stoppedSinceLastTurn = { itemId: item.itemId, submission: stopped } - } continue } const turn = readAgentJournalTurn(item.body) if (!turn || !isRootAgentJournalItem(item)) { continue } - const stoppedOpener = - stoppedSinceLastTurn !== null && - openedBeforeTakenBack(item, turn, stoppedSinceLastTurn.submission) - ? stoppedSinceLastTurn.itemId - : null - anchors.set( + let anchor = anchorOf( item.itemId, - anchorOf( - item.itemId, - turn, - userItemIds, - aliases, - precedingUserItemId, - inFlightSinceLastTurn ?? stoppedOpener - ) + turn, + userItemIds, + aliases, + precedingUserItemId, + inFlightSinceLastTurn ) + if (anchor === item.itemId && turn.userItemId !== undefined) { + const opener = stoppedBeforeEcho.find( + ({ sent, submission }) => + !claimed.has(sent.itemId) && sentBeforeAndTakenBackAfter(item, turn, submission, sent) + ) + if (opener) { + claimed.add(opener.sent.itemId) + anchor = opener.sent.itemId + } + } + anchors.set(item.itemId, anchor) inFlightSinceLastTurn = null - stoppedSinceLastTurn = null } return anchors } -/** Whether a turn record came before the row that took a send back: by journal order where the - * host publishes that row's place. Temporary: a host that predates `resolvedSequence` is read by - * times, which a resume rewrites to whole seconds; dropped once every supported remote host - * publishes it. */ -function openedBeforeTakenBack( +/** Whether a send was sent before a turn record and taken back after it, by journal order: a host + * that publishes `submittedSequence` also moves a rejected send's row to the row that rejected it. + * Temporary: an older host is read by its row's place and by times, which a resume rewrites to + * whole seconds; dropped once no supported remote host lacks `submittedSequence`. */ +function sentBeforeAndTakenBackAfter( record: AgentJournalRenderItem, turn: AgentJournalTurnLifecycle, - submission: Pick + submission: AgentJournalSubmission, + sent: AgentJournalRenderItem ): boolean { - if (submission.resolvedSequence !== undefined) { - return record.sequence < submission.resolvedSequence + if (submission.submittedSequence !== undefined) { + return submission.submittedSequence < record.sequence && record.sequence < sent.sequence } - return ( - turn.startedAt !== undefined && - submission.resolvedAt !== null && - turn.startedAt <= submission.resolvedAt - ) + if (turn.startedAt === undefined || submission.resolvedAt === null) { + return false + } + const sentBefore = sent.sequence < record.sequence || submission.submittedAt <= turn.startedAt + return sentBefore && turn.startedAt <= submission.resolvedAt } function anchorOf( diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts index 56cb26dd915d..6b5c3b61f958 100644 --- a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -86,13 +86,36 @@ const stopRow = (id: string) => ({ role: 'system' }) -/** A host that publishes journal positions, and one that predates them (the Temporary fallback). */ +/** A host that publishes where each send was sent, and one that predates it (the Temporary + * fallback). */ const HOSTS = [ - ['a host that publishes positions', true], + ['a host that publishes where it was sent', true], ['an older host', false] ] as const -const publishedPosition = (published: boolean, resolvedSequence: number) => - published ? { resolvedSequence } : {} + +/** A host that publishes `submittedSequence` also moves a taken-back send's row to the row that + * took it back (`at`), in no turn; an older host leaves the row where it was sent. */ +function takenBackOn( + published: boolean, + items: AgentJournalRenderItem[], + id: string, + at: number +): { items: AgentJournalRenderItem[]; sent: Partial } { + const key = agentJournalSubmissionKey(id) + const row = items.find((item) => item.itemId === key)! + if (!published) { + return { items, sent: {} } + } + const moved = items.map((item) => + item.itemId === key + ? { ...item, sequence: at, observedAt: at, turnScope: { kind: 'thread' as const } } + : item + ) + return { + items: moved.sort((left, right) => left.sequence - right.sequence), + sent: { submittedSequence: row.sequence } + } +} describe('a send a Stop took back before the agent started it', () => { it('stays where it was sent, with one row after it', () => { @@ -174,33 +197,50 @@ describe('a send a Stop took back before the agent started it', () => { expect(rows(items, [stopped('opened', { resolvedAt: 10 })])).toEqual([user('opened')]) }) - // A steer joins the running turn, whose interrupted end already says the Stop took it. - it('stays in the turn it was steered into, with no row of its own', () => { - const items = [ - sent('first', 'look around'), - entry('t1', { - kind: 'turn', - turnId: 't1', - state: 'interrupted', - outcome: 'cancellation', - userItemId: agentJournalSubmissionKey('first') - }), - entry( - agentJournalSubmissionKey('steer'), - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'and check the tests' }] }, - { kind: 'turn', turnItemId: 't1' } - ) - ] + // A steer joined the running turn, but the host takes a rejected send out of its turn, so no + // published fact says which turn it joined: it is drawn after that turn, with its own row. + it.each(HOSTS)( + 'draws a steer it took back after the turn it joined, with its own row, on %s', + (_host, published) => { + const steered = [ + sent('first', 'look around'), + entry('t1', { + kind: 'turn', + turnId: 't1', + state: 'interrupted', + outcome: 'cancellation', + userItemId: agentJournalSubmissionKey('first') + }), + entry( + agentJournalSubmissionKey('steer'), + { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'and check the tests' }] + }, + { kind: 'turn', turnItemId: 't1' } + ), + entry( + 'stop:t1', + { kind: 'status', text: 'Cancellation requested.' }, + { kind: 'turn', turnItemId: 't1' } + ) + ] + const { items, sent: steer } = takenBackOn(published, steered, 'steer', sequence + 1) - expect(rows(items, [submission('first'), stopped('steer')])).toEqual([ - user('first'), - user('steer') - ]) - }) + expect(rows(items, [submission('first'), stopped('steer', steer)])).toEqual([ + user('first'), + { id: 'stop:t1', role: 'system' }, + user('steer'), + stopRow('steer') + ]) + } + ) - // Where the host publishes the row that took it back, journal order decides, not start times. - it('stays in a turn whose record came before the row that took it back', () => { - const items = [ + // The host moved the send's row past its turn's record to the row that took it back; journal + // order decides, not start times, and it is still that turn's opener. + it('stays in the turn it opened when its row moved past that turn to the take-back', () => { + const opened = [ sent('opened', 'look around'), entry('turn-1', { kind: 'turn', @@ -208,21 +248,28 @@ describe('a send a Stop took back before the agent started it', () => { state: 'interrupted', outcome: 'cancellation', userItemId: 'codex:thread-1:turn-1:0' - }) + }), + entry( + 'stop:turn-1', + { kind: 'status', text: 'Cancellation requested.' }, + { kind: 'turn', turnItemId: 'turn-1' } + ) ] + const { items, sent: position } = takenBackOn(true, opened, 'opened', sequence + 1) - expect(rows(items, [stopped('opened', { resolvedSequence: sequence + 1 })])).toEqual([ - user('opened') + expect(rows(items, [stopped('opened', position)])).toEqual([ + user('opened'), + { id: 'stop:turn-1', role: 'system' } ]) }) // A turn restored after a restart, whose start reads as before the Stop in whole seconds, but // whose record the journal wrote after the row that took the send back. it('keeps its row when the turn record came after the row that took it back', () => { - const items = [sent('never-ran', 'look around')] + const before = [sent('never-ran', 'look around')] sequence += 1 const takenBackAt = sequence - items.push( + before.push( entry('restored', { kind: 'turn', turnId: 'turn-9', @@ -231,10 +278,12 @@ describe('a send a Stop took back before the agent started it', () => { startedAt: 5 }) ) + const { items, sent: position } = takenBackOn(true, before, 'never-ran', takenBackAt) - expect( - rows(items, [stopped('never-ran', { resolvedAt: 10, resolvedSequence: takenBackAt })]) - ).toEqual([user('never-ran'), stopRow('never-ran')]) + expect(rows(items, [stopped('never-ran', { resolvedAt: 10, ...position })])).toEqual([ + user('never-ran'), + stopRow('never-ran') + ]) }) it('keeps its row when a turn starts only after the Stop took it back', () => { @@ -364,7 +413,7 @@ describe('a send a Stop took back before the agent started it', () => { // The host's clock stepped back between the two accepts; the journal's order still holds. it('keeps two sends in journal order where the host publishes it, whatever the clock says', () => { const compactTurn = 'compact-turn' - const items = [ + const queued = [ sent('compact', '/compact'), entry(compactTurn, { kind: 'turn', @@ -376,25 +425,26 @@ describe('a send a Stop took back before the agent started it', () => { said('compacted', 'Context compacted', compactTurn), sent('queued-first', 'queued first') ] - const accepted = items.find( + const accepted = queued.find( (item) => item.itemId === agentJournalSubmissionKey('queued-second') )!.sequence + // Taken back in the opposite order: the second's row lands first. + const second = takenBackOn(true, queued, 'queued-second', sequence + 1) + const first = takenBackOn(true, second.items, 'queued-first', sequence + 2) expect( - rows(items, [ + rows(first.items, [ submission('compact', { submittedAt: 1 }), stopped('queued-first', { submittedAt: 9, submittedSequence: accepted - 1, handoverRecorded: true, - handedOverAt: 5, - resolvedSequence: sequence + 2 + handedOverAt: 5 }), stopped('queued-second', { submittedAt: 3, submittedSequence: accepted, - handoverRecorded: true, - resolvedSequence: sequence + 1 + handoverRecorded: true }) ]) ).toEqual([ @@ -433,8 +483,10 @@ describe('a send a Stop took back before the agent started it', () => { said('answer', 'answer to zero', 'turn-2') ] + const host = takenBackOn(published, items, 'queued-b', sequence + 1) + expect( - rows(items, [ + rows(host.items, [ submission('compact', { submittedAt: 1 }), submission('queued-zero', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), submission('queued-a', { @@ -446,7 +498,7 @@ describe('a send a Stop took back before the agent started it', () => { stopped('queued-b', { submittedAt: 4, handoverRecorded: true, - ...publishedPosition(published, sequence + 1) + ...host.sent }) ]) ).toEqual([ @@ -503,14 +555,16 @@ describe('a send a Stop took back before the agent started it', () => { (_host, published) => { const { items, takenBackAt } = streamingTurnThenStop() + const host = takenBackOn(published, items, 'queued-second', takenBackAt) + expect( - rows(items, [ + rows(host.items, [ submission('compact', { submittedAt: 1 }), submission('queued-first', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), stopped('queued-second', { submittedAt: 3, handoverRecorded: true, - ...publishedPosition(published, takenBackAt) + ...host.sent }) ]) ).toEqual([ @@ -551,14 +605,16 @@ describe('a send a Stop took back before the agent started it', () => { said('answer', 'answer to zero', 'turn-2') ] + const host = takenBackOn(published, items, 'queued-b', sequence + 1) + expect( - rows(items, [ + rows(host.items, [ submission('compact', { submittedAt: 1 }), submission('queued-zero', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), stopped('queued-b', { submittedAt: 4, handoverRecorded: true, - ...publishedPosition(published, sequence + 1) + ...host.sent }) ]) ).toEqual([ @@ -573,8 +629,8 @@ describe('a send a Stop took back before the agent started it', () => { ) // Accept times tie at the clock's resolution, and the client can hold the stopped send first: - // only the published row says which exchange came before the Stop. - it('stays below a finished exchange when accept times tie, by the row the host publishes', () => { + // only its row, which the host moved to the take-back, says which exchange came before the Stop. + it('stays below a finished exchange when accept times tie, by the row the host moved', () => { const compactTurn = 'compact-turn' const items = [ sent('compact', '/compact'), @@ -596,9 +652,10 @@ describe('a send a Stop took back before the agent started it', () => { said('answer', 'answer to zero', 'turn-2') ] + const host = takenBackOn(true, items, 'queued-b', sequence + 1) expect( - rows(items, [ - stopped('queued-b', { handoverRecorded: true, resolvedSequence: sequence + 1 }), + rows(host.items, [ + stopped('queued-b', { handoverRecorded: true, ...host.sent }), submission('compact'), submission('queued-zero', { handoverRecorded: true, handedOverAt: 5 }) ]) diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 91a215a56ea1..3bb11a52bac6 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -145,13 +145,13 @@ export function projectStructuredAgentSessionMessages( held.push({ ...message, queued: true }) } else if (!placement || !stoppedBeforeStart.has(message.id)) { delivered.push(message) - } else if (placement.staysInTurn(message.id)) { - // A turn opened for it, or it joined one (a steer): that turn's interrupted end is its stop. - delivered.push({ ...message, stoppedBeforeStart: true }) } else { - const position = placement.movedTo(message.id) + const { opensTurn, position } = placement(message.id) moved ||= position !== undefined - shownStopped.add(message.id) + // A turn opened for it: that turn's interrupted end is its stop, so it gets no row of its own. + if (!opensTurn) { + shownStopped.add(message.id) + } delivered.push({ ...message, stoppedBeforeStart: true, diff --git a/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts index 75a4b4fcaaa7..ef5d1eba2ec7 100644 --- a/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts @@ -10,8 +10,8 @@ import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent- import { readAgentSessionHydrationPage } from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' -// A released client that predates the published submission positions: it must fold and draw a -// page carrying them exactly as it draws the same page without them. +// A released client that predates the published submission position: it must fold and draw a page +// carrying it exactly as it draws the same page without it. const BASELINE_REF = 'v1.4.219' const IDENTITY: AgentSessionJournalIdentity = { @@ -34,18 +34,16 @@ function releaseExport(module: Record, name: string): T { type OldState = { submissions: Record[]; items: unknown[] } -/** Strips the published positions, as an older host's page would carry its submissions. */ -function withoutPositions(page: AgentSessionHistoryPage): AgentSessionHistoryPage { +/** Strips the published position, as an older host's page would carry its submissions. */ +function withoutPosition(page: AgentSessionHistoryPage): AgentSessionHistoryPage { return { ...page, - submissions: page.submissions.map( - ({ submittedSequence: _submitted, resolvedSequence: _resolved, ...rest }) => rest - ) + submissions: page.submissions.map(({ submittedSequence: _submitted, ...rest }) => rest) } } // Loads a real release checkout, cold extraction and transforms included. -test('a released client folds and draws a page whose submissions carry their journal positions', async () => { +test('a released client folds and draws a page whose submissions carry their journal position', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-submission-positions-downgrade-')) const journals = createTrackedJournalOpener() try { @@ -67,11 +65,10 @@ test('a released client folds and draws a page whose submissions carry their jou recovered: true }) const page = readAgentSessionHydrationPage(journal, 1) - // Anti-vacuous: the page this build publishes does carry both positions. + // Anti-vacuous: the page this build publishes does carry the position. expect(page.submissions.find((entry) => entry.clientMessageId === 'taken-back')).toEqual( expect.objectContaining({ - submittedSequence: expect.any(Number), - resolvedSequence: expect.any(Number) + submittedSequence: expect.any(Number) }) ) @@ -95,14 +92,12 @@ test('a released client folds and draws a page whose submissions carry their jou const draw = (from: AgentSessionHistoryPage) => { const state = reduce(empty, { type: 'history-page', page: from }) return { - submissions: state.submissions.map( - ({ submittedSequence: _s, resolvedSequence: _r, ...rest }) => rest - ), + submissions: state.submissions.map(({ submittedSequence: _s, ...rest }) => rest), messages: project(state.items, [], state.submissions) } } - expect(draw(page)).toEqual(draw(withoutPositions(page))) + expect(draw(page)).toEqual(draw(withoutPosition(page))) } finally { await journals.closeAll() rmSync(directory, { recursive: true, force: true }) From ab8ffbd98f5475f077f565b011cd9adef5afc786 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:04:25 -0700 Subject: [PATCH 145/182] test(native-chat): a turn recorded before a send was made is not the turn it opened --- ...ge-projection-stopped-before-start.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts index 6b5c3b61f958..f93f01050685 100644 --- a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -286,6 +286,26 @@ describe('a send a Stop took back before the agent started it', () => { ]) }) + // A turn the provider opened on its own, recorded before the send was made: the send did not + // open it, though the take-back moved the send's row past its record. + it('keeps its row when the turn record came before it was sent', () => { + const before = [ + entry('wake', { + kind: 'turn', + turnId: 'wake', + state: 'completed', + userItemId: 'codex:thread-1:wake:0' + }), + sent('never-ran', 'look around') + ] + const { items, sent: position } = takenBackOn(true, before, 'never-ran', sequence + 1) + + expect(rows(items, [stopped('never-ran', position)])).toEqual([ + user('never-ran'), + stopRow('never-ran') + ]) + }) + it('keeps its row when a turn starts only after the Stop took it back', () => { const items = [ sent('never-ran', 'look around'), From c04cf660f5beb771ae3c323b006d54207ff0d036 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:14:14 -0700 Subject: [PATCH 146/182] test(native-chat): a message a Stop withdrew is drawn with its stop row, not hidden #24710's tests pinned a withdrawn send as hidden, the rule this PR replaces. Each keeps what it is about: a withdrawn copy does not hide a failed one, no delivery notice is raised, and a message the host failed to deliver stays hidden on the phone. --- ...ssage-projection.rejected-in-place.test.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index 4e7577052256..d1e9dea27324 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -125,6 +125,8 @@ function rows(messages: ReturnType } const SEED = submission('seed', 'seed', 1) +// The one row drawn after a send a Stop took back before the agent started it. +const STOPPED_ROW = `stopped-before-start:${agentJournalSubmissionKey('stopped')}` const SEED_ROWS = [userItem('seed', 1, 'seed'), answer(2)] describe('a message the host accepted and then rejected, on the desktop', () => { @@ -264,11 +266,14 @@ describe('a message the host accepted and then rejected, on the desktop', () => withdrawn('stopped', 'again', 5) ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + const messages = projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS) + expect(rows(messages)).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('first'), text: 'again', unsent: false }, + { id: agentJournalSubmissionKey('stopped'), text: 'again', unsent: false }, { id: agentJournalSubmissionKey('failed'), text: 'again', unsent: true } ]) + expect(messages.map((message) => message.id)).toContain(STOPPED_ROW) }) // Its own reply reports the rejection, in the composer, as a command's. @@ -299,13 +304,16 @@ describe('a message the host accepted and then rejected, on the desktop', () => ).toBe(0) }) - it('keeps a message a Stop withdrew hidden: it went back to its sender', () => { + it('draws a message a Stop withdrew, with its stop row, and no delivery notice', () => { const items = [...SEED_ROWS, userItem('stopped', 3, 'never mind')] const submissions = [SEED, withdrawn('stopped', 'never mind', 3)] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ - { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } + const messages = projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS) + expect(rows(messages)).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('stopped'), text: 'never mind', unsent: false } ]) + expect(messages.map((message) => message.id)).toContain(STOPPED_ROW) expect( structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size ).toBe(0) @@ -446,7 +454,7 @@ describe('a rejected message the queue holds', () => { }) describe('the phone', () => { - it('still hides every accepted-then-rejected message: it gives the text back to its composer', () => { + it('still hides a message the host failed to deliver, but draws one a Stop withdrew', () => { const items = [ ...SEED_ROWS, userItem('lost', 3, 'fix the parser'), @@ -458,8 +466,11 @@ describe('the phone', () => { withdrawn('stopped', 'never mind', 4) ] - expect(rows(projectShared(items, [], submissions, { rejectedInPlace: false }))).toEqual([ - { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } + const messages = projectShared(items, [], submissions, { rejectedInPlace: false }) + expect(rows(messages)).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('stopped'), text: 'never mind', unsent: false } ]) + expect(messages.map((message) => message.id)).toContain(STOPPED_ROW) }) }) From eb76da2c962eee8061713c2c32a16dd24b8d5a36 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:22:51 -0700 Subject: [PATCH 147/182] fix(native-chat): a retried message no longer waits behind a later Stop Retry dropped the Stop it had outlived but kept the mark that it was sent while a Stop was ending a turn, so a retried message waited behind whatever later, unrelated turn a Stop was ending. Retry is a new send: drop that mark too. --- ...uctured-agent-session-outbox-retry.test.ts | 80 +++++++++++++++++++ .../structured-agent-session-outbox-retry.ts | 4 +- 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.test.ts diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.test.ts new file mode 100644 index 000000000000..08bb1fa0c751 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom + +// A send made while a Stop was ending a turn waits behind that turn. Retrying it later is a new +// send: it must not wait behind some later, unrelated turn a Stop is ending. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_WRITE_FAILED } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { retryStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-retry' +import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage' + +const SESSION = 'session-1' + +beforeEach(() => localStorage.clear()) +afterEach(() => localStorage.clear()) + +function sentWhileStopping(state: 'unconfirmed' | 'rejected') { + writeOutbox(SESSION, [ + { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'held', + sessionId: SESSION, + text: 'follow-up', + attachments: [], + queuedAt: 1 + }), + lastAttemptAt: 5, + outlivedStop: true, + sentWhileStopping: true, + state + } + ]) +} + +function retry(submissions: AgentJournalSubmission[] = []): void { + retryStructuredAgentSessionOutboxEntry({ + clientMessageId: 'held', + sessionId: SESSION, + submissions, + setError: vi.fn(), + createOperationId: () => 'retried' + }) +} + +describe('Retry on a send made while a Stop was ending a turn', () => { + it('sends it as a new send, no longer waiting behind a Stop', () => { + sentWhileStopping('unconfirmed') + + retry() + + expect(readOutbox(SESSION)).toEqual([ + expect.objectContaining({ clientMessageId: 'held', state: 'queued' }) + ]) + expect(readOutbox(SESSION)[0]).not.toHaveProperty('sentWhileStopping') + expect(readOutbox(SESSION)[0]).not.toHaveProperty('outlivedStop') + }) + + it('does the same when the Retry rotates the id of a send the host refused', () => { + sentWhileStopping('rejected') + + retry([ + { + clientMessageId: 'held', + fence: 1, + payloadFingerprint: 'held', + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_WRITE_FAILED, + submittedAt: 1, + resolvedAt: 2 + } + ]) + + expect(readOutbox(SESSION)).toEqual([ + expect.objectContaining({ clientMessageId: 'retried', state: 'queued' }) + ]) + expect(readOutbox(SESSION)[0]).not.toHaveProperty('sentWhileStopping') + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts index d431cccb0363..b61f17dfe9ba 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts @@ -74,10 +74,12 @@ export function retryStructuredAgentSessionOutboxEntry(args: { } } -/** The user's own Retry is what a Stop, or a failure saved on the message, left it waiting for. */ +/** The user's own Retry is what a Stop, or a failure saved on the message, left it waiting for. It + * is a new send, so it no longer waits behind the turn a Stop was ending when it was first sent. */ function retriedByUser({ outlivedStop: _retried, lastFailure: _sentAgain, + sentWhileStopping: _sentNow, ...entry }: StructuredAgentSessionOutboxEntry): StructuredAgentSessionOutboxEntry { return entry From aaf132e1bf55e434814d5c601c9cc26f847a2841 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:52:38 -0700 Subject: [PATCH 148/182] feat(native-chat): publish the turn a withdrawn Codex send was answered into Same change as #25073 at 93749070e48b, so this stack builds on it. --- .../codex/codex-structured-session-adapter.ts | 2 +- .../codex/codex-structured-session-state.ts | 5 +- ...dex-structured-turn-end-settlement.test.ts | 85 ++++++++++++++++++- .../codex-structured-turn-end-settlement.ts | 20 +++-- src/main/codex/codex-structured-turn-start.ts | 16 +++- .../journal-dispatch-reducer.ts | 9 ++ .../journal-row-builders.ts | 3 + .../journal-row-schema.ts | 4 + .../journal-store-contracts.ts | 5 +- .../journal-submission-positions.test.ts | 63 +++++++++++++- .../structured-agent-session-adapter.ts | 8 +- .../structured-agent-session-late-dispatch.ts | 6 +- .../structured-agent-session-turns.ts | 1 + ...-session-codex-turn-end-settlement.test.ts | 73 ++++++++++++++++ src/shared/agent-session-journal-schemas.ts | 1 + src/shared/agent-session-journal-types.ts | 4 + ...ubmission-positions-downgrade.unit.test.ts | 40 +++++++-- 17 files changed, 319 insertions(+), 26 deletions(-) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 13a687a72cdc..1184ada8c741 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -132,7 +132,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap } if (event.type === 'notification') { // Only an admitted turn end settles; a refused one settles on the retry that lands. - settleCodexSendsInEndedTurn(session, event.method, event.params, (settlement) => + settleCodexSendsInEndedTurn(session, event, (settlement) => this.deps.onDispatchSettledLate?.({ sessionId: event.sessionId, ...settlement }) ) // After the admission check, so a refused frame is observed by the child records diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 022628e216c6..107a4cc52cdb 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -84,7 +84,10 @@ export type CodexStructuredSessionAdapterDeps = { onDispatchSettledLate?: ( input: { sessionId: string; clientMessageId: string } & ( | { providerIdentity: AgentJournalItemIdentity } - | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | ({ + state: 'rejected' + answeredInTurn: AgentJournalItemIdentity + } & AgentJournalDispatchRejection) ) ) => void /** Codex reported its thread not running with no turn open: a send whose diff --git a/src/main/codex/codex-structured-turn-end-settlement.test.ts b/src/main/codex/codex-structured-turn-end-settlement.test.ts index 38cc648f79d9..ccb9fa7ba456 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.test.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { @@ -20,11 +24,17 @@ async function turnEndRig() { Object.assign(codex.routes, turns.routes) const settlements: LateSettlement[] = [] const bodies: AgentJournalItemBody[] = [] + const turnRecords: AgentJournalItemIdentity[] = [] const adapter = await acquiredCodexAdapter({ codex, settlements, sink: { - appendItem: (_identity, body) => bodies.push(body), + appendItem: (identity, body) => { + bodies.push(body) + if (body.kind === 'turn') { + turnRecords.push(identity) + } + }, appendTombstone: () => {}, publish: () => {} } @@ -46,7 +56,18 @@ async function turnEndRig() { const settledIds = () => settlements.map(({ clientMessageId }) => clientMessageId) const categoryOf = (settlement: LateSettlement | undefined) => settlement && 'state' in settlement ? classifyDispatchRejection(settlement).category : null - return { codex, turns, adapter, send, sendAndOpen, settlements, settledIds, categoryOf, bodies } + return { + codex, + turns, + adapter, + send, + sendAndOpen, + settlements, + settledIds, + categoryOf, + bodies, + turnRecords + } } describe('a Codex send its turn ended without echoing', () => { @@ -286,6 +307,64 @@ describe('a Codex send its turn ended without echoing', () => { }) }) +describe('the turn a withdrawn Codex send names', () => { + it('is the record of the turn it opened, when that turn is interrupted', async () => { + const rig = await turnEndRig() + await rig.sendAndOpen('client-1') + + rig.turns.end('interrupted') + + expect(rig.turnRecords.length).toBeGreaterThan(0) + expect(new Set(rig.turnRecords.map(agentJournalItemKey)).size).toBe(1) + expect(rig.settlements).toEqual([ + expect.objectContaining({ clientMessageId: 'client-1', answeredInTurn: rig.turnRecords[0] }) + ]) + }) + + it('is the running turn for a send steered into it', async () => { + const rig = await turnEndRig() + await rig.sendAndOpen('client-1') + await rig.send('client-2') + + rig.turns.end('interrupted') + + expect(rig.settlements.map((settlement) => [settlement.clientMessageId, settlement])).toEqual([ + ['client-1', expect.objectContaining({ answeredInTurn: rig.turnRecords[0] })], + ['client-2', expect.objectContaining({ answeredInTurn: rig.turnRecords[0] })] + ]) + }) + + it('is the ended turn whose end was read before the answer', async () => { + const rig = await turnEndRig() + const release = rig.turns.holdNextAnswer() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + rig.turns.end('interrupted') + release() + + await expect(sending).resolves.toMatchObject({ + state: 'rejected', + answeredInTurn: rig.turnRecords[0] + }) + }) + + it('is not named when its turn completes without echoing it: the send stays pending', async () => { + const rig = await turnEndRig() + const release = rig.turns.holdNextAnswer() + const sending = rig.send('client-1') + await vi.waitFor(() => expect(rig.turns.turnId).toBe('turn-1')) + rig.turns.start() + rig.turns.end('completed') + release() + + await expect(sending).resolves.toEqual({ state: 'admitted' }) + await rig.sendAndOpen('client-2') + rig.turns.end('completed') + expect(rig.settlements).toEqual([]) + }) +}) + describe('a send bound to a turn', () => { it('dies with the settlement its turn end makes', () => { const echoes = createCodexDispatchEchoes() diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index 657006449331..931f4d41a702 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -17,7 +17,9 @@ import { agentSessionFailureWords, type AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import type { CodexTurnEnd } from './codex-structured-dispatch-echo' +import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' import type { CodexSession } from './codex-structured-session-state' import { readCodexThreadId, @@ -41,6 +43,8 @@ export function codexDispatchRejection( export type CodexTurnEndSettlement = { clientMessageId: string state: 'rejected' + /** The turn Codex answered the send into, whose end settled it. */ + answeredInTurn: AgentJournalItemIdentity } & AgentJournalDispatchRejection function errorDetail(params: unknown): ProviderDiagnostic | undefined { @@ -80,19 +84,23 @@ export function codexTurnEndRejection(end: CodexTurnEnd): AgentJournalDispatchRe /** Settles the sends bound to the turn this admitted notification ended. */ export function settleCodexSendsInEndedTurn( session: Pick, - method: string, - params: unknown, + frame: { sessionId: string; method: string; params: unknown }, settle: (settlement: CodexTurnEndSettlement) => void ): void { - const turnId = readCodexTurnId(params) - const end = readCodexTurnEnd(method, params) - if (!turnId || !end || (readCodexThreadId(params) ?? session.threadId) !== session.threadId) { + const turnId = readCodexTurnId(frame.params) + const end = readCodexTurnEnd(frame.method, frame.params) + if ( + !turnId || + !end || + (readCodexThreadId(frame.params) ?? session.threadId) !== session.threadId + ) { return } const rejection = codexTurnEndRejection(end) + const answeredInTurn = codexTurnLifecycleIdentity(frame.sessionId, turnId) for (const clientMessageId of session.dispatchEchoes.endTurn(session.threadId, turnId, end)) { if (rejection) { - settle({ clientMessageId, state: 'rejected', ...rejection }) + settle({ clientMessageId, state: 'rejected', answeredInTurn, ...rejection }) } } } diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index dd577074c2b7..74de8ea33953 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -8,6 +8,7 @@ import { } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' import { readCodexTurnId } from './codex-structured-thread-facts' import { codexRunningOrOpeningTurn, @@ -184,7 +185,12 @@ export async function startCodexTurn( */ export async function dispatchCodexTurn( session: CodexTurnHost, - input: { clientMessageId: string; body: AgentJournalMessageItem; requestedAt?: number }, + input: { + sessionId: string + clientMessageId: string + body: AgentJournalMessageItem + requestedAt?: number + }, timeoutMs: number | undefined ): Promise { let answer: { turnId: string | null } | false @@ -214,5 +220,11 @@ export async function dispatchCodexTurn( ? session.dispatchEchoes.bindTurn(input.clientMessageId, session.threadId, answer.turnId) : null const rejection = endedFirst ? codexTurnEndRejection(endedFirst) : null - return rejection ? { state: 'rejected', ...rejection } : { state: 'admitted' } + return rejection && answer.turnId + ? { + state: 'rejected', + answeredInTurn: codexTurnLifecycleIdentity(input.sessionId, answer.turnId), + ...rejection + } + : { state: 'admitted' } } 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 c7c2a6716546..c2f7d45327a5 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 @@ -34,6 +34,15 @@ export function applyJournalDispatchRow( } else { delete submission.rejection } + if ( + row.state === 'rejected' && + typeof row.answeredInTurnItemId === 'string' && + row.answeredInTurnItemId + ) { + submission.answeredInTurnItemId = row.answeredInTurnItemId + } else { + delete submission.answeredInTurnItemId + } 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-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 0865df3a41fa..6873db0fd46b 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 @@ -110,6 +110,9 @@ export function journalDispatchRowBuilder( providerItemId, reason: boundedDispatchReason(input), ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), + ...(input.state === 'rejected' && input.answeredInTurn + ? { answeredInTurnItemId: agentJournalItemKey(input.answeredInTurn) } + : {}), ...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 9276b1a8c05f..194290542d93 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 @@ -150,6 +150,10 @@ 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`: the turn record a Codex send was answered into (`turn/start` or `turn/steer`) + * when that turn's end settled the send. Absent on every other row. Older readers keep the key + * and ignore it. */ + answeredInTurnItemId?: 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 cb86a7a8e5da..b2d2914b318b 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,7 +40,10 @@ 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' + answeredInTurn?: AgentJournalItemIdentity + } & AgentJournalDispatchRejection) | { state: 'unknown'; reason?: string | null } ) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts index eac42c79113c..31cfee3a25c9 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts @@ -1,6 +1,7 @@ // Each submission carries where the journal wrote its row. A rejected send's own row moves to its // rejection, so this is the only journal-order record of where it was sent. Derived on every fold: a -// replay of the same rows gives the same value, and a history page carries it. +// replay of the same rows gives the same value, and a history page carries it. A rejection a turn's +// end made also names that turn. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -8,7 +9,10 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalMessageItem, @@ -69,6 +73,61 @@ async function sendHandedOverThenWithdrawn() { return { journal, submitted, handedOver, pending, withdrawn } } +const TURN = { + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'turn-lifecycle:turn-1' +} as const + +/** A send its turn ended without taking: the rejection names that turn. */ +async function sendWithdrawnByItsTurnEnd() { + root = await mkdtemp(join(tmpdir(), 'orca-submission-positions-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'send-1', + body: BODY, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), + answeredInTurn: TURN, + fence: 1 + }) + return journal +} + +describe('the turn a rejected submission was answered into', () => { + it('is the turn record its rejection named, on the snapshot, a replay, and the page', async () => { + const journal = await sendWithdrawnByItsTurnEnd() + const turnItemId = agentJournalItemKey(TURN) + + expect(journal.submission('send-1')).toMatchObject({ + dispatchState: 'rejected', + answeredInTurnItemId: turnItemId + }) + expect(readAgentSessionHydrationPage(journal).submissions).toEqual([ + expect.objectContaining({ answeredInTurnItemId: turnItemId }) + ]) + await journals.closeAll() + const replayed = await journals.open({ identity: IDENTITY, stateDirectory: root! }) + expect(replayed.submission('send-1')).toMatchObject({ answeredInTurnItemId: turnItemId }) + }) + + it('is absent on a take-back that names no turn, as on rows from older hosts', async () => { + const { journal } = await sendHandedOverThenWithdrawn() + + expect(journal.submission('send-1')).toMatchObject({ dispatchState: 'rejected' }) + expect(journal.submission('send-1')).not.toHaveProperty('answeredInTurnItemId') + expect(readAgentSessionHydrationPage(journal).submissions[0]).not.toHaveProperty( + 'answeredInTurnItemId' + ) + }) +}) + describe("a submission's journal position", () => { it('is its own row, which a take-back does not move', async () => { const { journal, submitted, handedOver, pending, withdrawn } = diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 318bada1fe52..2aacb4d96333 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -176,8 +176,12 @@ export type AgentSessionDispatchOutcome = * anything and never promotes this to `unknown`. */ | { state: 'admitted' } - /** Words from `agentSessionFailureWords`, never written by hand. */ - | ({ state: 'rejected' } & AgentJournalDispatchRejection) + /** Words from `agentSessionFailureWords`, never written by hand. `answeredInTurn`: the turn the + * provider answered the send into, which ended before the answer was read. */ + | ({ + state: 'rejected' + answeredInTurn?: AgentJournalItemIdentity + } & AgentJournalDispatchRejection) /** The call did not settle. Never re-send on the user's behalf. */ | { state: 'unknown'; reason: string } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts index ba0ab9231261..4384d5fd5610 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts @@ -12,7 +12,10 @@ export async function settleStructuredAgentSessionLateDispatch( clientMessageId: string } & ( | { providerIdentity: AgentJournalItemIdentity } - | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | ({ + state: 'rejected' + answeredInTurn?: AgentJournalItemIdentity + } & AgentJournalDispatchRejection) | { state: 'unknown'; reason: string } ) ): Promise { @@ -43,6 +46,7 @@ export async function settleStructuredAgentSessionLateDispatch( state: 'rejected', reason: input.reason, rejection: input.rejection, + ...(input.answeredInTurn ? { answeredInTurn: input.answeredInTurn } : {}), fence } ) 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 629d7e717903..a291943fc0fd 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 @@ -231,6 +231,7 @@ export async function handOverSubmission( state: 'rejected', reason: outcome.reason, rejection: outcome.rejection, + ...(outcome.answeredInTurn ? { answeredInTurn: outcome.answeredInTurn } : {}), fence: ctx.fence } : { clientMessageId, state: 'unknown', reason: outcome.reason, fence: ctx.fence } diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 90af918c9f45..268db4aaa916 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -363,6 +363,79 @@ describe('a Codex send its turn ended without taking it', () => { }) }) +describe('the turn a withdrawn Codex send was answered into', () => { + async function answeredInto(clientMessageId: string) { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + const onPage = page.ok + ? page.page.submissions.find((entry) => entry.clientMessageId === clientMessageId) + : undefined + return { + turnRecords: snapshot.items.flatMap((item) => + item.body.kind === 'turn' ? [item.itemId] : [] + ), + named: snapshot.submissions.find((entry) => entry.clientMessageId === clientMessageId) + ?.answeredInTurnItemId, + onPage: onPage?.answeredInTurnItemId + } + } + + it("is that turn's record, for the send that opened it and for one steered into it", async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + const steered = await send('and check the tests') + await vi.waitFor(() => expect(steers).toBe(1)) + + await stop('turn-1') + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, steered)).toBe('withdrawn') + ) + + const { turnRecords } = await answeredInto(opening) + expect(turnRecords).toHaveLength(1) + expect(await answeredInto(opening)).toMatchObject({ + named: turnRecords[0], + onPage: turnRecords[0] + }) + expect(await answeredInto(steered)).toMatchObject({ + named: turnRecords[0], + onPage: turnRecords[0] + }) + }) + + it('is not named on a send the turn took', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + + await stop('turn-1') + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, opening)).toBe('accepted') + ) + + expect(await answeredInto(opening)).toMatchObject({ named: undefined, onPage: undefined }) + }) + + it('is named when the answer is read after that turn ended', async () => { + const release = turns.holdNextAnswer() + const sent = await send('look around') + await vi.waitFor(() => expect(turns.turnId).toBe('turn-1')) + turns.start() + turns.end('interrupted') + release() + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + ) + const { turnRecords, named } = await answeredInto(sent) + expect(turnRecords).toHaveLength(1) + expect(named).toBe(turnRecords[0]) + }) +}) + describe('a queued card sent now into the turn a Stop ends', () => { async function handoffs(messageId: string): Promise { return (await settled()).submissions.filter((entry) => entry.queuedMessageId === messageId) diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 8a7a51493675..00d88b74de56 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -319,6 +319,7 @@ export const AgentJournalSubmissionSchema = z.object({ submittedAt: z.number(), resolvedAt: z.number().nullable(), submittedSequence: z.number().int().optional(), + answeredInTurnItemId: z.string().min(1).optional(), recovered: z.literal(true).optional(), handoverRecorded: z.literal(true).optional(), handedOverAt: z.number().optional(), diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 28e470cb869e..4e21ee5f2e86 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -415,6 +415,10 @@ export type AgentJournalSubmission = { * never stored. Needed because a rejected send's own row moves to its rejection, which erases * where it was sent. Absent from hosts that predate it. */ submittedSequence?: number + /** On `rejected`: the item id of the turn record a Codex send was answered into, when that turn + * ended without taking it. Absent on every other send: accepted ones (the echo places them), + * Claude, queued take-backs, restart recovery, and rows from hosts that predate it. */ + answeredInTurnItemId?: string /** Set when crash reconciliation resolved the dispatch, not the provider. A live * `unknown` is a send still outstanding; a recovered one outlived its writer. */ recovered?: true diff --git a/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts index ef5d1eba2ec7..46ddf9715dea 100644 --- a/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts @@ -10,8 +10,8 @@ import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent- import { readAgentSessionHydrationPage } from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' -// A released client that predates the published submission position: it must fold and draw a page -// carrying it exactly as it draws the same page without it. +// A released client that predates the published submission position and answered turn: it must fold +// and draw a page carrying them exactly as it draws the same page without them. const BASELINE_REF = 'v1.4.219' const IDENTITY: AgentSessionJournalIdentity = { @@ -34,16 +34,19 @@ function releaseExport(module: Record, name: string): T { type OldState = { submissions: Record[]; items: unknown[] } -/** Strips the published position, as an older host's page would carry its submissions. */ +/** Strips the published position and answered turn, as an older host's page would carry its + * submissions. */ function withoutPosition(page: AgentSessionHistoryPage): AgentSessionHistoryPage { return { ...page, - submissions: page.submissions.map(({ submittedSequence: _submitted, ...rest }) => rest) + submissions: page.submissions.map( + ({ submittedSequence: _submitted, answeredInTurnItemId: _turn, ...rest }) => rest + ) } } // Loads a real release checkout, cold extraction and transforms included. -test('a released client folds and draws a page whose submissions carry their journal position', async () => { +test('a released client folds and draws a page whose submissions carry their journal position and answered turn', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-submission-positions-downgrade-')) const journals = createTrackedJournalOpener() try { @@ -64,13 +67,34 @@ test('a released client folds and draws a page whose submissions carry their jou fence: 1, recovered: true }) + await journal.appendSubmission({ + clientMessageId: 'turn-ended', + payloadFingerprint: 'turn-ended', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'turn-ended' }] }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'turn-ended', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), + answeredInTurn: { + provider: 'legacy', + agent: 'codex', + sessionId: IDENTITY.sessionId, + recordId: 'turn-lifecycle:turn-1' + }, + fence: 1 + }) const page = readAgentSessionHydrationPage(journal, 1) - // Anti-vacuous: the page this build publishes does carry the position. + // Anti-vacuous: the page this build publishes does carry both. expect(page.submissions.find((entry) => entry.clientMessageId === 'taken-back')).toEqual( expect.objectContaining({ submittedSequence: expect.any(Number) }) ) + expect(page.submissions.find((entry) => entry.clientMessageId === 'turn-ended')).toEqual( + expect.objectContaining({ answeredInTurnItemId: expect.any(String) }) + ) const checkout = await materializeReleaseCheckout(BASELINE_REF) const reducer = await importReleaseCheckoutModule( @@ -92,7 +116,9 @@ test('a released client folds and draws a page whose submissions carry their jou const draw = (from: AgentSessionHistoryPage) => { const state = reduce(empty, { type: 'history-page', page: from }) return { - submissions: state.submissions.map(({ submittedSequence: _s, ...rest }) => rest), + submissions: state.submissions.map( + ({ submittedSequence: _s, answeredInTurnItemId: _t, ...rest }) => rest + ), messages: project(state.items, [], state.submissions) } } From 8a2e1efbc94ec8ef5a58514e0c52c473283737db Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:04:59 -0700 Subject: [PATCH 149/182] feat(native-chat): say how a withdrawn Codex send joined the turn it names Same change as #25073 at ab2826ee1854, so this stack builds on it. --- .../codex-structured-dispatch-echo.test.ts | 14 ++++---- .../codex/codex-structured-dispatch-echo.ts | 36 +++++++++++++------ .../codex/codex-structured-session-state.ts | 3 +- ...dex-structured-turn-end-settlement.test.ts | 29 ++++++++++----- .../codex-structured-turn-end-settlement.ts | 16 +++++---- src/main/codex/codex-structured-turn-start.ts | 27 +++++++++----- .../journal-dispatch-reducer.ts | 25 +++++++++---- .../journal-row-builders.ts | 7 +++- .../journal-row-schema.ts | 9 ++--- .../journal-store-contracts.ts | 3 +- .../journal-submission-positions.test.ts | 14 ++++---- .../structured-agent-session-adapter.ts | 3 +- .../structured-agent-session-late-dispatch.ts | 7 ++-- ...-session-codex-turn-end-settlement.test.ts | 22 ++++++------ src/shared/agent-session-journal-schemas.ts | 3 +- src/shared/agent-session-journal-types.ts | 20 ++++++++--- ...ubmission-positions-downgrade.unit.test.ts | 19 ++++++---- 17 files changed, 171 insertions(+), 86 deletions(-) diff --git a/src/main/codex/codex-structured-dispatch-echo.test.ts b/src/main/codex/codex-structured-dispatch-echo.test.ts index c7eb43bf96f4..1eeb8186ad98 100644 --- a/src/main/codex/codex-structured-dispatch-echo.test.ts +++ b/src/main/codex/codex-structured-dispatch-echo.test.ts @@ -133,9 +133,9 @@ describe('the turn Codex answered a send into but has not opened', () => { it('is the turn the latest armed send was answered into', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') - echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') echoes.arm('client-2') - echoes.bindTurn('client-2', 'thread-1', 'turn-2') + echoes.bindTurn('client-2', 'thread-1', 'turn-2', 'start') expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBe('turn-2') }) @@ -143,7 +143,7 @@ describe('the turn Codex answered a send into but has not opened', () => { it('is none once Codex opened that turn', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') - echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') expect(echoes.answeredUnopenedTurn('thread-1', new Set(['turn-1']))).toBeNull() }) @@ -151,7 +151,7 @@ describe('the turn Codex answered a send into but has not opened', () => { it('is none once that turn ended, even with its send still armed for an echo', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') - echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') // A completed end leaves its unechoed send armed. expect(echoes.endTurn('thread-1', 'turn-1', { status: 'completed' })).toEqual([]) @@ -161,9 +161,9 @@ describe('the turn Codex answered a send into but has not opened', () => { it('skips a turn a wait left unopened, and still names an earlier one', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') - echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') echoes.arm('client-2') - echoes.bindTurn('client-2', 'thread-1', 'turn-2') + echoes.bindTurn('client-2', 'thread-1', 'turn-2', 'start') echoes.leftUnopened('thread-1', 'turn-2') @@ -174,7 +174,7 @@ describe('the turn Codex answered a send into but has not opened', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') echoes.arm('client-2') - echoes.bindTurn('client-2', 'thread-2', 'turn-2') + echoes.bindTurn('client-2', 'thread-2', 'turn-2', 'start') expect(echoes.answeredUnopenedTurn('thread-1', NONE_OPEN)).toBeNull() }) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 1eee3bc9a0a7..4ad6d58faedc 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -1,5 +1,8 @@ import type { ProviderDiagnostic } from '../../shared/agent-session-failure' -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalTurnJoin +} from '../../shared/agent-session-journal-types' /** Sends awaiting their echo. One bound to a turn that ended without taking it settles from that * end; any other whose echo never arrives is retired by the journal's recovery on exit. */ @@ -35,10 +38,15 @@ export type CodexDispatchEchoes = { /** Drops an armed send whose write never reached the provider. */ disarm: (clientMessageId: string) => void /** - * Binds a send to the turn Codex answered it into. Returns that turn's end when the answer is - * read after it; a send that end settles is no longer armed. + * Binds a send to the turn Codex answered it into, and how it joined that turn. Returns that + * turn's end when the answer is read after it; a send that end settles is no longer armed. */ - bindTurn: (clientMessageId: string, threadId: string, turnId: string) => CodexTurnEnd | null + bindTurn: ( + clientMessageId: string, + threadId: string, + turnId: string, + via: AgentJournalTurnJoin + ) => CodexTurnEnd | null /** The turn the latest armed send was answered into that is neither in `openTurnIds`, ended, nor * left unopened through a wait: one Codex has picked for the send but not opened. */ answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | null @@ -52,7 +60,11 @@ export type CodexDispatchEchoes = { * completed, which echoes its pending input first, so one it never echoed waits for recovery. An * interrupt withdraws an un-echoed send, steered or the turn's own input: neither reached history. */ - endTurn: (threadId: string, turnId: string, end: CodexTurnEnd) => string[] + endTurn: ( + threadId: string, + turnId: string, + end: CodexTurnEnd + ) => { clientMessageId: string; via: AgentJournalTurnJoin }[] /** Submission origin for this exact send, retained until its echo settles it. */ requestOrigin: (clientMessageId: string) => CodexDispatchRequestOrigin | null /** Highest causal sequence assigned to a dispatch in this session. */ @@ -64,7 +76,11 @@ export type CodexDispatchEchoes = { export function createCodexDispatchEchoes(): CodexDispatchEchoes { const armed = new Map< string, - { requestedAt: number | null; sequence: number; turn?: { threadId: string; turnId: string } } + { + requestedAt: number | null + sequence: number + turn?: { threadId: string; turnId: string; via: AgentJournalTurnJoin } + } >() const endedTurns = new Map() const unopenedTurns = new Set() @@ -101,12 +117,12 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { }, settle: (clientMessageId) => armed.delete(clientMessageId), disarm: (clientMessageId) => void armed.delete(clientMessageId), - bindTurn: (clientMessageId, threadId, turnId) => { + bindTurn: (clientMessageId, threadId, turnId, via) => { const entry = armed.get(clientMessageId) if (!entry) { return null } - entry.turn = { threadId, turnId } + entry.turn = { threadId, turnId, via } const end = endedTurns.get(turnKey(threadId, turnId)) ?? null if (end && settles(end)) { armed.delete(clientMessageId) @@ -141,10 +157,10 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { } const settled = [...armed].flatMap(([clientMessageId, entry]) => entry.turn && turnKey(entry.turn.threadId, entry.turn.turnId) === turn - ? [clientMessageId] + ? [{ clientMessageId, via: entry.turn.via }] : [] ) - for (const clientMessageId of settled) { + for (const { clientMessageId } of settled) { armed.delete(clientMessageId) } return settled diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 107a4cc52cdb..14ada0594c71 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -1,4 +1,5 @@ import type { + AgentJournalAnsweredTurnIdentity, AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' @@ -86,7 +87,7 @@ export type CodexStructuredSessionAdapterDeps = { | { providerIdentity: AgentJournalItemIdentity } | ({ state: 'rejected' - answeredInTurn: AgentJournalItemIdentity + answeredInTurn: AgentJournalAnsweredTurnIdentity } & AgentJournalDispatchRejection) ) ) => void diff --git a/src/main/codex/codex-structured-turn-end-settlement.test.ts b/src/main/codex/codex-structured-turn-end-settlement.test.ts index ccb9fa7ba456..2b6542a43336 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.test.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.test.ts @@ -317,7 +317,10 @@ describe('the turn a withdrawn Codex send names', () => { expect(rig.turnRecords.length).toBeGreaterThan(0) expect(new Set(rig.turnRecords.map(agentJournalItemKey)).size).toBe(1) expect(rig.settlements).toEqual([ - expect.objectContaining({ clientMessageId: 'client-1', answeredInTurn: rig.turnRecords[0] }) + expect.objectContaining({ + clientMessageId: 'client-1', + answeredInTurn: { turn: rig.turnRecords[0], via: 'start' } + }) ]) }) @@ -329,8 +332,14 @@ describe('the turn a withdrawn Codex send names', () => { rig.turns.end('interrupted') expect(rig.settlements.map((settlement) => [settlement.clientMessageId, settlement])).toEqual([ - ['client-1', expect.objectContaining({ answeredInTurn: rig.turnRecords[0] })], - ['client-2', expect.objectContaining({ answeredInTurn: rig.turnRecords[0] })] + [ + 'client-1', + expect.objectContaining({ answeredInTurn: { turn: rig.turnRecords[0], via: 'start' } }) + ], + [ + 'client-2', + expect.objectContaining({ answeredInTurn: { turn: rig.turnRecords[0], via: 'steer' } }) + ] ]) }) @@ -345,7 +354,7 @@ describe('the turn a withdrawn Codex send names', () => { await expect(sending).resolves.toMatchObject({ state: 'rejected', - answeredInTurn: rig.turnRecords[0] + answeredInTurn: { turn: rig.turnRecords[0], via: 'start' } }) }) @@ -369,9 +378,11 @@ describe('a send bound to a turn', () => { it('dies with the settlement its turn end makes', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') - echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') - expect(echoes.endTurn('thread-1', 'turn-1', { status: 'interrupted' })).toEqual(['client-1']) + expect(echoes.endTurn('thread-1', 'turn-1', { status: 'interrupted' })).toEqual([ + { clientMessageId: 'client-1', via: 'start' } + ]) expect(echoes.size).toBe(0) expect(echoes.settle('client-1')).toBe(false) }) @@ -379,20 +390,20 @@ describe('a send bound to a turn', () => { it('dies with its child, which forgets recorded turn ends too', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') - echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') echoes.endTurn('thread-2', 'turn-2', { status: 'interrupted' }) echoes.clear() expect(echoes.size).toBe(0) echoes.arm('client-2') - expect(echoes.bindTurn('client-2', 'thread-2', 'turn-2')).toBeNull() + expect(echoes.bindTurn('client-2', 'thread-2', 'turn-2', 'start')).toBeNull() }) it('is matched by thread as well as turn id', () => { const echoes = createCodexDispatchEchoes() echoes.arm('client-1') - echoes.bindTurn('client-1', 'thread-1', 'turn-1') + echoes.bindTurn('client-1', 'thread-1', 'turn-1', 'start') expect(echoes.endTurn('thread-2', 'turn-1', { status: 'interrupted' })).toEqual([]) expect(echoes.size).toBe(1) diff --git a/src/main/codex/codex-structured-turn-end-settlement.ts b/src/main/codex/codex-structured-turn-end-settlement.ts index 931f4d41a702..376d9adb6e1f 100644 --- a/src/main/codex/codex-structured-turn-end-settlement.ts +++ b/src/main/codex/codex-structured-turn-end-settlement.ts @@ -17,7 +17,7 @@ import { agentSessionFailureWords, type AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { AgentJournalAnsweredTurnIdentity } from '../../shared/agent-session-journal-types' import type { CodexTurnEnd } from './codex-structured-dispatch-echo' import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' import type { CodexSession } from './codex-structured-session-state' @@ -43,8 +43,8 @@ export function codexDispatchRejection( export type CodexTurnEndSettlement = { clientMessageId: string state: 'rejected' - /** The turn Codex answered the send into, whose end settled it. */ - answeredInTurn: AgentJournalItemIdentity + /** The turn Codex answered the send into, whose end settled it, and how the send joined it. */ + answeredInTurn: AgentJournalAnsweredTurnIdentity } & AgentJournalDispatchRejection function errorDetail(params: unknown): ProviderDiagnostic | undefined { @@ -97,10 +97,14 @@ export function settleCodexSendsInEndedTurn( return } const rejection = codexTurnEndRejection(end) - const answeredInTurn = codexTurnLifecycleIdentity(frame.sessionId, turnId) - for (const clientMessageId of session.dispatchEchoes.endTurn(session.threadId, turnId, end)) { + const turn = codexTurnLifecycleIdentity(frame.sessionId, turnId) + for (const { clientMessageId, via } of session.dispatchEchoes.endTurn( + session.threadId, + turnId, + end + )) { if (rejection) { - settle({ clientMessageId, state: 'rejected', answeredInTurn, ...rejection }) + settle({ clientMessageId, state: 'rejected', answeredInTurn: { turn, via }, ...rejection }) } } } diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 74de8ea33953..49ed42820ffb 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -1,5 +1,8 @@ import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentJournalTurnJoin +} from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { @@ -110,7 +113,7 @@ async function steerCodexTurn( host: CodexTurnHost, expectedTurnId: string, input: { clientMessageId: string; body: AgentJournalMessageItem; timeoutMs?: number } -): Promise<{ turnId: string } | null> { +): Promise<{ turnId: string; via: 'steer' } | null> { try { const answer = await host.connection.request( 'turn/steer', @@ -122,7 +125,7 @@ async function steerCodexTurn( }, { timeoutMs: input.timeoutMs } ) - return { turnId: readCodexTurnId(answer) ?? expectedTurnId } + return { turnId: readCodexTurnId(answer) ?? expectedTurnId, via: 'steer' } } catch (error) { if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { return null @@ -144,7 +147,7 @@ export async function startCodexTurn( requestedAt?: number timeoutMs?: number } -): Promise<{ turnId: string | null } | false> { +): Promise<{ turnId: string | null; via: AgentJournalTurnJoin } | false> { // Armed before the write: the echo and `turn/started` can both land while the // response is in flight, and the start must snapshot this send in its frontier. if (!host.dispatchEchoes.arm(input.clientMessageId, input.requestedAt)) { @@ -174,7 +177,7 @@ export async function startCodexTurn( }, { timeoutMs: input.timeoutMs } ) - return { turnId: readCodexTurnId(answer) } + return { turnId: readCodexTurnId(answer), via: 'start' } } /** @@ -193,7 +196,7 @@ export async function dispatchCodexTurn( }, timeoutMs: number | undefined ): Promise { - let answer: { turnId: string | null } | false + let answer: { turnId: string | null; via: AgentJournalTurnJoin } | false try { answer = await startCodexTurn(session, { ...input, timeoutMs }) } catch (error) { @@ -217,13 +220,21 @@ export async function dispatchCodexTurn( } // An answer read after the turn it names already ended is settled by that end. const endedFirst = answer.turnId - ? session.dispatchEchoes.bindTurn(input.clientMessageId, session.threadId, answer.turnId) + ? session.dispatchEchoes.bindTurn( + input.clientMessageId, + session.threadId, + answer.turnId, + answer.via + ) : null const rejection = endedFirst ? codexTurnEndRejection(endedFirst) : null return rejection && answer.turnId ? { state: 'rejected', - answeredInTurn: codexTurnLifecycleIdentity(input.sessionId, answer.turnId), + answeredInTurn: { + turn: codexTurnLifecycleIdentity(input.sessionId, answer.turnId), + via: answer.via + }, ...rejection } : { state: 'admitted' } 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 c2f7d45327a5..b19fa2f1ce9f 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 @@ -6,6 +6,7 @@ import { type UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalAnsweredTurn } from '../../../shared/agent-session-journal-types' import { journalDispatchRowApplies } from './journal-dispatch-settlement' import type { JournalReducerState } from './journal-reducer' import { @@ -34,14 +35,11 @@ export function applyJournalDispatchRow( } else { delete submission.rejection } - if ( - row.state === 'rejected' && - typeof row.answeredInTurnItemId === 'string' && - row.answeredInTurnItemId - ) { - submission.answeredInTurnItemId = row.answeredInTurnItemId + const answeredInTurn = row.state === 'rejected' ? readAnsweredTurn(row.answeredInTurn) : undefined + if (answeredInTurn) { + submission.answeredInTurn = answeredInTurn } else { - delete submission.answeredInTurnItemId + delete submission.answeredInTurn } submission.resolvedAt = row.state === 'pending' ? null : row.ts if (row.state === 'pending') { @@ -70,6 +68,19 @@ export function applyJournalDispatchRow( }) } +/** A stored answered turn; one malformed, or naming a way of joining this build does not know, is + * dropped, never the row. */ +function readAnsweredTurn(value: unknown): AgentJournalAnsweredTurn | undefined { + if (typeof value !== 'object' || value === null) { + return undefined + } + const turnItemId = 'turnItemId' in value ? value.turnItemId : undefined + const via = 'via' in value ? value.via : undefined + return typeof turnItemId === 'string' && turnItemId && (via === 'start' || via === 'steer') + ? { turnItemId, via } + : undefined +} + /** A stored rejection fact, read where it can be placed; a kind it cannot place is kept as * written, so the classifier still knows a fact was there without this build claiming what it * says. Shared with the queued-draft table, whose returned card mirrors its submission. */ 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 6873db0fd46b..4484f26a93f1 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 @@ -111,7 +111,12 @@ export function journalDispatchRowBuilder( reason: boundedDispatchReason(input), ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), ...(input.state === 'rejected' && input.answeredInTurn - ? { answeredInTurnItemId: agentJournalItemKey(input.answeredInTurn) } + ? { + answeredInTurn: { + turnItemId: agentJournalItemKey(input.answeredInTurn.turn), + via: input.answeredInTurn.via + } + } : {}), ...journalRowBase(state().epoch, seq, input.fence, ts), ...(input.recovered ? { recovered: input.recovered } : {}), 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 194290542d93..4957bd846be9 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 @@ -16,6 +16,7 @@ import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalAnsweredTurn, type AgentJournalDispatchState, type AgentJournalItemBody, type AgentJournalMessageItem, @@ -150,10 +151,10 @@ 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`: the turn record a Codex send was answered into (`turn/start` or `turn/steer`) - * when that turn's end settled the send. Absent on every other row. Older readers keep the key - * and ignore it. */ - answeredInTurnItemId?: string + /** On `rejected`: the turn a Codex send was answered into, and how it joined it, when that + * turn's end settled the send. Absent on every other row. Older readers keep the key and ignore + * it; a malformed one is dropped when read, never the row. */ + answeredInTurn?: AgentJournalAnsweredTurn } /** 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 b2d2914b318b..4232e805bbe4 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 @@ -1,5 +1,6 @@ import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { + AgentJournalAnsweredTurnIdentity, AgentJournalCursor, AgentJournalItemBody, AgentJournalItemIdentity, @@ -42,7 +43,7 @@ export type ResolveDispatchInput = { * `agentSessionFailureWords`, never written by hand. */ | ({ state: 'rejected' - answeredInTurn?: AgentJournalItemIdentity + answeredInTurn?: AgentJournalAnsweredTurnIdentity } & AgentJournalDispatchRejection) | { state: 'unknown'; reason?: string | null } ) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts index 31cfee3a25c9..2ad8953ea030 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts @@ -94,7 +94,7 @@ async function sendWithdrawnByItsTurnEnd() { clientMessageId: 'send-1', state: 'rejected', ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), - answeredInTurn: TURN, + answeredInTurn: { turn: TURN, via: 'start' }, fence: 1 }) return journal @@ -107,23 +107,25 @@ describe('the turn a rejected submission was answered into', () => { expect(journal.submission('send-1')).toMatchObject({ dispatchState: 'rejected', - answeredInTurnItemId: turnItemId + answeredInTurn: { turnItemId, via: 'start' } }) expect(readAgentSessionHydrationPage(journal).submissions).toEqual([ - expect.objectContaining({ answeredInTurnItemId: turnItemId }) + expect.objectContaining({ answeredInTurn: { turnItemId, via: 'start' } }) ]) await journals.closeAll() const replayed = await journals.open({ identity: IDENTITY, stateDirectory: root! }) - expect(replayed.submission('send-1')).toMatchObject({ answeredInTurnItemId: turnItemId }) + expect(replayed.submission('send-1')).toMatchObject({ + answeredInTurn: { turnItemId, via: 'start' } + }) }) it('is absent on a take-back that names no turn, as on rows from older hosts', async () => { const { journal } = await sendHandedOverThenWithdrawn() expect(journal.submission('send-1')).toMatchObject({ dispatchState: 'rejected' }) - expect(journal.submission('send-1')).not.toHaveProperty('answeredInTurnItemId') + expect(journal.submission('send-1')).not.toHaveProperty('answeredInTurn') expect(readAgentSessionHydrationPage(journal).submissions[0]).not.toHaveProperty( - 'answeredInTurnItemId' + 'answeredInTurn' ) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 2aacb4d96333..d08bf45b8d32 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -12,6 +12,7 @@ import type { import type { AgentSessionBackgroundTaskStops } from '../../../shared/agent-child-work-stop-targets' import type { + AgentJournalAnsweredTurnIdentity, AgentJournalItemIdentity, AgentJournalItemBody, AgentJournalMessageItem, @@ -180,7 +181,7 @@ export type AgentSessionDispatchOutcome = * provider answered the send into, which ended before the answer was read. */ | ({ state: 'rejected' - answeredInTurn?: AgentJournalItemIdentity + answeredInTurn?: AgentJournalAnsweredTurnIdentity } & AgentJournalDispatchRejection) /** The call did not settle. Never re-send on the user's behalf. */ | { state: 'unknown'; reason: string } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts index 4384d5fd5610..3d38494fea93 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts @@ -1,4 +1,7 @@ -import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalAnsweredTurnIdentity, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' @@ -14,7 +17,7 @@ export async function settleStructuredAgentSessionLateDispatch( | { providerIdentity: AgentJournalItemIdentity } | ({ state: 'rejected' - answeredInTurn?: AgentJournalItemIdentity + answeredInTurn?: AgentJournalAnsweredTurnIdentity } & AgentJournalDispatchRejection) | { state: 'unknown'; reason: string } ) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 268db4aaa916..ffcc7921ce14 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -376,12 +376,12 @@ describe('the turn a withdrawn Codex send was answered into', () => { item.body.kind === 'turn' ? [item.itemId] : [] ), named: snapshot.submissions.find((entry) => entry.clientMessageId === clientMessageId) - ?.answeredInTurnItemId, - onPage: onPage?.answeredInTurnItemId + ?.answeredInTurn, + onPage: onPage?.answeredInTurn } } - it("is that turn's record, for the send that opened it and for one steered into it", async () => { + it("is that turn's record, started by the send that opened it and steered by a later one", async () => { const opening = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) turns.start() @@ -395,13 +395,13 @@ describe('the turn a withdrawn Codex send was answered into', () => { const { turnRecords } = await answeredInto(opening) expect(turnRecords).toHaveLength(1) - expect(await answeredInto(opening)).toMatchObject({ - named: turnRecords[0], - onPage: turnRecords[0] - }) - expect(await answeredInto(steered)).toMatchObject({ - named: turnRecords[0], - onPage: turnRecords[0] + const started = { turnItemId: turnRecords[0], via: 'start' } + const steeredIn = { turnItemId: turnRecords[0], via: 'steer' } + expect(await answeredInto(opening)).toEqual({ turnRecords, named: started, onPage: started }) + expect(await answeredInto(steered)).toEqual({ + turnRecords, + named: steeredIn, + onPage: steeredIn }) }) @@ -432,7 +432,7 @@ describe('the turn a withdrawn Codex send was answered into', () => { ) const { turnRecords, named } = await answeredInto(sent) expect(turnRecords).toHaveLength(1) - expect(named).toBe(turnRecords[0]) + expect(named).toEqual({ turnItemId: turnRecords[0], via: 'start' }) }) }) diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 00d88b74de56..7a21d5816a27 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -319,7 +319,8 @@ export const AgentJournalSubmissionSchema = z.object({ submittedAt: z.number(), resolvedAt: z.number().nullable(), submittedSequence: z.number().int().optional(), - answeredInTurnItemId: z.string().min(1).optional(), + // Open like `dispatchState`: a way of joining a newer host names must not drop the submission. + answeredInTurn: z.object({ turnItemId: z.string().min(1), via: z.string().min(1) }).optional(), recovered: z.literal(true).optional(), handoverRecorded: z.literal(true).optional(), handedOverAt: z.number().optional(), diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 4e21ee5f2e86..b6b33950de44 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -391,6 +391,18 @@ export type AgentJournalRenderItem = AgentJournalProducerLinkage & { // ─── Submissions ──────────────────────────────────────────────────────────── +/** The turn a send was answered into: its record's item id, and how the send joined it. `start`: + * the provider answered the send's start request with that turn; `steer`: Orca steered it into + * that running turn. Known limit: a start the provider silently folds into a running turn reads + * as `start`. Open: a newer host may name another way, which a reader leaves unclaimed. */ +export type AgentJournalAnsweredTurn = { turnItemId: string; via: AgentJournalTurnJoin } +export type AgentJournalTurnJoin = 'start' | 'steer' +/** The same, as a writer names it: the turn record's identity, keyed when the row is written. */ +export type AgentJournalAnsweredTurnIdentity = { + turn: AgentJournalItemIdentity + via: AgentJournalTurnJoin +} + export const AGENT_JOURNAL_DISPATCH_STATES = ['pending', 'accepted', 'rejected', 'unknown'] as const export type AgentJournalDispatchState = (typeof AGENT_JOURNAL_DISPATCH_STATES)[number] @@ -415,10 +427,10 @@ export type AgentJournalSubmission = { * never stored. Needed because a rejected send's own row moves to its rejection, which erases * where it was sent. Absent from hosts that predate it. */ submittedSequence?: number - /** On `rejected`: the item id of the turn record a Codex send was answered into, when that turn - * ended without taking it. Absent on every other send: accepted ones (the echo places them), - * Claude, queued take-backs, restart recovery, and rows from hosts that predate it. */ - answeredInTurnItemId?: string + /** On `rejected`: the turn a Codex send was answered into, when that turn ended without taking + * it. Absent on every other send: accepted ones (the echo places them), Claude, queued + * take-backs, restart recovery, and rows from hosts that predate it. */ + answeredInTurn?: AgentJournalAnsweredTurn /** Set when crash reconciliation resolved the dispatch, not the provider. A live * `unknown` is a send still outstanding; a recovered one outlived its writer. */ recovered?: true diff --git a/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts index 46ddf9715dea..5265cfb1af99 100644 --- a/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts @@ -40,7 +40,7 @@ function withoutPosition(page: AgentSessionHistoryPage): AgentSessionHistoryPage return { ...page, submissions: page.submissions.map( - ({ submittedSequence: _submitted, answeredInTurnItemId: _turn, ...rest }) => rest + ({ submittedSequence: _submitted, answeredInTurn: _turn, ...rest }) => rest ) } } @@ -78,10 +78,13 @@ test('a released client folds and draws a page whose submissions carry their jou state: 'rejected', ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), answeredInTurn: { - provider: 'legacy', - agent: 'codex', - sessionId: IDENTITY.sessionId, - recordId: 'turn-lifecycle:turn-1' + turn: { + provider: 'legacy', + agent: 'codex', + sessionId: IDENTITY.sessionId, + recordId: 'turn-lifecycle:turn-1' + }, + via: 'start' }, fence: 1 }) @@ -93,7 +96,9 @@ test('a released client folds and draws a page whose submissions carry their jou }) ) expect(page.submissions.find((entry) => entry.clientMessageId === 'turn-ended')).toEqual( - expect.objectContaining({ answeredInTurnItemId: expect.any(String) }) + expect.objectContaining({ + answeredInTurn: { turnItemId: expect.any(String), via: 'start' } + }) ) const checkout = await materializeReleaseCheckout(BASELINE_REF) @@ -117,7 +122,7 @@ test('a released client folds and draws a page whose submissions carry their jou const state = reduce(empty, { type: 'history-page', page: from }) return { submissions: state.submissions.map( - ({ submittedSequence: _s, answeredInTurnItemId: _t, ...rest }) => rest + ({ submittedSequence: _s, answeredInTurn: _t, ...rest }) => rest ), messages: project(state.items, [], state.submissions) } From 9b7f81032d2d60f56e9ad448aca25d00ba66ca10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:05:59 -0700 Subject: [PATCH 150/182] fix(native-chat): open a turn for a stopped send only when the host says it started it A send a Stop took back after its turn opened but before Codex echoed it is drawn as that turn's opener when the host names the turn and says the send started it. A steer into that turn keeps its own row after the turn. The guess from journal order and times is kept only for hosts that publish neither field, and never hands a turn the provider resumed on its own to a send. A page that starts inside such a turn draws the send before the turn's first loaded row. --- ...ssage-projection.rejected-in-place.test.ts | 12 +- .../native-chat-stopped-before-start.ts | 68 ++++- .../native-chat-stopped-send-turn.test.ts | 277 ++++++++++++++++++ src/shared/native-chat-turn-membership.ts | 67 +++-- ...ge-projection-stopped-before-start.test.ts | 30 +- 5 files changed, 406 insertions(+), 48 deletions(-) create mode 100644 src/shared/native-chat-stopped-send-turn.test.ts diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index d1e9dea27324..aaa1b6e25b84 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -127,6 +127,12 @@ function rows(messages: ReturnType const SEED = submission('seed', 'seed', 1) // The one row drawn after a send a Stop took back before the agent started it. const STOPPED_ROW = `stopped-before-start:${agentJournalSubmissionKey('stopped')}` + +/** The stopped send's own row comes right after it. */ +function expectStopRowRightAfterStopped(messages: readonly { id: string }[]): void { + const ids = messages.map((message) => message.id) + expect(ids.indexOf(STOPPED_ROW)).toBe(ids.indexOf(agentJournalSubmissionKey('stopped')) + 1) +} const SEED_ROWS = [userItem('seed', 1, 'seed'), answer(2)] describe('a message the host accepted and then rejected, on the desktop', () => { @@ -273,7 +279,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => { id: agentJournalSubmissionKey('stopped'), text: 'again', unsent: false }, { id: agentJournalSubmissionKey('failed'), text: 'again', unsent: true } ]) - expect(messages.map((message) => message.id)).toContain(STOPPED_ROW) + expectStopRowRightAfterStopped(messages) }) // Its own reply reports the rejection, in the composer, as a command's. @@ -313,7 +319,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('stopped'), text: 'never mind', unsent: false } ]) - expect(messages.map((message) => message.id)).toContain(STOPPED_ROW) + expectStopRowRightAfterStopped(messages) expect( structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size ).toBe(0) @@ -471,6 +477,6 @@ describe('the phone', () => { { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('stopped'), text: 'never mind', unsent: false } ]) - expect(messages.map((message) => message.id)).toContain(STOPPED_ROW) + expectStopRowRightAfterStopped(messages) }) }) diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts index 5188a5e7f836..2dd508df0411 100644 --- a/src/shared/native-chat-stopped-before-start.ts +++ b/src/shared/native-chat-stopped-before-start.ts @@ -11,7 +11,7 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' -import { structuredAgentTurnAnchors } from './native-chat-turn-membership' +import { stoppedTurnOpeners, structuredAgentTurnAnchors } from './native-chat-turn-membership' import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' /** The row after a send a Stop took back before the agent started it; a client words it by this. */ @@ -33,11 +33,12 @@ export type StoppedSendPlace = { opensTurn: boolean; position?: AgentJournalPosi /** * A send a Stop took back (`stopped`, by item id) is drawn at its own row, past the end of every * turn whose opener the journal wrote before it: a host that publishes `submittedSequence` puts - * that row where the send was taken back. One that opened a turn is drawn as that turn's opener. - * Journal order only, never a clock. - * Temporary: a host that predates `submittedSequence` leaves the row where it was sent, so the send - * is also drawn below the latest row sent before it (`latestRowsSentBefore`); dropped once no - * supported remote host lacks the field. + * that row where the send was taken back. One that opened a turn is drawn as that turn's opener; + * when that turn's record is not loaded, before the turn's first loaded row. Journal order only, + * never a clock. + * Temporary: a host that predates `submittedSequence` may leave the row where it was sent, so the + * send is also drawn below the latest row sent before it (`latestRowsSentBefore`); harmless on one + * that moves the row. Dropped once no supported remote host lacks the field. */ export function placeStoppedSends( items: readonly AgentJournalRenderItem[], @@ -48,6 +49,7 @@ export function placeStoppedSends( const turnOpenedBy = new Map([...anchors].map(([turnItemId, anchorId]) => [anchorId, turnItemId])) const itemsById = new Map(items.map((item) => [item.itemId, item])) const pastTurnsOpenedBefore = turnEndsByOpener(items, anchors, itemsById) + const unloadedTurnOpened = unloadedTurnsOpened(items, itemsById, stopped) const sentBefore = [...stopped.values()].some( (submission) => submission.submittedSequence === undefined ) @@ -60,11 +62,11 @@ export function placeStoppedSends( } const own = agentJournalItemPosition(item) const record = itemsById.get(turnOpenedBy.get(itemId) ?? '') - if (record) { + const opened = record ? openerPosition(item, record) : unloadedTurnOpened.get(itemId) + if (opened) { // Drawn where its turn opened, as an opener is. - const opened = agentJournalItemPosition(record) - return compareAgentJournalPositions(own, opened) > 0 - ? { opensTurn: true, position: { sequence: opened.sequence, index: opened.index - 0.5 } } + return compareAgentJournalPositions(own, opened) !== 0 + ? { opensTurn: true, position: opened } : { opensTurn: true } } const floorRow = sentBefore?.get(itemId) @@ -81,6 +83,49 @@ export function placeStoppedSends( } } +/** Where a turn's opener is drawn: its own row, or just before the record when its row comes after + * it, as the row of a send taken back after its turn opened does. */ +function openerPosition( + opener: AgentJournalRenderItem, + record: AgentJournalRenderItem +): AgentJournalPosition { + const own = agentJournalItemPosition(opener) + const opened = agentJournalItemPosition(record) + return compareAgentJournalPositions(own, opened) > 0 + ? { sequence: opened.sequence, index: opened.index - 0.5 } + : own +} + +/** For each taken-back send that opened a turn whose record is not loaded (a page that starts inside + * that turn), just before the turn's first loaded row; none when a loaded user row is in it. */ +function unloadedTurnsOpened( + items: readonly AgentJournalRenderItem[], + itemsById: ReadonlyMap, + stopped: ReadonlyMap +): ReadonlyMap { + const openers = new Map( + [...stoppedTurnOpeners([...stopped.values()])].filter( + ([turnItemId]) => !itemsById.has(turnItemId) + ) + ) + const first = new Map() + for (const item of items) { + const turnItemId = item.turnScope?.kind === 'turn' ? item.turnScope.turnItemId : undefined + if (turnItemId === undefined || !openers.has(turnItemId) || first.has(turnItemId)) { + continue + } + const userRow = item.body.kind === 'message' && item.body.role === 'user' + const position = agentJournalItemPosition(item) + first.set(turnItemId, userRow ? null : { ...position, index: position.index - 0.5 }) + } + return new Map( + [...openers].flatMap(([turnItemId, itemId]) => { + const position = first.get(turnItemId) + return position ? [[itemId, position] as const] : [] + }) + ) +} + /** * For a point in the journal, just after the furthest row of every turn whose opener comes before * it, or the point itself when none reaches past it. One pass over the items, then a binary search @@ -109,8 +154,9 @@ function turnEndsByOpener( } const turns = [...anchors].flatMap(([turnItemId, anchorId]) => { const opener = itemsById.get(anchorId) + const record = itemsById.get(turnItemId) const last = lastOfTurn.get(turnItemId) - return opener && last ? [{ opener: agentJournalItemPosition(opener), last }] : [] + return opener && record && last ? [{ opener: openerPosition(opener, record), last }] : [] }) turns.sort((left, right) => compareAgentJournalPositions(left.opener, right.opener)) // The furthest row of the turns opened up to each one. diff --git a/src/shared/native-chat-stopped-send-turn.test.ts b/src/shared/native-chat-stopped-send-turn.test.ts new file mode 100644 index 000000000000..741a99e32040 --- /dev/null +++ b/src/shared/native-chat-stopped-send-turn.test.ts @@ -0,0 +1,277 @@ +// Which turn a send a Stop took back before its echo opened, by the turn the host names it answered +// into, and where it is drawn when that turn's record is not loaded. + +import { describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from './agent-session-journal-types' +import { projectNativeChatTranscript } from './native-chat-transcript-projection' +import { nativeChatRowsInDrawOrder } from './native-chat-turn-grouping' +import { nativeChatTurnMembership, structuredAgentTurnAnchors } from './native-chat-turn-membership' +import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const STOPPED_ROW = 'stopped-before-start:' +const key = agentJournalSubmissionKey + +function row( + itemId: string, + sequence: number, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = { kind: 'thread' } +): AgentJournalRenderItem { + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } +} + +const sent = (id: string, sequence: number, turnScope?: AgentJournalTurnScope) => + row( + key(id), + sequence, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + turnScope + ) + +const turn = (itemId: string, sequence: number, userItemId: string, startedAt?: number) => + row(itemId, sequence, { + kind: 'turn', + turnId: itemId, + state: 'interrupted', + outcome: 'cancellation', + userItemId, + ...(startedAt !== undefined ? { startedAt } : {}) + }) + +const stopNote = (turnItemId: string, sequence: number) => + row( + `stop:${turnItemId}`, + sequence, + { kind: 'status', text: 'Cancellation requested.' }, + { + kind: 'turn', + turnItemId + } + ) + +function submission( + clientMessageId: string, + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + ...overrides + } +} + +const stopped = (id: string, overrides: Partial = {}) => + submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED, ...overrides }) + +/** Row ids in the order the transcript draws them, and each row's turn. */ +function drawn(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { + const journal = { items, submissions } + const { conversation } = projectNativeChatTranscript( + projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: false }), + undefined, + journal + ) + const { drawOrder, turnKeys } = nativeChatTurnMembership(conversation, journal) + const turnOf = new Map(conversation.map((message, index) => [message.id, turnKeys[index]])) + return nativeChatRowsInDrawOrder(conversation, drawOrder).map((message) => ({ + id: message.id, + turn: turnOf.get(message.id) + })) +} + +describe('a send a Stop took back before its echo', () => { + it('opens the turn the host names, not an earlier send taken back across it', () => { + // `queued` was sent first and taken back after the record too, but the host names `opener`. + // Both rows sit where they were taken back. + const items = [ + turn('t1', 3, 'codex:thread-1:t1:0'), + stopNote('t1', 4), + sent('queued', 5), + sent('opener', 6) + ] + const submissions = [ + stopped('queued', { submittedSequence: 1 }), + stopped('opener', { + submittedSequence: 2, + answeredInTurn: { turnItemId: 't1', via: 'start' } + }) + ] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe(key('opener')) + expect(drawn(items, submissions)).toEqual([ + { id: key('opener'), turn: key('opener') }, + { id: 'stop:t1', turn: key('opener') }, + { id: key('queued'), turn: key('queued') }, + { id: `${STOPPED_ROW}${key('queued')}`, turn: undefined } + ]) + }) + + it('opens no turn the provider resumed on its own, whose record names itself', () => { + // An older host's clock claim would hand this turn to a send sent before it and taken back after. + const items = [sent('never-ran', 1), turn('wake', 2, 'wake', 5)] + const submissions = [stopped('never-ran', { submittedAt: 4, resolvedAt: 10 })] + + expect(structuredAgentTurnAnchors(items, submissions).get('wake')).toBe('wake') + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + key('never-ran'), + `${STOPPED_ROW}${key('never-ran')}` + ]) + }) + + it('opens no turn the host does not name, though it was sent before the record and taken back after', () => { + // A page whose turn's echoed opener is on an older page: only the record is loaded. + // Its times would pass an older host's clock claim. + const items = [turn('t1', 3, 'codex:thread-1:t1:0', 5), stopNote('t1', 4), sent('queued', 5)] + const submissions = [ + stopped('queued', { submittedSequence: 2, submittedAt: 4, resolvedAt: 10 }) + ] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe('t1') + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + 'stop:t1', + key('queued'), + `${STOPPED_ROW}${key('queued')}` + ]) + }) + + it('pushes a send taken back inside the turn it opened past that turn, from where it is drawn', () => { + // `follow-up` was taken back between the record and the opener's own take-back. + const items = [ + turn('t1', 2, 'codex:thread-1:t1:0'), + sent('follow-up', 4), + stopNote('t1', 5), + sent('opener', 6) + ] + const submissions = [ + stopped('opener', { + submittedSequence: 1, + answeredInTurn: { turnItemId: 't1', via: 'start' } + }), + stopped('follow-up', { submittedSequence: 3 }) + ] + + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + key('opener'), + 'stop:t1', + key('follow-up'), + `${STOPPED_ROW}${key('follow-up')}` + ]) + }) +}) + +describe('a steer a Stop took back before its echo', () => { + const started = { turnItemId: 't1', via: 'start' } as const + const steered = { turnItemId: 't1', via: 'steer' } as const + + it('opens no turn whose echoed opener the record names, and is drawn after it', () => { + const items = [ + sent('opener', 1), + turn('t1', 2, 'codex:thread-1:t1:0'), + stopNote('t1', 4), + sent('steer', 5) + ] + const submissions = [ + submission('opener', { submittedSequence: 1, providerItemId: 'codex:thread-1:t1:0' }), + stopped('steer', { submittedSequence: 3, answeredInTurn: steered }) + ] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe(key('opener')) + expect(drawn(items, submissions)).toEqual([ + { id: key('opener'), turn: key('opener') }, + { id: 'stop:t1', turn: key('opener') }, + { id: key('steer'), turn: key('steer') }, + { id: `${STOPPED_ROW}${key('steer')}`, turn: undefined } + ]) + }) + + it('leaves the turn to the send that started it when both were taken back', () => { + const items = [ + turn('t1', 2, 'codex:thread-1:t1:0'), + stopNote('t1', 4), + sent('steer', 5), + sent('opener', 6) + ] + const submissions = [ + stopped('opener', { submittedSequence: 1, answeredInTurn: started }), + stopped('steer', { submittedSequence: 3, answeredInTurn: steered }) + ] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe(key('opener')) + expect(drawn(items, submissions)).toEqual([ + { id: key('opener'), turn: key('opener') }, + { id: 'stop:t1', turn: key('opener') }, + { id: key('steer'), turn: key('steer') }, + { id: `${STOPPED_ROW}${key('steer')}`, turn: undefined } + ]) + }) + + it('opens no turn whose echoed opener is on an older page, and is drawn after it', () => { + // Only the record is loaded: the opener's row and submission are on the older page. + const items = [turn('t1', 2, 'codex:thread-1:t1:0'), stopNote('t1', 4), sent('steer', 5)] + const submissions = [stopped('steer', { submittedSequence: 1, answeredInTurn: steered })] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe('t1') + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + 'stop:t1', + key('steer'), + `${STOPPED_ROW}${key('steer')}` + ]) + }) + + it('keeps its own row on a page that starts inside the turn it joined', () => { + const items = [stopNote('t1', 4), sent('steer', 5)] + const submissions = [stopped('steer', { submittedSequence: 1, answeredInTurn: steered })] + + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + 'stop:t1', + key('steer'), + `${STOPPED_ROW}${key('steer')}` + ]) + }) +}) + +describe('a send a Stop took back on a page that starts inside the turn it opened', () => { + it("is drawn before that turn's first loaded row, with no row of its own", () => { + const items = [stopNote('t1', 4), sent('opener', 5)] + const submissions = [ + stopped('opener', { + submittedSequence: 1, + answeredInTurn: { turnItemId: 't1', via: 'start' } + }) + ] + + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([key('opener'), 'stop:t1']) + }) + + it('keeps its own row when a loaded message of that turn opened it instead', () => { + const items = [ + sent('steered', 3, { kind: 'turn', turnItemId: 't1' }), + stopNote('t1', 4), + sent('late', 5) + ] + const submissions = [ + submission('steered', { submittedSequence: 3 }), + stopped('late', { submittedSequence: 2, answeredInTurn: { turnItemId: 't1', via: 'start' } }) + ] + + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + key('steered'), + 'stop:t1', + key('late'), + `${STOPPED_ROW}${key('late')}` + ]) + }) +}) diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index 3fdbef99ea24..6d5c17d98add 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -45,8 +45,8 @@ export type NativeChatTurnJournal = { * entry, directly or through a submission's adopted provider item; a record that names no entry * falls back to the nearest user entry before it, which only older hosts write. A key nothing * resolves yet is the send still in flight ahead of the record — Codex reports a turn open before - * it echoes the send — or one a Stop took back before that echo; with none, the turn anchors on - * its own record. + * it echoes the send — or one a Stop took back before that echo, which the host names as answered + * into the turn; with none, the turn anchors on its own record. */ export function structuredAgentTurnAnchors( items: readonly AgentJournalRenderItem[], @@ -71,8 +71,7 @@ export function structuredAgentTurnAnchors( .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) // A send a Stop took back after its turn opened but before the provider echoed it: the record - // still names the provider's key. It opened the first such record the journal wrote after it was - // sent and before it was taken back, in send order. + // still names the provider's key, and the host names the turn the send was answered into. const takenBack = submissions.filter( (submission) => !submission.providerItemId && @@ -82,16 +81,14 @@ export function structuredAgentTurnAnchors( ) const itemsById = takenBack.length > 0 ? new Map(items.map((item) => [item.itemId, item])) : undefined - const stoppedBeforeEcho = takenBack + const openedBy = stoppedTurnOpeners(takenBack) + const unnamed = takenBack + .filter((submission) => submission.submittedSequence === undefined) .flatMap((submission) => { const sent = itemsById?.get(agentJournalSubmissionKey(submission.clientMessageId)) return sent ? [{ sent, submission }] : [] }) - .sort( - (left, right) => - (left.submission.submittedSequence ?? left.submission.submittedAt) - - (right.submission.submittedSequence ?? right.submission.submittedAt) - ) + .sort((left, right) => left.submission.submittedAt - right.submission.submittedAt) const claimed = new Set() const anchors = new Map() let precedingUserItemId: string | null = null @@ -116,14 +113,23 @@ export function structuredAgentTurnAnchors( precedingUserItemId, inFlightSinceLastTurn ) - if (anchor === item.itemId && turn.userItemId !== undefined) { - const opener = stoppedBeforeEcho.find( - ({ sent, submission }) => - !claimed.has(sent.itemId) && sentBeforeAndTakenBackAfter(item, turn, submission, sent) - ) - if (opener) { - claimed.add(opener.sent.itemId) - anchor = opener.sent.itemId + const key = turn.userItemId + const unresolved = + key !== undefined && !userItemIds.has(key) && !userItemIds.has(aliases.get(key) ?? '') + // A record naming itself is a turn the provider resumed on its own: no send opened it. + if (unresolved && key !== item.itemId) { + const named = openedBy.get(item.itemId) + if (named !== undefined && itemsById?.has(named)) { + anchor = named + } else if (anchor === item.itemId) { + const opener = unnamed.find( + ({ sent, submission }) => + !claimed.has(sent.itemId) && sentBeforeAndTakenBackAfter(item, turn, submission, sent) + ) + if (opener) { + claimed.add(opener.sent.itemId) + anchor = opener.sent.itemId + } } } anchors.set(item.itemId, anchor) @@ -132,19 +138,30 @@ export function structuredAgentTurnAnchors( return anchors } -/** Whether a send was sent before a turn record and taken back after it, by journal order: a host - * that publishes `submittedSequence` also moves a rejected send's row to the row that rejected it. - * Temporary: an older host is read by its row's place and by times, which a resume rewrites to - * whole seconds; dropped once no supported remote host lacks `submittedSequence`. */ +/** For each turn a taken-back send started (`answeredInTurn.via` is `start`), that send's item id. + * One steered into a turn opened none, nor one joined in a way this build does not know. */ +export function stoppedTurnOpeners( + takenBack: readonly AgentJournalSubmission[] +): ReadonlyMap { + const openers = new Map() + for (const { answeredInTurn, clientMessageId } of takenBack) { + if (answeredInTurn?.via === 'start' && !openers.has(answeredInTurn.turnItemId)) { + openers.set(answeredInTurn.turnItemId, agentJournalSubmissionKey(clientMessageId)) + } + } + return openers +} + +/** Whether a send was sent before a turn record and taken back after it, by its row's place and by + * times. Temporary: the journal-order fallback for a host that names no turn, which also lacks + * `submittedSequence` and leaves rows where they were sent or only moves them (#24710); a resume + * rewrites its times to whole seconds. Dropped once no supported remote host lacks the field. */ function sentBeforeAndTakenBackAfter( record: AgentJournalRenderItem, turn: AgentJournalTurnLifecycle, submission: AgentJournalSubmission, sent: AgentJournalRenderItem ): boolean { - if (submission.submittedSequence !== undefined) { - return submission.submittedSequence < record.sequence && record.sequence < sent.sequence - } if (turn.startedAt === undefined || submission.resolvedAt === null) { return false } diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts index f93f01050685..03b6d133e3ed 100644 --- a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import type { + AgentJournalAnsweredTurn, AgentJournalItemBody, AgentJournalRenderItem, AgentJournalSubmission, @@ -94,12 +95,14 @@ const HOSTS = [ ] as const /** A host that publishes `submittedSequence` also moves a taken-back send's row to the row that - * took it back (`at`), in no turn; an older host leaves the row where it was sent. */ + * took it back (`at`), in no turn, and names the turn it was answered into, if any; an older host + * leaves the row where it was sent. */ function takenBackOn( published: boolean, items: AgentJournalRenderItem[], id: string, - at: number + at: number, + answeredInTurn?: AgentJournalAnsweredTurn ): { items: AgentJournalRenderItem[]; sent: Partial } { const key = agentJournalSubmissionKey(id) const row = items.find((item) => item.itemId === key)! @@ -113,7 +116,10 @@ function takenBackOn( ) return { items: moved.sort((left, right) => left.sequence - right.sequence), - sent: { submittedSequence: row.sequence } + sent: { + submittedSequence: row.sequence, + ...(answeredInTurn !== undefined ? { answeredInTurn } : {}) + } } } @@ -197,8 +203,8 @@ describe('a send a Stop took back before the agent started it', () => { expect(rows(items, [stopped('opened', { resolvedAt: 10 })])).toEqual([user('opened')]) }) - // A steer joined the running turn, but the host takes a rejected send out of its turn, so no - // published fact says which turn it joined: it is drawn after that turn, with its own row. + // A steer joined the running turn, and the host takes a rejected send out of its turn. It names + // the turn the steer joined, but it is drawn after that turn, with its own row, as before. it.each(HOSTS)( 'draws a steer it took back after the turn it joined, with its own row, on %s', (_host, published) => { @@ -226,7 +232,10 @@ describe('a send a Stop took back before the agent started it', () => { { kind: 'turn', turnItemId: 't1' } ) ] - const { items, sent: steer } = takenBackOn(published, steered, 'steer', sequence + 1) + const { items, sent: steer } = takenBackOn(published, steered, 'steer', sequence + 1, { + turnItemId: 't1', + via: 'steer' + }) expect(rows(items, [submission('first'), stopped('steer', steer)])).toEqual([ user('first'), @@ -237,8 +246,8 @@ describe('a send a Stop took back before the agent started it', () => { } ) - // The host moved the send's row past its turn's record to the row that took it back; journal - // order decides, not start times, and it is still that turn's opener. + // The host moved the send's row past its turn's record to the row that took it back, and names + // the turn it was answered into: it is still that turn's opener. it('stays in the turn it opened when its row moved past that turn to the take-back', () => { const opened = [ sent('opened', 'look around'), @@ -255,7 +264,10 @@ describe('a send a Stop took back before the agent started it', () => { { kind: 'turn', turnItemId: 'turn-1' } ) ] - const { items, sent: position } = takenBackOn(true, opened, 'opened', sequence + 1) + const { items, sent: position } = takenBackOn(true, opened, 'opened', sequence + 1, { + turnItemId: 'turn-1', + via: 'start' + }) expect(rows(items, [stopped('opened', position)])).toEqual([ user('opened'), From 808195bc24104b46be6045407afe7ed54476bc95 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:15:10 -0700 Subject: [PATCH 151/182] test(native-chat): an echoed opener keeps its turn from a send the host says started it --- .../native-chat-stopped-send-turn.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/shared/native-chat-stopped-send-turn.test.ts b/src/shared/native-chat-stopped-send-turn.test.ts index 741a99e32040..f74b3da712a0 100644 --- a/src/shared/native-chat-stopped-send-turn.test.ts +++ b/src/shared/native-chat-stopped-send-turn.test.ts @@ -119,6 +119,31 @@ describe('a send a Stop took back before its echo', () => { ]) }) + it('opens no turn whose echoed opener the record names, though the host says it started it', () => { + // A start Codex folded into a turn already running reads as `start`; the echo still decides. + const items = [ + sent('opener', 1), + turn('t1', 2, 'codex:thread-1:t1:0'), + stopNote('t1', 4), + sent('folded', 5) + ] + const submissions = [ + submission('opener', { submittedSequence: 1, providerItemId: 'codex:thread-1:t1:0' }), + stopped('folded', { + submittedSequence: 3, + answeredInTurn: { turnItemId: 't1', via: 'start' } + }) + ] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe(key('opener')) + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + key('opener'), + 'stop:t1', + key('folded'), + `${STOPPED_ROW}${key('folded')}` + ]) + }) + it('opens no turn the provider resumed on its own, whose record names itself', () => { // An older host's clock claim would hand this turn to a send sent before it and taken back after. const items = [sent('never-ran', 1), turn('wake', 2, 'wake', 5)] From 92cee883e8547c56655d5a574fee6e24ed63ec0d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:45:32 -0700 Subject: [PATCH 152/182] feat(native-chat): state on every rejection whether it names a turn Same change as #25073 at e02f98add56b, so this stack builds on it. --- .../journal-dispatch-answered-turn.test.ts | 78 ++++++ .../journal-dispatch-reducer.ts | 15 +- .../journal-row-builders.ts | 13 +- .../journal-row-schema.ts | 8 +- .../journal-submission-positions.test.ts | 14 +- .../agent-session-answered-turn-schema.ts | 7 + src/shared/agent-session-journal-schemas.ts | 4 +- src/shared/agent-session-journal-types.ts | 10 +- ...ubmission-positions-downgrade.unit.test.ts | 247 +++++++++++------- 9 files changed, 277 insertions(+), 119 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-dispatch-answered-turn.test.ts create mode 100644 src/shared/agent-session-answered-turn-schema.ts diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-answered-turn.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-answered-turn.test.ts new file mode 100644 index 000000000000..bba802939679 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-answered-turn.test.ts @@ -0,0 +1,78 @@ +// How a stored dispatch row's answered turn reads onto its submission: a named turn, none stated, +// or absent on a row written before the field existed. + +import { describe, expect, it } from 'vitest' +import { applyJournalRow, createJournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' + +const EPOCH = 'epoch-1' + +function base(seq: number): { v: number; epoch: string; seq: number; fence: number; ts: number } { + return { v: 1, epoch: EPOCH, seq, fence: 1, ts: 1_000 + seq } +} + +/** The submission after a send and one dispatch row, stored with the given extra keys. */ +function readAfter(dispatch: Partial>) { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow(state, { + kind: 'submission', + clientMessageId: 'cm_1', + payloadFingerprint: 'fp_1', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + ...base(1) + }) + applyJournalRow(state, { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: 'This message was withdrawn before the agent started it.', + ...base(2), + ...dispatch + }) + return state.submissions.get('cm_1') +} + +describe("a rejected dispatch row's answered turn", () => { + it('is the turn it names', () => { + expect(readAfter({ answeredInTurn: { turnItemId: 'turn-1', via: 'steer' } })).toMatchObject({ + answeredInTurn: { turnItemId: 'turn-1', via: 'steer' } + }) + }) + + it('is none when the row states none', () => { + expect(readAfter({ answeredInTurn: null })).toMatchObject({ answeredInTurn: null }) + }) + + it('is absent on a row written before the field existed', () => { + const submission = readAfter({}) + + expect(submission).toMatchObject({ dispatchState: 'rejected' }) + expect(submission).not.toHaveProperty('answeredInTurn') + }) + + it('reads as none, keeping the row, when it names a way of joining this build does not know', () => { + expect(readAfter({ answeredInTurn: { turnItemId: 'turn-1', via: 'resume' } })).toMatchObject({ + dispatchState: 'rejected', + answeredInTurn: null + }) + }) + + it('reads as none, keeping the row, when it names no turn id', () => { + expect(readAfter({ answeredInTurn: { turnItemId: '', via: 'start' } })).toMatchObject({ + dispatchState: 'rejected', + answeredInTurn: null + }) + }) + + it('is absent on any other dispatch state', () => { + const submission = readAfter({ + state: 'unknown', + answeredInTurn: { turnItemId: 'turn-1', via: 'start' } + }) + + expect(submission).toMatchObject({ dispatchState: 'unknown' }) + expect(submission).not.toHaveProperty('answeredInTurn') + }) +}) 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 b19fa2f1ce9f..3625982a24fa 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 @@ -35,9 +35,8 @@ export function applyJournalDispatchRow( } else { delete submission.rejection } - const answeredInTurn = row.state === 'rejected' ? readAnsweredTurn(row.answeredInTurn) : undefined - if (answeredInTurn) { - submission.answeredInTurn = answeredInTurn + if (row.state === 'rejected' && row.answeredInTurn !== undefined) { + submission.answeredInTurn = readAnsweredTurn(row.answeredInTurn) } else { delete submission.answeredInTurn } @@ -68,17 +67,17 @@ export function applyJournalDispatchRow( }) } -/** A stored answered turn; one malformed, or naming a way of joining this build does not know, is - * dropped, never the row. */ -function readAnsweredTurn(value: unknown): AgentJournalAnsweredTurn | undefined { +/** A stored answered turn. One malformed, or naming a way of joining this build does not know, is + * read as no turn: it was written knowing the field, so it is not an older row. */ +function readAnsweredTurn(value: unknown): AgentJournalAnsweredTurn | null { if (typeof value !== 'object' || value === null) { - return undefined + return null } const turnItemId = 'turnItemId' in value ? value.turnItemId : undefined const via = 'via' in value ? value.via : undefined return typeof turnItemId === 'string' && turnItemId && (via === 'start' || via === 'steer') ? { turnItemId, via } - : undefined + : null } /** A stored rejection fact, read where it can be placed; a kind it cannot place is kept as 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 4484f26a93f1..0ffaf89e0d99 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 @@ -110,12 +110,15 @@ export function journalDispatchRowBuilder( providerItemId, reason: boundedDispatchReason(input), ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), - ...(input.state === 'rejected' && input.answeredInTurn + // Every rejection states its turn, null for none, so a reader tells it from an older row. + ...(input.state === 'rejected' ? { - answeredInTurn: { - turnItemId: agentJournalItemKey(input.answeredInTurn.turn), - via: input.answeredInTurn.via - } + answeredInTurn: input.answeredInTurn + ? { + turnItemId: agentJournalItemKey(input.answeredInTurn.turn), + via: input.answeredInTurn.via + } + : null } : {}), ...journalRowBase(state().epoch, seq, input.fence, ts), 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 4957bd846be9..6ac0fb16cedd 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 @@ -16,7 +16,6 @@ import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - type AgentJournalAnsweredTurn, type AgentJournalDispatchState, type AgentJournalItemBody, type AgentJournalMessageItem, @@ -152,9 +151,10 @@ export type JournalDispatchRow = JournalRowBase & { * dropped when read, never the row. */ rejection?: AgentSessionFailureFact /** On `rejected`: the turn a Codex send was answered into, and how it joined it, when that - * turn's end settled the send. Absent on every other row. Older readers keep the key and ignore - * it; a malformed one is dropped when read, never the row. */ - answeredInTurn?: AgentJournalAnsweredTurn + * turn's end settled the send; null on every other rejection. Absent on other rows and on rows + * written before it. `via` stays a string: a newer build may write another. Older readers keep + * the key and ignore it; one this build cannot read is read as null, never dropping the row. */ + answeredInTurn?: { turnItemId: string; via: string } | null } /** An item mutation may name its own producer, because one batch can CREATE diff --git a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts index 2ad8953ea030..d8aa5b4870cc 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts @@ -119,14 +119,16 @@ describe('the turn a rejected submission was answered into', () => { }) }) - it('is absent on a take-back that names no turn, as on rows from older hosts', async () => { + it('is stated as none on a take-back that names no turn, so it reads apart from an older row', async () => { const { journal } = await sendHandedOverThenWithdrawn() - expect(journal.submission('send-1')).toMatchObject({ dispatchState: 'rejected' }) - expect(journal.submission('send-1')).not.toHaveProperty('answeredInTurn') - expect(readAgentSessionHydrationPage(journal).submissions[0]).not.toHaveProperty( - 'answeredInTurn' - ) + expect(journal.submission('send-1')).toMatchObject({ + dispatchState: 'rejected', + answeredInTurn: null + }) + expect(readAgentSessionHydrationPage(journal).submissions[0]).toMatchObject({ + answeredInTurn: null + }) }) }) diff --git a/src/shared/agent-session-answered-turn-schema.ts b/src/shared/agent-session-answered-turn-schema.ts new file mode 100644 index 000000000000..44f574d9ec14 --- /dev/null +++ b/src/shared/agent-session-answered-turn-schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +/** A submission's answered turn as published. `via` is a string, as `dispatchState` is: a newer + * host may name another way of joining. null: the host recorded no turn. */ +export const AgentJournalAnsweredTurnSchema = z + .object({ turnItemId: z.string().min(1), via: z.string().min(1) }) + .nullable() diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 7a21d5816a27..1bbaee0330c4 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -16,6 +16,7 @@ import { z } from 'zod' import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema' import { AgentSessionFailureFactSchema } from './agent-session-failure-fact-schema' +import { AgentJournalAnsweredTurnSchema } from './agent-session-answered-turn-schema' import type { AgentJournalItemBody, AgentJournalMessageItem, @@ -319,8 +320,7 @@ export const AgentJournalSubmissionSchema = z.object({ submittedAt: z.number(), resolvedAt: z.number().nullable(), submittedSequence: z.number().int().optional(), - // Open like `dispatchState`: a way of joining a newer host names must not drop the submission. - answeredInTurn: z.object({ turnItemId: z.string().min(1), via: z.string().min(1) }).optional(), + answeredInTurn: AgentJournalAnsweredTurnSchema.optional(), recovered: z.literal(true).optional(), handoverRecorded: z.literal(true).optional(), handedOverAt: z.number().optional(), diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index b6b33950de44..f6bf89421ef0 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -394,7 +394,8 @@ export type AgentJournalRenderItem = AgentJournalProducerLinkage & { /** The turn a send was answered into: its record's item id, and how the send joined it. `start`: * the provider answered the send's start request with that turn; `steer`: Orca steered it into * that running turn. Known limit: a start the provider silently folds into a running turn reads - * as `start`. Open: a newer host may name another way, which a reader leaves unclaimed. */ + * as `start`, including into a turn no user entry opened. A newer host may name another way, + * which a reader leaves unclaimed. */ export type AgentJournalAnsweredTurn = { turnItemId: string; via: AgentJournalTurnJoin } export type AgentJournalTurnJoin = 'start' | 'steer' /** The same, as a writer names it: the turn record's identity, keyed when the row is written. */ @@ -428,9 +429,10 @@ export type AgentJournalSubmission = { * where it was sent. Absent from hosts that predate it. */ submittedSequence?: number /** On `rejected`: the turn a Codex send was answered into, when that turn ended without taking - * it. Absent on every other send: accepted ones (the echo places them), Claude, queued - * take-backs, restart recovery, and rows from hosts that predate it. */ - answeredInTurn?: AgentJournalAnsweredTurn + * it. null: the host recorded that it was answered into no turn, as every other rejection is + * (Claude, a queued message taken back before handover, restart recovery). Absent: written + * before this field existed, or not rejected. */ + answeredInTurn?: AgentJournalAnsweredTurn | null /** Set when crash reconciliation resolved the dispatch, not the provider. A live * `unknown` is a send still outstanding; a recovered one outlived its writer. */ recovered?: true diff --git a/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts index 5265cfb1af99..0945d6a77284 100644 --- a/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/submission-positions-downgrade.unit.test.ts @@ -4,15 +4,23 @@ import { join } from 'node:path' import { expect, test } from 'vitest' import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' -import type { AgentSessionJournalIdentity } from '../../../src/shared/agent-session-journal-types' +import type { + AgentJournalSubmission, + AgentSessionJournalIdentity +} from '../../../src/shared/agent-session-journal-types' import type { AgentSessionHistoryPage } from '../../../src/shared/agent-session-wire' -import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { + createTrackedJournalOpener, + type TrackedJournalOpener +} from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { projectJournalBatch } from '../../../src/main/native-chat/agent-session-wire/agent-session-journal-batch' import { readAgentSessionHydrationPage } from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' -// A released client that predates the published submission position and answered turn: it must fold -// and draw a page carrying them exactly as it draws the same page without them. -const BASELINE_REF = 'v1.4.219' +// Released clients that predate the published submission position and answered turn: each must +// fold and draw a history page and an incremental batch carrying them exactly as it draws the same +// without them, whether the turn is named or stated as none. +const BASELINE_REFS = ['v1.4.219', 'v1.4.220'] as const const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-positions', @@ -34,103 +42,162 @@ function releaseExport(module: Record, name: string): T { type OldState = { submissions: Record[]; items: unknown[] } -/** Strips the published position and answered turn, as an older host's page would carry its - * submissions. */ -function withoutPosition(page: AgentSessionHistoryPage): AgentSessionHistoryPage { - return { - ...page, - submissions: page.submissions.map( - ({ submittedSequence: _submitted, answeredInTurn: _turn, ...rest }) => rest - ) - } +/** Submissions as an older host would carry them. */ +function withoutNewFields( + submissions: readonly AgentJournalSubmission[] +): AgentJournalSubmission[] { + return submissions.map( + ({ submittedSequence: _submitted, answeredInTurn: _turn, ...rest }) => rest + ) } -// Loads a real release checkout, cold extraction and transforms included. -test('a released client folds and draws a page whose submissions carry their journal position and answered turn', async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-submission-positions-downgrade-')) - const journals = createTrackedJournalOpener() - try { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) - for (const id of ['taken-back', 'still-queued']) { - await journal.appendSubmission({ - clientMessageId: id, - payloadFingerprint: id, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, - fence: 1, - handoverRecorded: true - }) - } - await journal.resolveDispatch({ - clientMessageId: 'taken-back', - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), +/** A chat before and after three rejections: a queued send taken back, one its turn's end + * rejected, and one refused outright. */ +async function journalWithRejections(directory: string, journals: TrackedJournalOpener) { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + const message = (text: string) => ({ + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text }] + }) + for (const id of ['taken-back', 'still-queued']) { + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: id, + body: message(id), fence: 1, - recovered: true + handoverRecorded: true }) + } + for (const id of ['turn-ended', 'refused']) { await journal.appendSubmission({ - clientMessageId: 'turn-ended', - payloadFingerprint: 'turn-ended', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'turn-ended' }] }, + clientMessageId: id, + payloadFingerprint: id, + body: message(id), fence: 1 }) - await journal.resolveDispatch({ - clientMessageId: 'turn-ended', - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), - answeredInTurn: { - turn: { - provider: 'legacy', - agent: 'codex', - sessionId: IDENTITY.sessionId, - recordId: 'turn-lifecycle:turn-1' - }, - via: 'start' + } + const before = readAgentSessionHydrationPage(journal, 1) + const seen = journal.snapshot().cursor + const cancelled = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + await journal.resolveDispatch({ + clientMessageId: 'taken-back', + state: 'rejected', + ...cancelled, + fence: 1, + recovered: true + }) + await journal.resolveDispatch({ + clientMessageId: 'turn-ended', + state: 'rejected', + ...cancelled, + answeredInTurn: { + turn: { + provider: 'legacy', + agent: 'codex', + sessionId: IDENTITY.sessionId, + recordId: 'turn-lifecycle:turn-1' }, - fence: 1 - }) - const page = readAgentSessionHydrationPage(journal, 1) - // Anti-vacuous: the page this build publishes does carry both. - expect(page.submissions.find((entry) => entry.clientMessageId === 'taken-back')).toEqual( - expect.objectContaining({ - submittedSequence: expect.any(Number) + via: 'start' + }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'refused', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('queueFull'), { surface: 'rejection' }), + fence: 1 + }) + const since = journal.readSince(seen) + const batch = since.ok + ? projectJournalBatch({ + rows: since.rows, + snapshot: journal.snapshot(), + afterSequence: seen.sequence }) - ) - expect(page.submissions.find((entry) => entry.clientMessageId === 'turn-ended')).toEqual( - expect.objectContaining({ - answeredInTurn: { turnItemId: expect.any(String), via: 'start' } - }) - ) + : null + if (!batch?.ok) { + throw new Error('the batch did not project') + } + return { before, after: readAgentSessionHydrationPage(journal, 1), batch: batch.batch } +} + +// Loads real release checkouts, cold extraction and transforms included. +test.each(BASELINE_REFS)( + 'a released client (%s) draws a page and a batch carrying journal positions and answered turns as it draws them without', + async (ref) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-submission-positions-downgrade-')) + const journals = createTrackedJournalOpener() + try { + const { before, after, batch } = await journalWithRejections(directory, journals) + // Anti-vacuous: what this build publishes carries a position, a named turn, and stated none. + expect(after.submissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ clientMessageId: 'taken-back', answeredInTurn: null }), + expect.objectContaining({ + clientMessageId: 'turn-ended', + submittedSequence: expect.any(Number), + answeredInTurn: { turnItemId: expect.any(String), via: 'start' } + }), + expect.objectContaining({ clientMessageId: 'refused', answeredInTurn: null }) + ]) + ) + expect(batch.submissions.map((entry) => entry.answeredInTurn)).toContain(null) - const checkout = await materializeReleaseCheckout(BASELINE_REF) - const reducer = await importReleaseCheckoutModule( - checkout, - 'src/shared/structured-agent-session-reducer.ts' - ) - const projection = await importReleaseCheckoutModule( - checkout, - 'src/shared/structured-agent-session-message-projection.ts' - ) - const reduce = releaseExport<(state: unknown, action: unknown) => OldState>( - reducer, - 'reduceStructuredAgentSession' - ) - const empty = releaseExport(reducer, 'EMPTY_STRUCTURED_AGENT_SESSION') - const project = releaseExport< - (items: unknown[], outbox: unknown[], submissions: unknown[]) => unknown[] - >(projection, 'projectStructuredAgentSessionMessages') - const draw = (from: AgentSessionHistoryPage) => { - const state = reduce(empty, { type: 'history-page', page: from }) - return { + const checkout = await materializeReleaseCheckout(ref) + const reducer = await importReleaseCheckoutModule( + checkout, + 'src/shared/structured-agent-session-reducer.ts' + ) + const projection = await importReleaseCheckoutModule( + checkout, + 'src/shared/structured-agent-session-message-projection.ts' + ) + const reduce = releaseExport<(state: unknown, action: unknown) => OldState>( + reducer, + 'reduceStructuredAgentSession' + ) + const empty = releaseExport(reducer, 'EMPTY_STRUCTURED_AGENT_SESSION') + const project = releaseExport< + (items: unknown[], outbox: unknown[], submissions: unknown[]) => unknown[] + >(projection, 'projectStructuredAgentSessionMessages') + const draw = (state: OldState) => ({ submissions: state.submissions.map( ({ submittedSequence: _s, answeredInTurn: _t, ...rest }) => rest ), messages: project(state.items, [], state.submissions) - } - } + }) + const fromPage = (page: AgentSessionHistoryPage) => + draw(reduce(empty, { type: 'history-page', page })) + const fromBatch = (stripped: boolean) => + draw( + reduce(reduce(empty, { type: 'history-page', page: before }), { + type: 'event', + event: { + type: 'batch', + sessionId: IDENTITY.sessionId, + batch: stripped + ? { ...batch, submissions: withoutNewFields(batch.submissions) } + : batch, + fence: 1 + } + }) + ) - expect(draw(page)).toEqual(draw(withoutPosition(page))) - } finally { - await journals.closeAll() - rmSync(directory, { recursive: true, force: true }) - } -}, 180_000) + expect(fromPage(after)).toEqual( + fromPage({ ...after, submissions: withoutNewFields(after.submissions) }) + ) + expect(fromBatch(false)).toEqual(fromBatch(true)) + // Anti-vacuous: the batch reached the old client's submissions. + expect(fromBatch(false).submissions).toEqual( + expect.arrayContaining([expect.objectContaining({ dispatchState: 'rejected' })]) + ) + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } + }, + 180_000 +) From ac6d23a3a03e3d28dd4d6dac266bd9c465bde932 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:47:34 -0700 Subject: [PATCH 153/182] fix(native-chat): place a stopped send from older history by journal order A rejection written before the host named turns carries no answered turn, though a newer host still publishes where it was sent and has moved its row to the take-back. Such a send is matched to its turn by journal order again, as before the host named turns; a rejection that states no turn opens none. --- .../native-chat-stopped-send-turn.test.ts | 107 +++++++++++++++++- src/shared/native-chat-turn-membership.ts | 28 +++-- ...ge-projection-stopped-before-start.test.ts | 8 +- 3 files changed, 126 insertions(+), 17 deletions(-) diff --git a/src/shared/native-chat-stopped-send-turn.test.ts b/src/shared/native-chat-stopped-send-turn.test.ts index f74b3da712a0..c5004cae3cc2 100644 --- a/src/shared/native-chat-stopped-send-turn.test.ts +++ b/src/shared/native-chat-stopped-send-turn.test.ts @@ -103,7 +103,7 @@ describe('a send a Stop took back before its echo', () => { sent('opener', 6) ] const submissions = [ - stopped('queued', { submittedSequence: 1 }), + stopped('queued', { submittedSequence: 1, answeredInTurn: null }), stopped('opener', { submittedSequence: 2, answeredInTurn: { turnItemId: 't1', via: 'start' } @@ -156,12 +156,17 @@ describe('a send a Stop took back before its echo', () => { ]) }) - it('opens no turn the host does not name, though it was sent before the record and taken back after', () => { + it('opens no turn the host states it named none for, though it was sent before the record and taken back after', () => { // A page whose turn's echoed opener is on an older page: only the record is loaded. // Its times would pass an older host's clock claim. const items = [turn('t1', 3, 'codex:thread-1:t1:0', 5), stopNote('t1', 4), sent('queued', 5)] const submissions = [ - stopped('queued', { submittedSequence: 2, submittedAt: 4, resolvedAt: 10 }) + stopped('queued', { + submittedSequence: 2, + submittedAt: 4, + resolvedAt: 10, + answeredInTurn: null + }) ] expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe('t1') @@ -185,7 +190,38 @@ describe('a send a Stop took back before its echo', () => { submittedSequence: 1, answeredInTurn: { turnItemId: 't1', via: 'start' } }), - stopped('follow-up', { submittedSequence: 3 }) + stopped('follow-up', { submittedSequence: 3, answeredInTurn: null }) + ] + + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + key('opener'), + 'stop:t1', + key('follow-up'), + `${STOPPED_ROW}${key('follow-up')}` + ]) + }) +}) + +// Rows a host wrote before it named turns carry neither the turn nor `null`. A newer host still +// publishes where each was sent and has moved its row to the take-back, so journal order places it. +describe('a send a Stop took back before its echo, in history written before the host named turns', () => { + it('opens the turn recorded after it was sent and before it was taken back', () => { + const items = [turn('t1', 2, 'codex:thread-1:t1:0'), stopNote('t1', 3), sent('opener', 4)] + const submissions = [stopped('opener', { submittedSequence: 1 })] + + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([key('opener'), 'stop:t1']) + }) + + it('opens it as the first sent, leaving a later send its own row after the turn', () => { + const items = [ + turn('t1', 3, 'codex:thread-1:t1:0'), + stopNote('t1', 4), + sent('opener', 5), + sent('follow-up', 6) + ] + const submissions = [ + stopped('follow-up', { submittedSequence: 2, submittedAt: 1 }), + stopped('opener', { submittedSequence: 1, submittedAt: 2 }) ] expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ @@ -195,6 +231,69 @@ describe('a send a Stop took back before its echo', () => { `${STOPPED_ROW}${key('follow-up')}` ]) }) + + it('opens no turn recorded before it was sent', () => { + const items = [turn('t1', 1, 'codex:thread-1:t1:0'), stopNote('t1', 3), sent('late', 4)] + const submissions = [stopped('late', { submittedSequence: 2 })] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe('t1') + }) +}) + +describe('a send a Stop took back that the host stated was answered into no turn', () => { + it('opens no turn the provider resumed on its own, as a Claude rejection reads', () => { + const items = [ + turn('wake', 2, 'wake', 5), + sent('never-ran', 3), + row( + 'wake-note', + 4, + { kind: 'status', text: 'Background task finished.' }, + { + kind: 'turn', + turnItemId: 'wake' + } + ) + ] + const submissions = [ + stopped('never-ran', { + submittedSequence: 1, + submittedAt: 4, + resolvedAt: 10, + answeredInTurn: null + }) + ] + + expect(structuredAgentTurnAnchors(items, submissions).get('wake')).toBe('wake') + expect(drawn(items, submissions).map(({ id }) => id)).toContain( + `${STOPPED_ROW}${key('never-ran')}` + ) + }) + + it('opens no turn a later Codex record names by key', () => { + const items = [turn('t1', 2, 'codex:thread-1:t1:0'), stopNote('t1', 3), sent('queued', 4)] + const submissions = [stopped('queued', { submittedSequence: 1, answeredInTurn: null })] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe('t1') + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + 'stop:t1', + key('queued'), + `${STOPPED_ROW}${key('queued')}` + ]) + }) + + it('opens no turn when it names a way of joining this build does not know', () => { + const items = [turn('t1', 2, 'codex:thread-1:t1:0'), stopNote('t1', 3), sent('later', 4)] + const submissions = [ + stopped('later', { + submittedSequence: 1, + // A newer host's value, which this build's type does not name. + answeredInTurn: JSON.parse('{"turnItemId":"t1","via":"resume"}') + }) + ] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe('t1') + }) }) describe('a steer a Stop took back before its echo', () => { diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index 6d5c17d98add..8c6e5cd32663 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -71,7 +71,8 @@ export function structuredAgentTurnAnchors( .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) // A send a Stop took back after its turn opened but before the provider echoed it: the record - // still names the provider's key, and the host names the turn the send was answered into. + // still names the provider's key, and the host names the turn the send was answered into, or + // none (null). A rejection written before that field is placed by journal order instead. const takenBack = submissions.filter( (submission) => !submission.providerItemId && @@ -82,13 +83,18 @@ export function structuredAgentTurnAnchors( const itemsById = takenBack.length > 0 ? new Map(items.map((item) => [item.itemId, item])) : undefined const openedBy = stoppedTurnOpeners(takenBack) - const unnamed = takenBack - .filter((submission) => submission.submittedSequence === undefined) + const unstated = takenBack + .filter((submission) => submission.answeredInTurn === undefined) .flatMap((submission) => { const sent = itemsById?.get(agentJournalSubmissionKey(submission.clientMessageId)) return sent ? [{ sent, submission }] : [] }) - .sort((left, right) => left.submission.submittedAt - right.submission.submittedAt) + const byJournal = unstated.every(({ submission }) => submission.submittedSequence !== undefined) + unstated.sort(({ submission: left }, { submission: right }) => + byJournal + ? left.submittedSequence! - right.submittedSequence! + : left.submittedAt - right.submittedAt + ) const claimed = new Set() const anchors = new Map() let precedingUserItemId: string | null = null @@ -122,7 +128,7 @@ export function structuredAgentTurnAnchors( if (named !== undefined && itemsById?.has(named)) { anchor = named } else if (anchor === item.itemId) { - const opener = unnamed.find( + const opener = unstated.find( ({ sent, submission }) => !claimed.has(sent.itemId) && sentBeforeAndTakenBackAfter(item, turn, submission, sent) ) @@ -152,16 +158,20 @@ export function stoppedTurnOpeners( return openers } -/** Whether a send was sent before a turn record and taken back after it, by its row's place and by - * times. Temporary: the journal-order fallback for a host that names no turn, which also lacks - * `submittedSequence` and leaves rows where they were sent or only moves them (#24710); a resume - * rewrites its times to whole seconds. Dropped once no supported remote host lacks the field. */ +/** Whether a send was sent before a turn record and taken back after it: by journal order where + * the host publishes where it was sent (a rejection written before the host named turns, whose row + * it moves to the take-back), else by its row's place and by times, which a resume rewrites to + * whole seconds. Temporary, and long-lived: kept until no supported chat holds a rejection written + * before #25073, which old chats do indefinitely. */ function sentBeforeAndTakenBackAfter( record: AgentJournalRenderItem, turn: AgentJournalTurnLifecycle, submission: AgentJournalSubmission, sent: AgentJournalRenderItem ): boolean { + if (submission.submittedSequence !== undefined) { + return submission.submittedSequence < record.sequence && record.sequence < sent.sequence + } if (turn.startedAt === undefined || submission.resolvedAt === null) { return false } diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts index 03b6d133e3ed..73e93915dfd5 100644 --- a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -95,14 +95,14 @@ const HOSTS = [ ] as const /** A host that publishes `submittedSequence` also moves a taken-back send's row to the row that - * took it back (`at`), in no turn, and names the turn it was answered into, if any; an older host - * leaves the row where it was sent. */ + * took it back (`at`), in no turn, and states the turn it was answered into, null for none; an + * older host leaves the row where it was sent. */ function takenBackOn( published: boolean, items: AgentJournalRenderItem[], id: string, at: number, - answeredInTurn?: AgentJournalAnsweredTurn + answeredInTurn: AgentJournalAnsweredTurn | null = null ): { items: AgentJournalRenderItem[]; sent: Partial } { const key = agentJournalSubmissionKey(id) const row = items.find((item) => item.itemId === key)! @@ -118,7 +118,7 @@ function takenBackOn( items: moved.sort((left, right) => left.sequence - right.sequence), sent: { submittedSequence: row.sequence, - ...(answeredInTurn !== undefined ? { answeredInTurn } : {}) + answeredInTurn } } } From e171d49f8c0c689bf1553e47d61bb48a4e57c053 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:00:50 -0700 Subject: [PATCH 154/182] docs(native-chat): say an unreadable answered turn reads as none Same change as #25073 at 4ab6cc431c01. --- src/shared/agent-session-journal-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index f6bf89421ef0..832908795a11 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -431,7 +431,7 @@ export type AgentJournalSubmission = { /** On `rejected`: the turn a Codex send was answered into, when that turn ended without taking * it. null: the host recorded that it was answered into no turn, as every other rejection is * (Claude, a queued message taken back before handover, restart recovery). Absent: written - * before this field existed, or not rejected. */ + * before this field existed, or not rejected. A stored value this build cannot read is null. */ answeredInTurn?: AgentJournalAnsweredTurn | null /** Set when crash reconciliation resolved the dispatch, not the provider. A live * `unknown` is a send still outstanding; a recovered one outlived its writer. */ From edc1195aaaece2eef863f38d62d3f4042a48ac9f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:20:02 -0700 Subject: [PATCH 155/182] refactor(native-chat): order stopped sends without non-null assertions One helper sorts by the published send position when every entry has it, else by accept time, and both stopped-send sorts use it. The rule kept for rejections written before the host named turns is labelled as kept for good: it draws those rows exactly as before. --- src/shared/native-chat-send-order.ts | 24 +++++++++++++++ .../native-chat-stopped-before-start.ts | 17 ++++------- src/shared/native-chat-turn-membership.ts | 29 +++++++++---------- 3 files changed, 43 insertions(+), 27 deletions(-) create mode 100644 src/shared/native-chat-send-order.ts diff --git a/src/shared/native-chat-send-order.ts b/src/shared/native-chat-send-order.ts new file mode 100644 index 000000000000..5cd9349d771f --- /dev/null +++ b/src/shared/native-chat-send-order.ts @@ -0,0 +1,24 @@ +// The order a client's sends were made in, as the host published it. + +import type { AgentJournalSubmission } from './agent-session-journal-types' + +type SendTimes = Pick + +/** Entries in the order their sends were made: by the published `submittedSequence` when every one + * carries it, else by accept time. Stable, so ties keep the order given. */ +export function inSendOrder(entries: readonly T[], submissionOf: (entry: T) => SendTimes): T[] { + const keyed = entries.map((entry) => { + const { submittedSequence, submittedAt } = submissionOf(entry) + return { entry, submittedSequence, submittedAt } + }) + const sorted = allPositioned(keyed) + ? [...keyed].sort((left, right) => left.submittedSequence - right.submittedSequence) + : [...keyed].sort((left, right) => left.submittedAt - right.submittedAt) + return sorted.map(({ entry }) => entry) +} + +function allPositioned( + entries: T[] +): entries is (T & { submittedSequence: number })[] { + return entries.every((entry) => entry.submittedSequence !== undefined) +} diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts index 2dd508df0411..5b2b6c1fdc21 100644 --- a/src/shared/native-chat-stopped-before-start.ts +++ b/src/shared/native-chat-stopped-before-start.ts @@ -11,6 +11,7 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import { inSendOrder } from './native-chat-send-order' import { stoppedTurnOpeners, structuredAgentTurnAnchors } from './native-chat-turn-membership' import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' @@ -192,7 +193,7 @@ function latestRowsSentBefore( submissions: readonly AgentJournalSubmission[], itemsById: ReadonlyMap ): ReadonlyMap { - const inSendOrder = submissions + const byAcceptTime = submissions .map((submission, order) => ({ submission, order })) .sort( (left, right) => @@ -200,7 +201,7 @@ function latestRowsSentBefore( ) const before = new Map() let latest: AgentJournalRenderItem | undefined - for (const { submission } of inSendOrder) { + for (const { submission } of byAcceptTime) { const key = agentJournalSubmissionKey(submission.clientMessageId) if (latest) { before.set(key, latest) @@ -231,17 +232,11 @@ export function keepStoppedSendsInSendOrder( indexById.set(message.id, index) } }) - const taken = [...stopped].flatMap(([itemId, submission], order) => { + const taken = [...stopped].flatMap(([itemId, submission]) => { const index = indexById.get(itemId) - return index === undefined ? [] : [{ index, submission, order }] + return index === undefined ? [] : [{ index, submission }] }) - const byJournal = taken.every((entry) => entry.submission.submittedSequence !== undefined) - const sent = taken.sort( - (left, right) => - (byJournal - ? left.submission.submittedSequence! - right.submission.submittedSequence! - : left.submission.submittedAt - right.submission.submittedAt) || left.order - right.order - ) + const sent = inSendOrder(taken, ({ submission }) => submission) let floor: AgentJournalPosition | undefined let moved = false for (const { index } of sent) { diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index 8c6e5cd32663..2c5fd5c6d539 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -27,6 +27,7 @@ import { import type { NativeChatRole } from './native-chat-types' import { isStructuredAgentSessionCommandTurn } from './structured-agent-session-command-entry' import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' +import { inSendOrder } from './native-chat-send-order' import { liveStructuredAgentSessionTurnScope } from './structured-agent-session-live-turn' /** Whether the host writing this journal states each row's turn. Only a host that runs `/compact` @@ -83,17 +84,14 @@ export function structuredAgentTurnAnchors( const itemsById = takenBack.length > 0 ? new Map(items.map((item) => [item.itemId, item])) : undefined const openedBy = stoppedTurnOpeners(takenBack) - const unstated = takenBack - .filter((submission) => submission.answeredInTurn === undefined) - .flatMap((submission) => { - const sent = itemsById?.get(agentJournalSubmissionKey(submission.clientMessageId)) - return sent ? [{ sent, submission }] : [] - }) - const byJournal = unstated.every(({ submission }) => submission.submittedSequence !== undefined) - unstated.sort(({ submission: left }, { submission: right }) => - byJournal - ? left.submittedSequence! - right.submittedSequence! - : left.submittedAt - right.submittedAt + const unstated = inSendOrder( + takenBack + .filter((submission) => submission.answeredInTurn === undefined) + .flatMap((submission) => { + const sent = itemsById?.get(agentJournalSubmissionKey(submission.clientMessageId)) + return sent ? [{ sent, submission }] : [] + }), + ({ submission }) => submission ) const claimed = new Set() const anchors = new Map() @@ -158,11 +156,10 @@ export function stoppedTurnOpeners( return openers } -/** Whether a send was sent before a turn record and taken back after it: by journal order where - * the host publishes where it was sent (a rejection written before the host named turns, whose row - * it moves to the take-back), else by its row's place and by times, which a resume rewrites to - * whole seconds. Temporary, and long-lived: kept until no supported chat holds a rejection written - * before #25073, which old chats do indefinitely. */ +/** For a rejection written before the host named turns: whether its send was sent before a turn + * record and taken back after it. By journal order where the host publishes where it was sent (it + * has moved the row to the take-back), else by its row's place and by times, which a resume + * rewrites to whole seconds. Kept for good: it draws those older rows exactly as before. */ function sentBeforeAndTakenBackAfter( record: AgentJournalRenderItem, turn: AgentJournalTurnLifecycle, From 9f3cd5e5310a8e6a76a91792984d042669f75bb0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:20:47 -0700 Subject: [PATCH 156/182] docs(native-chat): say which half of the older-history turn rule is kept for good --- src/shared/native-chat-turn-membership.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index 2c5fd5c6d539..758f32e3304a 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -158,8 +158,9 @@ export function stoppedTurnOpeners( /** For a rejection written before the host named turns: whether its send was sent before a turn * record and taken back after it. By journal order where the host publishes where it was sent (it - * has moved the row to the take-back), else by its row's place and by times, which a resume - * rewrites to whole seconds. Kept for good: it draws those older rows exactly as before. */ + * has moved the row to the take-back): kept for good, as old chats keep those rows, and it draws + * them exactly as before. Else, on a host that publishes neither field, by its row's place and by + * times, which a resume rewrites to whole seconds; Temporary, until a host version floor. */ function sentBeforeAndTakenBackAfter( record: AgentJournalRenderItem, turn: AgentJournalTurnLifecycle, From e278b7a02107d7a204dc1dec18c71a604f2b7818 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:18:08 -0700 Subject: [PATCH 157/182] fix(native-chat): word a send after a Stop by whether this send will queue The 'queue a message to run after the stop' placeholder read the host's queue capability alone. A send queues only when the host queues and this send asks it to: the queue setting is on and no pending prompt blocks the queue. Desktop and phone now word the placeholder from that same decision their send uses. --- ...agent-session-newer-approval-send.test.tsx | 40 +++++++++++++ .../use-mobile-structured-agent-session.ts | 11 +++- ...use-mobile-structured-send-with-outcome.ts | 13 +++- ...iveChatStructuredSession.stopping.test.tsx | 2 +- ...tiveChatStructuredSession.test-harness.tsx | 6 +- .../native-chat-structured-stop-controls.ts | 4 +- ...tured-agent-session.queued-gating.test.tsx | 59 +++++++++++-------- .../use-structured-agent-session.ts | 6 +- ...tructured-agent-session-outbox-delivery.ts | 10 +++- 9 files changed, 113 insertions(+), 38 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx index 594a84acf5fe..9ed32c49579d 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx @@ -127,3 +127,43 @@ it('still queues beside a prompt this build can answer', async () => { ]) ).toBe('queue-if-active') }) + +const RUNNING_TURN: AgentJournalRenderItem = { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 1, + body: { kind: 'turn', turnId: 'provider-turn', state: 'running' } +} + +/** What the composer says a send does while this phone's Stop is still in flight. */ +async function afterStop(items: AgentJournalRenderItem[]): Promise { + // The Stop's request stays in flight, so the phone keeps reading Stopping. + sendRequest.mockImplementation(async (method) => + method === 'agentSession.cancel' + ? new Promise(() => undefined) + : method === 'agentSession.options' + ? ok({ models: [], current: {} }) + : ok({}) + ) + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + const event = snapshotEvent() + if (event.type === 'snapshot') { + event.page.items = [RUNNING_TURN, ...items] + } + act(() => listener?.(event)) + act(() => hook?.cancel()) + expect(hook?.turnIndicator.stopping).toBe(true) + return hook?.turnIndicator.afterStop +} + +it('says a send after a Stop is sent, not queued, while every pending prompt is one this build cannot answer', async () => { + expect(await afterStop([approval({ kind: 'diff', path: 'a.ts' })])).toBe('send') +}) + +it('says a send after a Stop is queued beside a prompt this build can answer', async () => { + expect(await afterStop([approval({ kind: 'plan', text: 'do it' })])).toBe('queue') +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index a5c2e6bb5428..0ab0420dcf04 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -37,6 +37,7 @@ import { import { useMobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' import { agentStopDisplayStatus } from '../../../src/shared/agent-stop-display-status' import { + mobileStructuredSendQueues, useMobileStructuredSendWithOutcome, type StructuredMobileSendAttachment } from './use-mobile-structured-send-with-outcome' @@ -209,9 +210,15 @@ export function useMobileStructuredAgentSession(args: { activityText, stopping, stopRequestInFlight, - ...(stopping ? { afterStop: queueCapable ? ('queue' as const) : ('send' as const) } : {}) + ...(stopping + ? { + afterStop: mobileStructuredSendQueues(queueCapable, state.items) + ? ('queue' as const) + : ('send' as const) + } + : {}) }), - [thinking, activityText, stopping, stopRequestInFlight, queueCapable] + [thinking, activityText, stopping, stopRequestInFlight, queueCapable, state.items] ) const status = state.status === 'idle' ? 'idle' : state.status const approvalPrompt = useMemo( diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index 613dd14fe084..366a77930a63 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -22,6 +22,15 @@ import { pendingStructuredQuestion } from './mobile-structured-agent-prompts' +/** Whether a send made now asks the host to queue it. The host's queue waits on any pending prompt; + * one this build cannot answer would hold the send forever, so it starts a turn instead. */ +export function mobileStructuredSendQueues( + queueCapable: boolean, + items: StructuredAgentSessionState['items'] +): boolean { + return queueCapable && !pendingPromptsAllUnanswerableHere(items) +} + export type StructuredMobileSendAttachment = StructuredAgentSessionAttachment & { id?: string contentFingerprint?: string @@ -117,9 +126,7 @@ export function useMobileStructuredSendWithOutcome(args: { expectedRuntimeFence: currentFence, text, attachments: sendAttachments, - // The host's queue waits on any pending prompt; one this build cannot answer would hold - // the send forever, so it starts a turn, whose card cancel then works. - ...(queueCapable && !pendingPromptsAllUnanswerableHere(stateRef.current.items) + ...(mobileStructuredSendQueues(queueCapable, stateRef.current.items) ? { delivery: 'queue-if-active' as const } : {}), deadline, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx index 2c994866d378..12daaa98afba 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx @@ -95,7 +95,7 @@ describe("the chat pane while a person's Stop ends the turn", () => { it('says a message is queued to run after the stop only where the host queues sends', async () => { mocks.turnId = 'turn-1' - mocks.queueCapable = true + mocks.sendsQueue = true renderPane() await waitFor(() => expect(hostStatus.emit).not.toBeNull()) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index df44a56cee88..668619a07394 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -148,7 +148,7 @@ export function createStructuredSessionMocks() { // Unset: Stop follows the turn, as against an older host. canStop: nullable(), stopPressed: false, - queueCapable: false, + sendsQueue: false, supportsBackgroundTaskStop: false, supportsBackgroundTaskStopAll: true, backgroundTasks: [] as AgentSessionBackgroundTask[], @@ -238,7 +238,7 @@ export function createStructuredSessionMocks() { turnId: mocks.turnId, canStop: mocks.canStop ?? mocks.turnId !== null, stopPressed: mocks.stopPressed, - queueCapable: mocks.queueCapable, + sendsQueue: mocks.sendsQueue, stop: mocks.stop, queuedMessages: { cards: mocks.queuedCards, @@ -373,7 +373,7 @@ export function createStructuredSessionMocks() { mocks.turnId = null mocks.canStop = null mocks.stopPressed = false - mocks.queueCapable = false + mocks.sendsQueue = false mocks.supportsBackgroundTaskStop = false mocks.supportsBackgroundTaskStopAll = true mocks.stopBackgroundTask.mockReset() diff --git a/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts b/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts index f3ff1926be35..423e114647f7 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-stop-controls.ts @@ -4,7 +4,7 @@ import type { NativeChatAfterStopSend } from './native-chat-composer-target' type StopController = Pick< ReturnType, - 'canStop' | 'stopPressed' | 'stop' | 'queuedMessages' | 'queueCapable' + 'canStop' | 'stopPressed' | 'stop' | 'queuedMessages' | 'sendsQueue' > /** @@ -38,7 +38,7 @@ export function nativeChatStructuredStopControls( isStopping: stopInFlight, onStop: () => void (stopInFlight || controller.stop()), steerQueued: stopping ? undefined : controller.queuedMessages.steerNewest, - afterStop: stopping ? (controller.queueCapable ? 'queue' : 'send') : undefined + afterStop: stopping ? (controller.sendsQueue ? 'queue' : 'send') : undefined } } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index bef56bf7f81e..d866e86feeb1 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -74,6 +74,7 @@ import { readNativeChatDraftCache } from './native-chat-draft-cache' import { useStructuredAgentSession } from './use-structured-agent-session' +import { nativeChatStructuredStopControls } from './native-chat-structured-stop-controls' const RUNNING_TURN: AgentJournalRenderItem = { itemId: 'turn-1', @@ -83,6 +84,28 @@ const RUNNING_TURN: AgentJournalRenderItem = { body: { kind: 'turn', turnId: 'provider-turn', state: 'running' } } +/** A pending approval; one of a subject kind this build does not know cannot be answered here. */ +function approval(subject: Record): AgentJournalRenderItem { + return JSON.parse( + JSON.stringify({ + itemId: `approval-${String(subject.kind)}`, + revision: 1, + sequence: 2, + observedAt: 1, + body: { + kind: 'approval', + title: 'Review', + detail: null, + subject, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) + ) +} + +const newerApproval = (): AgentJournalRenderItem => approval({ kind: 'diff', path: 'a.ts' }) + function draft(id: string): AgentSessionQueuedMessage { return { messageId: id, @@ -158,31 +181,21 @@ describe('against a capable host', () => { }) }) + // While a Stop runs, the composer says what a send made now does: queued after the stop, or sent. + it('words the send after a Stop as this send will go, not by the capability alone', () => { + const afterStop = (queueFollowUps?: boolean) => { + const { result } = render(queueFollowUps) + return nativeChatStructuredStopControls(result.current, true).composer.afterStop + } + expect(afterStop()).toBe('queue') + expect(afterStop(false)).toBe('send') + items = [RUNNING_TURN, newerApproval()] + expect(afterStop()).toBe('send') + }) + // The host's queue would hold a send behind a prompt nothing here can settle. it('sends immediately while every pending prompt is one this build cannot answer', () => { - const approval = (subject: Record): AgentJournalRenderItem => - JSON.parse( - JSON.stringify({ - itemId: `approval-${String(subject.kind)}`, - revision: 1, - sequence: 2, - observedAt: 1, - body: { - kind: 'approval', - title: 'Review', - detail: null, - subject, - options: [{ id: 'allow', label: 'Approve' }], - resolution: { - state: 'pending', - selectedOptionId: null, - resolvedBy: null, - resolvedAt: null - } - } - }) - ) - const newer = approval({ kind: 'diff', path: 'a.ts' }) + const newer = newerApproval() items = [newer] render() expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index e7cc0d1b37f8..07a6509c7f73 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -40,6 +40,7 @@ import { outboxOutsideQueuedCards } from './structured-agent-session-queued-card import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { agentStopDisplayStatus } from '../../../../shared/agent-stop-display-status' +import { structuredAgentSessionNewSendsQueue } from '../../../../shared/structured-agent-session-outbox-delivery' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names' @@ -276,8 +277,9 @@ export function useStructuredAgentSession(args: { : Promise.resolve(null) }, queuedMessages: queuedController, - /** The host holds a send made while the agent works as a queued card. */ - queueCapable, + /** A send made now while the agent works is held as a queued card: the host queues, and this + * send asks it to (the setting is on and no pending prompt blocks the queue). */ + sendsQueue: structuredAgentSessionNewSendsQueue(queueDelivery), cancel: async (turnId: string, prompt?: StructuredPromptCancelTarget) => { // Capability negotiation must complete before mutate fingerprints the payload: // older hosts reject the strict prompt field. diff --git a/src/shared/structured-agent-session-outbox-delivery.ts b/src/shared/structured-agent-session-outbox-delivery.ts index ab3e8be4b2d2..067cf2e729ee 100644 --- a/src/shared/structured-agent-session-outbox-delivery.ts +++ b/src/shared/structured-agent-session-outbox-delivery.ts @@ -17,6 +17,13 @@ export type StructuredAgentSessionQueueDelivery = { enabled: boolean } +/** Whether a send made now asks to be queued: a host known to queue, with queueing enabled. */ +export function structuredAgentSessionNewSendsQueue( + host: StructuredAgentSessionQueueDelivery +): boolean { + return host.capability === 'supported' && host.enabled +} + /** Whether this entry's next request asks to be queued. An attempted id asks exactly what it sent, * except of a host known not to queue, which rejects the field before its operation ledger, so * nothing there was recorded with it. A first attempt asks only of a host known to queue, with @@ -29,8 +36,7 @@ export function structuredAgentSessionEntryAsksToQueue( return entry.sentDelivery === 'queue-if-active' && host.capability !== 'unsupported' } return ( - host.capability === 'supported' && - host.enabled && + structuredAgentSessionNewSendsQueue(host) && entry.source !== 'launch' && entry.body.blocks.every((block) => block.type === 'text') ) From c6f96995f4eb5d57f7cfce197db946644faf5762 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:27:18 -0700 Subject: [PATCH 158/182] test(native-chat): one test per case for the words of a send after a Stop --- ...tured-agent-session.queued-gating.test.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index d866e86feeb1..a76a6c008e88 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -182,15 +182,24 @@ describe('against a capable host', () => { }) // While a Stop runs, the composer says what a send made now does: queued after the stop, or sent. - it('words the send after a Stop as this send will go, not by the capability alone', () => { + describe('the words for a send after a Stop', () => { const afterStop = (queueFollowUps?: boolean) => { const { result } = render(queueFollowUps) return nativeChatStructuredStopControls(result.current, true).composer.afterStop } - expect(afterStop()).toBe('queue') - expect(afterStop(false)).toBe('send') - items = [RUNNING_TURN, newerApproval()] - expect(afterStop()).toBe('send') + + it('say it queues while the setting is on', () => { + expect(afterStop()).toBe('queue') + }) + + it('say it is sent while the setting is off, though the host queues', () => { + expect(afterStop(false)).toBe('send') + }) + + it('say it is sent while every pending prompt is one this build cannot answer', () => { + items = [RUNNING_TURN, newerApproval()] + expect(afterStop()).toBe('send') + }) }) // The host's queue would hold a send behind a prompt nothing here can settle. From 76030540a928298212ecaa7643a1aad367ca052d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:26:15 -0700 Subject: [PATCH 159/182] refactor(native-chat): the dictation hook owns the composer's dictation state Keeps NativeChatComposer within its line limit after the Stop props and main's /context answer both landed in it. --- .../native-chat/NativeChatComposer.tsx | 22 +++++-------- .../use-native-chat-dictation-actions.ts | 33 ++++++++++++++----- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index ebcb8dcd1cde..5582a63e93cf 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -1,6 +1,5 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' import { forwardRef, useCallback, useState } from 'react' -import { useAppStore } from '../../store' import { useNativeChatComposerInterrupt } from './use-native-chat-composer-interrupt' import { useNativeChatContextUsageSummary } from './use-native-chat-context-usage-summary' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' @@ -97,21 +96,20 @@ const NativeChatComposerPane = forwardRef(EMPTY_HISTORY) const [activeSuggestion, setActiveSuggestion] = useState(0) const [notice, setNotice] = useState(null) - const [dictationPressed, setDictationPressed] = useState(false) const { textareaRef } = useNativeChatComposerAppMenuSelection(imeEnterGesture.isComposing) + const { + dictationDisabled, + isDictating, + isDictationHoldMode, + toggleDictation, + startHoldDictation, + stopHoldDictation + } = useNativeChatDictationActions(textareaRef) const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( terminalTabId, targetPtyId, onOptimisticSendCanceled ) - const dictationState = useAppStore((store) => store.dictationState) - const voiceSettings = useAppStore((store) => store.settings?.voice) - const dictationDisabled = voiceSettings?.enabled !== true || !voiceSettings.sttModel - const isDictating = - dictationPressed || - dictationState === 'starting' || - dictationState === 'listening' || - dictationState === 'stopping' const { agentCommands, sessionSkillNames } = useNativeChatComposerCatalog( agent, @@ -226,8 +224,6 @@ const NativeChatComposerPane = forwardRef { diff --git a/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts b/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts index 5c3d1e7b646d..10734330cfb6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts @@ -1,16 +1,22 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' -import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react' +import { useCallback, useState, type RefObject } from 'react' import { dispatchDictationControl } from '../dictation/dictation-control-events' +import { useAppStore } from '../../store' -export function useNativeChatDictationActions(args: { +/** The composer's dictation: whether it can dictate, whether it is, and the press actions. */ +export function useNativeChatDictationActions( textareaRef: RefObject - setDictationPressed: Dispatch> -}): { +): { + dictationDisabled: boolean + isDictating: boolean + isDictationHoldMode: boolean toggleDictation: () => void startHoldDictation: () => void stopHoldDictation: () => void } { - const { setDictationPressed, textareaRef } = args + const [dictationPressed, setDictationPressed] = useState(false) + const dictationState = useAppStore((store) => store.dictationState) + const voiceSettings = useAppStore((store) => store.settings?.voice) const focusForDictation = useCallback(() => textareaRef.current?.focus(), [textareaRef]) const toggleDictation = useCallback(() => { focusForDictation() @@ -20,10 +26,21 @@ export function useNativeChatDictationActions(args: { setDictationPressed(true) focusForDictation() dispatchDictationControl('start') - }, [focusForDictation, setDictationPressed]) + }, [focusForDictation]) const stopHoldDictation = useCallback(() => { setDictationPressed(false) dispatchDictationControl('stop') - }, [setDictationPressed]) - return { toggleDictation, startHoldDictation, stopHoldDictation } + }, []) + return { + dictationDisabled: voiceSettings?.enabled !== true || !voiceSettings.sttModel, + isDictating: + dictationPressed || + dictationState === 'starting' || + dictationState === 'listening' || + dictationState === 'stopping', + isDictationHoldMode: voiceSettings?.dictationMode === 'hold', + toggleDictation, + startHoldDictation, + stopHoldDictation + } } From 3bd8479069919c0e5826dca049892d21bb9b3465 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:35:05 -0700 Subject: [PATCH 160/182] fix(mobile-chat): a send a Stop took back is not handed back to the draft A send whose answer says a Stop withdrew it is drawn in the chat with its stop row, so handing its text back to the composer as well showed it twice. It reads as sent and spends its id; other rejections still go back to the draft as main has them. --- .../mobile-structured-send-delivery.test.ts | 25 ++++++++++++++ .../mobile-structured-send-delivery.ts | 6 ++++ ...ile-structured-agent-session-send.test.tsx | 33 +++++++++++++++++-- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index 8431e32a7aa9..5aa987d71b34 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalDispatchState } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' +import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' @@ -125,6 +126,30 @@ describe('mobileStructuredSendDelivery', () => { }) }) + it('reports a send a Stop took back as sent: the chat draws it, so nothing returns to the draft', () => { + for (const retained of [false, true]) { + for (const reason of [ + DISPATCH_REJECTED_CANCELLED, + 'This message was withdrawn before the agent started it.' + ]) { + const withdrawn = accepted('rejected', reason) + // The older marker alone, and the sentence with its typed fact. + if ( + reason !== DISPATCH_REJECTED_CANCELLED && + withdrawn.status === 'accepted' && + 'submission' in withdrawn.value + ) { + withdrawn.value.submission.rejection = { kind: 'cancelled' } + } + expect(mobileStructuredSendDelivery(withdrawn, retained)).toEqual({ + outcome: 'accepted', + operationIdSpent: true, + error: null + }) + } + } + }) + it('shows a provider content rejection verbatim', () => { expect( mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp')) diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index f20dc0971887..d93c59c5082f 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -30,6 +30,7 @@ import type { AgentSessionSendResult } from '../../../src/shared/agent-session-w import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry' import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition' +import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' @@ -96,6 +97,11 @@ export function mobileStructuredSendDelivery( if (!submission || submission.dispatchState === 'unknown') { return { outcome: 'unknown', operationIdSpent: false, error: null } } + if (submission.dispatchState === 'rejected' && dispatchWasWithdrawn(submission)) { + // The host recorded it, then a Stop took it back: the chat draws it with its stop row, so it is + // not handed back to the composer, which would show it twice. + return { outcome: 'accepted', operationIdSpent: true, error: null } + } if (submission.dispatchState === 'rejected') { return { outcome: 'rejected', diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 086482537ef2..66eca5e6a6ed 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -2,6 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalDispatchState } from '../../../src/shared/agent-session-journal-types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' @@ -21,13 +22,13 @@ function ok(result: unknown) { return { ok: true, result, _meta: { runtimeId: 'runtime-1' } } } -function sendResult(dispatchState: AgentJournalDispatchState) { +function sendResult(dispatchState: AgentJournalDispatchState, reason: string | null = null) { return ok({ ok: true, replayed: false, fence: 3, cursor: { epoch: 'epoch-1', sequence: 1 }, - value: structuredSendResultFixture(dispatchState) + value: structuredSendResultFixture(dispatchState, reason) }) } @@ -151,6 +152,34 @@ describe('mobile structured send retries', () => { expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true) }) + it('reads a replay a Stop took back as sent, saying nothing, and spends its id', async () => { + let attempts = 0 + sendRequest.mockImplementation(async (method) => { + if (method !== 'agentSession.send') { + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + } + attempts += 1 + if (attempts === 1) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + // Recorded, then withdrawn by a Stop before the agent started it. + return sendResult('rejected', DISPATCH_REJECTED_CANCELLED) + }) + await mountSession() + + await act(async () => { + expect(await hook!.sendWithOutcome('stopped one')).toBe('unknown') + // The chat draws it, stopped: nothing goes back to the draft and no "not sent" shows. + expect(await hook!.sendWithOutcome('stopped one')).toBe('accepted') + expect(await hook!.sendWithOutcome('stopped one')).toBe('accepted') + }) + + expect(onSendError).not.toHaveBeenCalled() + const [first, replay, later] = sentIds() + expect(replay).toBe(first) + expect(later).not.toBe(first) + }) + it('releases an ack-lost id after the journal accepts it for a later identical intent', async () => { let attempts = 0 sendRequest.mockImplementation(async (method) => { From 4acb3df6b7c9581c8e238086805cc5b35d628bdb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:03:55 -0700 Subject: [PATCH 161/182] refactor(native-chat): name the dictation hook for what it owns now --- .../src/components/native-chat/NativeChatComposer.tsx | 4 ++-- ...chat-dictation-actions.ts => use-native-chat-dictation.ts} | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) rename src/renderer/src/components/native-chat/{use-native-chat-dictation-actions.ts => use-native-chat-dictation.ts} (93%) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 5582a63e93cf..cfb861e79436 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -19,7 +19,7 @@ import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' import { useNativeChatSessionOptions } from './use-native-chat-session-options' import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions' -import { useNativeChatDictationActions } from './use-native-chat-dictation-actions' +import { useNativeChatDictation } from './use-native-chat-dictation' import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command' import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog' import { useNativeChatPickerState } from './use-native-chat-picker-state' @@ -104,7 +104,7 @@ const NativeChatComposerPane = forwardRef -): { +export function useNativeChatDictation(textareaRef: RefObject): { dictationDisabled: boolean isDictating: boolean isDictationHoldMode: boolean From 62d14880035a2ae46df7a90364c44265331473fe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:12:23 -0700 Subject: [PATCH 162/182] fix(mobile-chat): resend a retained replay a Stop took back, under a fresh id A retained id is keyed by the message text, so its replay cannot be told from a new send of the same text. When the answer is a submission a Stop withdrew, that message provably never ran, so the phone sends it once more under a fresh id, as it already does for a withdrawn queued card. A first answer a Stop took back still reads as sent, as on desktop. --- .../mobile-structured-agent-session-send.ts | 14 ++++--- .../mobile-structured-send-delivery.test.ts | 42 +++++++++---------- .../mobile-structured-send-delivery.ts | 28 +++++++++++-- ...e-native-chat-drafts-glued-pending.test.ts | 16 +++++++ ...ile-structured-agent-session-send.test.tsx | 34 ++++++++++++--- 5 files changed, 98 insertions(+), 36 deletions(-) diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index ce7f60a8b929..03b8b1a9cbb6 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -14,7 +14,10 @@ import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' import { structuredSessionOperationId } from './structured-session-operation-id' -import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' +import { + mobileStructuredSendDelivery, + mobileStructuredSendWithdrawnBeforeStart +} from './mobile-structured-send-delivery' import { bypassedMobileStructuredSendOperationId, clearMobileStructuredSendOperation, @@ -162,11 +165,12 @@ export async function sendMobileStructuredAgentSessionMessage(input: { } } const withdrawnReplay = - result.status === 'accepted' && - 'queued' in result.value && - result.value.queued?.state === 'withdrawn' + (result.status === 'accepted' && + 'queued' in result.value && + result.value.queued?.state === 'withdrawn') || + (operation.retained && mobileStructuredSendWithdrawnBeforeStart(result)) if (withdrawnReplay && operation.retained && !input.resendingAfterWithdrawal) { - // The retained id's draft was withdrawn, so it never reached the agent: + // The retained id's draft or submission was withdrawn, so it never reached the agent: // this identical message is a new one, not a replay to swallow. A record // storage would not clear is bookkeeping: it is reported, never allowed to // block the send. diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index 5aa987d71b34..537e11e5358e 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -6,6 +6,8 @@ import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' +const WITHDRAWN_SENTENCE = 'This message was withdrawn before the agent started it.' + function accepted( dispatchState: AgentJournalDispatchState, reason: string | null = null @@ -126,29 +128,27 @@ describe('mobileStructuredSendDelivery', () => { }) }) - it('reports a send a Stop took back as sent: the chat draws it, so nothing returns to the draft', () => { - for (const retained of [false, true]) { - for (const reason of [ - DISPATCH_REJECTED_CANCELLED, - 'This message was withdrawn before the agent started it.' - ]) { - const withdrawn = accepted('rejected', reason) - // The older marker alone, and the sentence with its typed fact. - if ( - reason !== DISPATCH_REJECTED_CANCELLED && - withdrawn.status === 'accepted' && - 'submission' in withdrawn.value - ) { - withdrawn.value.submission.rejection = { kind: 'cancelled' } - } - expect(mobileStructuredSendDelivery(withdrawn, retained)).toEqual({ - outcome: 'accepted', - operationIdSpent: true, - error: null - }) + // The chat draws a send a Stop took back with its stop row, so it never goes back to the draft. A + // retained replay is resent by the caller, which needs the id spent and no words of its own. + it.each([ + { retained: false, reason: DISPATCH_REJECTED_CANCELLED, fact: false, outcome: 'accepted' }, + { retained: false, reason: WITHDRAWN_SENTENCE, fact: true, outcome: 'accepted' }, + { retained: true, reason: DISPATCH_REJECTED_CANCELLED, fact: false, outcome: 'rejected' }, + { retained: true, reason: WITHDRAWN_SENTENCE, fact: true, outcome: 'rejected' } + ] as const)( + 'reads a send a Stop took back (retained $retained, typed fact $fact) as $outcome, spent, wordless', + ({ retained, reason, fact, outcome }) => { + const value = structuredSendResultFixture('rejected', reason) + if (fact && 'submission' in value) { + value.submission.rejection = { kind: 'cancelled' } } + expect(mobileStructuredSendDelivery({ status: 'accepted', value }, retained)).toEqual({ + outcome, + operationIdSpent: true, + error: null + }) } - }) + ) it('shows a provider content rejection verbatim', () => { expect( diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index d93c59c5082f..4deca5a8c38b 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -13,6 +13,11 @@ // // accepted/pending — the send happened. The id is spent; a later identical // message is a new message and must carry a new id. +// withdrawn — a submission a Stop took back before the agent started it is +// drawn in the chat with its stop row, so it spends the id and goes back to no +// draft. Answering a first send, it is that message: sent, then stopped. As a +// retained replay it cannot be told from a new send of the same text, and it +// provably never ran, so the caller sends it again under a fresh id. // rejected — a terminal refusal or rejected submission spends a fresh id. A // pending-admission refusal, or any refusal after earlier transport doubt, // keeps it because neither proves a retained delivery did not happen. Two @@ -42,6 +47,21 @@ export type MobileStructuredSendDelivery = { error: string | null } +/** Whether a send answer is its own submission, which a Stop took back before the agent started it. */ +export function mobileStructuredSendWithdrawnBeforeStart( + result: StructuredAgentSessionMutationCallResult +): boolean { + if (result.status !== 'accepted' || !('submission' in result.value)) { + return false + } + const { submission } = result.value + return ( + submission.queuedMessageId === undefined && + submission.dispatchState === 'rejected' && + dispatchWasWithdrawn(submission) + ) +} + export function mobileStructuredSendDelivery( result: StructuredAgentSessionMutationCallResult, retained = false @@ -97,10 +117,10 @@ export function mobileStructuredSendDelivery( if (!submission || submission.dispatchState === 'unknown') { return { outcome: 'unknown', operationIdSpent: false, error: null } } - if (submission.dispatchState === 'rejected' && dispatchWasWithdrawn(submission)) { - // The host recorded it, then a Stop took it back: the chat draws it with its stop row, so it is - // not handed back to the composer, which would show it twice. - return { outcome: 'accepted', operationIdSpent: true, error: null } + if (mobileStructuredSendWithdrawnBeforeStart(result)) { + // The chat draws it with its stop row, so it is never handed back to the composer as well. A + // retained replay is resent by the caller. + return { outcome: retained ? 'rejected' : 'accepted', operationIdSpent: true, error: null } } if (submission.dispatchState === 'rejected') { return { diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts index 008119dae5e4..583a36818083 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -158,6 +158,22 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { expect(renderedPendingTexts(history, state)).toEqual(['fix the', 'bug']) }) + // A send of the same text as a message a Stop took back is resent as a new message: its bubble + // waits beside the stopped row for that new row, never for the stopped one. + it('keeps a resend bubble beside a stopped row of the same text until its own row lands', async () => { + const history = [userTurn('m1', 'run the tests', 1000), assistantTurn('m2', 'stopped', 1100)] + await mount(history) + act(() => send('run the tests')) + + await update([...history]) + expect(pendingTexts()).toEqual(['run the tests']) + + const resent = [...history, userTurn('m3', 'run the tests', 5000)] + await update(resent) + expect(state?.pending).toEqual([]) + expect(renderedPendingTexts(resent, state)).toEqual([]) + }) + it('still retires each bubble on its own when the sends do not glue', async () => { const history = [assistantTurn('m1', 'ready', 1000)] await mount(history) diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 66eca5e6a6ed..7ea1fd3a571e 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -152,7 +152,9 @@ describe('mobile structured send retries', () => { expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true) }) - it('reads a replay a Stop took back as sent, saying nothing, and spends its id', async () => { + // A replay of a retained id cannot be told from a new send of the same text, and a Stop took the + // first one back before the agent started it, so this press goes out as a new message. + it('sends a replay a Stop took back once more, under a fresh id, saying nothing', async () => { let attempts = 0 sendRequest.mockImplementation(async (method) => { if (method !== 'agentSession.send') { @@ -162,21 +164,41 @@ describe('mobile structured send retries', () => { if (attempts === 1) { throw markRpcDeliveryUnknown(new Error('Connection closed')) } - // Recorded, then withdrawn by a Stop before the agent started it. - return sendResult('rejected', DISPATCH_REJECTED_CANCELLED) + return attempts === 2 + ? sendResult('rejected', DISPATCH_REJECTED_CANCELLED) + : sendResult('pending') }) await mountSession() await act(async () => { expect(await hook!.sendWithOutcome('stopped one')).toBe('unknown') - // The chat draws it, stopped: nothing goes back to the draft and no "not sent" shows. - expect(await hook!.sendWithOutcome('stopped one')).toBe('accepted') expect(await hook!.sendWithOutcome('stopped one')).toBe('accepted') }) expect(onSendError).not.toHaveBeenCalled() - const [first, replay, later] = sentIds() + const [first, replay, resent, ...rest] = sentIds() expect(replay).toBe(first) + expect(resent).not.toBe(first) + expect(rest).toEqual([]) + }) + + it('reads a first answer a Stop took back as sent, saying nothing, and spends its id', async () => { + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' + ? sendResult('rejected', DISPATCH_REJECTED_CANCELLED) + : method === 'agentSession.options' + ? ok({ models: [], current: {} }) + : ok({}) + ) + await mountSession() + + await act(async () => { + expect(await hook!.sendWithOutcome('stopped first')).toBe('accepted') + expect(await hook!.sendWithOutcome('stopped first')).toBe('accepted') + }) + + expect(onSendError).not.toHaveBeenCalled() + const [first, later] = sentIds() expect(later).not.toBe(first) }) From 517b83ea199a78c2fb097a31fe3b6c065db07206 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:15:13 -0700 Subject: [PATCH 163/182] test(mobile-chat): count every send a withdrawn replay makes --- ...e-mobile-structured-agent-session-send.test.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 7ea1fd3a571e..4fa1d5bd13c0 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -176,10 +176,11 @@ describe('mobile structured send retries', () => { }) expect(onSendError).not.toHaveBeenCalled() - const [first, replay, resent, ...rest] = sentIds() - expect(replay).toBe(first) - expect(resent).not.toBe(first) - expect(rest).toEqual([]) + // The lost first send, its replay, and exactly one resend under a new id. + const ids = sentIds() + expect(ids).toHaveLength(3) + expect(ids[1]).toBe(ids[0]) + expect(ids[2]).not.toBe(ids[0]) }) it('reads a first answer a Stop took back as sent, saying nothing, and spends its id', async () => { @@ -198,8 +199,9 @@ describe('mobile structured send retries', () => { }) expect(onSendError).not.toHaveBeenCalled() - const [first, later] = sentIds() - expect(later).not.toBe(first) + const ids = sentIds() + expect(ids).toHaveLength(2) + expect(ids[1]).not.toBe(ids[0]) }) it('releases an ack-lost id after the journal accepts it for a later identical intent', async () => { From b1800c61415e885a8d8ac2e6eb0401654758b2af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:33:00 -0700 Subject: [PATCH 164/182] fix(mobile-chat): a resend a Stop took back again is not handed back to the draft When storage fails, a resend's answer is lost and a second Stop takes the resend back, the fall-through after a withdrawn resend returned the text to the draft beside its stopped row. A withdrawn submission there now reads as sent; a withdrawn queued draft still goes back. Pins the one-resend guard and both orders of the stopped row and a resend. --- ...ructured-agent-session-send-bypass.test.ts | 64 ++++++++++++++++++- .../mobile-structured-agent-session-send.ts | 4 ++ ...e-native-chat-drafts-glued-pending.test.ts | 20 ++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts index c9da99a3cbaf..2652c041b525 100644 --- a/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import type { RpcResponse } from '../transport/types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' @@ -21,6 +22,29 @@ function ok(result: unknown): RpcResponse { return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } } +/** Its own submission, which a Stop took back before the agent started it. */ +function stoppedAnswer(clientMessageId: string): RpcResponse { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { + clientMessageId, + submission: { + clientMessageId, + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_CANCELLED, + submittedAt: 10, + resolvedAt: 10 + } + } + }) +} + function queuedAnswer(clientMessageId: string, state: 'waiting' | 'withdrawn'): RpcResponse { return ok({ ok: true, @@ -31,8 +55,9 @@ function queuedAnswer(clientMessageId: string, state: 'waiting' | 'withdrawn'): }) } -/** Each `agentSession.send` answered in turn: a lost answer, or a queued draft in that state. */ -function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn')[]) { +/** Each `agentSession.send` answered in turn: a lost answer, a queued draft in that state, or its + * submission stopped. */ +function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn' | 'stopped')[]) { const ids: string[] = [] const deliveries: unknown[] = [] const sendRequest = vi.fn(async (_method, params) => { @@ -44,7 +69,7 @@ function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn')[]) { if (answer === 'lost' || answer === undefined) { throw markRpcDeliveryUnknown(new Error('Connection closed')) } - return queuedAnswer(id, answer) + return answer === 'stopped' ? stoppedAnswer(id) : queuedAnswer(id, answer) }) const client: RpcClient = { sendRequest, @@ -154,4 +179,37 @@ describe('a resend past a saved record storage would not clear', () => { "Sent, but this phone couldn't update its record of sent messages." ) }) + + // Lost first send; its withdrawn replay; the fresh resend's answer lost; the retry's withdrawn + // replay; then that resend's own replay withdrawn too. A resend is sent once, never again. + it('sends a withdrawn replay once more at most, then hands a queued draft back', async () => { + const { client, ids } = hostAnswering(['lost', 'withdrawn', 'lost', 'withdrawn', 'withdrawn']) + const onError = vi.fn() + expect(await sendAgain(client, onError)).toBe('unknown') + asyncStorage.setItem.mockRejectedValue(new Error('disk full')) + asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) + expect(await sendAgain(client, onError)).toBe('unknown') + + expect(await sendAgain(client, onError)).toBe('rejected') + expect(ids).toHaveLength(5) + expect(ids[4]).toBe(ids[2]) + expect(onError).toHaveBeenCalledWith('Message not sent') + }) + + it('reads a resend a Stop took back again as sent, since the chat draws it, not as not sent', async () => { + const { client, ids } = hostAnswering(['lost', 'stopped', 'lost', 'stopped', 'stopped']) + const onError = vi.fn() + expect(await sendAgain(client, onError, false)).toBe('unknown') + asyncStorage.setItem.mockRejectedValue(new Error('disk full')) + asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) + expect(await sendAgain(client, onError, false)).toBe('unknown') + + expect(await sendAgain(client, onError, false)).toBe('accepted') + expect(ids).toHaveLength(5) + expect(ids[4]).toBe(ids[2]) + // Only the storage failure is reported; nothing says the message was not sent. + expect(onError.mock.calls).toEqual([ + ["Sent, but this phone couldn't update its record of sent messages."] + ]) + }) }) diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index 03b8b1a9cbb6..c7a2aa356128 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -186,6 +186,10 @@ export async function sendMobileStructuredAgentSessionMessage(input: { } return resent } + if (withdrawnReplay && mobileStructuredSendWithdrawnBeforeStart(result)) { + // Not resent, but the chat draws it with its stop row: handing it back too would show it twice. + return 'accepted' + } if (withdrawnReplay) { // Not resent: no card and no bubble holds the text, so it goes back to the // composer rather than vanishing. diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts index 583a36818083..21fc23efe724 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -174,6 +174,26 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { expect(renderedPendingTexts(resent, state)).toEqual([]) }) + // The likelier order: the press goes out before this phone has drawn the stopped row. + it('shows the text once at every step when the stopped row lands after the resend press', async () => { + const history = [assistantTurn('m0', 'ready', 500)] + await mount(history) + act(() => send('run the tests')) + expect(renderedPendingTexts(history, state)).toEqual(['run the tests']) + + // The stopped row takes the bubble's place; nothing is left pending beside it. + const stopped = [...history, userTurn('m1', 'run the tests', 1000)] + await update(stopped) + expect(state?.pending).toEqual([]) + expect(renderedPendingTexts(stopped, state)).toEqual([]) + + // The resend's own row then lands beside it, and still nothing is pending. + const resent = [...stopped, userTurn('m2', 'run the tests', 5000)] + await update(resent) + expect(state?.pending).toEqual([]) + expect(renderedPendingTexts(resent, state)).toEqual([]) + }) + it('still retires each bubble on its own when the sends do not glue', async () => { const history = [assistantTurn('m1', 'ready', 1000)] await mount(history) From b0ea254d2a043352f0737b4a0f6cb73729d9d400 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:04:54 -0700 Subject: [PATCH 165/182] fix(native-chat): a Stop's note says it took once a joined close proves the exit A session-ending Stop whose child's end failed revises its note to 'unconfirmed'. Since main's #24862, the next Stop joins that close rather than stopping again, and wrote no note, so a close that then proved the exit left 'unconfirmed' under a turn that ended. The close now carries the note it settles, and its proven exit revises it to 'Cancellation requested.'. A join that fails again leaves it unconfirmed. --- .../structured-agent-session-chat-stop.ts | 7 ++- .../structured-agent-session-child-close.ts | 45 ++++++++++++++++++ .../structured-agent-session-host-lifetime.ts | 5 +- ...tructured-agent-session-host-stop-event.ts | 4 +- .../structured-agent-session-host-types.ts | 8 +++- ...structured-agent-session-stop-wind-down.ts | 4 +- .../structured-agent-session-stopping.test.ts | 46 +++++++++++++++++-- 7 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index edfe42f6d858..602ff39d35d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -17,6 +17,7 @@ import { mutateStructuredAgentSession, type StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { MutationPlan } from './structured-agent-session-mutation-plans' import { @@ -61,7 +62,11 @@ export function mutateWithChatStop( let eventAfterEnd: Promise | undefined const named = turnId !== undefined ? { turnId } : {} // Its own step wrote the Stop's event first. - const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) + const stopChild = (settlesStopNote?: AgentJournalItemIdentity) => + context.stopAgent(sessionId, { + recorded: 'user-stop', + ...(settlesStopNote ? { settlesStopNote } : {}) + }) // The same for every client: once the Stop takes effect its event is written, and the queue's // pause follows from it. The cards stay published; no text rides the answer. const stop = (ctx: AgentSessionTurnContext): Promise => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index 0022df7c1565..8e136fb7cc58 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -8,6 +8,12 @@ // waiting reaches that handler as the adapter's report of the exit. import { refuse } from '../../../shared/agent-session-wire-refusals' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { STOP_NOTE_CANCELLATION_REQUESTED } from './structured-agent-session-turn-stop-notes' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' @@ -28,11 +34,50 @@ export async function joinStructuredAgentSessionChildClose( if (!(await closeProviderRoot(context, sessionId))) { return 'unverifiable' } + await settleStopNoteOnExit(context, sessionId, child).catch((error: unknown) => + context.deps.logger.warn("revising a Stop's note after its agent exited failed", { + scope: 'stop-note-settle', + sessionId, + error + }) + ) await context.endExitedChild(sessionId, child, { expected: true, reason: 'closed by Orca' }) context.restartWitness?.stopped(sessionId) return 'exited' } +/** The exit is proven, so the work the close's Stop ended did end: a note an unproven attempt left + * unconfirmed says the Stop took. Bookkeeping: a failure is reported, never the exit's. */ +async function settleStopNoteOnExit( + context: StructuredAgentSessionLifetimeContext, + sessionId: string, + child: StructuredAgentSessionProviderChild +): Promise { + const note = child.close?.settlesStopNote + const journal = context.sessions.get(sessionId)?.journal + if (!note || !journal) { + return + } + const itemId = agentJournalItemKey(note) + let written: { unconfirmed: boolean; turnScope?: AgentJournalTurnScope } | undefined + journal.visitItemsWithLinkage((id, _sequence, body, linkage) => { + if (id === itemId) { + written = { + unconfirmed: body.kind === 'status' && body.failure?.kind === 'cancelUnconfirmed', + ...(linkage.turnScope ? { turnScope: linkage.turnScope } : {}) + } + } + }) + if (!written?.unconfirmed) { + return + } + await journal.appendItem( + note, + { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED }, + { fence: child.fence, turnScope: written.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE } + ) +} + /** The refusal of an operation that met a child whose close is still unproven. */ export function previousExitUnverifiableRefusal(): AgentSessionWireRefusal { return refuse( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 5e8d7e2097f9..bbb53c586079 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -117,7 +117,10 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( cause, reason: ('reason' in ending ? ending.reason : undefined) ?? null, recorded, - requestedAt: session.journal.cursor() + requestedAt: session.journal.cursor(), + ...('settlesStopNote' in ending && ending.settlesStopNote + ? { settlesStopNote: ending.settlesStopNote } + : {}) } } else if (child.close.cause === cause) { // The same stop asked again, such as a second close of the chat, closes what came since, and diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts index 1c9e2720aca9..8872464b2aef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts @@ -3,6 +3,7 @@ import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { withTimeout } from '../../../shared/promise-timeout-fallback' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -14,7 +15,8 @@ import { sentSinceStop } from './structured-agent-session-queued-stop' * own step (`recorded` names its reason); any other stop names the reason its event records, with * the host's text for it. Quit writes none: its resume marker's trigger records why. */ export type StructuredAgentSessionStopEnding = - | { recorded: 'user-stop' } + /** `settlesStopNote`: the note of the session-ending Stop whose wind-down this end is. */ + | { recorded: 'user-stop'; settlesStopNote?: AgentJournalItemIdentity } | { cause: Exclude reason?: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 7016e91e17c6..37e806e64daa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -1,6 +1,9 @@ import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalCursor, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary, @@ -56,6 +59,9 @@ export type StructuredAgentSessionChildClose = { /** Where the journal stood when that stop was asked for: the child's end is ordered there, so a * message accepted while the exit was being proven came after it. A repeated ask moves it. */ requestedAt: AgentJournalCursor + /** The note of the session-ending Stop this close carries out. Once the exit is proven the work + * ended, so a note an unproven attempt left unconfirmed says the Stop took. */ + readonly settlesStopNote?: AgentJournalItemIdentity } /** The provider process behind a conversation. Written only in diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts index 921d5fd604f5..46ac052fe913 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts @@ -37,7 +37,7 @@ export type StructuredAgentSessionStopWindDown = { export async function endStoppedStructuredAgentSession( ctx: Pick, windDown: StructuredAgentSessionStopWindDown, - stopChild: () => Promise, + stopChild: (settlesStopNote: AgentJournalItemIdentity) => Promise, onError: (error: unknown) => void ): Promise { let failedOn: JournalStopFailedOn | undefined @@ -45,7 +45,7 @@ export async function endStoppedStructuredAgentSession( if (windDown.waitsForProvider) { await ctx.adapter.awaitStoppedRequestEnd?.(ctx.sessionId, windDown.stoppedAt) } - await stopChild() + await stopChild(windDown.stopNote) } catch (error) { onError(error) failedOn = structuredAgentSessionFailedStopMark(ctx.journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts index e48ea4b03bff..db80cc3d944a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -424,7 +424,7 @@ describe('a Stop whose provider ends its session', () => { expect(status()).toMatchObject({ status: 'working', stopping: true }) }) - it("says the Stop went unconfirmed once the child's end fails, and the next Stop retries it", async () => { + it("says the Stop went unconfirmed once the child's end fails, and that it took once the next Stop's joined close proves the exit", async () => { const { status } = await runningTurn({ stopEndsSession: true }) rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) @@ -434,13 +434,51 @@ describe('a Stop whose provider ends its session', () => { // Still the person's Stop while the work runs on: Stop stays enabled for the retry. expect(status()).toMatchObject({ status: 'working', stopping: true }) - // The retry's child end is held, so the status can be read while it runs. + // The retry's child end is held, so the status can be read while it runs. The retry joins the + // child's close, which runs the stop again since the last one came back unproven. const retried = Promise.withResolvers() rig.closeSession.mockImplementationOnce(() => retried.promise) - expect(await rig.stop()).toMatchObject({ ok: true }) + const retry = rig.stop() await eventually(() => expect(rig.closeSession).toHaveBeenCalledTimes(2)) - expect(stopAnswers()).toEqual(['took']) expect(status()).toMatchObject({ status: 'working', stopping: true }) retried.resolve(true) + expect(await retry).toMatchObject({ ok: true }) + expect(stopAnswers()).toEqual(['took']) + }) + + it('keeps the note unconfirmed when the next Stop joins a close that fails again', async () => { + const { status } = await runningTurn({ stopEndsSession: true }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out again')) + expect(await rig.stop()).toMatchObject({ ok: true }) + + expect(rig.closeSession).toHaveBeenCalledTimes(2) + expect(stopAnswers()).toEqual(['cancelUnconfirmed']) + expect(status()).toMatchObject({ status: 'working', stopping: true }) + }) + + // The close lives on the child, in memory, and dies with the host. The new host cannot prove the + // old process exited either, so the turn reads unverifiable and the note's "unconfirmed" stays true. + it('leaves the note unconfirmed across a host crash, beside a turn that reads unverifiable', async () => { + await runningTurn({ stopEndsSession: true }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + + rig.crashRestartHostProcess() + await rig.queuePause() + + const turnState = journal() + .snapshot() + .items.flatMap((item) => + item.body.kind === 'turn' && item.body.turnId === 'turn-1' ? [item.body.state] : [] + ) + expect({ notes: stopAnswers(), turn: turnState }).toEqual({ + notes: ['cancelUnconfirmed'], + turn: ['unverifiable'] + }) }) }) From 05ba8da6f4f4ca63f0a09bd633c908db3890e908 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:38:00 -0700 Subject: [PATCH 166/182] test(native-chat): draw a stopped send end to end in this PR's own Codex file The shared turn-end settlement file went past its line limit once main's Stop tests and the answered-turn tests met. This PR's two drawing checks move to its own end-to-end file, leaving that file as #25073 has it. --- ...t-session-codex-stopped-send-order.test.ts | 30 ++++++++++++++++ ...-session-codex-turn-end-settlement.test.ts | 35 ++----------------- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts index 50bf313c85ac..85a4e707660a 100644 --- a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -302,3 +302,33 @@ describe('a send a Stop took back, then a send made while it read Stopping', () expect(await drawn()).toEqual(live) }) }) + +describe('a send a Stop took back before Codex echoed it', () => { + it('is drawn as the opener of the turn Codex opened for it, with no row of its own', async () => { + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + await stop() + await vi.waitFor(async () => expect(verdictOf(await submissions(), sent)).toBe('withdrawn')) + + // That turn, ended interrupted, carries the stop. + expect(await drawn()).toEqual(['look around', 'Cancellation requested.']) + }) + + it('is drawn where it was sent, then the one row saying it never started, when no turn opened', async () => { + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + turns.end('completed') + const release = turns.holdNextAnswer() + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(2)) + const stopping = stop() + release() + await settledWithin(stopping, 5_000) + await vi.waitFor(async () => expect(verdictOf(await submissions(), sent)).toBe('withdrawn')) + + expect(await drawn()).toEqual(['warm up', 'look around', NATIVE_CHAT_STOPPED_BEFORE_START_TEXT]) + }) +}) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 1df1d19b9dbd..712e05552217 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -25,13 +25,7 @@ import type { AgentJournalSubmission } from '../../shared/agent-session-journal- import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' -import { - agentJournalItemKey, - agentJournalSubmissionKey -} from '../../shared/agent-session-journal-item-key' -import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../shared/native-chat-stopped-before-start' -import { structuredAgentTurnAnchors } from '../../shared/native-chat-turn-membership' -import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { structuredAgentSessionStopNoteIdentity } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' import { HOST_TEST_SESSION as SESSION, @@ -319,22 +313,6 @@ describe('a Codex send its turn ended without taking it', () => { const after = await settled() expect(verdictOf(after.submissions, sent)).toBe('withdrawn') expect(after.owesWork).toBe(false) - // Codex opened its turn before echoing it, so that turn, ended interrupted, is the send's: it - // carries the stop, and no row of its own follows the send. - const snapshot = await host.journalSnapshot(SESSION) - const turnRecord = snapshot.items.find( - (item) => readAgentJournalTurn(item.body)?.turnId === 'turn-1' - ) - expect( - structuredAgentTurnAnchors(snapshot.items, snapshot.submissions).get(turnRecord!.itemId) - ).toBe(agentJournalSubmissionKey(sent)) - expect( - projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions, { - rejectedInPlace: true - }).map((message) => - message.blocks.map((block) => ('text' in block ? block.text : block.type)) - ) - ).not.toContainEqual([NATIVE_CHAT_STOPPED_BEFORE_START_TEXT]) // Codex echoing it late changes nothing: a settled answer stands. await host.settleLateDispatch({ @@ -820,16 +798,9 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') expect(await statusRows()).toEqual([]) - const snapshot = await host.journalSnapshot(SESSION) - expect(turnRow(snapshot.items)).toMatchObject({ state: 'completed' }) - // Every client draws the send where it was sent, then the one row saying it never started. - const drawn = projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions, { - rejectedInPlace: true + expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ + state: 'completed' }) - expect(drawn.slice(-2).map((message) => [message.role, message.blocks])).toEqual([ - ['user', [{ type: 'text', text: 'look around' }]], - ['system', [expect.objectContaining({ text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT })]] - ]) }) // Its turn/start answer lost, the send is in doubt rather than pending; the child's end takes it From 9224f9966edaaf54b61438cfd6120a4e0dfb8856 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:02:52 -0700 Subject: [PATCH 167/182] fix(native-chat): a proven turn end says a Stop's unconfirmed note took Replaces the note carried on the child's close. Every settlement that ends turns interrupted on a proven exit, live or after a crash, also revises an unconfirmed Stop note on those turns to 'Cancellation requested.', found by the note's turn scope, in the same batch. A note a Stop wrote before its turn showed is re-keyed onto the running turn when it becomes unconfirmed, in one batch, so that end finds it. Known limit: with no turn open yet, the note keeps its key and no turn's end revises it. --- .../structured-agent-session-chat-stop.ts | 7 +- .../structured-agent-session-child-close.ts | 45 -------- ...gent-session-dead-generation-settlement.ts | 6 +- .../structured-agent-session-host-lifetime.ts | 5 +- ...tructured-agent-session-host-stop-event.ts | 4 +- .../structured-agent-session-host-types.ts | 8 +- ...-agent-session-stop-note-revisions.test.ts | 79 +++++++++++++ ...tured-agent-session-stop-note-revisions.ts | 50 ++++++++ ...structured-agent-session-stop-wind-down.ts | 42 +++++-- .../structured-agent-session-stopping.test.ts | 109 +++++++++++++++--- 10 files changed, 264 insertions(+), 91 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 602ff39d35d7..edfe42f6d858 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -17,7 +17,6 @@ import { mutateStructuredAgentSession, type StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' -import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { MutationPlan } from './structured-agent-session-mutation-plans' import { @@ -62,11 +61,7 @@ export function mutateWithChatStop( let eventAfterEnd: Promise | undefined const named = turnId !== undefined ? { turnId } : {} // Its own step wrote the Stop's event first. - const stopChild = (settlesStopNote?: AgentJournalItemIdentity) => - context.stopAgent(sessionId, { - recorded: 'user-stop', - ...(settlesStopNote ? { settlesStopNote } : {}) - }) + const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) // The same for every client: once the Stop takes effect its event is written, and the queue's // pause follows from it. The cards stay published; no text rides the answer. const stop = (ctx: AgentSessionTurnContext): Promise => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index 8e136fb7cc58..0022df7c1565 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -8,12 +8,6 @@ // waiting reaches that handler as the adapter's report of the exit. import { refuse } from '../../../shared/agent-session-wire-refusals' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalTurnScope -} from '../../../shared/agent-session-journal-types' -import { STOP_NOTE_CANCELLATION_REQUESTED } from './structured-agent-session-turn-stop-notes' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' @@ -34,50 +28,11 @@ export async function joinStructuredAgentSessionChildClose( if (!(await closeProviderRoot(context, sessionId))) { return 'unverifiable' } - await settleStopNoteOnExit(context, sessionId, child).catch((error: unknown) => - context.deps.logger.warn("revising a Stop's note after its agent exited failed", { - scope: 'stop-note-settle', - sessionId, - error - }) - ) await context.endExitedChild(sessionId, child, { expected: true, reason: 'closed by Orca' }) context.restartWitness?.stopped(sessionId) return 'exited' } -/** The exit is proven, so the work the close's Stop ended did end: a note an unproven attempt left - * unconfirmed says the Stop took. Bookkeeping: a failure is reported, never the exit's. */ -async function settleStopNoteOnExit( - context: StructuredAgentSessionLifetimeContext, - sessionId: string, - child: StructuredAgentSessionProviderChild -): Promise { - const note = child.close?.settlesStopNote - const journal = context.sessions.get(sessionId)?.journal - if (!note || !journal) { - return - } - const itemId = agentJournalItemKey(note) - let written: { unconfirmed: boolean; turnScope?: AgentJournalTurnScope } | undefined - journal.visitItemsWithLinkage((id, _sequence, body, linkage) => { - if (id === itemId) { - written = { - unconfirmed: body.kind === 'status' && body.failure?.kind === 'cancelUnconfirmed', - ...(linkage.turnScope ? { turnScope: linkage.turnScope } : {}) - } - } - }) - if (!written?.unconfirmed) { - return - } - await journal.appendItem( - note, - { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED }, - { fence: child.fence, turnScope: written.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE } - ) -} - /** The refusal of an operation that met a child whose close is still unproven. */ export function previousExitUnverifiableRefusal(): AgentSessionWireRefusal { return refuse( 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 639b34cb7955..8b447edd7018 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 @@ -12,6 +12,7 @@ import { type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { stopNoteRevisionsForEndedTurns } from './structured-agent-session-stop-note-revisions' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' @@ -186,7 +187,8 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { }) } } - mutations.push(...runningTurnLifecycleRevisions(items, input.verdict)) + const turnEnds = runningTurnLifecycleRevisions(items, input.verdict) + mutations.push(...turnEnds, ...stopNoteRevisionsForEndedTurns(items, turnEnds)) const batchId = `dead-generation:${input.settlementId}` for (const chunk of partitionJournalLifecycleMutations(batchId, mutations)) { await input.journal.appendLifecycleBatch({ @@ -249,7 +251,7 @@ export async function settleStaleStructuredAgentSessionState(input: { ...items.flatMap((item) => runningTurnLifecycleRevisions([item], verdictFor(item))), ...proven ] - mutations.push(...turnEnds) + mutations.push(...turnEnds, ...stopNoteRevisionsForEndedTurns(items, turnEnds)) const evidence = input.deathEvidence if ( evidence && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index bbb53c586079..5e8d7e2097f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -117,10 +117,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( cause, reason: ('reason' in ending ? ending.reason : undefined) ?? null, recorded, - requestedAt: session.journal.cursor(), - ...('settlesStopNote' in ending && ending.settlesStopNote - ? { settlesStopNote: ending.settlesStopNote } - : {}) + requestedAt: session.journal.cursor() } } else if (child.close.cause === cause) { // The same stop asked again, such as a second close of the chat, closes what came since, and diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts index 8872464b2aef..1c9e2720aca9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts @@ -3,7 +3,6 @@ import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { withTimeout } from '../../../shared/promise-timeout-fallback' -import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -15,8 +14,7 @@ import { sentSinceStop } from './structured-agent-session-queued-stop' * own step (`recorded` names its reason); any other stop names the reason its event records, with * the host's text for it. Quit writes none: its resume marker's trigger records why. */ export type StructuredAgentSessionStopEnding = - /** `settlesStopNote`: the note of the session-ending Stop whose wind-down this end is. */ - | { recorded: 'user-stop'; settlesStopNote?: AgentJournalItemIdentity } + | { recorded: 'user-stop' } | { cause: Exclude reason?: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 37e806e64daa..7016e91e17c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -1,9 +1,6 @@ import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { - AgentJournalCursor, - AgentJournalItemIdentity -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary, @@ -59,9 +56,6 @@ export type StructuredAgentSessionChildClose = { /** Where the journal stood when that stop was asked for: the child's end is ordered there, so a * message accepted while the exit was being proven came after it. A repeated ask moves it. */ requestedAt: AgentJournalCursor - /** The note of the session-ending Stop this close carries out. Once the exit is proven the work - * ended, so a note an unproven attempt left unconfirmed says the Stop took. */ - readonly settlesStopNote?: AgentJournalItemIdentity } /** The provider process behind a conversation. Written only in diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.test.ts new file mode 100644 index 000000000000..b40e6abe8264 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.test.ts @@ -0,0 +1,79 @@ +// A settlement's proven turn end says a Stop's unconfirmed note on that turn took; nothing else. + +import { describe, expect, it } from 'vitest' +import { 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 { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import { stopNoteRevisionsForEndedTurns } from './structured-agent-session-stop-note-revisions' + +const TURN: AgentJournalItemIdentity = { + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'turn-lifecycle:turn-1' +} +const TURN_ITEM = agentJournalItemKey(TURN) +const UNCONFIRMED: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) +} + +function note(key: string, body: AgentJournalItemBody, turnItemId = TURN_ITEM) { + const identity = structuredAgentSessionStopNoteIdentity(key) + return { + identity, + item: { + itemId: agentJournalItemKey(identity), + revision: 1, + sequence: 3, + observedAt: 3, + body, + turnScope: { kind: 'turn', turnItemId } + } satisfies AgentJournalRenderItem + } +} + +function turnEnd(state: 'interrupted' | 'completed'): JournalLifecycleMutationInput { + return { + kind: 'item', + identity: TURN, + body: agentJournalTurnBody({ turnId: 'turn-1', state, completedAt: 10 }), + turnScope: { kind: 'thread' } + } +} + +describe('a Stop note on a turn a settlement ends', () => { + it('says the Stop took when the turn ends interrupted', () => { + const { identity, item } = note('turn-1', UNCONFIRMED) + + expect(stopNoteRevisionsForEndedTurns([item], [turnEnd('interrupted')])).toEqual([ + { + kind: 'item', + identity, + body: { kind: 'status', text: 'Cancellation requested.' }, + turnScope: { kind: 'turn', turnItemId: TURN_ITEM } + } + ]) + }) + + it('is left alone when the turn completes on its own', () => { + const { item } = note('turn-1', UNCONFIRMED) + + expect(stopNoteRevisionsForEndedTurns([item], [turnEnd('completed')])).toEqual([]) + }) + + it('is left alone on another turn, or when it already says the Stop took', () => { + const elsewhere = note('turn-2', UNCONFIRMED, 'another-turn').item + const took = note('turn-1', { kind: 'status', text: 'Cancellation requested.' }).item + + expect(stopNoteRevisionsForEndedTurns([elsewhere, took], [turnEnd('interrupted')])).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.ts new file mode 100644 index 000000000000..4424c905f2bf --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.ts @@ -0,0 +1,50 @@ +// What a proven end says about a Stop's note: the work the Stop meant to end did end. + +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' +import { STOP_NOTE_CANCELLATION_REQUESTED } from './structured-agent-session-turn-stop-notes' + +/** + * A settlement that ends turns `interrupted` on a proven exit also revises any Stop note on those + * turns that an unproven attempt left unconfirmed: it says the Stop took. Found by the note's turn + * scope, whatever its key, and re-derived by every settlement, so nothing is stored for it. + */ +export function stopNoteRevisionsForEndedTurns( + items: readonly AgentJournalRenderItem[], + turnEnds: readonly JournalLifecycleMutationInput[] +): JournalLifecycleMutationInput[] { + const interrupted = new Set( + turnEnds.flatMap((mutation) => + mutation.kind === 'item' && readAgentJournalTurn(mutation.body)?.state === 'interrupted' + ? [agentJournalItemKey(mutation.identity)] + : [] + ) + ) + if (interrupted.size === 0) { + return [] + } + return items.flatMap((item) => { + const identity = parseAgentJournalItemKey(item.itemId) + return identity && + isStructuredAgentSessionStopNote(item.itemId) && + item.body.kind === 'status' && + item.body.failure?.kind === 'cancelUnconfirmed' && + item.turnScope?.kind === 'turn' && + interrupted.has(item.turnScope.turnItemId) + ? [ + { + kind: 'item' as const, + identity, + body: { kind: 'status' as const, text: STOP_NOTE_CANCELLATION_REQUESTED }, + turnScope: item.turnScope + } + ] + : [] + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts index 46ac052fe913..8750fe909a0a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts @@ -6,6 +6,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, type AgentJournalItemIdentity, type AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' @@ -13,6 +14,8 @@ import { isMainAgentWorking } from './structured-agent-session-turns-cancel' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause' import { structuredAgentSessionFailedStopMark } from './structured-agent-session-stopping' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import { structuredAgentSessionNamedTurnScope } from './structured-agent-session-turn-stop-notes' /** What a Stop that ends the provider's session leaves its next serialized step: whether the * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went @@ -37,7 +40,7 @@ export type StructuredAgentSessionStopWindDown = { export async function endStoppedStructuredAgentSession( ctx: Pick, windDown: StructuredAgentSessionStopWindDown, - stopChild: (settlesStopNote: AgentJournalItemIdentity) => Promise, + stopChild: () => Promise, onError: (error: unknown) => void ): Promise { let failedOn: JournalStopFailedOn | undefined @@ -45,7 +48,7 @@ export async function endStoppedStructuredAgentSession( if (windDown.waitsForProvider) { await ctx.adapter.awaitStoppedRequestEnd?.(ctx.sessionId, windDown.stoppedAt) } - await stopChild(windDown.stopNote) + await stopChild() } catch (error) { onError(error) failedOn = structuredAgentSessionFailedStopMark(ctx.journal) @@ -74,12 +77,31 @@ async function reviseStopNoteUnconfirmed( if (written === undefined) { return } - await ctx.journal.appendItem( - identity, - { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) - }, - { fence: ctx.fence, turnScope: written.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE } - ) + const body: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + } + // The note now speaks of the work running on, so it moves onto that work's turn, keyed by it as a + // Stop of that turn writes it: its proven end finds it there. A row keeps the scope it was created + // with, so a note a Stop wrote before the turn showed is re-keyed, in one batch. Known limit: with + // no turn open yet, the note keeps its key and scope, and no turn's end revises it. + const running = ctx.journal.activeTurnId() + const turnScope = + running !== null ? structuredAgentSessionNamedTurnScope(ctx.journal, running) : null + const onTurn = running !== null ? structuredAgentSessionStopNoteIdentity(running) : identity + if (turnScope && agentJournalItemKey(onTurn) !== itemId) { + await ctx.journal.appendLifecycleBatch({ + settlementId: `stop-note-on-turn:${itemId}`, + fence: ctx.fence, + mutations: [ + { kind: 'tombstone', identity }, + { kind: 'item', identity: onTurn, body, turnScope } + ] + }) + return + } + await ctx.journal.appendItem(identity, body, { + fence: ctx.fence, + turnScope: written.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts index db80cc3d944a..568271c6d7a6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -4,7 +4,10 @@ // feed, with turn rows named as Codex writes them. import { afterEach, describe, expect, it, vi } from 'vitest' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemIdentity @@ -14,7 +17,10 @@ import type { AgentSessionStatusEvent, AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' +import { + isStructuredAgentSessionStopNote, + structuredAgentSessionStopNoteIdentity +} from './structured-agent-session-command-turn' import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' import { createQueuedMessageTestRig, @@ -460,25 +466,100 @@ describe('a Stop whose provider ends its session', () => { expect(status()).toMatchObject({ status: 'working', stopping: true }) }) - // The close lives on the child, in memory, and dies with the host. The new host cannot prove the - // old process exited either, so the turn reads unverifiable and the note's "unconfirmed" stays true. - it('leaves the note unconfirmed across a host crash, beside a turn that reads unverifiable', async () => { + // The close lives on the child, in memory, and dies with the host. The new host's settlement, + // which ends the turn on a proof of the old owner's death, is what says the Stop took. + async function crashAfterUnconfirmedStop(): Promise { await runningTurn({ stopEndsSession: true }) rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) expect(await rig.stop()).toMatchObject({ ok: true }) await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) - rig.crashRestartHostProcess() - await rig.queuePause() + } - const turnState = journal() + function turnOneState(): string | undefined { + return journal() .snapshot() - .items.flatMap((item) => - item.body.kind === 'turn' && item.body.turnId === 'turn-1' ? [item.body.state] : [] - ) - expect({ notes: stopAnswers(), turn: turnState }).toEqual({ - notes: ['cancelUnconfirmed'], - turn: ['unverifiable'] + .items.map((item) => (item.body.kind === 'turn' ? item.body : undefined)) + .find((turn) => turn?.turnId === 'turn-1')?.state + } + + /** What the host's restart reconciliation or recovery writes once a probe finds the old pid gone. */ + async function proveOldOwnerGone(): Promise { + const record = rig.store.getRecord(HOST_TEST_SESSION)! + await rig.store.evictProvenDeadOwner({ + sessionId: HOST_TEST_SESSION, + expectedFence: record.lease.runtimeFence, + probe: { outcome: 'pid-absent' }, + now: Date.now() + }) + } + + it("says the Stop took once the new host proves the old child's exit after a crash", async () => { + await crashAfterUnconfirmedStop() + // Proven before the chat opens, as the restart's reconciliation does on most machines. + await proveOldOwnerGone() + await rig.queuePause() + + expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ + turn: 'interrupted', + notes: ['took'] + }) + }) + + it('keeps the note unconfirmed while the old exit stays unverifiable, then says it took once proven', async () => { + await crashAfterUnconfirmedStop() + await rig.queuePause() + expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ + turn: 'unverifiable', + notes: ['cancelUnconfirmed'] }) + + // A later proof naming the old owner, as recovery writes it, revises the open chat. + await proveOldOwnerGone() + await eventually(() => + expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ + turn: 'interrupted', + notes: ['took'] + }) + ) + }) + + // A Stop pressed before its turn showed keys its note by itself, with no turn to sit on; once it is + // unconfirmed while that turn runs, the note moves onto the turn, so the turn's proven end finds it. + it('moves a note a Stop wrote before its turn showed onto that turn, which then says the Stop took', async () => { + rig = await createQueuedMessageTestRig({ stopEndsSession: true }) + const sent = await rig.workingSend() + const kill = Promise.withResolvers() + rig.closeSession.mockImplementationOnce(() => kill.promise) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(rig.closeSession).toHaveBeenCalled()) + await rig.settleAccepted(sent, 'sent') + await turn('turn-1', sent, 'running') + kill.reject(new Error('the kill timed out')) + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + + // A join that fails again neither adds a note nor brings the first one back. + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out again')) + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(stopAnswers()).toEqual(['cancelUnconfirmed']) + + // The next Stop's joined close proves the exit. + expect(await rig.stop()).toMatchObject({ ok: true }) + + await eventually(() => expect(stopAnswers()).toEqual(['took'])) + const turnRecord = journal() + .snapshot() + .items.find((item) => item.body.kind === 'turn' && item.body.turnId === 'turn-1') + const page = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + const notes = page.ok + ? page.page.items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)) + : [] + // What a client loads: one note, on that turn. + expect(notes.map((item) => [item.itemId, item.turnScope])).toEqual([ + [ + agentJournalItemKey(structuredAgentSessionStopNoteIdentity('turn-1')), + { kind: 'turn', turnItemId: turnRecord?.itemId } + ] + ]) }) }) From a5429e8702ddc49721afa87c2031e3160cb50c27 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:45:05 -0700 Subject: [PATCH 168/182] test(native-chat): a Stop's unconfirmed note says it took when the agent exits on its own --- .../structured-agent-session-stopping.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts index 568271c6d7a6..92cd6f574331 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping.test.ts @@ -562,4 +562,33 @@ describe('a Stop whose provider ends its session', () => { ] ]) }) + + // The kill timed out, then the agent's process exits on its own: the adapter reports the end of + // the close Orca began, and nothing else asks to stop. + it("says the Stop took once the agent's process exits on its own after the kill timed out", async () => { + await runningTurn({ stopEndsSession: true }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true }) + await eventually(() => expect(stopAnswers()).toEqual(['cancelUnconfirmed'])) + + await rig.host.handleAdapterEvent({ + type: 'ended', + sessionId: HOST_TEST_SESSION, + reason: 'claude session closed', + cause: 'requested-close', + fence: fence(), + acquisitionGeneration: 'generation-1', + observedAt: Date.now() + }) + + await eventually(() => + expect( + rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child ?? null + ).toBeNull() + ) + expect({ turn: turnOneState(), notes: stopAnswers() }).toEqual({ + turn: 'interrupted', + notes: ['took'] + }) + }) }) From b63bd502e0b4e8900992ec6691240a9e8cdb032b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:46:48 -0700 Subject: [PATCH 169/182] test(native-chat): restore the named Stop, turn anchor and role checks in the moved draw tests --- ...t-session-codex-stopped-send-order.test.ts | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts index 85a4e707660a..80432e8399c8 100644 --- a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -14,13 +14,18 @@ import type { } from '../codex/codex-app-server-connection' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' import { settledWithin } from '../codex/codex-structured-dispatch-test-support' +import { CODEX_TURN_OPEN_WAIT_MS } from '../codex/codex-structured-turn-open-wait' +import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../shared/native-chat-stopped-before-start' import { projectNativeChatTranscript } from '../../shared/native-chat-transcript-projection' import { nativeChatRowsInDrawOrder } from '../../shared/native-chat-turn-grouping' -import { nativeChatTurnMembership } from '../../shared/native-chat-turn-membership' +import { + nativeChatTurnMembership, + structuredAgentTurnAnchors +} from '../../shared/native-chat-turn-membership' import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' import { @@ -84,8 +89,10 @@ async function send(text: string): Promise { return sent.value.clientMessageId } -function stop(): Promise { - return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) +/** The chat's Stop; `turnId` names the turn it stops, as the phone does. */ +function stop(turnId?: string): Promise { + const fields = turnId === undefined ? {} : { turnId } + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) } function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessageId: string) { @@ -308,10 +315,18 @@ describe('a send a Stop took back before Codex echoed it', () => { const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) turns.start() - await stop() + await stop('turn-1') await vi.waitFor(async () => expect(verdictOf(await submissions(), sent)).toBe('withdrawn')) - // That turn, ended interrupted, carries the stop. + // Codex opened its turn before echoing it, so that turn, ended interrupted, is the send's: it + // carries the stop, and no row of its own follows the send. + const snapshot = await host.journalSnapshot(SESSION) + const turnRecord = snapshot.items.find( + (item) => readAgentJournalTurn(item.body)?.turnId === 'turn-1' + ) + expect( + structuredAgentTurnAnchors(snapshot.items, snapshot.submissions).get(turnRecord!.itemId) + ).toBe(agentJournalSubmissionKey(sent)) expect(await drawn()).toEqual(['look around', 'Cancellation requested.']) }) @@ -326,9 +341,20 @@ describe('a send a Stop took back before Codex echoed it', () => { await vi.waitFor(() => expect(answers).toBe(2)) const stopping = stop() release() - await settledWithin(stopping, 5_000) - await vi.waitFor(async () => expect(verdictOf(await submissions(), sent)).toBe('withdrawn')) + // The Stop waits out the turn-open wait for a turn that never opens, then ends there. + expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + expect(verdictOf(await submissions(), sent)).toBe('withdrawn') + // Every client draws the send where it was sent, then the one row saying it never started. + await host.flushStreamedEvents(SESSION) + const { items, submissions: settled } = await host.journalSnapshot(SESSION) + const messages = projectStructuredAgentSessionMessages(items, [], settled, { + rejectedInPlace: true + }) + expect(messages.slice(-2).map((message) => [message.role, message.blocks])).toEqual([ + ['user', [{ type: 'text', text: 'look around' }]], + ['system', [expect.objectContaining({ text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT })]] + ]) expect(await drawn()).toEqual(['warm up', 'look around', NATIVE_CHAT_STOPPED_BEFORE_START_TEXT]) }) }) From 650326899cdb37d5754fb42831ccb65d3c8453a8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:12:35 -0700 Subject: [PATCH 170/182] test(native-chat): name the Codex handle as main's opaque handle does --- .../structured-agent-session-status-feed-stopping.test.ts | 7 ++++--- .../structured-agent-session-stopping-journal.test.ts | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts index 0d2d2055a53b..d409aeb98dfa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-stopping.test.ts @@ -1,6 +1,7 @@ // The status feed's Stopping is live state, like the child it rides beside: when the host lets go of // a session, nothing there is ending the work, so the retained summary no longer says Stopping. +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -35,7 +36,7 @@ describe("the status feed's Stopping", () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, SESSION) }) @@ -77,7 +78,7 @@ describe("the status feed's Stopping", () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, SESSION) }) @@ -122,7 +123,7 @@ describe("the status feed's Stopping", () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, SESSION) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts index 74eca0fadb3b..ba026d93a0d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts @@ -2,6 +2,7 @@ // the turn it named or its settle bound still runs, whatever the Stop's answer. It reads only the // journal's tail to tell. +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -32,7 +33,7 @@ beforeEach(async () => { workspaceId: 'workspace-1', hostId: 'local', agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } + providerHandle: codexProviderHandle('thread-1') }, stateDirectory: join(root, 'session-1') }) From c8a5ac80f812be8564fcce22158acea498c46298 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:32:10 -0700 Subject: [PATCH 171/182] test: restore the provider handle import the main merge dropped --- .../agent-session-journal/journal-submission-positions.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts index 3e05bbdbbcc1..374163786053 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts @@ -18,6 +18,7 @@ import { type AgentJournalMessageItem, type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { readAgentSessionHydrationPage } from '../agent-session-wire/agent-session-history-page' import { createTrackedJournalOpener } from './journal-host-database-test-support' From f803bb06e3bdb1a5d1fcb73254d9ec7c39361a91 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:00:04 -0700 Subject: [PATCH 172/182] fix: derive Stop note wording from interrupted turns --- .../agent-session-journal/journal-reducer.ts | 5 +- .../journal-stop-note-projection.ts | 21 + .../agent-session-journal-batch.ts | 33 +- ...agent-session-stop-note-projection.test.ts | 389 ++++++++++++++++++ .../agent-session-stop-note-refresh.ts | 77 ++++ ...agent-session-claude-stop-note-end.test.ts | 348 ++++++++++++++++ ...-agent-session-codex-stop-note-end.test.ts | 252 ++++++++++++ ...gent-session-dead-generation-settlement.ts | 5 +- ...t-session-rewind-stop-note.test-fixture.ts | 110 +++++ .../structured-agent-session-rewind.test.ts | 5 + .../structured-agent-session-rewind.ts | 20 +- ...-agent-session-stop-note-revisions.test.ts | 79 ---- ...tured-agent-session-stop-note-revisions.ts | 50 --- .../structured-agent-session-subscribers.ts | 10 + .../structured-rewind-recovery.ts | 5 +- ...-session-stop-event-downgrade.unit.test.ts | 71 ++++ ...ss-version-agent-session-wire.unit.test.ts | 3 + .../cross-version-stop-note-scenarios.ts | 194 +++++++++ .../cross-version-stop-note-test-rig.ts | 211 ++++++++++ .../versioned-agent-session-wire.ts | 42 ++ 20 files changed, 1788 insertions(+), 142 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index e2d1b1ef3ca3..3f9f31154048 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -30,6 +30,7 @@ import { isJournalStopOrResumeRow, type JournalRow } from './journal-row-schema' import { acceptSubmissionFromProviderItem, applyJournalSubmission } from './journal-submission-fold' import { applyJournalDispatchRow } from './journal-dispatch-reducer' import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' +import { projectJournalStopNote } from './journal-stop-note-projection' import { createJournalQueuePauseMarks, foldJournalQueuePauseMark, @@ -233,7 +234,9 @@ function resolveItemId(state: JournalReducerState, itemId: string): string { export function renderJournalState(state: JournalReducerState): AgentJournalSnapshot { // The journal position is the sole ordering key; map insertion order is not, // because a re-created item re-enters the map after the items that followed it. - const items = [...state.items.values()].sort(compareAgentJournalItems) + const items = [...state.items.values()] + .map((item) => projectJournalStopNote(item, state.items)) + .sort(compareAgentJournalItems) return { sessionId: state.sessionId, cursor: { epoch: state.epoch, sequence: state.lastSequence }, diff --git a/src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts b/src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts new file mode 100644 index 000000000000..8efdc8143104 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts @@ -0,0 +1,21 @@ +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { isStructuredAgentSessionStopNote } from '../agent-session-wire/structured-agent-session-command-turn' +import { STOP_NOTE_CANCELLATION_REQUESTED } from '../agent-session-wire/structured-agent-session-turn-stop-notes' + +/** The recorded turn end supersedes the earlier unconfirmed Stop for display only. */ +export function projectJournalStopNote( + item: AgentJournalRenderItem, + items: ReadonlyMap +): AgentJournalRenderItem { + if ( + isStructuredAgentSessionStopNote(item.itemId) && + item.body.kind === 'status' && + item.body.failure?.kind === 'cancelUnconfirmed' && + item.turnScope?.kind === 'turn' && + readAgentJournalTurn(items.get(item.turnScope.turnItemId)?.body)?.state === 'interrupted' + ) { + return { ...item, body: { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } } + } + return item +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts index 42af5f830420..92789fa549f8 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -13,6 +13,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournalBatch } from '../../../shared/agent-session-wire' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' import { findSequenceGap } from '../agent-session-journal/journal-cursor' import { isJournalStopOrResumeRow, @@ -70,6 +71,12 @@ export function projectJournalBatch(input: { } const live = liveItemsById(input.snapshot.items) + const notes = stopNotesByTurn(input.snapshot.items) + for (const itemId of touchedItemIds) { + for (const noteId of notes.get(itemId) ?? []) { + touchedItemIds.add(noteId) + } + } const items = [...touchedItemIds] .map((itemId) => live.get(itemId)) .filter((item) => item !== undefined) @@ -87,7 +94,7 @@ export function projectJournalBatch(input: { } } -// Both indexes are keyed on the snapshot arrays themselves, which the reducer +// The indexes are keyed on the snapshot arrays themselves, which the reducer // rebuilds on every change, so a paged catch-up over one snapshot pays for them // once instead of once per page — including the byte-shrink loop's re-projections. const liveItemsByTimeline = new WeakMap< @@ -98,6 +105,30 @@ const aliasesBySubmissions = new WeakMap< readonly AgentJournalSubmission[], ReadonlyMap >() +const stopNotesByTimeline = new WeakMap< + readonly AgentJournalRenderItem[], + ReadonlyMap +>() + +function stopNotesByTurn( + items: readonly AgentJournalRenderItem[] +): ReadonlyMap { + const cached = stopNotesByTimeline.get(items) + if (cached) { + return cached + } + const notes = new Map() + for (const item of items) { + if (isStructuredAgentSessionStopNote(item.itemId) && item.turnScope?.kind === 'turn') { + const turnItemId = item.turnScope.turnItemId + const ids = notes.get(turnItemId) ?? [] + ids.push(item.itemId) + notes.set(turnItemId, ids) + } + } + stopNotesByTimeline.set(items, notes) + return notes +} function liveItemsById( items: readonly AgentJournalRenderItem[] diff --git a/src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts b/src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts new file mode 100644 index 000000000000..d63ce1c4d1cc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts @@ -0,0 +1,389 @@ +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 { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + applyJournalRow, + createJournalReducerState, + renderJournalState +} from '../agent-session-journal/journal-reducer' +import { projectJournalBatch } from './agent-session-journal-batch' +import { readAgentSessionHistory } from './agent-session-history-page' +import { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import { HISTORY_PAGE_CONTENT_BUDGET_BYTES } from './agent-session-history-page-bounds' + +const journals = createTrackedJournalOpener() +const turnIdentity = { provider: 'orca', clientMessageId: 'turn-1' } as const +const turnItemId = agentJournalItemKey(turnIdentity) +const noteIdentity = structuredAgentSessionStopNoteIdentity('turn-1') +const noteId = agentJournalItemKey(noteIdentity) +const scope = { kind: 'turn', turnItemId } as const +const unconfirmed: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }), + tone: 'error', + presentation: 'error' +} +const ordinary = { kind: 'status', text: 'Cancellation requested.' } +let root: string +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-stop-note-projection-')) + journal = await journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + stateDirectory: root + }) +}) +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function turn(state: AgentJournalTurnLifecycle['state'], legacy = false) { + const lifecycle: AgentJournalTurnLifecycle = { turnId: 'turn-1', state } + await journal.appendItem( + turnIdentity, + legacy + ? { kind: 'status', text: 'Turn ended', turnLifecycle: lifecycle } + : { kind: 'turn', ...lifecycle }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} +async function note(body: AgentJournalItemBody = unconfirmed) { + await journal.appendItem(noteIdentity, body, { fence: 1, turnScope: scope }) +} +function readNote() { + return journal.snapshot().items.find((item) => item.itemId === noteId)! +} +function rows() { + const result = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!result.ok) { + throw new Error(result.reset) + } + return result.rows +} + +it.each([false, true])( + 'derives an interrupted turn note without changing any stored row (legacy=%s)', + async (legacy) => { + await turn('running', legacy) + await note() + const prior = readNote() + const raw = journal.itemBody(noteId) + const held = journal.cursor() + await turn('interrupted', legacy) + const stored = rows() + const projected = readNote() + expect(projected.body).toEqual(ordinary) + expect(projected).toEqual({ ...prior, body: ordinary }) + expect(projected).not.toBe(prior) + expect(readNote()).not.toBe(projected) + expect(journal.itemBody(noteId)).toBe(raw) + expect(rows()).toEqual(stored) + expect(stored.filter((row) => row.kind === 'item' && row.itemId === noteId)).toHaveLength(1) + const replay = createJournalReducerState('session-1', journal.epoch) + stored.forEach((row) => applyJournalRow(replay, row)) + expect(replay.items.get(noteId)?.body).toEqual(unconfirmed) + expect(renderJournalState(replay).items.find((item) => item.itemId === noteId)?.body).toEqual( + ordinary + ) + const batch = projectJournalBatch({ + rows: stored.filter((row) => row.seq > held.sequence), + snapshot: journal.snapshot(), + afterSequence: held.sequence + }) + expect(batch).toMatchObject({ + ok: true, + batch: { + items: [ + expect.objectContaining({ itemId: turnItemId }), + expect.objectContaining({ itemId: noteId, body: ordinary }) + ], + removedItemIds: [] + } + }) + } +) + +it.each(['running', 'completed', 'unverifiable', 'missing'] as const)( + 'leaves a %s turn note unchanged', + async (state) => { + if (state !== 'missing') { + await turn(state) + } + await note() + expect(readNote().body).toBe(journal.itemBody(noteId)) + expect(readNote().body).toEqual(unconfirmed) + } +) + +it('leaves unrelated failures and non-Stop notes unchanged', async () => { + await turn('interrupted') + const failed: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { surface: 'row' }) + } + await note(failed) + await journal.appendItem({ provider: 'orca', clientMessageId: 'other' }, unconfirmed, { + fence: 1, + turnScope: scope + }) + expect(readNote().body).toBe(journal.itemBody(noteId)) + expect(journal.snapshot().items.at(-1)?.body).toEqual(unconfirmed) +}) + +it('derives the op-keyed note after the retained association write re-keys it onto the turn', async () => { + const op = structuredAgentSessionStopNoteIdentity('operation-1') + await journal.appendItem(op, unconfirmed, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + await turn('running') + const held = journal.cursor() + await journal.appendLifecycleBatch({ + settlementId: 'stop-note-on-turn', + fence: 1, + mutations: [ + { kind: 'tombstone', identity: op }, + { kind: 'item', identity: noteIdentity, body: unconfirmed, turnScope: scope } + ] + }) + await turn('interrupted') + expect(readNote().body).toEqual(ordinary) + expect(journal.itemBody(noteId)).toEqual(unconfirmed) + const batch = projectJournalBatch({ + rows: rows().filter((row) => row.seq > held.sequence), + snapshot: journal.snapshot(), + afterSequence: held.sequence + }) + expect(batch).toMatchObject({ ok: true, batch: { removedItemIds: [agentJournalItemKey(op)] } }) +}) + +it('keeps an operation note unconfirmed when no turn opened before the kill failed', async () => { + await journal.appendItem(noteIdentity, unconfirmed, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await turn('interrupted') + expect(readNote().body).toEqual(unconfirmed) +}) + +it('re-emits the note for lifecycle turn mutations and restores the raw wording for a turn tombstone', async () => { + await turn('running') + await note() + const held = journal.cursor() + await journal.appendLifecycleBatch({ + settlementId: 'ended', + fence: 1, + mutations: [ + { + kind: 'item', + identity: turnIdentity, + body: { kind: 'turn', turnId: 'turn-1', state: 'interrupted' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + }) + const batch = projectJournalBatch({ + rows: rows().filter((row) => row.seq > held.sequence), + snapshot: journal.snapshot(), + afterSequence: held.sequence + }) + expect(batch).toMatchObject({ + ok: true, + batch: { + items: [ + expect.objectContaining({ itemId: turnItemId }), + expect.objectContaining({ itemId: noteId, body: ordinary }) + ] + } + }) + const ended = journal.cursor() + await journal.appendLifecycleBatch({ + settlementId: 'removed', + fence: 1, + mutations: [{ kind: 'tombstone', identity: turnIdentity }] + }) + const removed = projectJournalBatch({ + rows: rows().filter((row) => row.seq > ended.sequence), + snapshot: journal.snapshot(), + afterSequence: ended.sequence + }) + expect(removed).toMatchObject({ + ok: true, + batch: { + items: [expect.objectContaining({ itemId: noteId, body: unconfirmed })], + removedItemIds: [turnItemId] + } + }) +}) + +it('pages the end with its earlier note and advances by the consumed end row', async () => { + await turn('running') + await note() + const held = journal.cursor() + await turn('interrupted') + const page = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: held, + limit: 1 + }) + expect(page).toMatchObject({ + ok: true, + page: { + items: [ + expect.objectContaining({ itemId: turnItemId }), + expect.objectContaining({ itemId: noteId, body: ordinary }) + ], + window: { nextCursor: journal.cursor() }, + hasNewer: false + } + }) + const notePage = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'tail', + limit: 1 + }) + expect(notePage).toMatchObject({ + ok: true, + page: { items: [expect.objectContaining({ itemId: noteId, body: ordinary })] } + }) +}) + +it('derives a note page with its ended turn on an earlier page, in either paging direction', async () => { + await turn('interrupted') + const held = journal.cursor() + await note() + const forward = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: held, + limit: 1 + }) + expect(forward).toMatchObject({ + ok: true, + page: { + items: [expect.objectContaining({ itemId: noteId, body: ordinary })], + window: { nextCursor: journal.cursor() } + } + }) + await journal.appendItem( + { provider: 'orca', clientMessageId: 'later' }, + { kind: 'status', text: 'Later' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const backward = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'before', + cursor: journal.cursor(), + limit: 1 + }) + expect(backward).toMatchObject({ + ok: true, + page: { + items: [expect.objectContaining({ itemId: noteId, body: ordinary })], + window: { nextCursor: { sequence: readNote().sequence } } + } + }) +}) + +it('shrinks the triggering row window without dropping the dependent note', async () => { + await turn('running') + await note() + const held = journal.cursor() + const large = { + kind: 'message', + role: 'assistant', + blocks: [ + { type: 'text', text: 'x'.repeat(Math.floor(HISTORY_PAGE_CONTENT_BUDGET_BYTES * 0.6)) } + ] + } as const + for (const clientMessageId of ['large-1', 'large-2']) { + await journal.appendItem( + { provider: 'orca', clientMessageId }, + { ...large, blocks: [...large.blocks] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await turn('interrupted') + const first = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: held, + limit: 3 + }) + if (!first.ok) { + throw new Error(first.reset) + } + expect(first.page.hasNewer).toBe(true) + const next = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: first.page.window.nextCursor, + limit: 3 + }) + expect(next).toMatchObject({ + ok: true, + page: { + items: expect.arrayContaining([expect.objectContaining({ itemId: noteId, body: ordinary })]), + window: { nextCursor: journal.cursor() } + } + }) +}) + +it('refreshes at the exact tip using bounded frames and no durable writes', async () => { + await turn('interrupted') + for (let index = 0; index < 205; index += 1) { + await journal.appendItem(structuredAgentSessionStopNoteIdentity(`op-${index}`), unconfirmed, { + fence: 1, + turnScope: scope + }) + } + const stored = rows() + const events: AgentSessionSubscribeEvent[] = [] + new AgentSessionSubscribers().open({ + id: 'reconnect', + sessionId: 'session-1', + journal, + fence: 1, + cursor: journal.cursor(), + emit: (event) => events.push(event) + }) + const batches = events.flatMap((event) => (event.type === 'batch' ? [event.batch] : [])) + expect(batches.flatMap((batch) => batch.items)).toHaveLength(205) + expect( + batches.every( + (batch) => + batch.items.length <= 200 && + Buffer.byteLength(JSON.stringify(batch), 'utf8') < HISTORY_PAGE_CONTENT_BUDGET_BYTES && + batch.cursor.sequence === journal.cursor().sequence + ) + ).toBe(true) + expect( + batches + .flatMap((batch) => batch.items) + .every( + (item) => + item.body.kind === 'status' && item.body.text === ordinary.text && !item.body.failure + ) + ).toBe(true) + expect(rows()).toEqual(stored) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts new file mode 100644 index 000000000000..5f86048af0c1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts @@ -0,0 +1,77 @@ +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + HISTORY_PAGE_CONTENT_BUDGET_BYTES, + oversizedHistoryItem +} from './agent-session-history-page-bounds' +import { emptyAgentSessionBatch } from './agent-session-empty-batch' +import type { SubscriberDeliveryPort } from './agent-session-subscriber-catch-up' +import type { Subscriber } from './structured-agent-session-subscribers' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' + +/** A stale in-flight history page can land after its note's live correction was out of window. */ +export function refreshDerivedStopNotes( + port: Pick, + subscriber: Subscriber, + journal: AgentSessionJournal, + hostNow: number +): void { + let hasUnconfirmedStop = false + journal.visitItems((itemId, _sequence, body) => { + if ( + body.kind === 'status' && + body.failure?.kind === 'cancelUnconfirmed' && + isStructuredAgentSessionStopNote(itemId) + ) { + hasUnconfirmedStop = true + } + }) + if (!hasUnconfirmedStop) { + return + } + const snapshot = journal.snapshot() + let pending: AgentJournalRenderItem[] = [] + let bytes = 0 + const emit = (): void => { + port.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: { ...emptyAgentSessionBatch(subscriber.cursor), items: pending }, + fence: subscriber.fence, + hostNow + }) + pending = [] + bytes = 0 + } + for (const item of snapshot.items) { + if (!port.isActive(subscriber)) { + return + } + const raw = journal.itemBody(item.itemId) + if ( + raw?.kind !== 'status' || + raw.failure?.kind !== 'cancelUnconfirmed' || + item.body.kind !== 'status' || + item.body.failure !== undefined + ) { + continue + } + const projectedBytes = Buffer.byteLength(JSON.stringify(item), 'utf8') + const bounded = + projectedBytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES ? oversizedHistoryItem(item) : item + const itemBytes = Buffer.byteLength(JSON.stringify(bounded), 'utf8') + 1 + if ( + pending.length > 0 && + (pending.length === AGENT_SESSION_HISTORY_MAX_LIMIT || + bytes + itemBytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES) + ) { + emit() + } + pending.push(bounded) + bytes += itemBytes + } + if (pending.length > 0 && port.isActive(subscriber)) { + emit() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts new file mode 100644 index 000000000000..ee99584e69a4 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts @@ -0,0 +1,348 @@ +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +// Stop notes follow interrupted ends written by the shipping Claude translator. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const CAPABILITIES = ['interrupt_receipt_v1', 'interrupt_cancel_queued_v1', 'msg_lifecycle_v1'] + +let events: AgentSessionSubscribeEvent[] +let unconfirmedNote: AgentJournalRenderItem | undefined +let root: string +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let store: AgentSessionRecordStore +let claude: ReturnType +let log: ReturnType +let persistHandle: ReturnType Promise>> +let childWork: string[] + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-stop-exit-ends-record-')) + resetHostTestOperationIds() + events = [] + unconfirmedNote = undefined + log = recordingStructuredAgentSessionLogger() + persistHandle = vi.fn(async () => undefined) + childWork = [] + claude = fakeClaude({ + replayUuid: null, + routes: { interrupt: () => ({ still_queued: [], cancelled: [] }) } + }) + const lifecycle: Promise[] = [] + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + lifecycle.push(host.handleAdapterEvent(mapped)) + } + }, + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + persistHandle: () => persistHandle(), + logger: log.logger, + onChildWorkEvidence: (sessionId, evidence) => { + childWork.push(...evidence.map((edge) => edge.type)) + host.publishChildWorkEvidence(sessionId, evidence) + }, + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => NOW + }) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + logger: log.logger, + // Ticked by hand only, and with no idle window: a tick reaps whatever is at rest. + idleSweep: { intervalMs: 3_600_000, idleMs: 0 }, + now: () => NOW + }) + const params = hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) + await Promise.all(lifecycle) + await host.subscribe({ + id: 'stop-note-live', + sessionId: SESSION, + emit: (event) => events.push(event) + }) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function eventually(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +function envelope( + method: + | 'agentSession.send' + | 'agentSession.cancel' + | 'agentSession.setOption' + | 'agentSession.queuedMessageSend', + // The fingerprint's own field shape, as the sibling host tests type it. + fields: Parameters[0]['fields'] +) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(`send refused: ${JSON.stringify(sent.refusal)}`) + } + return sent.value.clientMessageId +} + +async function submission(clientMessageId: string) { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function wrote(connection: FakeConnection, text: string): boolean { + return connection.sent.some((message) => JSON.stringify(message).includes(text)) +} + +async function openTurn(connection: FakeConnection): Promise { + const text = 'Write a long reply.' + const clientMessageId = await send(text) + await eventually(() => expect(wrote(connection, text)).toBe(true)) + frame(connection, { + type: 'system', + subtype: 'init', + uuid: 'init-1', + model: 'claude-sonnet-5', + capabilities: CAPABILITIES + }) + const written = connection.sent.at(-1)! + frame(connection, { ...written, uuid: written.uuid }) + frame(connection, { + type: 'assistant', + uuid: 'stopped-turn-leaf', + parent_tool_use_id: null, + message: { id: 'msg-1', role: 'assistant', content: [{ type: 'text', text: 'Working on' }] } + }) + await eventually(async () => + expect((await submission(clientMessageId))?.dispatchState).toBe('accepted') + ) + expect( + activeStructuredAgentSessionTurnId((await host.journalSnapshot(SESSION)).items) + ).not.toBeNull() +} + +function stop() { + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) +} + +function laneDrained(): Promise { + return host['tasks'].serialize(SESSION, async () => {}) +} + +/** As the real connection: once a close begins it refuses every write, proven or not. */ +function closeUnprovenFor(connection: FakeConnection, failures: number): void { + const close = connection.close + let left = failures + connection.close = async () => { + if (left === 0) { + return close() + } + left -= 1 + connection.closeCount += 1 + connection.closed = true + return false + } + refuseWritesOnceClosed(connection) +} + +function refuseWritesOnceClosed(connection: FakeConnection): void { + const write = connection.send + connection.send = (message, beforeDispatch) => + connection.closed + ? Promise.reject( + claudeUnwrittenUserMessageError(new Error('claude stream-json connection is closed')) + ) + : write(message, beforeDispatch) +} + +const INTERRUPTED_RESULT = { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + terminal_reason: 'aborted_streaming', + uuid: 'interrupted-result' +} + +async function notesAndTurn() { + await host.flushStreamedEvents(SESSION) + const items = (await host.journalSnapshot(SESSION)).items + return { + notes: items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)), + turn: items.find((item) => item.body.kind === 'turn') + } +} + +async function unconfirmedStop(): Promise { + const connection = claude.connections[0]! + await openTurn(connection) + closeUnprovenFor(connection, 1) + await expect(stop()).resolves.toMatchObject({ ok: true }) + await eventually(async () => { + const { notes, turn } = await notesAndTurn() + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + expect(turn?.body).toMatchObject({ state: 'running' }) + unconfirmedNote = notes[0] + }) + return connection +} + +async function confirmedNote(): Promise { + await eventually(async () => { + const { notes, turn } = await notesAndTurn() + expect(turn?.body).toMatchObject({ state: 'interrupted' }) + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toEqual({ kind: 'status', text: 'Cancellation requested.' }) + expect(notes[0]?.turnScope).toEqual({ kind: 'turn', turnItemId: turn?.itemId }) + expect(notes[0]).toMatchObject({ + itemId: unconfirmedNote?.itemId, + revision: unconfirmedNote?.revision, + sequence: unconfirmedNote?.sequence + }) + const journal = host['sessions'].get(SESSION)!.journal + expect(journal.itemBody(notes[0]!.itemId)).toMatchObject({ + failure: { kind: 'cancelUnconfirmed' } + }) + expect( + events.some( + (event) => + event.type === 'batch' && + event.batch.items.some( + (item) => + item.itemId === notes[0]?.itemId && + item.body.kind === 'status' && + item.body.text === 'Cancellation requested.' && + !item.body.failure + ) && + event.batch.items.some( + (item) => + item.itemId === turn?.itemId && + item.body.kind === 'turn' && + item.body.state === 'interrupted' + ) + ) + ).toBe(true) + }) +} + +it("says the Stop took once the agent's process exits on its own after the kill timed out", async () => { + const connection = await unconfirmedStop() + connection.handlers.onExit?.(new Error('claude exited'), { expected: true }) + await laneDrained() + await confirmedNote() +}) + +it('projects the note when a second Stop joins the close and proves the exit', async () => { + await unconfirmedStop() + await expect(stop()).resolves.toMatchObject({ ok: true }) + await confirmedNote() +}) + +it('projects the note when a send joins the close and proves the exit', async () => { + const connection = await unconfirmedStop() + await send('Carry on.') + await eventually(() => { + const resumed = claude.connections.at(-1)! + expect(resumed).not.toBe(connection) + expect(wrote(resumed, 'Carry on.')).toBe(true) + }) + await confirmedNote() +}) + +it("projects the note when Claude's own interrupted result ends the turn", async () => { + const connection = await unconfirmedStop() + frame(connection, INTERRUPTED_RESULT) + await laneDrained() + await confirmedNote() +}) + +it('keeps the note unconfirmed when the turn completes', async () => { + const connection = await unconfirmedStop() + frame(connection, { + type: 'result', + subtype: 'success', + is_error: false, + uuid: 'completed-result' + }) + await eventually(async () => { + const { notes, turn } = await notesAndTurn() + expect(turn?.body).toMatchObject({ state: 'completed' }) + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts new file mode 100644 index 000000000000..822403f2ef97 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts @@ -0,0 +1,252 @@ +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +// What a Stop's row says when Codex took the interrupt, against the real host, journal and Codex +// adapter. Codex answers a turn's interrupt as the turn aborts and sends the turn's end right after +// the answer, so the end reaches Orca a moment after the Stop has its answer. + +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { CodexAppServerTimeoutError } from '../../codex/codex-app-server-session' +import { + THREAD_ID as THREAD, + adapterFor, + fakeCodex +} from '../../codex/codex-structured-session-adapter-fixture' +import { codexTurnLifecycleFake } from '../../codex/codex-turn-lifecycle-fake' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' +import type { CodexStructuredSessionAdapter } from '../../codex/codex-structured-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' + +const CALLER = { callerKey: 'client-1' } + +let events: AgentSessionSubscribeEvent[] +let unconfirmedNote: AgentJournalRenderItem | undefined +let root: string +let host: StructuredAgentSessionHost +let turns: ReturnType +let codex: ReturnType +let notify: (method: string, params: unknown) => void +/** Read at each start, so a test can say what the next start resumes. */ +let launch: { resumeThreadId?: string | null } +let adapter: CodexStructuredSessionAdapter + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-stop-row-')) + resetHostTestOperationIds() + events = [] + unconfirmedNote = undefined + codex = fakeCodex() + notify = (method, params) => codex.connections.at(-1)?.handlers.onNotification?.(method, params) + turns = codexTurnLifecycleFake(THREAD, () => notify) + codex.routes['turn/start'] = turns.routes['turn/start'] + codex.routes['turn/interrupt'] = () => { + const turnId = turns.turnId + // The answer first, then the turn's end on a later read of Codex's output. + setTimeout( + () => + notify('turn/completed', { threadId: THREAD, turn: { id: turnId, status: 'interrupted' } }), + 4 + ) + return {} + } + const store = await openTestAgentSessionRecordStore(root) + // The runtime's wiring: an echo accepts its send, and every exit reaches the host. + launch = {} + adapter = adapterFor(codex, launch, [], { + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + onEvent: (event) => { + if (event.type === 'ended' && 'cause' in event) { + void host.handleAdapterEvent(event) + } + } + }) + host = new StructuredAgentSessionHost({ + logger: recordingStructuredAgentSessionLogger().logger, + store, + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-1', + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + await host.subscribe({ + id: 'stop-note-live', + sessionId: SESSION, + emit: (event) => events.push(event) + }) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function send(text: string) { + const body = hostTestMessage(text) + return host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) +} + +function stop(turnId?: string) { + const fields = turnId === undefined ? {} : { turnId } + return host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields + }) + }, + ...fields + }) +} + +async function runningTurn(): Promise { + const sent = await send('count to 40') + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + await vi.waitFor(() => expect(turns.turnId).toBe('turn-1')) + turns.start() + turns.echo(sent.value.clientMessageId) + await vi.waitFor(async () => + expect((await notesAndTurn()).turn?.body).toMatchObject({ state: 'running' }) + ) +} + +async function notesAndTurn() { + await host.flushStreamedEvents(SESSION) + const items = (await host.journalSnapshot(SESSION)).items + return { + notes: items.filter((item) => isStructuredAgentSessionStopNote(item.itemId)), + turn: items.find((item) => item.body.kind === 'turn') + } +} + +async function unconfirmedStop() { + await runningTurn() + codex.routes['turn/interrupt'] = () => { + throw new CodexAppServerTimeoutError('codex app-server turn/interrupt exceeded 30000ms') + } + const connection = codex.connections[0]! + const close = connection.close + connection.close = async () => { + connection.close = close + connection.closeCount += 1 + return false + } + await expect(stop()).resolves.toMatchObject({ ok: true }) + const { notes, turn } = await notesAndTurn() + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + expect(turn?.body).toMatchObject({ state: 'running' }) + unconfirmedNote = notes[0] + return connection +} + +async function confirmedNote(): Promise { + await vi.waitFor(async () => { + const { notes, turn } = await notesAndTurn() + expect(turn?.body).toMatchObject({ state: 'interrupted' }) + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toEqual({ kind: 'status', text: 'Cancellation requested.' }) + expect(notes[0]?.turnScope).toEqual({ kind: 'turn', turnItemId: turn?.itemId }) + expect(notes[0]).toMatchObject({ + itemId: unconfirmedNote?.itemId, + revision: unconfirmedNote?.revision, + sequence: unconfirmedNote?.sequence + }) + const journal = host['sessions'].get(SESSION)!.journal + expect(journal.itemBody(notes[0]!.itemId)).toMatchObject({ + failure: { kind: 'cancelUnconfirmed' } + }) + expect( + events.some( + (event) => + event.type === 'batch' && + event.batch.items.some( + (item) => + item.itemId === notes[0]?.itemId && + item.body.kind === 'status' && + item.body.text === 'Cancellation requested.' && + !item.body.failure + ) && + event.batch.items.some( + (item) => + item.itemId === turn?.itemId && + item.body.kind === 'turn' && + item.body.state === 'interrupted' + ) + ) + ).toBe(true) + }) +} + +it('projects the note on a late self-exit after the kill timed out', async () => { + const connection = await unconfirmedStop() + connection.handlers.onExit?.(new Error('codex exited'), { expected: true }) + await confirmedNote() +}) + +it('projects the note when a second Stop joins the close and proves the exit', async () => { + await unconfirmedStop() + await expect(stop()).resolves.toMatchObject({ ok: true }) + await confirmedNote() +}) + +it('projects the note when a send joins the close and proves the exit', async () => { + const connection = await unconfirmedStop() + await expect(send('Carry on.')).resolves.toMatchObject({ ok: true }) + await vi.waitFor(() => { + expect(codex.connections.at(-1)).not.toBe(connection) + expect(connection.closeCount).toBe(2) + }) + await confirmedNote() +}) + +it("projects the note when Codex's own interrupted result ends the turn", async () => { + await unconfirmedStop() + turns.end('interrupted') + await confirmedNote() +}) + +it('keeps the note unconfirmed when the turn completes', async () => { + await unconfirmedStop() + turns.end('completed') + await vi.waitFor(async () => { + const { notes, turn } = await notesAndTurn() + expect(turn?.body).toMatchObject({ state: 'completed' }) + expect(notes).toHaveLength(1) + expect(notes[0]?.body).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + }) +}) 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 8b447edd7018..947153097b11 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 @@ -12,7 +12,6 @@ import { type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { stopNoteRevisionsForEndedTurns } from './structured-agent-session-stop-note-revisions' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { cancelledJournalPromptBody } from '../agent-session-journal/journal-prompt-body-bounds' @@ -188,7 +187,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { } } const turnEnds = runningTurnLifecycleRevisions(items, input.verdict) - mutations.push(...turnEnds, ...stopNoteRevisionsForEndedTurns(items, turnEnds)) + mutations.push(...turnEnds) const batchId = `dead-generation:${input.settlementId}` for (const chunk of partitionJournalLifecycleMutations(batchId, mutations)) { await input.journal.appendLifecycleBatch({ @@ -251,7 +250,7 @@ export async function settleStaleStructuredAgentSessionState(input: { ...items.flatMap((item) => runningTurnLifecycleRevisions([item], verdictFor(item))), ...proven ] - mutations.push(...turnEnds, ...stopNoteRevisionsForEndedTurns(items, turnEnds)) + mutations.push(...turnEnds) const evidence = input.deathEvidence if ( evidence && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts new file mode 100644 index 000000000000..f3664c3166f2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-stop-note.test-fixture.ts @@ -0,0 +1,110 @@ +import { expect, vi, type Mock } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_SESSION, + HOST_TEST_THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +export async function expectRawStopNoteRewindRecovery({ + host, + store, + rewind +}: { + host: StructuredAgentSessionHost + store: AgentSessionRecordStore + rewind: Mock> +}): Promise { + const caller = { callerKey: 'desktop' } + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const journal = host['sessions'].get(HOST_TEST_SESSION)!.journal + const turn = { provider: 'orca', clientMessageId: 'kept-turn' } as const + const note = { provider: 'orca', clientMessageId: 'stop:kept-turn' } as const + const noteId = agentJournalItemKey(note) + const unconfirmed: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + } + await journal.appendItem( + turn, + { kind: 'turn', turnId: 'kept-turn', state: 'interrupted' }, + { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) + await journal.appendItem(note, unconfirmed, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turn) } + }) + const targetIdentity = { + provider: 'codex', + threadId: HOST_TEST_THREAD, + turnId: 'drop', + ordinal: 0 + } as const + await journal.appendItem(targetIdentity, hostTestMessage('drop'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const target = agentJournalItemKey(targetIdentity) + expect(journal.snapshot().items.find((item) => item.itemId === noteId)?.body).toEqual({ + kind: 'status', + text: 'Cancellation requested.' + }) + rewind.mockImplementation(async () => { + expect( + store.getRecord(HOST_TEST_SESSION)?.rewind?.retained.find((item) => item.itemId === noteId) + ?.body + ).toEqual(unconfirmed) + return { ok: true } + }) + const replace = journal.replaceEpochItems.bind(journal) + const crash = vi.spyOn(journal, 'replaceEpochItems').mockImplementationOnce(async (...args) => { + await replace(...args) + throw new Error('crashed after commit') + }) + await expect( + host.rewind(caller, { + itemId: target, + expectedEpoch: journal.epoch, + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.rewind', + sessionId: HOST_TEST_SESSION, + fields: { itemId: target, expectedEpoch: journal.epoch } + }) + } + }) + ).rejects.toThrow('crashed after commit') + crash.mockRestore() + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('provider-succeeded') + expect( + await host.attach( + caller, + hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) + ) + ).toMatchObject({ ok: true }) + expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') + expect(journal.itemBody(noteId)).toEqual(unconfirmed) + expect(journal.snapshot().items.find((item) => item.itemId === noteId)?.body).toEqual({ + kind: 'status', + text: 'Cancellation requested.' + }) + expect(rewind).toHaveBeenCalledTimes(1) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 6e60651d4bdd..3e1d92357fa1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -1,3 +1,4 @@ +import { expectRawStopNoteRewindRecovery } from './structured-agent-session-rewind-stop-note.test-fixture' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemBody @@ -183,6 +184,10 @@ async function params(itemId: string, epoch?: string) { } describe('host rewind', () => { + it('retains the raw Stop failure and recovers a committed rewind against raw bodies', async () => { + await expectRawStopNoteRewindRecovery({ host, store, rewind }) + }) + it('resolves accepted codex user submissions to provider targets', async () => { const target = await seed(true) expect(target.startsWith('orca:')).toBe(true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index 6003bba605a1..12e73cb610f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -120,13 +120,19 @@ export async function rewindStructuredAgentSession( } const retained = snapshot.items .slice(0, boundary) - .map(({ itemId, body, observedAt, turnScope, ...linkage }) => ({ - itemId: providerKey(itemId), - body: withRenamedTurnOpener(body, providerKey), - observedAt, - ...(turnScope ? { turnScope } : {}), - ...agentJournalLinkageFields(linkage) - })) + .map(({ itemId, observedAt, turnScope, ...linkage }) => { + const body = ctx.journal.itemBody(itemId) + if (!body) { + throw new Error('agent_session_rewind:missing-retained-item') + } + return { + itemId: providerKey(itemId), + body: withRenamedTurnOpener(body, providerKey), + observedAt, + ...(turnScope ? { turnScope } : {}), + ...agentJournalLinkageFields(linkage) + } + }) if ( retained.length > 10_000 || Buffer.byteLength(JSON.stringify(retained), 'utf8') > diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.test.ts deleted file mode 100644 index b40e6abe8264..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -// A settlement's proven turn end says a Stop's unconfirmed note on that turn took; nothing else. - -import { describe, expect, it } from 'vitest' -import { 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 { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentJournalRenderItem -} from '../../../shared/agent-session-journal-types' -import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' -import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' -import { stopNoteRevisionsForEndedTurns } from './structured-agent-session-stop-note-revisions' - -const TURN: AgentJournalItemIdentity = { - provider: 'legacy', - agent: 'codex', - sessionId: 'session-1', - recordId: 'turn-lifecycle:turn-1' -} -const TURN_ITEM = agentJournalItemKey(TURN) -const UNCONFIRMED: AgentJournalItemBody = { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) -} - -function note(key: string, body: AgentJournalItemBody, turnItemId = TURN_ITEM) { - const identity = structuredAgentSessionStopNoteIdentity(key) - return { - identity, - item: { - itemId: agentJournalItemKey(identity), - revision: 1, - sequence: 3, - observedAt: 3, - body, - turnScope: { kind: 'turn', turnItemId } - } satisfies AgentJournalRenderItem - } -} - -function turnEnd(state: 'interrupted' | 'completed'): JournalLifecycleMutationInput { - return { - kind: 'item', - identity: TURN, - body: agentJournalTurnBody({ turnId: 'turn-1', state, completedAt: 10 }), - turnScope: { kind: 'thread' } - } -} - -describe('a Stop note on a turn a settlement ends', () => { - it('says the Stop took when the turn ends interrupted', () => { - const { identity, item } = note('turn-1', UNCONFIRMED) - - expect(stopNoteRevisionsForEndedTurns([item], [turnEnd('interrupted')])).toEqual([ - { - kind: 'item', - identity, - body: { kind: 'status', text: 'Cancellation requested.' }, - turnScope: { kind: 'turn', turnItemId: TURN_ITEM } - } - ]) - }) - - it('is left alone when the turn completes on its own', () => { - const { item } = note('turn-1', UNCONFIRMED) - - expect(stopNoteRevisionsForEndedTurns([item], [turnEnd('completed')])).toEqual([]) - }) - - it('is left alone on another turn, or when it already says the Stop took', () => { - const elsewhere = note('turn-2', UNCONFIRMED, 'another-turn').item - const took = note('turn-1', { kind: 'status', text: 'Cancellation requested.' }).item - - expect(stopNoteRevisionsForEndedTurns([elsewhere, took], [turnEnd('interrupted')])).toEqual([]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.ts deleted file mode 100644 index 4424c905f2bf..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-revisions.ts +++ /dev/null @@ -1,50 +0,0 @@ -// What a proven end says about a Stop's note: the work the Stop meant to end did end. - -import { - agentJournalItemKey, - parseAgentJournalItemKey -} from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn' -import { STOP_NOTE_CANCELLATION_REQUESTED } from './structured-agent-session-turn-stop-notes' - -/** - * A settlement that ends turns `interrupted` on a proven exit also revises any Stop note on those - * turns that an unproven attempt left unconfirmed: it says the Stop took. Found by the note's turn - * scope, whatever its key, and re-derived by every settlement, so nothing is stored for it. - */ -export function stopNoteRevisionsForEndedTurns( - items: readonly AgentJournalRenderItem[], - turnEnds: readonly JournalLifecycleMutationInput[] -): JournalLifecycleMutationInput[] { - const interrupted = new Set( - turnEnds.flatMap((mutation) => - mutation.kind === 'item' && readAgentJournalTurn(mutation.body)?.state === 'interrupted' - ? [agentJournalItemKey(mutation.identity)] - : [] - ) - ) - if (interrupted.size === 0) { - return [] - } - return items.flatMap((item) => { - const identity = parseAgentJournalItemKey(item.itemId) - return identity && - isStructuredAgentSessionStopNote(item.itemId) && - item.body.kind === 'status' && - item.body.failure?.kind === 'cancelUnconfirmed' && - item.turnScope?.kind === 'turn' && - interrupted.has(item.turnScope.turnItemId) - ? [ - { - kind: 'item' as const, - identity, - body: { kind: 'status' as const, text: STOP_NOTE_CANCELLATION_REQUESTED }, - turnScope: item.turnScope - } - ] - : [] - }) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index a61d5a572406..6625f85f8f54 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -18,6 +18,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import { emptyAgentSessionBatch } from './agent-session-empty-batch' import { readAgentSessionHydrationPage } from './agent-session-history-page' import { rememberSessionActivity } from './structured-agent-session-activity-retention' +import { refreshDerivedStopNotes } from './agent-session-stop-note-refresh' export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void export type AgentSessionSubscribeInput = { @@ -87,6 +88,15 @@ export class AgentSessionSubscribers { const hostNow = this.now() if (input.cursor) { this.deliver(subscriber, input.journal, hostNow, true, input.backgroundTasks) + refreshDerivedStopNotes( + { + emit: (target, event) => this.emit(target, event), + isActive: (target) => this.isActive(target) + }, + subscriber, + input.journal, + hostNow + ) } else { const page = readAgentSessionHydrationPage(input.journal, input.fence) this.emit(subscriber, { diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index a45b821e465c..6c9dfaf47823 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -152,7 +152,10 @@ export async function recoverStructuredRewind( if ( alreadyReplaced && !isDeepStrictEqual( - journal.snapshot().items.map(({ itemId, body }) => ({ itemId, body })), + journal.snapshot().items.map(({ itemId }) => ({ + itemId, + body: journal.itemBody(itemId) + })), replacement.map(({ identity, body }) => ({ itemId: agentJournalItemKey(identity), body })) ) ) { diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index d7444e2c77e6..634f4d566603 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -22,6 +22,14 @@ import { } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' +import { agentJournalItemKey } from '../../../src/shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { + applyJournalRow, + createJournalReducerState, + renderJournalState +} from '../../../src/main/native-chat/agent-session-journal/journal-reducer' // A release that knows neither the Stop event nor the Resume marker: an unknown row kind would // make it delete the journal from that row on, so both ride a tombstone it already reads. @@ -49,6 +57,69 @@ const OLDER_IDENTITY: OlderJournalIdentity = { providerHandle: { kind: 'codex', threadId: 'thread-1' } } +test.each(['v1.4.219', 'v1.4.220', WRITABLE_BASELINE_REF])( + '%s raw replay preserves the unconfirmed failure in both host directions', + async (ref) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-stop-note-raw-skew-')) + const journals = createTrackedJournalOpener() + try { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + const turn = { provider: 'orca', clientMessageId: 'turn-raw-skew' } as const + const note = { provider: 'orca', clientMessageId: 'stop:turn-raw-skew' } as const + const unconfirmed = { + kind: 'status' as const, + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { + surface: 'row' + }) + } + await journal.appendItem( + turn, + { + kind: 'status', + text: 'Interrupted', + turnLifecycle: { turnId: 'turn-raw-skew', state: 'interrupted' } + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendItem(note, unconfirmed, { + fence: 1, + turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turn) } + }) + const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(since.reset) + } + const rawJson = JSON.stringify(since.rows) + const checkout = await materializeReleaseCheckout(ref) + const reducer = await importReleaseCheckoutModule(checkout, `${JOURNAL}/journal-reducer.ts`) + const create = releaseExport<(sessionId: string, epoch: string) => OldReplay['state']>( + reducer, + 'createJournalReducerState' + ) + const fold = releaseExport<(state: OldReplay['state'], row: JournalRow) => void>( + reducer, + 'applyJournalRow' + ) + const old = create(IDENTITY.sessionId, journal.epoch) + since.rows.forEach((row) => fold(old, row)) + expect(old.items.get(agentJournalItemKey(note))).toMatchObject({ body: unconfirmed }) + const current = createJournalReducerState(IDENTITY.sessionId, journal.epoch) + since.rows.forEach((row) => applyJournalRow(current, row)) + expect(current.items.get(agentJournalItemKey(note))?.body).toEqual(unconfirmed) + expect( + renderJournalState(current).items.find((item) => item.itemId === agentJournalItemKey(note)) + ?.body + ).toEqual({ kind: 'status', text: 'Cancellation requested.' }) + expect(JSON.stringify(since.rows)).toBe(rawJson) + expect(journal.itemBody(agentJournalItemKey(note))).toEqual(unconfirmed) + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } + }, + 120_000 +) + function item(ordinal: number): AgentJournalItemIdentity { return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } } diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index d427eb5109a8..8f7543a22505 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -65,10 +65,13 @@ import { import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' +import { describeReleasedStopNoteProjection } from './cross-version-stop-note-scenarios' // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 +describeReleasedStopNoteProjection({ build: () => current, callBuild, runtimeStub }) + const CLIENT_CAPABILITY_UPDATE_METHOD = 'runtime.clientCapabilities.update' let baselineRef: string diff --git a/tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts b/tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts new file mode 100644 index 000000000000..8d3dc4f4d436 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-stop-note-scenarios.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../src/shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../src/shared/agent-session-journal-types' +import { HISTORY_PAGE_CONTENT_BUDGET_BYTES } from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page-bounds' +import { + createReleasedStopNoteRig, + noteId, + noteIdentity, + turnItemId, + scope, + ordinary, + unconfirmed, + type ScenarioPort +} from './cross-version-stop-note-test-rig' + +export function describeReleasedStopNoteProjection(port: ScenarioPort): void { + describe.each(['v1.4.219', 'v1.4.220'])( + '%s client against the current host Stop projection', + (ref) => { + const rig = createReleasedStopNoteRig(port, ref) + const { page, subscribe, events, reduce, render, turn, note, seed } = rig + it('renders the derived page with the interrupted turn outside the page', async () => { + await turn('interrupted') + await note() + const snapshot = rig.journal.snapshot() + const stored = rig.journal.itemBody(noteId) + const notePage = await page({ direction: 'tail', limit: 1 }) + expect(notePage.items.map((item) => item.itemId)).toEqual([noteId]) + const state = rig.client.reduceStructuredAgentSession( + rig.client.EMPTY_STRUCTURED_AGENT_SESSION, + { + type: 'history-page', + page: notePage + } + ) + expect(render(state)).toMatchObject({ type: 'text', text: ordinary.text }) + expect(state.items[0]).toEqual(snapshot.items.find((item) => item.itemId === noteId)) + expect(rig.journal.itemBody(noteId)).toBe(stored) + }) + it.each([false, true])( + 'renders the same-revision note with a live turn end (lifecycle batch=%s)', + async (batch) => { + const before = await seed() + expect(render(before)).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + const prior = before.items.find((item) => item.itemId === noteId)! + const replies = await subscribe() + replies.length = 0 + await turn('interrupted', batch) + const event = events(replies).find( + (event) => + event.type === 'batch' && event.batch.items.some((item) => item.itemId === noteId) + ) + expect(event).toMatchObject({ + type: 'batch', + batch: { + items: expect.arrayContaining([ + expect.objectContaining({ itemId: turnItemId }), + expect.objectContaining({ ...prior, body: ordinary }) + ]), + removedItemIds: [] + } + }) + const after = reduce(before, replies) + expect(render(after)).toEqual({ type: 'text', text: ordinary.text }) + expect(rig.journal.itemBody(noteId)).toEqual(unconfirmed) + const since = rig.journal.readSince(before.cursor!) + expect(since).toMatchObject({ + ok: true, + rows: [expect.objectContaining({ kind: batch ? 'lifecycle-batch' : 'item' })] + }) + } + ) + it('refreshes a non-upgrade stale page after an out-of-window live correction at the terminal cursor', async () => { + await turn('running') + await note() + await rig.journal.appendItem( + { provider: 'orca', clientMessageId: 'newer' }, + { kind: 'status', text: 'Newer' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const tail = await page({ direction: 'tail', limit: 1 }) + let state = rig.client.reduceStructuredAgentSession( + rig.client.EMPTY_STRUCTURED_AGENT_SESSION, + { + type: 'history-page', + page: tail + } + ) + const requestedCursor = tail.window.nextCursor + const stale = await page({ direction: 'before', cursor: requestedCursor, limit: 1 }) + const replies = await subscribe() + replies.length = 0 + await turn('interrupted') + state = reduce(state, replies) + expect(state.items.some((item) => item.itemId === noteId)).toBe(false) + state = rig.client.reduceStructuredAgentSession(state, { + type: 'older-page', + requestedCursor, + page: stale + }) + expect(render(state)).toMatchObject({ failure: { kind: 'cancelUnconfirmed' } }) + expect(state.cursor).toEqual(rig.journal.cursor()) + const held = rig.journal.cursor() + const refreshed = await subscribe(held) + state = reduce(state, refreshed) + expect(render(state)).toEqual({ type: 'text', text: ordinary.text }) + expect(rig.journal.cursor()).toEqual(held) + }) + it('catches up through byte-bounded pages without losing the dependent note or cursor', async () => { + const before = await seed() + const held = rig.journal.cursor() + for (const clientMessageId of ['large-a', 'large-b']) { + await rig.journal.appendItem( + { provider: 'orca', clientMessageId }, + { + kind: 'message', + role: 'assistant', + blocks: [ + { + type: 'text', + text: 'x'.repeat(Math.floor(HISTORY_PAGE_CONTENT_BUDGET_BYTES * 0.6)) + } + ] + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await turn('interrupted') + const first = await page({ direction: 'after', cursor: held, limit: 3 }) + expect(first.hasNewer).toBe(true) + const caughtUp = await subscribe(held) + const after = reduce(before, caughtUp) + expect(render(after)).toEqual({ type: 'text', text: ordinary.text }) + expect(after.cursor).toEqual(rig.journal.cursor()) + expect(events(caughtUp).filter((event) => event.type === 'batch').length).toBeGreaterThan(1) + }) + it.each(['running', 'completed', 'unverifiable', 'missing', 'other-failure'] as const)( + 'keeps %s wording through the released page reader', + async (state) => { + if (state !== 'missing') { + await turn(state === 'other-failure' ? 'interrupted' : state) + } + const body: AgentJournalItemBody = + state === 'other-failure' + ? { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + surface: 'row' + }) + } + : unconfirmed + await note(body) + const initial = await page({ direction: 'tail' }) + expect(initial.items.find((item) => item.itemId === noteId)?.body).toEqual(body) + } + ) + it('preserves removal when the operation note is re-keyed before the interrupted end', async () => { + const op = { provider: 'orca', clientMessageId: 'stop:operation-1' } as const + await rig.journal.appendItem(op, unconfirmed, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await turn('running') + const initial = await page({ direction: 'tail' }) + const before = rig.client.reduceStructuredAgentSession( + rig.client.EMPTY_STRUCTURED_AGENT_SESSION, + { + type: 'history-page', + page: initial + } + ) + const held = rig.journal.cursor() + await rig.journal.appendLifecycleBatch({ + settlementId: 're-key', + fence: 1, + mutations: [ + { kind: 'tombstone', identity: op }, + { kind: 'item', identity: noteIdentity, body: unconfirmed, turnScope: scope } + ] + }) + await turn('interrupted') + const replies = await subscribe(held) + const after = reduce(before, replies) + expect(after.items.some((item) => item.itemId === agentJournalItemKey(op))).toBe(false) + expect(render(after)).toEqual({ type: 'text', text: ordinary.text }) + }) + } + ) +} diff --git a/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts b/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts new file mode 100644 index 000000000000..91fc6d1e63e5 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts @@ -0,0 +1,211 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeAll, beforeEach, expect } from 'vitest' +import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' +import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../../../src/main/native-chat/agent-session-journal/journal-store' +import { agentJournalItemKey } from '../../../src/shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalTurnLifecycle +} from '../../../src/shared/agent-session-journal-types' +import type { + AgentSessionHistoryPage, + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' +import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' +import { codexProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' +import { + attachParams, + NOW, + resetOperationIds, + SESSION, + THREAD +} from './structured-agent-session-surface-manifest' +import { + loadAgentSessionWireBuild, + type AgentSessionClientProjection, + type AgentSessionWireBuild, + type RpcClientIdentity, + type RpcReply +} from './versioned-agent-session-wire' + +export type ScenarioPort = { + build: () => AgentSessionWireBuild + callBuild: ( + build: AgentSessionWireBuild, + method: string, + params: unknown, + client: RpcClientIdentity, + runtime?: unknown + ) => Promise + runtimeStub: () => unknown +} +export const turnIdentity = { provider: 'orca', clientMessageId: 'turn-stop-test' } as const +export const turnItemId = agentJournalItemKey(turnIdentity) +export const noteIdentity = { provider: 'orca', clientMessageId: 'stop:turn-stop-test' } as const +export const noteId = agentJournalItemKey(noteIdentity) +export const scope = { kind: 'turn', turnItemId } as const +export const ordinary = { kind: 'status', text: 'Cancellation requested.' } +export const unconfirmed: AgentJournalItemBody = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) +} + +export function createReleasedStopNoteRig(port: ScenarioPort, ref: string) { + let released: AgentSessionWireBuild + let client: AgentSessionClientProjection + let root: string + let host: StructuredAgentSessionHost + let journal: AgentSessionJournal + beforeAll(async () => { + released = await loadAgentSessionWireBuild(ref) + client = await released.clientProjection() + }, 180_000) + beforeEach(async () => { + resetOperationIds() + root = await mkdtemp(join(tmpdir(), 'orca-released-stop-note-')) + const store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + store, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + logger: createStructuredAgentSessionLogger(), + now: () => NOW, + adapter: { + supportsCreate: () => true, + acquire: async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ state: 'unknown', reason: 'unused' }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => {}, + setOption: async () => {} + } + }) + setStructuredAgentSessionHost(host) + expect(await host.attach({ callerKey: 'released-client' }, attachParams(null))).toMatchObject({ + ok: true + }) + journal = host['sessions'].get(SESSION)!.journal + }) + afterEach(async () => { + setStructuredAgentSessionHost(null) + await host.flushAllStreamedEvents() + await journal.close() + await rm(root, { recursive: true, force: true }) + }) + function call(method: string, params: unknown) { + return port.callBuild( + port.build(), + method, + params, + { + clientKind: 'runtime', + clientCapabilities: released.capabilities, + clientId: 'released-client' + }, + port.runtimeStub() + ) + } + async function page(params: Record): Promise { + const reply = (await call('agentSession.history', { sessionId: SESSION, ...params }))[0] + expect(reply?.ok).toBe(true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: real host history RPC answer; every item is admitted with the released client's schema below. + const result = reply?.result as AgentSessionHistoryResult + if (!result.ok) { + throw new Error(result.reset) + } + return { + ...result.page, + items: result.page.items.map((item) => client.AgentJournalRenderItemSchema.parse(item)) + } + } + async function subscribe(cursor = journal.cursor()): Promise { + return call('agentSession.subscribe', { sessionId: SESSION, cursor }) + } + function events(replies: RpcReply[]): AgentSessionSubscribeEvent[] { + return replies.map((reply) => { + expect(reply.ok).toBe(true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: real streaming RPC reply; release admission validates each item before reduction. + const event = reply.result as AgentSessionSubscribeEvent + return event.type === 'batch' + ? { + ...event, + batch: { + ...event.batch, + items: event.batch.items.map((item) => + client.AgentJournalRenderItemSchema.parse(item) + ) + } + } + : event + }) + } + function reduce(state: StructuredAgentSessionState, replies: RpcReply[]) { + return events(replies).reduce( + (state, event) => client.reduceStructuredAgentSession(state, { type: 'event', event }), + state + ) + } + function render(state: StructuredAgentSessionState) { + const item = state.items.find((item) => item.itemId === noteId)! + return client.projectStructuredItemsToNativeChat([item])[0]?.blocks[0] + } + async function turn(state: AgentJournalTurnLifecycle['state'], batch = false) { + const body = { kind: 'turn' as const, turnId: 'turn-stop-test', state } + await (batch + ? journal.appendLifecycleBatch({ + settlementId: `end-${state}`, + fence: 1, + mutations: [ + { kind: 'item', identity: turnIdentity, body, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ] + }) + : journal.appendItem(turnIdentity, body, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE })) + } + async function note(body: AgentJournalItemBody = unconfirmed) { + await journal.appendItem(noteIdentity, body, { fence: 1, turnScope: scope }) + } + async function seed() { + await turn('running') + await note() + const initial = await page({ direction: 'tail' }) + return client.reduceStructuredAgentSession(client.EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'history-page', + page: initial + }) + } + + return { + page, + subscribe, + events, + reduce, + render, + turn, + note, + seed, + get journal() { + return journal + }, + get client() { + return client + } + } +} diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts index da2f457ec8ad..2a8f9a2fc852 100644 --- a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts +++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts @@ -1,3 +1,6 @@ +import type * as ClientReducer from '../../../src/shared/structured-agent-session-reducer' +import type * as ClientProjection from '../../../src/shared/structured-agent-session-projection' +import type * as ClientSchemas from '../../../src/shared/agent-session-journal-schemas' import type { sendPlan } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans' import { importReleaseCheckoutModule, @@ -14,6 +17,13 @@ import { export const WORKING_TREE = 'working-tree' as const +export type AgentSessionClientProjection = Pick< + typeof ClientReducer, + 'EMPTY_STRUCTURED_AGENT_SESSION' | 'reduceStructuredAgentSession' +> & + Pick & + Pick + /** Each build owns its own copy of the module-level host slot, so a host installed * in current source is invisible to a release checkout's dispatcher. */ const STRUCTURED_HOST_REGISTRY = @@ -69,6 +79,28 @@ export type AgentSessionWireBuild = { * journal it stops after the fingerprint check: a fingerprint it derives differently refuses * as `fingerprintMismatch`, one it agrees with as `sessionNotAttached`. */ admitSend: (sent: SentMessage) => Promise + clientProjection: () => Promise +} + +async function loadClientProjection( + load: (path: string) => Promise> +): Promise { + const modules = await Promise.all([ + load('/src/shared/structured-agent-session-reducer.ts'), + load('/src/shared/structured-agent-session-projection.ts'), + load('/src/shared/agent-session-journal-schemas.ts') + ]) + const client = Object.assign({}, ...modules) + if ( + typeof client.reduceStructuredAgentSession !== 'function' || + typeof client.projectStructuredItemsToNativeChat !== 'function' || + !client.EMPTY_STRUCTURED_AGENT_SESSION || + typeof client.AgentJournalRenderItemSchema?.parse !== 'function' + ) { + throw new Error('Release does not export the structured transcript reader') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: checked release exports; skew tests exercise admission, reducer and transcript signatures against real host frames. + return client as AgentSessionClientProjection } type DispatcherModule = { @@ -143,6 +175,14 @@ async function loadWorkingTreeBuild(): Promise { capabilities: capabilityStrings(protocol as unknown as Record), protocolVersion: protocol.RUNTIME_PROTOCOL_VERSION, methodNames: registeredMethodNames(methods), + clientProjection: async () => { + const [reducer, projection, schemas] = await Promise.all([ + import('../../../src/shared/structured-agent-session-reducer'), + import('../../../src/shared/structured-agent-session-projection'), + import('../../../src/shared/agent-session-journal-schemas') + ]) + return { ...reducer, ...projection, ...schemas } + }, createDispatcher: (runtime) => new module.RpcDispatcher({ runtime, @@ -177,6 +217,8 @@ async function loadReleaseBuild(checkout: ReleaseCheckout): Promise + loadClientProjection((path) => importReleaseCheckoutModule(checkout, path)), createDispatcher: (runtime) => new module.RpcDispatcher({ runtime, From 3700e2d0924b25413ca23e35c92bcd91e588abd9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:04:03 -0700 Subject: [PATCH 173/182] test: name the raw replay case for what it covers --- .../agent-session-stop-event-downgrade.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 634f4d566603..ac18fe159869 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -58,7 +58,7 @@ const OLDER_IDENTITY: OlderJournalIdentity = { } test.each(['v1.4.219', 'v1.4.220', WRITABLE_BASELINE_REF])( - '%s raw replay preserves the unconfirmed failure in both host directions', + '%s replays current raw rows with the unconfirmed failure intact', async (ref) => { const directory = mkdtempSync(join(tmpdir(), 'orca-stop-note-raw-skew-')) const journals = createTrackedJournalOpener() From 53ae70808886a5e2e050afcecbc9f41cb350c62b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:12:40 -0700 Subject: [PATCH 174/182] refactor(native-chat): move the waiting-slot split into its own hook --- .../native-chat/NativeChatMessageList.tsx | 8 ++------ .../use-native-chat-waiting-slots.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-waiting-slots.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index c810dcefcce0..1044dd4a1edc 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -30,9 +30,9 @@ import type { NativeChatTranscriptRowContext } from './NativeChatTranscriptRow' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import { buildNativeChatTranscriptSlots, - splitNativeChatSlotsWaitingBehindLiveTurn, nativeChatSlotIndexOf } from './native-chat-transcript-slots' +import { useNativeChatWaitingSlots } from './use-native-chat-waiting-slots' import { useNativeChatTranscriptWindow } from './use-native-chat-transcript-window' import { nativeChatRowsInTranscriptOrder } from './native-chat-subagent-sections' import { useNativeChatSubagentSections } from './use-native-chat-subagent-sections' @@ -221,11 +221,7 @@ export function NativeChatMessageList({ turnStatuses ] ) - // A message waiting behind the live turn draws after that turn's live activity, not inside it. - const { slots, waitingSlots } = useMemo( - () => splitNativeChatSlotsWaitingBehindLiveTurn(allSlots, journalItems, stopping), - [allSlots, journalItems, stopping] - ) + const { slots, waitingSlots } = useNativeChatWaitingSlots(allSlots, journalItems, stopping) const transcriptWindow = useNativeChatTranscriptWindow({ scrollRef, slots, diff --git a/src/renderer/src/components/native-chat/use-native-chat-waiting-slots.ts b/src/renderer/src/components/native-chat/use-native-chat-waiting-slots.ts new file mode 100644 index 000000000000..8c588cd143a9 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-waiting-slots.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { + splitNativeChatSlotsWaitingBehindLiveTurn, + type NativeChatTranscriptSlot +} from './native-chat-transcript-slots' + +/** A message waiting behind the live turn draws after that turn's live activity, not inside it. */ +export function useNativeChatWaitingSlots( + allSlots: readonly NativeChatTranscriptSlot[], + journalItems: readonly AgentJournalRenderItem[] | undefined, + stopping: boolean +): { slots: NativeChatTranscriptSlot[]; waitingSlots: NativeChatTranscriptSlot[] } { + return useMemo( + () => splitNativeChatSlotsWaitingBehindLiveTurn(allSlots, journalItems, stopping), + [allSlots, journalItems, stopping] + ) +} From 970c6a10eb5751b0fbb596ec9acd07b63ca790bd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:12:07 -0700 Subject: [PATCH 175/182] test: follow main's chat font-size rename in the stopping tests --- .../NativeChatMessageList.stopping-turn-bar.test.tsx | 3 --- .../native-chat/NativeChatStructuredSession.stopping.test.tsx | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx index ec7e00acd2e3..65e7f5303965 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.stopping-turn-bar.test.tsx @@ -56,7 +56,6 @@ describe("the turn bar while a person's Stop ends the turn", () => { isWorking stopping expandSignal={false} - fontScale={1} /> ) @@ -87,7 +86,6 @@ describe('a message sent while Stopping, before the host has handed it over', () isWorking stopping={stopping} expandSignal={false} - fontScale={1} /> ) } @@ -118,7 +116,6 @@ describe('a message sent while Stopping, before the host has handed it over', () isWorking stopping expandSignal={false} - fontScale={1} /> ) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx index 12daaa98afba..7d5437b2e672 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx @@ -22,7 +22,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ } })) vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) From 082ffca5446ca9b808773bf891aa218bb37f5054 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:46:45 -0700 Subject: [PATCH 176/182] test: follow main's single live-line value in the Stopping tests --- .../MobileNativeChatView.turn-status.test.ts | 4 +++- .../NativeChatTurnActivityLine.test.tsx | 12 +++++++++--- src/shared/native-chat-live-line.test.ts | 19 ++++++++++++++++++- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/mobile/src/session/MobileNativeChatView.turn-status.test.ts b/mobile/src/session/MobileNativeChatView.turn-status.test.ts index f9fff0502544..a16968fb3277 100644 --- a/mobile/src/session/MobileNativeChatView.turn-status.test.ts +++ b/mobile/src/session/MobileNativeChatView.turn-status.test.ts @@ -182,7 +182,9 @@ describe('MobileNativeChatView', () => { function footerProps(): Record | null { const list = renderer!.root.find((node) => String(node.type) === 'FlatList') const line = list.props.ListFooterComponent?.props.line - return line ? { thinking: line.thinking, activityText: line.activityText } : null + return line + ? { thinking: line.thinking, activityText: line.activityText, stopping: line.stopping } + : null } function workingIndicators(): ReactTestInstance[] { diff --git a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx index 64c14f121483..9c0b2657cf8e 100644 --- a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.test.tsx @@ -2,17 +2,23 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' +import type { NativeChatLiveLine } from '../../../../shared/native-chat-live-line' import { NativeChatTurnActivityLine } from './NativeChatTurnActivityLine' afterEach(() => cleanup()) describe('NativeChatTurnActivityLine', () => { it("reads Stopping over the provider's activity while a person's Stop ends the turn", () => { - const activity = { kind: 'description', text: 'Running pnpm test' } as const - const { rerender } = render() + const line: NativeChatLiveLine = { + thinking: false, + stopping: false, + activityText: 'Running pnpm test', + reasoning: null + } + const { rerender } = render() expect(screen.getByText('Running pnpm test')).toBeTruthy() - rerender() + rerender() expect(screen.getByText('Stopping…')).toBeTruthy() expect(screen.queryByText('Running pnpm test')).toBeNull() diff --git a/src/shared/native-chat-live-line.test.ts b/src/shared/native-chat-live-line.test.ts index 981b9db2f23c..cf62ba523f74 100644 --- a/src/shared/native-chat-live-line.test.ts +++ b/src/shared/native-chat-live-line.test.ts @@ -17,7 +17,12 @@ const open: NativeChatMessage = { source: 'transcript', state: 'running' } -const line = (fields: { draws?: boolean; thinking?: boolean; activityText?: string | null }) => +const line = (fields: { + draws?: boolean + thinking?: boolean + stopping?: boolean + activityText?: string | null +}) => nativeChatLiveLine({ draws: true, thinking: true, @@ -30,6 +35,7 @@ describe('the live line both clients draw', () => { it('discloses the open block while it reads "Thinking"', () => { expect(line({})).toEqual({ thinking: true, + stopping: false, activityText: null, reasoning: { message: open, markdown: 'Weighing two approaches' } }) @@ -40,6 +46,17 @@ describe('the live line both clients draw', () => { expect(line({ draws: false })).toBeNull() expect(line({ thinking: false })).toEqual({ thinking: false, + stopping: false, + activityText: null, + reasoning: null + }) + }) + + // While a Stop ends the turn the line reads "Stopping…", so the open block's own row draws it. + it('discloses no block while a Stop is ending the turn', () => { + expect(line({ stopping: true })).toEqual({ + thinking: true, + stopping: true, activityText: null, reasoning: null }) From 0291a43113a36b640392fe79138fccb9af8e113a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:48:46 -0700 Subject: [PATCH 177/182] test: give the android live-line fixtures the stopping field --- .../MobileNativeChatLiveLine.android.test.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/mobile/src/session/MobileNativeChatLiveLine.android.test.ts b/mobile/src/session/MobileNativeChatLiveLine.android.test.ts index daecbe6ab622..1750c34ed58f 100644 --- a/mobile/src/session/MobileNativeChatLiveLine.android.test.ts +++ b/mobile/src/session/MobileNativeChatLiveLine.android.test.ts @@ -56,7 +56,13 @@ describe('MobileNativeChatLiveLine on Android', () => { act(() => { renderer = create( createElement(MobileNativeChatLiveLine, { - line: { thinking: true, activityText: null, reasoning: block, reasoningExpanded: true }, + line: { + thinking: true, + stopping: false, + activityText: null, + reasoning: block, + reasoningExpanded: true + }, onToggleReasoning: vi.fn(), fontScale: 1 }) @@ -78,7 +84,13 @@ describe('MobileNativeChatLiveLine on Android', () => { it('keeps the sheet through the block ending, and never opens one for the next by itself', () => { const line = (reasoning: NativeChatLiveReasoning | null) => createElement(MobileNativeChatLiveLine, { - line: { thinking: true, activityText: null, reasoning, reasoningExpanded: true }, + line: { + thinking: true, + stopping: false, + activityText: null, + reasoning, + reasoningExpanded: true + }, onToggleReasoning: vi.fn(), fontScale: 1 }) @@ -103,7 +115,13 @@ describe('MobileNativeChatLiveLine on Android', () => { it('opens no sheet for the next block when the first ends with the sheet open', () => { const line = (reasoning: NativeChatLiveReasoning | null) => createElement(MobileNativeChatLiveLine, { - line: { thinking: true, activityText: null, reasoning, reasoningExpanded: true }, + line: { + thinking: true, + stopping: false, + activityText: null, + reasoning, + reasoningExpanded: true + }, onToggleReasoning: vi.fn(), fontScale: 1 }) From 0516948a2399e2b728f776e1efa99dba780118ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:57:46 -0700 Subject: [PATCH 178/182] chore: keep the session host under its line limit after the main merge --- .../agent-session-wire/structured-agent-session-host.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 7d28b7ae3fbb..1074a99731b1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -67,8 +67,7 @@ export class StructuredAgentSessionHost { this.subscribers.publish(sessionId, journal) this.conversationDelivery.afterCommit(sessionId, journal) }, - deliverSettleEdge: (sessionId, journal) => - this.conversationDelivery.afterSettleEdge(sessionId, journal), + deliverSettleEdge: (id, journal) => this.conversationDelivery.afterSettleEdge(id, journal), logger: sessionLogger.deferredStructuredAgentSessionLogger(() => this.deps.logger), onOpened: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() From ae692eaf379db4bf3ea55a20b4da6073dee73964 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:35:30 -0700 Subject: [PATCH 179/182] test: follow main's card field and live-line component in the stop-withdrawn tests --- .../mobile-native-chat-merged-snapshot-parity-hooks.test.tsx | 2 +- .../native-chat/native-chat-rail-outline-parity.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx index b7c55b7a89f2..34b09b98a323 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -61,7 +61,7 @@ vi.mock('lucide-react-native', () => ({ Square: 'Square' })) vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) -vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnActivity: 'LiveStatus' })) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) vi.mock('./MobileNativeChatComposer', () => ({ MobileNativeChatComposer: 'Composer' })) vi.mock('./MobileNativeChatQueuedMessages', () => ({ MobileNativeChatQueuedMessages: 'Queued' })) vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index 6df658461364..4d8232303e99 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -145,7 +145,7 @@ describe('conversation outline parity with the loaded rail', () => { agentJournalSubmissionKey('client-stopped') ) expect( - projectStructuredAgentSessionMessages(journal, [], [REJECTED, stopped], NO_CARDS).map( + projectStructuredAgentSessionMessages(journal, [], [REJECTED, stopped]).map( (message) => message.id ) ).toContain(agentJournalSubmissionKey('client-stopped')) From 5dce67a3cb78fd331d1563c2251767e5474795bc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:14:17 -0700 Subject: [PATCH 180/182] chore: keep the composer test and the phone chat view under their line limits after the main merge The Stop control now disables itself while Stopping, so the composer passes the flag through and its test file stays as main has it. The phone chat header's Stop moves to its own component. --- .../session/MobileNativeChatStopButton.tsx | 28 +++++++++++++++++++ mobile/src/session/MobileNativeChatView.tsx | 28 +++++++------------ .../native-chat/NativeChatComposer.test.tsx | 23 --------------- .../native-chat/NativeChatComposer.tsx | 2 +- .../NativeChatComposerActions.test.tsx | 4 +-- .../native-chat/NativeChatComposerActions.tsx | 2 +- 6 files changed, 42 insertions(+), 45 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatStopButton.tsx diff --git a/mobile/src/session/MobileNativeChatStopButton.tsx b/mobile/src/session/MobileNativeChatStopButton.tsx new file mode 100644 index 000000000000..94034e1d47df --- /dev/null +++ b/mobile/src/session/MobileNativeChatStopButton.tsx @@ -0,0 +1,28 @@ +import { Pressable, Text } from 'react-native' +import { Square } from 'lucide-react-native' +import { NATIVE_CHAT_TURN_STATUS_COPY } from '../../../src/shared/native-chat-turn-status' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-native-chat-view-styles' + +/** The chat header's Stop. `held`: this phone's own Stop request is still in flight. */ +export function MobileNativeChatStopButton({ + onStop, + held +}: { + onStop?: () => void + held: boolean +}): React.JSX.Element { + const label = held ? NATIVE_CHAT_TURN_STATUS_COPY.stopping : 'Stop' + return ( + [styles.stopButton, pressed && styles.pressed]} + onPress={onStop} + disabled={held} + hitSlop={8} + accessibilityLabel={held ? label : 'Stop the agent'} + > + + {label} + + ) +} diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 727560fae93f..020b3dcb796c 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -10,13 +10,12 @@ import { } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' -import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' +import { ArrowDown, ChevronsDownUp, ChevronsUpDown } from 'lucide-react-native' import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { - NATIVE_CHAT_TURN_STATUS_COPY, - type NativeChatLiveTurnIndicator, - type NativeChatSettledTurns +import type { + NativeChatLiveTurnIndicator, + NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import { colors } from '../theme/mobile-theme' @@ -35,6 +34,7 @@ import { useSettledMobileNativeChatInputLock } from './use-mobile-native-chat-input-lease' import { MobileNativeChatLiveLine } from './MobileNativeChatLiveLine' +import { MobileNativeChatStopButton } from './MobileNativeChatStopButton' import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { MobileNativeChatComposer } from './MobileNativeChatComposer' @@ -283,9 +283,6 @@ export function MobileNativeChatView({ // three-dot indicator. // The display status, decided once in the session hook. const stopping = turnIndicator?.stopping === true - // Only this phone's own request holds Stop: a repeat is how a stuck stop escalates. - const stopHeld = agentWorking === true && turnIndicator?.stopRequestInFlight === true - const stoppingCopy = NATIVE_CHAT_TURN_STATUS_COPY.stopping const turns = useMobileNativeChatTurnDisclosure({ messages: data, enabled: structuredActivityUi, @@ -430,16 +427,11 @@ export function MobileNativeChatView({
{canStop ? ( - [styles.stopButton, pressed && styles.pressed]} - onPress={onStop} - disabled={stopHeld} - hitSlop={8} - accessibilityLabel={stopHeld ? stoppingCopy : 'Stop the agent'} - > - - {stopHeld ? stoppingCopy : 'Stop'} - + // Only this phone's own request holds Stop: a repeat is how a stuck stop escalates. + ) : null} {sendErrorMessage ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 1c33999ca0bd..a915e70327d1 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -14,7 +14,6 @@ const mocks = vi.hoisted(() => ({ composerIsComposing: null as (() => boolean) | null, attachmentIsComposing: null as (() => boolean) | null, flushPendingAttachments: vi.fn(), - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: null until the mocked field renders and records its props. fieldProps: null as { onSend?: () => void onStop?: () => void @@ -27,8 +26,6 @@ const mocks = vi.hoisted(() => ({ sessionOptionsSnapshot?: SessionOptionDescriptor[] attachDisabled?: boolean sendButtonDisabled?: boolean - isStopping?: boolean - afterStop?: 'queue' | 'send' autocomplete?: { mode: string; items?: { kind: string; name: string }[] } } | null, modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'unknown', @@ -249,26 +246,6 @@ describe('NativeChatComposer', () => { ) }) - it("disables Stop while a person's Stop is ending the turn, and says so", () => { - const props = { - terminalTabId: 'tab-1', - paneKey: 'tab-1:leaf-1', - targetPtyId: 'pty-1', - agent: 'codex' as const, - isWorking: true, - onStop: vi.fn() - } - const { rerender } = render() - expect(mocks.fieldProps?.sendButtonDisabled).toBe(false) - - rerender() - - expect(mocks.fieldProps).toMatchObject({ sendButtonDisabled: true, isStopping: true }) - // Answered: Stop is back, and the placeholder says a message sent now runs after the stop. - rerender() - expect(mocks.fieldProps).toMatchObject({ sendButtonDisabled: false, afterStop: 'send' }) - }) - it('writes nothing from a composer hidden under a prompt card that still holds focus', () => { render( cleanup()) describe('NativeChatComposerActions', () => { - it("names the Stop control Stopping while a person's Stop is ending the turn", () => { + it("disables the Stop control and names it Stopping while a person's Stop is ending the turn", () => { const props = { attachDisabled: false, dictationDisabled: false, @@ -54,7 +54,7 @@ describe('NativeChatComposerActions', () => { const { rerender } = render() expect(screen.getByRole('button', { name: 'Stop the agent' })).toBeTruthy() - rerender() + rerender() expect(screen.getByRole('button', { name: 'Stopping…' }).hasAttribute('disabled')).toBe(true) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index 155e85938c45..67472922904a 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -89,7 +89,7 @@ export function NativeChatComposerActions({ : translate('components.native-chat.stop', 'Stop the agent') : (sendReason ?? translate('components.native-chat.composer.send', 'Send')) } - disabled={sendDisabled} + disabled={sendDisabled || (isWorking && isStopping)} onClick={handleCriticalAction} variant={isWorking ? 'secondary' : 'default'} size="icon" From 44183990d559a970d0da2b7428c6e41abf97b7fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:32:44 -0700 Subject: [PATCH 181/182] perf(native-chat): read a Stop note's fields before parsing its key on every snapshot Every snapshot projects each item through the Stop-note read, and each new snapshot rebuilds the index of Stop notes by turn. Both parsed every item's key first; they now check the row's kind and turn scope (and, for the projection, its unconfirmed-stop failure) before the parse. Every Stop note is a status row, so what each finds is unchanged. --- .../agent-session-journal/journal-stop-note-projection.ts | 3 ++- .../agent-session-wire/agent-session-journal-batch.ts | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts b/src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts index 8efdc8143104..896b279dc26f 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-note-projection.ts @@ -8,11 +8,12 @@ export function projectJournalStopNote( item: AgentJournalRenderItem, items: ReadonlyMap ): AgentJournalRenderItem { + // Field checks before the key parse: this runs for every item of every snapshot. if ( - isStructuredAgentSessionStopNote(item.itemId) && item.body.kind === 'status' && item.body.failure?.kind === 'cancelUnconfirmed' && item.turnScope?.kind === 'turn' && + isStructuredAgentSessionStopNote(item.itemId) && readAgentJournalTurn(items.get(item.turnScope.turnItemId)?.body)?.state === 'interrupted' ) { return { ...item, body: { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } } diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts index 92789fa549f8..dc63bc0aebc7 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -119,7 +119,13 @@ function stopNotesByTurn( } const notes = new Map() for (const item of items) { - if (isStructuredAgentSessionStopNote(item.itemId) && item.turnScope?.kind === 'turn') { + // Every Stop note is a status row. Not filtered on its failure: a note already projected + // without one must still re-emit when its turn row is removed. + if ( + item.body.kind === 'status' && + item.turnScope?.kind === 'turn' && + isStructuredAgentSessionStopNote(item.itemId) + ) { const turnItemId = item.turnScope.turnItemId const ids = notes.get(turnItemId) ?? [] ids.push(item.itemId) From cc6d83826cfc947f9d06b84b2c12ceb86908f0e3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:55:48 -0700 Subject: [PATCH 182/182] fix(native-chat): keep the Spanish 'Stopped before the agent started' in the one notices block; the phone parity test reads Stopping from the live line --- ...mobile-native-chat-merged-snapshot-parity-hooks.test.tsx | 6 +++--- src/renderer/src/i18n/locales/es.json | 6 ++---- src/shared/structured-agent-session-outbox-reconcile.ts | 3 +-- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx index 34b09b98a323..065df61ea190 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -133,12 +133,12 @@ function drawnFooter(node: ReactNode): unknown { if (Array.isArray(node)) { return node.map(drawnFooter) } - if (!isValidElement<{ children?: ReactNode; stopping?: boolean }>(node)) { + if (!isValidElement<{ children?: ReactNode; line?: { stopping?: boolean } }>(node)) { return node ?? null } - const element: ReactElement<{ children?: ReactNode; stopping?: boolean }> = node + const element: ReactElement<{ children?: ReactNode; line?: { stopping?: boolean } }> = node if (element.type === 'LiveStatus') { - return ['live', element.props.stopping === true] + return ['live', element.props.line?.stopping === true] } return element.type === 'ChatMessage' ? drawnRow(element) : drawnFooter(element.props.children) } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e959ef0baff3..ca079f86056d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17661,9 +17661,6 @@ "startNewChat": "Inicie un chat nuevo para continuar.", "tryAgain": "Vuelva a intentarlo." }, - "notices": { - "stoppedBeforeStart": "Detenido antes de que el agente empezara" - }, "composer": { "imageUnsupported": "Este agente no admite pegar imágenes.", "send": "Enviar", @@ -18047,7 +18044,8 @@ "details": "Detalles", "plan": "Plan", "historyRepaired": "No se pudo cargar parte del historial de este chat.", - "historyItemTooLarge": "Esta parte del chat era demasiado grande para mostrarla." + "historyItemTooLarge": "Esta parte del chat era demasiado grande para mostrarla.", + "stoppedBeforeStart": "Detenido antes de que el agente empezara" }, "providerFrame": { "byteLength": "{{value0}} bytes" diff --git a/src/shared/structured-agent-session-outbox-reconcile.ts b/src/shared/structured-agent-session-outbox-reconcile.ts index abcd34d9d67c..30357ac1d06c 100644 --- a/src/shared/structured-agent-session-outbox-reconcile.ts +++ b/src/shared/structured-agent-session-outbox-reconcile.ts @@ -22,8 +22,7 @@ export function reconcileStructuredAgentSessionOutbox( // none changed: a caller re-reading every journal batch writes nothing then. const next = entries.flatMap((entry) => { const submission = settled.get(entry.clientMessageId) - // Settled by the host: its history shows one delivered; a withdrawn one goes back to the - // composer. + // Settled by the host: its history shows one delivered, and one a Stop took back in place. if (submission?.dispatchState === 'accepted' || dispatchWasWithdrawn(submission)) { return [] }