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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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/175] 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 5f44ff7bc5798a0af75e69780dcb629a6e74a600 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:37:23 -0700 Subject: [PATCH 089/175] fix(native-chat): drop the queue-paused header and Resume button A Stop, a restart or /clear holds the queued cards. The hold stays; only the header row naming why, and its Resume button, go. A held card shows no caption, and its own Steer, or any new message, releases the queue. --- .../NativeChatQueuedMessageCard.tsx | 6 +- .../NativeChatQueuedMessageList.test.tsx | 155 +++++++----------- .../NativeChatQueuedMessageList.tsx | 63 +------ .../structured-agent-session-queued-cards.ts | 4 +- ...nt-session-queued-messages.resume.test.tsx | 125 -------------- ...tructured-agent-session-queued-messages.ts | 43 +---- src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/fr.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- 12 files changed, 78 insertions(+), 360 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 29432e643304..690c69ae3d09 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -52,8 +52,8 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null ) } case 'paused': - // A card's own hold; the queue's pause is the list's header. Markers localize, and an absent - // or unknown one (newer host) is a plain pause, never shown raw. + // A card's own hold. Markers localize; an absent or unknown one (newer host) is a plain + // pause, never shown raw. if (card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { return translate( 'components.native-chat.queuedMessages.pausedSendFailed', @@ -73,7 +73,7 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null ) case 'turn': case 'queue-paused': - // Plainly queued; a paused queue's header row carries the why. + // Plainly queued: a held queue shows no caption. return null } } diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 41fd4852a202..9d4f704fec0b 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('../../store', () => { const state = { updateSettings: mocks.updateSettings } const useAppStore = (selector: (value: typeof state) => unknown): unknown => selector(state) @@ -23,7 +24,12 @@ import { TooltipProvider } from '@/components/ui/tooltip' import type { QueuedMessageCard } from './structured-agent-session-queued-cards' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' import { queuedMessageCardSendNow } from './NativeChatQueuedMessageCard' -import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' +import { + useStructuredAgentSessionQueuedMessages, + type StructuredAgentSessionQueuedMessagesController +} from './use-structured-agent-session-queued-messages' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' function renderList(owner: StructuredAgentSessionQueuedMessagesController) { // The app root mounts the provider; tests supply the same context. @@ -44,20 +50,13 @@ function card(overrides: Partial & { messageId: string }): Qu } } -function controller( - cards: QueuedMessageCard[], - pause: { reason: string } | null = null -): StructuredAgentSessionQueuedMessagesController & { +function controller(cards: QueuedMessageCard[]): StructuredAgentSessionQueuedMessagesController & { steer: ReturnType remove: ReturnType edit: ReturnType - resume: ReturnType } { return { cards, - pause, - resume: vi.fn(async () => {}), - resuming: false, steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), @@ -234,76 +233,50 @@ describe('NativeChatQueuedMessageList', () => { ) }) - it('a paused queue shows one header row per reason above the cards, with Resume', () => { - const cases = [ - ['stopped', 'Queue paused because you interrupted'], - ['restarted', 'Queue paused because Orca restarted'], - ['cleared', 'Queue paused after you cleared the conversation'], - ['some-newer-reason', 'Queue paused'] - ] as const - for (const [reason, text] of cases) { - const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { reason }) - const view = renderList(owner) - expect(view.container.textContent).toContain(text) - // The row sits above the list, not inside it: it is not a queued message. - expect(within(screen.getByRole('list')).queryByText(text)).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Resume' })) - expect(owner.resume).toHaveBeenCalledTimes(1) - view.unmount() - } - }) - - it('no header row when the queue drains on its own, or when there are no cards', () => { - renderList(controller([card({ messageId: 'waiting' })], null)) - expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() - cleanup() - const { container } = renderList(controller([], { reason: 'stopped' })) - expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() - expect(container.textContent).toBe('') - }) - - it('no header row over cards Resume would not send: returned, held on their own, or behind', () => { - renderList( - controller( - [ - card({ messageId: 'refused', state: 'returned', hold: 'returned', returnedReason: null }), - card({ messageId: 'behind', hold: 'behind-returned', position: 2 }), - card({ messageId: 'failed', hold: 'paused', pausedReason: 'send_failed', position: 3 }) - ], - { reason: 'stopped' } + it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( + "a queue the host holds ('%s') shows no header and no Resume; each card's Steer sends it", + (reason) => { + const mutate = vi.fn(async (..._call: [string, string, Record]) => null) + const waiting: AgentSessionQueuedMessage = { + messageId: 'held', + position: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'held text' }] }, + state: 'waiting' + } + function HeldQueue(): React.JSX.Element { + const owner = useStructuredAgentSessionQueuedMessages({ + enabled: true, + queuedMessages: [waiting], + queuePause: { reason }, + submissions: [], + hasPendingPrompt: false, + composerScopeKey: undefined, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list only awaits mutate; its answer is never read. + mutate: mutate as StructuredAgentSessionMutate + }) + return + } + const { container } = render( + + + + ) + expect(container.textContent).not.toContain('Queue paused') + expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + const list = screen.getByRole('list') + // The box holds only the list: no row above the cards. + expect(list.parentElement?.children).toHaveLength(1) + const row = within(list).getByRole('listitem') + // The text is the card's only line: no caption under it. + expect(row.querySelectorAll('p')).toHaveLength(1) + fireEvent.click(within(row).getByRole('button', { name: 'Steer' })) + expect(mutate).toHaveBeenCalledWith( + 'agentSession.queuedMessageSend', + 'agentSession.queuedMessageSend', + { messageId: 'held' } ) - ) - expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() - }) - - it('Resume shows it is pending, keeps the full text for a truncated line, and hands focus back', async () => { - const focusComposer = vi.fn() - const owner = { - ...controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { reason: 'stopped' }), - resuming: true } - render( - - - - ) - const resume = screen.getByRole('button', { name: 'Resume' }) - expect(resume).toHaveProperty('disabled', true) - expect(screen.getByTitle('Queue paused because you interrupted')).toBeTruthy() - cleanup() - const idle = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { - reason: 'stopped' - }) - render( - - - - ) - const enabled = screen.getByRole('button', { name: 'Resume' }) - enabled.focus() - fireEvent.click(enabled) - await waitFor(() => expect(focusComposer).toHaveBeenCalledTimes(1)) - }) + ) it('Steer carries the ↳ icon; a card leads with the queue glyph, a failed one with the alert', () => { const { container } = renderList( @@ -326,26 +299,20 @@ describe('NativeChatQueuedMessageList', () => { expect(container.querySelectorAll('.lucide-list-end')).toHaveLength(1) }) - it('one bordered box holds the pause row and every card, rows divided, cards unbordered', () => { + it('one bordered box holds every card, rows divided, cards unbordered', () => { renderList( - controller( - [ - card({ messageId: 'waiting', hold: 'queue-paused' }), - card({ messageId: 'refused', state: 'returned', hold: 'returned', position: 2 }), - card({ messageId: 'failed', hold: 'paused', pausedReason: 'send_failed', position: 3 }) - ], - { reason: 'stopped' } - ) + controller([ + card({ messageId: 'waiting', hold: 'queue-paused' }), + card({ messageId: 'refused', state: 'returned', hold: 'returned', position: 2 }), + card({ messageId: 'failed', hold: 'paused', pausedReason: 'send_failed', position: 3 }) + ]) ) const list = screen.getByRole('list') const box = list.parentElement! expect(box.className.split(' ')).toEqual( - expect.arrayContaining(['rounded-md', 'border', 'border-border', 'bg-card', 'divide-y']) + expect.arrayContaining(['rounded-md', 'border', 'border-border', 'bg-card']) ) - // The pause row is the box's first row, outside the list; the list is its second. - expect(box.children).toHaveLength(2) - expect(box.firstElementChild?.textContent).toContain('Queue paused because you interrupted') - expect(box.lastElementChild).toBe(list) + expect(box.children).toHaveLength(1) expect(list.className.split(' ')).toContain('divide-y') const rows = within(list).getAllByRole('listitem') expect(rows).toHaveLength(3) @@ -357,7 +324,7 @@ describe('NativeChatQueuedMessageList', () => { expect(rows[2]?.textContent).toContain("Couldn't send — press Send to retry.") }) - it('without a pause the box holds only the list, one card or many', () => { + it('the box holds only the list, one card or many', () => { for (const count of [1, 4]) { const view = renderList( controller( @@ -384,9 +351,7 @@ describe('NativeChatQueuedMessageList', () => { }) it('cards keep Steer, Delete and More actions while the queue is paused', () => { - const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { - reason: 'stopped' - }) + const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })]) renderList(owner) const row = screen.getByRole('listitem') expect(within(row).getByRole('button', { name: 'Steer' })).toBeTruthy() diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 17223e0194cb..1483681ca636 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -1,6 +1,4 @@ import { useRef } from 'react' -import { Pause, Play } from 'lucide-react' -import { Button } from '@/components/ui/button' import { useAppStore } from '../../store' import { translate } from '@/i18n/i18n' import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard' @@ -23,11 +21,7 @@ export function NativeChatQueuedMessageList({ const queueRef = useRef(null) const { cards } = controller const newest = cards.at(-1) - // A pause over cards Resume would not send (returned, held on their own, or behind a returned - // one) offers nothing to press. - const pause = cards.some((card) => card.hold === 'queue-paused') ? controller.pause : null - // Only when focus was on the queue (a card, or Resume) — never pull it from wherever the user - // moved on to. + // Only when focus was on the queue — never pull it from wherever the user moved on to. const refocusAfter = (action: Promise): void => { void action.then(() => { const active = document.activeElement @@ -40,15 +34,7 @@ export function NativeChatQueuedMessageList({
{cards.length > 0 ? (
- {/* One box: the pause row, when shown, is its first row, and each card a row below it. */}
- {pause ? ( - refocusAfter(controller.resume())} - /> - ) : null}
    ) } - -/** Why the queue sends nothing on its own. An unknown reason (a newer host) is a plain pause. */ -function queuePauseText(pause: { reason: string }): string { - switch (pause.reason) { - case 'stopped': - return translate( - 'components.native-chat.queuedMessages.queuePausedStopped', - 'Queue paused because you interrupted' - ) - case 'restarted': - return translate( - 'components.native-chat.queuedMessages.queuePausedRestarted', - 'Queue paused because Orca restarted' - ) - case 'cleared': - return translate( - 'components.native-chat.queuedMessages.queuePausedCleared', - 'Queue paused after you cleared the conversation' - ) - default: - return translate('components.native-chat.queuedMessages.queuePaused', 'Queue paused') - } -} - -function NativeChatQueuePauseRow({ - pause, - resuming, - onResume -}: { - pause: { reason: string } - resuming: boolean - onResume: () => void -}): React.JSX.Element { - const text = queuePauseText(pause) - return ( -
    - -

    - {text} -

    - -
    - ) -} 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..26189f19851b 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 @@ -18,8 +18,8 @@ import { /** Why a card is not on its way right now; decides the caption under the text. */ export type QueuedMessageCardHold = | 'turn' - /** The whole queue is paused: the header row says why and offers Resume, so the card makes - * no promise about when it sends — not even after an answer, which does not drain it. */ + /** Held since a Stop, restart or /clear: no caption and no promise of when it sends — not even + * after an answer. Its Steer, or any new send, releases it. */ | 'queue-paused' | 'awaiting-answer' | 'paused' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx deleted file mode 100644 index fa4ae91b1eae..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -// @vitest-environment happy-dom - -// Resume lifts the queue's pause through its own RPC, over the same fenced write every card action -// uses: a refusal or a failure is one toast, and the Resume button is the way to try again. - -import { act, renderHook } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -type ResumeParams = { envelope: { clientOperationId: string } } - -const mocks = vi.hoisted(() => ({ - call: vi.fn<(target: unknown, method: string, params: ResumeParams) => Promise>(), - toastError: vi.fn() -})) - -vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call -})) - -import { useStructuredAgentSessionMutate } from './use-structured-agent-session-mutate' -import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' - -function renderController() { - const stateRef = { current: { fence: 1 } } - return renderHook(() => { - const { mutate } = useStructuredAgentSessionMutate({ - sessionId: 'session-1', - target: { kind: 'local' }, - stateRef - }) - return useStructuredAgentSessionQueuedMessages({ - enabled: true, - queuedMessages: [], - queuePause: { reason: 'stopped' }, - submissions: [], - hasPendingPrompt: false, - composerScopeKey: undefined, - mutate - }) - }) -} - -afterEach(() => { - vi.clearAllMocks() -}) - -describe('Resume on a paused queue', () => { - it('calls queuedMessagesResume with only an envelope, and says nothing when it lands', async () => { - mocks.call.mockResolvedValue({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { resumed: true } - }) - const { result } = renderController() - expect(result.current.pause).toEqual({ reason: 'stopped' }) - await act(() => result.current.resume()) - expect(mocks.call).toHaveBeenCalledTimes(1) - const [, method, params] = mocks.call.mock.calls[0] ?? [] - expect(method).toBe('agentSession.queuedMessagesResume') - expect(Object.keys(params ?? {})).toEqual(['envelope']) - expect(mocks.toastError).not.toHaveBeenCalled() - }) - - it('a refused or failed Resume is one toast', async () => { - mocks.call.mockResolvedValueOnce({ - ok: false, - refusal: { code: 'agent_session_conflict', message: 'The session moved on.' } - }) - mocks.call.mockRejectedValueOnce(new Error('socket closed')) - const { result } = renderController() - await act(() => result.current.resume()) - expect(mocks.toastError).toHaveBeenCalledTimes(1) - await act(() => result.current.resume()) - expect(mocks.toastError).toHaveBeenCalledTimes(2) - }) - - it('every press is its own operation: a failed Resume never pins the next one to its id', async () => { - // Resume names no target, so a replayed id would answer `{ resumed: false }` or repeat the - // same refusal instead of lifting whatever pause holds now. - mocks.call.mockRejectedValueOnce(new Error('socket closed')) - mocks.call.mockResolvedValueOnce({ - ok: false, - refusal: { code: 'agent_session_operation_unknown', message: 'Unknown operation.' } - }) - mocks.call.mockResolvedValueOnce({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { resumed: true } - }) - const { result } = renderController() - for (let press = 0; press < 3; press += 1) { - await act(() => result.current.resume()) - } - const ids = mocks.call.mock.calls.map(([, , params]) => params.envelope.clientOperationId) - expect(ids).toHaveLength(3) - expect(new Set(ids).size).toBe(3) - }) - - it('reports a Resume in flight until it settles', async () => { - const answer = Promise.withResolvers() - mocks.call.mockReturnValueOnce(answer.promise) - const { result } = renderController() - let pending: Promise = Promise.resolve() - act(() => { - pending = result.current.resume() - }) - expect(result.current.resuming).toBe(true) - await act(async () => { - answer.resolve({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { resumed: true } - }) - await pending - }) - expect(result.current.resuming).toBe(false) - }) -}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 4596fb67180f..c78267e7be34 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -4,14 +4,13 @@ // text the card already shows into the composer, locally, before deleting the // draft, so no RPC outcome can lose it. -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage, AgentSessionQueuedMessageDeleteResult, - AgentSessionQueuedMessagesResumeResult, AgentSessionQueuePause, AgentSessionSendResult } from '../../../../shared/agent-session-wire' @@ -25,13 +24,6 @@ import type { StructuredAgentSessionMutate } from './use-structured-agent-sessio export type StructuredAgentSessionQueuedMessagesController = { cards: QueuedMessageCard[] - /** Why the whole queue sends nothing on its own; null when it drains. Shown only with cards. - * A string reason: a newer host may name one this build does not know. */ - pause: { reason: string } | null - /** Lift the queue's pause; a failure is a toast, and the Resume button is the retry. */ - resume: () => Promise - /** A Resume is in flight. */ - resuming: boolean /** Send-now into the running turn; the transcript shows it at delivery position. */ steer: (messageId: string) => Promise remove: (messageId: string) => Promise @@ -58,15 +50,12 @@ export function useStructuredAgentSessionQueuedMessages(args: { mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args - const pause = args.queuePause + // Held cards carry no caption; each card's Steer, or any new send, releases them. + const queuePaused = args.queuePause !== null const cards = useMemo( - () => - projectQueuedMessageCards(queuedMessages, submissions, { - hasPendingPrompt, - queuePaused: pause !== null - }), - [hasPendingPrompt, pause, queuedMessages, submissions] + () => projectQueuedMessageCards(queuedMessages, submissions, { hasPendingPrompt, queuePaused }), + [hasPendingPrompt, queuePaused, queuedMessages, submissions] ) const cardsRef = useRef(cards) useEffect(() => { @@ -146,26 +135,6 @@ export function useStructuredAgentSessionQueuedMessages(args: { [actOnce, composerScopeKey, mutate] ) - const resumingRef = useRef(false) - const [resuming, setResuming] = useState(false) - const resume = useCallback(async (): Promise => { - if (resumingRef.current) { - return - } - resumingRef.current = true - setResuming(true) - try { - await mutate( - 'agentSession.queuedMessagesResume', - 'agentSession.queuedMessagesResume', - {} - ) - } finally { - resumingRef.current = false - setResuming(false) - } - }, [mutate]) - const steerNewest = useCallback((): boolean => { if (!enabled) { return false @@ -178,5 +147,5 @@ export function useStructuredAgentSessionQueuedMessages(args: { return true }, [enabled, steer]) - return { cards, pause, resume, resuming, steer, remove, edit, steerNewest } + return { cards, steer, remove, edit, steerNewest } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 21fcdf4beea5..509775e58baa 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17984,12 +17984,7 @@ "editAlreadySent": "Already sent — your text is still in the composer.", "withdrawnHold": "Stopped before it was sent", "pausedSendFailed": "Couldn't send — press Send to retry.", - "paused": "Paused", - "queuePausedStopped": "Queue paused because you interrupted", - "queuePausedRestarted": "Queue paused because Orca restarted", - "queuePausedCleared": "Queue paused after you cleared the conversation", - "queuePaused": "Queue paused", - "resume": "Resume" + "paused": "Paused" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c1dc27fd97d8..ab892d16f77d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15205,12 +15205,7 @@ "turnOffQueueing": "Desactivar la cola", "withdrawnHold": "Detenido antes de enviarse", "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", - "paused": "En pausa", - "queuePausedStopped": "Cola en pausa porque interrumpiste", - "queuePausedRestarted": "Cola en pausa porque Orca se reinició", - "queuePausedCleared": "Cola en pausa después de que borraste la conversación", - "queuePaused": "Cola en pausa", - "resume": "Reanudar" + "paused": "En pausa" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index cc9d56836894..5e8f910eac1c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17887,12 +17887,7 @@ "turnOffQueueing": "Désactiver la file d'attente", "withdrawnHold": "Arrêté avant l'envoi", "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", - "paused": "En pause", - "queuePausedStopped": "File d'attente en pause, car vous avez interrompu", - "queuePausedRestarted": "File d'attente en pause, car Orca a redémarré", - "queuePausedCleared": "File d'attente en pause après l'effacement de la conversation", - "queuePaused": "File d'attente en pause", - "resume": "Reprendre" + "paused": "En pause" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 485f45e6e6fd..4fd83383db22 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17823,12 +17823,7 @@ "turnOffQueueing": "キューへの追加をオフにする", "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", - "paused": "一時停止中", - "queuePausedStopped": "中断したため、キューを一時停止しました", - "queuePausedRestarted": "Orca が再起動したため、キューを一時停止しました", - "queuePausedCleared": "会話を消去したため、キューを一時停止しました", - "queuePaused": "キューは一時停止中です", - "resume": "再開" + "paused": "一時停止中" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 23f07a9d2e95..36d6900743c5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17823,12 +17823,7 @@ "turnOffQueueing": "대기열 사용 안 함", "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", - "paused": "일시 중지됨", - "queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다", - "queuePausedRestarted": "Orca가 다시 시작되어 대기열이 일시 중지되었습니다", - "queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다", - "queuePaused": "대기열이 일시 중지되었습니다", - "resume": "재개" + "paused": "일시 중지됨" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6b13b751dd66..4f83a417f81e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17788,12 +17788,7 @@ "turnOffQueueing": "关闭排队", "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", - "paused": "已暂停", - "queuePausedStopped": "因你中断了操作,队列已暂停", - "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", - "queuePausedCleared": "清除对话后,队列已暂停", - "queuePaused": "队列已暂停", - "resume": "继续" + "paused": "已暂停" } }, "tab": { From dc181bc219e8099082bf69446e4c719bcc4571f4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:39:40 -0700 Subject: [PATCH 090/175] test(native-chat): type the unknown hold reason a newer host may publish --- .../native-chat/NativeChatQueuedMessageList.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 9d4f704fec0b..0d9bda07dd5d 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -30,6 +30,7 @@ import { } from './use-structured-agent-session-queued-messages' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' +import type { AgentSessionQueuePause } from '../../../../shared/agent-session-queued-message-wire' function renderList(owner: StructuredAgentSessionQueuedMessagesController) { // The app root mounts the provider; tests supply the same context. @@ -236,6 +237,8 @@ describe('NativeChatQueuedMessageList', () => { it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( "a queue the host holds ('%s') shows no header and no Resume; each card's Steer sends it", (reason) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a newer host may publish a reason this client's type does not list. + const queuePause = { reason } as AgentSessionQueuePause const mutate = vi.fn(async (..._call: [string, string, Record]) => null) const waiting: AgentSessionQueuedMessage = { messageId: 'held', @@ -247,7 +250,7 @@ describe('NativeChatQueuedMessageList', () => { const owner = useStructuredAgentSessionQueuedMessages({ enabled: true, queuedMessages: [waiting], - queuePause: { reason }, + queuePause, submissions: [], hasPendingPrompt: false, composerScopeKey: undefined, From 289a3450582ef19a54f0bc5927acf341b90bd70c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:15:22 -0700 Subject: [PATCH 091/175] fix(native-chat): a held card offers Send, not Steer, when no turn runs Steer vs Send now follows whether a turn is running, not the card's hold, so a card held after a Stop, a restart or /clear reads Send. --- .../NativeChatQueuedMessageCard.tsx | 15 ++++-- .../NativeChatQueuedMessageList.test.tsx | 47 +++++++++++-------- .../NativeChatQueuedMessageList.tsx | 1 + ...tiveChatStructuredSession.test-harness.tsx | 1 + ...tiveChatStructuredSessionDelivery.test.tsx | 1 + ...red-agent-session-queued-messages.test.tsx | 1 + ...tructured-agent-session-queued-messages.ts | 6 ++- .../use-structured-agent-session.ts | 1 + 8 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 690c69ae3d09..f4872c08120f 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -78,15 +78,18 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null } } -/** Steer names the mid-turn jump, also while the whole queue is paused; a card held on its own - * or returned is not waiting on the turn, so its action and tooltip are plainly Send. */ -export function queuedMessageCardSendNow(card: QueuedMessageCard): { +/** Steer names the jump into a running turn; with no turn running, or for a card held on its own + * or returned, the action and tooltip are plainly Send. */ +export function queuedMessageCardSendNow( + card: QueuedMessageCard, + turnRunning: boolean +): { /** Steer's ↳, or Send's paper plane. */ steers: boolean label: string hint: string } { - if (card.hold === 'paused' || card.hold === 'returned') { + if (!turnRunning || card.hold === 'paused' || card.hold === 'returned') { return { steers: false, label: translate('components.native-chat.queuedMessages.send', 'Send'), @@ -105,6 +108,7 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): { export function NativeChatQueuedMessageCard({ card, + turnRunning, showsSteerShortcut, onSteer, onDelete, @@ -112,6 +116,7 @@ export function NativeChatQueuedMessageCard({ onTurnOffQueueing }: { card: QueuedMessageCard + turnRunning: boolean /** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */ showsSteerShortcut: boolean onSteer: () => void @@ -121,7 +126,7 @@ export function NativeChatQueuedMessageCard({ }): React.JSX.Element { const caption = queuedMessageCardCaption(card) const returned = card.state === 'returned' - const sendNow = queuedMessageCardSendNow(card) + const sendNow = queuedMessageCardSendNow(card, turnRunning) const isMac = isMacPlatform() return (
  • & { messageId: string }): Qu } } -function controller(cards: QueuedMessageCard[]): StructuredAgentSessionQueuedMessagesController & { +/** `turnRunning` defaults on: a card waiting on the turn implies one is running. */ +function controller( + cards: QueuedMessageCard[], + turnRunning = true +): StructuredAgentSessionQueuedMessagesController & { steer: ReturnType remove: ReturnType edit: ReturnType } { return { cards, + turnRunning, steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), @@ -235,7 +240,7 @@ describe('NativeChatQueuedMessageList', () => { }) it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( - "a queue the host holds ('%s') shows no header and no Resume; each card's Steer sends it", + "a queue the host holds ('%s') shows no header and no Resume; each card's Send sends it", (reason) => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a newer host may publish a reason this client's type does not list. const queuePause = { reason } as AgentSessionQueuePause @@ -253,6 +258,8 @@ describe('NativeChatQueuedMessageList', () => { queuePause, submissions: [], hasPendingPrompt: false, + // Nothing runs after a Stop, a restart or a /clear. + isWorking: false, composerScopeKey: undefined, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list only awaits mutate; its answer is never read. mutate: mutate as StructuredAgentSessionMutate @@ -272,7 +279,8 @@ describe('NativeChatQueuedMessageList', () => { const row = within(list).getByRole('listitem') // The text is the card's only line: no caption under it. expect(row.querySelectorAll('p')).toHaveLength(1) - fireEvent.click(within(row).getByRole('button', { name: 'Steer' })) + expect(within(row).queryByRole('button', { name: 'Steer' })).toBeNull() + fireEvent.click(within(row).getByRole('button', { name: 'Send' })) expect(mutate).toHaveBeenCalledWith( 'agentSession.queuedMessageSend', 'agentSession.queuedMessageSend', @@ -353,32 +361,33 @@ describe('NativeChatQueuedMessageList', () => { expect(group.children).toHaveLength(2) }) - it('cards keep Steer, Delete and More actions while the queue is paused', () => { - const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })]) + it('a held card keeps Send, Delete and More actions; Steer only while a turn runs', () => { + const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], false) renderList(owner) const row = screen.getByRole('listitem') - expect(within(row).getByRole('button', { name: 'Steer' })).toBeTruthy() + expect(within(row).queryByRole('button', { name: 'Steer' })).toBeNull() expect(within(row).getByRole('button', { name: 'Delete' })).toBeTruthy() expect(within(row).getByRole('button', { name: 'More actions' })).toBeTruthy() expect(row.querySelectorAll('p')).toHaveLength(1) - fireEvent.click(within(row).getByRole('button', { name: 'Steer' })) + const send = within(row).getByRole('button', { name: 'Send' }) + expect(send.querySelector('.lucide-send')).not.toBeNull() + fireEvent.click(send) expect(owner.steer).toHaveBeenCalledWith('waiting') + cleanup() + // A turn Orca started (mail, a restart's continuation) runs while the queue stays held. + renderList(controller([card({ messageId: 'waiting', hold: 'queue-paused' })], true)) + expect(screen.getByRole('button', { name: 'Steer' })).toBeTruthy() }) - it('only a card still waiting on the turn promises to skip the wait', () => { - for (const hold of ['turn', 'awaiting-answer', 'behind-returned'] as const) { - expect(queuedMessageCardSendNow(card({ messageId: hold, hold }))).toEqual({ - steers: true, - label: 'Steer', - hint: 'Submit without interrupting the model' - }) + it('Steer only while a turn runs, and never for a card held on its own or returned', () => { + const SEND = { steers: false, label: 'Send', hint: 'Send this message now' } + const STEER = { steers: true, label: 'Steer', hint: 'Submit without interrupting the model' } + for (const hold of ['turn', 'awaiting-answer', 'behind-returned', 'queue-paused'] as const) { + expect(queuedMessageCardSendNow(card({ messageId: hold, hold }), true)).toEqual(STEER) + expect(queuedMessageCardSendNow(card({ messageId: hold, hold }), false)).toEqual(SEND) } for (const hold of ['paused', 'returned'] as const) { - expect(queuedMessageCardSendNow(card({ messageId: hold, hold }))).toEqual({ - steers: false, - label: 'Send', - hint: 'Send this message now' - }) + expect(queuedMessageCardSendNow(card({ messageId: hold, hold }), true)).toEqual(SEND) } }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 1483681ca636..cd37aeee322d 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -46,6 +46,7 @@ export function NativeChatQueuedMessageList({ refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} 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..58035cbc742e 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -192,6 +192,7 @@ export function createStructuredSessionMocks() { stop: mocks.stop, queuedMessages: { cards: mocks.queuedCards, + turnRunning: mocks.turnId !== null, steer: mocks.queuedSteer, remove: mocks.queuedRemove, edit: mocks.queuedEdit, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 460df23ef8b3..17567d984d80 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -92,6 +92,7 @@ vi.mock('./use-structured-agent-session', async () => { cancel: vi.fn(), queuedMessages: { cards: [], + turnRunning: false, steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx index 5fdfc0a95c7a..7eb8d08b09c6 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx @@ -51,6 +51,7 @@ function createHarness( queuePause: null, submissions: [], hasPendingPrompt: false, + isWorking: false, composerScopeKey: 'composerScopeKey' in overrides ? overrides.composerScopeKey : SCOPE, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each scripted answer is the result shape of the one mutate it responds to; generic erasure cannot express that. mutate: mutate as StructuredAgentSessionMutate diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index c78267e7be34..844c4d1f20ce 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -24,6 +24,8 @@ import type { StructuredAgentSessionMutate } from './use-structured-agent-sessio export type StructuredAgentSessionQueuedMessagesController = { cards: QueuedMessageCard[] + /** A turn is running, so Send-now steers into it rather than starting one. */ + turnRunning: boolean /** Send-now into the running turn; the transcript shows it at delivery position. */ steer: (messageId: string) => Promise remove: (messageId: string) => Promise @@ -46,6 +48,8 @@ export function useStructuredAgentSessionQueuedMessages(args: { queuePause: AgentSessionQueuePause | null submissions: readonly AgentJournalSubmission[] hasPendingPrompt: boolean + /** The main agent is working: a turn is running, whoever started it. */ + isWorking: boolean composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { @@ -147,5 +151,5 @@ export function useStructuredAgentSessionQueuedMessages(args: { return true }, [enabled, steer]) - return { cards, steer, remove, edit, steerNewest } + return { cards, turnRunning: args.isWorking, steer, remove, edit, steerNewest } } 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 96ad805560d9..6ab22de81963 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 @@ -174,6 +174,7 @@ export function useStructuredAgentSession(args: { queuePause: transportState.queuePause, submissions: transportState.submissions, hasPendingPrompt: prompts.length > 0, + isWorking, composerScopeKey, mutate }) From faaefccad938af66883b7cbac57e0f6a45ce2f06 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:17:53 -0700 Subject: [PATCH 092/175] fix(native-chat): the queue sends past held cards instead of stalling behind them A card queued after a Stop (or written after a restart or /clear) sent only once the cards held before it were released; with no header to explain or release the hold, it sat silently. The next sendable card now skips held cards; a returned card still blocks what is behind it. --- .../journal-queued-messages.ts | 2 +- .../queued-message-pause.test.ts | 28 +++++++++++++------ .../queued-message-pause.ts | 14 +++++----- ...tructured-agent-session-queued-messages.ts | 5 ++-- ...ed-agent-session-queued-pause-lift.test.ts | 10 +++---- ...ured-agent-session-queued-stop-row.test.ts | 23 ++++++--------- 6 files changed, 43 insertions(+), 39 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 b8fcafc0b6c0..4a9e0a6b1db3 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 @@ -247,7 +247,7 @@ export class JournalQueuedMessages { if (input.yieldsToPause) { // 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. + // this guards any pause-relevant row written off that lane from sending a card now held. 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-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index b8e45e0ef590..22855e183596 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,31 +447,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 () => { + it("the queue's own consume takes a newer card past a held one, and refuses the held one", async () => { const journal = await open() await queueDraft(journal, 'held') await userStop(journal) await queueDraft(journal, 'newer') - await expect( + const drain = (messageId: string) => journal.appendSubmission( { - clientMessageId: 'drain-newer', + clientMessageId: `drain-${messageId}`, origin: 'host', - payloadFingerprint: 'fp-newer', - body: message('newer'), + payloadFingerprint: `fp-${messageId}`, + body: message(messageId), fence: 0, handoverRecorded: true }, { - messageId: 'newer', + messageId, expect: 'waiting', settledByOp: null, hostInstance: HOST, yieldsToPause: { hostInstance: HOST } } ) - ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) - expect(journal.queuedMessages.get('newer')?.state).toBe('waiting') + await expect(drain('held')).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) + await drain('newer') + expect(journal.queuedMessages.get('newer')?.state).toBe('dispatched') + expect(journal.queuedMessages.get('held')?.state).toBe('waiting') }) it("'cleared' holds the carried cards, not one typed after them", async () => { @@ -581,6 +583,16 @@ describe('which cards the pauses in force hold', () => { expect(nextSendableQueuedCard(pausesOver([live]), [live])).toBe(live) }) + it('the next card skips held ones, but never a returned card', () => { + const held = card('held', 3) + const after = card('after', 5) + expect(nextSendableQueuedCard(pausesOver([held, after]), [held, after])).toBe(after) + const returned = card('returned', 5, { state: 'returned' }) + const behind = card('behind', 6) + const cards = [held, returned, behind] + expect(nextSendableQueuedCard(pausesOver(cards), cards)).toBeNull() + }) + 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 }), 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..e0892bf838d6 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 @@ -148,8 +148,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. The drain skips held cards, so it sends ahead of +// them. 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 @@ -165,18 +165,18 @@ export function queuePauseHolding( 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 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. */ +/** The card the queue sends next: the oldest waiting one that nothing holds, unless a returned + * card comes first. Held cards are skipped, not waited on: a queue, not a pipeline. The drain's + * pick, its consume, and send admission all read this. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] ): T | null { for (const card of cards) { - if (card.state === 'returned' || queuePauseHolding(pauses, card)) { + if (card.state === 'returned') { return null } - if (card.state === 'waiting' && card.holdReason === null) { + if (card.state === 'waiting' && card.holdReason === null && !queuePauseHolding(pauses, card)) { return card } } 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 7d0eb8581e66..c847ee429e66 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 @@ -59,9 +59,8 @@ export function pendingPromptExists(journal: Pick ): QueuedMessageRow | 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 d5aa5e497d45..161142b161fd 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 @@ -105,17 +105,15 @@ 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 sends once it ends; the older one stays held', 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 eventually(async () => expect(await rig.handoff(typedId)).toBeDefined()) + expect(await rig.handoff(olderId)).toBeUndefined() + expect(await rig.drafts()).toEqual([{ messageId: olderId, 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-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index d1db51426c50..13a0a38a19a7 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,6 @@ // 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 +// through the real host, the cards queued before a Stop wait, a card queued after it sends past +// 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' @@ -105,7 +105,7 @@ 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 () => { + it('a card queued after the Stop sends past the cards it holds, which stay held', async () => { const working = await rig.workingSend() const held = await queuedDraft('queued before the stop') await rig.stop() @@ -113,18 +113,13 @@ 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' } - ]) - 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') + // Held cards are skipped, not waited on: the newer card goes; the held one stays. await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) + expect(await rig.handoff(held)).toBeUndefined() + expect(await rig.drafts()).toEqual([{ messageId: held, state: 'waiting' }]) + // The drain's send is the queue's own, not a person's turn: it lifts nothing. + await rig.settleAccepted(await rig.handoffId(later), 'later') + await expectHeld('stopped', held) }) it("a person's accepted turn lifts it; a host turn and a later Stop do not", async () => { 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 093/175] 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 094/175] 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 095/175] 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 096/175] 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 097/175] 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 f91c1aed6c78326f0bc6b082ca0222ba136f9e9f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:04:01 -0700 Subject: [PATCH 098/175] fix(native-chat): the queue's send of a card is the person's turn, so held cards follow it After a Stop, a card queued later sent past the held cards, but the queue recorded that send as Orca's own turn. It never ended the Stop's pause, so the held cards then waited forever with nothing on the card saying why. A queued card is always something the person wrote: only the client send RPC may now create one. The queue's send of it is therefore recorded as the person's turn, which ends the Stop's pause once the agent takes it, and the held cards then drain in order. --- .../journal-row-schema.ts | 6 ++-- .../queued-message-pause.test.ts | 2 +- .../queued-message-pause.ts | 16 +++++---- ...-session-claude-unproven-stop-send.test.ts | 3 +- ...tured-agent-session-codex-stop-row.test.ts | 3 +- ...structured-agent-session-host-mutations.ts | 6 ++-- ...ructured-agent-session-queued-gate.test.ts | 9 +++-- ...ured-agent-session-queued-messages.test.ts | 13 ++++++-- ...tructured-agent-session-queued-messages.ts | 13 ++++++-- ...ed-agent-session-queued-pause-lift.test.ts | 11 ++++--- ...ured-agent-session-queued-stop-row.test.ts | 33 +++++++++++-------- ...d-agent-session-stop-event-restart.test.ts | 2 +- ...tructured-agent-session-stop-event.test.ts | 3 +- .../structured-agent-session-queued-cards.ts | 2 +- ...tructured-agent-session-queued-messages.ts | 2 +- 15 files changed, 77 insertions(+), 47 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 9276b1a8c05f..e56108c28093 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 @@ -130,9 +130,9 @@ export type JournalSubmissionRow = JournalRowBase & { * the key and ignore it. */ queuedMessageId?: string /** Who asked for this turn: `client` for a person's send over the client send RPC (typed, or - * a queued card they sent now); `host` for Orca's own — orchestration mail, a restart - * continuation, a launch prompt, the queue's automatic drain. Absent on rows from before it - * was recorded. Older readers keep the key and ignore it. */ + * a queued card, sent now or by the queue); `host` for Orca's own — orchestration mail, a + * restart continuation, a launch prompt. Absent on rows from before it was recorded. Older + * readers keep the key and ignore it. */ origin?: JournalSubmissionOrigin } 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 22855e183596..bd926f70d29b 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 @@ -456,7 +456,7 @@ describe('which cards a pause holds', () => { journal.appendSubmission( { clientMessageId: `drain-${messageId}`, - origin: 'host', + origin: 'client', payloadFingerprint: `fp-${messageId}`, body: message(messageId), fence: 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 e0892bf838d6..e0e413ff0475 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 @@ -7,8 +7,9 @@ // 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. +// A person's turn is an accepted submission of origin `client`: their send, or a card of theirs +// sent now or by the queue. Orchestration mail, a restart continuation and a launch prompt are +// `host` and never lift it. import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' @@ -149,7 +150,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. The drain skips held cards, so it sends ahead of -// them. true instead holds every waiting card, whenever it was queued. +// them, and that send is the person's turn, so the held cards follow it. 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 @@ -166,8 +168,8 @@ export function queuePauseHolding( } /** The card the queue sends next: the oldest waiting one that nothing holds, unless a returned - * card comes first. Held cards are skipped, not waited on: a queue, not a pipeline. The drain's - * pick, its consume, and send admission all read this. */ + * card comes first. Held cards are skipped, not waited on; a returned card blocks what follows it. + * The drain's pick, its consume, and send admission all read this. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] @@ -184,8 +186,8 @@ export function nextSendableQueuedCard( } /** 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. */ + * card, which blocks everything after it until the user acts. None otherwise, so a client that + * still shows a paused header (mobile, older desktops) never offers a Resume that sends nothing. */ export function resumableQueuePause( pauses: readonly DerivedQueuePause[], cards: readonly QueueCard[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index b86e25674641..b70139b4d274 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -476,7 +476,8 @@ it('queues a follow-up as a draft by default while a message waits, and Steer re const queued = await host.send(CALLER, { envelope: envelope('agentSession.send', { body, delivery }), body, - delivery + delivery, + userSend: true }) expect(queued).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) await commitSettled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index ef254fdc765c..0d2b3144a4f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -400,7 +400,8 @@ describe('a Codex Stop whose interrupt failed', () => { }) }, body, - delivery + delivery, + userSend: true }) if (!queued.ok || !('queued' in queued.value)) { throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) 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..4b04b6a7f74f 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 @@ -56,9 +56,9 @@ export function sendStructuredAgentSessionTurn( delivery?: 'queue-if-active' /** Host-local, set only by the client-facing `agentSession.send` RPC (the * renderer's launch prompt included): recorded as the submission's `client` - * origin, whose started turn ends a Stop's or a restart's queue pause. - * Orchestration mail, a restart continuation and `agent.launch`'s host-sent - * prompt never set it. */ + * origin, whose started turn ends a Stop's or a restart's queue pause, and the + * only send that may become a queued card. Orchestration mail, a restart + * continuation and `agent.launch`'s host-sent prompt never set it. */ userSend?: true beforeRun?: () => void } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts index 26eaa8b8ab59..c5a34298a83c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts @@ -128,7 +128,8 @@ describe('replay preference', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const + delivery: 'queue-if-active' as const, + userSend: true as const } expect(await host.send(CALLER, params)).toMatchObject({ ok: true, @@ -166,7 +167,8 @@ describe('replay of a deleted card', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const + delivery: 'queue-if-active' as const, + userSend: true as const } await host.send(CALLER, params) await settleAccepted(working, 'a') @@ -209,7 +211,8 @@ describe('the hand-off link on answers', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const + delivery: 'queue-if-active' as const, + userSend: true as const } await host.send(CALLER, params) await settleAccepted(working, 'a') 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 b972c56877e4..5644860c31d9 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 @@ -79,7 +79,8 @@ describe('accept', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const + delivery: 'queue-if-active' as const, + userSend: true as const } expect(await host.send(CALLER, params)).toMatchObject({ ok: true, @@ -109,7 +110,8 @@ describe('accept', () => { clientOperationId ), body, - delivery: 'queue-if-active' + delivery: 'queue-if-active', + userSend: true }) expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) expect(await drafts()).toHaveLength(0) @@ -477,6 +479,13 @@ describe('Stop and Delete', () => { expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) + it('a host-internal send never becomes a card, even asking to queue: only a person writes cards', async () => { + await workingSend() + const mail = send('coordinator mail', 'queue-if-active', { internal: true }) + expect(await mail.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + expect(await drafts()).toHaveLength(0) + }) + it("a user send lifts nothing from a 'send_failed' hold — that card waits for its explicit Send", async () => { const working = await workingSend() const queued = await send('conversion fails once', 'queue-if-active').result 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 c847ee429e66..30bc1d55dc6f 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 @@ -193,6 +193,7 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + userSend?: true } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -200,7 +201,12 @@ export async function maybeQueueStructuredAgentSessionSend( | null > { const clientMessageId = params.envelope.clientOperationId - if (params.delivery !== 'queue-if-active' || !queuedMessageBodyIsTextOnly(params.body)) { + // Only a person's send becomes a card, so the drain's send of it is their turn too. + if ( + params.delivery !== 'queue-if-active' || + !params.userSend || + !queuedMessageBodyIsTextOnly(params.body) + ) { return null } // Asked again with no ledger answer: a send this host queued answers as its replay would — @@ -347,8 +353,9 @@ export class StructuredAgentSessionQueuedMessageDrain { await journal.appendSubmission( { clientMessageId: submissionId, - // The queue's own automatic send: it never ends a pause. - origin: 'host', + // A card is only ever a person's send (§accept): sending it is their turn, which ends a + // Stop's pause so the cards it held follow. + origin: 'client', payloadFingerprint: next.fingerprint, body: next.body, 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 161142b161fd..311cdcffdee2 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 @@ -1,5 +1,5 @@ -// A Stop pauses the whole queue, derived from the journal: it lasts until a turn -// a person asked for (a send over the client RPC, or a card they sent now) +// A Stop pauses the cards queued before it, derived from the journal: it lasts until a turn a +// person asked for (a send over the client RPC, or a card of theirs, sent now or by the queue) // starts — the provider accepts it, never merely the host — or they Resume. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -105,15 +105,16 @@ 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 sends once it ends; the older one stays held', async () => { + it('a draft typed while the stopped turn winds down sends once it ends; the older one follows once that turn starts', 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 eventually(async () => expect(await rig.handoff(typedId)).toBeDefined()) - expect(await rig.handoff(olderId)).toBeUndefined() - expect(await rig.drafts()).toEqual([{ messageId: olderId, state: 'waiting' }]) + await expectPaused(olderId) + await rig.settleAccepted(await rig.handoffId(typedId), 'typed') + await eventually(async () => expect(await rig.handoff(olderId)).toBeDefined()) }) 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 13a0a38a19a7..33ebe4cc440f 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,6 @@ // 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 past -// them, a withdrawn card comes back under it, a crash keeps it, it +// them and they follow it, 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' @@ -105,21 +105,26 @@ describe("Stop's event", () => { await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) }) - it('a card queued after the Stop sends past the cards it holds, which stay held', async () => { + it('a card queued after the Stop sends past the cards it holds, which then follow it in order', async () => { const working = await rig.workingSend() - const held = await queuedDraft('queued before the stop') + const first = await queuedDraft('queued before the stop') + const second = await queuedDraft('also 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') - // Held cards are skipped, not waited on: the newer card goes; the held one stays. + // Held cards are skipped, not waited on: the newer card goes; the held ones wait. await eventually(async () => expect(await rig.handoff(later)).toBeDefined()) - expect(await rig.handoff(held)).toBeUndefined() - expect(await rig.drafts()).toEqual([{ messageId: held, state: 'waiting' }]) - // The drain's send is the queue's own, not a person's turn: it lifts nothing. + await expectHeld('stopped', first, second) + // The card is the person's, so its send is their turn: once it starts, the Stop is over. + expect(await rig.handoff(later)).toMatchObject({ origin: 'client' }) await rig.settleAccepted(await rig.handoffId(later), 'later') - await expectHeld('stopped', held) + expect(await rig.queuePause()).toBeNull() + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + expect(await rig.handoff(second)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(first), 'first') + await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) }) it("a person's accepted turn lifts it; a host turn and a later Stop do not", async () => { @@ -161,7 +166,9 @@ describe("Stop's event", () => { ['client', 'rejected'] ]) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => expect((await rig.handoff(sentId))?.origin).toBe('host')) + // Resume's drain sends it again under a fresh id, still the person's turn. + await eventually(async () => expect(await rig.handoffId(sentId)).not.toBe(handoffId)) + expect((await rig.handoff(sentId))?.origin).toBe('client') }) it('survives a host crash: the next open marks the hand-off unknown, the pause stays, Resume sends', async () => { @@ -204,7 +211,6 @@ describe("a Stop never hides a restart's pause", () => { 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() ) @@ -212,10 +218,9 @@ describe("a Stop never hides a restart's pause", () => { rig.crashRestartHostProcess() await rig.settleAccepted(await rig.handoffId(correction), 'correction') await rig.queuePause() - expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([ - 'stopped', - 'restarted' - ]) + // That send was the person's turn, so it ended the Stop; it started before the restart, so + // the restart's pause stands. + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['restarted']) await expectHeld('restarted', typed) }) }) 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..5e66b5a1e6f9 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 @@ -299,7 +299,7 @@ describe('a restart between a Stop and its turn end', () => { scope ) expect(settled(TURN).turn).toMatchObject({ outcome: 'cancellation' }) - // A send after the Stop, the queue's drain here, opens its own turn. + // A host send after the Stop opens its own turn. const drained = rig.send('drained after the Stop', undefined, { internal: true }) await drained.result await rig.settleAccepted(drained.id, 'drained') 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 1ceff1b63a44..2a8c496e8b2a 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 @@ -277,7 +277,8 @@ 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 (input.origin === 'host' && consume?.messageId === draftId && !injected) { + // The queue's own claim is the one no operation settles. + if (consume?.settledByOp === null && consume.messageId === draftId && !injected) { injected = true await open.appendStopEvent({ reason: 'user-stop' }, input.fence) } 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 26189f19851b..797721b0e965 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 @@ -19,7 +19,7 @@ import { export type QueuedMessageCardHold = | 'turn' /** Held since a Stop, restart or /clear: no caption and no promise of when it sends — not even - * after an answer. Its Steer, or any new send, releases it. */ + * after an answer. Its own Send or Steer, or any new message, releases it. */ | 'queue-paused' | 'awaiting-answer' | 'paused' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 844c4d1f20ce..73ff809020fe 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -54,7 +54,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args - // Held cards carry no caption; each card's Steer, or any new send, releases them. + // Held cards carry no caption; each card's Send or Steer, or any new message, releases them. const queuePaused = args.queuePause !== null const cards = useMemo( 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 099/175] 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 100/175] 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 101/175] 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 102/175] 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 103/175] 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 104/175] 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 105/175] 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 106/175] 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 107/175] 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 108/175] 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 109/175] 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 110/175] 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 111/175] 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 112/175] 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 113/175] 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 114/175] 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 115/175] 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 116/175] 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 117/175] 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 118/175] 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 119/175] 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 120/175] 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 121/175] 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 94e5509a19438b66888b91beaed300ab8c31c393 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:56:02 -0700 Subject: [PATCH 122/175] fix(native-chat): queue a message sent while Stopping, where the host queues sends Lands with the queued-messages capability rollout (#21062). Where the host advertises it, a message sent while a person's Stop ends the turn becomes a queued card at once and runs when the stop lands: - the desktop asks the host to queue it whatever the queueing setting, and the phone already does; - the host admits that card without waiting behind the Stop, which holds the session's lane until the provider answers its interrupt; only the card write skips the lane, through the journal's ordered writer; - a card queued after a person's Stop runs past the cards only that Stop's pause holds; a returned card and the restart and /clear pauses still hold everything behind them; - the host marks each card its pause holds (heldByPause), and the desktop and phone label only those as paused. --- ...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, 433 insertions(+), 82 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/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/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-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 3fbb96ddaf87..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 @@ -22,8 +22,15 @@ 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, @@ -64,23 +71,67 @@ 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, 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 ) } 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/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-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/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 9c5927797570..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 @@ -88,6 +88,38 @@ 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 }), @@ -310,3 +342,88 @@ 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 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 index 397c3a422bfe..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 @@ -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: -// 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. +// 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. import { cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' @@ -28,7 +29,10 @@ vi.mock('./use-structured-agent-session-read', () => ({ }) })) -import { AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +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' @@ -51,8 +55,10 @@ beforeEach(() => { localStorage.clear() clearNativeChatDraftCacheForTests() items = [RUNNING_TURN] - // A host that does not queue sends, as every shipped host does not yet. - setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY]) + 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(() => {})) }) @@ -77,7 +83,18 @@ 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) @@ -93,6 +110,7 @@ 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 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 e139125d2efe..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 @@ -121,6 +121,8 @@ 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, @@ -128,8 +130,8 @@ export function useStructuredAgentSession(args: { stopPressed: stopPress.pressed }) === 'stopping' const queueDelivery = useMemo( - () => ({ capability: queueCapability, enabled: queueFollowUps }), - [queueCapability, queueFollowUps] + () => ({ capability: queueCapability, enabled: queueFollowUps || stopping }), + [queueCapability, queueFollowUps, stopping] ) 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 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 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 123/175] 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 4ef532ee2686a726a44e53a8b4fc21db0c7fcbb6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:05:34 -0700 Subject: [PATCH 124/175] fix(native-chat): a queued card carries its author, so the queue's send of it is that author's turn Main now lets Orca's own sends ask to queue (sendAgentTurn's 'queue' delivery), so "every card is a person's" no longer holds by refusing host sends. Each card records who wrote it (the submission's client/host vocabulary) in a new nullable column; the drain records that origin, so a person's card ends a Stop's pause and Orca's does not. /clear carries the author. Rows from before the column read as a person's. The userSend-only admission gate is removed. --- .../journal-queued-messages.ts | 3 +- .../journal-row-schema.ts | 8 +- .../journal-submission-queued-link.test.ts | 6 +- ...queued-message-bookkeeping-failure.test.ts | 6 +- .../queued-message-delivered-echo.test.ts | 3 +- .../queued-message-pause.test.ts | 4 +- .../queued-message-pause.ts | 10 +- .../queued-message-schema.ts | 4 +- .../queued-message-store.test.ts | 65 ++++++++++- .../queued-message-table.ts | 24 +++- ...structured-agent-session-host-mutations.ts | 4 +- ...d-agent-session-queued-card-author.test.ts | 105 ++++++++++++++++++ ...ured-agent-session-queued-messages.test.ts | 7 -- ...tructured-agent-session-queued-messages.ts | 16 +-- ...ructured-agent-session-queued-mutations.ts | 3 +- ...ent-session-restore-without-import.test.ts | 3 +- 16 files changed, 226 insertions(+), 45 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.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 1a787b77fe5b..57090fe632dc 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 @@ -12,7 +12,7 @@ import { } from '../../../shared/agent-session-host-authority' import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' -import type { JournalRow } from './journal-row-schema' +import type { JournalRow, JournalSubmissionOrigin } 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' @@ -108,6 +108,7 @@ export class JournalQueuedMessages { fingerprint: string hostInstance: string carriedFrom?: string + origin: JournalSubmissionOrigin }): Promise { const { sessionId } = this.deps let inserted = false 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 e56108c28093..4850b6087229 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 @@ -129,10 +129,10 @@ export type JournalSubmissionRow = JournalRowBase & { /** The queued draft this submission hands off; absent for a direct send. Older readers keep * the key and ignore it. */ queuedMessageId?: string - /** Who asked for this turn: `client` for a person's send over the client send RPC (typed, or - * a queued card, sent now or by the queue); `host` for Orca's own — orchestration mail, a - * restart continuation, a launch prompt. Absent on rows from before it was recorded. Older - * readers keep the key and ignore it. */ + /** Who asked for this turn: `client` for a person — their send over the client send RPC, Send + * on a card, or the queue's send of a card they wrote; `host` for Orca's own — orchestration + * mail, a restart continuation, a launch prompt, or the queue's send of a card one of those + * wrote. Absent on rows from before it was recorded. Older readers keep the key and ignore it. */ origin?: JournalSubmissionOrigin } diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts index fc9431646158..654e02c5c851 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -47,7 +47,8 @@ async function handOff(journal: AgentSessionJournal, draftId: string, submission messageId: draftId, body: BODY, fingerprint: 'fp', - hostInstance: 'p' + hostInstance: 'p', + origin: 'client' }) await journal.appendSubmission( { clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 }, @@ -96,7 +97,8 @@ describe('the submission names the queued draft it hands off', () => { messageId: 'draft-1', body: BODY, fingerprint: 'fp', - hostInstance: 'p' + hostInstance: 'p', + origin: 'client' }) await expect( journal.appendSubmission( diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts index 638a62bdf02a..21b674f1bd41 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -64,7 +64,8 @@ async function queueAndConsume(journal: AgentSessionJournal, messageId: string): messageId, body, fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1' + hostInstance: 'proc-1', + origin: 'client' }) await journal.appendSubmission( { @@ -172,7 +173,8 @@ describe('draft bookkeeping inside a journal append', () => { messageId: 'draft-1', body: BODY, fingerprint: 'fp-draft-1', - hostInstance: 'proc-1' + hostInstance: 'proc-1', + origin: 'client' }) expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }]) const commit = failNextCommit() 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 4ce62c6192b2..02911d773c0d 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 @@ -76,7 +76,8 @@ async function handOffAndReject( messageId: 'draft-1', body, fingerprint, - hostInstance: 'p' + hostInstance: 'p', + origin: 'client' }) await journal.appendSubmission( { 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 bd926f70d29b..ec346fbda88c 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 @@ -62,6 +62,7 @@ function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom body: message(messageId), fingerprint: `fp-${messageId}`, hostInstance: HOST, + origin: 'client', ...(carriedFrom ? { carriedFrom } : {}) }) } @@ -496,7 +497,8 @@ describe("a restart's pause", () => { messageId: 'draft-restart', body: message('written before the restart'), fingerprint: 'fp-draft-restart', - hostInstance: 'proc-0' + hostInstance: 'proc-0', + origin: 'client' }) expect(reason(journal)).toBe('restarted') await queueDraft(journal, 'draft-legacy') 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 e0e413ff0475..0481264974be 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 @@ -7,9 +7,9 @@ // 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`: their send, or a card of theirs -// sent now or by the queue. Orchestration mail, a restart continuation and a launch prompt are -// `host` and never lift it. +// A person's turn is an accepted submission of origin `client`: their send, Send on any card, or +// the queue's send of a card they wrote. Orchestration mail, a restart continuation, a launch +// prompt and the queue's send of a card one of those wrote are `host` and never lift it. import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' @@ -150,8 +150,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. The drain skips held cards, so it sends ahead of -// them, and that send is the person's turn, so the held cards follow it. true instead holds every -// waiting card, whenever it was queued. +// them; when a person wrote that card its send is their turn, so the held cards follow it. 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 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 3f9d0122fda2..d621bbf80e8e 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 @@ -12,7 +12,8 @@ const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ ['consumed_as', 'TEXT'], ['carried_from', 'TEXT'], ['queued_epoch', 'TEXT'], - ['queued_sequence', 'INTEGER'] + ['queued_sequence', 'INTEGER'], + ['origin', 'TEXT'] ] /** @@ -43,6 +44,7 @@ CREATE TABLE IF NOT EXISTS queued_messages ( carried_from TEXT, queued_epoch TEXT, queued_sequence INTEGER, + origin TEXT, PRIMARY KEY (session_id, message_id) ); `) 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 c3910e466854..5cc8f4a8ea42 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 @@ -75,15 +75,44 @@ async function open(): Promise { return journal } -async function queueDraft(journal: AgentSessionJournal, messageId: string, text = 'queued text') { +async function queueDraft( + journal: AgentSessionJournal, + messageId: string, + text = 'queued text', + origin: 'client' | 'host' = 'client' +) { return journal.queuedMessages.insert({ messageId, body: message(text), fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1' + hostInstance: 'proc-1', + origin }) } +/** A row as a build that knows only the columns it names writes it, the host closed around it. */ +function insertAsOlderBuild(messageId: string, position: number, origin?: string): void { + closeTestJournalHostDatabases() + const db = new Database(journalDatabasePath(root)) + try { + db.prepare( + `INSERT INTO queued_messages (session_id, message_id, position, body_json, fingerprint, + created_at, host_instance, state${origin === undefined ? '' : ', origin'}) + VALUES (?, ?, ?, ?, ?, ?, 'proc-0', 'waiting'${origin === undefined ? '' : ', ?'})` + ).run( + IDENTITY.sessionId, + messageId, + position, + JSON.stringify(message(messageId)), + `fp-${messageId}`, + tick(), + ...(origin === undefined ? [] : [origin]) + ) + } finally { + db.close() + } +} + async function consumeDraft( journal: AgentSessionJournal, messageId: string, @@ -155,6 +184,38 @@ describe('draft rows', () => { expect(row.position).toBe(1) }) + it("keeps who wrote a card; one from before that was recorded reads as a person's", async () => { + const first = await open() + await queueDraft(first, 'draft-host', 'mail', 'host') + await first.close() + // An older build's insert leaves the author empty; a value no build writes is not a person's. + insertAsOlderBuild('draft-older', 2) + insertAsOlderBuild('draft-unknown', 3, 'automation') + const journal = await open() + expect(journal.queuedMessages.list().map((row) => [row.messageId, row.origin])).toEqual([ + ['draft-host', 'host'], + ['draft-older', 'client'], + ['draft-unknown', 'host'] + ]) + }) + + it("heals an older build's draft table that has no author column", async () => { + const first = await open() + await queueDraft(first, 'draft-1') + await first.close() + closeTestJournalHostDatabases() + const db = new Database(journalDatabasePath(root)) + db.exec('ALTER TABLE queued_messages DROP COLUMN origin') + db.close() + const journal = await open() + expect(journal.isReadOnly).toBe(false) + await queueDraft(journal, 'draft-2', 'mail', 'host') + expect(journal.queuedMessages.list().map((row) => [row.messageId, row.origin])).toEqual([ + ['draft-1', 'client'], + ['draft-2', 'host'] + ]) + }) + it('assigns monotonic positions and lists in order', async () => { const journal = await open() await queueDraft(journal, 'draft-1') 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 a2bd9c3e05ab..1c1703a6cef1 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 @@ -14,6 +14,7 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { rejectedDraftSettlement } from './journal-dispatch-settlement' +import type { JournalSubmissionOrigin } from './journal-row-schema' import { readStoredRejectionFact } from './journal-dispatch-reducer' export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' @@ -59,10 +60,13 @@ export type QueuedMessageRow = { /** 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 + /** Who wrote the card, in the submission's vocabulary: the queue's send of it carries this. A + * row from before it was recorded reads as a person's: no Orca-internal sender queued then. */ + origin: JournalSubmissionOrigin } 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, queued_epoch, queued_sequence' + '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, origin' export function insertQueuedMessage( db: Database.Database, @@ -74,6 +78,7 @@ export function insertQueuedMessage( hostInstance: string carriedFrom?: string queuedAt: AgentJournalCursor + origin: JournalSubmissionOrigin now: number } ): QueuedMessageRow { @@ -84,7 +89,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, @@ -95,7 +100,8 @@ export function insertQueuedMessage( input.hostInstance, input.carriedFrom ?? null, input.queuedAt.epoch, - input.queuedAt.sequence + input.queuedAt.sequence, + input.origin ) return { sessionId: input.sessionId, @@ -113,7 +119,8 @@ export function insertQueuedMessage( settledByOp: null, consumedAs: null, carriedFrom: input.carriedFrom ?? null, - queuedAt: input.queuedAt + queuedAt: input.queuedAt, + origin: input.origin } } @@ -295,6 +302,7 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { carried_from: string | null queued_epoch: string | null queued_sequence: number | null + origin: string | null } let body: AgentJournalMessageItem try { @@ -333,10 +341,16 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { queuedAt: record.queued_epoch !== null && typeof record.queued_sequence === 'number' ? { epoch: record.queued_epoch, sequence: record.queued_sequence } - : null + : null, + origin: storedOrigin(record.origin) } } +/** A row from before the column was a person's send; a value no build writes is not a person's. */ +function storedOrigin(value: string | null): JournalSubmissionOrigin { + return value === null || value === 'client' ? 'client' : 'host' +} + function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { if (json === null) { 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 4b04b6a7f74f..d3a647d2858b 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 @@ -56,8 +56,8 @@ export function sendStructuredAgentSessionTurn( delivery?: 'queue-if-active' /** Host-local, set only by the client-facing `agentSession.send` RPC (the * renderer's launch prompt included): recorded as the submission's `client` - * origin, whose started turn ends a Stop's or a restart's queue pause, and the - * only send that may become a queued card. Orchestration mail, a restart + * origin, whose started turn ends a Stop's or a restart's queue pause; a card it + * queues keeps that origin for the queue's send. Orchestration mail, a restart * continuation and `agent.launch`'s host-sent prompt never set it. */ userSend?: true beforeRun?: () => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.ts new file mode 100644 index 000000000000..29f3fde2c94d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.ts @@ -0,0 +1,105 @@ +// A card records who wrote it, and the queue's send of it is that author's turn: a person's card +// ends a Stop's pause, Orca's own does not. /clear carries the author with the card. + +import { afterEach, beforeEach, expect, it } from 'vitest' +import { sendAgentTurn } from '../../runtime/orchestration/send-agent-turn' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +async function queuedDraft(text: string, options?: { internal?: true }): Promise { + const queued = await rig.send(text, 'queue-if-active', options).result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +/** Each stored card's id and who wrote it. */ +function cardOrigins(sessionId = SESSION): [string, string][] { + const journal = rig.host.collaboratorsForTests().sessions.get(sessionId)?.journal + if (!journal) { + throw new Error('no open journal') + } + return journal.queuedMessages.list().map((row) => [row.messageId, row.origin]) +} + +it("a host-authored card queues, and its drained send does not end a Stop's hold; a person's card does", async () => { + const working = await rig.workingSend() + const heldId = await queuedDraft('queued before the stop') + await rig.stop() + // Both queued while the stopped turn winds down, so the Stop holds neither. + const mail = await sendAgentTurn({ + kind: 'structured-session', + host: rig.host, + sessionId: SESSION, + callerKey: 'orchestration-1', + turn: { + body: hostTestMessage('coordinator mail'), + delivery: 'queue', + operationId: hostTestOperationId(), + expectedRuntimeFence: 1 + } + }) + if (mail.kind !== 'queued') { + throw new Error('expected the mail to queue') + } + const typedId = await queuedDraft('typed while stopping') + expect(cardOrigins()).toEqual([ + [heldId, 'client'], + [mail.clientMessageId, 'host'], + [typedId, 'client'] + ]) + await rig.settleAccepted(working, 'a') + // Orca's card goes first, as Orca's turn: the Stop still holds the older card. + await eventually(async () => + expect(await rig.handoff(mail.clientMessageId)).toMatchObject({ origin: 'host' }) + ) + await rig.settleAccepted(await rig.handoffId(mail.clientMessageId), 'b') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // The person's card is their turn: once it starts, the held card follows. + await eventually(async () => + expect(await rig.handoff(typedId)).toMatchObject({ origin: 'client' }) + ) + expect(await rig.handoff(heldId)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(typedId), 'c') + expect(await rig.queuePause()).toBeNull() + await eventually(async () => expect(await rig.handoff(heldId)).toBeDefined()) +}) + +it('a card /clear carries keeps who wrote it', async () => { + const working = await rig.workingSend() + const typedId = await queuedDraft('typed text') + const mailId = await queuedDraft('coordinator mail', { internal: true }) + await rig.stop() + await rig.settleAccepted(working, 'a') + const fields = { command: 'clear' as const } + const cleared = await rig.host.conversationCommand(CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(cardOrigins(replacementId)).toEqual([ + [typedId, 'client'], + [mailId, 'host'] + ]) +}) 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 f0114fb17d3e..3011fa194d58 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 @@ -480,13 +480,6 @@ describe('Stop and Delete', () => { expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) - it('a host-internal send never becomes a card, even asking to queue: only a person writes cards', async () => { - await workingSend() - const mail = send('coordinator mail', 'queue-if-active', { internal: true }) - expect(await mail.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) - expect(await drafts()).toHaveLength(0) - }) - it("a user send lifts nothing from a 'send_failed' hold — that card waits for its explicit Send", async () => { const working = await workingSend() const queued = await send('conversion fails once', 'queue-if-active').result 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 0587bee653ac..f477514f0ae5 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 @@ -201,12 +201,7 @@ export async function maybeQueueStructuredAgentSessionSend( | null > { const clientMessageId = params.envelope.clientOperationId - // Only a person's send becomes a card, so the drain's send of it is their turn too. - if ( - params.delivery !== 'queue-if-active' || - !params.userSend || - !queuedMessageBodyIsTextOnly(params.body) - ) { + if (params.delivery !== 'queue-if-active' || !queuedMessageBodyIsTextOnly(params.body)) { return null } // Asked again with no ledger answer: a send this host queued answers as its replay would — @@ -240,7 +235,8 @@ export async function maybeQueueStructuredAgentSessionSend( messageId: clientMessageId, body: params.body, fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance() + hostInstance: structuredAgentSessionHostInstance(), + origin: params.userSend ? 'client' : 'host' }) return { ok: true, @@ -349,9 +345,9 @@ export class StructuredAgentSessionQueuedMessageDrain { await journal.appendSubmission( { clientMessageId: submissionId, - // A card is only ever a person's send (§accept): sending it is their turn, which ends a - // Stop's pause so the cards it held follow. - origin: 'client', + // Whoever wrote the card asked for this turn: a person's ends a Stop's pause, so the cards + // it held follow; Orca's own does not. + origin: next.origin, payloadFingerprint: next.fingerprint, body: next.body, fence, 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 ee32b3648a90..d3c06d9609c4 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 @@ -117,7 +117,8 @@ export async function carryQueuedMessagesToClearReplacement( body: row.body, fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId + carriedFrom: ctx.sessionId, + origin: row.origin }) } await withdrawQueuedMessagesForOperation(ctx.journal, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 4fa2811472a4..c8fe5d9da5d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -518,7 +518,8 @@ describe('startup restore of chats still in their per-chat files', () => { messageId: 'draft-1', body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, fingerprint: 'fp-draft-1', - hostInstance: 'proc-1' + hostInstance: 'proc-1', + origin: 'client' }) expect(journal.importPending).toBe(false) From 0809787d9b24bf53b9bfa7bb5e6fdd31d94950a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:18:57 -0700 Subject: [PATCH 125/175] docs(native-chat): state why an unrecorded card author reads as a person's --- .../native-chat/agent-session-journal/queued-message-table.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 1c1703a6cef1..1eb92414855e 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 @@ -60,8 +60,8 @@ export type QueuedMessageRow = { /** 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 - /** Who wrote the card, in the submission's vocabulary: the queue's send of it carries this. A - * row from before it was recorded reads as a person's: no Orca-internal sender queued then. */ + /** Who wrote the card, in the submission's vocabulary: the queue's send of it carries this. + * Unrecorded reads as a person's: before it, no production caller queued an Orca send. */ origin: JournalSubmissionOrigin } From ccbe202b8f5b99c22fcf9b497159986ad37a2544 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:42:34 -0700 Subject: [PATCH 126/175] fix(native-chat): a restart holds only cards written before it, and an idle held queue offers Resume A restart's pause held every waiting card, including one a person typed after the restart while Orca's own continuation ran, and nothing released it except a per-card Send. It now holds only cards another host process wrote, the same way a Stop holds only cards queued before it. The composer's primary button becomes Resume (Play) while nothing is typed, no turn runs and the host holds a card Resume would send, whatever held it (Stop, restart or /clear). It calls the existing agentSession.queuedMessagesResume, guarded against a second press in flight. A card nothing holds keeps the run going between a turn's end and the queue's send of it, so its Steer no longer flips to Send for the frame in between. --- .../queued-message-pause.test.ts | 10 + .../queued-message-pause.ts | 37 ++-- ...ured-agent-session-queued-stop-row.test.ts | 21 +- .../native-chat/NativeChatComposer.tsx | 4 +- .../NativeChatComposerActions.test.tsx | 48 ++++ .../native-chat/NativeChatComposerActions.tsx | 15 +- .../native-chat/NativeChatComposerField.tsx | 18 +- .../NativeChatQueuedMessageList.test.tsx | 3 +- ...tiveChatStructuredSession.test-harness.tsx | 12 +- .../NativeChatStructuredSession.test.tsx | 11 + .../NativeChatStructuredSession.tsx | 1 + ...tiveChatStructuredSessionDelivery.test.tsx | 3 +- ...native-chat-composer-field-resume.test.tsx | 151 +++++++++++++ ...ative-chat-composer-primary-action.test.ts | 18 ++ .../native-chat-composer-primary-action.ts | 16 ++ .../native-chat/native-chat-composer-types.ts | 3 + ...nt-session-queued-messages.resume.test.tsx | 208 ++++++++++++++++++ ...tructured-agent-session-queued-messages.ts | 57 ++++- 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 +- 24 files changed, 613 insertions(+), 41 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-primary-action.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx 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 ec346fbda88c..fb96550c39ea 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 @@ -585,6 +585,16 @@ describe('which cards the pauses in force hold', () => { expect(nextSendableQueuedCard(pausesOver([live]), [live])).toBe(live) }) + it('a restart holds only the cards another process wrote, never one written since', () => { + const cards = [card('before', 1, { hostInstance: DEAD }), card('since', 2)] + expect(pausesOver(cards, 0).map((pause) => pause.reason)).toEqual(['restarted']) + expect(holding(cards, 0)).toEqual([ + ['before', 'restarted'], + ['since', null] + ]) + expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)?.messageId).toBe('since') + }) + it('the next card skips held ones, but never a returned card', () => { const held = card('held', 3) const after = card('after', 5) 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 0481264974be..86e7dc7019fb 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 @@ -6,7 +6,7 @@ // - '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. +// started since this conversation opened. It holds only those cards, never one written since. // A person's turn is an accepted submission of origin `client`: their send, Send on any card, or // the queue's send of a card they wrote. Orchestration mail, a restart continuation, a launch // prompt and the queue's send of a card one of those wrote are `host` and never lift it. @@ -23,11 +23,12 @@ export type JournalQueuePauseMarks = { resumedSequence: number } -export type DerivedQueuePause = { - reason: QueuePauseReason - /** Where a Stop's pause began: a card queued at or after it is newer. Null for the others. */ - since: AgentJournalCursor | null -} +export type DerivedQueuePause = + /** `since`: where the Stop was written; a card queued at or after it is newer. */ + | { reason: 'stopped'; since: AgentJournalCursor } + | { reason: 'cleared' } + /** `hostInstance`: this process; a card it wrote came after the restart. */ + | { reason: 'restarted'; hostInstance: string } type QueueCard = { state: string @@ -123,35 +124,37 @@ export function deriveQueuePauses(input: { 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) { - pauses.push({ reason: 'cleared', since: null }) + pauses.push({ reason: 'cleared' }) } 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. - pauses.push({ reason: 'restarted', since: null }) + pauses.push({ reason: 'restarted', hostInstance: input.hostInstance }) } return pauses } -/** 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. */ +/** Queued before the pause began: for /clear, a card it carried; for a restart, a card another + * process wrote. 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 } + if (pause.reason === 'restarted') { + return card.hostInstance !== pause.hostInstance + } 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. The drain skips held cards, so it sends ahead of -// them; when a person wrote that card its send is their turn, so the held cards follow it. true -// instead holds every waiting card, whenever it was queued. +// Product decision: a card queued AFTER a Stop or a restart is a new instruction and is not held; +// only cards queued before it, and a steer a Stop withdrew, wait. The drain skips held cards, so +// it sends ahead of them; when a person wrote that card its send is their turn, so the held cards +// follow it. 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 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 33ebe4cc440f..300f5eed3e74 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,8 @@ // 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 past // them and they follow it, 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. +// never hides a restart's pause, a restart's holds only cards written before it, and no stored +// pause is ever written. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -225,6 +226,24 @@ describe("a Stop never hides a restart's pause", () => { }) }) +describe("a restart's pause", () => { + it("holds only cards written before it: one typed during Orca's own turn sends when that turn ends, and the older card follows once it starts", async () => { + const working = await rig.workingSend() + const before = await queuedDraft('written before the restart') + await rig.restartHostProcess() + await rig.settleAccepted(working, 'a') + // Orca's own turn after the restart, such as its continuation. + const continuation = await mailTurn() + const typed = await queuedDraft('typed during the continuation') + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + await rig.settleAccepted(continuation, 'continuation') + await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) + expect(await rig.handoff(before)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(typed), 'typed') + await eventually(async () => expect(await rig.handoff(before)).toBeDefined()) + }) +}) + describe("a /clear's carried cards", () => { function clear() { const fields = { command: 'clear' as const } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d9fb9f1ec2d7..2253b5a6faad 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -67,7 +67,8 @@ const NativeChatComposerPane = forwardRef { expect(onSend).not.toHaveBeenCalled() expect(onStop).not.toHaveBeenCalled() }) + + describe('Resume of a held queue', () => { + function renderPrimary(isWorking: boolean, onResume: (() => void) | undefined) { + const callbacks = { onSend: vi.fn(), onStop: vi.fn() } + render( + + ) + return callbacks + } + + it("takes Send's place: one press resumes, never sends", () => { + const onResume = vi.fn() + const { onSend } = renderPrimary(false, onResume) + expect(screen.queryByRole('button', { name: 'Send' })).toBeNull() + const resume = screen.getByRole('button', { name: 'Resume' }) + fireEvent.click(resume, { detail: 1 }) + fireEvent.click(resume, { detail: 2 }) + expect(onResume).toHaveBeenCalledTimes(1) + expect(onSend).not.toHaveBeenCalled() + }) + + it('is Send without it, and Stop while a turn runs', () => { + renderPrimary(false, undefined) + expect(screen.getByRole('button', { name: 'Send' })).toBeTruthy() + cleanup() + const onResume = vi.fn() + const { onStop } = renderPrimary(true, onResume) + expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Stop the agent' }), { detail: 1 }) + expect(onStop).toHaveBeenCalledTimes(1) + expect(onResume).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index 3012aa28cc44..a72a20323d28 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -1,4 +1,4 @@ -import { ArrowUp, Mic, Plus, Square } from 'lucide-react' +import { ArrowUp, Mic, Play, Plus, Square } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' @@ -25,6 +25,9 @@ export type NativeChatComposerActionsProps = { onDictationHoldEnd: () => void onSend: () => void onStop?: () => void + /** Present when no turn runs, the composer is empty and the queue is held: the primary action + * is Resume, which releases the held cards. */ + onResume?: () => void sessionOptionsSurface: SessionOptionsSurface | null sessionOptionsSnapshot: SessionOptionDescriptor[] sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null @@ -47,6 +50,7 @@ export function NativeChatComposerActions({ onDictationHoldEnd, onSend, onStop, + onResume, sessionOptionsSurface, sessionOptionsSnapshot, sessionOptionsPickerRequest, @@ -61,10 +65,13 @@ export function NativeChatComposerActions({ } if (isWorking) { onStop?.() + } else if (onResume) { + onResume() } else { onSend() } } + const resumes = !isWorking && onResume !== undefined const dictationLabel = isDictating ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') : translate('components.native-chat.composer.startDictation', 'Start dictation') @@ -151,7 +158,9 @@ export function NativeChatComposerActions({ aria-label={ isWorking ? translate('components.native-chat.stop', 'Stop the agent') - : translate('components.native-chat.composer.send', 'Send') + : resumes + ? translate('components.native-chat.queuedMessages.resume', 'Resume') + : translate('components.native-chat.composer.send', 'Send') } disabled={sendDisabled} onClick={handleCriticalAction} @@ -161,6 +170,8 @@ export function NativeChatComposerActions({ > {isWorking ? ( + ) : resumes ? ( + ) : ( )} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index ccbd38afe84f..97acea292306 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -15,7 +15,11 @@ import type { SessionOptionDescriptor, SessionOptionsSurface } from '../../../../shared/native-chat-session-options' -import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' +import type { + NativeChatComposerProps, + NativeChatOptionPickerRequest +} from './native-chat-composer-types' +import { nativeChatComposerPrimaryAction } from './native-chat-composer-primary-action' import { NativeChatImageAttachmentPreview } from './NativeChatImageAttachmentPreview' import type { NativeChatComposerGoalMode } from './use-native-chat-composer-submit' import { translate } from '@/i18n/i18n' @@ -56,6 +60,7 @@ export type NativeChatComposerFieldProps = { onDictationHoldEnd: () => void onSend: () => void onStop?: () => void + queueResume?: NativeChatComposerProps['queueResume'] sessionOptionsSurface: SessionOptionsSurface | null sessionOptionsSnapshot: SessionOptionDescriptor[] contextUsage?: NativeChatContextUsageSummary | null @@ -130,6 +135,7 @@ export function NativeChatComposerField({ onDictationHoldEnd, onSend, onStop, + queueResume, sessionOptionsSurface, sessionOptionsSnapshot, contextUsage, @@ -168,6 +174,13 @@ export function NativeChatComposerField({ onImeSettled(element) } + const primaryAction = nativeChatComposerPrimaryAction({ + isWorking, + composerEmpty: draft.trim() === '' && imageAttachments.length === 0, + queueHeld: queueResume !== undefined + }) + const resumesQueue = primaryAction === 'resume' ? queueResume : undefined + return (
    {/* Extra bottom padding keeps the input box off the window rim. */} @@ -284,7 +297,7 @@ export function NativeChatComposerField({ {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), - steerNewest: vi.fn(() => false) + steerNewest: vi.fn(() => false), + queueResume: undefined } } 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 58035cbc742e..329457096f79 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -87,6 +87,7 @@ export function createStructuredSessionMocks() { structuredTransport?: Record isWorking?: boolean onStop?: () => void + queueResume?: { resume: () => void; resuming: boolean } }>(), approvalCardProps: initialApprovalCardProps, questionCardProps: null as NativeChatQuestionCardProps | null, @@ -118,7 +119,9 @@ export function createStructuredSessionMocks() { queuedSteer: vi.fn<(messageId: string) => Promise>(async () => {}), queuedRemove: vi.fn<(messageId: string) => Promise>(async () => {}), queuedEdit: vi.fn<(messageId: string) => Promise>(async () => {}), - queuedSteerNewest: vi.fn<() => boolean>(() => false) + queuedSteerNewest: vi.fn<() => boolean>(() => false), + queuedResumable: false, + queuedResume: vi.fn<() => Promise>(async () => {}) } const moduleFactories = { @@ -196,7 +199,10 @@ export function createStructuredSessionMocks() { steer: mocks.queuedSteer, remove: mocks.queuedRemove, edit: mocks.queuedEdit, - steerNewest: mocks.queuedSteerNewest + steerNewest: mocks.queuedSteerNewest, + queueResume: mocks.queuedResumable + ? { resume: mocks.queuedResume, resuming: false } + : undefined }, threadGoal: mocks.threadGoal, cancel: mocks.cancel, @@ -333,6 +339,8 @@ export function createStructuredSessionMocks() { mocks.loadingOlder = false mocks.olderHistoryGeneration = 0 mocks.loadOlder.mockReset() + mocks.queuedResumable = false + mocks.queuedResume.mockReset() } return { mocks, moduleFactories, resetStructuredSessionMocks } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index e2c4c6ff0304..1ceeeda686d1 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -391,6 +391,17 @@ describe('NativeChatStructuredSession', () => { expect(mocks.cancel).not.toHaveBeenCalled() }) + it('hands the composer Resume only while the queue controller offers it', () => { + const { rerender } = render(claudeSessionView('structured-tab-resume', 'session-resume')) + expect(mocks.composerProps?.queueResume).toBeUndefined() + mocks.queuedResumable = true + rerender(claudeSessionView('structured-tab-resume', 'session-resume')) + act(() => { + mocks.composerProps?.queueResume?.resume() + }) + expect(mocks.queuedResume).toHaveBeenCalledOnce() + }) + it('keeps the strip mounted through a running turn, with the turn owning the voice', () => { // The strip stands for work that OUTLIVES a turn, so `show` is true while // `isMonitoring` is false: mounted, but not speaking as the live indicator. diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index eca921ed086c..20aaf1aadf55 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -396,6 +396,7 @@ export function NativeChatStructuredSession( isWorking={controller.canStop} onStop={() => void controller.stop()} steerQueued={controller.queuedMessages.steerNewest} + queueResume={controller.queuedMessages.queueResume} structuredTransport={structuredTransport} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} /> diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 17567d984d80..cb4e3ee4cab7 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -96,7 +96,8 @@ vi.mock('./use-structured-agent-session', async () => { steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), - steerNewest: () => false + steerNewest: () => false, + queueResume: undefined }, stopBackgroundTask: (taskId?: string) => mocks.stopBackgroundTask(props.sessionId, taskId), respond: mocks.respond, diff --git a/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx new file mode 100644 index 000000000000..a490e16bcd97 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom + +// A held queue's Resume is the composer's primary button exactly while nothing is typed or +// attached, no turn runs, and the queue controller offers it; Send otherwise, Stop while running. + +import { useRef, type ReactNode } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + variant: _variant, + size: _size, + ...props + }: { + children: ReactNode + variant?: string + size?: string + } & React.ButtonHTMLAttributes) => +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) =>
    {children}
    +})) + +vi.mock('./NativeChatSessionOptionPickers', () => ({ + NativeChatSessionOptionPickers: () => null +})) + +vi.mock('./NativeChatAutocompleteMenus', () => ({ + NativeChatMentionHint: () => null, + NativeChatPickerMenu: () => null +})) + +vi.mock('@/components/editor/useLocalImageSrc', () => ({ + useLocalImageSrc: () => undefined +})) + +import { NativeChatComposerField } from './NativeChatComposerField' +import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' + +afterEach(() => cleanup()) + +type FieldInput = { + draft?: string + imageAttachments?: { id: string; path: string }[] + isWorking?: boolean + queueResume?: { resume: () => void; resuming: boolean } +} + +const NO_IMAGES: { id: string; path: string }[] = [] + +function TestField({ + draft = '', + imageAttachments = NO_IMAGES, + isWorking = false, + queueResume, + onSend +}: FieldInput & { onSend: () => void }): React.JSX.Element { + const imeEnterGesture = useImeEnterGestureOwnership() + const textareaRef = useRef(null) + return ( + + ) +} + +/** The primary button's accessible name: the last button in the actions row. */ +function primaryButton(input: FieldInput, onSend = vi.fn()): HTMLButtonElement { + render() + const buttons = screen.getAllByRole('button') + const last = buttons.at(-1) + if (!(last instanceof HTMLButtonElement)) { + throw new Error('expected the primary button') + } + return last +} + +describe('the composer primary button over a held queue', () => { + const held = () => ({ resume: vi.fn(), resuming: false }) + + it('is Resume on an empty composer with no turn running; one press resumes, never sends', () => { + const queueResume = held() + const onSend = vi.fn() + const button = primaryButton({ queueResume }, onSend) + expect(button.getAttribute('aria-label')).toBe('Resume') + expect(button.disabled).toBe(false) + fireEvent.click(button, { detail: 1 }) + expect(queueResume.resume).toHaveBeenCalledTimes(1) + expect(onSend).not.toHaveBeenCalled() + }) + + it.each([ + ['text is typed', { draft: 'hello' }, 'Send'], + ['an image is attached', { imageAttachments: [{ id: 'image-1', path: '/tmp/a.png' }] }, 'Send'], + ['a turn runs', { isWorking: true }, 'Stop the agent'] + ])('gives way when %s', (_case, input, label) => { + expect(primaryButton({ ...input, queueResume: held() }).getAttribute('aria-label')).toBe(label) + }) + + it('is Send when nothing is held, and disabled while a Resume is in flight', () => { + expect(primaryButton({}).getAttribute('aria-label')).toBe('Send') + cleanup() + const resuming = primaryButton({ queueResume: { resume: vi.fn(), resuming: true } }) + expect(resuming.getAttribute('aria-label')).toBe('Resume') + expect(resuming.disabled).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-primary-action.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-primary-action.test.ts new file mode 100644 index 000000000000..e9f33e93a672 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-primary-action.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { nativeChatComposerPrimaryAction } from './native-chat-composer-primary-action' + +describe('the composer primary action', () => { + it.each([ + { isWorking: false, composerEmpty: true, queueHeld: true, action: 'resume' }, + { isWorking: false, composerEmpty: false, queueHeld: true, action: 'send' }, + { isWorking: false, composerEmpty: true, queueHeld: false, action: 'send' }, + { isWorking: false, composerEmpty: false, queueHeld: false, action: 'send' }, + { isWorking: true, composerEmpty: true, queueHeld: true, action: 'stop' }, + { isWorking: true, composerEmpty: false, queueHeld: false, action: 'stop' } + ])( + 'working $isWorking, empty $composerEmpty, held $queueHeld: $action', + ({ action, ...input }) => { + expect(nativeChatComposerPrimaryAction(input)).toBe(action) + } + ) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts b/src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts new file mode 100644 index 000000000000..3020e8b5f924 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts @@ -0,0 +1,16 @@ +// The composer's one primary button. A held queue's Resume takes Send's place only while nothing +// is typed or attached, so a typed message always offers Send. + +export type NativeChatComposerPrimaryAction = 'stop' | 'resume' | 'send' + +export function nativeChatComposerPrimaryAction(input: { + isWorking: boolean + composerEmpty: boolean + /** The host holds a queued card that Resume would send. */ + queueHeld: boolean +}): NativeChatComposerPrimaryAction { + if (input.isWorking) { + return 'stop' + } + return input.composerEmpty && input.queueHeld ? 'resume' : 'send' +} 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..736b7baccb2a 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 @@ -78,6 +78,9 @@ export type NativeChatComposerProps = { /** Cmd/Ctrl+Enter from an empty composer: send the newest queued draft now. * False = nothing queued, and the chord falls through to a plain send. */ steerQueued?: () => boolean + /** Present while the host holds the queue and no turn runs: an empty composer's primary + * action becomes Resume, which releases it. */ + queueResume?: { resume: () => void; resuming: boolean } | undefined } /** Launch context prefilled into the TUI input as an unsent draft, plus the two diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx new file mode 100644 index 000000000000..0f10c832763c --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -0,0 +1,208 @@ +// @vitest-environment happy-dom + +// Resume releases a held queue through its own RPC, over the same fenced write every card action +// uses: offered only while no turn runs and the host holds a card it would send; a refusal or a +// failure is one toast, and Resume stays the way to try again. A card the queue is about to send +// keeps the run going across the gap between a turn's end and that send. + +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause +} from '../../../../shared/agent-session-wire' + +type ResumeParams = { envelope: { clientOperationId: string } } + +const mocks = vi.hoisted(() => ({ + call: vi.fn<(target: unknown, method: string, params: ResumeParams) => Promise>(), + toastError: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import { useStructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' + +const RESUMED = { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { resumed: true } +} + +function card( + messageId: string, + fields: Partial = {} +): AgentSessionQueuedMessage { + const body = { kind: 'message' as const, role: 'user' as const, blocks: [] } + return { messageId, position: 1, body, state: 'waiting', ...fields } +} + +type ControllerInput = { + queuedMessages?: AgentSessionQueuedMessage[] + queuePause?: AgentSessionQueuePause | null + isWorking?: boolean + hasPendingPrompt?: boolean +} + +function renderController(initialProps: ControllerInput = {}) { + const stateRef = { current: { fence: 1 } } + return renderHook( + (input: ControllerInput) => { + const { mutate } = useStructuredAgentSessionMutate({ + sessionId: 'session-1', + target: { kind: 'local' }, + stateRef + }) + return useStructuredAgentSessionQueuedMessages({ + enabled: true, + queuedMessages: input.queuedMessages ?? [card('held')], + queuePause: input.queuePause === undefined ? { reason: 'stopped' } : input.queuePause, + submissions: [], + hasPendingPrompt: input.hasPendingPrompt ?? false, + isWorking: input.isWorking ?? false, + composerScopeKey: undefined, + mutate + }) + }, + { initialProps } + ) +} + +/** A press of the composer's Resume, which the controller offers only over a held queue. */ +function resume(result: { current: { queueResume: { resume: () => Promise } | undefined } }) { + const offered = result.current.queueResume + if (!offered) { + throw new Error('expected Resume to be offered') + } + return offered.resume() +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('whether Resume is offered', () => { + it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( + "over a card the host holds ('%s') with no turn running", + (reason) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a newer host may publish a reason this client's type does not list. + const queuePause = { reason } as AgentSessionQueuePause + const { result } = renderController({ queuePause }) + expect(result.current.queueResume).toBeDefined() + } + ) + + it('not while a turn runs, nor when nothing is held', () => { + expect(renderController({ isWorking: true }).result.current.queueResume).toBeUndefined() + expect(renderController({ queuePause: null }).result.current.queueResume).toBeUndefined() + expect(renderController({ queuedMessages: [] }).result.current.queueResume).toBeUndefined() + }) + + it('not over cards Resume would not send: held on their own, returned, or behind one', () => { + const queuedMessages = [ + card('failed', { paused: true, pausedReason: 'send_failed' }), + card('returned', { position: 2, state: 'returned' }), + card('behind', { position: 3 }) + ] + expect(renderController({ queuedMessages }).result.current.queueResume).toBeUndefined() + }) +}) + +describe("between a turn's end and the queue's send of its next card", () => { + it('a card nothing holds keeps the run going, so its Steer never flips to Send and back', () => { + const next = [card('next')] + const { result, rerender } = renderController({ + queuedMessages: next, + queuePause: null, + isWorking: true + }) + const running = [result.current.turnRunning] + // The turn ended; the host has not yet published the card's send. + rerender({ queuedMessages: next, queuePause: null, isWorking: false }) + running.push(result.current.turnRunning) + expect(result.current.queueResume).toBeUndefined() + rerender({ queuedMessages: [], queuePause: null, isWorking: true }) + running.push(result.current.turnRunning) + expect(running).toEqual([true, true, true]) + }) + + it('a card that waits on a hold, an answer or a returned card does not', () => { + const idle = { queuePause: null, isWorking: false } + expect(renderController({ ...idle }).result.current.turnRunning).toBe(true) + expect(renderController({ isWorking: false }).result.current.turnRunning).toBe(false) + const prompt = renderController({ ...idle, hasPendingPrompt: true }) + expect(prompt.result.current.turnRunning).toBe(false) + const returned = [card('returned', { state: 'returned' }), card('behind', { position: 2 })] + expect(renderController({ ...idle, queuedMessages: returned }).result.current.turnRunning).toBe( + false + ) + }) +}) + +describe('Resume on a held queue', () => { + it('calls queuedMessagesResume with only an envelope, and says nothing when it lands', async () => { + mocks.call.mockResolvedValue(RESUMED) + const { result } = renderController() + await act(() => resume(result)) + expect(mocks.call).toHaveBeenCalledTimes(1) + const [, method, params] = mocks.call.mock.calls[0] ?? [] + expect(method).toBe('agentSession.queuedMessagesResume') + expect(Object.keys(params ?? {})).toEqual(['envelope']) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it('a second press while one is in flight sends nothing', async () => { + const answer = Promise.withResolvers() + mocks.call.mockReturnValueOnce(answer.promise) + const { result } = renderController() + let pending: Promise = Promise.resolve() + act(() => { + pending = resume(result) + }) + expect(result.current.queueResume?.resuming).toBe(true) + await act(() => resume(result)) + expect(mocks.call).toHaveBeenCalledTimes(1) + await act(async () => { + answer.resolve(RESUMED) + await pending + }) + expect(result.current.queueResume?.resuming).toBe(false) + }) + + it('a refused or failed Resume is one toast', async () => { + mocks.call.mockResolvedValueOnce({ + ok: false, + refusal: { code: 'agent_session_conflict', message: 'The session moved on.' } + }) + mocks.call.mockRejectedValueOnce(new Error('socket closed')) + const { result } = renderController() + await act(() => resume(result)) + expect(mocks.toastError).toHaveBeenCalledTimes(1) + await act(() => resume(result)) + expect(mocks.toastError).toHaveBeenCalledTimes(2) + }) + + it('every press is its own operation: a failed Resume never pins the next one to its id', async () => { + // Resume names no target, so a replayed id would answer `{ resumed: false }` or repeat the + // same refusal instead of lifting whatever holds the queue now. + mocks.call.mockRejectedValueOnce(new Error('socket closed')) + mocks.call.mockResolvedValueOnce({ + ok: false, + refusal: { code: 'agent_session_operation_unknown', message: 'Unknown operation.' } + }) + mocks.call.mockResolvedValueOnce(RESUMED) + const { result } = renderController() + for (let press = 0; press < 3; press += 1) { + await act(() => resume(result)) + } + const ids = mocks.call.mock.calls.map(([, , params]) => params.envelope.clientOperationId) + expect(ids).toHaveLength(3) + expect(new Set(ids).size).toBe(3) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 73ff809020fe..54838ff119d6 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -1,16 +1,17 @@ // Host-held drafts as this pane acts on them: the card list, Send-now (Steer), -// Delete, Edit, and the Cmd/Ctrl+Enter steer chord. Everything durable lives on -// the host, and no draft text ever travels back over the wire: Edit copies the -// text the card already shows into the composer, locally, before deleting the -// draft, so no RPC outcome can lose it. +// Delete, Edit, the Cmd/Ctrl+Enter steer chord, and Resume of a held queue. +// Everything durable lives on the host, and no draft text ever travels back over +// the wire: Edit copies the text the card already shows into the composer, +// locally, before deleting the draft, so no RPC outcome can lose it. -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage, AgentSessionQueuedMessageDeleteResult, + AgentSessionQueuedMessagesResumeResult, AgentSessionQueuePause, AgentSessionSendResult } from '../../../../shared/agent-session-wire' @@ -24,7 +25,8 @@ import type { StructuredAgentSessionMutate } from './use-structured-agent-sessio export type StructuredAgentSessionQueuedMessagesController = { cards: QueuedMessageCard[] - /** A turn is running, so Send-now steers into it rather than starting one. */ + /** A turn is running, or the queue is about to send its next card, so Send-now steers rather + * than starting a turn. */ turnRunning: boolean /** Send-now into the running turn; the transcript shows it at delivery position. */ steer: (messageId: string) => Promise @@ -33,6 +35,15 @@ export type StructuredAgentSessionQueuedMessagesController = { edit: (messageId: string) => Promise /** Cmd/Ctrl+Enter: Send-now the newest card. False when there is none to steer. */ steerNewest: () => boolean + /** Present while no turn runs and the host holds a card Resume would send: the composer offers + * Resume. A failure is a toast, and Resume stays the retry. */ + queueResume: StructuredAgentSessionQueueResume | undefined +} + +export type StructuredAgentSessionQueueResume = { + resume: () => Promise + /** A Resume is in flight. */ + resuming: boolean } function alreadySentNotice(): void { @@ -54,7 +65,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args - // Held cards carry no caption; each card's Send or Steer, or any new message, releases them. + // Held cards carry no caption; Resume, a card's Send or Steer, or a new message releases them. const queuePaused = args.queuePause !== null const cards = useMemo( @@ -151,5 +162,35 @@ export function useStructuredAgentSessionQueuedMessages(args: { return true }, [enabled, steer]) - return { cards, turnRunning: args.isWorking, steer, remove, edit, steerNewest } + const resumingRef = useRef(false) + const [resuming, setResuming] = useState(false) + const resume = useCallback(async (): Promise => { + if (resumingRef.current) { + return + } + resumingRef.current = true + setResuming(true) + try { + await mutate( + 'agentSession.queuedMessagesResume', + 'agentSession.queuedMessagesResume', + {} + ) + } finally { + resumingRef.current = false + setResuming(false) + } + }, [mutate]) + // A turn's end and the queue's send of its next card publish as two frames; a card nothing holds + // keeps the run going across that gap, so its Steer never flips to Send and back. + const turnRunning = args.isWorking || cards.some((card) => card.hold === 'turn') + // Only over a card the published pause holds: one held on its own, returned or behind a + // returned card would not send, so Resume would do nothing. + const resumable = enabled && !turnRunning && cards.some((card) => card.hold === 'queue-paused') + const queueResume = useMemo( + () => (resumable ? { resume, resuming } : undefined), + [resumable, resume, resuming] + ) + + return { cards, turnRunning, steer, remove, edit, steerNewest, queueResume } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 641c1e6590f7..624bcd12df52 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18071,7 +18071,8 @@ "editAlreadySent": "Already sent — your text is still in the composer.", "withdrawnHold": "Stopped before it was sent", "pausedSendFailed": "Couldn't send — press Send to retry.", - "paused": "Paused" + "paused": "Paused", + "resume": "Resume" }, "structuredSessionHostDeclined": "Opened {{value0}} in a terminal", "structuredSessionHostDeclinedDescription": "This server can't run a {{value0}} chat in this workspace.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6bbfd6ca187b..1891e56aa128 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15219,7 +15219,8 @@ "turnOffQueueing": "Desactivar la cola", "withdrawnHold": "Detenido antes de enviarse", "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", - "paused": "En pausa" + "paused": "En pausa", + "resume": "Reanudar" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index f386bf2ff38e..ebf3b163b2aa 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17904,7 +17904,8 @@ "turnOffQueueing": "Désactiver la file d'attente", "withdrawnHold": "Arrêté avant l'envoi", "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", - "paused": "En pause" + "paused": "En pause", + "resume": "Reprendre" }, "structuredSessionHostDeclined": "{{value0}} ouvert dans un terminal", "structuredSessionHostDeclinedDescription": "Ce serveur ne peut pas exécuter de discussion {{value0}} dans cet espace de travail.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e386e9cb71f7..b6891dc4d608 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17841,7 +17841,8 @@ "turnOffQueueing": "キューへの追加をオフにする", "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", - "paused": "一時停止中" + "paused": "一時停止中", + "resume": "再開" }, "structuredSessionHostDeclined": "{{value0}} をターミナルで開きました", "structuredSessionHostDeclinedDescription": "このサーバーはこのワークスペースで {{value0}} チャットを実行できません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index cbfdfb036b75..3c3960fe8023 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17841,7 +17841,8 @@ "turnOffQueueing": "대기열 사용 안 함", "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", - "paused": "일시 중지됨" + "paused": "일시 중지됨", + "resume": "재개" }, "structuredSessionHostDeclined": "{{value0}}을(를) 터미널에서 열었습니다", "structuredSessionHostDeclinedDescription": "이 서버는 이 워크스페이스에서 {{value0}} 채팅을 실행할 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 938913983dcc..16bad88eba84 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17858,7 +17858,8 @@ "turnOffQueueing": "关闭排队", "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", - "paused": "已暂停" + "paused": "已暂停", + "resume": "继续" }, "structuredSessionHostDeclined": "已在终端中打开 {{value0}}", "structuredSessionHostDeclinedDescription": "此服务器无法在此工作区中运行 {{value0}} 聊天。", 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 127/175] 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 bf8207791ce19516d002fe25fc7fe5e7808ab38d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:11:21 -0700 Subject: [PATCH 128/175] fix(native-chat): the host publishes which pause holds each queued card The host published one pause for the whole queue, so a client held every waiting card while it was set. Between a turn's end and the queue's send of a card queued after a Stop or restart, the composer could flash Resume and the cards Send, and a card queued after a Stop lost its "Waiting for your answer" caption. Each published card now carries an optional `heldBy`: the pause holding it, or null, derived from the same rule the drain reads. A client holds only those cards; against a host without the field it falls back to the queue-level pause. --- config/tsconfig.node.json | 1 + ...ed-agent-session-queued-card-holds.test.ts | 146 ++++++++++++++++++ ...ctured-agent-session-queued-publication.ts | 47 ++++-- .../NativeChatQueuedMessageCard.tsx | 10 +- ...uctured-agent-session-queued-cards.test.ts | 44 +++++- .../structured-agent-session-queued-cards.ts | 31 +++- ...tructured-agent-session-queued-messages.ts | 10 +- .../agent-session-queued-message-wire.ts | 4 + 8 files changed, 265 insertions(+), 28 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 7d4d951d2d62..20c324385790 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -11,6 +11,7 @@ "../src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts", "../src/renderer/src/components/native-chat/structured-agent-question-projection.ts", "../src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts", + "../src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts", "../src/preload/**/*", "../src/shared/**/*", "../src/relay/**/*", diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts new file mode 100644 index 000000000000..41f204e6d19f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -0,0 +1,146 @@ +// The host publishes which pause holds each card, and a client reading its updates one at a time +// sees the queue's run continue across a turn's end and the queue's send of the next card: no +// card flips Steer → Send → Steer, and Resume is never offered in between. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause +} from '../../../shared/agent-session-wire' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { + projectQueuedMessageCards, + queuedMessageCardSteers, + queuedMessageQueueRun +} from '../../../renderer/src/components/native-chat/structured-agent-session-queued-cards' +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 + +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 +} + +/** Each waiting card's published hold, by id. */ +async function heldBy(): Promise> { + const page = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return Object.fromEntries( + (page.page.queuedMessages ?? []).map((card) => [card.messageId, card.heldBy]) + ) +} + +type ClientView = { resumable: boolean; steers: boolean[]; cards: number; working: boolean } + +/** Folds every published update as a client does, one at a time, into what its queue shows. */ +async function watchClient(): Promise { + const views: ClientView[] = [] + const submissions = new Map() + const turns = new Map() + let queued: AgentSessionQueuedMessage[] = [] + let queuePause: AgentSessionQueuePause | null = null + await rig.host.subscribe({ + id: 'client-view', + sessionId: HOST_TEST_SESSION, + emit: (event) => { + if (event.type === 'end') { + return + } + const rows = event.type === 'batch' ? event.batch : event.page + for (const submission of rows.submissions) { + submissions.set(submission.clientMessageId, submission) + } + for (const item of rows.items) { + if (item.body.kind === 'turn') { + turns.set(item.itemId, item.body.state) + } + } + if (event.queuedMessages !== undefined) { + queued = event.queuedMessages ?? [] + queuePause = event.queuePause ?? null + } + const running = [...turns].find(([, state]) => state === 'running')?.[0] ?? null + const all = [...submissions.values()] + const cards = projectQueuedMessageCards(queued, all, { + hasPendingPrompt: false, + queuePaused: queuePause !== null + }) + const working = isStructuredAgentSessionMainAgentWorking(running, all) + const run = queuedMessageQueueRun(cards, working) + views.push({ + resumable: run.resumable, + steers: cards.map((card) => queuedMessageCardSteers(card, run.turnRunning)), + cards: cards.length, + working + }) + } + }) + return views +} + +describe('which pause holds each card', () => { + it("a Stop's: the card queued before it, not the one typed while it lands; Resume and Send never show as the queue sends them", async () => { + const working = await rig.workingSend() + const held = await queuedDraft('held by the stop') + await rig.stop() + const typed = await queuedDraft('typed while the stop lands') + expect(await heldBy()).toEqual({ [held]: { reason: 'stopped' }, [typed]: null }) + const views = await watchClient() + await rig.settleAccepted(working, 'stopped') + await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) + await rig.settleAccepted(await rig.handoffId(typed), 'typed') + await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) + // The update between the stopped turn's end and the typed card's send is among them. + expect(views).toContainEqual({ + resumable: false, + steers: [true, true], + cards: 2, + working: false + }) + expect(views.filter((view) => view.resumable)).toEqual([]) + expect(views.flatMap((view) => view.steers)).not.toContain(false) + }) + + it("a restart's: the card from before it, not one typed during Orca's own turn since; the same holds as that turn ends", async () => { + const working = await rig.workingSend() + const before = await queuedDraft('written before the restart') + await rig.restartHostProcess() + await rig.settleAccepted(working, 'a') + const continuation = rig.send('continue where you left off', undefined, { internal: true }) + await continuation.result + await eventually(async () => + expect((await rig.submission(continuation.id))?.handedOverAt).toBeDefined() + ) + const typed = await queuedDraft('typed during the continuation') + expect(await heldBy()).toEqual({ [before]: { reason: 'restarted' }, [typed]: null }) + const views = await watchClient() + await rig.settleAccepted(continuation.id, 'continuation') + await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) + expect(views).toContainEqual({ + resumable: false, + steers: [true, true], + cards: 2, + working: false + }) + expect(views.filter((view) => view.resumable)).toEqual([]) + expect(views.flatMap((view) => view.steers)).not.toContain(false) + }) +}) 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..e8bcf84f8fa5 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 = { @@ -18,8 +22,11 @@ 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[] { + * conversion); `heldBy` names the queue-level pause holding the card, if any. */ +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') { @@ -36,6 +43,7 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess ...(held && row.holdReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED ? { pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED } : {}), + heldBy: heldBy(pauses, row), ...(row.state === 'returned' ? { returnedReason: row.returnedReason } : {}), ...(row.state === 'returned' && row.returnedRejection ? { returnedRejection: row.returnedRejection } @@ -45,21 +53,33 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess return published } +function heldBy( + pauses: readonly DerivedQueuePause[], + row: Parameters[1] +): AgentSessionQueuePause | null { + const holding = queuePauseHolding(pauses, row) + return holding ? { reason: holding.reason } : null +} + type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessage[] } /** Reference-stable per journal handle: an unchanged list is never - * re-serialized onto token-stream frames, and any draft-table write changes - * the reference by construction. */ + * re-serialized onto token-stream frames, and any draft-table write or change + * of the pauses in force changes the reference by construction. */ 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 also turn on journal rows (a Stop, a person's turn), not only on the drafts. + 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 +100,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()) + // Read per emit: the pauses also turn on submissions (a person's turn starting). + const pauses = structuredQueuePauses(journal) + const queuedMessages = readPublishedQueuedMessages(journal, pauses) + // Shown only over a card Resume would send, so a header never offers to send nothing; + // deleting a blocking returned card shows it again. + const pause = resumableQueuePause(pauses, journal.queuedMessages.list()) const queuePause = pause ? { reason: pause.reason } : null const previous = publications.get(journal) if ( diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index f4872c08120f..cc4f2f173f93 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -23,7 +23,10 @@ import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-sessi import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-session-wire' import { isMacPlatform } from './native-chat-shortcut' -import type { QueuedMessageCard } from './structured-agent-session-queued-cards' +import { + queuedMessageCardSteers, + type QueuedMessageCard +} from './structured-agent-session-queued-cards' /** The visible caption under the text; the default waiting hold needs none. */ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null { @@ -78,8 +81,7 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null } } -/** Steer names the jump into a running turn; with no turn running, or for a card held on its own - * or returned, the action and tooltip are plainly Send. */ +/** Steer's or Send's label and tooltip (`queuedMessageCardSteers`). */ export function queuedMessageCardSendNow( card: QueuedMessageCard, turnRunning: boolean @@ -89,7 +91,7 @@ export function queuedMessageCardSendNow( label: string hint: string } { - if (!turnRunning || card.hold === 'paused' || card.hold === 'returned') { + if (!queuedMessageCardSteers(card, turnRunning)) { return { steers: false, label: translate('components.native-chat.queuedMessages.send', 'Send'), 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..d9ef924ebaae 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 @@ -1,5 +1,5 @@ // Card projection policy: queue order, derived hold labels (the wire carries -// none), and the presentation-only suppression of a card whose submission +// holds, not labels), and the presentation-only suppression of a card whose submission // already arrived — a queued draft is otherwise never a transcript bubble. import { describe, expect, it } from 'vitest' @@ -9,7 +9,9 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/struc import { newestSteerableQueuedMessageCard, outboxOutsideQueuedCards, - projectQueuedMessageCards + projectQueuedMessageCards, + queuedMessageCardSteers, + queuedMessageQueueRun } from './structured-agent-session-queued-cards' function draft( @@ -159,6 +161,44 @@ describe('queued message cards', () => { ).toBe('awaiting-answer') }) + it("a host that names each card's hold holds only those cards; one that does not holds every card", () => { + const named = [ + draft('held', 1, { heldBy: { reason: 'stopped' } }), + draft('typed-after', 2, { heldBy: null }) + ] + const paused = { hasPendingPrompt: true, queuePaused: true } + // The card queued after the Stop still says it waits for the answer. + expect(projectQueuedMessageCards(named, [], paused).map((card) => card.hold)).toEqual([ + 'queue-paused', + 'awaiting-answer' + ]) + const older = [draft('held', 1), draft('typed-after', 2)] + expect(projectQueuedMessageCards(older, [], paused).map((card) => card.hold)).toEqual([ + 'queue-paused', + 'queue-paused' + ]) + }) + + it('a card nothing holds keeps the run going; Resume only over a card a pause holds', () => { + const project = (messages: AgentSessionQueuedMessage[]) => + projectQueuedMessageCards(messages, [], { hasPendingPrompt: false, queuePaused: true }) + const held = draft('held', 1, { heldBy: { reason: 'stopped' } }) + const next = draft('next', 2, { heldBy: null }) + // Between a turn's end and the queue's send of `next`: still running, and nothing to resume. + const gap = project([held, next]) + expect(queuedMessageQueueRun(gap, false)).toEqual({ turnRunning: true, resumable: false }) + expect(gap.map((card) => queuedMessageCardSteers(card, true))).toEqual([true, true]) + expect(queuedMessageQueueRun(project([held]), false)).toEqual({ + turnRunning: false, + resumable: true + }) + const failed = draft('failed', 1, { paused: true, pausedReason: 'send_failed', heldBy: null }) + expect(queuedMessageQueueRun(project([failed]), false)).toEqual({ + turnRunning: false, + resumable: false + }) + }) + it('steers the newest card', () => { const cards = projectQueuedMessageCards([draft('a', 1), draft('b', 2)], [], IDLE) expect(newestSteerableQueuedMessageCard(cards)?.messageId).toBe('b') 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 797721b0e965..4ec66daabb57 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 @@ -18,8 +18,8 @@ import { /** Why a card is not on its way right now; decides the caption under the text. */ export type QueuedMessageCardHold = | 'turn' - /** Held since a Stop, restart or /clear: no caption and no promise of when it sends — not even - * after an answer. Its own Send or Steer, or any new message, releases it. */ + /** Held by a Stop, restart or /clear: no caption and no promise of when it sends — not even + * after an answer. Resume, its own Send or Steer, or any new message releases it. */ | 'queue-paused' | 'awaiting-answer' | 'paused' @@ -63,6 +63,9 @@ export function projectQueuedMessageCards( .filter((message) => message.state === 'returned' || !handedOff.has(message.messageId)) let behindReturned = false return ordered.map((message) => { + // A host that predates `heldBy` publishes only the queue's pause, which then covers every card. + const heldByPause = + message.heldBy === undefined ? session.queuePaused === true : message.heldBy !== null const hold: QueuedMessageCardHold = message.state === 'returned' ? 'returned' @@ -70,7 +73,7 @@ export function projectQueuedMessageCards( ? 'paused' : behindReturned ? 'behind-returned' - : session.queuePaused + : heldByPause ? 'queue-paused' : session.hasPendingPrompt ? 'awaiting-answer' @@ -91,6 +94,28 @@ export function projectQueuedMessageCards( }) } +/** Whether the queue's run is going, and whether Resume would release anything. A turn's end and + * the queue's send of its next card reach the client as two updates; a card nothing holds keeps + * the run going across that gap, so no card flips Steer → Send → Steer and Resume never flashes. + * Resume needs a card a queue-level pause holds: one held on its own, returned or behind a + * returned card would not send. */ +export function queuedMessageQueueRun( + cards: readonly QueuedMessageCard[], + isWorking: boolean +): { turnRunning: boolean; resumable: boolean } { + const turnRunning = isWorking || cards.some((card) => card.hold === 'turn') + return { + turnRunning, + resumable: !turnRunning && cards.some((card) => card.hold === 'queue-paused') + } +} + +/** Steer jumps into a running turn; with none running, or for a card held on its own or + * returned, the action is plainly Send. */ +export function queuedMessageCardSteers(card: QueuedMessageCard, turnRunning: boolean): boolean { + return turnRunning && card.hold !== 'paused' && card.hold !== 'returned' +} + /** The card Cmd/Ctrl+Enter steers: the newest one; every shown card takes Send-now. */ export function newestSteerableQueuedMessageCard( cards: readonly QueuedMessageCard[] diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 54838ff119d6..111892e25a0b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -19,6 +19,7 @@ import { appendNativeChatDraftCache } from './native-chat-draft-cache' import { newestSteerableQueuedMessageCard, projectQueuedMessageCards, + queuedMessageQueueRun, type QueuedMessageCard } from './structured-agent-session-queued-cards' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' @@ -181,12 +182,9 @@ export function useStructuredAgentSessionQueuedMessages(args: { setResuming(false) } }, [mutate]) - // A turn's end and the queue's send of its next card publish as two frames; a card nothing holds - // keeps the run going across that gap, so its Steer never flips to Send and back. - const turnRunning = args.isWorking || cards.some((card) => card.hold === 'turn') - // Only over a card the published pause holds: one held on its own, returned or behind a - // returned card would not send, so Resume would do nothing. - const resumable = enabled && !turnRunning && cards.some((card) => card.hold === 'queue-paused') + const run = queuedMessageQueueRun(cards, args.isWorking) + const { turnRunning } = run + const resumable = enabled && run.resumable const queueResume = useMemo( () => (resumable ? { resume, resuming } : undefined), [resumable, resume, resuming] diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index f7659c95873b..a07c9e1a56e2 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-level pause holding this card, or null when none does: a card queued after a Stop + * is not held by it. Absent from a host that predates it; a client then treats every waiting + * card as held while `queuePause` is set. */ + heldBy?: AgentSessionQueuePause | null /** 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 71d7c62632b26d30536d86f3747d3d60c1f9d4b5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:18:56 -0700 Subject: [PATCH 129/175] test(native-chat): Resume needs the queue capability and is disabled whenever Send is --- .../native-chat-composer-field-resume.test.tsx | 15 +++++++++++++-- ...-agent-session-queued-messages.resume.test.tsx | 7 ++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx index a490e16bcd97..f17d530710f9 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx @@ -49,6 +49,8 @@ import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-even afterEach(() => cleanup()) type FieldInput = { + /** The composer cannot send at all. */ + disabled?: boolean draft?: string imageAttachments?: { id: string; path: string }[] isWorking?: boolean @@ -58,6 +60,7 @@ type FieldInput = { const NO_IMAGES: { id: string; path: string }[] = [] function TestField({ + disabled = false, draft = '', imageAttachments = NO_IMAGES, isWorking = false, @@ -71,14 +74,14 @@ function TestField({ composerScopeKey="pane-test" textareaRef={textareaRef} draft={draft} - disabled={false} + disabled={disabled} hasPty canSend autocomplete={{ mode: 'none' }} activeSuggestion={0} notice={null} imageAttachments={imageAttachments} - sendButtonDisabled={!isWorking && draft === '' && imageAttachments.length === 0} + sendButtonDisabled={disabled || (!isWorking && draft === '' && imageAttachments.length === 0)} isWorking={isWorking} attachDisabled={false} dictationDisabled={false} @@ -141,6 +144,14 @@ describe('the composer primary button over a held queue', () => { expect(primaryButton({ ...input, queueResume: held() }).getAttribute('aria-label')).toBe(label) }) + it('is disabled whenever Send would be: the composer cannot send', () => { + expect(primaryButton({ draft: 'hello', disabled: true }).disabled).toBe(true) + cleanup() + const resume = primaryButton({ disabled: true, queueResume: held() }) + expect(resume.getAttribute('aria-label')).toBe('Resume') + expect(resume.disabled).toBe(true) + }) + it('is Send when nothing is held, and disabled while a Resume is in flight', () => { expect(primaryButton({}).getAttribute('aria-label')).toBe('Send') cleanup() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx index 0f10c832763c..f13a8e8cab63 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -44,6 +44,7 @@ function card( } type ControllerInput = { + enabled?: boolean queuedMessages?: AgentSessionQueuedMessage[] queuePause?: AgentSessionQueuePause | null isWorking?: boolean @@ -60,7 +61,7 @@ function renderController(initialProps: ControllerInput = {}) { stateRef }) return useStructuredAgentSessionQueuedMessages({ - enabled: true, + enabled: input.enabled ?? true, queuedMessages: input.queuedMessages ?? [card('held')], queuePause: input.queuePause === undefined ? { reason: 'stopped' } : input.queuePause, submissions: [], @@ -98,6 +99,10 @@ describe('whether Resume is offered', () => { } ) + it('not without the queue capability: an older host, or no fence yet while connecting', () => { + expect(renderController({ enabled: false }).result.current.queueResume).toBeUndefined() + }) + it('not while a turn runs, nor when nothing is held', () => { expect(renderController({ isWorking: true }).result.current.queueResume).toBeUndefined() expect(renderController({ queuePause: null }).result.current.queueResume).toBeUndefined() From 4c3a31d45cee3d8ede2b4b8c6156669b053d6c37 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:23:48 -0700 Subject: [PATCH 130/175] docs(native-chat): describe per-card holds in the queue contract and table comments --- .../agent-session-journal/queued-message-table.ts | 5 +++-- src/shared/protocol-version.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) 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 1eb92414855e..40ff205cad11 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 @@ -21,8 +21,9 @@ export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdr /** Why ONE waiting draft is held from auto-sending: its conversion failed. * Stored on the row, so it survives handle eviction and restart; a wire marker - * (it publishes as `pausedReason`). A Stop or a restart pauses the whole queue - * instead. A reader treats an unknown stored value as a plain hold. */ + * (it publishes as `pausedReason`). A Stop, restart or /clear instead holds the + * cards written before it, derived, and the queue skips held cards. A reader + * treats an unknown stored value as a plain hold. */ export type QueuedMessageHoldReason = 'send_failed' /** Definitively unsettled: what Stop, /clear, Edit and the budget count, and diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 47deda80d81d..b085b6d13f54 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -203,8 +203,9 @@ export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = // v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and // never compares a draft id with a submission id. It also publishes the queue's pause once, as // `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or the user's next -// turn; cards carry a hold of their own only when their conversion failed. The host mechanism lands first; the constant -// gates the rollout. +// turn. Each card may carry `heldBy`, the pause holding it or null; when it is absent (an older +// host) a client falls back to `queuePause` for every card. A card's own hold (`paused`) means only +// that its conversion failed. The host mechanism lands first; the constant gates the rollout. export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = 'agent-session.queued-messages.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific From 34d5ba965bdc42b5ca024f2e728bd100964d17a3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:46:54 -0700 Subject: [PATCH 131/175] fix(native-chat): the composer goes from Resume straight to Stop, and Resume returns focus After Resume, the host lifts the hold in one update and sends the first card in a later one. In between nothing was running, so the composer's button flashed a disabled Send. A card nothing holds now keeps the queue's run going for the button too: an empty composer shows Stop, disabled until the turn starts. Not when the host refuses every send (a rewind whose outcome is unknown, read from its status), where nothing is coming. The same fix removes the Stop, Send, Stop flip between queued turns. Resume disables the button, which dropped keyboard focus; focus now returns to the composer. --- config/tsconfig.node.json | 1 + ...ed-agent-session-queued-card-holds.test.ts | 43 +++++++++++-- .../native-chat/NativeChatComposer.tsx | 4 +- .../NativeChatComposerActions.test.tsx | 4 ++ .../native-chat/NativeChatComposerActions.tsx | 24 ++++---- .../native-chat/NativeChatComposerField.tsx | 32 ++++++---- .../NativeChatQueuedMessageList.test.tsx | 3 +- ...tiveChatStructuredSession.test-harness.tsx | 7 ++- .../NativeChatStructuredSession.test.tsx | 7 ++- .../NativeChatStructuredSession.tsx | 4 +- ...tiveChatStructuredSessionDelivery.test.tsx | 2 +- .../StructuredAgentSessionStatusBridge.tsx | 14 ++--- ...native-chat-composer-field-resume.test.tsx | 39 ++++++++---- ...ative-chat-composer-primary-action.test.ts | 19 +++--- .../native-chat-composer-primary-action.ts | 43 +++++++++++-- .../native-chat/native-chat-composer-types.ts | 6 +- ...uctured-agent-session-queued-cards.test.ts | 23 ++++++- .../structured-agent-session-queued-cards.ts | 14 +++-- ...nt-session-queued-messages.resume.test.tsx | 60 +++++++++++++++---- ...red-agent-session-queued-messages.test.tsx | 1 + ...tructured-agent-session-queued-messages.ts | 38 +++++++----- ...e-structured-agent-session-status-field.ts | 36 +++++++++++ .../use-structured-agent-session.ts | 4 ++ 23 files changed, 322 insertions(+), 106 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-status-field.ts diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 20c324385790..7c9634b680d0 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -12,6 +12,7 @@ "../src/renderer/src/components/native-chat/structured-agent-question-projection.ts", "../src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts", "../src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts", + "../src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts", "../src/preload/**/*", "../src/shared/**/*", "../src/relay/**/*", diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index 41f204e6d19f..db8ac155baec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -14,6 +14,10 @@ import { queuedMessageCardSteers, queuedMessageQueueRun } from '../../../renderer/src/components/native-chat/structured-agent-session-queued-cards' +import { + nativeChatComposerPrimaryAction, + type NativeChatComposerPrimaryAction +} from '../../../renderer/src/components/native-chat/native-chat-composer-primary-action' import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' import { createQueuedMessageTestRig, @@ -48,7 +52,14 @@ async function heldBy(): Promise { @@ -84,12 +95,14 @@ async function watchClient(): Promise { queuePaused: queuePause !== null }) const working = isStructuredAgentSessionMainAgentWorking(running, all) - const run = queuedMessageQueueRun(cards, working) + const run = queuedMessageQueueRun(cards, { isWorking: working, sendBlocked: false }) + const queue = run.resumable ? 'held' : run.sendsNext ? 'sending' : null views.push({ resumable: run.resumable, steers: cards.map((card) => queuedMessageCardSteers(card, run.turnRunning)), cards: cards.length, - working + working, + button: nativeChatComposerPrimaryAction({ isWorking: working, composerEmpty: true, queue }) }) } }) @@ -113,10 +126,12 @@ describe('which pause holds each card', () => { resumable: false, steers: [true, true], cards: 2, - working: false + working: false, + button: 'stop' }) expect(views.filter((view) => view.resumable)).toEqual([]) expect(views.flatMap((view) => view.steers)).not.toContain(false) + expect(views.map((view) => view.button)).not.toContain('send') }) it("a restart's: the card from before it, not one typed during Orca's own turn since; the same holds as that turn ends", async () => { @@ -138,9 +153,27 @@ describe('which pause holds each card', () => { resumable: false, steers: [true, true], cards: 2, - working: false + working: false, + button: 'stop' }) expect(views.filter((view) => view.resumable)).toEqual([]) expect(views.flatMap((view) => view.steers)).not.toContain(false) + expect(views.map((view) => view.button)).not.toContain('send') + }) + + it('after Resume over held cards the empty composer goes from Resume straight to Stop, never Send', async () => { + const working = await rig.workingSend() + const first = await queuedDraft('first') + await queuedDraft('second') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + const views = await watchClient() + expect(views.at(-1)?.button).toBe('resume') + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + await eventually(() => expect(views.at(-1)?.working).toBe(true)) + // The update between the lifted hold and the first card's send is among them. + expect(views).toContainEqual(expect.objectContaining({ working: false, button: 'stop' })) + expect(views.map((view) => view.button)).not.toContain('send') }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 2253b5a6faad..d5d42ea919aa 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -68,7 +68,7 @@ const NativeChatComposerPane = forwardRef { attachDisabled={false} dictationDisabled={false} sendDisabled={false} + primaryAction="send" isWorking={false} isDictating={false} isDictationHoldMode={false} @@ -66,6 +67,7 @@ describe('NativeChatComposerActions', () => { attachDisabled={false} dictationDisabled={false} sendDisabled={false} + primaryAction="stop" isWorking isDictating={false} isDictationHoldMode={false} @@ -95,6 +97,7 @@ describe('NativeChatComposerActions', () => { attachDisabled={false} dictationDisabled={false} sendDisabled={false} + primaryAction="stop" isWorking isDictating={false} isDictationHoldMode={false} @@ -123,6 +126,7 @@ describe('NativeChatComposerActions', () => { attachDisabled={false} dictationDisabled={false} sendDisabled={false} + primaryAction={isWorking ? 'stop' : onResume ? 'resume' : 'send'} isWorking={isWorking} isDictating={false} isDictationHoldMode={false} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index a72a20323d28..e5c5fb336aa1 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -11,11 +11,14 @@ import { NativeChatComposerGoalChip } from './NativeChatComposerGoalChip' import { NativeChatContextUsageRing } from './NativeChatContextUsageRing' import type { NativeChatContextUsageSummary } from './native-chat-context-usage-summary' import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' +import type { NativeChatComposerPrimaryAction } from './native-chat-composer-primary-action' export type NativeChatComposerActionsProps = { attachDisabled: boolean dictationDisabled: boolean sendDisabled: boolean + /** What the primary button does (`nativeChatComposerPrimaryAction`). */ + primaryAction: NativeChatComposerPrimaryAction isWorking: boolean isDictating: boolean isDictationHoldMode: boolean @@ -25,8 +28,7 @@ export type NativeChatComposerActionsProps = { onDictationHoldEnd: () => void onSend: () => void onStop?: () => void - /** Present when no turn runs, the composer is empty and the queue is held: the primary action - * is Resume, which releases the held cards. */ + /** Releases the held queue when the primary action is Resume. */ onResume?: () => void sessionOptionsSurface: SessionOptionsSurface | null sessionOptionsSnapshot: SessionOptionDescriptor[] @@ -41,6 +43,7 @@ export function NativeChatComposerActions({ attachDisabled, dictationDisabled, sendDisabled, + primaryAction, isWorking, isDictating, isDictationHoldMode, @@ -57,21 +60,22 @@ export function NativeChatComposerActions({ onExitGoalMode, contextUsage }: NativeChatComposerActionsProps): React.JSX.Element { + const stops = primaryAction === 'stop' + const resumes = primaryAction === 'resume' const handleCriticalAction = (event: React.MouseEvent): void => { // A double-click commonly lands after the first send has started and the button has // changed to Stop; ignore the second click instead of cancelling the new turn. if (event.detail > 1) { return } - if (isWorking) { + if (stops) { onStop?.() - } else if (onResume) { - onResume() + } else if (resumes) { + onResume?.() } else { onSend() } } - const resumes = !isWorking && onResume !== undefined const dictationLabel = isDictating ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') : translate('components.native-chat.composer.startDictation', 'Start dictation') @@ -154,9 +158,9 @@ export function NativeChatComposerActions({
+ textareaRef.current?.focus()} + />
) } diff --git a/src/renderer/src/components/native-chat/NativeChatQueueSendConfirmDialog.tsx b/src/renderer/src/components/native-chat/NativeChatQueueSendConfirmDialog.tsx new file mode 100644 index 000000000000..11ad56ff6508 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatQueueSendConfirmDialog.tsx @@ -0,0 +1,65 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { NativeChatQueueSendConfirm } from './use-native-chat-held-queue-composer-send' + +/** Asks, before a message sent over a held queue, whether to clear the cards first. */ +export function NativeChatQueueSendConfirmDialog({ + confirm, + focusComposer +}: { + confirm: NativeChatQueueSendConfirm | null + /** Where focus goes whichever way it closes. */ + focusComposer: () => void +}): React.JSX.Element | null { + if (!confirm) { + return null + } + return ( + { + if (!isOpen) { + confirm.dismiss() + } + }} + > + { + event.preventDefault() + focusComposer() + }} + > + + + {translate('components.native-chat.queuedMessages.sendConfirmTitle', 'Send message?')} + + + {translate( + 'components.native-chat.queuedMessages.sendConfirmBody', + 'You are about to send a message. Do you want to clear the {{count}} messages previously queued?', + { count: confirm.count } + )} + + + + + + + + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index cc4f2f173f93..1513b301b38e 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -55,8 +55,8 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null ) } case 'paused': - // A card's own hold. Markers localize; an absent or unknown one (newer host) is a plain - // pause, never shown raw. + // A card's own hold; the queue's pause is the list's header. Markers localize, and an absent + // or unknown one (newer host) is a plain pause, never shown raw. if (card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { return translate( 'components.native-chat.queuedMessages.pausedSendFailed', @@ -76,22 +76,19 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null ) case 'turn': case 'queue-paused': - // Plainly queued: a held queue shows no caption. + // Plainly queued; a paused queue's header row carries the why. return null } } /** Steer's or Send's label and tooltip (`queuedMessageCardSteers`). */ -export function queuedMessageCardSendNow( - card: QueuedMessageCard, - turnRunning: boolean -): { +export function queuedMessageCardSendNow(card: QueuedMessageCard): { /** Steer's ↳, or Send's paper plane. */ steers: boolean label: string hint: string } { - if (!queuedMessageCardSteers(card, turnRunning)) { + if (!queuedMessageCardSteers(card)) { return { steers: false, label: translate('components.native-chat.queuedMessages.send', 'Send'), @@ -110,7 +107,6 @@ export function queuedMessageCardSendNow( export function NativeChatQueuedMessageCard({ card, - turnRunning, showsSteerShortcut, onSteer, onDelete, @@ -118,7 +114,6 @@ export function NativeChatQueuedMessageCard({ onTurnOffQueueing }: { card: QueuedMessageCard - turnRunning: boolean /** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */ showsSteerShortcut: boolean onSteer: () => void @@ -128,7 +123,7 @@ export function NativeChatQueuedMessageCard({ }): React.JSX.Element { const caption = queuedMessageCardCaption(card) const returned = card.state === 'returned' - const sendNow = queuedMessageCardSendNow(card, turnRunning) + const sendNow = queuedMessageCardSendNow(card) const isMac = isMacPlatform() return (
  • & { messageId: string }): Qu } } -/** `turnRunning` defaults on: a card waiting on the turn implies one is running. */ function controller( cards: QueuedMessageCard[], - turnRunning = true + pause: { reason: string } | null = null ): StructuredAgentSessionQueuedMessagesController & { steer: ReturnType remove: ReturnType edit: ReturnType + resume: ReturnType } { return { cards, - turnRunning, + pause, + resume: vi.fn(async () => {}), + resuming: false, steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), steerNewest: vi.fn(() => false), - queueResume: undefined + queueResume: undefined, + queueHold: undefined } } +function waitingDraft( + messageId: string, + position: number, + overrides: Partial = {} +): AgentSessionQueuedMessage { + return { + messageId, + position, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `${messageId} text` }] }, + state: 'waiting', + ...overrides + } +} + +/** The list over the real controller: cards and header as the host's publication projects them. + * Nothing runs after a Stop, a restart or a /clear. */ +function renderHeldQueue( + queuedMessages: AgentSessionQueuedMessage[], + queuePause: AgentSessionQueuePause | null +) { + const mutate = vi.fn(async (..._call: [string, string, Record]) => null) + function HeldQueue(): React.JSX.Element { + const owner = useStructuredAgentSessionQueuedMessages({ + enabled: true, + queuedMessages, + queuePause, + submissions: [], + hasPendingPrompt: false, + isWorking: false, + composerScopeKey: undefined, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list only awaits mutate; its answer is never read. + mutate: mutate as StructuredAgentSessionMutate + }) + return + } + const view = render( + + + + ) + return { ...view, mutate } +} + beforeEach(() => { mocks.updateSettings.mockReset() }) @@ -240,56 +286,97 @@ describe('NativeChatQueuedMessageList', () => { ) }) - it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( - "a queue the host holds ('%s') shows no header and no Resume; each card's Send sends it", - (reason) => { + it.each([ + ['stopped', 'Queue paused because you interrupted'], + ['restarted', 'Queue paused because Orca restarted'], + ['cleared', 'Queue paused after you cleared the conversation'], + ['some-newer-reason', 'Queue paused'] + ])( + "a queue the host holds ('%s') shows one header row above the cards, with Resume; the held card still reads Steer", + (reason, text) => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a newer host may publish a reason this client's type does not list. - const queuePause = { reason } as AgentSessionQueuePause - const mutate = vi.fn(async (..._call: [string, string, Record]) => null) - const waiting: AgentSessionQueuedMessage = { - messageId: 'held', - position: 1, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'held text' }] }, - state: 'waiting' - } - function HeldQueue(): React.JSX.Element { - const owner = useStructuredAgentSessionQueuedMessages({ - enabled: true, - queuedMessages: [waiting], - queuePause, - submissions: [], - hasPendingPrompt: false, - // Nothing runs after a Stop, a restart or a /clear. - isWorking: false, - composerScopeKey: undefined, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list only awaits mutate; its answer is never read. - mutate: mutate as StructuredAgentSessionMutate - }) - return - } - const { container } = render( - - - + const pause = { reason } as AgentSessionQueuePause + const { container, mutate } = renderHeldQueue( + [waitingDraft('held', 1, { heldBy: pause })], + pause ) - expect(container.textContent).not.toContain('Queue paused') - expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + expect(container.textContent).toContain(text) const list = screen.getByRole('list') - // The box holds only the list: no row above the cards. - expect(list.parentElement?.children).toHaveLength(1) + // The row sits above the list, not inside it: it is not a queued message. + expect(within(list).queryByText(text)).toBeNull() const row = within(list).getByRole('listitem') - // The text is the card's only line: no caption under it. + // The text is the card's only line: the header carries the why. expect(row.querySelectorAll('p')).toHaveLength(1) - expect(within(row).queryByRole('button', { name: 'Steer' })).toBeNull() - fireEvent.click(within(row).getByRole('button', { name: 'Send' })) + expect(within(row).queryByRole('button', { name: 'Send' })).toBeNull() + expect(within(row).getByRole('button', { name: 'Steer' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Resume' })) expect(mutate).toHaveBeenCalledWith( - 'agentSession.queuedMessageSend', - 'agentSession.queuedMessageSend', - { messageId: 'held' } + 'agentSession.queuedMessagesResume', + 'agentSession.queuedMessagesResume', + {} ) } ) + it('an older host that names no per-card hold: its queue pause holds every card and heads them', () => { + renderHeldQueue([waitingDraft('a', 1), waitingDraft('b', 2)], { reason: 'stopped' }) + expect(screen.getByText('Queue paused because you interrupted')).toBeTruthy() + expect(screen.getAllByRole('button', { name: 'Steer' })).toHaveLength(2) + }) + + it('no header row when the queue drains on its own, or when there are no cards', () => { + renderHeldQueue([waitingDraft('typed-after', 1, { heldBy: null })], { reason: 'stopped' }) + expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + cleanup() + const { container } = renderHeldQueue([], { reason: 'stopped' }) + expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + expect(container.textContent).toBe('') + }) + + it('no header row over cards Resume would not send: returned, held on their own, or behind', () => { + const stopped = { reason: 'stopped' } as const + const { container } = renderHeldQueue( + [ + waitingDraft('refused', 1, { state: 'returned', returnedReason: null, heldBy: null }), + waitingDraft('behind', 2, { heldBy: stopped }), + waitingDraft('failed', 3, { paused: true, pausedReason: 'send_failed', heldBy: stopped }) + ], + stopped + ) + expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + expect(container.textContent).not.toContain('Queue paused') + }) + + it('Resume shows it is pending, keeps the full text for a truncated line, and hands focus back', async () => { + const focusComposer = vi.fn() + const owner = { + ...controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { reason: 'stopped' }), + resuming: true + } + render( + + + + ) + const resume = screen.getByRole('button', { name: 'Resume' }) + expect(resume).toHaveProperty('disabled', true) + expect(screen.getByTitle('Queue paused because you interrupted')).toBeTruthy() + cleanup() + const idle = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { + reason: 'stopped' + }) + render( + + + + ) + const enabled = screen.getByRole('button', { name: 'Resume' }) + enabled.focus() + fireEvent.click(enabled) + expect(idle.resume).toHaveBeenCalledTimes(1) + await waitFor(() => expect(focusComposer).toHaveBeenCalledTimes(1)) + }) + it('Steer carries the ↳ icon; a card leads with the queue glyph, a failed one with the alert', () => { const { container } = renderList( controller([ @@ -311,20 +398,26 @@ describe('NativeChatQueuedMessageList', () => { expect(container.querySelectorAll('.lucide-list-end')).toHaveLength(1) }) - it('one bordered box holds every card, rows divided, cards unbordered', () => { + it('one bordered box holds the pause row and every card, rows divided, cards unbordered', () => { renderList( - controller([ - card({ messageId: 'waiting', hold: 'queue-paused' }), - card({ messageId: 'refused', state: 'returned', hold: 'returned', position: 2 }), - card({ messageId: 'failed', hold: 'paused', pausedReason: 'send_failed', position: 3 }) - ]) + controller( + [ + card({ messageId: 'waiting', hold: 'queue-paused' }), + card({ messageId: 'refused', state: 'returned', hold: 'returned', position: 2 }), + card({ messageId: 'failed', hold: 'paused', pausedReason: 'send_failed', position: 3 }) + ], + { reason: 'stopped' } + ) ) const list = screen.getByRole('list') const box = list.parentElement! expect(box.className.split(' ')).toEqual( - expect.arrayContaining(['rounded-md', 'border', 'border-border', 'bg-card']) + expect.arrayContaining(['rounded-md', 'border', 'border-border', 'bg-card', 'divide-y']) ) - expect(box.children).toHaveLength(1) + // The pause row is the box's first row, outside the list; the list is its second. + expect(box.children).toHaveLength(2) + expect(box.firstElementChild?.textContent).toContain('Queue paused because you interrupted') + expect(box.lastElementChild).toBe(list) expect(list.className.split(' ')).toContain('divide-y') const rows = within(list).getAllByRole('listitem') expect(rows).toHaveLength(3) @@ -336,7 +429,7 @@ describe('NativeChatQueuedMessageList', () => { expect(rows[2]?.textContent).toContain("Couldn't send — press Send to retry.") }) - it('the box holds only the list, one card or many', () => { + it('without a pause the box holds only the list, one card or many', () => { for (const count of [1, 4]) { const view = renderList( controller( @@ -362,33 +455,34 @@ describe('NativeChatQueuedMessageList', () => { expect(group.children).toHaveLength(2) }) - it('a held card keeps Send, Delete and More actions; Steer only while a turn runs', () => { - const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], false) + it('cards keep Steer, Delete and More actions while the queue is paused', () => { + const owner = controller([card({ messageId: 'waiting', hold: 'queue-paused' })], { + reason: 'stopped' + }) renderList(owner) const row = screen.getByRole('listitem') - expect(within(row).queryByRole('button', { name: 'Steer' })).toBeNull() + expect(within(row).getByRole('button', { name: 'Steer' })).toBeTruthy() expect(within(row).getByRole('button', { name: 'Delete' })).toBeTruthy() expect(within(row).getByRole('button', { name: 'More actions' })).toBeTruthy() expect(row.querySelectorAll('p')).toHaveLength(1) - const send = within(row).getByRole('button', { name: 'Send' }) - expect(send.querySelector('.lucide-send')).not.toBeNull() - fireEvent.click(send) + fireEvent.click(within(row).getByRole('button', { name: 'Steer' })) expect(owner.steer).toHaveBeenCalledWith('waiting') - cleanup() - // A turn Orca started (mail, a restart's continuation) runs while the queue stays held. - renderList(controller([card({ messageId: 'waiting', hold: 'queue-paused' })], true)) - expect(screen.getByRole('button', { name: 'Steer' })).toBeTruthy() }) - it('Steer only while a turn runs, and never for a card held on its own or returned', () => { - const SEND = { steers: false, label: 'Send', hint: 'Send this message now' } - const STEER = { steers: true, label: 'Steer', hint: 'Submit without interrupting the model' } + it('every card still waiting on the queue reads Steer; one held on its own or returned, Send', () => { for (const hold of ['turn', 'awaiting-answer', 'behind-returned', 'queue-paused'] as const) { - expect(queuedMessageCardSendNow(card({ messageId: hold, hold }), true)).toEqual(STEER) - expect(queuedMessageCardSendNow(card({ messageId: hold, hold }), false)).toEqual(SEND) + expect(queuedMessageCardSendNow(card({ messageId: hold, hold }))).toEqual({ + steers: true, + label: 'Steer', + hint: 'Submit without interrupting the model' + }) } for (const hold of ['paused', 'returned'] as const) { - expect(queuedMessageCardSendNow(card({ messageId: hold, hold }), true)).toEqual(SEND) + expect(queuedMessageCardSendNow(card({ messageId: hold, hold }))).toEqual({ + steers: false, + label: 'Send', + hint: 'Send this message now' + }) } }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index cd37aeee322d..c9d3c2f1d1b4 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -1,4 +1,6 @@ import { useRef } from 'react' +import { Pause, Play } from 'lucide-react' +import { Button } from '@/components/ui/button' import { useAppStore } from '../../store' import { translate } from '@/i18n/i18n' import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard' @@ -19,9 +21,10 @@ export function NativeChatQueuedMessageList({ }): React.JSX.Element { const updateSettings = useAppStore((store) => store.updateSettings) const queueRef = useRef(null) - const { cards } = controller + const { cards, pause } = controller const newest = cards.at(-1) - // Only when focus was on the queue — never pull it from wherever the user moved on to. + // Only when focus was on the queue (a card, or Resume) — never pull it from wherever the user + // moved on to. const refocusAfter = (action: Promise): void => { void action.then(() => { const active = document.activeElement @@ -34,7 +37,15 @@ export function NativeChatQueuedMessageList({
    {cards.length > 0 ? (
    + {/* One box: the pause row, when shown, is its first row, and each card a row below it. */}
    + {pause ? ( + refocusAfter(controller.resume())} + /> + ) : null}
      refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} @@ -61,3 +71,50 @@ export function NativeChatQueuedMessageList({
    ) } + +/** Why the queue sends nothing on its own. An unknown reason (a newer host) is a plain pause. */ +function queuePauseText(pause: { reason: string }): string { + switch (pause.reason) { + case 'stopped': + return translate( + 'components.native-chat.queuedMessages.queuePausedStopped', + 'Queue paused because you interrupted' + ) + case 'restarted': + return translate( + 'components.native-chat.queuedMessages.queuePausedRestarted', + 'Queue paused because Orca restarted' + ) + case 'cleared': + return translate( + 'components.native-chat.queuedMessages.queuePausedCleared', + 'Queue paused after you cleared the conversation' + ) + default: + return translate('components.native-chat.queuedMessages.queuePaused', 'Queue paused') + } +} + +function NativeChatQueuePauseRow({ + pause, + resuming, + onResume +}: { + pause: { reason: string } + resuming: boolean + onResume: () => void +}): React.JSX.Element { + const text = queuePauseText(pause) + return ( +
    + +

    + {text} +

    + +
    + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 85e1804e5a69..7f011ebb639d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -240,7 +240,8 @@ export function NativeChatStructuredSession( runtime: (props.target.kind === 'local' ? 'local' : 'remote') as 'local' | 'remote', sessionId: props.sessionId, runtimeEnvironmentId: - props.target.kind === 'local' ? null : (props.target.environmentId ?? null) + props.target.kind === 'local' ? null : (props.target.environmentId ?? null), + queueHold: controller.queuedMessages.queueHold } }, [ controller, 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 abbe42ab9f67..c8da1870ce3f 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 @@ -16,6 +16,14 @@ export type NativeChatOptionPickerRequest = { sequence: number } +/** A queue the host holds, with cards waiting. */ +export type NativeChatQueueHold = { + /** Every card shown, held or not. */ + count: number + /** Delete every card; false when one could not be (already reported). */ + clear: () => Promise +} + export type NativeChatStructuredComposerTransport = { conversationCommands?: readonly AgentSessionConversationCommand[] send: (text: string, attachments: readonly NativeChatComposerImageAttachment[]) => boolean @@ -37,6 +45,8 @@ export type NativeChatStructuredComposerTransport = { sessionId: string /** Owning runtime for that report; null is the local runtime. */ runtimeEnvironmentId: string | null + /** Present while the queue is held: a message sent now first asks whether to clear its cards. */ + queueHold?: NativeChatQueueHold } export type NativeChatOptimisticSendOutcome = { diff --git a/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx b/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx new file mode 100644 index 000000000000..1262cb878a39 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx @@ -0,0 +1,248 @@ +import { changePrompt, promptValue } from './native-chat-prompt-editor.test-support' +// @vitest-environment happy-dom + +// A message sent while the queue is held asks first, from Enter and from the button alike: +// Clear queue deletes every card and then sends, Send message sends and keeps them, and +// dismissing sends nothing and leaves the draft where it was. + +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as nativeChatAgentProfiles from '../../../../shared/native-chat-agent-profiles' +import type { + NativeChatQueueHold, + NativeChatStructuredComposerTransport +} from './native-chat-composer-types' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, options?: { count?: number }) => + fallback.replace('{{count}}', String(options?.count ?? '')) +})) +// The button's own wiring: the primary action calls the same `send` Enter does. +vi.mock('./NativeChatComposerActions', () => ({ + NativeChatComposerActions: ({ onSend }: { onSend: () => void }) => ( + + ) +})) +vi.mock('./NativeChatAutocompleteMenus', () => ({ + NativeChatMentionHint: () => null, + NativeChatPickerMenu: () => null +})) +vi.mock('../../store', () => { + const state = { + dictationState: 'idle', + settings: { voice: { enabled: false }, nativeChatSessionOptions: {} }, + agentStatusByPaneKey: {}, + updateSettings: vi.fn(), + clearNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchDraftAdopted: vi.fn() + } + const useAppStore = (selector: (value: typeof state) => unknown): unknown => selector(state) + useAppStore.getState = () => state + return { useAppStore } +}) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: () => false, + sendRuntimePtyInput: vi.fn() +})) +vi.mock('@/lib/agent-paste-draft', () => ({ + getSettingsForAgentTabRuntimeOwner: () => ({}) +})) +vi.mock('./native-chat-runtime-send', () => ({ + sendNativeChatMessage: vi.fn(), + sendNativeChatTypedCommand: vi.fn(), + sendNativeChatMessageVerified: vi.fn(), + typeNativeChatCommand: vi.fn(), + submitNativeChatPrompt: vi.fn() +})) +vi.mock('./native-chat-runtime-image-send', () => ({ + sendNativeChatMessageWithImageAttachments: vi.fn() +})) +vi.mock('./claude-model-switch-confirmation', () => ({ + createClaudeModelSwitchConfirmationObserver: vi.fn() +})) +vi.mock('../../../../shared/native-chat-agent-profiles', async (importOriginal) => ({ + ...(await importOriginal()), + getVerifiedNativeChatCommands: () => [] +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ + emitNativeChatMessageSent: vi.fn(), + emitNativeChatPickerItemAccepted: vi.fn(), + emitNativeChatPickerOpened: vi.fn(), + emitNativeChatSendClassified: vi.fn() +})) +vi.mock('./use-native-chat-skills', () => ({ + useNativeChatSkills: () => ({ status: 'ready', skills: [], error: null, retry: () => {} }) +})) +vi.mock('../dictation/dictation-control-events', () => ({ + dispatchDictationControl: vi.fn() +})) + +import { NativeChatComposer } from './NativeChatComposer' + +const PASS_THROUGH = { handled: false, accepted: false, error: null } + +function transport(queueHold?: NativeChatQueueHold): NativeChatStructuredComposerTransport { + return { + send: vi.fn(() => true), + dispatchCommand: vi.fn(async () => PASS_THROUGH), + optionsSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, + optionSnapshot: [], + onError: vi.fn(), + runtime: 'local', + sessionId: 'session-test', + runtimeEnvironmentId: null, + ...(queueHold ? { queueHold } : {}) + } +} + +function heldQueue( + count: number, + cleared = true +): NativeChatQueueHold & { + clear: ReturnType +} { + return { count, clear: vi.fn(async () => cleared) } +} + +let paneCounter = 0 + +function renderComposer(structuredTransport: NativeChatStructuredComposerTransport) { + paneCounter += 1 + render( + + ) + return screen.getByRole('textbox') +} + +function pressEnter(input: HTMLElement): void { + fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, isComposing: false }) +} + +const BODY = + 'You are about to send a message. Do you want to clear the 2 messages previously queued?' + +beforeEach(() => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + git: { discoverCommitMessageModels: vi.fn().mockResolvedValue({ success: false }) }, + pty: { getMainBufferSnapshot: vi.fn().mockResolvedValue(null) }, + ui: { onFileDrop: () => vi.fn() } + } + }) +}) + +afterEach(() => cleanup()) + +describe('sending while the queue is held', () => { + it('Enter and the send button both ask first, and nothing is sent until the person chooses', async () => { + for (const submit of [pressEnter, () => fireEvent.click(screen.getByTestId('composer-send'))]) { + const structured = transport(heldQueue(2)) + const input = renderComposer(structured) + changePrompt(input, 'a new instruction') + await act(async () => submit(input)) + const dialog = await screen.findByRole('dialog') + expect(dialog.textContent).toContain('Send message?') + expect(dialog.textContent).toContain(BODY) + expect(screen.getByRole('button', { name: 'Clear queue' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Send message' })).toBeTruthy() + expect(structured.dispatchCommand).not.toHaveBeenCalled() + expect(structured.send).not.toHaveBeenCalled() + cleanup() + } + }) + + it('Send message sends the draft and keeps the cards', async () => { + const hold = heldQueue(2) + const structured = transport(hold) + const input = renderComposer(structured) + changePrompt(input, 'a new instruction') + await act(async () => pressEnter(input)) + await act(async () => + fireEvent.click(await screen.findByRole('button', { name: 'Send message' })) + ) + await waitFor(() => expect(structured.send).toHaveBeenCalledWith('a new instruction', [])) + expect(hold.clear).not.toHaveBeenCalled() + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + }) + + it('Clear queue deletes every card first, then sends', async () => { + const deleted = Promise.withResolvers() + const hold = heldQueue(2) + hold.clear.mockReturnValue(deleted.promise) + const structured = transport(hold) + const input = renderComposer(structured) + changePrompt(input, 'start over') + await act(async () => pressEnter(input)) + await act(async () => + fireEvent.click(await screen.findByRole('button', { name: 'Clear queue' })) + ) + expect(hold.clear).toHaveBeenCalledTimes(1) + // Nothing goes out while the deletes are on their way. + await act(async () => {}) + expect(structured.dispatchCommand).not.toHaveBeenCalled() + expect(structured.send).not.toHaveBeenCalled() + await act(async () => deleted.resolve(true)) + await waitFor(() => expect(structured.send).toHaveBeenCalledWith('start over', [])) + }) + + it('a Clear queue whose delete fails sends nothing and keeps the draft', async () => { + const hold = heldQueue(2, false) + const structured = transport(hold) + const input = renderComposer(structured) + changePrompt(input, 'start over') + await act(async () => pressEnter(input)) + await act(async () => + fireEvent.click(await screen.findByRole('button', { name: 'Clear queue' })) + ) + await waitFor(() => expect(hold.clear).toHaveBeenCalledTimes(1)) + await act(async () => {}) + expect(structured.dispatchCommand).not.toHaveBeenCalled() + expect(structured.send).not.toHaveBeenCalled() + expect(promptValue(input)).toBe('start over') + }) + + it('dismissing sends nothing, keeps the draft and returns focus to the composer', async () => { + const hold = heldQueue(2) + const structured = transport(hold) + const input = renderComposer(structured) + changePrompt(input, 'not yet') + await act(async () => pressEnter(input)) + const dialog = await screen.findByRole('dialog') + await act(async () => fireEvent.keyDown(dialog, { key: 'Escape' })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + expect(structured.send).not.toHaveBeenCalled() + expect(hold.clear).not.toHaveBeenCalled() + expect(promptValue(input)).toBe('not yet') + await waitFor(() => expect(input.contains(document.activeElement)).toBe(true)) + }) + + it('does not ask when the queue is not held, nor for a command the host runs itself', async () => { + const free = transport() + const input = renderComposer(free) + changePrompt(input, 'plain send') + await act(async () => pressEnter(input)) + await waitFor(() => expect(free.send).toHaveBeenCalledWith('plain send', [])) + expect(screen.queryByRole('dialog')).toBeNull() + cleanup() + const held = transport(heldQueue(2)) + const commandInput = renderComposer(held) + changePrompt(commandInput, '/compact') + await act(async () => pressEnter(commandInput)) + await waitFor(() => expect(held.dispatchCommand).toHaveBeenCalledWith('/compact')) + expect(screen.queryByRole('dialog')).toBeNull() + }) +}) 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 8a51296f8e0a..9c60e50837ad 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 @@ -11,6 +11,7 @@ import { outboxOutsideQueuedCards, projectQueuedMessageCards, queuedMessageCardSteers, + queuedMessagesQueuePause, queuedMessagesResumable } from './structured-agent-session-queued-cards' @@ -148,7 +149,7 @@ describe('queued message cards', () => { draft('behind', 4) ], [], - { hasPendingPrompt: true, queuePaused: true } + { hasPendingPrompt: true, queuePause: { reason: 'stopped' } } ) expect(cards.map((card) => card.hold)).toEqual([ 'queue-paused', @@ -166,7 +167,7 @@ describe('queued message cards', () => { draft('held', 1, { heldBy: { reason: 'stopped' } }), draft('typed-after', 2, { heldBy: null }) ] - const paused = { hasPendingPrompt: true, queuePaused: true } + const paused = { hasPendingPrompt: true, queuePause: { reason: 'restarted' } } as const // The card queued after the Stop still says it waits for the answer. expect(projectQueuedMessageCards(named, [], paused).map((card) => card.hold)).toEqual([ 'queue-paused', @@ -181,7 +182,10 @@ describe('queued message cards', () => { it('Resume only over a card a pause holds, and only while nothing runs', () => { const project = (messages: AgentSessionQueuedMessage[]) => - projectQueuedMessageCards(messages, [], { hasPendingPrompt: false, queuePaused: true }) + projectQueuedMessageCards(messages, [], { + hasPendingPrompt: false, + queuePause: { reason: 'stopped' } + }) const held = draft('held', 1, { heldBy: { reason: 'stopped' } }) expect(queuedMessagesResumable(project([held]), false)).toBe(true) // Working counts the queue's coming send, which the host names. @@ -193,7 +197,34 @@ describe('queued message cards', () => { 'turn' ]) const [card] = project([held]) - expect(card && queuedMessageCardSteers(card, true)).toBe(true) + // Still Steer while nothing runs: the header row, not the card, says it waits. + expect(card && queuedMessageCardSteers(card)).toBe(true) + }) + + it("the header names the oldest held card's pause, and none over cards Resume would not send", () => { + const project = (messages: AgentSessionQueuedMessage[]) => + projectQueuedMessageCards(messages, [], { + hasPendingPrompt: false, + queuePause: { reason: 'stopped' } + }) + const restarted = draft('restarted', 1, { heldBy: { reason: 'restarted' } }) + const stopped = draft('stopped', 2, { heldBy: { reason: 'stopped' } }) + expect(queuedMessagesQueuePause(project([restarted, stopped]))).toEqual({ reason: 'restarted' }) + // An older host names no per-card hold: its queue-level pause is the reason. + expect(queuedMessagesQueuePause(project([draft('older', 1)]))).toEqual({ reason: 'stopped' }) + const unsendable = [ + draft('returned', 1, { state: 'returned', returnedReason: null, heldBy: null }), + draft('behind', 2, { heldBy: { reason: 'stopped' } }), + draft('failed', 3, { + paused: true, + pausedReason: 'send_failed', + heldBy: { reason: 'stopped' } + }) + ] + expect(queuedMessagesQueuePause(project(unsendable))).toBeNull() + expect( + queuedMessagesQueuePause(project([draft('typed-after', 1, { heldBy: null })])) + ).toBeNull() }) it('steers the newest card', () => { 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 3c5861da148f..ff08241d3602 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 @@ -3,7 +3,10 @@ import type { UnreadAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause +} from '../../../../shared/agent-session-wire' import { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' import { structuredAgentSessionEntryAsksToQueue, @@ -18,8 +21,8 @@ import { /** Why a card is not on its way right now; decides the caption under the text. */ export type QueuedMessageCardHold = | 'turn' - /** Held by a Stop, restart or /clear: no caption and no promise of when it sends — not even - * after an answer. Resume, its own Send or Steer, or any new message releases it. */ + /** Held by a Stop, restart or /clear: the header row above the cards says why and offers + * Resume, so the card makes no promise of when it sends — not even after an answer. */ | 'queue-paused' | 'awaiting-answer' | 'paused' @@ -33,6 +36,9 @@ export type QueuedMessageCard = { text: string state: 'waiting' | 'returned' hold: QueuedMessageCardHold + /** The pause holding a 'queue-paused' card. A string: a newer host may name a reason this build + * does not know. */ + queuePause?: { reason: string } pausedReason?: string returnedReason?: string | null /** The typed fact the returned card's submission settled with; read like its `rejection`. */ @@ -53,7 +59,7 @@ function queuedMessageCardText(body: AgentSessionQueuedMessage['body']): string export function projectQueuedMessageCards( queuedMessages: readonly AgentSessionQueuedMessage[] | null | undefined, submissions: readonly AgentJournalSubmission[], - session: { hasPendingPrompt: boolean; queuePaused?: boolean } + session: { hasPendingPrompt: boolean; queuePause?: AgentSessionQueuePause | null } ): QueuedMessageCard[] { const handedOff = handedOffQueuedMessageIds( submissions.filter((submission) => submission.dispatchState !== 'rejected') @@ -64,8 +70,7 @@ export function projectQueuedMessageCards( let behindReturned = false return ordered.map((message) => { // A host that predates `heldBy` publishes only the queue's pause, which then covers every card. - const heldByPause = - message.heldBy === undefined ? session.queuePaused === true : message.heldBy !== null + const heldBy = message.heldBy === undefined ? (session.queuePause ?? null) : message.heldBy const hold: QueuedMessageCardHold = message.state === 'returned' ? 'returned' @@ -73,7 +78,7 @@ export function projectQueuedMessageCards( ? 'paused' : behindReturned ? 'behind-returned' - : heldByPause + : heldBy ? 'queue-paused' : session.hasPendingPrompt ? 'awaiting-answer' @@ -85,6 +90,7 @@ export function projectQueuedMessageCards( text: queuedMessageCardText(message.body), state: message.state, hold, + ...(hold === 'queue-paused' && heldBy ? { queuePause: { reason: heldBy.reason } } : {}), ...(message.pausedReason !== undefined ? { pausedReason: message.pausedReason } : {}), ...(message.returnedReason !== undefined ? { returnedReason: message.returnedReason } : {}), ...(message.returnedRejection !== undefined @@ -94,20 +100,27 @@ export function projectQueuedMessageCards( }) } -/** Whether Resume would release anything: nothing runs, and a queue-level pause holds a card. One - * held on its own, returned or behind a returned card would not send. `isWorking` counts the - * queue's coming send, which the host names. */ +/** The pause the header row names, from the oldest card it holds. A pause over cards Resume would + * not send (returned, held on their own, or behind a returned one) offers nothing to press. */ +export function queuedMessagesQueuePause( + cards: readonly QueuedMessageCard[] +): { reason: string } | null { + return cards.find((card) => card.hold === 'queue-paused')?.queuePause ?? null +} + +/** Whether the composer's Resume would release anything: nothing runs, and a pause holds a card. + * `isWorking` counts the queue's coming send, which the host names. */ export function queuedMessagesResumable( cards: readonly QueuedMessageCard[], isWorking: boolean ): boolean { - return !isWorking && cards.some((card) => card.hold === 'queue-paused') + return !isWorking && queuedMessagesQueuePause(cards) !== null } -/** Steer jumps into a running turn; with none running, or for a card held on its own or - * returned, the action is plainly Send. */ -export function queuedMessageCardSteers(card: QueuedMessageCard, turnRunning: boolean): boolean { - return turnRunning && card.hold !== 'paused' && card.hold !== 'returned' +/** Steer names the mid-turn jump, also while the whole queue is paused; a card held on its own or + * returned is not waiting on the turn, so its action is plainly Send. */ +export function queuedMessageCardSteers(card: QueuedMessageCard): boolean { + return card.hold !== 'paused' && card.hold !== 'returned' } /** The card Cmd/Ctrl+Enter steers: the newest one; every shown card takes Send-now. */ diff --git a/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts new file mode 100644 index 000000000000..f66621c81fda --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts @@ -0,0 +1,89 @@ +// A message sent while the queue is held asks first whether to clear the cards, which would +// otherwise wait behind it. A host command is not a message and sends as it is. + +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { + isNativeChatStructuredHostCommand, + useNativeChatStructuredComposerSend, + type UseNativeChatStructuredComposerSendArgs +} from './use-native-chat-structured-composer-send' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' + +type StructuredComposerSend = ( + text: string, + attachments?: readonly NativeChatComposerImageAttachment[] +) => void + +/** The confirmation as the dialog shows it; kept after it closes so the closing frame still reads. */ +export type NativeChatQueueSendConfirm = { + open: boolean + /** The cards waiting when it opened. */ + count: number + /** Delete every card, then send; a failed delete sends nothing. */ + clearQueue: () => void + /** Send and keep the cards: the message is the person's turn, so they follow it. */ + sendMessage: () => void + /** Send nothing; the draft and its attachments stay in the composer. */ + dismiss: () => void +} + +type PendingSend = { + text: string + attachments: readonly NativeChatComposerImageAttachment[] | undefined + count: number + clear: () => Promise +} + +export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructuredComposerSendArgs): { + send: StructuredComposerSend + confirm: NativeChatQueueSendConfirm | null +} { + const sendNow = useNativeChatStructuredComposerSend(args) + const { agent, structuredTransport } = args + const queueHold = structuredTransport?.queueHold + const [pending, setPending] = useState(null) + const [open, setOpen] = useState(false) + // Clear queue sends once its deletes settle, through the send of that render. + const sendNowRef = useRef(sendNow) + useLayoutEffect(() => { + sendNowRef.current = sendNow + }, [sendNow]) + + const send = useCallback( + (text, attachments) => { + if ( + queueHold && + structuredTransport && + !isNativeChatStructuredHostCommand(text, agent, structuredTransport) + ) { + setPending({ text, attachments, count: queueHold.count, clear: queueHold.clear }) + setOpen(true) + return + } + sendNow(text, attachments) + }, + [agent, queueHold, sendNow, structuredTransport] + ) + + const sendMessage = useCallback(() => { + setOpen(false) + if (pending) { + sendNowRef.current(pending.text, pending.attachments) + } + }, [pending]) + const clearQueue = useCallback(() => { + setOpen(false) + if (!pending) { + return + } + void pending.clear().then((cleared) => { + if (cleared) { + sendNowRef.current(pending.text, pending.attachments) + } + }) + }, [pending]) + const dismiss = useCallback(() => setOpen(false), []) + + const confirm = pending ? { open, count: pending.count, clearQueue, sendMessage, dismiss } : null + return { send, confirm } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index f208e6cb4a93..f01023fb4958 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -23,6 +23,18 @@ export type UseNativeChatStructuredComposerSendArgs = { setCaret: (caret: number) => void } +/** A command the host runs itself (and `/goal` where the host sets goals), never a message. */ +export function isNativeChatStructuredHostCommand( + text: string, + agent: AgentType, + transport: NativeChatStructuredComposerTransport +): boolean { + return ( + isStructuredAgentSessionComposerCommand(text, agent) || + (transport.threadGoal !== undefined && isStructuredAgentSessionGoalCommand(text)) + ) +} + /** Send through the structured journal transport, clearing the composer only * once the transport accepts (the PTY path has its own sibling hook). */ export function useNativeChatStructuredComposerSend({ @@ -48,9 +60,7 @@ export function useNativeChatStructuredComposerSend({ if (!structuredTransport) { return } - const hostCommand = - isStructuredAgentSessionComposerCommand(text, agent) || - (structuredTransport.threadGoal !== undefined && isStructuredAgentSessionGoalCommand(text)) + const hostCommand = isNativeChatStructuredHostCommand(text, agent, structuredTransport) if (attachments.length > 0 && hostCommand) { structuredTransport.onError('Remove attachments before using a chat-session command.') return diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx new file mode 100644 index 000000000000..eff432dd7a12 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom + +// What a message sent over a held queue asks about: offered exactly while the header row shows, +// counting every card shown, and Clear queue deletes each of them through the card's own Delete. + +import { act, render, renderHook, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setRendererUiLanguage } from '@/i18n/i18n' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause +} from '../../../../shared/agent-session-wire' +import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import { NativeChatQueueSendConfirmDialog } from './NativeChatQueueSendConfirmDialog' + +const STOPPED = { reason: 'stopped' } as const + +function card( + messageId: string, + position: number, + fields: Partial = {} +): AgentSessionQueuedMessage { + const body = { kind: 'message' as const, role: 'user' as const, blocks: [] } + return { messageId, position, body, state: 'waiting', ...fields } +} + +type DeleteAnswer = { deleted: true; messageId: string } | null + +function renderController( + queuedMessages: AgentSessionQueuedMessage[], + options: { + queuePause?: AgentSessionQueuePause | null + enabled?: boolean + answer?: (messageId: string) => DeleteAnswer + } = {} +) { + const answer = options.answer ?? ((messageId) => ({ deleted: true, messageId })) + const mutate = vi.fn( + async (_method: string, _fingerprint: string, fields: Record) => + answer(String(fields.messageId)) + ) + const view = renderHook(() => + useStructuredAgentSessionQueuedMessages({ + enabled: options.enabled ?? true, + queuedMessages, + queuePause: options.queuePause === undefined ? STOPPED : options.queuePause, + submissions: [], + hasPendingPrompt: false, + isWorking: false, + composerScopeKey: undefined, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller only awaits mutate; the stub answers Delete's shape. + mutate: mutate as unknown as StructuredAgentSessionMutate + }) + ) + return { ...view, mutate } +} + +afterEach(async () => { + vi.clearAllMocks() + await setRendererUiLanguage('en') +}) + +describe('the held queue a new message asks about', () => { + it('is offered while a pause holds a card, counting every card shown, held or not', () => { + const { result } = renderController([ + card('held', 1, { heldBy: STOPPED }), + card('typed-after', 2, { heldBy: null }), + card('failed', 3, { paused: true, pausedReason: 'send_failed', heldBy: null }) + ]) + expect(result.current.pause).toEqual(STOPPED) + expect(result.current.queueHold?.count).toBe(3) + }) + + it('is not offered when no pause holds a card, with no cards, or without the queue', () => { + const unheld = [card('typed-after', 1, { heldBy: null })] + expect(renderController(unheld).result.current.queueHold).toBeUndefined() + const ownHeld = [card('failed', 1, { paused: true, heldBy: null })] + expect(renderController(ownHeld).result.current.queueHold).toBeUndefined() + expect(renderController([]).result.current.queueHold).toBeUndefined() + const held = [card('held', 1, { heldBy: STOPPED })] + expect(renderController(held, { enabled: false }).result.current.queueHold).toBeUndefined() + }) + + it('Clear queue deletes every card shown, and answers true once all are gone', async () => { + const { result, mutate } = renderController([ + card('held', 1, { heldBy: STOPPED }), + card('typed-after', 2, { heldBy: null }) + ]) + let cleared: boolean | undefined + await act(async () => { + cleared = await result.current.queueHold?.clear() + }) + expect(cleared).toBe(true) + expect(mutate.mock.calls.map(([method, , fields]) => [method, fields.messageId])).toEqual([ + ['agentSession.queuedMessageDelete', 'held'], + ['agentSession.queuedMessageDelete', 'typed-after'] + ]) + }) + + it('a delete that fails makes Clear queue answer false', async () => { + const { result } = renderController( + [card('held', 1, { heldBy: STOPPED }), card('typed-after', 2, { heldBy: null })], + { answer: (messageId) => (messageId === 'held' ? null : { deleted: true, messageId }) } + ) + let cleared: boolean | undefined + await act(async () => { + cleared = await result.current.queueHold?.clear() + }) + expect(cleared).toBe(false) + }) +}) + +describe('the confirmation copy', () => { + it('counts the queued messages in the plural the language uses', () => { + const confirm = (count: number) => ({ + open: true, + count, + clearQueue: vi.fn(), + sendMessage: vi.fn(), + dismiss: vi.fn() + }) + const view = render( + + ) + expect(screen.getByRole('dialog').textContent).toContain( + 'Do you want to clear the 1 message previously queued?' + ) + view.rerender() + expect(screen.getByRole('dialog').textContent).toContain( + 'Do you want to clear the 3 messages previously queued?' + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx index 6c81e40dfa06..068e0eb8ef69 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -1,8 +1,9 @@ // @vitest-environment happy-dom // Resume releases a held queue through its own RPC, over the same fenced write every card action -// uses: offered only while no turn runs and the host holds a card it would send; a refusal or a -// failure is one toast, and Resume stays the way to try again. +// uses. The header row offers it whenever the host holds a card it would send, the composer only +// while no turn runs too; one press at a time, a refusal or a failure is one toast, and Resume +// stays the way to try again. import { act, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -118,15 +119,56 @@ describe('whether Resume is offered', () => { }) }) -describe('while the queue is about to send its next card', () => { - it('the cards steer and Resume is not offered: the chat reads as working', () => { +describe('while a turn runs over held cards', () => { + it('the header row still names the pause and offers Resume; the composer does not', () => { // `isWorking` counts the queue's coming send, which the host names. const { result } = renderController({ isWorking: true }) - expect(result.current.turnRunning).toBe(true) + expect(result.current.pause).toEqual({ reason: 'stopped' }) expect(result.current.queueResume).toBeUndefined() }) }) +describe("the header row's pause", () => { + it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( + "names the reason the host holds a card for ('%s')", + (reason) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a newer host may publish a reason this client's type does not list. + const heldBy = { reason } as AgentSessionQueuePause + const { result } = renderController({ queuedMessages: [card('held', { heldBy })] }) + expect(result.current.pause).toEqual({ reason }) + } + ) + + it('is absent over cards held only on their own or returned, and over cards no pause holds', () => { + const queuedMessages = [ + card('failed', { paused: true, pausedReason: 'send_failed', heldBy: null }), + card('returned', { position: 2, state: 'returned', heldBy: null }) + ] + expect(renderController({ queuedMessages }).result.current.pause).toBeNull() + const typedAfter = [card('typed-after', { heldBy: null })] + expect(renderController({ queuedMessages: typedAfter }).result.current.pause).toBeNull() + }) + + it("shares one Resume with the composer's: a press of either while one is in flight sends nothing", async () => { + const answer = Promise.withResolvers() + mocks.call.mockReturnValueOnce(answer.promise) + const { result } = renderController() + let pending: Promise = Promise.resolve() + act(() => { + pending = result.current.resume() + }) + expect(result.current.resuming).toBe(true) + expect(result.current.queueResume?.resuming).toBe(true) + await act(() => resume(result)) + expect(mocks.call).toHaveBeenCalledTimes(1) + await act(async () => { + answer.resolve(RESUMED) + await pending + }) + expect(result.current.resuming).toBe(false) + }) +}) + describe('Resume on a held queue', () => { it('calls queuedMessagesResume with only an envelope, and says nothing when it lands', async () => { mocks.call.mockResolvedValue(RESUMED) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 9db0e95ded3a..16b1bfd7a8b3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -1,5 +1,6 @@ // Host-held drafts as this pane acts on them: the card list, Send-now (Steer), -// Delete, Edit, the Cmd/Ctrl+Enter steer chord, and Resume of a held queue. +// Delete, Edit, the Cmd/Ctrl+Enter steer chord, Resume of a held queue, and +// clearing it before a new message. // Everything durable lives on the host, and no draft text ever travels back over // the wire: Edit copies the text the card already shows into the composer, // locally, before deleting the draft, so no RPC outcome can lose it. @@ -19,16 +20,23 @@ import { appendNativeChatDraftCache } from './native-chat-draft-cache' import { newestSteerableQueuedMessageCard, projectQueuedMessageCards, + queuedMessagesQueuePause, queuedMessagesResumable, type QueuedMessageCard } from './structured-agent-session-queued-cards' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import type { NativeChatQueueHold } from './native-chat-composer-types' export type StructuredAgentSessionQueuedMessagesController = { cards: QueuedMessageCard[] - /** A turn is running, or the queue is about to send its next card, so Send-now steers rather - * than starting a turn. */ - turnRunning: boolean + /** Why the queue holds cards Resume would send: the header row above them. A string reason: a + * newer host may name one this build does not know. */ + pause: { reason: string } | null + /** Lift the queue's pause; a failure is a toast, and Resume is the retry. Shared by the header + * row and the composer. */ + resume: () => Promise + /** A Resume is in flight. */ + resuming: boolean /** Send-now into the running turn; the transcript shows it at delivery position. */ steer: (messageId: string) => Promise remove: (messageId: string) => Promise @@ -39,6 +47,8 @@ export type StructuredAgentSessionQueuedMessagesController = { /** Present while no turn runs and the host holds a card Resume would send: the composer offers * Resume. A failure is a toast, and Resume stays the retry. */ queueResume: StructuredAgentSessionQueueResume | undefined + /** Present while the header row shows: a new message first asks whether to clear the cards. */ + queueHold: NativeChatQueueHold | undefined } export type StructuredAgentSessionQueueResume = { @@ -66,12 +76,11 @@ export function useStructuredAgentSessionQueuedMessages(args: { mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args - // Held cards carry no caption; Resume, a card's Send or Steer, or a new message releases them. - const queuePaused = args.queuePause !== null + const { queuePause } = args const cards = useMemo( - () => projectQueuedMessageCards(queuedMessages, submissions, { hasPendingPrompt, queuePaused }), - [hasPendingPrompt, queuePaused, queuedMessages, submissions] + () => projectQueuedMessageCards(queuedMessages, submissions, { hasPendingPrompt, queuePause }), + [hasPendingPrompt, queuePause, queuedMessages, submissions] ) const cardsRef = useRef(cards) useEffect(() => { @@ -107,9 +116,11 @@ export function useStructuredAgentSessionQueuedMessages(args: { [actOnce, mutate] ) - const remove = useCallback( - (messageId: string): Promise => - actOnce(messageId, async () => { + /** True once the card has left the queue, deleted or already sent; a failure is a toast. */ + const removeCard = useCallback( + async (messageId: string): Promise => { + let removed = false + await actOnce(messageId, async () => { const result = await mutate( 'agentSession.queuedMessageDelete', 'agentSession.queuedMessageDelete', @@ -118,9 +129,18 @@ export function useStructuredAgentSessionQueuedMessages(args: { if (result && !result.deleted && result.disposition === 'dispatched') { alreadySentNotice() } - }), + removed = result !== null + }) + return removed + }, [actOnce, mutate] ) + const remove = useCallback( + async (messageId: string): Promise => { + await removeCard(messageId) + }, + [removeCard] + ) const edit = useCallback( (messageId: string): Promise => @@ -188,5 +208,26 @@ export function useStructuredAgentSessionQueuedMessages(args: { [resumable, resume, resuming] ) - return { cards, turnRunning: args.isWorking, steer, remove, edit, steerNewest, queueResume } + const pause = useMemo(() => queuedMessagesQueuePause(cards), [cards]) + // Every card shown, held or not: Clear queue empties the list the person sees. + const clear = useCallback(async (): Promise => { + const removed = await Promise.all(cardsRef.current.map((card) => removeCard(card.messageId))) + return removed.every(Boolean) + }, [removeCard]) + const count = cards.length + const held = enabled && pause !== null + const queueHold = useMemo(() => (held ? { count, clear } : undefined), [held, count, clear]) + + return { + cards, + pause, + resume, + resuming, + steer, + remove, + edit, + steerNewest, + queueResume, + queueHold + } } 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 a4308b5f11d0..c373afe6f31e 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 @@ -281,7 +281,7 @@ describe('against a capable host', () => { nextQueuedMessageId = 'draft-1' const { result } = render() expect(result.current).toMatchObject({ isWorking: true, queueSendsNext: true, canStop: false }) - expect(result.current.queuedMessages.turnRunning).toBe(true) + expect(result.current.queuedMessages.cards.map((card) => card.hold)).toEqual(['turn']) // Where the host would refuse that send, it names none: the chat reads idle. nextQueuedMessageId = null const refused = render() diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index b20c7037d554..a09cce7ec8e4 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2808,6 +2808,10 @@ "question": { "other": "Other…" }, + "queuedMessages": { + "sendConfirmBody_one": "You are about to send a message. Do you want to clear the {{count}} message previously queued?", + "sendConfirmBody_other": "You are about to send a message. Do you want to clear the {{count}} messages previously queued?" + }, "state": { "empty": { "subtitle": "Ask {{value0}} to inspect code, explain output, or make a change.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 624bcd12df52..bd4de95851f3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18072,7 +18072,17 @@ "withdrawnHold": "Stopped before it was sent", "pausedSendFailed": "Couldn't send — press Send to retry.", "paused": "Paused", - "resume": "Resume" + "queuePausedStopped": "Queue paused because you interrupted", + "queuePausedRestarted": "Queue paused because Orca restarted", + "queuePausedCleared": "Queue paused after you cleared the conversation", + "queuePaused": "Queue paused", + "resume": "Resume", + "sendConfirmTitle": "Send message?", + "sendConfirmBody": "You are about to send a message. Do you want to clear the {{count}} messages previously queued?", + "sendConfirmBody_one": "You are about to send a message. Do you want to clear the {{count}} message previously queued?", + "sendConfirmBody_other": "You are about to send a message. Do you want to clear the {{count}} messages previously queued?", + "sendConfirmClear": "Clear queue", + "sendConfirmSend": "Send message" }, "structuredSessionHostDeclined": "Opened {{value0}} in a terminal", "structuredSessionHostDeclinedDescription": "This server can't run a {{value0}} chat in this workspace.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 1891e56aa128..e90eca47f837 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15220,7 +15220,17 @@ "withdrawnHold": "Detenido antes de enviarse", "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", "paused": "En pausa", - "resume": "Reanudar" + "queuePausedStopped": "Cola en pausa porque interrumpiste", + "queuePausedRestarted": "Cola en pausa porque Orca se reinició", + "queuePausedCleared": "Cola en pausa después de que borraste la conversación", + "queuePaused": "Cola en pausa", + "resume": "Reanudar", + "sendConfirmTitle": "¿Enviar mensaje?", + "sendConfirmBody": "Estás a punto de enviar un mensaje. ¿Quieres borrar los {{count}} mensajes que había en cola?", + "sendConfirmBody_one": "Estás a punto de enviar un mensaje. ¿Quieres borrar {{count}} mensaje que había en cola?", + "sendConfirmBody_other": "Estás a punto de enviar un mensaje. ¿Quieres borrar los {{count}} mensajes que había en cola?", + "sendConfirmClear": "Vaciar cola", + "sendConfirmSend": "Enviar mensaje" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index ebf3b163b2aa..78d1e28eb95b 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17905,7 +17905,17 @@ "withdrawnHold": "Arrêté avant l'envoi", "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", "paused": "En pause", - "resume": "Reprendre" + "queuePausedStopped": "File d'attente en pause, car vous avez interrompu", + "queuePausedRestarted": "File d'attente en pause, car Orca a redémarré", + "queuePausedCleared": "File d'attente en pause après l'effacement de la conversation", + "queuePaused": "File d'attente en pause", + "resume": "Reprendre", + "sendConfirmTitle": "Envoyer le message ?", + "sendConfirmBody": "Vous êtes sur le point d'envoyer un message. Voulez-vous effacer les {{count}} messages déjà en file d'attente ?", + "sendConfirmBody_one": "Vous êtes sur le point d'envoyer un message. Voulez-vous effacer {{count}} message déjà en file d'attente ?", + "sendConfirmBody_other": "Vous êtes sur le point d'envoyer un message. Voulez-vous effacer les {{count}} messages déjà en file d'attente ?", + "sendConfirmClear": "Vider la file d'attente", + "sendConfirmSend": "Envoyer le message" }, "structuredSessionHostDeclined": "{{value0}} ouvert dans un terminal", "structuredSessionHostDeclinedDescription": "Ce serveur ne peut pas exécuter de discussion {{value0}} dans cet espace de travail.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b6891dc4d608..1434688c8974 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17842,7 +17842,17 @@ "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", "paused": "一時停止中", - "resume": "再開" + "queuePausedStopped": "中断したため、キューを一時停止しました", + "queuePausedRestarted": "Orca が再起動したため、キューを一時停止しました", + "queuePausedCleared": "会話を消去したため、キューを一時停止しました", + "queuePaused": "キューは一時停止中です", + "resume": "再開", + "sendConfirmTitle": "メッセージを送信しますか?", + "sendConfirmBody": "メッセージを送信しようとしています。キューに入っている {{count}} 件のメッセージを消去しますか?", + "sendConfirmBody_one": "メッセージを送信しようとしています。キューに入っている {{count}} 件のメッセージを消去しますか?", + "sendConfirmBody_other": "メッセージを送信しようとしています。キューに入っている {{count}} 件のメッセージを消去しますか?", + "sendConfirmClear": "キューを消去", + "sendConfirmSend": "メッセージを送信" }, "structuredSessionHostDeclined": "{{value0}} をターミナルで開きました", "structuredSessionHostDeclinedDescription": "このサーバーはこのワークスペースで {{value0}} チャットを実行できません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3c3960fe8023..91b73ef25d48 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17842,7 +17842,17 @@ "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", "paused": "일시 중지됨", - "resume": "재개" + "queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다", + "queuePausedRestarted": "Orca가 다시 시작되어 대기열이 일시 중지되었습니다", + "queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다", + "queuePaused": "대기열이 일시 중지되었습니다", + "resume": "재개", + "sendConfirmTitle": "메시지를 보낼까요?", + "sendConfirmBody": "메시지를 보내려고 합니다. 대기열에 있는 메시지 {{count}}개를 지울까요?", + "sendConfirmBody_one": "메시지를 보내려고 합니다. 대기열에 있는 메시지 {{count}}개를 지울까요?", + "sendConfirmBody_other": "메시지를 보내려고 합니다. 대기열에 있는 메시지 {{count}}개를 지울까요?", + "sendConfirmClear": "대기열 지우기", + "sendConfirmSend": "메시지 보내기" }, "structuredSessionHostDeclined": "{{value0}}을(를) 터미널에서 열었습니다", "structuredSessionHostDeclinedDescription": "이 서버는 이 워크스페이스에서 {{value0}} 채팅을 실행할 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 16bad88eba84..83cdad1e4dcd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17859,7 +17859,17 @@ "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", "paused": "已暂停", - "resume": "继续" + "queuePausedStopped": "因你中断了操作,队列已暂停", + "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", + "queuePausedCleared": "清除对话后,队列已暂停", + "queuePaused": "队列已暂停", + "resume": "继续", + "sendConfirmTitle": "发送消息?", + "sendConfirmBody": "你即将发送一条消息。是否清除之前排队的 {{count}} 条消息?", + "sendConfirmBody_one": "你即将发送一条消息。是否清除之前排队的 {{count}} 条消息?", + "sendConfirmBody_other": "你即将发送一条消息。是否清除之前排队的 {{count}} 条消息?", + "sendConfirmClear": "清空队列", + "sendConfirmSend": "发送消息" }, "structuredSessionHostDeclined": "已在终端中打开 {{value0}}", "structuredSessionHostDeclinedDescription": "此服务器无法在此工作区中运行 {{value0}} 聊天。", From 8ef6ea4f89eb946a8529d4210cbd2289a1f65ca6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:46:27 -0700 Subject: [PATCH 138/175] fix(native-chat): the paused row goes while your own message is on its way to lift it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After "Send message" over a held queue, the row kept saying "Queue paused…" until the agent accepted the new turn. The chat now reads that gap from the outbox: while this composer's direct send is recorded by the host and not yet accepted, the controller shows no paused row (and so no Resume or confirmation). A refusal settles the entry and the row comes back, since the hold did not lift. Orca's own sends never enter this outbox, and the queue's send of a card goes under a fresh id, so neither hides it. Nothing is stored. --- ...ed-agent-session-queued-card-holds.test.ts | 77 ++++++++++++++++--- ...uctured-agent-session-queued-cards.test.ts | 27 +++++++ .../structured-agent-session-queued-cards.ts | 16 ++++ ...ent-session-queued-messages.clear.test.tsx | 14 +++- ...tructured-agent-session-queued-messages.ts | 8 +- ...tured-agent-session.queued-gating.test.tsx | 49 +++++++++++- .../use-structured-agent-session.ts | 6 +- 7 files changed, 180 insertions(+), 17 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index 65c1674c2889..3aae992262bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -12,6 +12,11 @@ import type { } from '../../../shared/agent-session-wire' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { + reconcileStructuredAgentSessionOutbox, + type StructuredAgentSessionOutboxEntry +} from '../../../shared/structured-agent-session-outbox' +import { + ownDirectSendOnItsWay, projectQueuedMessageCards, queuedMessageCardSteers, queuedMessagesQueuePause, @@ -81,8 +86,31 @@ type ClientView = { nextQueuedMessageId: string | null } +/** This client's composer sends, settled against the journal as the outbox settles them. */ +type ComposerOutbox = { entries: StructuredAgentSessionOutboxEntry[] } + +/** A composer message on its way, as the outbox holds it once the request went out. */ +function composerSend( + outbox: ComposerOutbox, + text: string +): ReturnType { + const sent = rig.send(text, 'queue-if-active') + outbox.entries.push({ + clientMessageId: sent.id, + sessionId: HOST_TEST_SESSION, + body: hostTestMessage(text), + previewUris: [], + state: 'dispatching', + queuedAt: 1, + lastAttemptAt: 1, + retryAfterUnknownSubmittedAt: null, + sentDelivery: 'queue-if-active' + }) + return sent +} + /** Folds every published update as the chat does, one at a time, into what it shows. */ -async function watchClient(): Promise { +async function watchClient(outbox: ComposerOutbox = { entries: [] }): Promise { const views: ClientView[] = [] const submissions = new Map() const turns = new Map() @@ -117,6 +145,7 @@ async function watchClient(): Promise { const working = hostWorking || (nextQueuedMessageId !== null && !hostWorking) const cards = projectQueuedMessageCards(queued, all, { hasPendingPrompt: false, queuePause }) const queueHeld = queuedMessagesResumable(cards, working) + outbox.entries = reconcileStructuredAgentSessionOutbox(outbox.entries, all) views.push({ working, stopLive: hostWorking, @@ -125,7 +154,8 @@ async function watchClient(): Promise { composerEmpty: true, queueHeld }), - header: queuedMessagesQueuePause(cards) !== null, + header: + !ownDirectSendOnItsWay(outbox.entries, all) && queuedMessagesQueuePause(cards) !== null, steers: cards.map((card) => queuedMessageCardSteers(card)), cards: cards.length, nextQueuedMessageId @@ -193,36 +223,63 @@ describe('which pause holds each card', () => { }) describe("a message sent over a held queue (the confirmation's Send message)", () => { - it("goes out at once as the person's turn; the held cards stay, the header goes once that turn starts, and they follow in order", async () => { + /** Two cards a Stop holds, the stopped turn over: the paused row and Resume show. */ + async function heldIdleQueue(outbox: ComposerOutbox) { const working = await rig.workingSend() const first = await queuedDraft('first') const second = await queuedDraft('second') await rig.stop() await rig.settleAccepted(working, 'stopped') - const views = await watchClient() + const views = await watchClient(outbox) expect(views.at(-1)).toMatchObject({ header: true, button: 'resume', cards: 2 }) + return { first, second, views } + } + + it("goes out at once as the person's turn: the paused row goes as it is sent and never comes back, and the held cards follow it in order", async () => { + const outbox: ComposerOutbox = { entries: [] } + const { first, second, views } = await heldIdleQueue(outbox) // Sent with the composer's queue delivery: no card ahead may send, so nothing queues it. - const message = rig.send('a new instruction', 'queue-if-active') + const message = composerSend(outbox, 'a new instruction') + const sentAt = views.length expect(await message.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + // The cards stay held until the agent accepts the message's turn. expect(await heldBy()).toEqual({ [first]: { reason: 'stopped' }, [second]: { reason: 'stopped' } }) + await eventually(async () => + expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() + ) + await eventually(() => expect(views.at(-1)?.stopLive).toBe(true)) + const acceptedAt = views.length await rig.settleAccepted(message.id, 'message') await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) expect(await rig.handoff(second)).toBeUndefined() await rig.settleAccepted(await rig.handoffId(first), 'first') await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) - // The header goes while both cards still wait: the message's turn lifted the hold. - const lifted = views.findIndex((view) => !view.header) - expect(views[lifted]?.cards).toBe(2) - // Once lifted it stays lifted, and no card ever reads Send. - expect(views.slice(lifted).filter((view) => view.header)).toEqual([]) + // Updates arrived while the message waited for the agent, with both cards still held. + const waiting = views.slice(sentAt, acceptedAt) + expect(waiting.some((view) => view.stopLive && view.cards === 2)).toBe(true) + expect(views.slice(sentAt).filter((view) => view.header)).toEqual([]) expect(views.flatMap((view) => view.steers)).not.toContain(false) }) + + it('a send the agent refuses lifts nothing: the paused row comes back over the held cards', async () => { + const outbox: ComposerOutbox = { entries: [] } + const { first, views } = await heldIdleQueue(outbox) + const message = composerSend(outbox, 'refused') + await message.result + await eventually(async () => + expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() + ) + await eventually(() => expect(views.at(-1)?.header).toBe(false)) + await rig.settleRejected(message.id, 'turn/start refused') + await eventually(() => expect(views.at(-1)).toMatchObject({ header: true, cards: 2 })) + expect(await rig.handoff(first)).toBeUndefined() + }) }) describe("the queue's next card on a history page", () => { 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 9c60e50837ad..36562dbd0f98 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 @@ -9,6 +9,7 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/struc import { newestSteerableQueuedMessageCard, outboxOutsideQueuedCards, + ownDirectSendOnItsWay, projectQueuedMessageCards, queuedMessageCardSteers, queuedMessagesQueuePause, @@ -227,6 +228,32 @@ describe('queued message cards', () => { ).toBeNull() }) + it("only the person's own direct send, recorded and not yet accepted, is on its way to lift a pause", () => { + const entry = ( + clientMessageId: string, + state: StructuredAgentSessionOutboxEntry['state'] = 'dispatching' + ): StructuredAgentSessionOutboxEntry => ({ + clientMessageId, + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + previewUris: [], + state, + queuedAt: 1, + lastAttemptAt: 1, + retryAfterUnknownSubmittedAt: null + }) + expect(ownDirectSendOnItsWay([entry('mine')], [submission('mine')])).toBe(true) + // Not yet recorded: it may still become a card. + expect(ownDirectSendOnItsWay([entry('mine')], [])).toBe(false) + // Orca's own send never enters this outbox. + expect(ownDirectSendOnItsWay([], [submission('orca')])).toBe(false) + // The queue's send of a card goes under a fresh id, even for a card this composer queued. + expect(ownDirectSendOnItsWay([entry('card')], [handOff('card')])).toBe(false) + expect(ownDirectSendOnItsWay([entry('mine', 'rejected')], [submission('mine')])).toBe(false) + expect(ownDirectSendOnItsWay([entry('mine')], [submission('mine', 'rejected')])).toBe(false) + expect(ownDirectSendOnItsWay([entry('mine')], [submission('mine', 'unknown')])).toBe(false) + }) + it('steers the newest card', () => { const cards = projectQueuedMessageCards([draft('a', 1), draft('b', 2)], [], IDLE) expect(newestSteerableQueuedMessageCard(cards)?.messageId).toBe('b') 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 ff08241d3602..1307286fb7b2 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 @@ -108,6 +108,22 @@ export function queuedMessagesQueuePause( return cards.find((card) => card.hold === 'queue-paused')?.queuePause ?? null } +/** The person's own message from this composer has reached the host as a direct send and waits + * for the agent to accept it. Its turn will lift the queue's pause, so the header row does not + * say paused meanwhile; a refusal settles the entry and the row comes back. An Orca send never + * enters this outbox, and the queue's send of a card goes under a fresh id. */ +export function ownDirectSendOnItsWay( + outbox: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] +): boolean { + const pending = new Set( + submissions + .filter((submission) => submission.dispatchState === 'pending') + .map((submission) => submission.clientMessageId) + ) + return outbox.some((entry) => entry.state !== 'rejected' && pending.has(entry.clientMessageId)) +} + /** Whether the composer's Resume would release anything: nothing runs, and a pause holds a card. * `isWorking` counts the queue's coming send, which the host names. */ export function queuedMessagesResumable( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx index eff432dd7a12..8549d1b7862a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx @@ -32,6 +32,7 @@ function renderController( options: { queuePause?: AgentSessionQueuePause | null enabled?: boolean + ownSendOnItsWay?: boolean answer?: (messageId: string) => DeleteAnswer } = {} ) { @@ -47,7 +48,8 @@ function renderController( queuePause: options.queuePause === undefined ? STOPPED : options.queuePause, submissions: [], hasPendingPrompt: false, - isWorking: false, + isWorking: options.ownSendOnItsWay === true, + ownSendOnItsWay: options.ownSendOnItsWay === true, composerScopeKey: undefined, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller only awaits mutate; the stub answers Delete's shape. mutate: mutate as unknown as StructuredAgentSessionMutate @@ -82,6 +84,16 @@ describe('the held queue a new message asks about', () => { expect(renderController(held, { enabled: false }).result.current.queueHold).toBeUndefined() }) + it("goes, with the paused row, while the person's own message is on its way to lift the pause", () => { + const held = [card('held', 1, { heldBy: STOPPED })] + const { result } = renderController(held, { ownSendOnItsWay: true }) + expect(result.current.pause).toBeNull() + expect(result.current.queueHold).toBeUndefined() + expect(result.current.queueResume).toBeUndefined() + // The held card is unchanged: only the row says nothing while the turn is on its way. + expect(result.current.cards.map((entry) => entry.hold)).toEqual(['queue-paused']) + }) + it('Clear queue deletes every card shown, and answers true once all are gone', async () => { const { result, mutate } = renderController([ card('held', 1, { heldBy: STOPPED }), diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 16b1bfd7a8b3..a64baf63d96a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -72,6 +72,8 @@ export function useStructuredAgentSessionQueuedMessages(args: { hasPendingPrompt: boolean /** A turn is running, whoever started it, or the queue is about to send its next card. */ isWorking: boolean + /** The person's own message is on its way to start the turn that lifts the pause. */ + ownSendOnItsWay?: boolean composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { @@ -208,7 +210,11 @@ export function useStructuredAgentSessionQueuedMessages(args: { [resumable, resume, resuming] ) - const pause = useMemo(() => queuedMessagesQueuePause(cards), [cards]) + const ownSendOnItsWay = args.ownSendOnItsWay === true + const pause = useMemo( + () => (ownSendOnItsWay ? null : queuedMessagesQueuePause(cards)), + [cards, ownSendOnItsWay] + ) // Every card shown, held or not: Clear queue empties the list the person sees. const clear = useCallback(async (): Promise => { const removed = await Promise.all(cardsRef.current.map((card) => removeCard(card.messageId))) 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 c373afe6f31e..09d3b1e2d915 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 @@ -9,8 +9,14 @@ 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' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause +} from '../../../../shared/agent-session-wire' import { createStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry @@ -24,6 +30,8 @@ const mocks = vi.hoisted(() => ({ let items: AgentJournalRenderItem[] = [] let queuedMessages: AgentSessionQueuedMessage[] | undefined let nextQueuedMessageId: string | null = null +let queuePause: AgentSessionQueuePause | null = null +let submissions: AgentJournalSubmission[] = [] let outboxEntries: StructuredAgentSessionOutboxEntry[] = [] vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -36,11 +44,11 @@ vi.mock('./use-structured-agent-session-read', () => ({ state: { fence: 3, items, - submissions: [], + submissions, status: 'ready', error: null, hasOlder: false, - ...(queuedMessages !== undefined ? { queuedMessages, nextQueuedMessageId } : {}) + ...(queuedMessages !== undefined ? { queuedMessages, nextQueuedMessageId, queuePause } : {}) }, loadingOlder: false, loadOlder: vi.fn() @@ -129,6 +137,8 @@ beforeEach(() => { items = [RUNNING_TURN] queuedMessages = undefined nextQueuedMessageId = null + queuePause = null + submissions = [] outboxEntries = [] localStorage.clear() clearNativeChatDraftCacheForTests() @@ -288,6 +298,37 @@ describe('against a capable host', () => { expect(refused.result.current).toMatchObject({ isWorking: false, queueSendsNext: false }) }) + it("hides the paused row while the person's own message is on its way, and shows it again once that send is refused", () => { + items = [] + queuePause = { reason: 'stopped' } + queuedMessages = [{ ...draft('held'), heldBy: queuePause }] + const held = render() + expect(held.result.current.queuedMessages.pause).toEqual({ reason: 'stopped' }) + const sent = createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'mine', + sessionId: 'session-1', + text: 'new', + attachments: [], + queuedAt: 1 + }) + const recorded: AgentJournalSubmission = { + clientMessageId: 'mine', + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } + outboxEntries = [{ ...sent, state: 'dispatching', lastAttemptAt: 1 }] + submissions = [recorded] + expect(render().result.current.queuedMessages.pause).toBeNull() + outboxEntries = [{ ...sent, state: 'rejected', lastAttemptAt: 1 }] + submissions = [{ ...recorded, dispatchState: 'rejected', resolvedAt: 2 }] + expect(render().result.current.queuedMessages.pause).toEqual({ reason: 'stopped' }) + }) + 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 3ebf0494809c..8a95f270986a 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 @@ -35,7 +35,10 @@ import { useStructuredAgentSessionThreadGoal } from './use-structured-agent-sess import { useStructuredAgentSessionContextUsage } from './use-structured-agent-session-context-usage' import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline' import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' -import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' +import { + outboxOutsideQueuedCards, + ownDirectSendOnItsWay +} from './structured-agent-session-queued-cards' import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' @@ -176,6 +179,7 @@ export function useStructuredAgentSession(args: { submissions: transportState.submissions, hasPendingPrompt: prompts.length > 0, isWorking, + ownSendOnItsWay: ownDirectSendOnItsWay(outbox, transportState.submissions), composerScopeKey, mutate }) From dfe173825bec15c1c1e6bc264b44a3b02d969665 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:58:27 -0700 Subject: [PATCH 139/175] fix(native-chat): a "Send message?" choice is taken once, and a failed Clear queue is one toast The closing dialog stays mounted and clickable through its exit animation, and a double-click or a held Enter lands twice before any re-render, so Send message (or Clear queue) could send the captured message twice. The pending send now lives in a ref that the first choice takes; a second one finds nothing. Clear queue deletes one card at a time and stops at the first failure, so a failed press shows one toast instead of one per card. The dialog keeps its compact width at desktop sizes and the primitive's narrow-window gutter (`max-w-sm sm:max-w-sm`, as the other compact confirmations). --- .../NativeChatQueueSendConfirmDialog.tsx | 2 +- .../native-chat-queue-send-confirm.test.tsx | 66 +++++++++++++++++++ ...se-native-chat-held-queue-composer-send.ts | 35 ++++++---- ...nt-session-queued-messages.resume.test.tsx | 15 +++++ ...tructured-agent-session-queued-messages.ts | 15 +++-- 5 files changed, 116 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatQueueSendConfirmDialog.tsx b/src/renderer/src/components/native-chat/NativeChatQueueSendConfirmDialog.tsx index 11ad56ff6508..b2cdc032f785 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueueSendConfirmDialog.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueueSendConfirmDialog.tsx @@ -32,7 +32,7 @@ export function NativeChatQueueSendConfirmDialog({ }} > { event.preventDefault() diff --git a/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx b/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx index 1262cb878a39..e3c99ff5ce5a 100644 --- a/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx @@ -230,6 +230,72 @@ describe('sending while the queue is held', () => { await waitFor(() => expect(input.contains(document.activeElement)).toBe(true)) }) + describe('a choice is taken once', () => { + /** The primitive's exit animation, which keeps the closing dialog mounted and clickable. */ + function withExitAnimation(): () => void { + const style = document.createElement('style') + style.textContent = + '[role="dialog"][data-state="closed"] { animation-name: exit; animation-duration: 200ms; }' + document.head.appendChild(style) + return () => style.remove() + } + + it('Send message pressed again while the dialog closes sends nothing more', async () => { + const removeAnimation = withExitAnimation() + try { + const structured = transport(heldQueue(1)) + const input = renderComposer(structured) + changePrompt(input, 'once only') + await act(async () => pressEnter(input)) + const sendButton = await screen.findByRole('button', { name: 'Send message' }) + await act(async () => fireEvent.click(sendButton)) + expect(document.body.contains(sendButton)).toBe(true) + await act(async () => fireEvent.click(sendButton)) + await act(async () => {}) + expect(structured.send).toHaveBeenCalledTimes(1) + } finally { + removeAnimation() + } + }) + + it('a double-click, or a held Enter, before any re-render sends once', async () => { + const structured = transport(heldQueue(1)) + const input = renderComposer(structured) + changePrompt(input, 'once only') + await act(async () => pressEnter(input)) + const sendButton = await screen.findByRole('button', { name: 'Send message' }) + await act(async () => { + sendButton.click() + sendButton.click() + }) + await act(async () => {}) + expect(structured.send).toHaveBeenCalledTimes(1) + }) + + it('Clear queue pressed twice clears once and sends once', async () => { + const removeAnimation = withExitAnimation() + try { + const hold = heldQueue(2) + const structured = transport(hold) + const input = renderComposer(structured) + changePrompt(input, 'start over') + await act(async () => pressEnter(input)) + const clearButton = await screen.findByRole('button', { name: 'Clear queue' }) + await act(async () => { + clearButton.click() + clearButton.click() + }) + await act(async () => fireEvent.click(clearButton)) + await waitFor(() => expect(structured.send).toHaveBeenCalledTimes(1)) + await act(async () => {}) + expect(hold.clear).toHaveBeenCalledTimes(1) + expect(structured.send).toHaveBeenCalledTimes(1) + } finally { + removeAnimation() + } + }) + }) + it('does not ask when the queue is not held, nor for a command the host runs itself', async () => { const free = transport() const input = renderComposer(free) diff --git a/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts index f66621c81fda..615ff96b300c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts @@ -43,6 +43,15 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured const queueHold = structuredTransport?.queueHold const [pending, setPending] = useState(null) const [open, setOpen] = useState(false) + // The send a choice may still take, taken once: the closing dialog stays clickable for its exit + // animation, and a double-click or a held Enter lands twice before any re-render. + const untakenRef = useRef(null) + const take = useCallback((): PendingSend | null => { + const taken = untakenRef.current + untakenRef.current = null + setOpen(false) + return taken + }, []) // Clear queue sends once its deletes settle, through the send of that render. const sendNowRef = useRef(sendNow) useLayoutEffect(() => { @@ -56,7 +65,9 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured structuredTransport && !isNativeChatStructuredHostCommand(text, agent, structuredTransport) ) { - setPending({ text, attachments, count: queueHold.count, clear: queueHold.clear }) + const asked = { text, attachments, count: queueHold.count, clear: queueHold.clear } + untakenRef.current = asked + setPending(asked) setOpen(true) return } @@ -66,23 +77,25 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured ) const sendMessage = useCallback(() => { - setOpen(false) - if (pending) { - sendNowRef.current(pending.text, pending.attachments) + const taken = take() + if (taken) { + sendNowRef.current(taken.text, taken.attachments) } - }, [pending]) + }, [take]) const clearQueue = useCallback(() => { - setOpen(false) - if (!pending) { + const taken = take() + if (!taken) { return } - void pending.clear().then((cleared) => { + void taken.clear().then((cleared) => { if (cleared) { - sendNowRef.current(pending.text, pending.attachments) + sendNowRef.current(taken.text, taken.attachments) } }) - }, [pending]) - const dismiss = useCallback(() => setOpen(false), []) + }, [take]) + const dismiss = useCallback(() => { + take() + }, [take]) const confirm = pending ? { open, count: pending.count, clearQueue, sendMessage, dismiss } : null return { send, confirm } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx index 068e0eb8ef69..87aa65a665e4 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -169,6 +169,21 @@ describe("the header row's pause", () => { }) }) +describe('Clear queue before a new message', () => { + it('one failed press is one toast, however many cards it held', async () => { + mocks.call.mockRejectedValue(new Error('socket closed')) + const { result } = renderController({ + queuedMessages: [card('a'), card('b', { position: 2 }), card('c', { position: 3 })] + }) + let cleared: boolean | undefined + await act(async () => { + cleared = await result.current.queueHold?.clear() + }) + expect(cleared).toBe(false) + expect(mocks.toastError).toHaveBeenCalledTimes(1) + }) +}) + describe('Resume on a held queue', () => { it('calls queuedMessagesResume with only an envelope, and says nothing when it lands', async () => { mocks.call.mockResolvedValue(RESUMED) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index a64baf63d96a..c72e9b6d2690 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -215,11 +215,16 @@ export function useStructuredAgentSessionQueuedMessages(args: { () => (ownSendOnItsWay ? null : queuedMessagesQueuePause(cards)), [cards, ownSendOnItsWay] ) - // Every card shown, held or not: Clear queue empties the list the person sees. - const clear = useCallback(async (): Promise => { - const removed = await Promise.all(cardsRef.current.map((card) => removeCard(card.messageId))) - return removed.every(Boolean) - }, [removeCard]) + // Every card shown, held or not: Clear queue empties the list the person sees. One at a time, + // stopping at the first failure, so one failed press is one toast. + const clear = useCallback( + (): Promise => + cardsRef.current.reduce>( + (previous, card) => previous.then((removed) => removed && removeCard(card.messageId)), + Promise.resolve(true) + ), + [removeCard] + ) const count = cards.length const held = enabled && pause !== null const queueHold = useMemo(() => (held ? { count, clear } : undefined), [held, count, clear]) From 818ec9acdc556c54d19fd0602b37323ecc87bd2d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:24:34 -0700 Subject: [PATCH 140/175] fix(native-chat): Clear queue's message goes out once, and keeps text typed while it waits After Clear queue, the message waited in the composer while the cards were deleted one by one. A second Enter in that window sent it again, and text typed meanwhile was wiped when the chained send was accepted. From the Clear queue choice until its message has gone out, the composer's structured send does nothing. The chained send (and Send message's) now carries the composition it was taken from, and the composer is cleared on acceptance only if it still holds exactly that, as host commands already do. Also: the v1 contract comment names `nextQueuedMessageId` and its absent- means-null fallback, and the own-send check returns at once on an empty outbox. --- .../native-chat-queue-send-confirm.test.tsx | 47 +++++++++++++++++++ .../structured-agent-session-queued-cards.ts | 3 ++ ...se-native-chat-held-queue-composer-send.ts | 45 ++++++++++++++---- ...se-native-chat-structured-composer-send.ts | 31 ++++++++---- src/shared/protocol-version.ts | 6 ++- 5 files changed, 111 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx b/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx index e3c99ff5ce5a..6a820c915dd9 100644 --- a/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-queue-send-confirm.test.tsx @@ -296,6 +296,53 @@ describe('sending while the queue is held', () => { }) }) + describe("while Clear queue's deletes run", () => { + async function clearingQueue(text: string) { + const deleted = Promise.withResolvers() + const hold = heldQueue(2) + hold.clear.mockReturnValue(deleted.promise) + const structured = transport(hold) + const input = renderComposer(structured) + changePrompt(input, text) + await act(async () => pressEnter(input)) + await act(async () => + fireEvent.click(await screen.findByRole('button', { name: 'Clear queue' })) + ) + return { deleted, structured, input } + } + + it('sending again sends nothing: the message goes out once, after the deletes', async () => { + const { deleted, structured, input } = await clearingQueue('start over') + // The draft still holds the message. + expect(promptValue(input)).toBe('start over') + await act(async () => pressEnter(input)) + await act(async () => fireEvent.click(screen.getByTestId('composer-send'))) + await act(async () => {}) + expect(screen.queryByRole('dialog')).toBeNull() + expect(structured.send).not.toHaveBeenCalled() + await act(async () => deleted.resolve(true)) + await waitFor(() => expect(structured.send).toHaveBeenCalledWith('start over', [])) + await act(async () => {}) + expect(structured.send).toHaveBeenCalledTimes(1) + }) + + it('text typed meanwhile stays in the composer once the message is accepted', async () => { + const { deleted, structured, input } = await clearingQueue('start over') + changePrompt(input, 'start over, and also check the tests') + await act(async () => deleted.resolve(true)) + await waitFor(() => expect(structured.send).toHaveBeenCalledWith('start over', [])) + await act(async () => {}) + expect(promptValue(input)).toBe('start over, and also check the tests') + }) + + it('an unchanged draft is cleared once the message is accepted', async () => { + const { deleted, structured, input } = await clearingQueue('start over') + await act(async () => deleted.resolve(true)) + await waitFor(() => expect(structured.send).toHaveBeenCalledWith('start over', [])) + await waitFor(() => expect(promptValue(input)).toBe('')) + }) + }) + it('does not ask when the queue is not held, nor for a command the host runs itself', async () => { const free = transport() const input = renderComposer(free) 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 1307286fb7b2..18d8aa6ac2ba 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 @@ -116,6 +116,9 @@ export function ownDirectSendOnItsWay( outbox: readonly StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[] ): boolean { + if (outbox.length === 0) { + return false + } const pending = new Set( submissions .filter((submission) => submission.dispatchState === 'pending') diff --git a/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts index 615ff96b300c..b53fec950a70 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts @@ -5,6 +5,8 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react' import { isNativeChatStructuredHostCommand, useNativeChatStructuredComposerSend, + type NativeChatComposerComposition, + type NativeChatStructuredComposerSend, type UseNativeChatStructuredComposerSendArgs } from './use-native-chat-structured-composer-send' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' @@ -32,6 +34,8 @@ type PendingSend = { attachments: readonly NativeChatComposerImageAttachment[] | undefined count: number clear: () => Promise + /** The composer as it was when the message was asked for. */ + sentFrom: NativeChatComposerComposition } export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructuredComposerSendArgs): { @@ -53,25 +57,42 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured return taken }, []) // Clear queue sends once its deletes settle, through the send of that render. - const sendNowRef = useRef(sendNow) + const sendNowRef = useRef(sendNow) + const compositionRef = useRef({ + draft: args.draft, + imageAttachments: args.imageAttachments + }) useLayoutEffect(() => { sendNowRef.current = sendNow - }, [sendNow]) + compositionRef.current = { draft: args.draft, imageAttachments: args.imageAttachments } + }, [args.draft, args.imageAttachments, sendNow]) + // From a Clear queue choice until its message has gone out: the draft still holds that message, + // and sending it again meanwhile would send it twice. + const clearingRef = useRef(false) const send = useCallback( (text, attachments) => { + if (clearingRef.current) { + return + } if ( queueHold && structuredTransport && !isNativeChatStructuredHostCommand(text, agent, structuredTransport) ) { - const asked = { text, attachments, count: queueHold.count, clear: queueHold.clear } + const asked = { + text, + attachments, + count: queueHold.count, + clear: queueHold.clear, + sentFrom: compositionRef.current + } untakenRef.current = asked setPending(asked) setOpen(true) return } - sendNow(text, attachments) + void sendNow(text, attachments) }, [agent, queueHold, sendNow, structuredTransport] ) @@ -79,7 +100,7 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured const sendMessage = useCallback(() => { const taken = take() if (taken) { - sendNowRef.current(taken.text, taken.attachments) + void sendNowRef.current(taken.text, taken.attachments, taken.sentFrom) } }, [take]) const clearQueue = useCallback(() => { @@ -87,11 +108,17 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured if (!taken) { return } - void taken.clear().then((cleared) => { - if (cleared) { - sendNowRef.current(taken.text, taken.attachments) + clearingRef.current = true + void (async () => { + try { + if (await taken.clear()) { + // Text typed during the deletes stays: the message was taken from what came before. + await sendNowRef.current(taken.text, taken.attachments, taken.sentFrom) + } + } finally { + clearingRef.current = false } - }) + })() }, [take]) const dismiss = useCallback(() => { take() diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index f01023fb4958..ca5942fd6153 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -35,6 +35,20 @@ export function isNativeChatStructuredHostCommand( ) } +/** What the composer held when a send was asked for. */ +export type NativeChatComposerComposition = { + draft: string | undefined + imageAttachments: readonly NativeChatComposerImageAttachment[] +} + +/** A structured send. `sentFrom`: the composition the message was taken from, for a send that + * goes out later than it was asked for; the composer is then cleared only if still unchanged. */ +export type NativeChatStructuredComposerSend = ( + text: string, + attachments?: readonly NativeChatComposerImageAttachment[], + sentFrom?: NativeChatComposerComposition +) => Promise + /** Send through the structured journal transport, clearing the composer only * once the transport accepts (the PTY path has its own sibling hook). */ export function useNativeChatStructuredComposerSend({ @@ -47,16 +61,13 @@ export function useNativeChatStructuredComposerSend({ setHistory, setDraft, setCaret -}: UseNativeChatStructuredComposerSendArgs): ( - text: string, - attachments?: readonly NativeChatComposerImageAttachment[] -) => void { - const composition = useRef({ draft, imageAttachments }) +}: UseNativeChatStructuredComposerSendArgs): NativeChatStructuredComposerSend { + const composition = useRef({ draft, imageAttachments }) useLayoutEffect(() => { composition.current = { draft, imageAttachments } }, [draft, imageAttachments]) - return useCallback( - (text: string, attachments = imageAttachments): void => { + return useCallback( + async (text, attachments = imageAttachments, sentFrom): Promise => { if (!structuredTransport) { return } @@ -65,8 +76,8 @@ export function useNativeChatStructuredComposerSend({ structuredTransport.onError('Remove attachments before using a chat-session command.') return } - const submitted = composition.current - void dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) + const submitted = sentFrom ?? composition.current + await dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) .then(({ accepted, error }) => { structuredTransport.onError(error) if (!accepted) { @@ -82,7 +93,7 @@ export function useNativeChatStructuredComposerSend({ ) setHistory((previous) => pushHistory(previous, text)) if ( - hostCommand && + (hostCommand || sentFrom !== undefined) && (composition.current.draft !== submitted.draft || composition.current.imageAttachments !== submitted.imageAttachments) ) { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 4305f340d029..7ccde8122752 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -218,8 +218,10 @@ export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = // never compares a draft id with a submission id. It also publishes the queue's pause once, as // `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or the user's next // turn. Each card may carry `heldBy`, the pause holding it or null; when it is absent (an older -// host) a client falls back to `queuePause` for every card. A card's own hold (`paused`) means only -// that its conversion failed. The host mechanism lands first; the constant gates the rollout. +// host) a client falls back to `queuePause` for every card. `nextQueuedMessageId` beside the list +// names the card the queue sends next once nothing runs, null while anything holds it; absent (an +// older host) reads as null. A card's own hold (`paused`) means only that its conversion failed. +// The host mechanism lands first; the constant gates the rollout. export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = 'agent-session.queued-messages.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific 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 141/175] 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 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 142/175] 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 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 143/175] 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 144/175] 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 145/175] 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 af021dbea3e04faf049c194dc3e87162e4c65060 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:50:58 -0700 Subject: [PATCH 146/175] fix(native-chat): the queue carries on after any turn, in order, and a restart sends nothing by itself - Any accepted turn ends a Stop's or a /clear's pause, whoever sent it (a person, Orca's own messages, or the queue), and so does Resume. The card and submission author fields that only fed the old person-only rule are gone. - The queue sends strictly in order: a card never overtakes a held one. - After a restart nothing sends by itself and no paused row shows: the chat's next turn (the carry-on, or the person's own message) runs first, then the cards. - Resume and "Send message?" are offered only while nothing runs and no prompt waits. --- .../MobileNativeChatQueuedMessages.test.tsx | 3 +- ...le-structured-queued-message-cards.test.ts | 3 - .../mobile-structured-queued-message-cards.ts | 1 - .../journal-dispatch-reducer.ts | 4 +- .../journal-queued-messages.ts | 13 +- .../agent-session-journal/journal-reducer.ts | 8 +- .../journal-row-builders.ts | 5 +- .../journal-row-schema.ts | 8 +- .../journal-store-collaborators.ts | 2 +- .../journal-store-contracts.ts | 2 - .../journal-submission-fold.ts | 17 +- .../journal-submission-queued-link.test.ts | 6 +- ...queued-message-bookkeeping-failure.test.ts | 6 +- .../queued-message-delivered-echo.test.ts | 3 +- .../queued-message-pause.test.ts | 91 ++---- .../queued-message-pause.ts | 95 +++---- .../queued-message-schema.ts | 4 +- .../queued-message-store.test.ts | 64 +---- .../queued-message-table.ts | 29 +- ...d-agent-session-claude-queued-stop.test.ts | 11 +- ...t-session-claude-stop-ends-session.test.ts | 3 +- ...-session-claude-unproven-stop-send.test.ts | 3 +- ...tured-agent-session-codex-stop-row.test.ts | 3 +- ...structured-agent-session-host-mutations.ts | 6 - ...structured-agent-session-mutation-plans.ts | 4 - ...d-agent-session-newer-content-host.test.ts | 3 +- ...d-agent-session-queued-card-author.test.ts | 105 ------- ...ed-agent-session-queued-card-holds.test.ts | 266 ++++++++++++++---- ...tured-agent-session-queued-command.test.ts | 3 +- ...ructured-agent-session-queued-gate.test.ts | 12 +- ...session-queued-message-rig.test-fixture.ts | 8 +- ...ured-agent-session-queued-messages.test.ts | 33 +-- ...tructured-agent-session-queued-messages.ts | 12 +- ...ructured-agent-session-queued-mutations.ts | 5 +- ...ed-agent-session-queued-pause-lift.test.ts | 82 +++--- .../structured-agent-session-queued-pause.ts | 3 +- ...ctured-agent-session-queued-publication.ts | 49 +--- .../structured-agent-session-queued-send.ts | 6 +- ...ured-agent-session-queued-stop-row.test.ts | 101 +++---- .../structured-agent-session-queued-stop.ts | 4 +- .../structured-agent-session-queued-wiring.ts | 2 +- ...ssion-restart-interruption-test-harness.ts | 10 +- ...ent-session-restore-without-import.test.ts | 3 +- ...d-agent-session-stop-event-binding.test.ts | 10 +- ...d-agent-session-stop-event-entries.test.ts | 6 +- ...d-agent-session-stop-event-restart.test.ts | 2 +- .../structured-agent-session-turns.ts | 2 - ...d-agent-session-send-compatibility.test.ts | 6 - ...ctured-agent-session-send-compatibility.ts | 3 +- ...-session-codex-turn-end-settlement.test.ts | 10 +- .../NativeChatQueuedMessageList.test.tsx | 21 +- .../NativeChatQueuedMessageList.tsx | 5 - ...uctured-agent-session-queued-cards.test.ts | 88 ++---- .../structured-agent-session-queued-cards.ts | 38 +-- ...se-native-chat-held-queue-composer-send.ts | 2 +- ...ent-session-queued-messages.clear.test.tsx | 40 +-- ...nt-session-queued-messages.resume.test.tsx | 34 ++- ...tructured-agent-session-queued-messages.ts | 31 +- ...tured-agent-session.queued-gating.test.tsx | 2 +- 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/agent-session-journal-types.ts | 3 - .../agent-session-queued-message-wire.ts | 13 +- src/shared/protocol-version.ts | 5 +- ...structured-agent-session-coalescer.test.ts | 4 +- 69 files changed, 610 insertions(+), 822 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.ts diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index b2929f1a1a57..c44f34f365d6 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -262,7 +262,6 @@ describe('MobileNativeChatQueuedMessages', () => { it('heads the box with why the queue is paused, for each reason', async () => { const rows: readonly [AgentSessionQueuePause['reason'], string][] = [ ['stopped', 'Queue paused because you interrupted'], - ['restarted', 'Queue paused because Orca restarted'], ['cleared', 'Queue paused after you cleared the conversation'] ] for (const [reason, label] of rows) { @@ -298,7 +297,7 @@ describe('MobileNativeChatQueuedMessages', () => { it('Resume asks the host to lift the pause once, however fast it is tapped twice', async () => { let answer: (resumed: boolean) => void = () => undefined const onResume = vi.fn(() => new Promise((resolve) => (answer = resolve))) - const mounted = await mountPaused({ pause: { reason: 'restarted' }, onResume }) + const mounted = await mountPaused({ pause: { reason: 'stopped' }, onResume }) // Both taps land in one frame, before the disabled state can render. await act(async () => { resumeButton(mounted).props.onPress() 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..9f512a9b3311 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -105,9 +105,6 @@ describe('mobileQueuedMessageCards', () => { expect(mobileQueuePauseLabel({ reason: 'stopped' })).toBe( 'Queue paused because you interrupted' ) - expect(mobileQueuePauseLabel({ reason: 'restarted' })).toBe( - 'Queue paused because Orca restarted' - ) expect(mobileQueuePauseLabel({ reason: 'cleared' })).toBe( 'Queue paused after you cleared the conversation' ) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index f3edb13e53c8..f620f3f2f0ea 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -59,7 +59,6 @@ function pausedCaption(reason: string | undefined): string { const QUEUE_PAUSE_LABELS: Readonly> = { stopped: 'Queue paused because you interrupted', - restarted: 'Queue paused because Orca restarted', cleared: 'Queue paused after you cleared the conversation' } 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..218552fe0210 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 @@ -9,7 +9,7 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal import { journalDispatchRowApplies } from './journal-dispatch-settlement' import type { JournalReducerState } from './journal-reducer' import { - notePersonTurnAccepted, + noteTurnAccepted, placeHandedOverMessage, placeRejectedMessage } from './journal-submission-fold' @@ -47,7 +47,7 @@ export function applyJournalDispatchRow( delete submission.recovered } if (row.state === 'accepted') { - notePersonTurnAccepted(state, submission) + noteTurnAccepted(state, submission) } if (row.state !== 'accepted' || !row.providerItemId) { return 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 8f9a6b46eacf..f2faa8d758be 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 @@ -12,7 +12,7 @@ import { } from '../../../shared/agent-session-host-authority' import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' -import type { JournalRow, JournalSubmissionOrigin } from './journal-row-schema' +import type { JournalRow } from './journal-row-schema' import type { JournalOperationReceipt, JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' @@ -110,7 +110,6 @@ export class JournalQueuedMessages { fingerprint: string hostInstance: string carriedFrom?: string - origin: JournalSubmissionOrigin }, receipt?: JournalOperationReceipt ): Promise { @@ -158,7 +157,7 @@ export class JournalQueuedMessages { /** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ userStopInForce(): JournalQueuePauseMarks['latestStop'] { const state = this.deps.state() - return journalUserStopInForce(state.queuePauseMarks, state.latestPersonTurnSequence) + return journalUserStopInForce(state.queuePauseMarks, state.latestAcceptedTurnSequence) } private derivePauses( @@ -169,16 +168,16 @@ export class JournalQueuedMessages { return deriveQueuePauses({ epoch: state.epoch, marks: state.queuePauseMarks, - latestPersonTurnSequence: state.latestPersonTurnSequence, + latestAcceptedTurnSequence: state.latestAcceptedTurnSequence, cards, hostInstance, restartEnded: this.restartEnded() }) } - /** A person's turn started since this handle opened, which ends a restart's pause. */ + /** A turn started since this handle opened, which ends a restart's pause. */ restartEnded(): boolean { - const latest = this.deps.state().latestPersonTurnSequence + const latest = this.deps.state().latestAcceptedTurnSequence return latest > 0 && !this.deps.wroteBeforeOpen(latest) } @@ -258,7 +257,7 @@ export class JournalQueuedMessages { if (input.yieldsToPause) { // 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 sending a card now held. + // 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-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index e2d1b1ef3ca3..f4a76fa886ef 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -59,9 +59,9 @@ export type JournalReducerState = { appliedSettlementIds: Set /** Scope for rows stored without one; rebuilt by replay, never persisted. */ derivedTurnScope: JournalDerivedTurnScope - /** 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 submission row of the latest turn the provider accepted, whoever sent it; 0 when none. + * Kept as it folds so the queue's pause reads it in O(1). */ + latestAcceptedTurnSequence: number /** The latest person's Stop event and Resume, what the queue's pause is derived from. */ queuePauseMarks: JournalQueuePauseMarks } @@ -82,7 +82,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou aliases: new Map(), appliedSettlementIds: new Set(), derivedTurnScope: new JournalDerivedTurnScope(), - latestPersonTurnSequence: 0, + latestAcceptedTurnSequence: 0, queuePauseMarks: createJournalQueuePauseMarks() } } 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..a994f05c5f97 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 @@ -71,7 +71,6 @@ export function journalSubmissionRowBuilder( fence: number handoverRecorded?: true queuedMessageId?: string - origin?: 'client' | 'host' }, /** Present when the append hands off a queued draft: the row names that draft, stamped here * from the consume itself so no hand-off path can leave the link off. */ @@ -307,7 +306,6 @@ export function buildJournalSubmissionRow(input: { ts: number handoverRecorded?: true queuedMessageId?: string - origin?: 'client' | 'host' }): JournalSubmissionRow { return { kind: 'submission', @@ -317,7 +315,6 @@ export function buildJournalSubmissionRow(input: { body: input.body, ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), ...(input.handoverRecorded ? { handoverRecorded: true } : {}), - ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}), - ...(input.origin !== undefined ? { origin: input.origin } : {}) + ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}) } } 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 209d8a8a1335..7b74b025759a 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 @@ -139,15 +139,9 @@ export type JournalSubmissionRow = JournalRowBase & { /** The queued draft this submission hands off; absent for a direct send. Older readers keep * the key and ignore it. */ queuedMessageId?: string - /** Who asked for this turn: `client` for a person — their send over the client send RPC, Send - * on a card, or the queue's send of a card they wrote; `host` for Orca's own — orchestration - * mail, a restart continuation, a launch prompt, or the queue's send of a card one of those - * wrote. Absent on rows from before it was recorded. Older readers keep the key and ignore it. */ - origin?: JournalSubmissionOrigin + // Rows from earlier builds may carry `origin` ('client' | 'host'); nothing reads it. } -export type JournalSubmissionOrigin = 'client' | 'host' - export type JournalDispatchRow = JournalRowBase & { kind: 'dispatch' clientMessageId: string 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 dc270bc97a6d..225000bc7ea0 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 @@ -71,7 +71,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal queuePauseRestatement: () => journalQueuePauseRestatement( host.state().queuePauseMarks, - host.state().latestPersonTurnSequence + host.state().latestAcceptedTurnSequence ), cursor: host.cursor, adopt: host.adopt 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..9d939887ded1 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 @@ -85,8 +85,6 @@ export type JournalSubmissionInput = { handoverRecorded?: true /** Stamped by `appendSubmission` from its consume; a caller-passed value must match it. */ queuedMessageId?: string - /** Who asked for this turn (`JournalSubmissionRow.origin`). */ - origin?: 'client' | 'host' } /** A submission append that converts a queued draft, in one transaction. */ 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 80e4e2c93e95..f1eaab96b500 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 @@ -29,8 +29,7 @@ export function applyJournalSubmission( // A malformed stored link is dropped, never the row. ...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0 ? { queuedMessageId: row.queuedMessageId } - : {}), - ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}) + : {}) }) const itemId = agentJournalSubmissionKey(row.clientMessageId) // A message handed over later belongs to no turn until its handover names one. @@ -108,7 +107,7 @@ export function acceptSubmissionFromProviderItem( } submission.fence = row.fence submission.dispatchState = 'accepted' - notePersonTurnAccepted(state, submission) + noteTurnAccepted(state, submission) submission.providerItemId = providerItemId submission.reason = null submission.resolvedAt = row.ts @@ -121,14 +120,14 @@ export function acceptSubmissionFromProviderItem( }) } -/** A person's turn the provider accepted: the fact the queue's pause is lifted by. */ -export function notePersonTurnAccepted( +/** A turn the provider accepted, whoever sent it: the fact the queue's pause is lifted by. */ +export function noteTurnAccepted( state: JournalReducerState, - submission: Pick + submission: Pick ): void { - if (submission.origin === 'client' && submission.acceptedSequence !== undefined) { - state.latestPersonTurnSequence = Math.max( - state.latestPersonTurnSequence, + if (submission.acceptedSequence !== undefined) { + state.latestAcceptedTurnSequence = Math.max( + state.latestAcceptedTurnSequence, submission.acceptedSequence ) } diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts index 654e02c5c851..fc9431646158 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -47,8 +47,7 @@ async function handOff(journal: AgentSessionJournal, draftId: string, submission messageId: draftId, body: BODY, fingerprint: 'fp', - hostInstance: 'p', - origin: 'client' + hostInstance: 'p' }) await journal.appendSubmission( { clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 }, @@ -97,8 +96,7 @@ describe('the submission names the queued draft it hands off', () => { messageId: 'draft-1', body: BODY, fingerprint: 'fp', - hostInstance: 'p', - origin: 'client' + hostInstance: 'p' }) await expect( journal.appendSubmission( diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts index 31e06659667c..15911b5e080c 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -64,8 +64,7 @@ async function queueAndConsume(journal: AgentSessionJournal, messageId: string): messageId, body, fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1', - origin: 'client' + hostInstance: 'proc-1' }) await journal.appendSubmission( { @@ -172,8 +171,7 @@ describe('draft bookkeeping inside a journal append', () => { messageId: 'draft-1', body: BODY, fingerprint: 'fp-draft-1', - hostInstance: 'proc-1', - origin: 'client' + hostInstance: 'proc-1' }) expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }]) const commit = failNextCommit() 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 02911d773c0d..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 @@ -76,8 +76,7 @@ async function handOffAndReject( messageId: 'draft-1', body, fingerprint, - hostInstance: 'p', - origin: 'client' + hostInstance: 'p' }) await journal.appendSubmission( { 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 fb96550c39ea..840398e0a098 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 @@ -1,5 +1,5 @@ -// 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 +// The queue's pause is a pure function of the journal: a Stop and a Resume are rows, an 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' @@ -62,21 +62,14 @@ function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom body: message(messageId), fingerprint: `fp-${messageId}`, hostInstance: HOST, - origin: 'client', ...(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 { +/** A turn sent, then accepted by the provider. */ +async function turn(journal: AgentSessionJournal, id: string, accept = true): Promise { await journal.appendSubmission({ clientMessageId: id, - origin, payloadFingerprint: `fp-${id}`, body: message(id), fence: 0, @@ -180,24 +173,24 @@ describe("the queue's pause, derived from the journal", () => { expect(pauseTables()).toBe(0) }) - it("only a person's turn sent after the Stop and accepted lifts it; host turns never do", async () => { + it("any turn sent after the Stop and accepted lifts it, Orca's own mail included", async () => { const journal = await open() - await turn(journal, 'before-stop', 'client', false) + await turn(journal, 'before-stop', false) 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') expect(reason(journal)).toBe('stopped') - await turn(journal, 'typed', 'client', false) + // Orchestration mail: sent, not yet accepted, lifts nothing; accepted, it lifts the pause. + await turn(journal, 'mail', false) expect(reason(journal)).toBe('stopped') - await acceptTurn(journal, 'typed') + await acceptTurn(journal, 'mail') expect(reason(journal)).toBeNull() }) it('a later Stop is the latest, and a Resume row lifts it', async () => { const journal = await open() await userStop(journal) - await turn(journal, 'typed', 'client') + await turn(journal, 'typed') expect(reason(journal)).toBeNull() await userStop(journal) expect(reason(journal)).toBe('stopped') @@ -220,18 +213,18 @@ describe("the queue's pause, derived from the journal", () => { expect(pauseTables()).toBe(0) }) - it("a person's accepted turn on the replacement lifts 'cleared'; a host turn does not", async () => { + it("any accepted turn on the replacement lifts 'cleared', a launch prompt Orca sent included", async () => { const journal = await open() await queueDraft(journal, 'carried', 'source-session') - await turn(journal, 'launch', 'host') + await turn(journal, 'launch', false) expect(reason(journal)).toBe('cleared') - await turn(journal, 'typed', 'client') + await acceptTurn(journal, 'launch') 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 turn(journal, 'typed') 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) @@ -303,7 +296,6 @@ describe("the queue's pause, derived from the journal", () => { journal.appendSubmission( { clientMessageId: id, - origin: automatic ? 'host' : 'client', payloadFingerprint: 'fp-draft-1', body: message('draft-1'), fence: 0, @@ -324,12 +316,12 @@ describe("the queue's pause, derived from the journal", () => { 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 () => { + it("a rewind's epoch replacement restates a Stop still pausing, and not one a turn ended", async () => { const journal = await open() await userStop(journal) await journal.replaceEpochItems('handle_forked', 0, []) expect(reason(journal)).toBe('stopped') - await turn(journal, 'typed', 'client') + await turn(journal, 'typed') await journal.replaceEpochItems('handle_forked', 0, []) expect(reason(journal)).toBeNull() }) @@ -379,7 +371,7 @@ describe("the queue's pause, derived from the journal", () => { }) it.each([ - ["a person's turn", (journal: AgentSessionJournal) => turn(journal, 'typed', 'client')], + ['an accepted turn', (journal: AgentSessionJournal) => turn(journal, 'typed')], ['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() @@ -393,7 +385,7 @@ describe("the queue's pause, derived from the journal", () => { 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 turn(journal, 'typed') await userStop(journal) await journal.replaceEpochItems('handle_forked', 0, []) expect(journal.queuedMessages.pauses(HOST).map((pause) => pause.reason)).toEqual(['stopped']) @@ -426,7 +418,6 @@ describe('which cards a pause holds', () => { await journal.appendSubmission( { clientMessageId: 'send-now-1', - origin: 'client', payloadFingerprint: 'fp-steered', body: message('steered'), fence: 0, @@ -448,33 +439,30 @@ describe('which cards a pause holds', () => { ]) }) - it("the queue's own consume takes a newer card past a held one, and refuses the held one", 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) await queueDraft(journal, 'newer') - const drain = (messageId: string) => + await expect( journal.appendSubmission( { - clientMessageId: `drain-${messageId}`, - origin: 'client', - payloadFingerprint: `fp-${messageId}`, - body: message(messageId), + clientMessageId: 'drain-newer', + payloadFingerprint: 'fp-newer', + body: message('newer'), fence: 0, handoverRecorded: true }, { - messageId, + messageId: 'newer', expect: 'waiting', settledByOp: null, hostInstance: HOST, yieldsToPause: { hostInstance: HOST } } ) - await expect(drain('held')).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) - await drain('newer') - expect(journal.queuedMessages.get('newer')?.state).toBe('dispatched') - expect(journal.queuedMessages.get('held')?.state).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 () => { @@ -497,8 +485,7 @@ describe("a restart's pause", () => { messageId: 'draft-restart', body: message('written before the restart'), fingerprint: 'fp-draft-restart', - hostInstance: 'proc-0', - origin: 'client' + hostInstance: 'proc-0' }) expect(reason(journal)).toBe('restarted') await queueDraft(journal, 'draft-legacy') @@ -540,7 +527,7 @@ describe('which cards the pauses in force hold', () => { return { messageId, ...base, queuedAt, ...fields } } - /** A Stop at sequence 5 unless `stopped` is 0; no person's turn or Resume since. */ + /** A Stop at sequence 5 unless `stopped` is 0; no turn or Resume since. */ function pausesOver(cards: readonly Card[], stopped = 5) { return deriveQueuePauses({ epoch: 'epoch-1', @@ -548,7 +535,7 @@ describe('which cards the pauses in force hold', () => { latestStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, resumedSequence: 0 }, - latestPersonTurnSequence: 0, + latestAcceptedTurnSequence: 0, cards, hostInstance: HOST, restartEnded: false @@ -585,26 +572,6 @@ describe('which cards the pauses in force hold', () => { expect(nextSendableQueuedCard(pausesOver([live]), [live])).toBe(live) }) - it('a restart holds only the cards another process wrote, never one written since', () => { - const cards = [card('before', 1, { hostInstance: DEAD }), card('since', 2)] - expect(pausesOver(cards, 0).map((pause) => pause.reason)).toEqual(['restarted']) - expect(holding(cards, 0)).toEqual([ - ['before', 'restarted'], - ['since', null] - ]) - expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)?.messageId).toBe('since') - }) - - it('the next card skips held ones, but never a returned card', () => { - const held = card('held', 3) - const after = card('after', 5) - expect(nextSendableQueuedCard(pausesOver([held, after]), [held, after])).toBe(after) - const returned = card('returned', 5, { state: 'returned' }) - const behind = card('behind', 6) - const cards = [held, returned, behind] - expect(nextSendableQueuedCard(pausesOver(cards), cards)).toBeNull() - }) - 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 }), 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 add2725dc8ff..a4670f6b1493 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,15 +1,14 @@ // 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 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 -// started since this conversation opened. It holds only those cards, never one written since. -// A person's turn is an accepted submission of origin `client`: their send, Send on any card, or -// the queue's send of a card they wrote. Orchestration mail, a restart continuation, a launch -// prompt and the queue's send of a card one of those wrote are `host` and never lift it. +// and no turn 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 turn or Resume has +// happened here since. +// - 'restarted': a waiting card was written by another host process, and no turn has started +// since this conversation opened. Never published: after a restart nothing sends by itself, +// and the next turn (the carry-on or the person's own message) runs first. +// Any accepted turn lifts them, whoever sent it: a person, Orca's own messages, or the queue. import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' @@ -27,12 +26,11 @@ export type JournalQueuePauseMarks = { resumedSequence: number } -export type DerivedQueuePause = - /** `since`: where the Stop was written; a card queued at or after it is newer. */ - | { reason: 'stopped'; since: AgentJournalCursor } - | { reason: 'cleared' } - /** `hostInstance`: this process; a card it wrote came after the restart. */ - | { reason: 'restarted'; hostInstance: string } +export type DerivedQueuePause = { + reason: QueuePauseReason + /** 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 @@ -70,28 +68,28 @@ export function foldJournalQueuePauseMark( } } -/** A person's Stop still pauses: it is the latest Stop, and nothing a person did since, and no +/** A person's Stop still pauses: it is the latest Stop, and no turn accepted 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 + latestAcceptedTurnSequence: number ): boolean { return ( marks.latestStop?.event.reason === 'user-stop' && - marks.latestStop.sequence > Math.max(latestPersonTurnSequence, marks.resumedSequence) + marks.latestStop.sequence > Math.max(latestAcceptedTurnSequence, marks.resumedSequence) ) } /** The latest person's Stop while it still pauses, with where it was written; else null. */ export function journalUserStopInForce( marks: JournalQueuePauseMarks, - latestPersonTurnSequence: number + latestAcceptedTurnSequence: number ): JournalQueuePauseMarks['latestStop'] { - return journalQueueStopHolds(marks, latestPersonTurnSequence) ? marks.latestStop : null + return journalQueueStopHolds(marks, latestAcceptedTurnSequence) ? marks.latestStop : 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 + * 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 @@ -100,11 +98,11 @@ export type JournalQueuePauseRestatement = { export function journalQueuePauseRestatement( marks: JournalQueuePauseMarks, - latestPersonTurnSequence: number + latestAcceptedTurnSequence: number ): JournalQueuePauseRestatement { return { - lifted: latestPersonTurnSequence > 0 || marks.resumedSequence > 0, - liveStop: journalUserStopInForce(marks, latestPersonTurnSequence)?.event ?? null + lifted: latestAcceptedTurnSequence > 0 || marks.resumedSequence > 0, + liveStop: journalUserStopInForce(marks, latestAcceptedTurnSequence)?.event ?? null } } @@ -113,54 +111,46 @@ export function deriveQueuePauses(input: { /** The journal's epoch: sequences compare only within one. */ epoch: string marks: JournalQueuePauseMarks - latestPersonTurnSequence: number + latestAcceptedTurnSequence: number cards: readonly QueueCard[] hostInstance: string - /** A person's turn started since this conversation opened. */ + /** A turn started since this conversation opened. */ restartEnded: boolean }): DerivedQueuePause[] { - const { epoch, marks, latestPersonTurnSequence } = input + const { epoch, marks, latestAcceptedTurnSequence } = input const pauses: DerivedQueuePause[] = [] - const stop = journalUserStopInForce(marks, latestPersonTurnSequence) + const stop = journalUserStopInForce(marks, latestAcceptedTurnSequence) 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) - if (carried.length > 0 && latestPersonTurnSequence === 0 && marks.resumedSequence === 0) { - pauses.push({ reason: 'cleared' }) + if (carried.length > 0 && latestAcceptedTurnSequence === 0 && marks.resumedSequence === 0) { + pauses.push({ reason: 'cleared', since: null }) } if (!input.restartEnded && waiting.some((card) => card.hostInstance !== input.hostInstance)) { - pauses.push({ reason: 'restarted', hostInstance: input.hostInstance }) + // The process that wrote a card is gone: every card waits, whenever it was written. + pauses.push({ reason: 'restarted', since: null }) } return pauses } -/** Queued before the pause began: for /clear, a card it carried; for a restart, a card another - * process wrote. 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. */ +/** 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 } - if (pause.reason === 'restarted') { - return card.hostInstance !== pause.hostInstance - } 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 or a restart is a new instruction and is not held; -// only cards queued before it, and a steer a Stop withdrew, wait. The drain skips held cards, so -// it sends ahead of them; when a person wrote that card its send is their turn, so the held cards -// follow it. 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 * a Stop's that holds nothing never hides a restart's. The drain, its consume, and publication * all read it. */ @@ -171,21 +161,22 @@ export function queuePauseHolding( if (card.state !== 'waiting' || card.holdReason !== null) { return undefined } - return pauses.find((pause) => PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT || queuedBeforePause(pause, card)) + // A card queued AFTER a Stop is a new instruction and is not held; it still waits behind a held one. + return pauses.find((pause) => queuedBeforePause(pause, card)) } -/** The card the queue sends next: the oldest waiting one that nothing holds, unless a returned - * card comes first. Held cards are skipped, not waited on; a returned card blocks what follows it. - * The drain's pick, its consume, and send admission all read this. */ +/** 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. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] ): T | null { for (const card of cards) { - if (card.state === 'returned') { + if (card.state === 'returned' || queuePauseHolding(pauses, card)) { return null } - if (card.state === 'waiting' && card.holdReason === null && !queuePauseHolding(pauses, card)) { + if (card.state === 'waiting' && card.holdReason === null) { return card } } @@ -193,8 +184,8 @@ export function nextSendableQueuedCard( } /** 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 a client that - * still shows a paused header (mobile, older desktops) never offers a Resume that sends nothing. */ + * 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[] 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 d621bbf80e8e..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 @@ -12,8 +12,7 @@ const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ ['consumed_as', 'TEXT'], ['carried_from', 'TEXT'], ['queued_epoch', 'TEXT'], - ['queued_sequence', 'INTEGER'], - ['origin', 'TEXT'] + ['queued_sequence', 'INTEGER'] ] /** @@ -44,7 +43,6 @@ CREATE TABLE IF NOT EXISTS queued_messages ( carried_from TEXT, queued_epoch TEXT, queued_sequence INTEGER, - origin TEXT, PRIMARY KEY (session_id, message_id) ); `) 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 551e4214cc4c..6c609853d690 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 @@ -75,44 +75,15 @@ async function open(): Promise { return journal } -async function queueDraft( - journal: AgentSessionJournal, - messageId: string, - text = 'queued text', - origin: 'client' | 'host' = 'client' -) { +async function queueDraft(journal: AgentSessionJournal, messageId: string, text = 'queued text') { return journal.queuedMessages.insert({ messageId, body: message(text), fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1', - origin + hostInstance: 'proc-1' }) } -/** A row as a build that knows only the columns it names writes it, the host closed around it. */ -function insertAsOlderBuild(messageId: string, position: number, origin?: string): void { - closeTestJournalHostDatabases() - const db = new Database(journalDatabasePath(root)) - try { - db.prepare( - `INSERT INTO queued_messages (session_id, message_id, position, body_json, fingerprint, - created_at, host_instance, state${origin === undefined ? '' : ', origin'}) - VALUES (?, ?, ?, ?, ?, ?, 'proc-0', 'waiting'${origin === undefined ? '' : ', ?'})` - ).run( - IDENTITY.sessionId, - messageId, - position, - JSON.stringify(message(messageId)), - `fp-${messageId}`, - tick(), - ...(origin === undefined ? [] : [origin]) - ) - } finally { - db.close() - } -} - async function consumeDraft( journal: AgentSessionJournal, messageId: string, @@ -183,37 +154,6 @@ describe('draft rows', () => { expect(row.position).toBe(1) }) - it("keeps who wrote a card; one from before that was recorded reads as a person's", async () => { - const first = await open() - await queueDraft(first, 'draft-host', 'mail', 'host') - await first.close() - // An older build's insert leaves the author empty; a value no build writes is not a person's. - insertAsOlderBuild('draft-older', 2) - insertAsOlderBuild('draft-unknown', 3, 'automation') - const journal = await open() - expect(journal.queuedMessages.list().map((row) => [row.messageId, row.origin])).toEqual([ - ['draft-host', 'host'], - ['draft-older', 'client'], - ['draft-unknown', 'host'] - ]) - }) - - it("heals an older build's draft table that has no author column", async () => { - const first = await open() - await queueDraft(first, 'draft-1') - await first.close() - closeTestJournalHostDatabases() - const db = new Database(journalDatabasePath(root)) - db.exec('ALTER TABLE queued_messages DROP COLUMN origin') - db.close() - const journal = await open() - await queueDraft(journal, 'draft-2', 'mail', 'host') - expect(journal.queuedMessages.list().map((row) => [row.messageId, row.origin])).toEqual([ - ['draft-1', 'client'], - ['draft-2', 'host'] - ]) - }) - it('assigns monotonic positions and lists in order', async () => { const journal = await open() await queueDraft(journal, 'draft-1') 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 40ff205cad11..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 @@ -14,16 +14,14 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { rejectedDraftSettlement } from './journal-dispatch-settlement' -import type { JournalSubmissionOrigin } from './journal-row-schema' import { readStoredRejectionFact } from './journal-dispatch-reducer' export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' /** Why ONE waiting draft is held from auto-sending: its conversion failed. * Stored on the row, so it survives handle eviction and restart; a wire marker - * (it publishes as `pausedReason`). A Stop, restart or /clear instead holds the - * cards written before it, derived, and the queue skips held cards. A reader - * treats an unknown stored value as a plain hold. */ + * (it publishes as `pausedReason`). A Stop or a restart pauses the whole queue + * instead. A reader treats an unknown stored value as a plain hold. */ export type QueuedMessageHoldReason = 'send_failed' /** Definitively unsettled: what Stop, /clear, Edit and the budget count, and @@ -61,13 +59,10 @@ export type QueuedMessageRow = { /** 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 - /** Who wrote the card, in the submission's vocabulary: the queue's send of it carries this. - * Unrecorded reads as a person's: before it, no production caller queued an Orca send. */ - origin: JournalSubmissionOrigin } 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, queued_epoch, queued_sequence, origin' + '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, @@ -79,7 +74,6 @@ export function insertQueuedMessage( hostInstance: string carriedFrom?: string queuedAt: AgentJournalCursor - origin: JournalSubmissionOrigin now: number } ): QueuedMessageRow { @@ -90,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, @@ -101,8 +95,7 @@ export function insertQueuedMessage( input.hostInstance, input.carriedFrom ?? null, input.queuedAt.epoch, - input.queuedAt.sequence, - input.origin + input.queuedAt.sequence ) return { sessionId: input.sessionId, @@ -120,8 +113,7 @@ export function insertQueuedMessage( settledByOp: null, consumedAs: null, carriedFrom: input.carriedFrom ?? null, - queuedAt: input.queuedAt, - origin: input.origin + queuedAt: input.queuedAt } } @@ -303,7 +295,6 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { carried_from: string | null queued_epoch: string | null queued_sequence: number | null - origin: string | null } let body: AgentJournalMessageItem try { @@ -342,16 +333,10 @@ function toStoredRow(row: unknown): QueuedMessageRow | null { queuedAt: record.queued_epoch !== null && typeof record.queued_sequence === 'number' ? { epoch: record.queued_epoch, sequence: record.queued_sequence } - : null, - origin: storedOrigin(record.origin) + : null } } -/** A row from before the column was a person's send; a value no build writes is not a person's. */ -function storedOrigin(value: string | null): JournalSubmissionOrigin { - return value === null || value === 'client' ? 'client' : 'host' -} - function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { if (json === null) { return null 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 5e4ac75b0662..713e0b9cc570 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 @@ -327,8 +327,7 @@ it('a card sent now into the running turn comes back paused when Stop withdraws const queuedSend = await host.send(CALLER, { envelope: envelope('agentSession.send', { body, delivery }), body, - delivery, - userSend: true + delivery }) if (!queuedSend.ok || !('queued' in queuedSend.value)) { throw new Error('expected a queued receipt') @@ -345,7 +344,7 @@ it('a card sent now into the running turn comes back paused when Stop withdraws const sends = async () => (await host.journalSnapshot(SESSION)).submissions .filter((entry) => entry.queuedMessageId === cardId) - .map((entry) => ({ origin: entry.origin, state: entry.dispatchState, reason: entry.reason })) + .map((entry) => ({ 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 } }) @@ -366,14 +365,12 @@ it('a card sent now into the running turn comes back paused when Stop withdraws }).toEqual({ pause: { reason: 'stopped' }, cards: ['waiting'], - sends: [{ origin: 'client', state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }] + sends: [{ 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) - expect(await sends()).toEqual([ - { origin: 'client', state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED } - ]) + expect(await sends()).toEqual([{ state: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }]) }, 20_000) 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 9c920b45eb39..536d865e4804 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 @@ -590,8 +590,7 @@ it('sends a queue-if-active message issued while the Stop ends the child directl const sent = host.send(CALLER, { envelope: envelope('agentSession.send', { body, delivery: 'queue-if-active' }, fence), body, - delivery: 'queue-if-active', - userSend: true + delivery: 'queue-if-active' }) frame(connection, INTERRUPTED_RESULT) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts index 2aac7328966f..65dcfa420738 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts @@ -476,8 +476,7 @@ it('queues a follow-up as a draft by default while a message waits, and Steer re const queued = await host.send(CALLER, { envelope: envelope('agentSession.send', { body, delivery }), body, - delivery, - userSend: true + delivery }) expect(queued).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) await commitSettled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 165184af7bcd..7f828a484688 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -384,8 +384,7 @@ describe('a Codex Stop whose interrupt failed', () => { }) }, body, - delivery, - userSend: true + delivery }) if (!queued.ok || !('queued' in queued.value)) { throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) 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 6e4b4bc90128..ec56f341e110 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 @@ -54,12 +54,6 @@ export function sendStructuredAgentSessionTurn( body: AgentJournalMessageItem retryUnknown?: true delivery?: 'queue-if-active' - /** Host-local, set only by the client-facing `agentSession.send` RPC (the - * renderer's launch prompt included): recorded as the submission's `client` - * origin, whose started turn ends a Stop's or a restart's queue pause; a card it - * queues keeps that origin for the queue's send. Orchestration mail, a restart - * continuation and `agent.launch`'s host-sent prompt never set it. */ - userSend?: true beforeRun?: () => void }, arrival?: Parameters[2] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 6a2a6268ae94..a7223ca8c4ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -76,7 +76,6 @@ export function sendPlan(params: { body: AgentJournalMessageItem retryUnknown?: true delivery?: 'queue-if-active' - userSend?: true beforeRun?: () => void }): MutationPlan { // The operation id IS the client message id: one send, one durable row, one @@ -97,7 +96,6 @@ export function sendPlan(params: { // Asked at acceptance: a send accepted after this one is queued behind it. params.beforeRun?.() return performSend(ctx, { - origin: params.userSend ? 'client' : 'host', clientMessageId, payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body @@ -161,8 +159,6 @@ export function conversationCommandPlan(params: { run: async (ctx) => { const sent = await performSend(ctx, { clientMessageId, - // Only a client asks through the command RPC: the person's own turn. - origin: 'client', payloadFingerprint: params.envelope.payloadFingerprint, body: structuredAgentSessionCompactBody() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts index f3ce31243dad..76cf89fc5a22 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts @@ -141,8 +141,7 @@ it("takes a send past a newer Orca's live approval, and the next turn's card can expect( await host.send(CALLER, { envelope: envelope('agentSession.send', queuedFields), - ...queuedFields, - userSend: true + ...queuedFields }) ).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) expect(hostTestState().dispatch).not.toHaveBeenCalled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.ts deleted file mode 100644 index 29f3fde2c94d..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-author.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -// A card records who wrote it, and the queue's send of it is that author's turn: a person's card -// ends a Stop's pause, Orca's own does not. /clear carries the author with the card. - -import { afterEach, beforeEach, expect, it } from 'vitest' -import { sendAgentTurn } from '../../runtime/orchestration/send-agent-turn' -import { - HOST_TEST_SESSION as SESSION, - hostTestMessage, - hostTestOperationId -} from './structured-agent-session-host-test-data' -import { - createQueuedMessageTestRig, - eventually, - QUEUED_RIG_CALLER as CALLER, - type QueuedMessageTestRig -} from './structured-agent-session-queued-message-rig.test-fixture' - -let rig: QueuedMessageTestRig - -beforeEach(async () => { - rig = await createQueuedMessageTestRig() -}) - -afterEach(() => rig.dispose()) - -async function queuedDraft(text: string, options?: { internal?: true }): Promise { - const queued = await rig.send(text, 'queue-if-active', options).result - if (!queued.ok || !('queued' in queued.value)) { - throw new Error('expected a queued receipt') - } - return queued.value.queued.messageId -} - -/** Each stored card's id and who wrote it. */ -function cardOrigins(sessionId = SESSION): [string, string][] { - const journal = rig.host.collaboratorsForTests().sessions.get(sessionId)?.journal - if (!journal) { - throw new Error('no open journal') - } - return journal.queuedMessages.list().map((row) => [row.messageId, row.origin]) -} - -it("a host-authored card queues, and its drained send does not end a Stop's hold; a person's card does", async () => { - const working = await rig.workingSend() - const heldId = await queuedDraft('queued before the stop') - await rig.stop() - // Both queued while the stopped turn winds down, so the Stop holds neither. - const mail = await sendAgentTurn({ - kind: 'structured-session', - host: rig.host, - sessionId: SESSION, - callerKey: 'orchestration-1', - turn: { - body: hostTestMessage('coordinator mail'), - delivery: 'queue', - operationId: hostTestOperationId(), - expectedRuntimeFence: 1 - } - }) - if (mail.kind !== 'queued') { - throw new Error('expected the mail to queue') - } - const typedId = await queuedDraft('typed while stopping') - expect(cardOrigins()).toEqual([ - [heldId, 'client'], - [mail.clientMessageId, 'host'], - [typedId, 'client'] - ]) - await rig.settleAccepted(working, 'a') - // Orca's card goes first, as Orca's turn: the Stop still holds the older card. - await eventually(async () => - expect(await rig.handoff(mail.clientMessageId)).toMatchObject({ origin: 'host' }) - ) - await rig.settleAccepted(await rig.handoffId(mail.clientMessageId), 'b') - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - // The person's card is their turn: once it starts, the held card follows. - await eventually(async () => - expect(await rig.handoff(typedId)).toMatchObject({ origin: 'client' }) - ) - expect(await rig.handoff(heldId)).toBeUndefined() - await rig.settleAccepted(await rig.handoffId(typedId), 'c') - expect(await rig.queuePause()).toBeNull() - await eventually(async () => expect(await rig.handoff(heldId)).toBeDefined()) -}) - -it('a card /clear carries keeps who wrote it', async () => { - const working = await rig.workingSend() - const typedId = await queuedDraft('typed text') - const mailId = await queuedDraft('coordinator mail', { internal: true }) - await rig.stop() - await rig.settleAccepted(working, 'a') - const fields = { command: 'clear' as const } - const cleared = await rig.host.conversationCommand(CALLER, { - envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), - ...fields - }) - const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined - if (!replacementId) { - throw new Error('expected a replacement session') - } - expect(cardOrigins(replacementId)).toEqual([ - [typedId, 'client'], - [mailId, 'host'] - ]) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index f321484117a7..0e23de428b13 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -1,8 +1,9 @@ -// The host publishes which pause holds each card and the card its queue sends next. A client -// reading its updates one at a time, as the chat does, reads one working state across a turn's end -// or a Resume and the queue's send of the next card: the working status and row, the pickers, the -// composer button and the card labels never flip in between. Where the host would refuse the send, -// it names no next card, so the chat reads idle. +// The queue sends its cards strictly in order. The host publishes the queue's pause and the card it +// sends next. A client reading its updates one at a time, as the chat does, reads one working state +// across a turn's end or a Resume and the queue's send of the next card: the working status and +// row, the pickers, the composer button and the card labels never flip in between. After a restart +// nothing sends by itself and no pause shows: the chat's next turn runs first, then the cards. +// Where the host would refuse the send, it names no next card, so the chat reads idle. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { @@ -20,8 +21,7 @@ import { ownDirectSendOnItsWay, projectQueuedMessageCards, queuedMessageCardSteers, - queuedMessagesQueuePause, - queuedMessagesResumable + queuedMessagesQueuePause } from '../../../renderer/src/components/native-chat/structured-agent-session-queued-cards' import { nativeChatComposerPrimaryAction, @@ -32,7 +32,11 @@ import { readQueuePublication, structuredQueueSendGate } from './structured-agent-session-queued-publication' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + structuredAgentSessionHostInstance, + structuredQueuePauses +} from './structured-agent-session-queued-pause' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { HOST_TEST_SESSION, hostTestMessage, @@ -47,6 +51,8 @@ import { let rig: QueuedMessageTestRig +const MANUAL_IDLE_SWEEP = { idleMs: 0, intervalMs: 60 * 60 * 1000 } + beforeEach(async () => { rig = await createQueuedMessageTestRig({ restartable: true }) }) @@ -61,15 +67,36 @@ async function queuedDraft(text: string): Promise { return queued.value.queued.messageId } -/** Each waiting card's published hold, by id. */ -async function heldBy(): Promise> { +/** What a reader opening the chat now is told: the queue's pause and the card it sends next. */ +async function published(): Promise<{ + queuePause: AgentSessionQueuePause | null + nextQueuedMessageId: string | null +}> { const page = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) if (!page.ok) { throw new Error('history refused') } - return Object.fromEntries( - (page.page.queuedMessages ?? []).map((card) => [card.messageId, card.heldBy]) - ) + return { + queuePause: page.page.queuePause ?? null, + nextQueuedMessageId: page.page.nextQueuedMessageId ?? null + } +} + +/** The pauses in force, published or not: a restart's is never published. */ +function derivedPauses(): string[] { + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + return structuredQueuePauses(journal).map((pause) => pause.reason) +} + +/** Lets the drain run its steps; none may hand a card off. */ +async function expectNothingSent(...draftIds: string[]): Promise { + await new Promise((resolve) => setTimeout(resolve, 250)) + for (const draftId of draftIds) { + expect(await rig.handoff(draftId)).toBeUndefined() + } } type ClientView = { @@ -81,6 +108,8 @@ type ClientView = { button: NativeChatComposerPrimaryAction /** The header row above the cards: the queue is held. */ header: boolean + /** A new message first asks whether to clear the cards ("Send message?"). */ + dialog: boolean /** Each card's Send-now reads Steer. */ steers: boolean[] cards: number @@ -146,11 +175,18 @@ async function watchClient(outbox: ComposerOutbox = { entries: [] }): Promise queuedMessageCardSteers(card)), cards: cards.length, nextQueuedMessageId @@ -178,37 +214,27 @@ function expectOneWorkingRun(views: readonly ClientView[]): void { expect(views.some((view) => !view.stopLive && view.nextQueuedMessageId !== null)).toBe(true) } -describe('which pause holds each card', () => { - it("a Stop's: the card queued before it, not the one typed while it lands; Resume and Send never show as the queue sends them", async () => { +describe('a Stop holds the queue, in order', () => { + it('a card typed while the Stop lands waits behind the held one; Resume sends both in order, with no Resume or Send between them', async () => { const working = await rig.workingSend() const held = await queuedDraft('held by the stop') await rig.stop() const typed = await queuedDraft('typed while the stop lands') - expect(await heldBy()).toEqual({ [held]: { reason: 'stopped' }, [typed]: null }) const views = await watchClient() + // Still winding down: the paused row shows, but neither Resume nor "Send message?" while it runs. + expect(views.at(-1)).toMatchObject({ working: true, header: true, dialog: false }) await rig.settleAccepted(working, 'stopped') - await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) - await rig.settleAccepted(await rig.handoffId(typed), 'typed') + await eventually(() => expect(views.at(-1)).toMatchObject({ header: true, dialog: true })) + expect(views.at(-1)?.button).toBe('resume') + await expectNothingSent(held, typed) + expect((await published()).queuePause).toEqual({ reason: 'stopped' }) + const resumedAt = views.length + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(async () => expect(await rig.handoff(held)).toBeDefined()) - expectOneWorkingRun(views.slice()) - }) - - it("a restart's: the card from before it, not one typed during Orca's own turn since; the same holds as that turn ends", async () => { - const working = await rig.workingSend() - const before = await queuedDraft('written before the restart') - await rig.restartHostProcess() - await rig.settleAccepted(working, 'a') - const continuation = rig.send('continue where you left off', undefined, { internal: true }) - await continuation.result - await eventually(async () => - expect((await rig.submission(continuation.id))?.handedOverAt).toBeDefined() - ) - const typed = await queuedDraft('typed during the continuation') - expect(await heldBy()).toEqual({ [before]: { reason: 'restarted' }, [typed]: null }) - const views = await watchClient() - await rig.settleAccepted(continuation.id, 'continuation') + expect(await rig.handoff(typed)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(held), 'held') await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) - expectOneWorkingRun(views.slice()) + expectOneWorkingRun(views.slice(resumedAt)) }) it('after Resume over held cards the chat goes from idle with Resume straight to working with Stop', async () => { @@ -227,6 +253,154 @@ describe('which pause holds each card', () => { }) }) +describe('after a restart, nothing sends by itself', () => { + /** A turn runs with cards A and B waiting, and Orca quits: a quit writes no Stop event. */ + async function restartedWithCards(): Promise<{ first: string; second: string }> { + await rig.workingSend() + const first = await queuedDraft('A') + const second = await queuedDraft('B') + rig.crashRestartHostProcess() + // The new host opens the chat for its first reader. + await published() + return { first, second } + } + + /** The same, through a restart whose old agent this rig can still settle, so the next turn can + * reach the agent: the turn that ran ends before anything new is sent. */ + async function restartedIdleWithCards(): Promise<{ first: string; second: string }> { + const working = await rig.workingSend() + const first = await queuedDraft('A') + const second = await queuedDraft('B') + await rig.restartHostProcess() + await rig.settleAccepted(working, 'before-restart') + await published() + return { first, second } + } + + it('idle: no card is handed off across opens and commits, and no pause or next card is published', async () => { + const { first, second } = await restartedWithCards() + const views = await watchClient() + await expectNothingSent(first, second) + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + expect(derivedPauses()).toEqual(['restarted']) + // Closed and opened again (the idle sweep, a reconnect): the same. + await rig.host.close(HOST_TEST_SESSION, 'evict') + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + await expectNothingSent(first, second) + // The chat reads idle with plain cards: no paused row, no Resume, no "Send message?". + expect(views.at(-1)).toMatchObject({ + working: false, + header: false, + dialog: false, + button: 'send', + cards: 2 + }) + expect(await rig.drafts()).toEqual([ + { messageId: first, state: 'waiting' }, + { messageId: second, state: 'waiting' } + ]) + }) + + it("Resume: Orca's carry-on turn runs first, then A, then B, as if no restart happened", async () => { + const { first, second } = await restartedIdleWithCards() + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + const views = await watchClient() + // The restart prompt's Resume sends the carry-on as Orca's own message, straight to the agent. + const carryOn = rig.send('continue where you left off') + expect(await carryOn.result).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + await eventually(async () => + expect((await rig.submission(carryOn.id))?.handedOverAt).toBeDefined() + ) + await expectNothingSent(first, second) + await rig.settleAccepted(carryOn.id, 'carry-on') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + expect(await rig.handoff(second)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(first), 'A') + await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) + expect(views.filter((view) => view.header || view.dialog)).toEqual([]) + expectOneWorkingRun(views.slice(views.findIndex((view) => view.working))) + }) + + it("the person's own message goes straight to the agent first, then A, then B", async () => { + const { first, second } = await restartedIdleWithCards() + const outbox: ComposerOutbox = { entries: [] } + const views = await watchClient(outbox) + // Sent with the composer's queue delivery: nothing ahead may send, so it is not queued. + const message = composerSend(outbox, 'a new instruction') + expect(await message.result).toMatchObject({ + ok: true, + value: { submission: expect.anything() } + }) + await eventually(async () => + expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() + ) + await expectNothingSent(first, second) + await rig.settleAccepted(message.id, 'message') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + expect(await rig.handoff(second)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(first), 'A') + await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) + expect(views.filter((view) => view.header || view.dialog)).toEqual([]) + }) + + it('a hand-off the quit cut short goes back to waiting first, and nothing sends', async () => { + rig.dispose() + rig = await createQueuedMessageTestRig({ restartable: true, idleSweep: MANUAL_IDLE_SWEEP }) + const working = await rig.workingSend() + const first = await queuedDraft('A') + const second = await queuedDraft('B') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + // The agent at rest goes; Resume's hand-off of A must start a new one, which never starts. + await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() + let release: () => void = () => undefined + rig.awaitStarted.mockImplementation( + () => new Promise((resolve) => (release = () => resolve(undefined))) + ) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + const cutShort = await rig.handoffId(first) + expect((await rig.handoff(first))?.handedOverAt).toBeUndefined() + rig.crashRestartHostProcess() + rig.awaitStarted.mockImplementation(async () => undefined) + release() + await published() + // The new host refuses the leftover hand-off, and A waits again in its own place. + await eventually(async () => + expect(await rig.drafts()).toEqual([ + { messageId: first, state: 'waiting' }, + { messageId: second, state: 'waiting' } + ]) + ) + await expectNothingSent() + expect(await rig.handoffId(first)).toBe(cutShort) + expect(await rig.handoff(second)).toBeUndefined() + expect(derivedPauses()).toEqual(['restarted']) + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + }) + + it('an epoch replacement (a rewind or a legacy import) before any turn still holds the cards', async () => { + // A turn happened in this chat before, so the new epoch restates a lift. + await rig.settleAccepted(await rig.workingSend(), 'earlier') + const { first, second } = await restartedWithCards() + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + await journal.replaceEpochItems( + 'legacy_import', + structuredAgentSessionConversationFence(rig.store, HOST_TEST_SESSION), + [] + ) + await expectNothingSent(first, second) + expect(derivedPauses()).toEqual(['restarted']) + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + }) +}) + describe("a message sent over a held queue (the confirmation's Send message)", () => { /** Two cards a Stop holds, the stopped turn over: the paused row and Resume show. */ async function heldIdleQueue(outbox: ComposerOutbox) { @@ -236,11 +410,11 @@ describe("a message sent over a held queue (the confirmation's Send message)", ( await rig.stop() await rig.settleAccepted(working, 'stopped') const views = await watchClient(outbox) - expect(views.at(-1)).toMatchObject({ header: true, button: 'resume', cards: 2 }) + expect(views.at(-1)).toMatchObject({ header: true, button: 'resume', dialog: true, cards: 2 }) return { first, second, views } } - it("goes out at once as the person's turn: the paused row goes as it is sent and never comes back, and the held cards follow it in order", async () => { + it('goes out at once: the paused row goes as it is sent and never comes back, and the held cards follow it in order', async () => { const outbox: ComposerOutbox = { entries: [] } const { first, second, views } = await heldIdleQueue(outbox) // Sent with the composer's queue delivery: no card ahead may send, so nothing queues it. @@ -251,10 +425,7 @@ describe("a message sent over a held queue (the confirmation's Send message)", ( value: { submission: expect.anything() } }) // The cards stay held until the agent accepts the message's turn. - expect(await heldBy()).toEqual({ - [first]: { reason: 'stopped' }, - [second]: { reason: 'stopped' } - }) + expect((await published()).queuePause).toEqual({ reason: 'stopped' }) await eventually(async () => expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() ) @@ -331,8 +502,7 @@ describe('where the host would refuse the send', () => { messageId: card, body: hostTestMessage('left on the source'), fingerprint: 'fp-left-behind', - hostInstance: structuredAgentSessionHostInstance(), - origin: 'client' + hostInstance: structuredAgentSessionHostInstance() }) const record = rig.store.getRecord(HOST_TEST_SESSION) if (!record) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts index 144a43302b71..dda3d2112cd3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -160,8 +160,7 @@ describe('/clear', () => { expect( await rig.host.send(CALLER, { envelope: rig.envelope(fields, 'agentSession.send', sentId, replacementId), - ...fields, - userSend: true + ...fields }) ).toMatchObject({ ok: true, value: { submission: expect.anything() } }) await eventually(() => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts index c5a34298a83c..09ba1797b29a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts @@ -128,8 +128,7 @@ describe('replay preference', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const, - userSend: true as const + delivery: 'queue-if-active' as const } expect(await host.send(CALLER, params)).toMatchObject({ ok: true, @@ -167,8 +166,7 @@ describe('replay of a deleted card', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const, - userSend: true as const + delivery: 'queue-if-active' as const } await host.send(CALLER, params) await settleAccepted(working, 'a') @@ -211,8 +209,7 @@ describe('the hand-off link on answers', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const, - userSend: true as const + delivery: 'queue-if-active' as const } await host.send(CALLER, params) await settleAccepted(working, 'a') @@ -244,8 +241,7 @@ describe('the hand-off link on answers', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const, - userSend: true as const + delivery: 'queue-if-active' as const } await host.send(CALLER, params) await settleAccepted(working, 'a') 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 f94d091a5f97..cfb4c0321b00 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 @@ -138,17 +138,15 @@ export async function createQueuedMessageTestRig( } } - /** A client's send, as the `agentSession.send` RPC hands it to the host; - * `internal` is a host-side sender (orchestration mail, a restart continuation). */ - function send(text: string, delivery?: 'queue-if-active', options?: { internal?: true }) { + /** A send as the host takes it: a client's over the `agentSession.send` RPC, or Orca's own. */ + function send(text: string, delivery?: 'queue-if-active') { const body = hostTestMessage(text) const clientOperationId = hostTestOperationId() const fields = { body, ...(delivery ? { delivery } : {}) } const result = host.send(QUEUED_RIG_CALLER, { envelope: envelope(fields, 'agentSession.send', clientOperationId), body, - ...(delivery ? { delivery } : {}), - ...(options?.internal ? {} : { userSend: true as const }) + ...(delivery ? { delivery } : {}) }) return { id: clientOperationId, result } } 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 3011fa194d58..c5d5b3c77696 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 @@ -79,8 +79,7 @@ describe('accept', () => { clientOperationId ), body, - delivery: 'queue-if-active' as const, - userSend: true as const + delivery: 'queue-if-active' as const } expect(await host.send(CALLER, params)).toMatchObject({ ok: true, @@ -110,8 +109,7 @@ describe('accept', () => { clientOperationId ), body, - delivery: 'queue-if-active', - userSend: true + delivery: 'queue-if-active' }) expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) expect(await drafts()).toHaveLength(0) @@ -264,7 +262,7 @@ describe('drain', () => { }) describe('held drafts', () => { - it('a restart pauses the queue, reason restarted, and it survives a reopen; never auto-sent', async () => { + it('a restart holds the queue without ever showing a pause, across a reopen; never auto-sent', async () => { const working = await workingSend() const queued = await send('written before the restart', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { @@ -273,11 +271,11 @@ describe('held drafts', () => { const draftId = queued.value.queued.messageId await rig.restartHostProcess() await settleAccepted(working, 'a') - // The queue is paused, not the card: it carries no hold of its own. + // The queue is held, not the card: it carries no hold of its own, and no pause is published. expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.queuePause()).toBeNull() await host.close(SESSION, 'evict') - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.queuePause()).toBeNull() await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() expect(await sendNow(draftId)).toMatchObject({ @@ -286,7 +284,7 @@ describe('held drafts', () => { }) }) - it("a restart's pause also lifts when the user's next send starts its turn, exactly like a Stop's", async () => { + it("a restart's hold lifts when the user's next send starts its turn, exactly like a Stop's", async () => { const working = await workingSend() const queued = await send('written before the restart', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { @@ -297,11 +295,11 @@ describe('held drafts', () => { await settleAccepted(working, 'a') await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.queuePause()).toBeNull() // The user's send starting its turn lifts it, and adopts the row into this instance. const next = send('user starts a new turn') await next.result - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.handoff(draftId)).toBeUndefined() await settleAccepted(next.id, 'b') await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) @@ -459,7 +457,7 @@ describe('Stop and Delete', () => { await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) - it('a host-internal send (orchestration mail, a restart continuation, a host-sent launch prompt) never lifts the pause', async () => { + it("a host-internal send (orchestration mail, a restart continuation, a host-sent launch prompt) lifts the pause once accepted, like a person's", async () => { const working = await workingSend() const queued = await send('paused by stop', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { @@ -469,15 +467,14 @@ describe('Stop and Delete', () => { await stop() await settleAccepted(working, 'a') // All three reach the host as a send the client send RPC did not make. - const mail = send('coordinator mail', undefined, { internal: true }) + const mail = send('coordinator mail') expect(await mail.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) - // The journal records who asked, which is what the pause reads. - expect(await rig.submission(mail.id)).toMatchObject({ origin: 'host' }) - expect(await rig.submission(working)).toMatchObject({ origin: 'client' }) - await settleAccepted(mail.id, 'b') await new Promise((resolve) => setTimeout(resolve, 250)) - expect(await rig.handoff(draftId)).toBeUndefined() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.handoff(draftId)).toBeUndefined() + await settleAccepted(mail.id, 'b') + expect(await rig.queuePause()).toBeNull() + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) it("a user send lifts nothing from a 'send_failed' hold — that card waits for its explicit Send", 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 fe6a72bc951b..5689b9144e0e 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 @@ -60,8 +60,9 @@ export function pendingPromptExists(journal: Pick ): QueuedMessageRow | null { @@ -211,7 +212,6 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' - userSend?: true } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -252,8 +252,7 @@ export async function maybeQueueStructuredAgentSessionSend( messageId: clientMessageId, body: params.body, fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance(), - origin: params.userSend ? 'client' : 'host' + hostInstance: structuredAgentSessionHostInstance() }, ctx.operationReceipt ) @@ -362,9 +361,6 @@ export class StructuredAgentSessionQueuedMessageDrain { await journal.appendSubmission( { clientMessageId: submissionId, - // Whoever wrote the card asked for this turn: a person's ends a Stop's pause, so the cards - // it held follow; Orca's own does not. - origin: next.origin, payloadFingerprint: next.fingerprint, body: next.body, fence, 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 d3c06d9609c4..b9bde73d94d9 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 @@ -117,8 +117,7 @@ export async function carryQueuedMessagesToClearReplacement( body: row.body, fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId, - origin: row.origin + carriedFrom: ctx.sessionId }) } await withdrawQueuedMessagesForOperation(ctx.journal, { @@ -222,8 +221,6 @@ export function sendQueuedStructuredAgentMessage( await ctx.journal.appendSubmission( { clientMessageId: submissionId, - // The person asked for this turn, so it ends a Stop's pause once it starts. - origin: 'client', payloadFingerprint: row.fingerprint, body: row.body, 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 ef6c6ee95328..154470f2a5af 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 @@ -1,6 +1,6 @@ -// A Stop pauses the cards queued before it, derived from the journal: it lasts until a turn a -// person asked for (a send over the client RPC, or a card of theirs, sent now or by the queue) -// starts — the provider accepts it, never merely the host — or they Resume. +// A Stop pauses the whole queue, derived from the journal: it lasts until any turn +// sent after it starts — the provider accepts it, never merely the host — or the +// person Resumes. Whoever sent that turn: a person, Orca's own mail, or the queue. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -41,6 +41,15 @@ async function expectPaused(...draftIds: string[]): Promise { expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) } +/** The pauses in force, published or not: a restart's is never published. */ +function derivedPauses(): string[] { + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + return structuredQueuePauses(journal).map((pause) => pause.reason) +} + async function queuedDraft(text: string): Promise { const queued = await rig.send(text, 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { @@ -99,24 +108,27 @@ describe("a Stop's queue pause", () => { 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' })) + // The Stop's pause is over; the restart's own, never shown, lasts until a turn sent since it. + await eventually(async () => expect(derivedPauses()).toEqual(['restarted'])) + expect(await rig.queuePause()).toBeNull() + expect(await rig.handoff(draftId)).toBeUndefined() const next = rig.send('sent after the restart') await next.result await rig.settleAccepted(next.id, 'next') await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) - it('a draft typed while the stopped turn winds down sends once it ends; the older one follows once that turn starts', 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 eventually(async () => expect(await rig.handoff(typedId)).toBeDefined()) - await expectPaused(olderId) - await rig.settleAccepted(await rig.handoffId(typedId), 'typed') - await eventually(async () => expect(await rig.handoff(olderId)).toBeDefined()) + 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 () => { @@ -127,13 +139,13 @@ describe("a Stop's queue pause", () => { await rig.settleAccepted(working, 'stopped') expect(await rig.sendNow(sentId)).toMatchObject({ ok: true, - value: { submission: { origin: 'client', queuedMessageId: sentId } } + value: { submission: { queuedMessageId: sentId } } }) await handedOver(sentId) // Only the card the user asked for went: the queue is still paused. await expectPaused(heldId) expect(await rig.drafts()).toEqual([{ messageId: heldId, state: 'waiting' }]) - // A turn the user asked for has now started, which ends the pause. + // A turn sent after the Stop has now started, which ends the pause. await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') await eventually(async () => expect(await rig.handoff(heldId)).toBeDefined()) }) @@ -166,8 +178,7 @@ describe("a Stop's queue pause", () => { const body = hostTestMessage('work on this') const replayed = await rig.host.send(QUEUED_RIG_CALLER, { envelope: rig.envelope({ body }, 'agentSession.send', working), - body, - userSend: true + body }) expect(replayed).toMatchObject({ ok: true, @@ -175,15 +186,19 @@ describe("a Stop's queue pause", () => { value: { submission: { dispatchState: 'accepted' } } }) // A later journal commit re-derives the pause; the old send was accepted before the Stop. - const mail = rig.send('coordinator mail', undefined, { internal: true }) - await mail.result - await rig.settleAccepted(mail.id, 'mail') + rig + .providerEvents() + .appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-later', ordinal: 901 }, + { kind: 'turn', turnId: 'turn-later', state: 'completed', startedAt: 1 }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await expectPaused(draftId) }) }) describe('the pause read', () => { - it("costs no scan of the submissions: the reducer keeps the latest person's accepted turn", async () => { + it('costs no scan of the submissions: the reducer keeps the latest accepted turn', async () => { const draftId = await stoppedDraft() const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal if (!journal) { @@ -194,14 +209,11 @@ describe('the pause read', () => { expect(structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) expect(scan).not.toHaveBeenCalled() scan.mockRestore() - const mail = rig.send('coordinator mail', undefined, { internal: true }) + const mail = rig.send('coordinator mail') await mail.result - await rig.settleAccepted(mail.id, 'mail') - // Orca's own turn lifts nothing; a person's does. expect(structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) - const next = rig.send('user starts a new turn') - await next.result - await rig.settleAccepted(next.id, 'next') + // Orca's own turn, accepted after the Stop, lifts it like a person's. + await rig.settleAccepted(mail.id, 'mail') expect(structuredQueuePauses(journal)).toEqual([]) await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) @@ -226,7 +238,7 @@ describe('a card queued after a Stop is a new instruction', () => { expect(await rig.queuePause()).toBeNull() await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') await rig.settleAccepted(working, 'stopped') - const mail = rig.send('coordinator mail', undefined, { internal: true }) + const mail = rig.send('coordinator mail') await mail.result await eventually(async () => expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() @@ -234,8 +246,8 @@ describe('a card queued after a Stop is a new instruction', () => { const later = await queuedDraft('typed during the mail turn') 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 && structuredQueuePauses(journal)).toMatchObject([{ reason: 'stopped' }]) + // The mail's turn, sent after the Stop, ended it. + expect(derivedPauses()).toEqual([]) }) it("deleting the last paused card hides the pause; a person's next turn is what ends it", async () => { @@ -342,13 +354,14 @@ describe('a pause only over cards Resume could send', () => { }) describe("a restart's pause", () => { - it("once a person's turn ends it, stays ended when the conversation reopens", async () => { + it('once a turn ends it, stays ended when the conversation reopens', async () => { const working = await rig.workingSend() const first = await queuedDraft('first') const second = await queuedDraft('second') await rig.restartHostProcess() await rig.settleAccepted(working, 'a') - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.queuePause()).toBeNull() + expect(derivedPauses()).toEqual(['restarted']) const next = rig.send('user starts a new turn') await next.result await rig.settleAccepted(next.id, 'b') @@ -430,12 +443,11 @@ describe('Resume', () => { await expectPaused(draftId) }) - it("lifts a restart's pause too", async () => { - const working = await rig.workingSend() - const draftId = await queuedDraft('written before the restart') - await rig.restartHostProcess() - await rig.settleAccepted(working, 'a') - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + it("of a Stop from before a restart also lifts the restart's pause", async () => { + const draftId = await stoppedDraft() + rig.crashRestartHostProcess() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(derivedPauses()).toEqual(['stopped', 'restarted']) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) }) 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 7b85ede6fe92..6d29bcf74a1b 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 @@ -30,8 +30,7 @@ export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[ } /** - * 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 + * Every journal publish: a restart's rows are adopted into this instance once a 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 adoptEndedRestartPause( 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 f4aff39a14c8..18bfa4f91ea5 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' import { nextStructuredQueuedMessage } from './structured-agent-session-queued-messages' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' @@ -42,11 +38,8 @@ export function structuredQueueSendGate( } /** Waiting and returned rows only. `paused` is a per-card hold (a failed - * conversion); `heldBy` names the queue-level pause holding the card, if any. */ -function computePublishedQueuedMessages( - journal: AgentSessionJournal, - pauses: readonly DerivedQueuePause[] -): AgentSessionQueuedMessage[] { + * conversion); a Stop or a /clear pauses the queue, published once beside it. */ +function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { const published: AgentSessionQueuedMessage[] = [] for (const row of journal.queuedMessages.list()) { if (row.state !== 'waiting' && row.state !== 'returned') { @@ -63,7 +56,6 @@ function computePublishedQueuedMessages( ...(held && row.holdReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED ? { pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED } : {}), - heldBy: heldBy(pauses, row), ...(row.state === 'returned' ? { returnedReason: row.returnedReason } : {}), ...(row.state === 'returned' && row.returnedRejection ? { returnedRejection: row.returnedRejection } @@ -73,33 +65,21 @@ function computePublishedQueuedMessages( return published } -function heldBy( - pauses: readonly DerivedQueuePause[], - row: Parameters[1] -): AgentSessionQueuePause | null { - const holding = queuePauseHolding(pauses, row) - return holding ? { reason: holding.reason } : null -} - type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessage[] } /** Reference-stable per journal handle: an unchanged list is never - * re-serialized onto token-stream frames, and any draft-table write or change - * of the pauses in force changes the reference by construction. */ + * re-serialized onto token-stream frames, and any draft-table write changes + * the reference by construction. */ const listMemos = new WeakMap() const publications = new WeakMap() -function readPublishedQueuedMessages( - journal: AgentSessionJournal, - pauses: readonly DerivedQueuePause[] -): AgentSessionQueuedMessage[] { - // The pauses also turn on journal rows (a Stop, a person's turn), not only on the drafts. - 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) { @@ -123,13 +103,12 @@ export function readQueuePublication( journal: AgentSessionJournal, gate: QueueSendGate ): QueuePublication { - // Read per emit: the pauses also turn on submissions (a person's turn starting). - const pauses = structuredQueuePauses(journal) - const queuedMessages = readPublishedQueuedMessages(journal, pauses) - // Shown only over a card Resume would send, so a header never offers to send nothing; - // deleting a blocking returned card shows it again. - const pause = resumableQueuePause(pauses, journal.queuedMessages.list()) - const queuePause = pause ? { reason: pause.reason } : null + const queuedMessages = readPublishedQueuedMessages(journal) + // Read per emit: the pause also turns on submissions (a 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. A restart's pause is never shown: the chat's next turn lifts it. + const pause = resumableQueuePause(structuredQueuePauses(journal), journal.queuedMessages.list()) + const queuePause = pause && pause.reason !== 'restarted' ? { reason: pause.reason } : null const nextQueuedMessageId = nextStructuredQueuedMessage({ journal, ...gate() })?.messageId ?? null const previous = publications.get(journal) if ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index d411efc42395..0fa49460d5e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -1,6 +1,5 @@ -// A send's queue step around its immediate path: the queue decision before it. A -// person's send lifts a paused queue through its recorded origin once its turn -// starts (`structured-agent-session-queued-pause.ts`), not through anything here. +// A send's queue step around its immediate path: the queue decision before it. Its +// accepted turn lifts a paused queue (`queued-message-pause.ts`), not anything here. import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' @@ -15,7 +14,6 @@ export async function runQueueableStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' - userSend?: true }, immediate: () => Promise> ): Promise> { 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 300f5eed3e74..5080ea935a56 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,8 +1,7 @@ // 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 past -// them and they follow it, a withdrawn card comes back under it, a crash keeps it, it -// never hides a restart's pause, a restart's holds only cards written before 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, any +// turn sent after it ends it, and no stored pause is ever written. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -46,17 +45,17 @@ function journal(sessionId = HOST_TEST_SESSION) { return open } -/** No drain step converts the cards, and the published pause names `reason`. */ -async function expectHeld(reason: string, ...draftIds: string[]): Promise { +/** No drain step converts the cards, and the published pause names `reason` (null: none shown). */ +async function expectHeld(reason: string | null, ...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 }) + expect(await rig.queuePause()).toEqual(reason === null ? null : { reason }) } async function mailTurn(): Promise { - const mail = rig.send('coordinator mail', undefined, { internal: true }) + const mail = rig.send('coordinator mail') await mail.result await eventually(async () => expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined()) return mail.id @@ -106,34 +105,31 @@ describe("Stop's event", () => { await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) }) - it('a card queued after the Stop sends past the cards it holds, which then follow it in order', 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 first = await queuedDraft('queued before the stop') - const second = await queuedDraft('also queued before the stop') + const held = await queuedDraft('queued before the stop') await rig.stop() + const later = await queuedDraft('typed while the stopped turn winds down') await rig.settleAccepted(working, 'stopped') - const mail = await mailTurn() - const later = await queuedDraft('queued during the mail turn') - await rig.settleAccepted(mail, 'mail') - // Held cards are skipped, not waited on: the newer card goes; the held ones wait. + // 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()) - await expectHeld('stopped', first, second) - // The card is the person's, so its send is their turn: once it starts, the Stop is over. - expect(await rig.handoff(later)).toMatchObject({ origin: 'client' }) - await rig.settleAccepted(await rig.handoffId(later), 'later') - expect(await rig.queuePause()).toBeNull() - await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - expect(await rig.handoff(second)).toBeUndefined() - await rig.settleAccepted(await rig.handoffId(first), 'first') - await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) }) - it("a person's accepted turn lifts it; a host turn and a later Stop do not", async () => { + it("any accepted turn sent after it lifts it, Orca's own mail included; a later Stop supersedes", 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 @@ -141,9 +137,10 @@ describe("Stop's event", () => { 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') + // Orchestration mail sent after the second Stop: once accepted, the queue carries on. + const mail = await mailTurn() + await expectHeld('stopped', first) + await rig.settleAccepted(mail, 'mail') await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) }) @@ -163,13 +160,9 @@ describe("Stop's event", () => { 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(sends.map((entry) => entry.dispatchState)).toEqual(['rejected']) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - // Resume's drain sends it again under a fresh id, still the person's turn. await eventually(async () => expect(await rig.handoffId(sentId)).not.toBe(handoffId)) - expect((await rig.handoff(sentId))?.origin).toBe('client') }) it('survives a host crash: the next open marks the hand-off unknown, the pause stays, Resume sends', async () => { @@ -188,26 +181,27 @@ describe("Stop's event", () => { }) }) -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 () => { +describe("a restart's hold over a card queued after a Stop", () => { + it('a card queued after a Stop over an empty queue, before a restart, waits unshown', 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') - // The process dies with no close: a quit writes no Stop event either, so the Stop's pause stays. + // The process dies with no close: a quit writes no Stop event, so only a turn ends the pauses. rig.crashRestartHostProcess() await rig.settleAccepted(mail, 'mail') - // The new host opens the conversation for its first reader. + // The new host opens the conversation for its first reader. The card came after the Stop, so + // only the restart holds it, and a restart's hold is never published. await rig.queuePause() expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([ 'stopped', 'restarted' ]) - await expectHeld('restarted', typed) + await expectHeld(null, typed) }) - it("a card queued during the queue's own send after a Stop, before a restart, waits restarted", async () => { + it("a card queued during the queue's own send after a Stop, before a restart, waits unshown", async () => { const working = await rig.workingSend() await rig.stop() const correction = await queuedDraft('typed while the interrupt lands') @@ -219,28 +213,8 @@ describe("a Stop never hides a restart's pause", () => { rig.crashRestartHostProcess() await rig.settleAccepted(await rig.handoffId(correction), 'correction') await rig.queuePause() - // That send was the person's turn, so it ended the Stop; it started before the restart, so - // the restart's pause stands. expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['restarted']) - await expectHeld('restarted', typed) - }) -}) - -describe("a restart's pause", () => { - it("holds only cards written before it: one typed during Orca's own turn sends when that turn ends, and the older card follows once it starts", async () => { - const working = await rig.workingSend() - const before = await queuedDraft('written before the restart') - await rig.restartHostProcess() - await rig.settleAccepted(working, 'a') - // Orca's own turn after the restart, such as its continuation. - const continuation = await mailTurn() - const typed = await queuedDraft('typed during the continuation') - expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) - await rig.settleAccepted(continuation, 'continuation') - await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) - expect(await rig.handoff(before)).toBeUndefined() - await rig.settleAccepted(await rig.handoffId(typed), 'typed') - await eventually(async () => expect(await rig.handoff(before)).toBeDefined()) + await expectHeld(null, typed) }) }) @@ -253,7 +227,7 @@ describe("a /clear's carried cards", () => { }) } - it("wait 'cleared' on the replacement until a person's turn there", async () => { + it("wait 'cleared' on the replacement until a turn there", async () => { const working = await rig.workingSend() const carried = await queuedDraft('written for the old context') await rig.stop() @@ -273,8 +247,7 @@ describe("a /clear's carried cards", () => { hostTestOperationId(), replacementId ), - body: text, - userSend: true + body: text }) const sent = await person if (!sent.ok || !('submission' in sent.value)) { 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 03c1042309fa..964cefc1b2f5 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,8 +1,8 @@ // 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 +// (`queued-message-pause.ts`) until any turn is sent after it and accepted, or the +// person Resumes. 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. 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 6fd6bb136125..af7aee44bc98 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 @@ -40,7 +40,7 @@ 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 adopts a restart's cards once a person's turn started. */ + * and adopts a restart's cards once a turn started. */ onJournalActivity: (sessionId: string) => { sessions.touch(sessionId) const journal = sessions.get(sessionId)?.journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 59d478cbb40a..616cf3e89704 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -165,18 +165,16 @@ export async function interruptedRestart( return { ...hostTestState(), host, store, log, closeSession, marker, clock } } -/** The continuation's submission commits, then its send throws: a send Orca may have taken. */ +/** The continuation's submission commits, then its send throws: a send Orca may have taken. + * Install it right before the continuation, the only submission written from then on. */ export function throwAfterContinuationAccepted(): void { const append = AgentSessionJournal.prototype.appendSubmission vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockImplementation(async function ( this: AgentSessionJournal, ...args: Parameters ) { - const cursor = await append.apply(this, args) - if (args[0].origin === 'host') { - throw new Error('the accepted continuation could not be answered') - } - return cursor + await append.apply(this, args) + throw new Error('the accepted continuation could not be answered') }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index c8fe5d9da5d1..4fa2811472a4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -518,8 +518,7 @@ describe('startup restore of chats still in their per-chat files', () => { messageId: 'draft-1', body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, fingerprint: 'fp-draft-1', - hostInstance: 'proc-1', - origin: 'client' + hostInstance: 'proc-1' }) expect(journal.importPending).toBe(false) 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 a91ed7c8cbd4..fa2ac365bb8c 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 @@ -121,7 +121,7 @@ async function stopOfStart(options: { held?: true } = {}): Promise { - const mail = rig.send('mail for the worker', undefined, { internal: true }) + const mail = rig.send('mail for the worker') await mail.result await eventually(() => expect(rig.dispatch).toHaveBeenCalled()) await turnOpenedBy(mail.id) @@ -155,7 +155,7 @@ describe('a Stop of a start that never landed binds no later turn', () => { 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 }) + const mail = rig.send('mail for the worker') await mail.result await turnOpenedBy(mail.id) @@ -263,7 +263,7 @@ describe('a Stop pressed before its send opened a turn binds only the turn it st 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 }) + const mail = rig.send('mail for the lead') await mail.result await turnOpenedBy(mail.id) @@ -389,7 +389,7 @@ describe('a host stop with no turn running after a Stop that named none', () => rig = await createQueuedMessageTestRig() await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true }) - const mail = rig.send('mail for the lead', undefined, { internal: true }) + const mail = rig.send('mail for the lead') await mail.result await eventually(async () => expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined() @@ -429,7 +429,7 @@ describe('a rewind that restates a turnless Stop', () => { body: { ...ended, completedAt: Date.now() + 1, outcome: 'cancellation' } } ]) - const mail = rig.send('mail after the rewind', undefined, { internal: true }) + const mail = rig.send('mail after the rewind') await mail.result await turnOpenedBy(mail.id) await turnOpenedBy(mail.id, 'interrupted') 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 dd0acf82b371..ad9ad0ee8048 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 @@ -385,8 +385,8 @@ describe("a person's Stop pause and the Stop events after it", () => { 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 + // Orchestration mail's turn runs, but its send is not accepted yet, so it lifts nothing yet. + await rig.send('mail for the lead').result await journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-mail', ordinal: 999 }, { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: 1 }, @@ -415,7 +415,7 @@ describe("a person's Stop pause and the Stop events after it", () => { await idleSweep().tick() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) holdStart() - rig.send('mail for the lead', undefined, { internal: true }) + rig.send('mail for the lead') await eventually(async () => expect(rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe( 'starting' 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 9a978877888b..55eb9080a5b1 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 @@ -322,7 +322,7 @@ describe('a restart between a Stop and its turn end', () => { ) expect(settled(TURN).turn).toMatchObject({ outcome: 'cancellation' }) // A host send after the Stop opens its own turn. - const drained = rig.send('drained after the Stop', undefined, { internal: true }) + const drained = rig.send('drained after the Stop') await drained.result await rig.settleAccepted(drained.id, 'drained') await journal().appendItem( 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..7f244e6f019e 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 @@ -125,8 +125,6 @@ export async function performSend( clientMessageId: string payloadFingerprint: string body: AgentJournalMessageItem - /** Who asked for the turn; absent on callers that predate it. */ - origin?: 'client' | 'host' } ): Promise> { const existing = ctx.journal diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts index e420176c70cc..ed989fb83e83 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts @@ -37,12 +37,6 @@ describe('agentSession.send reply timing', () => { }) }) - it("marks a client's send as the user's own, which alone lifts a Stop's queue pause", async () => { - hostCalls.send.mockResolvedValueOnce(pendingSendResult()) - await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) - expect(hostCalls.send.mock.calls[0]?.[1]).toMatchObject({ userSend: true }) - }) - it('answers at acceptance for the local desktop and paired desktop clients (W2)', async () => { for (const clientCapabilities of [ DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts index 47afaa8ac735..ba2d9279a51c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts @@ -20,8 +20,7 @@ export async function sendStructuredAgentSessionForClient( context: RpcContext ) { const host = requireStructuredHost(context) - // Only a client's own send lifts a Stop's queue pause; host-internal senders never do. - const result = await host.send(structuredCallerFor(context), { ...params, userSend: true }) + const result = await host.send(structuredCallerFor(context), params) const capabilities = context.clientCapabilities ?? [] if ( !result.ok || 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 b7bbd283fd8c..2da1d5713af4 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 @@ -132,8 +132,7 @@ async function queueThenSendNow(text: string): Promise<{ messageId: string; sent const queued = await host.send(CALLER, { envelope: envelope('agentSession.send', { body, delivery }), body, - delivery, - userSend: true + delivery }) if (!queued.ok || !('queued' in queued.value)) { throw new Error(`expected a queued card: ${JSON.stringify(queued)}`) @@ -348,10 +347,9 @@ describe('a queued card sent now into the turn a Stop ends', () => { return (await settled()).submissions.filter((entry) => entry.queuedMessageId === messageId) } - /** Each hand-off of the card, as who sent it and how it settled. */ + /** Each hand-off of the card, as how it settled. */ async function sends(messageId: string) { return (await handoffs(messageId)).map((entry) => ({ - origin: entry.origin, verdict: verdictOf([entry], entry.clientMessageId) })) } @@ -389,7 +387,7 @@ describe('a queued card sent now into the turn a Stop ends', () => { expect({ ...(await queue()), sends: await sends(cardId) }).toEqual({ pause: { reason: 'stopped' }, cards: [{ messageId: cardId, state: 'waiting' }], - sends: [{ origin: 'client', verdict: 'withdrawn' }] + sends: [{ verdict: 'withdrawn' }] }), { timeout: 5_000 } ) @@ -404,7 +402,7 @@ describe('a queued card sent now into the turn a Stop ends', () => { clock.restore() } expect(steers + answers).toBe(2) - expect(await sends(cardId)).toEqual([{ origin: 'client', verdict: 'withdrawn' }]) + expect(await sends(cardId)).toEqual([{ verdict: 'withdrawn' }]) }, 20_000) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 9481dd9008c9..3b35359565f6 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -89,7 +89,7 @@ function waitingDraft( } /** The list over the real controller: cards and header as the host's publication projects them. - * Nothing runs after a Stop, a restart or a /clear. */ + * Nothing runs after a Stop or a /clear. */ function renderHeldQueue( queuedMessages: AgentSessionQueuedMessage[], queuePause: AgentSessionQueuePause | null @@ -288,7 +288,6 @@ describe('NativeChatQueuedMessageList', () => { it.each([ ['stopped', 'Queue paused because you interrupted'], - ['restarted', 'Queue paused because Orca restarted'], ['cleared', 'Queue paused after you cleared the conversation'], ['some-newer-reason', 'Queue paused'] ])( @@ -296,10 +295,7 @@ describe('NativeChatQueuedMessageList', () => { (reason, text) => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a newer host may publish a reason this client's type does not list. const pause = { reason } as AgentSessionQueuePause - const { container, mutate } = renderHeldQueue( - [waitingDraft('held', 1, { heldBy: pause })], - pause - ) + const { container, mutate } = renderHeldQueue([waitingDraft('held', 1)], pause) expect(container.textContent).toContain(text) const list = screen.getByRole('list') // The row sits above the list, not inside it: it is not a queued message. @@ -318,14 +314,15 @@ describe('NativeChatQueuedMessageList', () => { } ) - it('an older host that names no per-card hold: its queue pause holds every card and heads them', () => { + it('a paused queue holds every card, in order, under one header', () => { renderHeldQueue([waitingDraft('a', 1), waitingDraft('b', 2)], { reason: 'stopped' }) expect(screen.getByText('Queue paused because you interrupted')).toBeTruthy() expect(screen.getAllByRole('button', { name: 'Steer' })).toHaveLength(2) }) - it('no header row when the queue drains on its own, or when there are no cards', () => { - renderHeldQueue([waitingDraft('typed-after', 1, { heldBy: null })], { reason: 'stopped' }) + it('no header row when the queue drains on its own (or after a restart), or with no cards', () => { + renderHeldQueue([waitingDraft('waiting', 1)], null) + expect(screen.queryByText(/Queue paused/)).toBeNull() expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() cleanup() const { container } = renderHeldQueue([], { reason: 'stopped' }) @@ -337,9 +334,9 @@ describe('NativeChatQueuedMessageList', () => { const stopped = { reason: 'stopped' } as const const { container } = renderHeldQueue( [ - waitingDraft('refused', 1, { state: 'returned', returnedReason: null, heldBy: null }), - waitingDraft('behind', 2, { heldBy: stopped }), - waitingDraft('failed', 3, { paused: true, pausedReason: 'send_failed', heldBy: stopped }) + waitingDraft('refused', 1, { state: 'returned', returnedReason: null }), + waitingDraft('behind', 2), + waitingDraft('failed', 3, { paused: true, pausedReason: 'send_failed' }) ], stopped ) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index c9d3c2f1d1b4..ade8680fe793 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -80,11 +80,6 @@ function queuePauseText(pause: { reason: string }): string { 'components.native-chat.queuedMessages.queuePausedStopped', 'Queue paused because you interrupted' ) - case 'restarted': - return translate( - 'components.native-chat.queuedMessages.queuePausedRestarted', - 'Queue paused because Orca restarted' - ) case 'cleared': return translate( 'components.native-chat.queuedMessages.queuePausedCleared', 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 36562dbd0f98..ee8e3ea34161 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 @@ -12,8 +12,7 @@ import { ownDirectSendOnItsWay, projectQueuedMessageCards, queuedMessageCardSteers, - queuedMessagesQueuePause, - queuedMessagesResumable + queuedMessagesQueuePause } from './structured-agent-session-queued-cards' function draft( @@ -150,7 +149,7 @@ describe('queued message cards', () => { draft('behind', 4) ], [], - { hasPendingPrompt: true, queuePause: { reason: 'stopped' } } + { hasPendingPrompt: true, queuePaused: true } ) expect(cards.map((card) => card.hold)).toEqual([ 'queue-paused', @@ -163,69 +162,34 @@ describe('queued message cards', () => { ).toBe('awaiting-answer') }) - it("a host that names each card's hold holds only those cards; one that does not holds every card", () => { - const named = [ - draft('held', 1, { heldBy: { reason: 'stopped' } }), - draft('typed-after', 2, { heldBy: null }) - ] - const paused = { hasPendingPrompt: true, queuePause: { reason: 'restarted' } } as const - // The card queued after the Stop still says it waits for the answer. - expect(projectQueuedMessageCards(named, [], paused).map((card) => card.hold)).toEqual([ - 'queue-paused', - 'awaiting-answer' - ]) - const older = [draft('held', 1), draft('typed-after', 2)] - expect(projectQueuedMessageCards(older, [], paused).map((card) => card.hold)).toEqual([ - 'queue-paused', - 'queue-paused' - ]) - }) - - it('Resume only over a card a pause holds, and only while nothing runs', () => { - const project = (messages: AgentSessionQueuedMessage[]) => - projectQueuedMessageCards(messages, [], { - hasPendingPrompt: false, - queuePause: { reason: 'stopped' } - }) - const held = draft('held', 1, { heldBy: { reason: 'stopped' } }) - expect(queuedMessagesResumable(project([held]), false)).toBe(true) - // Working counts the queue's coming send, which the host names. - expect(queuedMessagesResumable(project([held]), true)).toBe(false) - const failed = draft('failed', 1, { paused: true, pausedReason: 'send_failed', heldBy: null }) - expect(queuedMessagesResumable(project([failed]), false)).toBe(false) - expect(project([held, draft('next', 2, { heldBy: null })]).map((card) => card.hold)).toEqual([ - 'queue-paused', - 'turn' - ]) - const [card] = project([held]) - // Still Steer while nothing runs: the header row, not the card, says it waits. - expect(card && queuedMessageCardSteers(card)).toBe(true) + it('a paused queue holds every waiting card, in order: an answer does not drain it', () => { + const cards = projectQueuedMessageCards([draft('held', 1), draft('typed-after', 2)], [], { + hasPendingPrompt: true, + queuePaused: true + }) + expect(cards.map((card) => card.hold)).toEqual(['queue-paused', 'queue-paused']) + // Still Steer: the header row, not the card, says it waits. + expect(cards.map((card) => queuedMessageCardSteers(card))).toEqual([true, true]) }) - it("the header names the oldest held card's pause, and none over cards Resume would not send", () => { - const project = (messages: AgentSessionQueuedMessage[]) => - projectQueuedMessageCards(messages, [], { - hasPendingPrompt: false, - queuePause: { reason: 'stopped' } - }) - const restarted = draft('restarted', 1, { heldBy: { reason: 'restarted' } }) - const stopped = draft('stopped', 2, { heldBy: { reason: 'stopped' } }) - expect(queuedMessagesQueuePause(project([restarted, stopped]))).toEqual({ reason: 'restarted' }) - // An older host names no per-card hold: its queue-level pause is the reason. - expect(queuedMessagesQueuePause(project([draft('older', 1)]))).toEqual({ reason: 'stopped' }) + it("the header names the queue's pause while it holds a card, and none over cards Resume would not send", () => { + const stopped = { reason: 'stopped' } as const + const project = (messages: AgentSessionQueuedMessage[], queuePaused = true) => + projectQueuedMessageCards(messages, [], { hasPendingPrompt: false, queuePaused }) + expect(queuedMessagesQueuePause(project([draft('held', 1)]), stopped)).toEqual(stopped) + expect(queuedMessagesQueuePause(project([draft('held', 1)]), { reason: 'cleared' })).toEqual({ + reason: 'cleared' + }) const unsendable = [ - draft('returned', 1, { state: 'returned', returnedReason: null, heldBy: null }), - draft('behind', 2, { heldBy: { reason: 'stopped' } }), - draft('failed', 3, { - paused: true, - pausedReason: 'send_failed', - heldBy: { reason: 'stopped' } - }) + draft('returned', 1, { state: 'returned', returnedReason: null }), + draft('behind', 2), + draft('failed', 3, { paused: true, pausedReason: 'send_failed' }) ] - expect(queuedMessagesQueuePause(project(unsendable))).toBeNull() - expect( - queuedMessagesQueuePause(project([draft('typed-after', 1, { heldBy: null })])) - ).toBeNull() + expect(queuedMessagesQueuePause(project(unsendable), stopped)).toBeNull() + // No published pause (an idle chat after a restart): plain cards, no header. + const unpaused = project([draft('waiting', 1)], false) + expect(unpaused.map((card) => card.hold)).toEqual(['turn']) + expect(queuedMessagesQueuePause(unpaused, null)).toBeNull() }) it("only the person's own direct send, recorded and not yet accepted, is on its way to lift a pause", () => { 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 18d8aa6ac2ba..49ee3fbc83df 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 @@ -21,8 +21,8 @@ import { /** Why a card is not on its way right now; decides the caption under the text. */ export type QueuedMessageCardHold = | 'turn' - /** Held by a Stop, restart or /clear: the header row above the cards says why and offers - * Resume, so the card makes no promise of when it sends — not even after an answer. */ + /** The whole queue is paused: the header row says why and offers Resume, so the card makes + * no promise about when it sends — not even after an answer, which does not drain it. */ | 'queue-paused' | 'awaiting-answer' | 'paused' @@ -36,9 +36,6 @@ export type QueuedMessageCard = { text: string state: 'waiting' | 'returned' hold: QueuedMessageCardHold - /** The pause holding a 'queue-paused' card. A string: a newer host may name a reason this build - * does not know. */ - queuePause?: { reason: string } pausedReason?: string returnedReason?: string | null /** The typed fact the returned card's submission settled with; read like its `rejection`. */ @@ -59,7 +56,7 @@ function queuedMessageCardText(body: AgentSessionQueuedMessage['body']): string export function projectQueuedMessageCards( queuedMessages: readonly AgentSessionQueuedMessage[] | null | undefined, submissions: readonly AgentJournalSubmission[], - session: { hasPendingPrompt: boolean; queuePause?: AgentSessionQueuePause | null } + session: { hasPendingPrompt: boolean; queuePaused?: boolean } ): QueuedMessageCard[] { const handedOff = handedOffQueuedMessageIds( submissions.filter((submission) => submission.dispatchState !== 'rejected') @@ -69,8 +66,6 @@ export function projectQueuedMessageCards( .filter((message) => message.state === 'returned' || !handedOff.has(message.messageId)) let behindReturned = false return ordered.map((message) => { - // A host that predates `heldBy` publishes only the queue's pause, which then covers every card. - const heldBy = message.heldBy === undefined ? (session.queuePause ?? null) : message.heldBy const hold: QueuedMessageCardHold = message.state === 'returned' ? 'returned' @@ -78,7 +73,7 @@ export function projectQueuedMessageCards( ? 'paused' : behindReturned ? 'behind-returned' - : heldBy + : session.queuePaused ? 'queue-paused' : session.hasPendingPrompt ? 'awaiting-answer' @@ -90,7 +85,6 @@ export function projectQueuedMessageCards( text: queuedMessageCardText(message.body), state: message.state, hold, - ...(hold === 'queue-paused' && heldBy ? { queuePause: { reason: heldBy.reason } } : {}), ...(message.pausedReason !== undefined ? { pausedReason: message.pausedReason } : {}), ...(message.returnedReason !== undefined ? { returnedReason: message.returnedReason } : {}), ...(message.returnedRejection !== undefined @@ -100,18 +94,19 @@ export function projectQueuedMessageCards( }) } -/** The pause the header row names, from the oldest card it holds. A pause over cards Resume would - * not send (returned, held on their own, or behind a returned one) offers nothing to press. */ +/** The pause the header row names, while it holds a card. A pause over cards Resume would not + * send (returned, held on their own, or behind a returned one) offers nothing to press. */ export function queuedMessagesQueuePause( - cards: readonly QueuedMessageCard[] -): { reason: string } | null { - return cards.find((card) => card.hold === 'queue-paused')?.queuePause ?? null + cards: readonly QueuedMessageCard[], + queuePause: AgentSessionQueuePause | null +): AgentSessionQueuePause | null { + return cards.some((card) => card.hold === 'queue-paused') ? queuePause : null } /** The person's own message from this composer has reached the host as a direct send and waits * for the agent to accept it. Its turn will lift the queue's pause, so the header row does not - * say paused meanwhile; a refusal settles the entry and the row comes back. An Orca send never - * enters this outbox, and the queue's send of a card goes under a fresh id. */ + * say paused meanwhile; a refusal settles the entry and the row comes back. The queue's send of a + * card goes under a fresh id, so it never counts. */ export function ownDirectSendOnItsWay( outbox: readonly StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[] @@ -127,15 +122,6 @@ export function ownDirectSendOnItsWay( return outbox.some((entry) => entry.state !== 'rejected' && pending.has(entry.clientMessageId)) } -/** Whether the composer's Resume would release anything: nothing runs, and a pause holds a card. - * `isWorking` counts the queue's coming send, which the host names. */ -export function queuedMessagesResumable( - cards: readonly QueuedMessageCard[], - isWorking: boolean -): boolean { - return !isWorking && queuedMessagesQueuePause(cards) !== null -} - /** Steer names the mid-turn jump, also while the whole queue is paused; a card held on its own or * returned is not waiting on the turn, so its action is plainly Send. */ export function queuedMessageCardSteers(card: QueuedMessageCard): boolean { diff --git a/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts index b53fec950a70..b6af3bf1d139 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts @@ -23,7 +23,7 @@ export type NativeChatQueueSendConfirm = { count: number /** Delete every card, then send; a failed delete sends nothing. */ clearQueue: () => void - /** Send and keep the cards: the message is the person's turn, so they follow it. */ + /** Send and keep the cards: the message's turn lifts the pause, so they follow it. */ sendMessage: () => void /** Send nothing; the draft and its attachments stay in the composer. */ dismiss: () => void diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx index 8549d1b7862a..e82f60e73393 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx @@ -32,6 +32,7 @@ function renderController( options: { queuePause?: AgentSessionQueuePause | null enabled?: boolean + isWorking?: boolean ownSendOnItsWay?: boolean answer?: (messageId: string) => DeleteAnswer } = {} @@ -48,7 +49,7 @@ function renderController( queuePause: options.queuePause === undefined ? STOPPED : options.queuePause, submissions: [], hasPendingPrompt: false, - isWorking: options.ownSendOnItsWay === true, + isWorking: options.isWorking === true || options.ownSendOnItsWay === true, ownSendOnItsWay: options.ownSendOnItsWay === true, composerScopeKey: undefined, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller only awaits mutate; the stub answers Delete's shape. @@ -66,26 +67,35 @@ afterEach(async () => { describe('the held queue a new message asks about', () => { it('is offered while a pause holds a card, counting every card shown, held or not', () => { const { result } = renderController([ - card('held', 1, { heldBy: STOPPED }), - card('typed-after', 2, { heldBy: null }), - card('failed', 3, { paused: true, pausedReason: 'send_failed', heldBy: null }) + card('held', 1), + card('typed-after', 2), + card('failed', 3, { paused: true, pausedReason: 'send_failed' }) ]) expect(result.current.pause).toEqual(STOPPED) expect(result.current.queueHold?.count).toBe(3) }) it('is not offered when no pause holds a card, with no cards, or without the queue', () => { - const unheld = [card('typed-after', 1, { heldBy: null })] - expect(renderController(unheld).result.current.queueHold).toBeUndefined() - const ownHeld = [card('failed', 1, { paused: true, heldBy: null })] + const unheld = [card('waiting', 1)] + expect(renderController(unheld, { queuePause: null }).result.current.queueHold).toBeUndefined() + const ownHeld = [card('failed', 1, { paused: true })] expect(renderController(ownHeld).result.current.queueHold).toBeUndefined() expect(renderController([]).result.current.queueHold).toBeUndefined() - const held = [card('held', 1, { heldBy: STOPPED })] + const held = [card('held', 1)] expect(renderController(held, { enabled: false }).result.current.queueHold).toBeUndefined() }) + it('is not offered while a turn runs, though the paused row shows as the Stop winds down', () => { + const held = [card('held', 1)] + const { result } = renderController(held, { isWorking: true }) + expect(result.current.pause).toEqual(STOPPED) + expect(result.current.queueHold).toBeUndefined() + // Enter queues behind the held cards as usual; once nothing runs it asks again. + expect(renderController(held).result.current.queueHold?.count).toBe(1) + }) + it("goes, with the paused row, while the person's own message is on its way to lift the pause", () => { - const held = [card('held', 1, { heldBy: STOPPED })] + const held = [card('held', 1)] const { result } = renderController(held, { ownSendOnItsWay: true }) expect(result.current.pause).toBeNull() expect(result.current.queueHold).toBeUndefined() @@ -95,10 +105,7 @@ describe('the held queue a new message asks about', () => { }) it('Clear queue deletes every card shown, and answers true once all are gone', async () => { - const { result, mutate } = renderController([ - card('held', 1, { heldBy: STOPPED }), - card('typed-after', 2, { heldBy: null }) - ]) + const { result, mutate } = renderController([card('held', 1), card('typed-after', 2)]) let cleared: boolean | undefined await act(async () => { cleared = await result.current.queueHold?.clear() @@ -111,10 +118,9 @@ describe('the held queue a new message asks about', () => { }) it('a delete that fails makes Clear queue answer false', async () => { - const { result } = renderController( - [card('held', 1, { heldBy: STOPPED }), card('typed-after', 2, { heldBy: null })], - { answer: (messageId) => (messageId === 'held' ? null : { deleted: true, messageId }) } - ) + const { result } = renderController([card('held', 1), card('typed-after', 2)], { + answer: (messageId) => (messageId === 'held' ? null : { deleted: true, messageId }) + }) let cleared: boolean | undefined await act(async () => { cleared = await result.current.queueHold?.clear() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx index 87aa65a665e4..8796fd3aad98 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -89,7 +89,7 @@ afterEach(() => { }) describe('whether Resume is offered', () => { - it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( + it.each(['stopped', 'cleared', 'some-newer-reason'])( "over a card the host holds ('%s') with no turn running", (reason) => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a newer host may publish a reason this client's type does not list. @@ -109,6 +109,21 @@ describe('whether Resume is offered', () => { expect(renderController({ queuedMessages: [] }).result.current.queueResume).toBeUndefined() }) + it('not while a prompt waits, which holds the queue too: the composer shows beside one this build cannot answer', () => { + const { result } = renderController({ hasPendingPrompt: true }) + expect(result.current.pause).toEqual({ reason: 'stopped' }) + expect(result.current.queueResume).toBeUndefined() + expect(result.current.queueHold).toBeUndefined() + }) + + it('an idle chat with cards and no published pause (as after a restart): no row, Resume or "Send message?"', () => { + const { result } = renderController({ queuePause: null }) + expect(result.current.cards.map((entry) => entry.hold)).toEqual(['turn']) + expect(result.current.pause).toBeNull() + expect(result.current.queueResume).toBeUndefined() + expect(result.current.queueHold).toBeUndefined() + }) + it('not over cards Resume would not send: held on their own, returned, or behind one', () => { const queuedMessages = [ card('failed', { paused: true, pausedReason: 'send_failed' }), @@ -129,24 +144,23 @@ describe('while a turn runs over held cards', () => { }) describe("the header row's pause", () => { - it.each(['stopped', 'restarted', 'cleared', 'some-newer-reason'])( - "names the reason the host holds a card for ('%s')", + it.each(['stopped', 'cleared', 'some-newer-reason'])( + "names the reason the host holds the queue for ('%s')", (reason) => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a newer host may publish a reason this client's type does not list. - const heldBy = { reason } as AgentSessionQueuePause - const { result } = renderController({ queuedMessages: [card('held', { heldBy })] }) + const queuePause = { reason } as AgentSessionQueuePause + const { result } = renderController({ queuePause }) expect(result.current.pause).toEqual({ reason }) } ) - it('is absent over cards held only on their own or returned, and over cards no pause holds', () => { + it('is absent over cards held only on their own or returned, and with no published pause', () => { const queuedMessages = [ - card('failed', { paused: true, pausedReason: 'send_failed', heldBy: null }), - card('returned', { position: 2, state: 'returned', heldBy: null }) + card('failed', { paused: true, pausedReason: 'send_failed' }), + card('returned', { position: 2, state: 'returned' }) ] expect(renderController({ queuedMessages }).result.current.pause).toBeNull() - const typedAfter = [card('typed-after', { heldBy: null })] - expect(renderController({ queuedMessages: typedAfter }).result.current.pause).toBeNull() + expect(renderController({ queuePause: null }).result.current.pause).toBeNull() }) it("shares one Resume with the composer's: a press of either while one is in flight sends nothing", async () => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index c72e9b6d2690..31d834bd750e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -21,7 +21,6 @@ import { newestSteerableQueuedMessageCard, projectQueuedMessageCards, queuedMessagesQueuePause, - queuedMessagesResumable, type QueuedMessageCard } from './structured-agent-session-queued-cards' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' @@ -44,10 +43,10 @@ export type StructuredAgentSessionQueuedMessagesController = { edit: (messageId: string) => Promise /** Cmd/Ctrl+Enter: Send-now the newest card. False when there is none to steer. */ steerNewest: () => boolean - /** Present while no turn runs and the host holds a card Resume would send: the composer offers + /** Present while the header row shows and the queue could send now: the composer offers * Resume. A failure is a toast, and Resume stays the retry. */ queueResume: StructuredAgentSessionQueueResume | undefined - /** Present while the header row shows: a new message first asks whether to clear the cards. */ + /** Present while `queueResume` is: a new message first asks whether to clear the cards. */ queueHold: NativeChatQueueHold | undefined } @@ -81,7 +80,11 @@ export function useStructuredAgentSessionQueuedMessages(args: { const { queuePause } = args const cards = useMemo( - () => projectQueuedMessageCards(queuedMessages, submissions, { hasPendingPrompt, queuePause }), + () => + projectQueuedMessageCards(queuedMessages, submissions, { + hasPendingPrompt, + queuePaused: queuePause !== null + }), [hasPendingPrompt, queuePause, queuedMessages, submissions] ) const cardsRef = useRef(cards) @@ -204,17 +207,20 @@ export function useStructuredAgentSessionQueuedMessages(args: { setResuming(false) } }, [mutate]) - const resumable = enabled && queuedMessagesResumable(cards, args.isWorking) - const queueResume = useMemo( - () => (resumable ? { resume, resuming } : undefined), - [resumable, resume, resuming] - ) - const ownSendOnItsWay = args.ownSendOnItsWay === true const pause = useMemo( - () => (ownSendOnItsWay ? null : queuedMessagesQueuePause(cards)), - [cards, ownSendOnItsWay] + () => (ownSendOnItsWay ? null : queuedMessagesQueuePause(cards, queuePause)), + [cards, ownSendOnItsWay, queuePause] ) + // Resume and the "Send message?" choice only where the queue could send now: no turn runs (the + // queue's coming send counts, as the host names it) and no prompt waits, which holds the queue + // too; the composer shows beside a prompt only when this build cannot answer it. + const held = enabled && pause !== null && !args.isWorking && !hasPendingPrompt + const queueResume = useMemo( + () => (held ? { resume, resuming } : undefined), + [held, resume, resuming] + ) + // Every card shown, held or not: Clear queue empties the list the person sees. One at a time, // stopping at the first failure, so one failed press is one toast. const clear = useCallback( @@ -226,7 +232,6 @@ export function useStructuredAgentSessionQueuedMessages(args: { [removeCard] ) const count = cards.length - const held = enabled && pause !== null const queueHold = useMemo(() => (held ? { count, clear } : undefined), [held, count, clear]) return { 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 02deb70497e2..963f8b669693 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 @@ -341,7 +341,7 @@ describe('against a capable host', () => { it("hides the paused row while the person's own message is on its way, and shows it again once that send is refused", () => { items = [] queuePause = { reason: 'stopped' } - queuedMessages = [{ ...draft('held'), heldBy: queuePause }] + queuedMessages = [draft('held')] const held = render() expect(held.result.current.queuedMessages.pause).toEqual({ reason: 'stopped' }) const sent = createStructuredAgentSessionOutboxEntry({ diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2a42c585adde..1ad4750b5777 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18193,7 +18193,6 @@ "pausedSendFailed": "Couldn't send — press Send to retry.", "paused": "Paused", "queuePausedStopped": "Queue paused because you interrupted", - "queuePausedRestarted": "Queue paused because Orca restarted", "queuePausedCleared": "Queue paused after you cleared the conversation", "queuePaused": "Queue paused", "resume": "Resume", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e396da1bd3f8..19b3e821a706 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17853,7 +17853,6 @@ "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", "paused": "En pausa", "queuePausedStopped": "Cola en pausa porque interrumpiste", - "queuePausedRestarted": "Cola en pausa porque Orca se reinició", "queuePausedCleared": "Cola en pausa después de que borraste la conversación", "queuePaused": "Cola en pausa", "resume": "Reanudar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 38ffb4133073..03d290dbbbb7 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18064,7 +18064,6 @@ "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", "paused": "En pause", "queuePausedStopped": "File d'attente en pause, car vous avez interrompu", - "queuePausedRestarted": "File d'attente en pause, car Orca a redémarré", "queuePausedCleared": "File d'attente en pause après l'effacement de la conversation", "queuePaused": "File d'attente en pause", "resume": "Reprendre", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c40989a9c4af..eeb2da6cb11c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18000,7 +18000,6 @@ "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", "paused": "一時停止中", "queuePausedStopped": "中断したため、キューを一時停止しました", - "queuePausedRestarted": "Orca が再起動したため、キューを一時停止しました", "queuePausedCleared": "会話を消去したため、キューを一時停止しました", "queuePaused": "キューは一時停止中です", "resume": "再開", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c5da761137e1..fd48d7ea1747 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18000,7 +18000,6 @@ "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", "paused": "일시 중지됨", "queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다", - "queuePausedRestarted": "Orca가 다시 시작되어 대기열이 일시 중지되었습니다", "queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다", "queuePaused": "대기열이 일시 중지되었습니다", "resume": "재개", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e62fd953cfea..31068c07065f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17965,7 +17965,6 @@ "pausedSendFailed": "无法发送。点按发送以重试。", "paused": "已暂停", "queuePausedStopped": "因你中断了操作,队列已暂停", - "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", "queuePausedCleared": "清除对话后,队列已暂停", "queuePaused": "队列已暂停", "resume": "继续", diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 9613afa8a849..7bd658f740d5 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -437,9 +437,6 @@ export type AgentJournalSubmission = { /** 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. */ - origin?: 'client' | 'host' } /** Durable answer to "did my send land?", keyed by client message id. Only an diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index 5b22da35b249..66696d5066ce 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -10,13 +10,12 @@ export const QUEUED_MESSAGE_PAUSED_SEND_FAILED = 'send_failed' as const export type AgentSessionQueuedMessagePausedReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED /** The whole queue is paused and sends nothing on its own: 'stopped' — the user - * interrupted ("Queue paused because you interrupted") — 'cleared' — a /clear - * carried the cards into a fresh conversation — or 'restarted' — Orca restarted - * with cards waiting. Resume (`agentSession.queuedMessagesResume`), or the user's own next - * turn starting, lifts it; Send-now on one card sends that card and leaves the + * interrupted ("Queue paused because you interrupted") — or 'cleared' — a /clear + * carried the cards into a fresh conversation. Resume (`agentSession.queuedMessagesResume`), + * or any turn starting, lifts it; Send-now on one card sends that card and leaves the * rest paused until its turn starts. A client treats an unknown reason as a * plain pause, so a newer host can add one. */ -export type AgentSessionQueuePause = { reason: 'stopped' | 'restarted' | 'cleared' } +export type AgentSessionQueuePause = { reason: 'stopped' | 'cleared' } /** What rides beside a frame's `queuedMessages`, published together with the list. */ export type AgentSessionQueuePublicationFields = { @@ -46,10 +45,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-level pause holding this card, or null when none does: a card queued after a Stop - * is not held by it. Absent from a host that predates it; a client then treats every waiting - * card as held while `queuePause` is set. */ - heldBy?: AgentSessionQueuePause | null /** 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. diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index b155f9c70649..49c202468b74 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -221,9 +221,8 @@ export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = // matrix (#21062) in the shipped host, and the desktop and phone clients that render the queue. // v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and // never compares a draft id with a submission id. It also publishes the queue's pause once, as -// `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or the user's next -// turn. Each card may carry `heldBy`, the pause holding it or null; when it is absent (an older -// host) a client falls back to `queuePause` for every card. `nextQueuedMessageId` beside the list +// `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or any turn sent +// after it starting; while it is set every waiting card waits. `nextQueuedMessageId` beside the list // names the card the queue sends next once nothing runs, null while anything holds it; absent (an // older host) reads as null. A card's own hold (`paused`) means only that its conversion failed. // The host mechanism lands first; the constant gates the rollout. diff --git a/src/shared/structured-agent-session-coalescer.test.ts b/src/shared/structured-agent-session-coalescer.test.ts index 333ca053c03d..33422421902d 100644 --- a/src/shared/structured-agent-session-coalescer.test.ts +++ b/src/shared/structured-agent-session-coalescer.test.ts @@ -115,10 +115,10 @@ describe('structured agent session event coalescer', () => { const events: AgentSessionSubscribeEvent[] = [] const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) - coalescer.push({ ...batch(1), queuedMessages: [], queuePause: { reason: 'restarted' } }) + coalescer.push({ ...batch(1), queuedMessages: [], queuePause: { reason: 'cleared' } }) coalescer.push(batch(2)) coalescer.flush() - expect(events[0]).toMatchObject({ queuedMessages: [], queuePause: { reason: 'restarted' } }) + expect(events[0]).toMatchObject({ queuedMessages: [], queuePause: { reason: 'cleared' } }) coalescer.push({ ...batch(3), queuedMessages: [], queuePause: null }) coalescer.push({ ...batch(4), queuedMessages: [], queuePause: { reason: 'stopped' } }) 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 147/175] 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 8252a23ffe3eecf70332601ea2964643410e56d4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:07:02 -0700 Subject: [PATCH 148/175] fix(native-chat): after a restart no queue pause shows, and a card written before the next turn waits for it too --- ...ed-agent-session-queued-card-holds.test.ts | 64 ++++++++++++++++++- ...tructured-agent-session-queued-messages.ts | 3 +- ...ed-agent-session-queued-pause-lift.test.ts | 8 ++- .../structured-agent-session-queued-pause.ts | 14 ++++ ...ctured-agent-session-queued-publication.ts | 7 +- ...ured-agent-session-queued-stop-row.test.ts | 9 ++- 6 files changed, 96 insertions(+), 9 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index 0e23de428b13..351299764905 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -112,6 +112,8 @@ type ClientView = { dialog: boolean /** Each card's Send-now reads Steer. */ steers: boolean[] + /** Why each card waits, which picks its caption: 'turn' is a plain waiting card. */ + holds: string[] cards: number nextQueuedMessageId: string | null } @@ -198,6 +200,7 @@ async function watchClient(outbox: ComposerOutbox = { entries: [] }): Promise queuedMessageCardSteers(card)), + holds: cards.map((card) => card.hold), cards: cards.length, nextQueuedMessageId }) @@ -293,7 +296,9 @@ describe('after a restart, nothing sends by itself', () => { header: false, dialog: false, button: 'send', - cards: 2 + cards: 2, + holds: ['turn', 'turn'], + steers: [true, true] }) expect(await rig.drafts()).toEqual([ { messageId: first, state: 'waiting' }, @@ -346,6 +351,63 @@ describe('after a restart, nothing sends by itself', () => { expect(views.filter((view) => view.header || view.dialog)).toEqual([]) }) + it('Steer on a card sends it now, and the rest follow it in order', async () => { + const { first, second } = await restartedIdleWithCards() + expect(await rig.sendNow(second)).toMatchObject({ + ok: true, + value: { submission: { queuedMessageId: second } } + }) + await eventually(async () => expect((await rig.handoff(second))?.handedOverAt).toBeDefined()) + await expectNothingSent(first) + await rig.settleAccepted(await rig.handoffId(second), 'B') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + }) + + it("a Stop's row from before the restart is not shown either; the carry-on releases every card, in order", async () => { + const working = await rig.workingSend() + const first = await queuedDraft('A') + const second = await queuedDraft('B') + await rig.stop() + await rig.settleAccepted(working, 'stopped') + expect((await published()).queuePause).toEqual({ reason: 'stopped' }) + await rig.restartHostProcess() + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + expect(derivedPauses()).toEqual(['stopped', 'restarted']) + const views = await watchClient() + expect(views.at(-1)).toMatchObject({ header: false, dialog: false, button: 'send' }) + const carryOn = rig.send('continue where you left off') + await carryOn.result + await expectNothingSent(first, second) + await rig.settleAccepted(carryOn.id, 'carry-on') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + expect(await rig.handoff(second)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(first), 'A') + await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) + expect(views.filter((view) => view.header || view.dialog)).toEqual([]) + }) + + it('a card written after the restart, before any turn, never sends by itself, even once the older cards are gone', async () => { + const { first, second } = await restartedIdleWithCards() + // Your message goes straight to the agent; while it waits, the next one queues. + const message = rig.send('a new instruction') + await message.result + await eventually(async () => + expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() + ) + const typed = await queuedDraft('typed while it waits') + expect(await rig.deleteQueued(first)).toMatchObject({ ok: true, value: { deleted: true } }) + expect(await rig.deleteQueued(second)).toMatchObject({ ok: true, value: { deleted: true } }) + // The agent refuses it: no turn started, so the card written meanwhile still waits. + await rig.settleRejected(message.id, 'turn/start refused') + await expectNothingSent(typed) + expect(derivedPauses()).toEqual(['restarted']) + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + const next = rig.send('asked again') + await next.result + await rig.settleAccepted(next.id, 'next') + await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) + }) + it('a hand-off the quit cut short goes back to waiting first, and nothing sends', async () => { rig.dispose() rig = await createQueuedMessageTestRig({ restartable: true, idleSweep: MANUAL_IDLE_SWEEP }) 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 5689b9144e0e..77ff5a72971a 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 @@ -29,6 +29,7 @@ import type { QueuedMessageRow } from '../agent-session-journal/queued-message-t import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionHostInstance, + structuredQueuedCardHostInstance, structuredQueuePauses } from './structured-agent-session-queued-pause' import { nextSendableQueuedCard } from '../agent-session-journal/queued-message-pause' @@ -252,7 +253,7 @@ export async function maybeQueueStructuredAgentSessionSend( messageId: clientMessageId, body: params.body, fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance() + hostInstance: structuredQueuedCardHostInstance(ctx.journal) }, ctx.operationReceipt ) 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 154470f2a5af..2ada2ff0f5ae 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 @@ -106,7 +106,9 @@ describe("a Stop's queue pause", () => { // 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' }) + // Still derived, though after a restart no pause is shown. + expect(await rig.queuePause()).toBeNull() + expect(derivedPauses()).toEqual(['stopped', 'restarted']) await rig.settleAccepted(inFlight, 'after-restart') // The Stop's pause is over; the restart's own, never shown, lasts until a turn sent since it. await eventually(async () => expect(derivedPauses()).toEqual(['restarted'])) @@ -443,10 +445,10 @@ describe('Resume', () => { await expectPaused(draftId) }) - it("of a Stop from before a restart also lifts the restart's pause", async () => { + it("of a Stop from before a restart, which no client offers there, also lifts the restart's pause", async () => { const draftId = await stoppedDraft() rig.crashRestartHostProcess() - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.queuePause()).toBeNull() expect(derivedPauses()).toEqual(['stopped', 'restarted']) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) 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 6d29bcf74a1b..97ee20fd6d1e 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 @@ -29,6 +29,20 @@ export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[ return journal.queuedMessages.pauses(hostInstance) } +/** The process a new card is written for. While a restart's hold is in force (no turn since this + * conversation opened, a card from an earlier process waiting), the earlier process: the card + * joins that hold, so it never sends by itself even once the older cards are gone. */ +export function structuredQueuedCardHostInstance(journal: PauseJournal): string { + const { queuedMessages } = journal + if (queuedMessages.restartEnded()) { + return hostInstance + } + const earlier = queuedMessages + .list() + .find((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) + return earlier?.hostInstance ?? hostInstance +} + /** * Every journal publish: a restart's rows are adopted into this instance once a 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. 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 18bfa4f91ea5..27475bd4ffca 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 @@ -106,8 +106,11 @@ export function readQueuePublication( const queuedMessages = readPublishedQueuedMessages(journal) // Read per emit: the pause also turns on submissions (a 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. A restart's pause is never shown: the chat's next turn lifts it. - const pause = resumableQueuePause(structuredQueuePauses(journal), journal.queuedMessages.list()) + // card shows it again. After a restart nothing is shown, a Stop's or /clear's included: the + // chat's next turn lifts every pause, and the cards read as plain waiting cards until then. + const pauses = structuredQueuePauses(journal) + const restarted = pauses.some((pause) => pause.reason === 'restarted') + const pause = restarted ? null : resumableQueuePause(pauses, journal.queuedMessages.list()) const queuePause = pause && pause.reason !== 'restarted' ? { reason: pause.reason } : null const nextQueuedMessageId = nextStructuredQueuedMessage({ journal, ...gate() })?.messageId ?? null const previous = publications.get(journal) 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 5080ea935a56..79d201b33167 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 @@ -165,7 +165,7 @@ describe("Stop's event", () => { await eventually(async () => expect(await rig.handoffId(sentId)).not.toBe(handoffId)) }) - it('survives a host crash: the next open marks the hand-off unknown, the pause stays, Resume sends', async () => { + it('survives a host crash, unshown: the next open marks the hand-off unknown, nothing sends, Resume releases', async () => { await rig.workingSend() const sentId = await queuedDraft('sent now into the turn') const waiting = await queuedDraft('waiting behind it') @@ -175,7 +175,12 @@ describe("Stop's event", () => { // 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) + // After a restart no row shows, the Stop's included, and nothing sends. + await expectHeld(null, waiting) + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([ + 'stopped', + 'restarted' + ]) expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(async () => expect(await rig.handoff(waiting)).toBeDefined()) }) From 7a61051caf2b6a94a9ce9b6b5a5e67d247d8c278 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:52:40 -0700 Subject: [PATCH 149/175] fix(native-chat): a quit hands no queued card off, and the paused row goes while any turn that will lift it is on its way - The queue stops handing cards off when the host tears down. A card sent during a quit was refused at close, and that refused send withdrew the chat's restart offer, so resuming after the relaunch sent nothing. - The host publishes no pause while a turn sent after it (your message, Steer, or Orca's own) waits for the agent; a refusal shows it again. This replaces the client's own-send check. - A card written after a restart is an ordinary card again: it waits while any card from before the restart still waits. --- .../queued-message-pause.ts | 25 ++++- .../structured-agent-session-host.ts | 1 + ...ed-agent-session-queued-card-holds.test.ts | 105 ++++++++---------- ...ured-agent-session-queued-messages.test.ts | 10 +- ...tructured-agent-session-queued-messages.ts | 14 ++- ...ed-agent-session-queued-pause-lift.test.ts | 11 +- .../structured-agent-session-queued-pause.ts | 19 +--- ...ctured-agent-session-queued-publication.ts | 11 +- ...ructured-agent-session-queued-quit.test.ts | 36 ++++++ ...ured-agent-session-queued-stop-row.test.ts | 3 +- ...ssion-restart-interruption-test-harness.ts | 22 +++- ...d-agent-session-stop-event-entries.test.ts | 3 +- ...tiveChatStructuredSession.test-harness.tsx | 1 - ...uctured-agent-session-queued-cards.test.ts | 27 ----- .../structured-agent-session-queued-cards.ts | 19 ---- ...ent-session-queued-messages.clear.test.tsx | 14 +-- ...tructured-agent-session-queued-messages.ts | 8 +- ...tured-agent-session.queued-gating.test.tsx | 49 +------- .../use-structured-agent-session.ts | 6 +- 19 files changed, 176 insertions(+), 208 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-quit.test.ts 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 a4670f6b1493..c6e07234d99f 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 @@ -10,7 +10,10 @@ // and the next turn (the carry-on or the person's own message) runs first. // Any accepted turn lifts them, whoever sent it: a person, Orca's own messages, or the queue. -import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalCursor, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' @@ -201,3 +204,23 @@ export function resumableQueuePause( } return null } + +/** A turn sent after `pause` began that the agent has not answered yet: its acceptance lifts the + * pause, whoever sent it, so the pause is not shown meanwhile; a refusal shows it again. A send + * made before a Stop lifts nothing, so it never counts. */ +export function queuePauseLiftOnItsWay( + pause: DerivedQueuePause, + submissions: Iterable> +): boolean { + const anchor = pause.since?.sequence ?? 0 + for (const submission of submissions) { + if ( + submission.dispatchState === 'pending' && + submission.acceptedSequence !== undefined && + submission.acceptedSequence > anchor + ) { + return true + } + } + return false +} 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 8256f90e53cf..85f770dc919d 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 @@ -251,6 +251,7 @@ export class StructuredAgentSessionHost { // type, and this file has no line budget left for the import. async flushAllStreamedEvents(options?: { trigger?: 'quit' | 'update' }): Promise { this.conversationDelivery.dispose() + this.queued.drain.dispose() await flushStructuredAgentSessionHost({ ...this.lifetimeContext(), idleSweep: this.lifetime, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index 351299764905..b169bbe4e5e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -6,19 +6,13 @@ // Where the host would refuse the send, it names no next card, so the chat reads idle. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalRenderItem, - AgentJournalSubmission -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage, AgentSessionQueuePause } from '../../../shared/agent-session-wire' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' -import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' -import { reconcileStructuredAgentSessionOutboxWithQueue } from '../../../shared/structured-agent-session-draft-hand-off' import { - ownDirectSendOnItsWay, projectQueuedMessageCards, queuedMessageCardSteers, queuedMessagesQueuePause @@ -118,35 +112,16 @@ type ClientView = { nextQueuedMessageId: string | null } -/** This client's composer sends, settled against the journal as the outbox settles them. */ -type ComposerOutbox = { entries: StructuredAgentSessionOutboxEntry[] } - -/** A composer message on its way, as the outbox holds it once the request went out. */ -function composerSend( - outbox: ComposerOutbox, - text: string -): ReturnType { - const sent = rig.send(text, 'queue-if-active') - outbox.entries.push({ - clientMessageId: sent.id, - sessionId: HOST_TEST_SESSION, - body: hostTestMessage(text), - previewUris: [], - state: 'dispatching', - queuedAt: 1, - lastAttemptAt: 1, - retryAfterUnknownSubmittedAt: null, - sentDelivery: 'queue-if-active' - }) - return sent +/** A composer message: sent with the composer's queue delivery. */ +function composerSend(text: string): ReturnType { + return rig.send(text, 'queue-if-active') } /** Folds every published update as the chat does, one at a time, into what it shows. */ -async function watchClient(outbox: ComposerOutbox = { entries: [] }): Promise { +async function watchClient(): Promise { const views: ClientView[] = [] const submissions = new Map() const turns = new Map() - const items = new Map() let queued: AgentSessionQueuedMessage[] = [] let queuePause: AgentSessionQueuePause | null = null let nextQueuedMessageId: string | null = null @@ -162,7 +137,6 @@ async function watchClient(outbox: ComposerOutbox = { entries: [] }): Promise { it("the person's own message goes straight to the agent first, then A, then B", async () => { const { first, second } = await restartedIdleWithCards() - const outbox: ComposerOutbox = { entries: [] } - const views = await watchClient(outbox) + const views = await watchClient() // Sent with the composer's queue delivery: nothing ahead may send, so it is not queued. - const message = composerSend(outbox, 'a new instruction') + const message = composerSend('a new instruction') expect(await message.result).toMatchObject({ ok: true, value: { submission: expect.anything() } @@ -386,7 +354,7 @@ describe('after a restart, nothing sends by itself', () => { expect(views.filter((view) => view.header || view.dialog)).toEqual([]) }) - it('a card written after the restart, before any turn, never sends by itself, even once the older cards are gone', async () => { + it('a card written after the restart waits while any card from before it waits; then it is an ordinary queue', async () => { const { first, second } = await restartedIdleWithCards() // Your message goes straight to the agent; while it waits, the next one queues. const message = rig.send('a new instruction') @@ -395,16 +363,15 @@ describe('after a restart, nothing sends by itself', () => { expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() ) const typed = await queuedDraft('typed while it waits') - expect(await rig.deleteQueued(first)).toMatchObject({ ok: true, value: { deleted: true } }) - expect(await rig.deleteQueued(second)).toMatchObject({ ok: true, value: { deleted: true } }) - // The agent refuses it: no turn started, so the card written meanwhile still waits. + // The agent refuses it: no turn started, so every card waits, the new one included. await rig.settleRejected(message.id, 'turn/start refused') - await expectNothingSent(typed) + await expectNothingSent(first, second, typed) expect(derivedPauses()).toEqual(['restarted']) expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) - const next = rig.send('asked again') - await next.result - await rig.settleAccepted(next.id, 'next') + expect(await rig.deleteQueued(first)).toMatchObject({ ok: true, value: { deleted: true } }) + await expectNothingSent(second, typed) + // With nothing from before the restart left, the card sends like any queued card. + expect(await rig.deleteQueued(second)).toMatchObject({ ok: true, value: { deleted: true } }) await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) }) @@ -465,29 +432,30 @@ describe('after a restart, nothing sends by itself', () => { describe("a message sent over a held queue (the confirmation's Send message)", () => { /** Two cards a Stop holds, the stopped turn over: the paused row and Resume show. */ - async function heldIdleQueue(outbox: ComposerOutbox) { + async function heldIdleQueue() { const working = await rig.workingSend() const first = await queuedDraft('first') const second = await queuedDraft('second') await rig.stop() await rig.settleAccepted(working, 'stopped') - const views = await watchClient(outbox) + const views = await watchClient() expect(views.at(-1)).toMatchObject({ header: true, button: 'resume', dialog: true, cards: 2 }) return { first, second, views } } it('goes out at once: the paused row goes as it is sent and never comes back, and the held cards follow it in order', async () => { - const outbox: ComposerOutbox = { entries: [] } - const { first, second, views } = await heldIdleQueue(outbox) + const { first, second, views } = await heldIdleQueue() // Sent with the composer's queue delivery: no card ahead may send, so nothing queues it. - const message = composerSend(outbox, 'a new instruction') + const message = composerSend('a new instruction') const sentAt = views.length expect(await message.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) - // The cards stay held until the agent accepts the message's turn. - expect((await published()).queuePause).toEqual({ reason: 'stopped' }) + // The cards stay held until the agent accepts the message's turn, but the host no longer says + // paused: that turn is on its way to lift it. + expect(await published()).toEqual({ queuePause: null, nextQueuedMessageId: null }) + expect(await rig.handoff(first)).toBeUndefined() await eventually(async () => expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() ) @@ -505,10 +473,31 @@ describe("a message sent over a held queue (the confirmation's Send message)", ( expect(views.flatMap((view) => view.steers)).not.toContain(false) }) + it('Steer on a held card: the paused row goes from the press until its turn is accepted, then the rest follow', async () => { + const { first, second, views } = await heldIdleQueue() + const pressedAt = views.length + expect(await rig.sendNow(second)).toMatchObject({ ok: true }) + await eventually(async () => expect((await rig.handoff(second))?.handedOverAt).toBeDefined()) + expect((await published()).queuePause).toBeNull() + await rig.settleAccepted(await rig.handoffId(second), 'second') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + expect(views.slice(pressedAt).filter((view) => view.header || view.dialog)).toEqual([]) + }) + + it('a Steer the agent refuses lifts nothing: the paused row comes back over the card before it', async () => { + const { first, second, views } = await heldIdleQueue() + expect(await rig.sendNow(second)).toMatchObject({ ok: true }) + await eventually(async () => expect((await rig.handoff(second))?.handedOverAt).toBeDefined()) + await eventually(() => expect(views.at(-1)?.header).toBe(false)) + await rig.settleRejected(await rig.handoffId(second), 'turn/start refused') + await eventually(() => expect(views.at(-1)?.header).toBe(true)) + expect((await published()).queuePause).toEqual({ reason: 'stopped' }) + expect(await rig.handoff(first)).toBeUndefined() + }) + it('a send the agent refuses lifts nothing: the paused row comes back over the held cards', async () => { - const outbox: ComposerOutbox = { entries: [] } - const { first, views } = await heldIdleQueue(outbox) - const message = composerSend(outbox, 'refused') + const { first, views } = await heldIdleQueue() + const message = composerSend('refused') await message.result await eventually(async () => expect((await rig.submission(message.id))?.handedOverAt).toBeDefined() 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 c5d5b3c77696..aa32a663ef4c 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 @@ -447,11 +447,12 @@ describe('Stop and Delete', () => { await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - // The host accepting the send is not yet a turn: the pause lifts when the - // provider accepts it, and the draft drains after that turn. + // The host accepting the send is not yet a turn: the pause lifts when the provider accepts + // it, and the draft drains after that turn. Meanwhile no pause is shown: it is on its way. const next = send('user starts a new turn') await next.result - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.queuePause()).toBeNull() + expect(await rig.handoff(draftId)).toBeUndefined() await settleAccepted(next.id, 'b') expect(await rig.queuePause()).toBeNull() await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) @@ -470,7 +471,8 @@ describe('Stop and Delete', () => { const mail = send('coordinator mail') expect(await mail.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) await new Promise((resolve) => setTimeout(resolve, 250)) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // Not shown while the mail awaits the agent, and nothing sends yet. + expect(await rig.queuePause()).toBeNull() expect(await rig.handoff(draftId)).toBeUndefined() await settleAccepted(mail.id, 'b') expect(await rig.queuePause()).toBeNull() 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 77ff5a72971a..3945563757df 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 @@ -29,7 +29,6 @@ import type { QueuedMessageRow } from '../agent-session-journal/queued-message-t import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionHostInstance, - structuredQueuedCardHostInstance, structuredQueuePauses } from './structured-agent-session-queued-pause' import { nextSendableQueuedCard } from '../agent-session-journal/queued-message-pause' @@ -253,7 +252,7 @@ export async function maybeQueueStructuredAgentSessionSend( messageId: clientMessageId, body: params.body, fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredQueuedCardHostInstance(ctx.journal) + hostInstance: structuredAgentSessionHostInstance() }, ctx.operationReceipt ) @@ -284,11 +283,18 @@ export type QueuedMessageDrainDeps = { */ export class StructuredAgentSessionQueuedMessageDrain { private readonly scheduled = new Set() + private disposed = false constructor(private readonly deps: QueuedMessageDrainDeps) {} + /** Host teardown: nothing is handed off from here on. A card sent while the host quits is + * refused at close, and its row would read as the chat having moved on past its restart offer. */ + dispose(): void { + this.disposed = true + } + schedule(sessionId: string): void { - const journal = this.deps.sessions.get(sessionId)?.journal + const journal = this.disposed ? undefined : this.deps.sessions.get(sessionId)?.journal if (!journal) { return } @@ -331,7 +337,7 @@ export class StructuredAgentSessionQueuedMessageDrain { } private async step(sessionId: string): Promise { - const session = this.deps.sessions.get(sessionId) + const session = this.disposed ? undefined : this.deps.sessions.get(sessionId) if (!session) { return } 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 2ada2ff0f5ae..cf1746eaaf0a 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 @@ -84,7 +84,8 @@ describe("a Stop's queue pause", () => { it('outlives a user send the provider accepts and then refuses; a later send that starts lifts it', async () => { const draftId = await stoppedDraft() const refused = await handedOverUserSend('the start fails') - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // On its way to lift the pause, so not shown; the refusal brings it back. + expect(await rig.queuePause()).toBeNull() await rig.settleRejected(refused, 'turn/start refused') await expectPaused(draftId) const started = await handedOverUserSend('this one starts') @@ -144,8 +145,12 @@ describe("a Stop's queue pause", () => { value: { submission: { queuedMessageId: sentId } } }) await handedOver(sentId) - // Only the card the user asked for went: the queue is still paused. - await expectPaused(heldId) + // Only the card the user asked for went: the queue is still paused, though not shown while + // that turn is on its way. + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(heldId)).toBeUndefined() + expect(derivedPauses()).toEqual(['stopped']) + expect(await rig.queuePause()).toBeNull() expect(await rig.drafts()).toEqual([{ messageId: heldId, state: 'waiting' }]) // A turn sent after the Stop has now started, which ends the pause. await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now') 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 97ee20fd6d1e..6722313d0394 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 @@ -29,23 +29,10 @@ export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[ return journal.queuedMessages.pauses(hostInstance) } -/** The process a new card is written for. While a restart's hold is in force (no turn since this - * conversation opened, a card from an earlier process waiting), the earlier process: the card - * joins that hold, so it never sends by itself even once the older cards are gone. */ -export function structuredQueuedCardHostInstance(journal: PauseJournal): string { - const { queuedMessages } = journal - if (queuedMessages.restartEnded()) { - return hostInstance - } - const earlier = queuedMessages - .list() - .find((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) - return earlier?.hostInstance ?? hostInstance -} - /** - * Every journal publish: a restart's rows are adopted into this instance once a 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. + * Every journal publish: a restart's rows are adopted into this instance once a 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 adoptEndedRestartPause( sessionId: string, 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 27475bd4ffca..eb4dd5ec4fe9 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,10 @@ 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 { + queuePauseLiftOnItsWay, + resumableQueuePause +} from '../agent-session-journal/queued-message-pause' import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { nextStructuredQueuedMessage } from './structured-agent-session-queued-messages' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' @@ -110,7 +113,11 @@ export function readQueuePublication( // chat's next turn lifts every pause, and the cards read as plain waiting cards until then. const pauses = structuredQueuePauses(journal) const restarted = pauses.some((pause) => pause.reason === 'restarted') - const pause = restarted ? null : resumableQueuePause(pauses, journal.queuedMessages.list()) + const resumable = restarted ? null : resumableQueuePause(pauses, journal.queuedMessages.list()) + // The submissions are read only while a pause would show, never while the queue runs freely. + const pause = + resumable && !queuePauseLiftOnItsWay(resumable, journal.submissions()) ? resumable : null + // `restarted` is already excluded above; the test narrows the type. const queuePause = pause && pause.reason !== 'restarted' ? { reason: pause.reason } : null const nextQueuedMessageId = nextStructuredQueuedMessage({ journal, ...gate() })?.messageId ?? null const previous = publications.get(journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-quit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-quit.test.ts new file mode 100644 index 000000000000..cd8bf2f1870c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-quit.test.ts @@ -0,0 +1,36 @@ +// A real quit with cards queued behind a running turn. The queue hands nothing off while the host +// quits, so the chat keeps its restart offer: after the relaunch nothing sends by itself, and +// resuming runs Orca's carry-on first, then the cards in order. + +import { afterEach, expect, it, vi } from 'vitest' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { + interruptedRestart, + QUEUED_BEFORE_QUIT +} from './structured-agent-session-restart-interruption-test-harness' + +afterEach(() => vi.restoreAllMocks()) + +it('keeps the restart offer through a quit with cards queued; Resume sends the carry-on, then the cards in order', async () => { + const { host, dispatch } = await interruptedRestart('queued-cards') + // Nothing was handed off while the host quit. + const snapshot = await host.journalSnapshot(SESSION) + expect(snapshot.submissions.filter((entry) => entry.queuedMessageId !== undefined)).toEqual([]) + expect(await host.restartResume.list()).toHaveLength(1) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuePause).toBeFalsy() + expect(page.ok && page.page.queuedMessages?.map((card) => card.state)).toEqual([ + 'waiting', + 'waiting' + ]) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(dispatch).not.toHaveBeenCalled() + + const resumed = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(resumed.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) + await vi.waitFor(() => expect(dispatch.mock.calls.length).toBeGreaterThanOrEqual(3)) + const sent = dispatch.mock.calls.map((call) => JSON.stringify(call[0])) + expect(sent[0]).not.toContain('card') + expect(sent[1]).toContain(QUEUED_BEFORE_QUIT[0]) + expect(sent[2]).toContain(QUEUED_BEFORE_QUIT[1]) +}) 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 79d201b33167..4e45ab4a6cc3 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 @@ -139,7 +139,8 @@ describe("Stop's event", () => { await expectHeld('stopped', first) // Orchestration mail sent after the second Stop: once accepted, the queue carries on. const mail = await mailTurn() - await expectHeld('stopped', first) + // Not shown while the mail awaits the agent, and nothing sends yet. + await expectHeld(null, first) await rig.settleAccepted(mail, 'mail') await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 616cf3e89704..50134e6b5829 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -40,6 +40,10 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' +import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' + +/** What `interruptedRestart('queued-cards')` queues behind the running turn, in order. */ +export const QUEUED_BEFORE_QUIT = ['card A', 'card B'] as const /** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a * running child before its next step. Nothing else starts one ahead of a send. */ @@ -55,7 +59,8 @@ export async function startAgent(state: { } export async function interruptedRestart( - work: 'turn' | 'submission' | 'send-after-reply' | 'children' = 'turn', + /** 'queued-cards': a running turn with `QUEUED_BEFORE_QUIT` queued behind it. */ + work: 'turn' | 'submission' | 'send-after-reply' | 'children' | 'queued-cards' = 'turn', historyBoundaryConsistent = true, /** What the restarted host proves about the recorded owner; gone unless a test says otherwise. */ probeOwner: NonNullable = async () => ({ @@ -125,7 +130,22 @@ export async function interruptedRestart( ) } await previous.host.flushStreamedEvents(SESSION) + if (work === 'queued-cards') { + for (const text of QUEUED_BEFORE_QUIT) { + const body = hostTestMessage(text) + const fields = { body, delivery: 'queue-if-active' as const } + const queued = await previous.host.send(CALLER, { + envelope: envelope('agentSession.send', fields), + ...fields + }) + expect(queued).toMatchObject({ ok: true, value: { queued: expect.anything() } }) + } + } await previous.host.flushAllStreamedEvents() + if (work === 'queued-cards') { + // The relaunch is a new process: the cards it finds were written by the one that quit. + rotateStructuredAgentSessionHostInstanceForTests() + } const store = await openTestAgentSessionRecordStore(previous.root) const closeSession = vi.fn(async () => true) // The relaunch comes after the quit that recorded the offer. 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 ad9ad0ee8048..a58273350a47 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 @@ -14,6 +14,7 @@ import { eventually, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig @@ -392,7 +393,7 @@ describe("a person's Stop pause and the Stop events after it", () => { { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: 1 }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['stopped']) const atClose = stopEventsAtClose() await rig.host.close(HOST_TEST_SESSION, 'evict') 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 ffe525948489..e7d11a00da16 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -239,7 +239,6 @@ export function createStructuredSessionMocks() { stop: mocks.stop, queuedMessages: { cards: mocks.queuedCards, - turnRunning: mocks.turnId !== null, steer: mocks.queuedSteer, remove: mocks.queuedRemove, edit: mocks.queuedEdit, 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 ee8e3ea34161..2f734e05804b 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 @@ -9,7 +9,6 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/struc import { newestSteerableQueuedMessageCard, outboxOutsideQueuedCards, - ownDirectSendOnItsWay, projectQueuedMessageCards, queuedMessageCardSteers, queuedMessagesQueuePause @@ -192,32 +191,6 @@ describe('queued message cards', () => { expect(queuedMessagesQueuePause(unpaused, null)).toBeNull() }) - it("only the person's own direct send, recorded and not yet accepted, is on its way to lift a pause", () => { - const entry = ( - clientMessageId: string, - state: StructuredAgentSessionOutboxEntry['state'] = 'dispatching' - ): StructuredAgentSessionOutboxEntry => ({ - clientMessageId, - sessionId: 'session-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, - previewUris: [], - state, - queuedAt: 1, - lastAttemptAt: 1, - retryAfterUnknownSubmittedAt: null - }) - expect(ownDirectSendOnItsWay([entry('mine')], [submission('mine')])).toBe(true) - // Not yet recorded: it may still become a card. - expect(ownDirectSendOnItsWay([entry('mine')], [])).toBe(false) - // Orca's own send never enters this outbox. - expect(ownDirectSendOnItsWay([], [submission('orca')])).toBe(false) - // The queue's send of a card goes under a fresh id, even for a card this composer queued. - expect(ownDirectSendOnItsWay([entry('card')], [handOff('card')])).toBe(false) - expect(ownDirectSendOnItsWay([entry('mine', 'rejected')], [submission('mine')])).toBe(false) - expect(ownDirectSendOnItsWay([entry('mine')], [submission('mine', 'rejected')])).toBe(false) - expect(ownDirectSendOnItsWay([entry('mine')], [submission('mine', 'unknown')])).toBe(false) - }) - it('steers the newest card', () => { const cards = projectQueuedMessageCards([draft('a', 1), draft('b', 2)], [], IDLE) expect(newestSteerableQueuedMessageCard(cards)?.messageId).toBe('b') 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 49ee3fbc83df..bf70de99ef40 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 @@ -103,25 +103,6 @@ export function queuedMessagesQueuePause( return cards.some((card) => card.hold === 'queue-paused') ? queuePause : null } -/** The person's own message from this composer has reached the host as a direct send and waits - * for the agent to accept it. Its turn will lift the queue's pause, so the header row does not - * say paused meanwhile; a refusal settles the entry and the row comes back. The queue's send of a - * card goes under a fresh id, so it never counts. */ -export function ownDirectSendOnItsWay( - outbox: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] -): boolean { - if (outbox.length === 0) { - return false - } - const pending = new Set( - submissions - .filter((submission) => submission.dispatchState === 'pending') - .map((submission) => submission.clientMessageId) - ) - return outbox.some((entry) => entry.state !== 'rejected' && pending.has(entry.clientMessageId)) -} - /** Steer names the mid-turn jump, also while the whole queue is paused; a card held on its own or * returned is not waiting on the turn, so its action is plainly Send. */ export function queuedMessageCardSteers(card: QueuedMessageCard): boolean { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx index e82f60e73393..ab6d7fd7263c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx @@ -33,7 +33,6 @@ function renderController( queuePause?: AgentSessionQueuePause | null enabled?: boolean isWorking?: boolean - ownSendOnItsWay?: boolean answer?: (messageId: string) => DeleteAnswer } = {} ) { @@ -49,8 +48,7 @@ function renderController( queuePause: options.queuePause === undefined ? STOPPED : options.queuePause, submissions: [], hasPendingPrompt: false, - isWorking: options.isWorking === true || options.ownSendOnItsWay === true, - ownSendOnItsWay: options.ownSendOnItsWay === true, + isWorking: options.isWorking === true, composerScopeKey: undefined, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller only awaits mutate; the stub answers Delete's shape. mutate: mutate as unknown as StructuredAgentSessionMutate @@ -94,16 +92,6 @@ describe('the held queue a new message asks about', () => { expect(renderController(held).result.current.queueHold?.count).toBe(1) }) - it("goes, with the paused row, while the person's own message is on its way to lift the pause", () => { - const held = [card('held', 1)] - const { result } = renderController(held, { ownSendOnItsWay: true }) - expect(result.current.pause).toBeNull() - expect(result.current.queueHold).toBeUndefined() - expect(result.current.queueResume).toBeUndefined() - // The held card is unchanged: only the row says nothing while the turn is on its way. - expect(result.current.cards.map((entry) => entry.hold)).toEqual(['queue-paused']) - }) - it('Clear queue deletes every card shown, and answers true once all are gone', async () => { const { result, mutate } = renderController([card('held', 1), card('typed-after', 2)]) let cleared: boolean | undefined diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 31d834bd750e..8131aa77c79c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -71,8 +71,6 @@ export function useStructuredAgentSessionQueuedMessages(args: { hasPendingPrompt: boolean /** A turn is running, whoever started it, or the queue is about to send its next card. */ isWorking: boolean - /** The person's own message is on its way to start the turn that lifts the pause. */ - ownSendOnItsWay?: boolean composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { @@ -207,11 +205,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { setResuming(false) } }, [mutate]) - const ownSendOnItsWay = args.ownSendOnItsWay === true - const pause = useMemo( - () => (ownSendOnItsWay ? null : queuedMessagesQueuePause(cards, queuePause)), - [cards, ownSendOnItsWay, queuePause] - ) + const pause = useMemo(() => queuedMessagesQueuePause(cards, queuePause), [cards, queuePause]) // Resume and the "Send message?" choice only where the queue could send now: no turn runs (the // queue's coming send counts, as the host names it) and no prompt waits, which holds the queue // too; the composer shows beside a prompt only when this build cannot answer it. 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 963f8b669693..4f9be93563d2 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 @@ -9,14 +9,8 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalRenderItem, - AgentJournalSubmission -} from '../../../../shared/agent-session-journal-types' -import type { - AgentSessionQueuedMessage, - AgentSessionQueuePause -} from '../../../../shared/agent-session-wire' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' import { createStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry @@ -30,8 +24,6 @@ const mocks = vi.hoisted(() => ({ let items: AgentJournalRenderItem[] = [] let queuedMessages: AgentSessionQueuedMessage[] | undefined let nextQueuedMessageId: string | null = null -let queuePause: AgentSessionQueuePause | null = null -let submissions: AgentJournalSubmission[] = [] let outboxEntries: StructuredAgentSessionOutboxEntry[] = [] vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -44,11 +36,11 @@ vi.mock('./use-structured-agent-session-read', () => ({ state: { fence: 3, items, - submissions, + submissions: [], status: 'ready', error: null, hasOlder: false, - ...(queuedMessages !== undefined ? { queuedMessages, nextQueuedMessageId, queuePause } : {}) + ...(queuedMessages !== undefined ? { queuedMessages, nextQueuedMessageId } : {}) }, loadingOlder: false, loadOlder: vi.fn() @@ -137,8 +129,6 @@ beforeEach(() => { items = [RUNNING_TURN] queuedMessages = undefined nextQueuedMessageId = null - queuePause = null - submissions = [] outboxEntries = [] localStorage.clear() clearNativeChatDraftCacheForTests() @@ -338,37 +328,6 @@ describe('against a capable host', () => { expect(refused.result.current).toMatchObject({ isWorking: false, queueSendsNext: false }) }) - it("hides the paused row while the person's own message is on its way, and shows it again once that send is refused", () => { - items = [] - queuePause = { reason: 'stopped' } - queuedMessages = [draft('held')] - const held = render() - expect(held.result.current.queuedMessages.pause).toEqual({ reason: 'stopped' }) - const sent = createStructuredAgentSessionOutboxEntry({ - clientMessageId: 'mine', - sessionId: 'session-1', - text: 'new', - attachments: [], - queuedAt: 1 - }) - const recorded: AgentJournalSubmission = { - clientMessageId: 'mine', - fence: 3, - payloadFingerprint: 'fingerprint', - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null - } - outboxEntries = [{ ...sent, state: 'dispatching', lastAttemptAt: 1 }] - submissions = [recorded] - expect(render().result.current.queuedMessages.pause).toBeNull() - outboxEntries = [{ ...sent, state: 'rejected', lastAttemptAt: 1 }] - submissions = [{ ...recorded, dispatchState: 'rejected', resolvedAt: 2 }] - expect(render().result.current.queuedMessages.pause).toEqual({ reason: 'stopped' }) - }) - 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 351bffcd413a..a2230404b91a 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 @@ -35,10 +35,7 @@ import { useStructuredAgentSessionThreadGoal } from './use-structured-agent-sess import { useStructuredAgentSessionContextUsage } from './use-structured-agent-session-context-usage' import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline' import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-session-queued-messages' -import { - outboxOutsideQueuedCards, - ownDirectSendOnItsWay -} from './structured-agent-session-queued-cards' +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 { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' @@ -195,7 +192,6 @@ export function useStructuredAgentSession(args: { submissions: transportState.submissions, hasPendingPrompt: prompts.length > 0, isWorking, - ownSendOnItsWay: ownDirectSendOnItsWay(outbox, transportState.submissions), composerScopeKey, mutate }) From d1170f3e728e0bb1e74c178a239259d01d763561 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:02:33 -0700 Subject: [PATCH 150/175] refactor(native-chat): the host's paused-row-while-a-turn-is-on-its-way check in one expression --- .../queued-message-pause.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) 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 c6e07234d99f..a7b4818607c2 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 @@ -210,17 +210,11 @@ export function resumableQueuePause( * made before a Stop lifts nothing, so it never counts. */ export function queuePauseLiftOnItsWay( pause: DerivedQueuePause, - submissions: Iterable> + submissions: readonly Pick[] ): boolean { const anchor = pause.since?.sequence ?? 0 - for (const submission of submissions) { - if ( - submission.dispatchState === 'pending' && - submission.acceptedSequence !== undefined && - submission.acceptedSequence > anchor - ) { - return true - } - } - return false + return submissions.some( + (submission) => + submission.dispatchState === 'pending' && (submission.acceptedSequence ?? 0) > anchor + ) } 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 151/175] 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 7c561ee503b1139500c18212de4946381db344b1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:38:09 -0700 Subject: [PATCH 152/175] refactor(native-chat): the composer's queue Resume rides the structured transport beside the held queue --- .../src/components/native-chat/NativeChatComposer.tsx | 5 ++--- .../NativeChatStructuredSession.test-harness.tsx | 5 +++-- .../native-chat/NativeChatStructuredSession.test.tsx | 6 +++--- .../components/native-chat/NativeChatStructuredSession.tsx | 4 ++-- .../components/native-chat/native-chat-composer-types.ts | 6 +++--- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 37ee949d901b..ab54ed848bd9 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -68,8 +68,7 @@ const NativeChatComposerPane = forwardRef + structuredTransport?: Record & { + queueResume?: { resume: () => void; resuming: boolean } + } isWorking?: boolean onStop?: () => void - queueResume?: { resume: () => void; resuming: boolean } }>(), approvalCardProps: initialApprovalCardProps, questionCardProps: null as NativeChatQuestionCardProps | null, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 5f9054b010b7..22704f84b77b 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -398,13 +398,13 @@ describe('NativeChatStructuredSession', () => { expect(mocks.composerProps?.onStop).toBeUndefined() }) - it('hands the composer Resume only while the queue controller offers it', () => { + it('hands the composer Resume, on its transport, only while the queue controller offers it', () => { const { rerender } = render(claudeSessionView('structured-tab-resume', 'session-resume')) - expect(mocks.composerProps?.queueResume).toBeUndefined() + expect(mocks.composerProps?.structuredTransport?.queueResume).toBeUndefined() mocks.queuedResumable = true rerender(claudeSessionView('structured-tab-resume', 'session-resume')) act(() => { - mocks.composerProps?.queueResume?.resume() + mocks.composerProps?.structuredTransport?.queueResume?.resume() }) expect(mocks.queuedResume).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index e921a7a5f532..6e0c8822211c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -220,7 +220,8 @@ export function NativeChatStructuredSession( sessionId: props.sessionId, runtimeEnvironmentId: props.target.kind === 'local' ? null : (props.target.environmentId ?? null), - queueHold: controller.queuedMessages.queueHold + queueHold: controller.queuedMessages.queueHold, + queueResume: controller.queuedMessages.queueResume } }, [ controller, @@ -382,7 +383,6 @@ export function NativeChatStructuredSession( isWorking={controller.canStop || controller.queueSendsNext} onStop={controller.canStop ? () => void controller.stop() : undefined} steerQueued={controller.queuedMessages.steerNewest} - queueResume={controller.queuedMessages.queueResume} 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 a652f4eef720..7bef1865466c 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 @@ -48,6 +48,9 @@ export type NativeChatStructuredComposerTransport = { runtimeEnvironmentId: string | null /** Present while the queue is held: a message sent now first asks whether to clear its cards. */ queueHold?: NativeChatQueueHold + /** Present while the host holds the queue and no turn runs: an empty composer's primary + * action becomes Resume, which releases it. */ + queueResume?: NativeChatQueueResume } export type NativeChatOptimisticSendOutcome = { @@ -93,9 +96,6 @@ export type NativeChatComposerProps = { /** Cmd/Ctrl+Enter from an empty composer: send the newest queued draft now. * False = nothing queued, and the chord falls through to a plain send. */ steerQueued?: () => boolean - /** Present while the host holds the queue and no turn runs: an empty composer's primary - * action becomes Resume, which releases it. */ - queueResume?: NativeChatQueueResume | undefined } /** Launch context prefilled into the TUI input as an unsent draft, plus the two 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 153/175] 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 154/175] 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 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 155/175] 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 156/175] 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 439545e12424eef9e8e65200508cc993ff7bb266 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:41:43 -0700 Subject: [PATCH 157/175] fix(native-chat): the "Send message?" choice ends with the pause it asked about; tests follow main's draft props - The open dialog closes when the queue's pause lifts under it (Orca's mail, another client's Resume, any accepted turn): nothing is sent, the draft stays, and the next Enter sends as usual. The pending choice records the hold it was asked under; nothing new is stored. - The composer-field Resume test passes main's dropScopeKey/draftScopeKey. - The dialog test expects main's rule: only the sent text leaves the composer. --- ...native-chat-composer-field-resume.test.tsx | 3 +- .../native-chat-queue-send-confirm.test.tsx | 37 ++++++++++++++++++- ...se-native-chat-held-queue-composer-send.ts | 26 +++++++++---- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx index d6d315b7b8cd..f496af3e6c12 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-field-resume.test.tsx @@ -71,7 +71,8 @@ function TestField({ const textareaRef = useRef(null) return ( { expect(structured.send).toHaveBeenCalledTimes(1) }) - it('text typed meanwhile stays in the composer once the message is accepted', async () => { + it('only the sent message leaves the composer; text typed meanwhile stays', async () => { const { deleted, structured, input } = await clearingQueue('start over') changePrompt(input, 'start over, and also check the tests') await act(async () => deleted.resolve(true)) await waitFor(() => expect(structured.send).toHaveBeenCalledWith('start over', [])) await act(async () => {}) - expect(promptValue(input)).toBe('start over, and also check the tests') + expect(promptValue(input)).toBe(', and also check the tests') }) it('an unchanged draft is cleared once the message is accepted', async () => { @@ -358,4 +358,37 @@ describe('sending while the queue is held', () => { await waitFor(() => expect(held.dispatchCommand).toHaveBeenCalledWith('/compact')) expect(screen.queryByRole('dialog')).toBeNull() }) + + it('closes when the pause lifts under it: nothing is sent, the draft stays, and the next Enter sends', async () => { + const held = transport(heldQueue(2)) + const composer = (structuredTransport: NativeChatStructuredComposerTransport) => ( + + ) + const view = render(composer(held)) + const input = screen.getByRole('textbox') + changePrompt(input, 'a new instruction') + await act(async () => pressEnter(input)) + await screen.findByRole('dialog') + // Orca's mail or another client's Resume lifts the pause: the host stops publishing the hold. + const { queueHold: _lifted, ...lifted } = held + view.rerender(composer(lifted)) + await act(async () => {}) + expect(screen.queryByRole('dialog')).toBeNull() + expect(held.send).not.toHaveBeenCalled() + expect(promptValue(input)).toBe('a new instruction') + // A later pause does not bring the old question back. + view.rerender(composer({ ...lifted, queueHold: heldQueue(1) })) + await act(async () => {}) + expect(screen.queryByRole('dialog')).toBeNull() + view.rerender(composer(lifted)) + await act(async () => pressEnter(input)) + await waitFor(() => expect(held.send).toHaveBeenCalledWith('a new instruction', [])) + expect(held.send).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts index b5a6d0043140..8ca9f12a55e5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-held-queue-composer-send.ts @@ -11,6 +11,7 @@ import { import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import { readNativeChatComposerDraft } from './native-chat-composer-draft-store' import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' +import type { NativeChatQueueHold } from './native-chat-composer-types' type StructuredComposerSend = ( text: string, @@ -33,8 +34,8 @@ export type NativeChatQueueSendConfirm = { type PendingSend = { text: string attachments: readonly NativeChatComposerImageAttachment[] | undefined - count: number - clear: () => Promise + /** The hold it was asked under: the question stands only while that hold does. */ + hold: NativeChatQueueHold /** The draft as it was when the message was asked for. */ sentFrom: NativeChatComposerDraft } @@ -51,12 +52,14 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured // The send a choice may still take, taken once: the closing dialog stays clickable for its exit // animation, and a double-click or a held Enter lands twice before any re-render. const untakenRef = useRef(null) + // A pause lifted under the open dialog (Orca's mail, another client's Resume) voids the choice: + // nothing is sent, and the draft stays for the next Enter, which then sends as usual. const take = useCallback((): PendingSend | null => { const taken = untakenRef.current untakenRef.current = null setOpen(false) - return taken - }, []) + return taken?.hold === queueHold ? taken : null + }, [queueHold]) // Clear queue sends once its deletes settle, through the send of that render. const sendNowRef = useRef(sendNow) useLayoutEffect(() => { @@ -79,8 +82,7 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured const asked = { text, attachments, - count: queueHold.count, - clear: queueHold.clear, + hold: queueHold, sentFrom: readNativeChatComposerDraft(args.draftScopeKey) } untakenRef.current = asked @@ -107,7 +109,7 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured clearingRef.current = true void (async () => { try { - if (await taken.clear()) { + if (await taken.hold.clear()) { // Text typed during the deletes stays: the message was taken from what came before. await sendNowRef.current(taken.text, taken.attachments, taken.sentFrom) } @@ -120,6 +122,14 @@ export function useNativeChatHeldQueueComposerSend(args: UseNativeChatStructured take() }, [take]) - const confirm = pending ? { open, count: pending.count, clearQueue, sendMessage, dismiss } : null + const confirm = pending + ? { + open: open && pending.hold === queueHold, + count: pending.hold.count, + clearQueue, + sendMessage, + dismiss + } + : null return { send, confirm } } 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 158/175] 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 159/175] 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 160/175] 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 161/175] 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 162/175] 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 163/175] 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 164/175] 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 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 165/175] 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 ed5a16acd924a4719dbb4da3afd0a5dc7a9c1f9e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:14:36 -0700 Subject: [PATCH 166/175] test(native-chat): give the composer's Stopping test the primary action the queue merge added --- .../components/native-chat/NativeChatComposerActions.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx index 5b4c417accc0..c741ba784368 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx @@ -41,6 +41,7 @@ describe('NativeChatComposerActions', () => { attachDisabled: false, dictationDisabled: false, isWorking: true, + primaryAction: 'stop' as const, isDictating: false, isDictationHoldMode: false, onAttach: vi.fn(), From 1a04c3d791e943fca9207e0b917b43f10d2f3c9a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:14:36 -0700 Subject: [PATCH 167/175] revert(native-chat): drop heldByPause, a card field the Stop's pause no longer needs A person's Stop now holds every card while it is in force, so no card queued under it is let through and the per-card mark carries nothing. Desktop and phone labels go with it. --- ...ile-structured-queued-message-cards.test.ts | 10 ---------- .../mobile-structured-queued-message-cards.ts | 5 +---- ...ructured-agent-session-queued-cards.test.ts | 18 ------------------ .../structured-agent-session-queued-cards.ts | 5 +---- .../agent-session-queued-message-wire.ts | 4 ---- 5 files changed, 2 insertions(+), 40 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 28091ebd4cc2..29153e0a3b9a 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -69,16 +69,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 d63210914e31..e4d2c123a015 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -107,9 +107,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) { @@ -124,7 +121,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/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 847993b90291..e2be4a65189e 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 @@ -139,24 +139,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 aefc02edeb31..bf70de99ef40 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 @@ -64,9 +64,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 = @@ -76,7 +73,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 3c01d9eb6694..a1df60b85f6f 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -51,10 +51,6 @@ export type AgentSessionQueuedMessage = { * must treat an unknown 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 fa7e030cfeea4de52a921f7d8421ade2369fba50 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:14:47 -0700 Subject: [PATCH 168/175] fix(native-chat): a person's Stop holds every card while in force; orchestration mail runs past it Before, the Stop's pause held only cards queued before its row, so a message queued while the stopped turn wound down ran as soon as the stop landed. Now the pause holds every card until Resume or a turn accepted after the Stop. Mail never waits on a person's Resume: the person's Stop holds no mail card, and the queue's one ordering exception lets mail pass cards held only by that Stop. Its accepted turn lifts the pause, so the person's cards follow in order. The drain, its consume and admission all read the same rule (nextSendableQueuedCard). --- .../queued-message-pause.test.ts | 176 +++++++++++++----- .../queued-message-pause.ts | 57 +++--- .../queued-message-table.ts | 4 +- ...ured-agent-session-queued-messages.test.ts | 3 +- ...ed-agent-session-queued-pause-lift.test.ts | 16 +- ...ured-agent-session-queued-stop-row.test.ts | 36 ++-- .../structured-agent-session-queued-stop.ts | 2 +- ...tructured-agent-session-stop-event.test.ts | 17 +- 8 files changed, 216 insertions(+), 95 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 43b8120d9b43..61c220a2a845 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 @@ -31,6 +31,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-p', @@ -58,13 +59,25 @@ function open(): Promise { }) } -function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom?: string) { +/** Orchestration mail, as another agent's queued send records its sender. */ +const MAIL: AgentSessionMessageSource = { + kind: 'agent', + senders: [], + orchestration: { message: 'mail-notice', mailbox: 'inbox', dispatchId: null, messages: [] } +} + +function queueDraft( + journal: AgentSessionJournal, + messageId: string, + carriedFrom?: string, + source: AgentSessionMessageSource = { kind: 'user' } +) { return journal.queuedMessages.insert({ messageId, body: message(messageId), fingerprint: `fp-${messageId}`, hostInstance: HOST, - source: { kind: 'user' }, + source, ...(carriedFrom ? { carriedFrom } : {}) }) } @@ -398,23 +411,59 @@ describe("the queue's pause, derived from the journal", () => { }) describe('which cards a pause holds', () => { - it('only cards queued before the Stop event: one queued after it is a new instruction', async () => { + // Sent while the stopped turn winds down, a card waits with the rest for Resume or a new turn. + it("a person's Stop holds every card, one queued after it too, across a reopen", async () => { let journal = await open() await queueDraft(journal, 'before') await userStop(journal) await queueDraft(journal, 'after') expect(held(journal)).toEqual([ ['before', true], - ['after', false] + ['after', true] ]) await journal.close() journal = await open() expect(held(journal)).toEqual([ ['before', true], - ['after', false] + ['after', true] ]) }) + it('holds a lone card queued after it, and publishes its pause over it', async () => { + const journal = await open() + await userStop(journal) + await queueDraft(journal, 'alone') + const pauses = journal.queuedMessages.pauses(HOST) + const cards = journal.queuedMessages.list() + expect(held(journal)).toEqual([['alone', true]]) + expect(nextSendableQueuedCard(pauses, cards)).toBeNull() + expect(resumableQueuePause(pauses, cards)?.reason).toBe('stopped') + }) + + // Mail never waits on a person's Resume: it runs past the cards the Stop holds, and its turn, + // once accepted, lifts the pause for them. + it('holds no mail, queued before the Stop or after, and mail passes the cards it holds', async () => { + const journal = await open() + await queueDraft(journal, 'mail-before', undefined, MAIL) + await queueDraft(journal, 'person') + await userStop(journal) + await queueDraft(journal, 'mail-after', undefined, MAIL) + expect(held(journal)).toEqual([ + ['mail-before', false], + ['person', true], + ['mail-after', false] + ]) + const next = () => + nextSendableQueuedCard(journal.queuedMessages.pauses(HOST), journal.queuedMessages.list()) + expect(next()?.messageId).toBe('mail-before') + await journal.queuedMessages.withdraw({ messageIds: ['mail-before'], settledByOp: 'op-1' }) + expect(next()?.messageId).toBe('mail-after') + expect( + resumableQueuePause(journal.queuedMessages.pauses(HOST), journal.queuedMessages.list()) + ?.reason + ).toBe('stopped') + }) + it('a steer the Stop withdrew comes back in its own place, and is held', async () => { const journal = await open() await queueDraft(journal, 'steered') @@ -438,12 +487,11 @@ describe('which cards a pause holds', () => { }) expect(held(journal)).toEqual([ ['steered', true], - ['after', false] + ['after', true] ]) }) - // 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) @@ -465,9 +513,33 @@ describe('which cards a pause holds', () => { yieldsToPause: { hostInstance: HOST } } ) - ).resolves.toBeDefined() + ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) + expect(journal.queuedMessages.get('newer')?.state).toBe('waiting') + }) + + it("the queue's own consume takes mail past the cards a person's Stop holds", async () => { + const journal = await open() + await queueDraft(journal, 'held') + await userStop(journal) + await queueDraft(journal, 'mail', undefined, MAIL) + await journal.appendSubmission( + { + clientMessageId: 'drain-mail', + payloadFingerprint: 'fp-mail', + body: message('mail'), + fence: 0, + handoverRecorded: true + }, + { + messageId: 'mail', + expect: 'waiting', + settledByOp: null, + hostInstance: HOST, + yieldsToPause: { hostInstance: HOST } + } + ) + expect(journal.queuedMessages.get('mail')?.state).toBe('dispatched') 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 () => { @@ -545,10 +617,19 @@ describe('which cards the pauses in force hold', () => { 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 } + function card(messageId: string, fields: Partial = {}): Card { + const base = { + state: 'waiting', + holdReason: null, + hostInstance: HOST, + carriedFrom: null, + source: { kind: 'user' } as const + } + return { messageId, ...base, ...fields } + } + + function mail(messageId: string, fields: Partial = {}): Card { + return card(messageId, { source: MAIL, ...fields }) } /** A Stop at sequence 5 unless `stopped` is 0; no turn or Resume since. */ @@ -571,55 +652,54 @@ describe('which cards the pauses in force hold', () => { 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']) - 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) + it("a Stop that holds no mail never hides a restart's: a dead process's mail waits", () => { + const deadMail = mail('mail', { hostInstance: DEAD }) + expect(pausesOver([deadMail]).map((pause) => pause.reason)).toEqual(['stopped', 'restarted']) + expect(holding([deadMail])).toEqual([['mail', 'restarted']]) + expect(nextSendableQueuedCard(pausesOver([deadMail]), [deadMail])).toBeNull() + expect(resumableQueuePause(pausesOver([deadMail]), [deadMail])?.reason).toBe('restarted') + // Written by this process, nothing holds it: mail runs as the stop lands. + const live = mail('mail') 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 }) - ] + const cards = [card('person', { hostInstance: DEAD }), mail('mail', { hostInstance: DEAD })] expect(holding(cards)).toEqual([ - ['before', 'stopped'], - ['after', 'restarted'] + ['person', 'stopped'], + ['mail', 'restarted'] ]) expect(resumableQueuePause(pausesOver(cards), cards)?.reason).toBe('stopped') }) + // Mail passes only the cards a person's Stop alone holds: never a returned card, and never one + // another pause holds too. + it("mail passes the cards held only by a person's Stop, and nothing else", () => { + const passing = [card('person-1'), card('person-2'), mail('mail'), card('person-3')] + expect(nextSendableQueuedCard(pausesOver(passing), passing)?.messageId).toBe('mail') + expect(nextSendableQueuedCard(pausesOver(passing, 0), passing)?.messageId).toBe('person-1') + const returned = [card('refused', { state: 'returned' }), mail('mail')] + expect(nextSendableQueuedCard(pausesOver(returned), returned)).toBeNull() + const carried = [card('carried', { carriedFrom: 'source-session' }), mail('mail')] + expect(holding(carried)).toEqual([ + ['carried', 'stopped'], + ['mail', null] + ]) + expect(nextSendableQueuedCard(pausesOver(carried), carried)).toBeNull() + }) + // Held on its own, as a card whose send failed: the queue goes past it, and Resume is offered // over the cards a pause holds behind it. it('a kept card is skipped like a send_failed one; the cards behind it still send', () => { - const behind = card('behind', 2) - const kept = [card('kept', 1, { holdReason: QUEUED_MESSAGE_PAUSED_KEPT }), behind] + const behind = card('behind') + const kept = [card('kept', { holdReason: QUEUED_MESSAGE_PAUSED_KEPT }), behind] expect(holding(kept, 0)).toEqual([ ['kept', null], ['behind', null] ]) expect(nextSendableQueuedCard(pausesOver(kept, 0), kept)).toBe(behind) expect(resumableQueuePause(pausesOver(kept, 0), kept)).toBeNull() - const restarted = [kept[0]!, card('dead', 2, { hostInstance: DEAD })] + const restarted = [kept[0]!, card('dead', { hostInstance: DEAD })] expect(nextSendableQueuedCard(pausesOver(restarted, 0), restarted)).toBeNull() expect(resumableQueuePause(pausesOver(restarted, 0), restarted)?.reason).toBe('restarted') }) @@ -627,7 +707,7 @@ describe('which cards the pauses in force hold', () => { // A dead process's card held on its own waits for its own Send, so it pauses no other card. it('a card held on its own from a dead process starts no restart pause', () => { for (const holdReason of [QUEUED_MESSAGE_PAUSED_KEPT, 'send_failed']) { - const cards = [card('held', 1, { hostInstance: DEAD, holdReason }), card('live', 2)] + const cards = [card('held', { hostInstance: DEAD, holdReason }), card('live')] expect(pausesOver(cards, 0)).toEqual([]) expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)?.messageId).toBe('live') } @@ -635,8 +715,8 @@ describe('which cards the pauses in force hold', () => { 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 }) + card('carried', { carriedFrom: 'source-session', holdReason: 'send_failed' }), + card('typed', { hostInstance: DEAD }) ] expect(pausesOver(cards, 0).map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) expect(holding(cards, 0)).toEqual([ 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 5b13856b8f0a..4f8188e661f7 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 @@ -2,7 +2,8 @@ // nothing has to retire them. Each holds its own cards (`queuePauseHolding`). In force when: // - 'stopped': the latest Stop event is a person's (reason `user-stop`), with no later Resume row // and no turn sent after it and accepted. A later Stop of any reason supersedes it; only a -// person's pauses. +// person's pauses. It holds every card but orchestration mail, which never waits on a +// person's Resume: it runs past the cards this pause holds, and its turn lifts the pause. // - 'cleared': a card /clear carried into this conversation waits, and no turn or Resume has // happened here since. // - 'restarted': a waiting card with no hold of its own was written by another host process, @@ -16,6 +17,7 @@ import type { AgentJournalCursor, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' @@ -42,7 +44,7 @@ export type JournalQueuePauseMarks = { export type DerivedQueuePause = { reason: QueuePauseReason - /** Where a Stop's pause began: a card queued at or after it is newer. Null for the others. */ + /** Where a Stop's pause began: only a turn sent after it lifts it. Null for the others. */ since: AgentJournalCursor | null } @@ -51,7 +53,7 @@ type QueueCard = { holdReason: string | null hostInstance: string carriedFrom: string | null - queuedAt: AgentJournalCursor | null + source: AgentSessionMessageSource } export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { @@ -153,20 +155,21 @@ export function deriveQueuePauses(input: { return pauses } -/** 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 +function isOrchestrationMail(card: QueueCard): boolean { + return card.source.kind === 'agent' +} + +/** A person's Stop holds every card but mail, one sent while it winds down too; /clear the cards + * it carried; a restart every card. */ +function pauseHolds(pause: DerivedQueuePause, card: QueueCard): boolean { + switch (pause.reason) { + case 'stopped': + return !isOrchestrationMail(card) + case 'cleared': + return card.carriedFrom !== null + case 'restarted': + return true } - const { since } = pause - return ( - since === null || - card.queuedAt === null || - card.queuedAt.epoch !== since.epoch || - card.queuedAt.sequence < since.sequence - ) } /** THE rule for which cards are held: by ANY pause in force, named by the first that holds it, so @@ -179,20 +182,26 @@ export function queuePauseHolding( if (card.state !== 'waiting' || card.holdReason !== null) { return undefined } - // A card queued AFTER a Stop is a new instruction and is not held; it still waits behind a held one. - return pauses.find((pause) => queuedBeforePause(pause, card)) + return pauses.find((pause) => pauseHolds(pause, card)) } /** The card the queue sends next: the oldest waiting one with no hold of its own, unless a - * returned card or one a pause holds comes first. The queue never reorders, so a newer card never - * overtakes one a pause holds; a card held on its own is passed over. The drain's pick and its - * consume both read this. */ + * returned card or one a pause holds comes first. The queue never reorders, with one exception: + * mail passes cards held only by a person's Stop, which it lifts once its turn is accepted. A + * card held on its own is passed over. The drain's pick, its consume and admission all read this. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] ): T | null { for (const card of cards) { - if (card.state === 'returned' || queuePauseHolding(pauses, card)) { + if (card.state === 'returned') { + return null + } + if (queuePauseHolding(pauses, card)) { + // The Stop holds every other card, so the only one found past it is mail. + if (heldOnlyByStop(pauses, card)) { + continue + } return null } if (card.state === 'waiting' && card.holdReason === null) { @@ -202,6 +211,10 @@ export function nextSendableQueuedCard( return null } +function heldOnlyByStop(pauses: readonly DerivedQueuePause[], card: QueueCard): boolean { + return pauses.every((pause) => pause.reason === 'stopped' || !pauseHolds(pause, card)) +} + /** 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. */ 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 d68341888a6f..2729b89a8b22 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 @@ -67,8 +67,8 @@ 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. */ + /** Where the journal stood when it was queued. Still written for older builds, whose Stop pause + * holds only cards queued before it; null on rows from builds before it was recorded. */ queuedAt: AgentJournalCursor | null /** Who it is from: the person, or another agent through Orca. */ source: AgentSessionMessageSource 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 b5759f03595c..0df8e2b46682 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 @@ -649,7 +649,8 @@ describe('/clear', () => { const working = await workingSend() await send('pointer', 'queue-if-active', { source }).result await send('typed', 'queue-if-active').result - await stop() + // A restart holds both: mail waits on no person's Stop. + await rig.restartHostProcess() await settleAccepted(working, 'a') const cleared = await clear(hostTestOperationId()) const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined 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 9e040f9669b4..3edd62f627a7 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 @@ -121,14 +121,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 () => { @@ -223,12 +226,15 @@ describe('the pause read', () => { }) }) -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 () => { +describe('a card queued after a Stop', () => { + // Sent while the stopped turn winds down, it waits with the rest: the Stop's pause holds it. + it('waits for Resume even over an empty queue: the stopped turn ending sends nothing', 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) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(async () => expect(await rig.handoff(correction)).toBeDefined()) }) 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 4e45ab4a6cc3..4c8cbfd7b3a7 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,10 +1,11 @@ // 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, any -// turn sent after it ends it, and no stored pause is ever written. +// through the real host, every card waits under a person's Stop, one queued while it winds down +// too, a withdrawn card comes back under it, a crash keeps it, any turn sent after it ends it, and +// no stored pause is ever written. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import Database from '../../sqlite/sync-database' import { journalDatabasePath } from '../agent-session-journal/journal-host-database' @@ -23,6 +24,12 @@ import { structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig +const RIG_MAIL: AgentMessageSource = { + kind: 'agent', + senders: [], + orchestration: { message: 'mail-notice', mailbox: 'run:r1', dispatchId: null, messages: [] } +} + beforeEach(async () => { rig = await createQueuedMessageTestRig({ restartable: true }) }) @@ -94,7 +101,7 @@ describe("Stop's event", () => { expect(pausedAtInterrupt).toBe('stopped') }) - it('over an EMPTY queue holds nothing: a card queued during a later mail turn sends normally', async () => { + it('over an EMPTY queue, a later mail turn ends it: a card queued during that turn sends after it', async () => { const working = await rig.workingSend() await rig.stop() await rig.settleAccepted(working, 'stopped') @@ -105,13 +112,13 @@ 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 () => { + it('holds a card queued after the Stop at the end, behind the cards before it; Resume sends all in order', async () => { const working = await rig.workingSend() const held = await queuedDraft('queued before the stop') await rig.stop() const later = await queuedDraft('typed while the stopped turn winds down') await rig.settleAccepted(working, 'stopped') - // The queue never reorders: the newer card waits behind the held one, with no caption of its own. + // Both held, the newer one at the end, with no caption of its own. await expectHeld('stopped', held, later) expect(await rig.drafts()).toEqual([ { messageId: held, state: 'waiting' }, @@ -207,17 +214,22 @@ describe("a restart's hold over a card queued after a Stop", () => { await expectHeld(null, typed) }) - it("a card queued during the queue's own send after a Stop, before a restart, waits unshown", async () => { + // Queued mail is the queue's one send a person's Stop does not hold: it runs as the stop lands. + it("a card queued during the queue's own send of mail after a Stop, before a restart, waits unshown", async () => { const working = await rig.workingSend() await rig.stop() - const correction = await queuedDraft('typed while the interrupt lands') + const mail = await rig.send('mail sent while the interrupt lands', 'queue-if-active', { + source: RIG_MAIL + }).result + if (!mail.ok || !('queued' in mail.value)) { + throw new Error('expected a queued receipt') + } + const mailId = mail.value.queued.messageId await rig.settleAccepted(working, 'stopped') - await eventually(async () => - expect((await rig.handoff(correction))?.handedOverAt).toBeDefined() - ) + await eventually(async () => expect((await rig.handoff(mailId))?.handedOverAt).toBeDefined()) const typed = await queuedDraft('typed during that send') rig.crashRestartHostProcess() - await rig.settleAccepted(await rig.handoffId(correction), 'correction') + await rig.settleAccepted(await rig.handoffId(mailId), 'mail') await rig.queuePause() expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['restarted']) await expectHeld(null, typed) 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 964cefc1b2f5..7a5b427cc8d6 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 @@ -61,7 +61,7 @@ export async function runRecordedStop( * 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 + * queued between the presses then waits under that one Stop. `late`: it names a turn * already over, as a late Stop from a phone can. */ export function stopReachesUnrecordedWork( 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 2a8c496e8b2a..a7e54d5f9fbe 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 @@ -55,6 +55,15 @@ async function turnRow(turnId: string, state: 'running' | 'interrupted') { ) } +/** Held by the one Stop once its turn ends, then sent by Resume. */ +async function expectReleasedByResume(draftId: string): Promise { + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) +} + function withdraw(clientMessageId: string) { return rig.host.settleLateDispatch({ sessionId: HOST_TEST_SESSION, @@ -169,7 +178,7 @@ describe("a Stop's event", () => { ]) }) - it('a second press while the first interrupt lands writes nothing: a card queued between them sends normally', async () => { + it('a second press while the first interrupt lands writes nothing: a card queued between them waits as after one Stop', async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -177,10 +186,10 @@ describe("a Stop's event", () => { 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()) + await expectReleasedByResume(between) }) - it('a second press once the turn shows, after a first before it did, writes nothing: a card queued between sends', async () => { + it('a second press once the turn shows, after a first before it did, writes nothing: a card queued between waits', async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() await rig.stop() @@ -190,7 +199,7 @@ describe("a Stop's event", () => { expect(stopEvents()).toHaveLength(1) await rig.settleAccepted(working, 'stopped') await turnRow('turn-1', 'interrupted') - await eventually(async () => expect(await rig.handoff(between)).toBeDefined()) + await expectReleasedByResume(between) }) it('a second press after a card sent into the turn settled unknown writes again', async () => { From 9facff6becb72c20dd6345565bc0ae7f3bedce02 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:15:03 -0700 Subject: [PATCH 169/175] fix(native-chat): a message sent while Stopping is a card at the end at once, beside the Stop's lane A person's Stop holds the session's lane until its provider answers, so a send asking to be queued showed nothing for the whole Stopping window. While the host reads Stopping, a text send asking to be queued is admitted beside the lane and written as a card at the end through the journal's own ordered writer. It is always the card: a conversation closed or replaced since admission throws, leaving the operation pending for a resend to rerun, and a send-blocking record refuses as the lane does. A /clear in flight, a rewind the lane would recover, or an acceptance-time check keeps the lane. The queue decision and its card write move to structured-agent-session-queued-send.ts. --- ...t-session-claude-stop-ends-session.test.ts | 27 +- ...structured-agent-session-host-mutations.ts | 42 ++- ...ructured-agent-session-mutation-context.ts | 46 ++- ...tructured-agent-session-queued-messages.ts | 87 +----- ...session-queued-send-while-stopping.test.ts | 265 ++++++++++++++++++ .../structured-agent-session-queued-send.ts | 137 ++++++++- ...gent-session-codex-interrupt-order.test.ts | 172 ++++++++---- 7 files changed, 603 insertions(+), 173 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-while-stopping.test.ts 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 ed06f809c6d8..50a02567ee4c 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 @@ -150,7 +150,7 @@ function eventually(assertion: () => T | Promise): Promise { } function envelope( - method: 'agentSession.send' | 'agentSession.cancel', + method: 'agentSession.send' | 'agentSession.cancel' | 'agentSession.queuedMessagesResume', fields: Record, fence = store.getRecord(SESSION)!.lease.runtimeFence ) { @@ -568,17 +568,10 @@ it('delivers a send issued with the pre-Stop fence during the Stop to the resume expect((await statusTexts()).filter((text) => text !== 'Cancellation requested.')).toEqual([]) }) -it('sends a queue-if-active message issued while the Stop ends the child directly to the resumed child', async () => { +it('holds a queue-if-active message issued while the Stop ends the child as a card; Resume sends it to the resumed child', async () => { const connection = claude.connections[0]! await openTurn(connection) const fence = store.getRecord(SESSION)!.lease.runtimeFence - let childrenWhenClosed: number | undefined - const close = connection.close - connection.close = async () => { - const proven = await close() - childrenWhenClosed = claude.connections.length - return proven - } await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) // Issued while the Stop's second step waits for the stopped turn, with the fence it moves. @@ -590,15 +583,23 @@ it('sends a queue-if-active message issued while the Stop ends the child directl }) frame(connection, INTERRUPTED_RESULT) - // Nothing runs after the rest, so it goes out as a direct submission, not a queued card. - expect(await sent).toMatchObject({ ok: true, value: { submission: expect.any(Object) } }) + // Sent while Stopping, it is a card the Stop holds: the stop landing runs nothing. + expect(await sent).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) + await eventually(() => expect(connection.closed).toBe(true)) + await laneDrained() + expect(claude.connections).toHaveLength(1) + expect(wrote(connection, 'Queued during the Stop.')).toBe(false) + + expect( + await host.queuedMessagesResume(CALLER, { + envelope: envelope('agentSession.queuedMessagesResume', {}) + }) + ).toMatchObject({ ok: true, value: { resumed: true } }) await eventually(() => { const started = claude.connections.at(-1)! expect(started).not.toBe(connection) expect(wrote(started, 'Queued during the Stop.')).toBe(true) }) - expect(childrenWhenClosed).toBe(1) - expect(wrote(connection, 'Queued during the Stop.')).toBe(false) }) it("runs nothing queued during the Stop's first step before the child's end", async () => { 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 da35be359e11..a0c5f5467b4d 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,11 @@ 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 { queuedMessageBodyIsTextOnly } from './structured-agent-session-queued-messages' +import { AgentSessionJournalError } from '../agent-session-journal/journal-write-guards' import { openForProviderWrite, openWithAgent, @@ -36,7 +39,10 @@ import { sendPlan, setOptionPlan } from './structured-agent-session-mutation-plans' -import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' +import { + queueStructuredAgentSessionSendAfterStop, + runQueueableStructuredAgentSessionSend +} from './structured-agent-session-queued-send' import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import { mutateWithChatStop } from './structured-agent-session-chat-stop' @@ -70,6 +76,21 @@ export function sendStructuredAgentSessionTurn( arrival?: Parameters[2] ): Promise> { const plan = sendPlan(params) + if (sendQueuesAfterStop(context, params, arrival)) { + // A person's Stop holds the lane until its provider answers; this send is only a card write, + // ordered with the Stop's own writes by the journal's writer, so it is a card at once. + return mutateStructuredAgentSessionOffLane(context, caller, params.envelope, { + ...plan, + run: (ctx) => { + if (context.sessions.get(ctx.sessionId)?.journal !== ctx.journal) { + // Closed or reopened since admission: nothing is written, so the operation stays + // pending and a resend runs it afresh. + throw new AgentSessionJournalError('journal_closed', 'the conversation was replaced') + } + return queueStructuredAgentSessionSendAfterStop(context, ctx, params) + } + }) + } return mutateStructuredAgentSession( context, caller, @@ -90,6 +111,25 @@ export function sendStructuredAgentSessionTurn( ) } +/** A text send asking to be queued while the host reads that a person's Stop is ending the work, + * with nothing the lane would answer first: a /clear in flight, a rewind it recovers or refuses, + * or a check made at acceptance. */ +function sendQueuesAfterStop( + context: StructuredAgentSessionMutationContext, + params: Parameters[2], + arrival: Parameters[2] +): boolean { + const { sessionId } = params.envelope + return ( + params.delivery === 'queue-if-active' && + queuedMessageBodyIsTextOnly(params.body) && + !params.beforeRun && + !arrival?.clearInFlight && + context.readStopping?.(sessionId) === true && + structuredAgentSessionSendBlock(context.deps.store.getRecord(sessionId)) === null + ) +} + export function cancelStructuredAgentSessionTurn( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, 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 fb12399c2370..0af758ccabd1 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 @@ -1,5 +1,6 @@ // The context every client mutation of a session runs with, and the one path each takes: admit the -// envelope against the lease, then run its plan inside the session's serialize. +// envelope against the lease, then run its plan inside the session's serialize (or, for a plan +// that only writes the journal, beside it). import type { AgentSessionMutationEnvelope, @@ -44,6 +45,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. */ + readStopping?: (sessionId: string) => boolean now: () => number } @@ -56,19 +60,31 @@ export function mutateStructuredAgentSession( prepareSession?: AgentSessionMutationRequest['prepareSession'] ): Promise> { return context.serialize(envelope.sessionId, () => - admitAndRunAgentSessionMutation({ - store: context.deps.store, - adapter: context.deps.adapter, - agents: context.deps.agents, - 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, + agents: context.deps.agents, + 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 7a80a14ca609..199a1b9d1ba6 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 @@ -1,6 +1,7 @@ -// Mid-turn queueing: the accept decision that turns a send into a host-held -// draft, the serialized drain that converts one draft into an ordinary -// submission when the session stops owing work, and the published draft list. +// Mid-turn queueing: the accept gate that turns a send into a host-held draft +// (written by `structured-agent-session-queued-send.ts`), the serialized drain that +// converts one draft into an ordinary submission when the session stops owing +// work, and the published draft list. // // Drafts are never owed work: they feed no reducer, no working status, no // teardown and no idle sweep. The drain re-reads every gate inside its own @@ -8,26 +9,19 @@ import { randomUUID } from 'node:crypto' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { - USER_MESSAGE_SOURCE, - type AgentSessionMessageSource -} from '../../../shared/agent-session-message-source' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED, - type AgentSessionSendResult, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { createStructuredAgentSessionOperationId, structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' -import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' import { structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' import { isUnsettledQueuedMessage } from '../agent-session-journal/queued-message-table' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -201,79 +195,6 @@ export function queuedMessageBudgetRefusal( return null } -/** - * The accept branch: a capable send while the session is working (or behind an - * actionable backlog) becomes a draft instead of a submission. Returns null for - * the immediate path — an incapable client, an image body (text-only v1), a - * replayed id the journal already answers, or an idle session. - */ -export async function maybeQueueStructuredAgentSessionSend( - context: { - deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } } - }, - ctx: Pick, - params: { - envelope: { clientOperationId: string } - body: AgentJournalMessageItem - delivery?: 'queue-if-active' - /** A person's send at a chat surface; it outranks any `source`. */ - userSend?: true - /** Who a host-side send is from. */ - source?: AgentSessionMessageSource - } -): Promise< - | { ok: true; value: AgentSessionSendResult } - | { ok: false; refusal: AgentSessionWireRefusal } - | null -> { - const clientMessageId = params.envelope.clientOperationId - if (params.delivery !== 'queue-if-active' || !queuedMessageBodyIsTextOnly(params.body)) { - return null - } - // Asked again with no ledger answer: a send this host queued answers as its replay would — - // its hand-off goes out under a fresh id, so no submission under this id guards it. - const queuedBefore = queuedSendAnswer(ctx.journal, clientMessageId) - if (queuedBefore) { - return { ok: true, value: queuedBefore } - } - // A recorded direct submission under this id replays through today's path. - if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) { - return null - } - if ( - !shouldQueueStructuredAgentSessionSend({ - journal: ctx.journal, - record: context.deps.store.getRecord(ctx.sessionId), - fence: ctx.fence - }) - ) { - return null - } - const refusal = queuedMessageBudgetRefusal(ctx.journal, params.body) - if (refusal) { - return { ok: false, refusal } - } - // The insert notifies through the journal's commit listener: publication and - // the drain re-derive with no call here to forget. - const row = await ctx.journal.queuedMessages.insert( - { - messageId: clientMessageId, - body: params.body, - fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance(), - source: params.userSend ? USER_MESSAGE_SOURCE : (params.source ?? USER_MESSAGE_SOURCE) - }, - ctx.operationReceipt - ) - return { - ok: true, - value: { - clientMessageId, - queued: { messageId: row.messageId, position: row.position, state: row.state } - } - } -} - export type QueuedMessageDrainDeps = { sessions: ReadonlyMap getRecord: (sessionId: string) => AgentSessionRecord | null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-while-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-while-stopping.test.ts new file mode 100644 index 000000000000..a7da0b099f24 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-while-stopping.test.ts @@ -0,0 +1,265 @@ +// A send asking to be queued while a person's Stop ends the work is a card at the END of the queue +// at once, written beside the session's lane, which that Stop holds until its provider answers. +// The Stop's pause holds it, even alone, until Resume or a turn accepted after the Stop; +// orchestration mail is the exception, running as the stop lands. Through the real host and journal. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + HOST_TEST_SESSION as 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' + +const MAIL: AgentMessageSource = { + kind: 'agent', + senders: [], + orchestration: { message: 'mail-notice', mailbox: 'run:r1', dispatchId: null, messages: [] } +} + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(async () => { + vi.restoreAllMocks() + await rig.dispose() +}) + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +/** The provider's row for the working send's turn. */ +function turnRow(state: 'running' | 'interrupted') { + return journal().appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 }, + { kind: 'turn', turnId: 'turn-1', state, startedAt: 1 }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +/** A send whose turn runs until the Stop `stop` presses lands. */ +async function runningTurn(): Promise { + const working = await rig.workingSend() + await turnRow('running') + return working +} + +/** A person's Stop of the running turn: the chat reads Stopping until the returned land ends it. */ +async function stop(working: string): Promise<() => Promise> { + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(readStopping()).toBe(true) + return async () => { + await rig.settleAccepted(working, 'stopped') + await turnRow('interrupted') + expect(readStopping()).toBe(false) + } +} + +function readStopping(): boolean { + return rig.host['clientDelivery'].readStopping(SESSION) +} + +/** Holds the session's lane until released: a send that answers meanwhile never waited on it. */ +function holdLane(): () => void { + const held = Promise.withResolvers() + void rig.host['tasks'].serialize(SESSION, () => held.promise) + return () => held.resolve() +} + +async function queuedId( + sent: ReturnType['result'] +): Promise { + const queued = await sent + if (!queued.ok || !('queued' in queued.value)) { + throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) + } + return queued.value.queued.messageId +} + +/** Nothing sends any of them by itself, and the queue reads paused by the Stop. */ +async function expectHeld(...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: 'stopped' }) +} + +/** The next admission waits until released, once it has read the journal it will write to. */ +function holdNextAdmission(): { reached: Promise; release: () => void } { + const reached = Promise.withResolvers() + const gate = Promise.withResolvers() + const admit = rig.store.admitMutationOperation.bind(rig.store) + vi.spyOn(rig.store, 'admitMutationOperation').mockImplementationOnce(async (operation) => { + reached.resolve() + await gate.promise + return admit(operation) + }) + return { reached: reached.promise, release: () => gate.resolve() } +} + +describe('a queued send while Stopping', () => { + it('is a card at once, at the end, with the lane held; Resume sends the cards in order', async () => { + const working = await runningTurn() + const before = await queuedId(rig.send('queued before the stop', 'queue-if-active').result) + const land = await stop(working) + const release = holdLane() + + const during = await queuedId(rig.send('sent while stopping', 'queue-if-active').result) + + release() + expect(await rig.drafts()).toEqual([ + { messageId: before, state: 'waiting' }, + { messageId: during, state: 'waiting' } + ]) + await land() + await expectHeld(before, during) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect((await rig.handoff(before))?.handedOverAt).toBeDefined()) + expect(await rig.handoff(during)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(before), 'before') + await eventually(async () => expect(await rig.handoff(during)).toBeDefined()) + }) + + it('alone, waits too; a turn sent and accepted after the Stop releases it after that turn', async () => { + const land = await stop(await runningTurn()) + const alone = await queuedId(rig.send('sent while stopping', 'queue-if-active').result) + await land() + await expectHeld(alone) + // What "Send message" does: a plain send, past the card, that ends the pause once accepted. + const next = rig.send('sent now') + expect(await next.result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + await eventually(async () => + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() + ) + expect(await rig.handoff(alone)).toBeUndefined() + await rig.settleAccepted(next.id, 'next') + await eventually(async () => expect(await rig.handoff(alone)).toBeDefined()) + }) +}) + +// Mail never waits on a person's Resume: queued while Stopping it runs as the stop lands, ahead of +// the person's cards, and its accepted turn lifts the pause so they follow in order. +describe('orchestration mail sent while Stopping', () => { + it("is a card at the end the person's Stop does not hold: it runs first, then the cards follow", async () => { + const working = await runningTurn() + const person = await queuedId(rig.send('queued before the stop', 'queue-if-active').result) + const land = await stop(working) + const mail = await queuedId( + rig.send('mail sent while stopping', 'queue-if-active', { source: MAIL }).result + ) + const typed = await queuedId(rig.send('typed while stopping', 'queue-if-active').result) + expect((await rig.drafts()).map((draft) => draft.messageId)).toEqual([person, mail, typed]) + + await land() + await eventually(async () => expect((await rig.handoff(mail))?.handedOverAt).toBeDefined()) + expect(await rig.handoff(person)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(mail), 'mail') + await eventually(async () => expect((await rig.handoff(person))?.handedOverAt).toBeDefined()) + expect(await rig.handoff(typed)).toBeUndefined() + await rig.settleAccepted(await rig.handoffId(person), 'person') + await eventually(async () => expect(await rig.handoff(typed)).toBeDefined()) + }) +}) + +describe('the card written beside the lane', () => { + // Read once, before admission: a stop that lands meanwhile still gets the card, held, and never + // a fallback onto the lane. + it('is still a held card at the end when the stop lands between the read and the write', async () => { + const working = await runningTurn() + const before = await queuedId(rig.send('queued before the stop', 'queue-if-active').result) + const land = await stop(working) + const admission = holdNextAdmission() + const sent = rig.send('sent as the stop lands', 'queue-if-active') + await admission.reached + await land() + admission.release() + + const during = await queuedId(sent.result) + expect(await rig.drafts()).toEqual([ + { messageId: before, state: 'waiting' }, + { messageId: during, state: 'waiting' } + ]) + expect(await rig.submission(sent.id)).toBeUndefined() + await expectHeld(before, during) + }) + + it('writes nothing when the conversation closes meanwhile; the operation stays pending and a resend answers once', async () => { + const land = await stop(await runningTurn()) + const admission = holdNextAdmission() + const clientOperationId = hostTestOperationId() + const fields = { body: hostTestMessage('sent as the chat closes'), delivery: 'queue-if-active' } + const resend = () => + rig.host.send(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.send', clientOperationId), + body: fields.body, + delivery: 'queue-if-active' + }) + const sent = resend() + await admission.reached + await land() + await rig.host.close(SESSION, 'evict') + admission.release() + + await expect(sent).rejects.toMatchObject({ code: 'journal_closed' }) + expect(rig.store.getOperationRow(QUEUED_RIG_CALLER.callerKey, clientOperationId)).toMatchObject( + { outcome: { status: 'pending' } } + ) + // The chat reopens idle: the resend runs for the first time, once. + const first = await resend() + expect(first).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + expect(await resend()).toMatchObject({ + ok: true, + replayed: true, + value: first.ok ? first.value : {} + }) + expect(await rig.drafts()).toEqual([]) + }) + + it('refuses as the lane does when the conversation is cleared meanwhile', async () => { + await stop(await runningTurn()) + const admission = holdNextAdmission() + const sent = rig.send('sent as the chat clears', 'queue-if-active') + await admission.reached + const getRecord = rig.store.getRecord.bind(rig.store) + vi.spyOn(rig.store, 'getRecord').mockImplementation((sessionId): AgentSessionRecord | null => { + const record = getRecord(sessionId) + return record && { + ...record, + conversationCommand: { + command: 'clear', + runtimeFence: 1, + operationId: 'clear-1', + callerKey: QUEUED_RIG_CALLER.callerKey, + phase: 'committed', + state: 'completed', + replacementSessionId: 'clear-replacement-1' + } + } + }) + admission.release() + + expect(await sent.result).toMatchObject({ + ok: false, + refusal: { details: { reason: 'conversationCleared' } } + }) + vi.mocked(rig.store.getRecord).mockRestore() + expect(await rig.drafts()).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index 7a7da56dd25a..557fea118695 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -1,11 +1,26 @@ -// A send's queue step around its immediate path: the queue decision before it. Its -// accepted turn lifts a paused queue (`queued-message-pause.ts`), not anything here. +// A send's queue step around its immediate path: the queue decision before it, and the card it +// writes. Its accepted turn lifts a paused queue (`queued-message-pause.ts`), not anything here. -import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { + AgentSessionSendResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' +import { + USER_MESSAGE_SOURCE, + type AgentSessionMessageSource +} from '../../../shared/agent-session-message-source' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { maybeQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages' +import { + queuedMessageBodyIsTextOnly, + queuedMessageBudgetRefusal, + queuedMessageFingerprint, + shouldQueueStructuredAgentSessionSend +} from './structured-agent-session-queued-messages' +import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' +import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' export async function runQueueableStructuredAgentSessionSend( @@ -33,3 +48,115 @@ export async function runQueueableStructuredAgentSessionSend( } return accepted } + +/** + * The accept branch: a capable send while the session is working (or behind an + * actionable backlog) becomes a draft instead of a submission. Returns null for + * the immediate path — an incapable client, an image body (text-only v1), a + * replayed id the journal already answers, or an idle session. + */ +export async function maybeQueueStructuredAgentSessionSend( + context: { + deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } } + }, + ctx: Pick, + params: { + envelope: { clientOperationId: string } + body: AgentJournalMessageItem + delivery?: 'queue-if-active' + /** A person's send at a chat surface; it outranks any `source`. */ + userSend?: true + /** Who a host-side send is from. */ + source?: AgentSessionMessageSource + } +): Promise< + | { ok: true; value: AgentSessionSendResult } + | { ok: false; refusal: AgentSessionWireRefusal } + | null +> { + const clientMessageId = params.envelope.clientOperationId + if (params.delivery !== 'queue-if-active' || !queuedMessageBodyIsTextOnly(params.body)) { + return null + } + // Asked again with no ledger answer: a send this host queued answers as its replay would — + // its hand-off goes out under a fresh id, so no submission under this id guards it. + const queuedBefore = queuedSendAnswer(ctx.journal, clientMessageId) + if (queuedBefore) { + return { ok: true, value: queuedBefore } + } + // A recorded direct submission under this id replays through today's path. + if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) { + return null + } + if ( + !shouldQueueStructuredAgentSessionSend({ + journal: ctx.journal, + record: context.deps.store.getRecord(ctx.sessionId), + fence: ctx.fence + }) + ) { + return null + } + return insertQueuedSend(ctx, params) +} + +type QueuedSendParams = Parameters[2] + +/** + * A text send asking to be queued while a person's Stop ends the work: always a card at the end, + * whatever the session reads by now, which that Stop's pause holds until Resume or an accepted + * turn. A replayed id answers as it was recorded, and `blocked` refuses as the immediate path does. + */ +export async function queueStructuredAgentSessionSendAfterStop( + context: { + deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } } + }, + ctx: Pick, + params: QueuedSendParams +): Promise> { + const clientMessageId = params.envelope.clientOperationId + const queuedBefore = queuedSendAnswer(ctx.journal, clientMessageId) + if (queuedBefore) { + return { ok: true, value: queuedBefore } + } + const submission = ctx.journal.submission(clientMessageId) + if (submission) { + return { ok: true, value: { clientMessageId, submission } } + } + return ( + structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) ?? + insertQueuedSend(ctx, params) + ) +} + +async function insertQueuedSend( + ctx: Pick, + params: QueuedSendParams +): Promise< + { ok: true; value: AgentSessionSendResult } | { ok: false; refusal: AgentSessionWireRefusal } +> { + const clientMessageId = params.envelope.clientOperationId + const refusal = queuedMessageBudgetRefusal(ctx.journal, params.body) + if (refusal) { + return { ok: false, refusal } + } + // The insert notifies through the journal's commit listener: publication and + // the drain re-derive with no call here to forget. + const row = await ctx.journal.queuedMessages.insert( + { + messageId: clientMessageId, + body: params.body, + fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), + hostInstance: structuredAgentSessionHostInstance(), + source: params.userSend ? USER_MESSAGE_SOURCE : (params.source ?? USER_MESSAGE_SOURCE) + }, + ctx.operationReceipt + ) + return { + ok: true, + value: { + clientMessageId, + queued: { messageId: row.messageId, position: row.position, state: row.state } + } + } +} 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..68194731bffa 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 @@ -343,41 +343,90 @@ describe('a send behind a Codex Stop answered before its turn ends', () => { }) }) -// 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. +async function resume(): Promise { + const resumed = await host.queuedMessagesResume(CALLER, { + envelope: envelope('agentSession.queuedMessagesResume', {}) + }) + expect(resumed).toMatchObject({ ok: true, value: { resumed: true } }) +} + +/** The cards each queue hand-off sent, in order. */ +async function handOffs(): Promise { + return (await host.journalSnapshot(SESSION)).submissions.flatMap((entry) => + entry.queuedMessageId ? [entry.queuedMessageId] : [] + ) +} + +function queuedMessageId(sent: Awaited>): string { + if (!('queued' in sent)) { + throw new Error('expected a queued receipt') + } + return sent.queued.messageId +} + +/** A running turn-1, and a person's Stop of it whose interrupt Codex has yet to answer. */ +async function stopAwaitingCodex(beforeStop?: () => Promise) { + const first = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(first) + await beforeStop?.() + 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)) + return { + stopped: () => stopped, + /** Codex answers the interrupt, then ends the turn; nothing then runs by itself. */ + land: async () => { + answer.resolve() + await stopping + turns.end('interrupted') + await vi.waitFor(async () => expect(await owesWork()).toBe(false)) + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(answers).toBe(1) + expect(startedTurns).toEqual(['turn-1']) + } + } +} + +/** The turn the last turn/start opened runs, takes the latest send, and completes. */ +async function completeTurn(): Promise { + const latest = (await host.journalSnapshot(SESSION)).submissions.at(-1) + turns.start() + turns.echo(latest?.clientMessageId ?? '') + turns.end('completed') +} + +// A message sent asking to be queued while a person's Stop ends the turn is a card at once, at the +// end, while the Stop still waits on Codex. The Stop's pause holds it, even alone: Resume, or a turn +// accepted after the Stop, sends the cards, each as its own turn. 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)) + it('is a card at once that the Stop holds even alone; Resume sends it as its own turn', async () => { + const stopping = await stopAwaitingCodex() 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(stopping.stopped()).toBe(false) + const messageId = queuedMessageId(queued) + expect(await queuePage()).toEqual({ + cards: [expect.objectContaining({ messageId, state: 'waiting' })], + pause: expect.objectContaining({ reason: 'stopped' }) + }) + // A retry of the same operation answers the same card. expect(await sendQueued('run this after the stop', messageId)).toEqual(queued) - answer.resolve() - await stopping - turns.end('interrupted') + await stopping.land() + expect(await queuedCards()).toEqual([expect.objectContaining({ messageId, state: 'waiting' })]) + await resume() 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. + // Retried once handed off, 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 } }) @@ -389,41 +438,52 @@ describe('a queued send while a Codex Stop waits on its answer', () => { }) }) -// 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') + let paused = '' + const queueBeforeStop = async () => { + paused = queuedMessageId(await sendQueued('queued before the stop')) + } - 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]) + it('waits at the end with it; Resume sends both in order, each its own turn', async () => { + const stopping = await stopAwaitingCodex(queueBeforeStop) + const during = queuedMessageId(await sendQueued('sent while stopping')) + expect((await queuedCards()).map((card) => card.messageId)).toEqual([paused, during]) + + await stopping.land() expect(await queuePage()).toEqual({ - cards: [expect.objectContaining({ messageId: paused, state: 'waiting', heldByPause: true })], + cards: [ + expect.objectContaining({ messageId: paused, state: 'waiting' }), + expect.objectContaining({ messageId: during, state: 'waiting' }) + ], pause: expect.objectContaining({ reason: 'stopped' }) }) + + await resume() + await vi.waitFor(() => expect(answers).toBe(2)) + expect(await handOffs()).toEqual([paused]) + await completeTurn() + await vi.waitFor(() => expect(answers).toBe(3)) + expect(await handOffs()).toEqual([paused, during]) + expect(startedTurns).toEqual(['turn-1', 'turn-2', 'turn-3']) + expect(steers).toBe(0) + }) + + it('the next turn sent after the stop releases them: it runs first, then both in order', async () => { + const stopping = await stopAwaitingCodex(queueBeforeStop) + const during = queuedMessageId(await sendQueued('sent while stopping')) + await stopping.land() + + // What the "Send message?" dialog's Send does: a plain send past the paused cards. + await send('sent now, past the paused cards') + await vi.waitFor(() => expect(answers).toBe(2)) + expect(await handOffs()).toEqual([]) + await completeTurn() + await vi.waitFor(() => expect(answers).toBe(3)) + expect(await handOffs()).toEqual([paused]) + await completeTurn() + await vi.waitFor(() => expect(answers).toBe(4)) + expect(await handOffs()).toEqual([paused, during]) + expect(startedTurns).toEqual(['turn-1', 'turn-2', 'turn-3', 'turn-4']) + expect(steers).toBe(0) }) }) From 65018ffa30ed9e0d8b4b39b042e3aab47c25fc04 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:15:03 -0700 Subject: [PATCH 170/175] fix(native-chat): drop the Stopping placeholder that promised a queued message runs after the stop A message queued while Stopping now waits for Resume, so "Queue a message to run after the stop" was false. The composer shows its normal prompt there, desktop and phone. "Send a message to run after the stop" stays where sends are not queued: the host holds that send until the stop lands. --- mobile/src/session/MobileNativeChatView.test.ts | 4 ++-- .../use-mobile-native-chat-input-lease.test.ts | 12 +++++++----- .../session/use-mobile-native-chat-input-lease.ts | 9 ++++----- .../native-chat-composer-placeholder.test.ts | 12 +++++++----- .../native-chat/native-chat-composer-target.ts | 7 +------ src/renderer/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 | 5 ++--- 13 files changed, 23 insertions(+), 33 deletions(-) diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 5dc4e59a7abf..290074c01464 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -170,10 +170,10 @@ describe('MobileNativeChatView', () => { }) it.each([ - ['queue', 'Queue a message to run after the stop'], + ['queue', 'Message, @files, /commands'], ['send', 'Send a message to run after the stop'] ] as const)( - 'tells the composer a message sent now runs after the stop (%s)', + 'tells the composer whether a message sent now runs after the stop (%s)', async (afterStop, placeholder) => { await render({ structuredActivityUi: true, diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts index 847fbe3d517d..75681d84f25b 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts @@ -114,16 +114,18 @@ describe('useSettledMobileNativeChatInputLock', () => { }) describe("the phone chat composer's placeholder", () => { - it('says a message runs after the stop while the chat reads Stopping', () => { - 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. + // Where the host does not queue sends, it holds the message until the stop lands. + it('says a message runs after the stop while the chat reads Stopping and sends are not queued', () => { expect(mobileNativeChatComposerPlaceholder(null, 'send')).toBe( 'Send a message to run after the stop' ) }) + // A queued one is a card the Stop holds until Resume: nothing promises it runs after the stop. + it('reads as usual while Stopping where a send is queued', () => { + expect(mobileNativeChatComposerPlaceholder(null, 'queue')).toBe('Message, @files, /commands') + }) + it('reads as usual otherwise', () => { expect(mobileNativeChatComposerPlaceholder(null, undefined)).toBe('Message, @files, /commands') }) diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.ts b/mobile/src/session/use-mobile-native-chat-input-lease.ts index 02cb57849d4d..c881c888cd34 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.ts @@ -95,7 +95,8 @@ export function useSettledMobileNativeChatInputLock( } /** What the composer says while the input lease blocks it, else that a message sent now runs - * after a Stop the chat reads as stopping, else its normal prompt. */ + * after a Stop the chat reads as stopping, else its normal prompt. A queued one is a card the + * Stop holds until Resume, so it gets the normal prompt. */ export function mobileNativeChatComposerPlaceholder( lockReason: MobileNativeChatInputLockReason | null, /** While stopping: whether a message sent now is queued, or sent for the host to hold. */ @@ -107,10 +108,8 @@ export function mobileNativeChatComposerPlaceholder( if (lockReason === 'waiting') { return 'Waiting for terminal…' } - if (afterStop) { - return afterStop === 'queue' - ? NATIVE_CHAT_TURN_STATUS_COPY.queueAfterStop - : NATIVE_CHAT_TURN_STATUS_COPY.sendAfterStop + if (afterStop === 'send') { + return NATIVE_CHAT_TURN_STATUS_COPY.sendAfterStop } return 'Message, @files, /commands' } 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 0fbb8a9cec79..0eb56c05423f 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 @@ -6,16 +6,18 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: 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, '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. + // Where a send is not queued, the host holds it until the stop lands. + it('says a message runs after the stop while the chat reads Stopping and sends are not queued', () => { expect(nativeChatComposerPlaceholder(true, true, 'send')).toBe( 'Send a message to run after the stop' ) }) + // A queued one is a card the Stop holds until Resume: nothing promises it runs after the stop. + it('reads as usual while Stopping where a send is queued', () => { + expect(nativeChatComposerPlaceholder(true, true, 'queue')).toBe('Send a message…') + }) + it('reads as usual otherwise', () => { expect(nativeChatComposerPlaceholder(true, true)).toBe('Send a message…') }) 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 ff4e64bc6abb..5997d2a3382d 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 @@ -30,12 +30,7 @@ export function nativeChatComposerPlaceholder( if (!canSend) { return translate('components.native-chat.composer.locked', 'Input is held by another device.') } - if (afterStop === 'queue') { - return translate( - 'components.native-chat.status.queueAfterStop', - NATIVE_CHAT_TURN_STATUS_COPY.queueAfterStop - ) - } + // A queued one is a card the Stop holds until Resume, so it gets the normal prompt. if (afterStop === 'send') { return translate( 'components.native-chat.status.sendAfterStop', diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 94cbe1aaa27e..59a169367c84 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2851,7 +2851,6 @@ "status": { "failedAfter": "Failed after {{value0}}", "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", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e9dc47723d90..76aa6ccc2eeb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18091,7 +18091,6 @@ "responding": "Agent is responding", "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}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 59b456f7787f..23765875082b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17827,7 +17827,6 @@ "status": { "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 f3e17d839fb4..26d8a8b74ac5 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17927,7 +17927,6 @@ "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", "sendAfterStop": "Envoyez un message pour l'exécuter après l'arrêt", "thinking": "Réflexion", "workingFor": "En cours depuis {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e06e1928f4ee..be42f61c6c8d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17863,7 +17863,6 @@ "responding": "Agent が応答中です", "working": "処理中…", "stopping": "停止中…", - "queueAfterStop": "停止後に実行するメッセージをキューに追加", "sendAfterStop": "停止後に実行するメッセージを送信", "thinking": "考え", "workingFor": "{{value0}} 処理中", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7bebd1e8d7a7..c02e645b42cf 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17863,7 +17863,6 @@ "responding": "Agent이 응답 중입니다.", "working": "작업 중…", "stopping": "중지하는 중…", - "queueAfterStop": "중지 후에 실행할 메시지를 대기열에 추가", "sendAfterStop": "중지 후에 실행할 메시지 보내기", "thinking": "생각", "workingFor": "{{value0}} 작업 중", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4aabf2d0f2ec..648b6367aaf7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17828,7 +17828,6 @@ "responding": "智能体正在回复", "working": "处理中…", "stopping": "正在停止…", - "queueAfterStop": "将消息加入队列,在停止后运行", "sendAfterStop": "发送消息,在停止后运行", "thinking": "思维", "workingFor": "已工作 {{value0}}", diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index 83da06a82702..26c4e45f8494 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -10,9 +10,8 @@ export const NATIVE_CHAT_TURN_STATUS_COPY = { thinking: 'Thinking', working: 'Working…', stopping: '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. */ + /** The composer's placeholder while the chat reads Stopping and a send is not queued: the host + * holds it until the stop lands. */ sendAfterStop: 'Send a message to run after the stop', workingFor: 'Working for {{value0}}', workedFor: 'Worked for {{value0}}', From b01d6bb9e4326aac43012f8d822b08a905d63a05 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:15:04 -0700 Subject: [PATCH 171/175] test(native-chat): a desktop send while Stopping follows the queue setting, with no dialog while working Queueing on asks a queueing host to queue it and draws no bubble; queueing off sends it plain, marked as sent while stopping. The "Send message?" choice is never offered while a turn runs. --- ...nt-session-queued-messages.resume.test.tsx | 4 +- ...gent-session.queue-while-stopping.test.tsx | 55 ++++++++++++------- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx index 8796fd3aad98..e76d5a8971f0 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -135,11 +135,13 @@ describe('whether Resume is offered', () => { }) describe('while a turn runs over held cards', () => { - it('the header row still names the pause and offers Resume; the composer does not', () => { + it('the header row still names the pause and offers Resume; the composer offers neither it nor "Send message?"', () => { // `isWorking` counts the queue's coming send, which the host names. const { result } = renderController({ isWorking: true }) expect(result.current.pause).toEqual({ reason: 'stopped' }) expect(result.current.queueResume).toBeUndefined() + // Stopping reads as working: a message sent then joins the queue, with no dialog. + expect(result.current.queueHold).toBeUndefined() }) }) 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..8ae466493ca0 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,9 @@ // @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. +// With queueing on, where the host queues sends, it asks to be queued and draws no bubble: the host +// makes it a card the Stop holds. Otherwise 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' @@ -55,10 +55,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(() => {})) }) @@ -69,7 +67,7 @@ afterEach(() => { mocks.call.mockReset() }) -function renderStopping() { +function renderStopping(queueFollowUps = false) { return renderHook(() => useStructuredAgentSession({ sessionId: 'session-1', @@ -77,14 +75,27 @@ function renderStopping() { target: { kind: 'local' }, isVisible: true, composerScopeKey: 'scope-1', - queueFollowUps: false, + queueFollowUps, hostStopping: true }) ) } -it('asks the host to queue a send made while the host reads Stopping, and draws no bubble', async () => { - const { result } = renderStopping() +function expectPlainSentWhileStopping( + messages: ReturnType['result']['current']['messages'] +) { + expect(sends()[0]).not.toHaveProperty('delivery') + expect( + messages.find((message) => JSON.stringify(message.blocks).includes('run this after the stop')) + ).toMatchObject({ role: 'user', sentWhileStopping: true }) +} + +it('with queueing on, asks a queueing host to queue it, and draws no bubble', async () => { + setLocalRuntimeCapabilitiesForTests([ + AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY + ]) + const { result } = renderStopping(true) expect(result.current.send('run this after the stop')).toBe(true) @@ -93,24 +104,30 @@ 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('with queueing off, sends plain to a queueing host, marked as sent while stopping', async () => { + setLocalRuntimeCapabilitiesForTests([ + AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY + ]) + const { result } = renderStopping(false) + + expect(result.current.send('run this after the stop')).toBe(true) + + await waitFor(() => expect(sends()).toHaveLength(1)) + expectPlainSentWhileStopping(result.current.messages) +}) + 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) 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', sentWhileStopping: true }) + expectPlainSentWhileStopping(result.current.messages) }) // 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({ From d1d0bb193b45647e38edd6d69044dbe36151772f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:18:22 -0700 Subject: [PATCH 172/175] fix(native-chat): only mail passes the cards a person's Stop holds, whatever else that Stop holds Said in the queue's pick itself rather than left to the hold rule, so a later change to which cards a Stop holds cannot let a person's card overtake. --- .../agent-session-journal/queued-message-pause.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 4f8188e661f7..67c67fcb7fd8 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 @@ -193,19 +193,20 @@ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] ): T | null { + let pastStopHeld = false for (const card of cards) { if (card.state === 'returned') { return null } if (queuePauseHolding(pauses, card)) { - // The Stop holds every other card, so the only one found past it is mail. - if (heldOnlyByStop(pauses, card)) { - continue + if (!heldOnlyByStop(pauses, card)) { + return null } - return null + pastStopHeld = true + continue } if (card.state === 'waiting' && card.holdReason === null) { - return card + return !pastStopHeld || isOrchestrationMail(card) ? card : null } } return null From 70a37e35820f5b65f54746c7cbf2f7911acd08cb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:52:09 -0700 Subject: [PATCH 173/175] test(mobile): a host-kept card's test stands in a Stop's pause, as this host publishes no restart pause Main's #24660 test published queuePause 'restarted', which this branch's wire type no longer lists, so the mobile tests typecheck ratchet failed. --- .../use-mobile-structured-agent-session-queued.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 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 fe7f7f1eb14a..abecc1550c67 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 @@ -580,7 +580,8 @@ describe('mobile structured queued messages', () => { queuedDraft({ messageId: 'behind', position: 2 }) ], [], - { reason: 'restarted' } + // This host publishes no restart pause; a Stop's stands in for any queue-wide one. + { reason: 'stopped' } ) ) ) @@ -589,7 +590,7 @@ describe('mobile structured queued messages', () => { { messageId: 'behind', caption: null } ]) // The kept card is held on its own, so Resume would send the card behind it. - expect(hook!.queued.pause).toEqual({ reason: 'restarted' }) + expect(hook!.queued.pause).toEqual({ reason: 'stopped' }) }) }) From d7768c35595ac03a66d1a7f6eb7469a927e5647b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:29:29 -0700 Subject: [PATCH 174/175] test(native-chat): the restart-hold test queues mail, which a person's Stop never holds; say mail passes a Stop's held cards where the queue rule is described --- ...tructured-agent-session-queued-messages.ts | 5 ++-- ...session-queued-send-while-stopping.test.ts | 28 +++++++++---------- ...ured-agent-session-queued-stop-row.test.ts | 13 +++++++-- ...iveChatStructuredSession.stopping.test.tsx | 2 +- .../agent-session-queued-message-wire.ts | 7 +++-- 5 files changed, 32 insertions(+), 23 deletions(-) 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 199a1b9d1ba6..0735f5293200 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 @@ -59,8 +59,9 @@ export function pendingPromptExists(journal: Pick ): QueuedMessageRow | null { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-while-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-while-stopping.test.ts index a7da0b099f24..7591a02f4d4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-while-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-while-stopping.test.ts @@ -82,9 +82,7 @@ function holdLane(): () => void { return () => held.resolve() } -async function queuedId( - sent: ReturnType['result'] -): Promise { +async function queuedId(sent: ReturnType['result']): Promise { const queued = await sent if (!queued.ok || !('queued' in queued.value)) { throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) @@ -240,18 +238,20 @@ describe('the card written beside the lane', () => { const getRecord = rig.store.getRecord.bind(rig.store) vi.spyOn(rig.store, 'getRecord').mockImplementation((sessionId): AgentSessionRecord | null => { const record = getRecord(sessionId) - return record && { - ...record, - conversationCommand: { - command: 'clear', - runtimeFence: 1, - operationId: 'clear-1', - callerKey: QUEUED_RIG_CALLER.callerKey, - phase: 'committed', - state: 'completed', - replacementSessionId: 'clear-replacement-1' + return ( + record && { + ...record, + conversationCommand: { + command: 'clear', + runtimeFence: 1, + operationId: 'clear-1', + callerKey: QUEUED_RIG_CALLER.callerKey, + phase: 'committed', + state: 'completed', + replacementSessionId: 'clear-replacement-1' + } } - } + ) }) admission.release() 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 4c8cbfd7b3a7..51dcdbc72475 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 @@ -195,16 +195,23 @@ describe("Stop's event", () => { }) describe("a restart's hold over a card queued after a Stop", () => { - it('a card queued after a Stop over an empty queue, before a restart, waits unshown', async () => { + // Mail, which a person's Stop never holds: only the restart can hold it. + it('mail queued after a Stop over an empty queue, before a restart, waits unshown', 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') + const queued = await rig.send('mail queued during the mail turn', 'queue-if-active', { + source: RIG_MAIL + }).result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const typed = queued.value.queued.messageId // The process dies with no close: a quit writes no Stop event, so only a turn ends the pauses. rig.crashRestartHostProcess() await rig.settleAccepted(mail, 'mail') - // The new host opens the conversation for its first reader. The card came after the Stop, so + // The new host opens the conversation for its first reader. The Stop's pause passes mail, so // only the restart holds it, and a restart's hold is never published. await rig.queuePause() expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual([ 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 7d5437b2e672..6a60fa073358 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx @@ -93,7 +93,7 @@ describe("the chat pane while a person's Stop ends the turn", () => { expect(mocks.stop).toHaveBeenCalledOnce() }) - it('says a message is queued to run after the stop only where the host queues sends', async () => { + it('tells the composer a send while Stopping becomes a card where the host queues sends', async () => { mocks.turnId = 'turn-1' mocks.sendsQueue = true renderPane() diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index a1df60b85f6f..c24e4971fff2 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -15,9 +15,10 @@ export type AgentSessionQueuedMessagePausedReason = | typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED | typeof QUEUED_MESSAGE_PAUSED_KEPT -/** The whole queue is paused and sends nothing on its own: 'stopped' — the user - * interrupted ("Queue paused because you interrupted") — or 'cleared' — a /clear - * carried the cards into a fresh conversation. Resume (`agentSession.queuedMessagesResume`), +/** The queue is paused and sends nothing on its own: 'stopped' — the user + * interrupted ("Queue paused because you interrupted"); it holds every card but + * orchestration mail — or 'cleared' — a /clear carried the cards into a fresh + * conversation. Resume (`agentSession.queuedMessagesResume`), * or any turn starting, lifts it; Send-now on one card sends that card and leaves the * rest paused until its turn starts. A client treats an unknown reason as a * plain pause, so a newer host can add one. */ From 173e9531bd4e4bbd3d08b0aa9b7e1da358896758 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:25:56 -0700 Subject: [PATCH 175/175] feat(native-chat): each queued card names its sender's kind, so a person's Stop marks only their own cards paused; another agent's mail reads as waiting --- ...le-structured-queued-message-cards.test.ts | 21 ++++++- .../mobile-structured-queued-message-cards.ts | 5 +- ...bile-structured-queued-message-controls.ts | 2 +- ...ed-agent-session-queued-card-holds.test.ts | 2 +- ...ctured-agent-session-queued-publication.ts | 1 + ...session-queued-send-while-stopping.test.ts | 8 +++ ...uctured-agent-session-queued-cards.test.ts | 29 +++++++-- .../structured-agent-session-queued-cards.ts | 11 ++-- ...tructured-agent-session-queued-messages.ts | 2 +- .../agent-session-queued-message-wire.ts | 15 +++++ src/shared/protocol-version.ts | 4 +- .../queued-card-source-downgrade.unit.test.ts | 63 +++++++++++++++++++ 12 files changed, 144 insertions(+), 19 deletions(-) create mode 100644 tests/e2e/cross-version-wire/queued-card-source-downgrade.unit.test.ts 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 29153e0a3b9a..763eb030ebe7 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -19,6 +19,8 @@ function returnedAs(fact: Parameters[0]) { return { state: 'returned' as const, returnedReason: reason, returnedRejection: rejection } } +const STOPPED = { reason: 'stopped' } as const + function draft(overrides: Partial & { messageId: string }) { return { position: 1, @@ -62,13 +64,26 @@ describe('mobileQueuedMessageCards', () => { it('captions nothing on a waiting card of a paused queue; its pause row explains', () => { const [card] = mobileQueuedMessageCards([draft({ messageId: 'a' })], [], { pendingPrompt: false, - queuePaused: true + queuePause: STOPPED }) expect(card?.caption).toBeNull() expect(card?.paused).toBe(false) expect(card?.needsAttention).toBe(false) }) + // Another agent's mail runs when the stop lands: only a person's card waits on the Stop's pause. + it("captions mail as an ordinary waiting card under a person's Stop", () => { + const cards = mobileQueuedMessageCards( + [ + draft({ messageId: 'typed' }), + draft({ messageId: 'mail', position: 2, source: { kind: 'agent' } }) + ], + [], + { pendingPrompt: true, queuePause: STOPPED } + ) + 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( [ @@ -76,7 +91,7 @@ describe('mobileQueuedMessageCards', () => { draft({ messageId: 'b', position: 2 }) ], [], - { pendingPrompt: true, queuePaused: true } + { pendingPrompt: true, queuePause: STOPPED } ) expect(cards.map((card) => card.caption)).toEqual(["Couldn't send — tap Send to retry", null]) expect(cards[0]?.paused).toBe(true) @@ -85,7 +100,7 @@ describe('mobileQueuedMessageCards', () => { it('finds something for Resume to send only in a waiting card with no hold, ahead of a returned one', () => { const resumable = (drafts: AgentSessionQueuedMessage[]) => mobileQueueHasResumableCard( - mobileQueuedMessageCards(drafts, [], { pendingPrompt: false, queuePaused: true }) + mobileQueuedMessageCards(drafts, [], { pendingPrompt: false, queuePause: STOPPED }) ) const returned = draft({ messageId: 'r', diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index e4d2c123a015..f4e2caed4664 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -10,6 +10,7 @@ import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/s import { QUEUED_MESSAGE_PAUSED_KEPT, QUEUED_MESSAGE_PAUSED_SEND_FAILED, + queuePauseHoldsCard, type AgentSessionQueuedMessage, type AgentSessionQueuePause } from '../../../src/shared/agent-session-wire' @@ -95,7 +96,7 @@ export function mobileQueuePauseLabel(pause: Pick[], - facts: { pendingPrompt: boolean; queuePaused?: boolean } + facts: { pendingPrompt: boolean; queuePause?: AgentSessionQueuePause | null } ): MobileQueuedMessageCard[] { if (!queuedMessages || queuedMessages.length === 0) { return [] @@ -121,7 +122,7 @@ export function mobileQueuedMessageCards( ? pausedCaption(draft.pausedReason) : behindReturned ? 'Waiting — a message ahead needs attention' - : facts.queuePaused + : queuePauseHoldsCard(facts.queuePause, draft) ? // 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-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index f65ecf2d46e3..5d872ae2ce77 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -73,7 +73,7 @@ export function useMobileStructuredQueuedMessageControls(args: { () => mobileQueuedMessageCards(queuedMessages, submissions, { pendingPrompt, - queuePaused: queuePause !== null + queuePause }), [pendingPrompt, queuePause, queuedMessages, submissions] ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index ac3956bcc0d0..80696ac53d56 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -154,7 +154,7 @@ async function watchClient(): Promise { const working = hostWorking || (nextQueuedMessageId !== null && !hostWorking) const cards = projectQueuedMessageCards(queued, all, { hasPendingPrompt: false, - queuePaused: queuePause !== null + queuePause }) // As use-structured-agent-session-queued-messages.ts derives them. const header = queuedMessagesQueuePause(cards, queuePause) !== null 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 af1b7e567b3f..f254416bddff 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 @@ -57,6 +57,7 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess position: row.position, body: row.body, state: row.state, + source: { kind: row.source.kind }, ...(held ? { paused: true as const } : {}), // The stored reason is a typed marker; an unknown one reads as a plain hold. ...(held && isPublishedPausedReason(row.holdReason) ? { pausedReason: row.holdReason } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-while-stopping.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-while-stopping.test.ts index 7591a02f4d4c..914763b36a24 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-while-stopping.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-while-stopping.test.ts @@ -164,6 +164,14 @@ describe('orchestration mail sent while Stopping', () => { ) const typed = await queuedId(rig.send('typed while stopping', 'queue-if-active').result) expect((await rig.drafts()).map((draft) => draft.messageId)).toEqual([person, mail, typed]) + // Each card names its sender's kind, so a client labels the mail as waiting, not paused; the + // senders and their mail stay host-only. + const page = await rig.host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.map((card) => card.source)).toEqual([ + { kind: 'user' }, + { kind: 'agent' }, + { kind: 'user' } + ]) await land() await eventually(async () => expect((await rig.handoff(mail))?.handedOverAt).toBeDefined()) 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 e2be4a65189e..e9db7d933a26 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 @@ -4,7 +4,10 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause +} from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { newestSteerableQueuedMessageCard, @@ -56,6 +59,7 @@ function handOff( } const IDLE = { hasPendingPrompt: false } +const STOPPED = { reason: 'stopped' } as const const QUEUEING = { capability: 'supported', enabled: true } as const describe('queued message cards', () => { @@ -148,7 +152,7 @@ describe('queued message cards', () => { draft('behind', 4) ], [], - { hasPendingPrompt: true, queuePaused: true } + { hasPendingPrompt: true, queuePause: STOPPED } ) expect(cards.map((card) => card.hold)).toEqual([ 'queue-paused', @@ -161,10 +165,22 @@ describe('queued message cards', () => { ).toBe('awaiting-answer') }) + // Another agent's mail runs when the stop lands, so only a person's card reads paused. + it("a person's Stop holds every card but mail; any other pause holds mail too", () => { + const mail = draft('mail', 2, { source: { kind: 'agent' } }) + const project = (queuePause: AgentSessionQueuePause) => + projectQueuedMessageCards([draft('typed', 1), mail], [], { + hasPendingPrompt: false, + queuePause + }).map((card) => card.hold) + expect(project(STOPPED)).toEqual(['queue-paused', 'turn']) + expect(project({ reason: 'cleared' })).toEqual(['queue-paused', 'queue-paused']) + }) + it('a paused queue holds every waiting card, in order: an answer does not drain it', () => { const cards = projectQueuedMessageCards([draft('held', 1), draft('typed-after', 2)], [], { hasPendingPrompt: true, - queuePaused: true + queuePause: STOPPED }) expect(cards.map((card) => card.hold)).toEqual(['queue-paused', 'queue-paused']) // Still Steer: the header row, not the card, says it waits. @@ -173,8 +189,11 @@ describe('queued message cards', () => { it("the header names the queue's pause while it holds a card, and none over cards Resume would not send", () => { const stopped = { reason: 'stopped' } as const - const project = (messages: AgentSessionQueuedMessage[], queuePaused = true) => - projectQueuedMessageCards(messages, [], { hasPendingPrompt: false, queuePaused }) + const project = (messages: AgentSessionQueuedMessage[], paused = true) => + projectQueuedMessageCards(messages, [], { + hasPendingPrompt: false, + queuePause: paused ? stopped : null + }) expect(queuedMessagesQueuePause(project([draft('held', 1)]), stopped)).toEqual(stopped) expect(queuedMessagesQueuePause(project([draft('held', 1)]), { reason: 'cleared' })).toEqual({ reason: 'cleared' 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 bf70de99ef40..35a120286e35 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 @@ -3,9 +3,10 @@ import type { UnreadAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import type { - AgentSessionQueuedMessage, - AgentSessionQueuePause +import { + queuePauseHoldsCard, + type AgentSessionQueuedMessage, + type AgentSessionQueuePause } from '../../../../shared/agent-session-wire' import { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' import { @@ -56,7 +57,7 @@ function queuedMessageCardText(body: AgentSessionQueuedMessage['body']): string export function projectQueuedMessageCards( queuedMessages: readonly AgentSessionQueuedMessage[] | null | undefined, submissions: readonly AgentJournalSubmission[], - session: { hasPendingPrompt: boolean; queuePaused?: boolean } + session: { hasPendingPrompt: boolean; queuePause?: AgentSessionQueuePause | null } ): QueuedMessageCard[] { const handedOff = handedOffQueuedMessageIds( submissions.filter((submission) => submission.dispatchState !== 'rejected') @@ -73,7 +74,7 @@ export function projectQueuedMessageCards( ? 'paused' : behindReturned ? 'behind-returned' - : session.queuePaused + : queuePauseHoldsCard(session.queuePause, message) ? 'queue-paused' : session.hasPendingPrompt ? 'awaiting-answer' diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index ba3c6d66315b..62a282e38a0e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -85,7 +85,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { () => projectQueuedMessageCards(queuedMessages, submissions, { hasPendingPrompt, - queuePaused: queuePause !== null + queuePause }), [hasPendingPrompt, queuePause, queuedMessages, submissions] ) diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index c24e4971fff2..41f9e8fecdc7 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -58,6 +58,21 @@ export type AgentSessionQueuedMessage = { * `classifyDispatchRejection({ reason: returnedReason, rejection: returnedRejection })`. */ returnedReason?: string | null returnedRejection?: UnreadAgentSessionFailureFact + /** Who it is from: the kind of its sender ('user' or 'agent'), as a submission's `source`. Only + * the kind: the senders stay host-only. Absent from an older host, read as the person's. */ + source?: { kind: string } +} + +/** Whether the queue's pause holds this card: a person's Stop holds every card but another + * agent's mail, which runs when the stop lands; any other pause, an unknown one too, holds all. */ +export function queuePauseHoldsCard( + pause: AgentSessionQueuePause | null | undefined, + card: Pick +): boolean { + if (!pause) { + return false + } + return pause.reason !== 'stopped' || card.source?.kind !== 'agent' } /** No body: the card leaving the published list IS the outcome, so a lost diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 23006730517d..d5547e15337f 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -223,7 +223,9 @@ export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = // v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and // never compares a draft id with a submission id. It also publishes the queue's pause once, as // `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or any turn sent -// after it starting; while it is set every waiting card waits. `nextQueuedMessageId` beside the list +// after it starting; while it is set every waiting card waits, but under a person's Stop another +// agent's mail, which runs as the stop lands (each card's optional `source.kind` says which, so a +// client labels it). `nextQueuedMessageId` beside the list // names the card the queue sends next once nothing runs, null while anything holds it; absent (an // older host) reads as null. A card carries a hold of its own when its conversion failed // (`send_failed`) or the host kept it unsent (`kept`). The host mechanism lands first; the constant diff --git a/tests/e2e/cross-version-wire/queued-card-source-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/queued-card-source-downgrade.unit.test.ts new file mode 100644 index 000000000000..5be53f5f5482 --- /dev/null +++ b/tests/e2e/cross-version-wire/queued-card-source-downgrade.unit.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from 'vitest' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuePause +} from '../../../src/shared/agent-session-wire' +import { projectQueuedMessageCards } from '../../../src/renderer/src/components/native-chat/structured-agent-session-queued-cards' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// Each published card now names the kind of its sender (`source`), so a client labels another +// agent's mail, which a person's Stop does not hold, as waiting rather than paused. The field is +// optional: a build without it ignores it and labels every card under a pause as before, and this +// build reads a card from a host without it as the person's. The main commit this change branched +// from, which has the queue; move it to the newest release that has the queue and predates this +// change. A baseline holding this change tests no downgrade. +const BASELINE_REF = '5a56636f6679071d6ec68b851ef7932cd3222560' +const CARDS = 'src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts' +const STOPPED: AgentSessionQueuePause = { reason: 'stopped' } + +function card(messageId: string, position: number, kind?: string): AgentSessionQueuedMessage { + return { + messageId, + position, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: messageId }] }, + state: 'waiting', + ...(kind === undefined ? {} : { source: { kind } }) + } +} + +type OlderProjection = ( + queuedMessages: readonly unknown[], + submissions: readonly unknown[], + session: { hasPendingPrompt: boolean; queuePaused?: boolean } +) => readonly { messageId: string; hold: string }[] + +async function olderProjection(): Promise { + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const cards = await importReleaseCheckoutModule(checkout, CARDS) + const project = cards.projectQueuedMessageCards + if (typeof project !== 'function') { + throw new Error('the pinned build exports no projectQueuedMessageCards') + } + return (queuedMessages, submissions, session) => project(queuedMessages, submissions, session) +} + +test("an older client reads this host's cards, mail included, as paused under a person's Stop, as before", async () => { + const project = await olderProjection() + const held = project([card('typed', 1, 'user'), card('mail', 2, 'agent')], [], { + hasPendingPrompt: false, + queuePaused: true + }) + expect(held.map((entry) => [entry.messageId, entry.hold])).toEqual([ + ['typed', 'queue-paused'], + ['mail', 'queue-paused'] + ]) +}) + +test("this client reads an older host's cards, which name no sender, as the person's", () => { + const held = projectQueuedMessageCards([card('typed', 1), card('mail-unnamed', 2)], [], { + hasPendingPrompt: false, + queuePause: STOPPED + }) + expect(held.map((entry) => entry.hold)).toEqual(['queue-paused', 'queue-paused']) +})