From 8ffcfc6042b084984e068dd9f640e8a959826a68 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:10:57 -0700 Subject: [PATCH 1/9] fix(native-chat): a Stop's note records whether it took, and one rule reads it A turnless Stop that interrupted nothing no longer marks the turn that opens next as the person's cancellation. --- .../journal-stop-answers.ts | 33 +++++++++++ .../journal-stop-marks.ts | 10 +++- .../journal-stop-turn-end.ts | 16 +++-- .../structured-agent-session-chat-stop.ts | 9 +-- ...ctured-agent-session-prompt-cancel.test.ts | 3 +- .../structured-agent-session-queued-stop.ts | 39 +++++++----- .../structured-agent-session-turns-cancel.ts | 38 ++++++++++-- .../structured-agent-session-turns.test.ts | 2 +- .../agent-session-journal-fact-schemas.ts | 39 ++++++++++++ src/shared/agent-session-journal-schemas.ts | 39 +++--------- src/shared/agent-session-journal-types.ts | 27 +++++++++ src/shared/agent-session-stop-answer.ts | 59 +++++++++++++++++++ 12 files changed, 252 insertions(+), 62 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-answers.ts create mode 100644 src/shared/agent-session-journal-fact-schemas.ts create mode 100644 src/shared/agent-session-stop-answer.ts diff --git a/src/main/native-chat/agent-session-journal/journal-stop-answers.ts b/src/main/native-chat/agent-session-journal/journal-stop-answers.ts new file mode 100644 index 000000000000..147feda53955 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-answers.ts @@ -0,0 +1,33 @@ +// Whether a Stop still counts, from the answers its notes record: the one rule every reader of a +// Stop's effect applies (the turn-end write, the host stop's deference, and the "Stopping…" state). + +import { + agentJournalStopAnswerTook, + readAgentJournalStopAnswer +} from '../../../shared/agent-session-stop-answer' +import type { JournalReducerState } from './journal-reducer' +import type { JournalStopEvent } from './journal-row-schema' + +/** + * Whether the Stop that wrote `event` still counts as taking effect: it has no answer yet, or any + * answer it has says it took. A later answer that it did not take never cancels one that says it + * did. Answers attach by the event's `at`, so a rewind's restatement keeps them; an older host's + * note, and an answer this build does not know, are no answer. + */ +export function journalStopStillCounts( + state: Pick, + event: Pick +): boolean { + let answered = false + for (const item of state.items.values()) { + const stop = readAgentJournalStopAnswer(item.body) + if (stop?.eventAt !== event.at) { + continue + } + if (agentJournalStopAnswerTook(stop.answer)) { + return true + } + answered = true + } + return !answered +} 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..687154cc9c93 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,7 +1,10 @@ // 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 +// whether a Stop still counts by its answers (`journal-stop-answers.ts`). import type { JournalReducerState } from './journal-reducer' +import type { JournalStopEvent } from './journal-row-schema' +import { journalStopStillCounts } from './journal-stop-answers' import { latestAcceptedSendUnopened, personStopDecidesTurn, @@ -24,4 +27,9 @@ export class JournalStopMarks { personStopDecides(turnId: string | null, endedAt?: number, openedBy?: string): boolean { return personStopDecidesTurn(this.deps.state(), turnId, endedAt, openedBy) } + + /** `journalStopStillCounts`: the Stop that wrote `event` has no answer yet, or one says it took. */ + stillCounts(event: Pick): boolean { + return journalStopStillCounts(this.deps.state(), event) + } } 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..c68c7a9a7b4e 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 @@ -2,7 +2,8 @@ // // 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 +// cancellation, unless every answer that Stop has says it did not take (`journalStopStillCounts`). +// 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. @@ -19,6 +20,7 @@ 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 { journalStopStillCounts } from './journal-stop-answers' import type { JournalQueuePauseMarks } from './queued-message-pause' export type JournalLatestStop = NonNullable @@ -132,7 +134,8 @@ function stopEndsTurnAsCancellation( return ( stop !== null && stopIsTurnCancellation(state, stop, turnId, userItemId) && - (endedAt === undefined || endedAt >= stop.event.at) + (endedAt === undefined || endedAt >= stop.event.at) && + journalStopStillCounts(state, stop.event) ) } @@ -165,7 +168,11 @@ export function personStopDecidesTurn( return false } if (turnId === null) { - return stop.event.turnId === undefined && unansweredSendsAreStopTargets(state, stop) + return ( + stop.event.turnId === undefined && + unansweredSendsAreStopTargets(state, stop) && + journalStopStillCounts(state, stop.event) + ) } const turn = [...state.items.values()] .map((item) => readAgentJournalTurn(item.body)) @@ -178,7 +185,8 @@ export function personStopDecidesTurn( /** * 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. + * it, while it was still open (running, or unproven), and that still counts. A provider's own + * verdict always stands. */ export function turnEndAfterStop( state: TurnEndState, 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..1345893e4e6f 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 @@ -18,7 +18,7 @@ import { } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { MutationPlan } from './structured-agent-session-mutation-plans' -import { runRecordedStop, stopReachesUnrecordedWork } from './structured-agent-session-queued-stop' +import { runRecordedStop, stopRecordedWork } from './structured-agent-session-queued-stop' import { openForWrite, structuredAgentSessionFailureWordsContext @@ -90,14 +90,15 @@ 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 recorded = withdrawn.length > 0 ? 'unrecorded' : await stopRecordedWork(ctx, turnId) + // The event this Stop's answer is for: the one it writes, or the one in force it repeats. + const answers = recorded === 'unrecorded' ? await tookEffect() : recorded return performCancel( { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, { clientOperationId: envelope.clientOperationId, ...named, + ...(answers ? { stopEventAt: answers.at } : {}), stopChild, onStopChildError: (error) => context.deps.logger.warn('ending the agent process on Stop failed', { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index 8d67ba0aa7c0..a984155d745a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -161,7 +161,8 @@ describe('performCancel for a pending prompt', () => { }) expect(journal.snapshot().items.map((item) => item.body)).toEqual([ expect.objectContaining({ resolution: expect.objectContaining({ state: 'cancelled' }) }), - { kind: 'status', text: 'Cancellation requested.' } + // A card's own Cancel writes no Stop event, so its answer names none. + { kind: 'status', text: 'Cancellation requested.', stop: { answer: 'took' } } ]) }) 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..d0f2156a99c8 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 @@ -31,52 +31,59 @@ 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 (`stopReachesUnrecordedWork`), changed - * nothing and writes nothing. + * written. A Stop that throws before then, or stops nothing (`stopRecordedWork`), changed + * nothing and writes nothing. `tookEffect` resolves to the event as written, which the Stop's + * answer names; undefined when the write failed. * The drain cannot slip a card in between: the Stop runs on the drain's serialized lane. */ export async function runRecordedStop( ctx: AgentSessionTurnContext, /** `turnId` absent: the turn running when the Stop takes effect, if any. */ event: Omit, - stop: (tookEffect: () => Promise) => Promise> + stop: (tookEffect: () => Promise) => Promise> ): Promise> { - const skipped = (error: unknown): void => report(ctx, 'event row', error) + const skipped = (error: unknown): undefined => { + report(ctx, 'event row', error) + return undefined + } return stop(() => { try { const turnId = structuredAgentSessionStoppedTurnId(ctx.journal, event.turnId) ?? undefined return ctx.journal .appendStopEvent({ ...event, ...(turnId ? { turnId } : {}) }, ctx.fence) - .then(() => undefined, skipped) + .then((written) => { + const latest = ctx.journal.stopMarks.latest() + return latest?.sequence === written.sequence ? latest.event : undefined + }, 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 finds recorded: `unrecorded` when it stops work no Stop event + * records yet, so it writes one; the event of the Stop still in force when it repeats that 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); null when it + * names a turn already over (a late Stop from a phone). */ -export async function stopReachesUnrecordedWork( +export async function stopRecordedWork( ctx: Pick, namedTurnId: string | undefined -): Promise { +): Promise<'unrecorded' | JournalStopEvent | null> { 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 null } 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. @@ -89,6 +96,8 @@ export async function stopReachesUnrecordedWork( entry.acceptedSequence > inForce.sequence ) return sentSince || structuredAgentSessionStopNamesTurnNotLive(inForce.event.turnId, live) + ? 'unrecorded' + : inForce.event } function report(ctx: AgentSessionTurnContext, step: string, error: unknown): void { 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..f37b2920c076 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,15 @@ 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 type { + AgentJournalStatusItem, + AgentJournalStopAnswer +} from '../../../shared/agent-session-journal-types' +import { + agentJournalStopAnswerReplaces, + readAgentJournalStopAnswer +} from '../../../shared/agent-session-stop-answer' 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' @@ -121,6 +129,9 @@ export async function performCancel( endSession?: (windDown: StructuredAgentSessionStopWindDown) => void /** The host already withdrew queued messages for this Stop. */ withdrewQueued?: boolean + /** The Stop event this Stop answers (`JournalStopEvent.at`): the one it wrote, or the one in + * force it repeated. Absent: its answer names none. */ + stopEventAt?: number /** The session's child records: a background Stop reaches the tasks they offer a stop. */ childWork?: () => readonly AgentChildWorkView[] | undefined } @@ -136,6 +147,8 @@ export async function performCancel( kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } + // What the note records the Stop did, set with each note below. + let answer: AgentJournalStopAnswer = 'took' // The turn the Stop names, read before the cancel settles it: the note reports on that turn. const turnScope = (input.turnId !== undefined && !input.scope @@ -202,6 +215,8 @@ export async function performCancel( } else if (!cancelled && input.turnId === undefined) { // Sent only while the chat reads working, so a Stop that ended nothing must say why. note = stopRefusedNote(ctx, refusal) + // No interrupt went out, or the provider had no turn running for it. + answer = interruptFailed ? 'declined' : 'no-effect' } } catch (error) { if (input.prompt) { @@ -213,6 +228,7 @@ export async function performCancel( kind: 'status', ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) } + answer = 'interrupt-unconfirmed' } // A Stop naming a turn that has since ended keeps the session only when the provider declined // it: an interrupt, answered or not, can stop a follow-up whose turn has not opened. @@ -223,12 +239,14 @@ export async function performCancel( cancelled = true // The child's end confirms the Stop, so a refused or unconfirmed interrupt says nothing more. if (note !== null) { - note = { kind: 'status', text: 'Cancellation requested.' } + note = { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } + answer = 'end-owed' } } else if (runningCommand && !cancelled) { await input.stopChild?.() cancelled = true note = { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } + answer = 'took' } else if ( !cancelled && interruptFailed && @@ -249,9 +267,11 @@ export async function performCancel( if (ended) { cancelled = true note = { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } + answer = 'took' } 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) + answer = 'declined' } } else if (!cancelled && taken === false && input.turnId !== undefined) { // Nothing was left of the turn it named and nothing else ended: a Stop that ends nothing writes no row. @@ -265,10 +285,16 @@ export async function performCancel( 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 } + const identity = structuredAgentSessionStopNoteIdentity(stoppedTurnId ?? input.clientOperationId) + const stop = { + answer, + ...(input.stopEventAt !== undefined ? { eventAt: input.stopEventAt } : {}) + } + const previous = readAgentJournalStopAnswer( + ctx.journal.itemBody(agentJournalItemKey(identity)) ?? undefined ) + if (agentJournalStopAnswerReplaces(previous, stop)) { + await ctx.journal.appendItem(identity, { ...note, stop }, { fence: ctx.fence, turnScope }) + } return { ok: true, value } } 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 a09024ffcdda..5584214c2765 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 @@ -75,7 +75,7 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { kind: 'status', text: 'Cancellation requested.' } + { kind: 'status', text: 'Cancellation requested.', stop: { answer: 'took' } } ]) }) diff --git a/src/shared/agent-session-journal-fact-schemas.ts b/src/shared/agent-session-journal-fact-schemas.ts new file mode 100644 index 000000000000..daa2cae53135 --- /dev/null +++ b/src/shared/agent-session-journal-fact-schemas.ts @@ -0,0 +1,39 @@ +// The typed facts a journal row carries beside the text older clients print: a thread goal, a +// failure, and a Stop's answer. Open like the rest of the render model (see +// agent-session-journal-schemas.ts): a vocabulary a newer build writes must not turn a row malformed. + +import { z } from 'zod' + +const ThreadGoal = z.object({ + objective: z.string(), + status: z.string().min(1), + tokenBudget: z.number().finite().nullable(), + tokensUsed: z.number().finite(), + timeUsedSeconds: z.number().finite(), + createdAt: z.number().finite(), + updatedAt: z.number().finite() +}) + +/** Like blocks: an unknown `state` stays admissible, a known one with a broken payload does not. */ +export const AgentJournalThreadGoalStateSchema = z.union([ + z.discriminatedUnion('state', [ + z.object({ state: z.literal('set'), goal: ThreadGoal }), + z.object({ state: z.literal('cleared') }) + ]), + z.object({ state: z.string() }).refine((value) => !['set', 'cleared'].includes(value.state)) +]) + +/** Open like `state`: a kind, audience or refusal detail a newer host writes must not turn the row + * malformed; the fact reader is where an unplaceable one is dropped. */ +export const AgentJournalFailureFactSchema = z.object({ + kind: z.string().min(1), + detail: z.object({ text: z.string(), audience: z.string().min(1) }).optional(), + refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional() +}) + +/** Open like the failure fact: an answer a newer host writes must not turn the row malformed; + * `readAgentJournalStopAnswer` reads an unknown one as no answer. */ +export const AgentJournalStopNoteAnswerSchema = z.object({ + answer: z.string().min(1), + eventAt: z.number().finite().optional() +}) diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 035bb7d1d01c..dd51b75bc5c7 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -15,6 +15,11 @@ import { z } from 'zod' import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema' +import { + AgentJournalFailureFactSchema, + AgentJournalStopNoteAnswerSchema, + AgentJournalThreadGoalStateSchema +} from './agent-session-journal-fact-schemas' import type { AgentJournalItemBody, AgentJournalMessageItem, @@ -182,33 +187,6 @@ const MessageBody = z.object({ command: z.object({ name: z.string().min(1) }).optional() }) -const ThreadGoal = z.object({ - objective: z.string(), - status: z.string().min(1), - tokenBudget: z.number().finite().nullable(), - tokensUsed: z.number().finite(), - timeUsedSeconds: z.number().finite(), - createdAt: z.number().finite(), - updatedAt: z.number().finite() -}) - -/** Like blocks: an unknown `state` stays admissible, a known one with a broken payload does not. */ -const ThreadGoalState = z.union([ - z.discriminatedUnion('state', [ - z.object({ state: z.literal('set'), goal: ThreadGoal }), - z.object({ state: z.literal('cleared') }) - ]), - z.object({ state: z.string() }).refine((value) => !['set', 'cleared'].includes(value.state)) -]) - -/** Open like `state`: a kind, audience or refusal detail a newer host writes must not turn the row - * malformed; the fact reader is where an unplaceable one is dropped. */ -const FailureFact = z.object({ - kind: z.string().min(1), - detail: z.object({ text: z.string(), audience: z.string().min(1) }).optional(), - refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional() -}) - export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ MessageBody, z.object({ @@ -261,8 +239,9 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ }) .optional(), providerFrame: ProviderFrame.optional(), - threadGoal: ThreadGoalState.optional(), - failure: FailureFact.optional() + threadGoal: AgentJournalThreadGoalStateSchema.optional(), + failure: AgentJournalFailureFactSchema.optional(), + stop: AgentJournalStopNoteAnswerSchema.optional() }), z.object({ kind: z.literal('turn'), @@ -328,7 +307,7 @@ export const AgentJournalSubmissionSchema = z.object({ recovered: z.literal(true).optional(), handoverRecorded: z.literal(true).optional(), handedOverAt: z.number().optional(), - rejection: FailureFact.optional(), + rejection: AgentJournalFailureFactSchema.optional(), // Listed, or the parse strips it: this schema drops unknown keys. queuedMessageId: z.string().min(1).optional() }) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 548b263bbfec..c326b87ebcdd 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -265,6 +265,31 @@ export type AgentJournalThreadGoalState = | { state: 'set'; goal: AgentJournalThreadGoal } | { state: 'cleared' } +/** What a Stop did, as its note says. Persisted and published: never rename an arm. Open like the + * other persisted vocabularies: an arm a newer host writes must not turn the row malformed, and a + * reader treats one it does not know as no answer (`readAgentJournalStopAnswer`). + * - `took`: the provider took the interrupt, or the host ended the child running the turn. + * - `end-owed`: the Stop ends the provider's process in its next step, which still owes it. + * - `declined`: the provider could not interrupt the turn, and the turn may run on. + * - `interrupt-unconfirmed`: the interrupt failed with no answer, and nothing ended the child. + * - `no-effect`: the provider had nothing running for it to stop. */ +export const AGENT_JOURNAL_STOP_ANSWERS = [ + 'took', + 'end-owed', + 'declined', + 'interrupt-unconfirmed', + 'no-effect' +] as const +export type AgentJournalStopAnswer = (typeof AGENT_JOURNAL_STOP_ANSWERS)[number] + +/** A Stop's answer, on the note it writes. */ +export type AgentJournalStopNoteAnswer = { + answer: AgentJournalStopAnswer + /** The Stop event this answers, by its `at`, which a rewind's restatement keeps: the event the + * Stop wrote, or the one in force it repeated. Absent: it answers no recorded Stop. */ + eventAt?: number +} + type AgentJournalStatusItemFields = { kind: 'status' /** Optional display hints; unknown values retain the ordinary text fallback. */ @@ -283,6 +308,8 @@ type AgentJournalStatusItemFields = { } /** Present on thread-goal transitions; absent on rows from older hosts. */ threadGoal?: AgentJournalThreadGoalState + /** Present on a Stop's note (`stop:`-keyed); absent on rows from older hosts. */ + stop?: AgentJournalStopNoteAnswer } /** A status row that reports no failure; its text is its writer's own. */ diff --git a/src/shared/agent-session-stop-answer.ts b/src/shared/agent-session-stop-answer.ts new file mode 100644 index 000000000000..a817832dab4d --- /dev/null +++ b/src/shared/agent-session-stop-answer.ts @@ -0,0 +1,59 @@ +// A Stop's answer as its note records it (`AgentJournalStopNoteAnswer`): read from a row, whether it +// says the Stop took effect, and which answer may replace another on the same note. + +import { + AGENT_JOURNAL_STOP_ANSWERS, + type AgentJournalItemBody, + type AgentJournalStopAnswer, + type AgentJournalStopNoteAnswer +} from './agent-session-journal-types' + +function isAgentJournalStopAnswer(value: unknown): value is AgentJournalStopAnswer { + return AGENT_JOURNAL_STOP_ANSWERS.some((answer) => answer === value) +} + +/** The Stop answer a row records; undefined for any other row, an older host's note, or an answer + * this build does not know, which reads as no answer. */ +export function readAgentJournalStopAnswer( + body: AgentJournalItemBody | undefined +): AgentJournalStopNoteAnswer | undefined { + const stop = body?.kind === 'status' ? body.stop : undefined + if (!stop || !isAgentJournalStopAnswer(stop.answer)) { + return undefined + } + const { eventAt } = stop + return typeof eventAt === 'number' && Number.isFinite(eventAt) + ? { answer: stop.answer, eventAt } + : { answer: stop.answer } +} + +/** Whether an answer says the Stop took effect: its interrupt was taken, or its end of the child is + * done or still owed. */ +export function agentJournalStopAnswerTook(answer: AgentJournalStopAnswer): boolean { + switch (answer) { + case 'took': + case 'end-owed': + return true + case 'declined': + case 'interrupt-unconfirmed': + case 'no-effect': + return false + } +} + +/** Whether `next` may replace `previous` on one note. A later answer that the Stop did not take + * never hides one that says the same Stop did; only the failure of an owed end revises that. */ +export function agentJournalStopAnswerReplaces( + previous: AgentJournalStopNoteAnswer | undefined, + next: AgentJournalStopNoteAnswer +): boolean { + if ( + previous === undefined || + previous.eventAt !== next.eventAt || + !agentJournalStopAnswerTook(previous.answer) || + agentJournalStopAnswerTook(next.answer) + ) { + return true + } + return previous.answer === 'end-owed' && next.answer === 'interrupt-unconfirmed' +} From 18798895f6f75c7f0152b5e51960d2f379afb15b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:15:22 -0700 Subject: [PATCH 2/9] test(native-chat): each Stop answer, the shared rule, the turnless gap and a downgrade --- .../journal-stop-answers.test.ts | 154 ++++++++++ ...t-session-claude-stop-ends-session.test.ts | 15 + ...tured-agent-session-codex-stop-row.test.ts | 62 ++-- ...ructured-agent-session-stop-answer.test.ts | 275 ++++++++++++++++++ src/shared/agent-session-stop-answer.test.ts | 90 ++++++ ...session-stop-answer-downgrade.unit.test.ts | 151 ++++++++++ 6 files changed, 729 insertions(+), 18 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-answers.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts create mode 100644 src/shared/agent-session-stop-answer.test.ts create mode 100644 tests/e2e/cross-version-wire/agent-session-stop-answer-downgrade.unit.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-stop-answers.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-answers.test.ts new file mode 100644 index 000000000000..d80ccd222f66 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-answers.test.ts @@ -0,0 +1,154 @@ +// Whether a Stop still counts, read from its notes' answers: until it has one, and then while any +// says it took. The turn-end rule reads it, so a turn a Stop that took nothing named ends as the +// news it is. + +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 AgentJournalItemIdentity, + type AgentJournalStopNoteAnswer, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { createTrackedJournalOpener } from './journal-host-database-test-support' +import type { AgentSessionJournal } from './journal-store' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-answers', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} +const SCOPE = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } +const TURN: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 0 +} + +let root: string +let journal: AgentSessionJournal +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-stop-answers-')) + journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +/** A person's Stop of `turn-1`, as its own step writes it; its event's time. */ +async function stopped(): Promise { + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, 1) + return journal.stopMarks.latest()!.event.at +} + +/** A Stop's note at `key`, as `performCancel` writes it. */ +async function answered(key: string, stop: AgentJournalStopNoteAnswer | undefined): Promise { + await journal.appendItem( + { provider: 'orca', clientMessageId: `stop:${key}` }, + { kind: 'status', text: 'Cancellation requested.', ...(stop ? { stop } : {}) }, + SCOPE + ) +} + +function stillCounts(): boolean { + return journal.stopMarks.stillCounts(journal.stopMarks.latest()!.event) +} + +describe('journalStopStillCounts', () => { + it('counts a Stop with no answer yet', async () => { + await stopped() + + expect(stillCounts()).toBe(true) + }) + + it.each(['declined', 'interrupt-unconfirmed', 'no-effect'] as const)( + 'stops counting a Stop whose only answer is %s', + async (answer) => { + const eventAt = await stopped() + await answered('turn-1', { answer, eventAt }) + + expect(stillCounts()).toBe(false) + } + ) + + it('keeps counting while any answer says it took, a later one that it did not included', async () => { + const eventAt = await stopped() + await answered('turn-1', { answer: 'took', eventAt }) + await answered('op-2', { answer: 'no-effect', eventAt }) + + expect(stillCounts()).toBe(true) + }) + + it('counts an owed end as taking effect', async () => { + const eventAt = await stopped() + await answered('turn-1', { answer: 'end-owed', eventAt }) + + expect(stillCounts()).toBe(true) + }) + + it("reads only answers to this Stop: an earlier Stop's, one naming no event, and an older host's note are none", async () => { + const earlier = await stopped() + await answered('turn-1', { answer: 'no-effect', eventAt: earlier }) + await new Promise((resolve) => setTimeout(resolve, 2)) + const latest = await stopped() + expect(latest).not.toBe(earlier) + await answered('op-2', { answer: 'declined' }) + await answered('op-3', undefined) + + expect(stillCounts()).toBe(true) + }) +}) + +describe('the turn-end rule', () => { + /** `turn-1` running, and a person's Stop of it; the event's time. */ + async function stoppedWhileRunning(): Promise { + await journal.appendItem( + TURN, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: Date.now() - 1_000 }, + SCOPE + ) + return stopped() + } + + function endTurn(): Promise { + return journal.appendItem( + TURN, + { kind: 'turn', turnId: 'turn-1', state: 'interrupted', completedAt: Date.now() + 1_000 }, + SCOPE + ) + } + + function outcome(): string | undefined { + return journal + .snapshot() + .items.map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-1')?.outcome + } + + it("makes the end of a turn a Stop that took named the person's cancellation", async () => { + await answered('turn-1', { answer: 'took', eventAt: await stoppedWhileRunning() }) + + await endTurn() + + expect(outcome()).toBe('cancellation') + }) + + it('leaves the end of a turn a declined Stop named as news, so a crash reads as one', async () => { + await answered('turn-1', { answer: 'declined', eventAt: await stoppedWhileRunning() }) + + await endTurn() + + expect(outcome()).toBeUndefined() + expect(journal.stopMarks.personStopDecides('turn-1')).toBe(false) + }) +}) 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 ddef0ed0bb65..bc8ac9d891b5 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 @@ -8,6 +8,7 @@ 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 { readAgentJournalStopAnswer } from '../../../shared/agent-session-stop-answer' import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' @@ -269,6 +270,17 @@ async function turnOutcome(): Promise { ?.outcome } +/** What each Stop's note records it did, and whether it names the latest Stop event. */ +async function stopAnswers(): Promise<{ answer: string; answersLatestStop: boolean }[]> { + const latest = host.collaboratorsForTests().sessions.get(SESSION)?.journal.stopMarks.latest() + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => { + const stop = readAgentJournalStopAnswer(item.body) + return stop + ? [{ answer: stop.answer, answersLatestStop: stop.eventAt === latest?.event.at }] + : [] + }) +} + async function statusTexts(): Promise { await host.flushStreamedEvents(SESSION) return (await host.journalSnapshot(SESSION)).items.flatMap((item) => @@ -285,6 +297,8 @@ it('answers on the interrupt, ends the child once the stopped turn ends, and res // The Stop answered on the interrupt Claude took: the child waits for Claude to end the turn. expect(connection.closed).toBe(false) expect(await statusTexts()).toEqual(['Cancellation requested.']) + // The child's end is still owed to the Stop's next step. + expect(await stopAnswers()).toEqual([{ answer: 'end-owed', answersLatestStop: true }]) const ended = Date.now() frame(connection, INTERRUPTED_RESULT) await laneDrained() @@ -422,6 +436,7 @@ it('ends the child at once when Claude refuses the interrupt, and says only that const texts = await statusTexts() expect(texts).toContain('Cancellation requested.') expect(texts.some((text) => text.includes("didn't stop"))).toBe(false) + expect(await stopAnswers()).toEqual([{ answer: 'end-owed', answersLatestStop: true }]) }) it('ends the child when the interrupt fails, with no unconfirmed row', async () => { 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..73ccd54f6401 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 @@ -16,6 +16,7 @@ import { fakeCodex } from '../../codex/codex-structured-session-adapter-fixture' import { codexTurnLifecycleFake } from '../../codex/codex-turn-lifecycle-fake' +import { readAgentJournalStopAnswer } from '../../../shared/agent-session-stop-answer' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -136,7 +137,13 @@ async function journalRows() { return { statuses: items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])), turns: items.flatMap((item) => (item.body.kind === 'turn' ? [item.body.state] : [])), - outcomes: items.flatMap((item) => (item.body.kind === 'turn' ? [item.body.outcome] : [])) + outcomes: items.flatMap((item) => (item.body.kind === 'turn' ? [item.body.outcome] : [])), + // What each Stop's note records it did; every one here answers the Stop's own event. + answers: items.flatMap((item) => { + const stop = readAgentJournalStopAnswer(item.body) + const event = host['sessions'].get(SESSION)?.journal.stopMarks.latest()?.event + return stop ? [stop.eventAt === event?.at ? stop.answer : 'another event'] : [] + }) } } @@ -203,6 +210,7 @@ describe('a Codex Stop that Codex answered', () => { await host.flushStreamedEvents(SESSION) expect((await journalRows()).statuses).toEqual(['Cancellation requested.']) + expect((await journalRows()).answers).toEqual(['took']) expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } }) // Codex's background terminals live in its child, so a Stop it took keeps the child. expect(childEndedByStop()).toBe(false) @@ -235,25 +243,41 @@ describe('a Codex Stop whose interrupt failed', () => { expect(rows.turns).toEqual(['interrupted']) expect(rows.outcomes).toEqual(['cancellation']) expect(rows.statuses).toEqual(['Cancellation requested.']) + expect(rows.answers).toEqual(['took']) } ) - it('says Codex did not stop when a Stop naming the running turn could not prove the exit', async () => { - await runningTurn() - codex.routes['turn/interrupt'] = () => { - throw interruptFailure('internal error') - } - disposeSession.mockResolvedValueOnce(false) + it.each([ + [ + 'declined: Codex could not submit it', + 'internal error', + "Codex didn't stop: failed to interrupt turn: channel closed.", + 'declined' + ], + [ + 'unconfirmed: Codex never answered', + 'unanswered', + 'Cancellation was not confirmed.', + 'interrupt-unconfirmed' + ] + ] as const)( + 'says the Stop did not take when one naming the running turn could not prove the exit, %s', + async (_case, failure, status, answer) => { + await runningTurn() + codex.routes['turn/interrupt'] = () => { + throw interruptFailure(failure) + } + disposeSession.mockResolvedValueOnce(false) - const stopped = await stop('turn-1') - await host.flushStreamedEvents(SESSION) + const stopped = await stop('turn-1') + await host.flushStreamedEvents(SESSION) - expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) - expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect((await journalRows()).statuses).toEqual([ - "Codex didn't stop: failed to interrupt turn: channel closed." - ]) - }) + expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(disposeSession).toHaveBeenCalledExactlyOnceWith(SESSION) + expect((await journalRows()).statuses).toEqual([status]) + expect((await journalRows()).answers).toEqual([answer]) + } + ) it.each([ ['naming no turn', undefined], @@ -281,11 +305,11 @@ describe('a Codex Stop whose interrupt failed', () => { // Codex marks the turn ended before it writes the frame, so its refusal can come first. it.each([ - ['naming no turn', undefined], - ['naming its turn', 'turn-1'] + ['naming no turn', undefined, ['no-effect']], + ['naming its turn', 'turn-1', []] ] as const)( "keeps the child when Codex's refusal arrives before the turn's end, %s", - async (_case, turnId) => { + async (_case, turnId, answers) => { await runningTurn() codex.routes['turn/interrupt'] = () => { setTimeout(() => turns.end('completed'), 2) @@ -300,6 +324,8 @@ describe('a Codex Stop whose interrupt failed', () => { expect(childEndedByStop()).toBe(false) expect(codex.connections.at(-1)?.closed).toBe(false) expect((await journalRows()).outcomes).toEqual(['success']) + // A Stop that stopped nothing says so; one naming a turn that ended writes no row. + expect((await journalRows()).answers).toEqual(answers) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts new file mode 100644 index 000000000000..5658dcd4e3e5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts @@ -0,0 +1,275 @@ +// A Stop's note records what the Stop did, naming the Stop event it answers, and a turn's end reads +// it: a Stop that interrupted nothing, or one the provider declined, never makes a later end of +// the turn the person's cancellation, live or after a restart. Against the real host and journal. + +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalStopNoteAnswer +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalStopAnswer } from '../../../shared/agent-session-stop-answer' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' +import { + QUEUED_RIG_CALLER, + createQueuedMessageTestRig, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +const TURN = 'turn-1' +const CODEX_TURN: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: TURN, + ordinal: 999 +} +const SCOPE = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } +/** Codex's answer to a Stop pressed before its send's turn started: no interrupt went out. */ +const NOTHING_TO_INTERRUPT: AgentSessionCancelOutcome = { cancelled: false } + +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 stopEventCount(): number { + const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) + return since.ok + ? since.rows.filter((row) => row.kind === 'tombstone' && row.stopEvent).length + : -1 +} + +/** Every Stop answer the journal holds, with the text its note shows. */ +function answers(): (AgentJournalStopNoteAnswer & { text: string })[] { + return journal() + .snapshot() + .items.flatMap((item) => { + const stop = readAgentJournalStopAnswer(item.body) + return stop && item.body.kind === 'status' ? [{ ...stop, text: item.body.text }] : [] + }) +} + +function latestEventAt(): number | undefined { + return journal().stopMarks.latest()?.event.at +} + +/** Turn `turn-1` as Codex writes it: opened by `clientMessageId`, running or cut off. */ +async function turnOpenedBy( + clientMessageId: string, + state: 'running' | 'interrupted' +): Promise { + const userItemId = agentJournalSubmissionKey(clientMessageId) + await journal().appendItem( + CODEX_TURN, + state === 'running' + ? { kind: 'turn', turnId: TURN, state, startedAt: Date.now(), userItemId } + : { kind: 'turn', turnId: TURN, state, completedAt: Date.now() + 5, userItemId }, + SCOPE + ) +} + +function turn() { + return journal() + .snapshot() + .items.map((item) => readAgentJournalTurn(item.body)) + .find((entry) => entry?.turnId === TURN) +} + +/** Orca dies with the turn unended; the relaunch proves the old child gone after the Stop. */ +async function restartAndSettle(): Promise { + rig.crashRestartHostProcess() + await rig.host.journalSnapshot(HOST_TEST_SESSION) + await settleStaleStructuredAgentSessionState({ + journal: journal(), + sessionId: HOST_TEST_SESSION, + fence: 2, + acquisitionGeneration: 'generation-2', + deathEvidence: { + kind: 'exit-observed', + detail: 'the relaunch proved the old child gone', + observedAt: Date.now() + 60_000, + ownerFence: 1 + } + }) +} + +function stopNaming(turnId: string) { + const fields = { turnId } + return rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()), + ...fields + }) +} + +describe('a Stop pressed before its send opened a turn, which interrupted nothing', () => { + /** The send is handed over and its turn has not opened; the Stop names none. */ + async function stoppedBeforeTheTurn(answer: AgentSessionCancelOutcome): Promise { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + rig.cancelTurn.mockResolvedValueOnce(answer) + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + await rig.settleAccepted(stopped, 'stopped') + await turnOpenedBy(stopped, 'running') + return stopped + } + + it('answers no-effect, naming the event it wrote', async () => { + await stoppedBeforeTheTurn(NOTHING_TO_INTERRUPT) + + expect(answers()).toEqual([ + { answer: 'no-effect', eventAt: latestEventAt(), text: 'Codex had no turn running to stop.' } + ]) + }) + + it('reads the turn its send opened, cut off later, as news', async () => { + const stopped = await stoppedBeforeTheTurn(NOTHING_TO_INTERRUPT) + + await turnOpenedBy(stopped, 'interrupted') + + expect(turn()).toMatchObject({ state: 'interrupted' }) + expect(turn()).not.toHaveProperty('outcome') + }) + + it('reads that turn as news when a restart settles it', async () => { + await stoppedBeforeTheTurn(NOTHING_TO_INTERRUPT) + + await restartAndSettle() + + expect(turn()).toMatchObject({ state: 'interrupted' }) + expect(turn()).not.toHaveProperty('outcome') + }) + + it('reads the same turn as the cancellation once a second press of the same Stop took', async () => { + const stopped = await stoppedBeforeTheTurn(NOTHING_TO_INTERRUPT) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await turnOpenedBy(stopped, 'interrupted') + + // The press repeats the Stop in force: no event of its own, an answer to that one's. + expect(stopEventCount()).toBe(1) + expect(answers().map(({ answer, eventAt }) => ({ answer, eventAt }))).toEqual([ + { answer: 'no-effect', eventAt: latestEventAt() }, + { answer: 'took', eventAt: latestEventAt() } + ]) + expect(turn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) +}) + +describe("a host stop after a person's Stop that named no turn", () => { + /** A send handed over with no turn open, and a Stop of it the provider answered with `answer`. */ + async function stoppedUnopenedSend(answer: AgentSessionCancelOutcome): Promise { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + rig.cancelTurn.mockResolvedValueOnce(answer) + expect(await rig.stop()).toMatchObject({ ok: true }) + } + + /** The Stop events' reasons as the eviction's provider close finds them. */ + async function evicted(): Promise { + let atClose: string[] = [] + rig.closeSession.mockImplementationOnce(async () => { + const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) + atClose = since.ok + ? since.rows.flatMap((row) => + row.kind === 'tombstone' && row.stopEvent ? [row.stopEvent.reason] : [] + ) + : [] + return true + }) + await rig.host.close(HOST_TEST_SESSION, 'evict') + return atClose + } + + it("defers to a Stop that took, writing nothing of the host's", async () => { + await stoppedUnopenedSend({ cancelled: true }) + + expect(await evicted()).toEqual(['user-stop']) + }) + + it('records its own event over a Stop that interrupted nothing', async () => { + await stoppedUnopenedSend(NOTHING_TO_INTERRUPT) + + expect(await evicted()).toEqual(['user-stop', 'evict']) + }) +}) + +describe('a Stop of a running turn', () => { + async function runningTurn(options: { stopEndsSession?: true } = {}): Promise { + rig = await createQueuedMessageTestRig(options) + await rig.workingSend() + await journal().appendItem( + CODEX_TURN, + { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() - 30_000 }, + SCOPE + ) + } + + it('answers took when the provider took the interrupt, and its end reads as the cancellation', async () => { + await runningTurn() + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await restartAndSettle() + + expect(answers()).toEqual([ + { answer: 'took', eventAt: latestEventAt(), text: 'Cancellation requested.' } + ]) + expect(turn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + it('answers end-owed when the Stop ends the provider process next, whatever it answered', async () => { + await runningTurn({ stopEndsSession: true }) + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(answers()).toEqual([ + { answer: 'end-owed', eventAt: latestEventAt(), text: 'Cancellation requested.' } + ]) + }) + + it('answers declined when the interrupt failed and the child could not be ended, and a crash reads as news', async () => { + await runningTurn() + rig.cancelTurn.mockResolvedValueOnce({ + cancelled: false, + refusal: { detail: { text: 'failed to interrupt turn', audience: 'person' } } + }) + rig.closeSession.mockResolvedValueOnce(false) + + expect(await stopNaming(TURN)).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(answers()).toEqual([ + { answer: 'declined', eventAt: latestEventAt(), text: expect.stringContaining("didn't stop") } + ]) + + await restartAndSettle() + + expect(turn()).toMatchObject({ state: 'interrupted' }) + expect(turn()).not.toHaveProperty('outcome') + }) + + it('keeps the answer that it took when the same Stop, pressed again, finds nothing to interrupt', async () => { + await runningTurn() + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnNotRunning: true } }) + + await rig.stop() + + expect(stopEventCount()).toBe(1) + expect(answers()).toEqual([ + { answer: 'took', eventAt: latestEventAt(), text: 'Cancellation requested.' } + ]) + await restartAndSettle() + expect(turn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) +}) diff --git a/src/shared/agent-session-stop-answer.test.ts b/src/shared/agent-session-stop-answer.test.ts new file mode 100644 index 000000000000..a7f72c80af6f --- /dev/null +++ b/src/shared/agent-session-stop-answer.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { isAdmissibleAgentJournalItemBody } from './agent-session-journal-schemas' +import type { AgentJournalItemBody } from './agent-session-journal-types' +import { + agentJournalStopAnswerReplaces, + agentJournalStopAnswerTook, + readAgentJournalStopAnswer +} from './agent-session-stop-answer' + +/** A Stop's note as a host of some version wrote it. */ +function note(stop: unknown): AgentJournalItemBody { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stands in for a row read from disk, whose `stop` this build may not know; the reader under test validates it. + return { kind: 'status', text: 'Cancellation requested.', stop } as AgentJournalItemBody +} + +describe('readAgentJournalStopAnswer', () => { + it('reads the answer and the event it answers', () => { + expect(readAgentJournalStopAnswer(note({ answer: 'declined', eventAt: 5 }))).toEqual({ + answer: 'declined', + eventAt: 5 + }) + expect(readAgentJournalStopAnswer(note({ answer: 'took' }))).toEqual({ answer: 'took' }) + }) + + it("reads an older host's note, a newer host's unknown answer, and any other row as no answer", () => { + expect( + readAgentJournalStopAnswer({ kind: 'status', text: 'Cancellation requested.' }) + ).toBeUndefined() + expect(readAgentJournalStopAnswer(note({ answer: 'withdrawn', eventAt: 5 }))).toBeUndefined() + expect( + readAgentJournalStopAnswer({ kind: 'message', role: 'user', blocks: [] }) + ).toBeUndefined() + }) + + it("keeps a newer host's unknown answer admissible, so the row is never dropped as malformed", () => { + expect(isAdmissibleAgentJournalItemBody(note({ answer: 'withdrawn', eventAt: 5 }))).toBe(true) + expect(isAdmissibleAgentJournalItemBody(note({ answer: '' }))).toBe(false) + }) +}) + +describe('agentJournalStopAnswerTook', () => { + it('counts a taken interrupt and an owed end, and nothing else', () => { + expect( + (['took', 'end-owed', 'declined', 'interrupt-unconfirmed', 'no-effect'] as const).filter( + agentJournalStopAnswerTook + ) + ).toEqual(['took', 'end-owed']) + }) +}) + +describe('agentJournalStopAnswerReplaces', () => { + it('never hides an answer that the same Stop took behind one that it did not', () => { + for (const next of ['declined', 'interrupt-unconfirmed', 'no-effect'] as const) { + expect( + agentJournalStopAnswerReplaces({ answer: 'took', eventAt: 5 }, { answer: next, eventAt: 5 }) + ).toBe(false) + } + expect( + agentJournalStopAnswerReplaces( + { answer: 'end-owed', eventAt: 5 }, + { answer: 'no-effect', eventAt: 5 } + ) + ).toBe(false) + }) + + it('lets an owed end that failed revise its answer', () => { + expect( + agentJournalStopAnswerReplaces( + { answer: 'end-owed', eventAt: 5 }, + { answer: 'interrupt-unconfirmed', eventAt: 5 } + ) + ).toBe(true) + }) + + it('lets a later Stop, a taken answer, or a first answer write', () => { + expect( + agentJournalStopAnswerReplaces( + { answer: 'took', eventAt: 5 }, + { answer: 'no-effect', eventAt: 9 } + ) + ).toBe(true) + expect( + agentJournalStopAnswerReplaces( + { answer: 'declined', eventAt: 5 }, + { answer: 'took', eventAt: 5 } + ) + ).toBe(true) + expect(agentJournalStopAnswerReplaces(undefined, { answer: 'no-effect' })).toBe(true) + }) +}) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-answer-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-answer-downgrade.unit.test.ts new file mode 100644 index 000000000000..d1276f822773 --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-stop-answer-downgrade.unit.test.ts @@ -0,0 +1,151 @@ +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 AgentSessionJournalIdentity +} from '../../../src/shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../src/shared/agent-session-journal-item-key' +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, + resolveBaselineReleaseRef +} from './release-checkout' + +// A Stop's answer rides its note as an optional key (Rule 1). Whatever the newest release knows of +// it, that release's host must keep the note when it replays this build's journal, and its client +// must admit the note it is published and print its text. Both hold for every version, so the +// baseline rolls with each cut. +const JOURNAL = 'src/main/native-chat/agent-session-journal' +const NOTE_TEXT = 'Codex had no turn running to stop.' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-stop-answer', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +/** A function the pinned release exports, typed as the caller calls it. */ +function releaseExport(module: Record, name: string): T { + const value = module[name] + if (typeof value !== 'function') { + 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 } + corrupt: boolean + malformedRows: number + truncateFrom?: number +} + +test("an older build keeps a Stop's note carrying its answer, folds the rows after it, and publishes it to a client that admits it", async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-stop-answer-downgrade-')) + const journals = createTrackedJournalOpener() + try { + // This build: a Stop's event, its note with the answer, then more history. + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + await journal.appendStopEvent({ reason: 'user-stop', caller: 'client-1' }, 1) + const before = journal.cursor() + const eventAt = journal.stopMarks.latest()!.event.at + const note = { provider: 'orca' as const, clientMessageId: 'stop:operation-1' } + await journal.appendItem( + note, + { kind: 'status', text: NOTE_TEXT, stop: { answer: 'no-effect', eventAt } }, + scope + ) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'status', text: 'after the Stop' }, + scope + ) + 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 + const noteId = agentJournalItemKey(note) + + const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef()) + const [database, table, open, reducer, batch, schemas] = 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', + 'src/shared/agent-session-journal-schemas.ts' + ].map((path) => importReleaseCheckoutModule(checkout, path)) + ) + 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: { itemId: string; body: { text?: string } }[] } + } + >(batch, 'projectJournalBatch') + const isAdmissibleAgentJournalRenderItem = releaseExport<(value: unknown) => boolean>( + schemas, + 'isAdmissibleAgentJournalRenderItem' + ) + + 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) + } + // Older host after a downgrade: no row is unknown or malformed, and the rows after it fold. + const replayed = replayJournal(db, false, IDENTITY.sessionId) + expect(replayed).toMatchObject({ corrupt: false, malformedRows: 0 }) + expect(replayed?.truncateFrom).toBeUndefined() + expect(replayed?.state.items.get(noteId)?.body.text).toBe(NOTE_TEXT) + expect(replayed?.state.items.size).toBe(2) + + // Older client against a host publishing the note: it admits the item and reads its text. + const projected = projectJournalBatch({ + rows: rows.filter((row) => row.seq > before.sequence), + snapshot: renderJournalState(replayed?.state), + afterSequence: before.sequence + }) + expect(projected.ok).toBe(true) + const published = projected.batch?.items.find((item) => item.itemId === noteId) + expect(published?.body.text).toBe(NOTE_TEXT) + expect(isAdmissibleAgentJournalRenderItem(published)).toBe(true) + const current = journal.snapshot().items.find((item) => item.itemId === noteId) + expect(isAdmissibleAgentJournalRenderItem(current)).toBe(true) + } finally { + db.close() + } + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}, 120_000) From 54914c00a77d4a461641d33f74933c65ccc2dadb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:23:59 -0700 Subject: [PATCH 3/9] fix(native-chat): say when a turnless Stop's answer is declined --- .../agent-session-wire/structured-agent-session-turns-cancel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f37b2920c076..2b49a5dabe8b 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 @@ -215,7 +215,7 @@ export async function performCancel( } else if (!cancelled && input.turnId === undefined) { // Sent only while the chat reads working, so a Stop that ended nothing must say why. note = stopRefusedNote(ctx, refusal) - // No interrupt went out, or the provider had no turn running for it. + // Declined only when the provider could not interrupt a turn it runs; else nothing ran for it. answer = interruptFailed ? 'declined' : 'no-effect' } } catch (error) { From 031ca614544255dd28e901d38eef8589b3ae6aa0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:58:22 -0700 Subject: [PATCH 4/9] fix(native-chat): a Stop's answer names its event by id, and a host stop defers whatever it answered The note linked an answer to its Stop event by the event's time. Two of a person's events can share a millisecond (a close right after a declined Stop), and the writer read the event back to learn which one it wrote. The event now carries an id its writer mints, and the note's answer names it (`stop.eventId`); a rewind's restatement keeps the id. Answers are read only from Stop notes. A host stop or eviction after a person's Stop that did nothing wrote its own event, which lifted the person's queue pause and sent the held card on reopen. The host now defers to a person's Stop that covers the work whatever it answered (`personStopCovers`); only the turn-end attribution applies the answers (`personStopDecides`), so that turn still ends as news. --- .../journal-row-schema.ts | 5 +- .../journal-stop-and-resume-rows.test.ts | 2 +- .../journal-stop-and-resume-rows.ts | 6 +- .../journal-stop-answers.test.ts | 94 ++++++++++++++----- .../journal-stop-answers.ts | 20 ++-- .../journal-stop-marks.ts | 12 ++- .../journal-stop-turn-end.ts | 78 ++++++++++----- .../agent-session-journal/journal-store.ts | 7 +- .../queued-message-pause.test.ts | 30 ++++-- .../structured-agent-session-chat-stop.ts | 4 +- ...t-session-claude-stop-ends-session.test.ts | 2 +- ...tured-agent-session-codex-stop-row.test.ts | 24 ++++- .../structured-agent-session-command-turn.ts | 17 +--- ...tructured-agent-session-host-stop-event.ts | 8 +- .../structured-agent-session-queued-stop.ts | 17 ++-- ...ent-session-restore-without-import.test.ts | 2 +- ...ructured-agent-session-stop-answer.test.ts | 71 +++++++++----- ...d-agent-session-stop-event-entries.test.ts | 14 ++- ...tructured-agent-session-stop-event.test.ts | 17 +++- .../structured-agent-session-turns-cancel.ts | 14 +-- .../structured-conversation-command.test.ts | 2 +- .../agent-session-journal-fact-schemas.ts | 2 +- src/shared/agent-session-journal-types.ts | 6 +- src/shared/agent-session-stop-answer.test.ts | 63 ++++++++++--- src/shared/agent-session-stop-answer.ts | 27 ++++-- ...session-stop-answer-downgrade.unit.test.ts | 68 ++++++++++---- ...-session-stop-event-downgrade.unit.test.ts | 23 ++++- 27 files changed, 450 insertions(+), 185 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 e65d5919b39b..18199a02f967 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 @@ -84,11 +84,14 @@ export type JournalTombstoneRow = JournalRowBase & { * `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 = { + /** Identity, minted by the writer; a rewind's restatement keeps it. Absent only on rows dev + * builds wrote before it, which no answer names. */ + id?: string /** 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. */ + /** When it took effect: timing only, never identity. A rewind's restatement keeps it. */ at: number /** Who asked (`StructuredAgentSessionCaller.callerKey`). */ caller?: string 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 8eee42a70d03..1e811744f187 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 @@ -95,7 +95,7 @@ describe("a Stop's event and a Resume", () => { { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const cursor = journal.cursor() - await journal.appendStopEvent({ reason: 'user-stop' }, 1) + await journal.appendStopEvent({ id: 'stop-1', reason: 'user-stop' }, 1) await journal.appendQueueResume(1) const since = journal.readSince(cursor) if (!since.ok) { 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..019f8ccde7b0 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 @@ -40,10 +40,14 @@ export function buildJournalQueueResumeRow(input: RowPlace): JournalTombstoneRow } } +/** A Stop event as its writer hands it over: with the id the writer minted, so the writer can name + * the event without reading it back, and untimed. */ +export type JournalNewStopEvent = Omit & { id: string } + /** A Stop taking effect now: its event's time is its row's. */ export function journalStopEventRowBuilder( state: () => JournalReducerState, - event: Omit, + event: JournalNewStopEvent, fence: number ): (seq: number, ts: number) => JournalTombstoneRow { return (seq, ts) => diff --git a/src/main/native-chat/agent-session-journal/journal-stop-answers.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-answers.test.ts index d80ccd222f66..3edd77f1166e 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-answers.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-answers.test.ts @@ -33,11 +33,14 @@ const TURN: AgentJournalItemIdentity = { let root: string let journal: AgentSessionJournal +let minted = 0 const journals = createTrackedJournalOpener() beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-stop-answers-')) - journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + minted = 0 + // One millisecond for every row: an answer must find its Stop by id, never by time. + journal = await journals.open({ identity: IDENTITY, stateDirectory: root, now: () => 1_000 }) }) afterEach(async () => { @@ -45,16 +48,22 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -/** A person's Stop of `turn-1`, as its own step writes it; its event's time. */ -async function stopped(): Promise { - await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1' }, 1) - return journal.stopMarks.latest()!.event.at +/** A Stop of `turn-1`, a person's unless `reason` says otherwise, as its writer writes it; its + * event's id. */ +async function stopped(reason: 'user-stop' | 'user-close' = 'user-stop'): Promise { + const id = `stop-${++minted}` + await journal.appendStopEvent({ id, reason, turnId: 'turn-1' }, 1) + return id } /** A Stop's note at `key`, as `performCancel` writes it. */ -async function answered(key: string, stop: AgentJournalStopNoteAnswer | undefined): Promise { +async function answered( + key: string, + stop: AgentJournalStopNoteAnswer | undefined, + identity: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: `stop:${key}` } +): Promise { await journal.appendItem( - { provider: 'orca', clientMessageId: `stop:${key}` }, + identity, { kind: 'status', text: 'Cancellation requested.', ...(stop ? { stop } : {}) }, SCOPE ) @@ -74,44 +83,79 @@ describe('journalStopStillCounts', () => { it.each(['declined', 'interrupt-unconfirmed', 'no-effect'] as const)( 'stops counting a Stop whose only answer is %s', async (answer) => { - const eventAt = await stopped() - await answered('turn-1', { answer, eventAt }) + const eventId = await stopped() + await answered('turn-1', { answer, eventId }) expect(stillCounts()).toBe(false) } ) it('keeps counting while any answer says it took, a later one that it did not included', async () => { - const eventAt = await stopped() - await answered('turn-1', { answer: 'took', eventAt }) - await answered('op-2', { answer: 'no-effect', eventAt }) + const eventId = await stopped() + await answered('turn-1', { answer: 'took', eventId }) + await answered('op-2', { answer: 'no-effect', eventId }) expect(stillCounts()).toBe(true) }) it('counts an owed end as taking effect', async () => { - const eventAt = await stopped() - await answered('turn-1', { answer: 'end-owed', eventAt }) + const eventId = await stopped() + await answered('turn-1', { answer: 'end-owed', eventId }) expect(stillCounts()).toBe(true) }) - it("reads only answers to this Stop: an earlier Stop's, one naming no event, and an older host's note are none", async () => { + it("reads only answers to this Stop: an earlier Stop's in the same millisecond, one naming no event, and an older host's note are none", async () => { const earlier = await stopped() - await answered('turn-1', { answer: 'no-effect', eventAt: earlier }) - await new Promise((resolve) => setTimeout(resolve, 2)) - const latest = await stopped() - expect(latest).not.toBe(earlier) + await answered('turn-1', { answer: 'no-effect', eventId: earlier }) + await stopped() + expect(journal.stopMarks.latest()?.event.at).toBe(1_000) await answered('op-2', { answer: 'declined' }) await answered('op-3', undefined) expect(stillCounts()).toBe(true) }) + + it("keeps counting a person's close written in the same millisecond as their declined Stop", async () => { + await answered('turn-1', { answer: 'declined', eventId: await stopped() }) + await stopped('user-close') + + expect(stillCounts()).toBe(true) + expect(journal.stopMarks.personStopDecides('turn-1')).toBe(true) + }) + + it("reads an answer only from a Stop's note", async () => { + const eventId = await stopped() + await answered('row', { answer: 'declined', eventId }, TURN) + + expect(stillCounts()).toBe(true) + }) + + it('counts an event an early build wrote with no id: no answer names it', async () => { + await answered('op-1', { answer: 'declined' }) + + expect(journal.stopMarks.stillCounts({})).toBe(true) + }) + + it('links an answer a rewind keeps to the Stop it restates, by the id the restatement keeps', async () => { + const eventId = await stopped() + const note = { provider: 'orca' as const, clientMessageId: 'stop:turn-1' } + + await journal.replaceEpochItems('legacy_import', 1, [ + { + identity: note, + body: { kind: 'status', text: 'Codex didn’t stop.', stop: { answer: 'declined', eventId } } + } + ]) + + expect(journal.stopMarks.latest()?.event).toMatchObject({ id: eventId, reason: 'user-stop' }) + expect(stillCounts()).toBe(false) + }) }) describe('the turn-end rule', () => { - /** `turn-1` running, and a person's Stop of it; the event's time. */ - async function stoppedWhileRunning(): Promise { + /** `turn-1` running, and a person's Stop of it; the event's id. */ + async function stoppedWhileRunning(): Promise { await journal.appendItem( TURN, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: Date.now() - 1_000 }, @@ -136,7 +180,7 @@ describe('the turn-end rule', () => { } it("makes the end of a turn a Stop that took named the person's cancellation", async () => { - await answered('turn-1', { answer: 'took', eventAt: await stoppedWhileRunning() }) + await answered('turn-1', { answer: 'took', eventId: await stoppedWhileRunning() }) await endTurn() @@ -144,11 +188,13 @@ describe('the turn-end rule', () => { }) it('leaves the end of a turn a declined Stop named as news, so a crash reads as one', async () => { - await answered('turn-1', { answer: 'declined', eventAt: await stoppedWhileRunning() }) + await answered('turn-1', { answer: 'declined', eventId: await stoppedWhileRunning() }) + // Still the person's Stop of the turn, which a host stop defers to; not their end. + expect(journal.stopMarks.personStopCovers('turn-1')).toBe(true) + expect(journal.stopMarks.personStopDecides('turn-1')).toBe(false) await endTurn() expect(outcome()).toBeUndefined() - expect(journal.stopMarks.personStopDecides('turn-1')).toBe(false) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-answers.ts b/src/main/native-chat/agent-session-journal/journal-stop-answers.ts index 147feda53955..1be2e9321b63 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-answers.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-answers.ts @@ -1,8 +1,10 @@ // Whether a Stop still counts, from the answers its notes record: the one rule every reader of a -// Stop's effect applies (the turn-end write, the host stop's deference, and the "Stopping…" state). +// Stop's effect on a turn's end applies (the turn-end write, and through it the readers that must +// choose before the end is written, and the "Stopping…" state). import { agentJournalStopAnswerTook, + isStructuredAgentSessionStopNote, readAgentJournalStopAnswer } from '../../../shared/agent-session-stop-answer' import type { JournalReducerState } from './journal-reducer' @@ -11,17 +13,23 @@ import type { JournalStopEvent } from './journal-row-schema' /** * Whether the Stop that wrote `event` still counts as taking effect: it has no answer yet, or any * answer it has says it took. A later answer that it did not take never cancels one that says it - * did. Answers attach by the event's `at`, so a rewind's restatement keeps them; an older host's - * note, and an answer this build does not know, are no answer. + * did. Answers name the event by its id; an event with none (an early dev build's), an older + * host's note, and an answer this build does not know have no answer. */ export function journalStopStillCounts( state: Pick, - event: Pick + event: Pick ): boolean { + if (event.id === undefined) { + return true + } let answered = false - for (const item of state.items.values()) { + for (const [itemId, item] of state.items) { + if (!isStructuredAgentSessionStopNote(itemId)) { + continue + } const stop = readAgentJournalStopAnswer(item.body) - if (stop?.eventAt !== event.at) { + if (stop?.eventId !== event.id) { continue } if (agentJournalStopAnswerTook(stop.answer)) { 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 687154cc9c93..de3b9aa93407 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,13 @@ // 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`), whether a person's still decides, and -// whether a Stop still counts by its answers (`journal-stop-answers.ts`). +// the turn-end rule reads (`journal-stop-turn-end.ts`), whether a person's covers or still decides +// a turn, and whether a Stop still counts by its answers (`journal-stop-answers.ts`). import type { JournalReducerState } from './journal-reducer' import type { JournalStopEvent } from './journal-row-schema' import { journalStopStillCounts } from './journal-stop-answers' import { latestAcceptedSendUnopened, + personStopCoversTurn, personStopDecidesTurn, type JournalLatestStop } from './journal-stop-turn-end' @@ -23,13 +24,18 @@ export class JournalStopMarks { return latestAcceptedSendUnopened(this.deps.state()) } + /** `personStopCoversTurn`: a person's Stop is in force for turn `turnId`, whatever it answered. */ + personStopCovers(turnId: string | null): boolean { + return personStopCoversTurn(this.deps.state(), turnId) + } + /** `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) } /** `journalStopStillCounts`: the Stop that wrote `event` has no answer yet, or one says it took. */ - stillCounts(event: Pick): boolean { + stillCounts(event: Pick): boolean { return journalStopStillCounts(this.deps.state(), event) } } 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 c68c7a9a7b4e..cd862b20498f 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 @@ -121,9 +121,25 @@ function stopIsTurnCancellation( return opener !== undefined && isStopTarget(state, stop, opener) } +/** Whether `stop`, a person's, reaches the end of turn `turnId`, opened by `userItemId` and ending + * at `endedAt`, whatever it answered. An exit the provider saw before the Stop was news, whenever + * its end is written. */ +function stopCoversTurnEnd( + state: TurnEndState, + stop: JournalLatestStop, + turnId: string, + userItemId: string | undefined, + endedAt: number | undefined +): boolean { + return ( + stopIsTurnCancellation(state, stop, turnId, userItemId) && + (endedAt === undefined || endedAt >= stop.event.at) + ) +} + /** 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. */ + * `endedAt` with no verdict of its own, a person's cancellation: it reaches that end and still + * counts. */ function stopEndsTurnAsCancellation( state: TurnEndState, turnId: string, @@ -133,8 +149,7 @@ function stopEndsTurnAsCancellation( const stop = state.queuePauseMarks.latestStop return ( stop !== null && - stopIsTurnCancellation(state, stop, turnId, userItemId) && - (endedAt === undefined || endedAt >= stop.event.at) && + stopCoversTurnEnd(state, stop, turnId, userItemId, endedAt) && journalStopStillCounts(state, stop.event) ) } @@ -150,36 +165,55 @@ function unansweredSendsAreStopTargets(state: TurnEndState, stop: JournalLatestS ) } -/** - * 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( +/** The latest Stop when it is a person's and reaches turn `turnId` (null: the sends in flight with + * no turn running), ending at `endedAt`, whatever it answered; else null. */ +function personStopCoveringTurn( state: TurnEndState, turnId: string | null, - endedAt?: number, - /** The submission that opened the turn, for one whose rows have yet to land. */ - openedBy?: string -): boolean { + endedAt: number | undefined, + openedBy: string | undefined +): JournalLatestStop | null { const stop = state.queuePauseMarks.latestStop if (stop === null || !stopIsAPersons(stop.event.reason)) { - return false + return null } if (turnId === null) { - return ( - stop.event.turnId === undefined && - unansweredSendsAreStopTargets(state, stop) && - journalStopStillCounts(state, stop.event) - ) + return stop.event.turnId === undefined && unansweredSendsAreStopTargets(state, stop) + ? stop + : null } 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 stopCoversTurnEnd(state, stop, turnId, userItemId, endedAt) ? stop : null +} + +/** + * Whether a person's Stop is in force for turn `turnId` (null: the sends in flight with no turn + * running), whatever it answered: a host stop defers to it, so the person's pause on the queue + * holds and their reason stays the latest. Not who owns the end; that is `personStopDecidesTurn`. + */ +export function personStopCoversTurn(state: TurnEndState, turnId: string | null): boolean { + return personStopCoveringTurn(state, turnId, undefined, undefined) !== null +} + +/** + * 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, such as a + * Claude error result naming no reason, which leaves its verdict to it. + */ +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 +): boolean { + const stop = personStopCoveringTurn(state, turnId, endedAt, openedBy) + return stop !== null && journalStopStillCounts(state, stop.event) } /** 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 3de15cc3df60..dc64bd0ab148 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -60,9 +60,10 @@ import type { import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' import { journalQueueResumeRowBuilder, - journalStopEventRowBuilder + journalStopEventRowBuilder, + type JournalNewStopEvent } from './journal-stop-and-resume-rows' -import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' +import type { AgentJournalEpochReason } from './journal-row-schema' import type { JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' @@ -315,7 +316,7 @@ export class AgentSessionJournal { } /** A Stop that took effect, timed by its row (`JournalStopEvent`). */ - appendStopEvent(event: Omit, fence: number): Promise { + appendStopEvent(event: JournalNewStopEvent, 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 b8e45e0ef590..43a648240f78 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 @@ -97,7 +97,7 @@ function acceptTurn(journal: AgentSessionJournal, id: string) { /** A person's Stop taking effect. */ function userStop(journal: AgentSessionJournal) { - return journal.appendStopEvent({ reason: 'user-stop' }, 0) + return journal.appendStopEvent({ id: 'stop-person', reason: 'user-stop' }, 0) } function reason(journal: AgentSessionJournal): string | null { @@ -231,7 +231,10 @@ 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.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 0) + await journal.appendStopEvent( + { id: 'stop-1', 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() @@ -240,7 +243,7 @@ describe("the queue's pause, derived from the journal", () => { ok: true, row: { kind: 'tombstone', - stopEvent: { reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' } + stopEvent: { id: 'stop-1', reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' } } }) if (!parsed.ok || parsed.row.kind !== 'tombstone') { @@ -337,11 +340,11 @@ describe("the queue's pause, derived from the journal", () => { 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) + await journal.appendStopEvent({ id: `stop-${reason}`, 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) + await journal.appendStopEvent({ id: 'stop-future', reason: 'future-reason' as 'evict' }, 0) expect(reason(journal)).toBeNull() }) @@ -351,7 +354,7 @@ describe("the queue's pause, derived from the journal", () => { 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) + await journal.appendStopEvent({ id: `stop-${later}`, reason: later }, 0) expect(reason(journal)).toBeNull() expect(held(journal)).toEqual([['draft-1', false]]) } @@ -366,14 +369,23 @@ describe("the queue's pause, derived from the journal", () => { expect(held(journal)).toEqual([['held', true]]) }) - it('the restated Stop is the same event: its reason, turn, caller and time', async () => { + it('the restated Stop is the same event: its id, reason, turn, caller and time', async () => { const journal = await open() - await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-7', caller: 'phone' }, 0) + await journal.appendStopEvent( + { id: 'stop-7', 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) } + { + id: 'stop-7', + reason: 'user-stop', + turnId: 'turn-7', + caller: 'phone', + at: expect.any(Number) + } ]) }) 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 1345893e4e6f..0016d0fe37cb 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 @@ -92,13 +92,13 @@ export function mutateWithChatStop( // Awaited until journal appends are synchronous; then issued here, and a `finally` awaits it. const recorded = withdrawn.length > 0 ? 'unrecorded' : await stopRecordedWork(ctx, turnId) // The event this Stop's answer is for: the one it writes, or the one in force it repeats. - const answers = recorded === 'unrecorded' ? await tookEffect() : recorded + const stopEventId = recorded === 'unrecorded' ? await tookEffect() : recorded?.id return performCancel( { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, { clientOperationId: envelope.clientOperationId, ...named, - ...(answers ? { stopEventAt: answers.at } : {}), + ...(stopEventId !== undefined ? { stopEventId } : {}), stopChild, onStopChildError: (error) => context.deps.logger.warn('ending the agent process on Stop failed', { 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 bc8ac9d891b5..d03d42aa8479 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 @@ -276,7 +276,7 @@ async function stopAnswers(): Promise<{ answer: string; answersLatestStop: boole return (await host.journalSnapshot(SESSION)).items.flatMap((item) => { const stop = readAgentJournalStopAnswer(item.body) return stop - ? [{ answer: stop.answer, answersLatestStop: stop.eventAt === latest?.event.at }] + ? [{ answer: stop.answer, answersLatestStop: stop.eventId === latest?.event.id }] : [] }) } 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 73ccd54f6401..e3fdc988e379 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 @@ -142,7 +142,7 @@ async function journalRows() { answers: items.flatMap((item) => { const stop = readAgentJournalStopAnswer(item.body) const event = host['sessions'].get(SESSION)?.journal.stopMarks.latest()?.event - return stop ? [stop.eventAt === event?.at ? stop.answer : 'another event'] : [] + return stop ? [stop.eventId === event?.id ? stop.answer : 'another event'] : [] }) } } @@ -351,6 +351,28 @@ describe('a Codex Stop whose interrupt failed', () => { } ) + it('answers no-effect when a Stop naming no turn finds none running, and the one it waits for never opens', async () => { + 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')) + + const stopping = stop() + // Codex goes idle without opening the turn it answered the send into. + setTimeout( + () => notify('thread/status/changed', { threadId: THREAD, status: { type: 'idle' } }), + 20 + ) + const stopped = await stopping + await host.flushStreamedEvents(SESSION) + + expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) + expect(childEndedByStop()).toBe(false) + expect((await journalRows()).statuses).toEqual(['Codex had no turn running to stop.']) + expect((await journalRows()).answers).toEqual(['no-effect']) + }) + it('keeps the child at rest, and writes no row, when a Stop names a turn that already ended', async () => { await runningTurn() turns.end('completed') 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 9eb5cb02ea47..282524bfce3e 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 @@ -18,8 +18,7 @@ import { } from '../../../shared/agent-session-failure-words' import { agentJournalItemKey, - agentJournalSubmissionKey, - parseAgentJournalItemKey + agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -31,6 +30,7 @@ import type { AgentSessionConversationCommand } from '../../../shared/agent-sess import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { isStructuredAgentSessionStopNote } from '../../../shared/agent-session-stop-answer' import { agentJournalTurnBody, readAgentJournalTurn @@ -118,19 +118,6 @@ export function isStructuredAgentSessionCommandTurnId(turnId: string): boolean { return turnId.startsWith('compact:') } -const STOP_NOTE_PREFIX = 'stop:' - -/** A Stop's note, keyed by the turn it stopped (or, with no turn, by its operation). */ -export function structuredAgentSessionStopNoteIdentity(stopKey: string): AgentJournalItemIdentity { - return { provider: 'orca', clientMessageId: `${STOP_NOTE_PREFIX}${stopKey}` } -} - -/** Whether a journal row 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( 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..aaf9f72c92d8 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 @@ -1,6 +1,7 @@ // The Stop event a host stop writes (`JournalStopEvent`): whether it ends work its event must // record, and the write itself, issued before the kill. +import { randomUUID } from 'node:crypto' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { withTimeout } from '../../../shared/promise-timeout-fallback' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' @@ -63,10 +64,11 @@ 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. + // A host stop of work a person's Stop is already ending must not supersede that Stop's reason, + // whatever it answered: the latest Stop is what holds their queue's pause. return ( working && - (ending.cause === 'user-close' || !journal.stopMarks.personStopDecides(journal.activeTurnId())) + (ending.cause === 'user-close' || !journal.stopMarks.personStopCovers(journal.activeTurnId())) ) } @@ -84,7 +86,7 @@ export function recordStopEvent( const turnId = session.journal.activeTurnId() return session.journal .appendStopEvent( - { reason: ending.cause, ...(turnId !== null ? { turnId } : {}) }, + { id: randomUUID(), reason: ending.cause, ...(turnId !== null ? { turnId } : {}) }, structuredAgentSessionConversationFence(context.deps.store, sessionId) ) .then( 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 d0f2156a99c8..b9ab8e4baea7 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 @@ -6,6 +6,7 @@ // 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 { randomUUID } from 'node:crypto' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -32,15 +33,15 @@ export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMes * 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 (`stopRecordedWork`), changed - * nothing and writes nothing. `tookEffect` resolves to the event as written, which the Stop's - * answer names; undefined when the write failed. + * nothing and writes nothing. `tookEffect` resolves to the id it gave the event, which the + * Stop's answer names; undefined when the write failed. * The drain cannot slip a card in between: the Stop runs on the drain's serialized lane. */ export async function runRecordedStop( ctx: AgentSessionTurnContext, /** `turnId` absent: the turn running when the Stop takes effect, if any. */ - event: Omit, - stop: (tookEffect: () => Promise) => Promise> + event: Omit, + stop: (tookEffect: () => Promise) => Promise> ): Promise> { const skipped = (error: unknown): undefined => { report(ctx, 'event row', error) @@ -49,12 +50,10 @@ export async function runRecordedStop( return stop(() => { try { const turnId = structuredAgentSessionStoppedTurnId(ctx.journal, event.turnId) ?? undefined + const id = randomUUID() return ctx.journal - .appendStopEvent({ ...event, ...(turnId ? { turnId } : {}) }, ctx.fence) - .then((written) => { - const latest = ctx.journal.stopMarks.latest() - return latest?.sequence === written.sequence ? latest.event : undefined - }, skipped) + .appendStopEvent({ ...event, id, ...(turnId ? { turnId } : {}) }, ctx.fence) + .then(() => id, skipped) } catch (error) { // A throw before the append is queued is reported too: the Stop still interrupts. return Promise.resolve(skipped(error)) 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..bfc04c081885 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 @@ -485,7 +485,7 @@ describe('startup restore of chats still in their per-chat files', () => { // In the Stop's order: its event, then the stopped turn's end, then a row the kill writes. const [stopped, ended, killed] = await Promise.all([ - journal.appendStopEvent({ reason: 'user-stop' }, 1), + journal.appendStopEvent({ id: 'stop-1', reason: 'user-stop' }, 1), journal.appendItem( { provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 9 }, { kind: 'turn', turnId: 't', state: 'interrupted', startedAt: 1 }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts index 5658dcd4e3e5..d977eb401bdb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts @@ -60,8 +60,8 @@ function answers(): (AgentJournalStopNoteAnswer & { text: string })[] { }) } -function latestEventAt(): number | undefined { - return journal().stopMarks.latest()?.event.at +function latestEventId(): string | undefined { + return journal().stopMarks.latest()?.event.id } /** Turn `turn-1` as Codex writes it: opened by `clientMessageId`, running or cut off. */ @@ -128,8 +128,9 @@ describe('a Stop pressed before its send opened a turn, which interrupted nothin it('answers no-effect, naming the event it wrote', async () => { await stoppedBeforeTheTurn(NOTHING_TO_INTERRUPT) + expect(latestEventId()).toEqual(expect.any(String)) expect(answers()).toEqual([ - { answer: 'no-effect', eventAt: latestEventAt(), text: 'Codex had no turn running to stop.' } + { answer: 'no-effect', eventId: latestEventId(), text: 'Codex had no turn running to stop.' } ]) }) @@ -159,22 +160,31 @@ describe('a Stop pressed before its send opened a turn, which interrupted nothin // The press repeats the Stop in force: no event of its own, an answer to that one's. expect(stopEventCount()).toBe(1) - expect(answers().map(({ answer, eventAt }) => ({ answer, eventAt }))).toEqual([ - { answer: 'no-effect', eventAt: latestEventAt() }, - { answer: 'took', eventAt: latestEventAt() } + expect(answers().map(({ answer, eventId }) => ({ answer, eventId }))).toEqual([ + { answer: 'no-effect', eventId: latestEventId() }, + { answer: 'took', eventId: latestEventId() } ]) expect(turn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) }) }) -describe("a host stop after a person's Stop that named no turn", () => { - /** A send handed over with no turn open, and a Stop of it the provider answered with `answer`. */ - async function stoppedUnopenedSend(answer: AgentSessionCancelOutcome): Promise { +describe("an eviction after a person's Stop that named no turn", () => { + /** A send handed over with no turn open, a card queued behind it, and a Stop of that send the + * provider answered with `answer`; the card's id. */ + async function stoppedUnopenedSend(answer: AgentSessionCancelOutcome): Promise { rig = await createQueuedMessageTestRig() - await rig.workingSend() + const sent = await rig.workingSend() + const queued = await rig.send('then this', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error(`expected a queued receipt: ${JSON.stringify(queued)}`) + } rig.cancelTurn.mockResolvedValueOnce(answer) expect(await rig.stop()).toMatchObject({ ok: true }) + expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + stoppedSend = sent + return queued.value.queued.messageId } + let stoppedSend = '' /** The Stop events' reasons as the eviction's provider close finds them. */ async function evicted(): Promise { @@ -192,16 +202,35 @@ describe("a host stop after a person's Stop that named no turn", () => { return atClose } - it("defers to a Stop that took, writing nothing of the host's", async () => { - await stoppedUnopenedSend({ cancelled: true }) + // The latest Stop is what holds the person's pause, so the host never writes over theirs. + it.each([ + ['took', { cancelled: true }], + ['interrupted nothing', NOTHING_TO_INTERRUPT] + ] as const)( + "defers to a Stop that %s, so the person's pause still holds the card queued before it", + async (_case, answer) => { + const card = await stoppedUnopenedSend(answer) - expect(await evicted()).toEqual(['user-stop']) - }) + expect(await evicted()).toEqual(['user-stop']) - it('records its own event over a Stop that interrupted nothing', async () => { - await stoppedUnopenedSend(NOTHING_TO_INTERRUPT) + expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await rig.drafts()).toEqual([{ messageId: card, state: 'waiting' }]) + expect(await rig.handoff(card)).toBeUndefined() + } + ) - expect(await evicted()).toEqual(['user-stop', 'evict']) + it('defers to a Stop that interrupted nothing once its send opened a turn, and that turn still ends as news', async () => { + const card = await stoppedUnopenedSend(NOTHING_TO_INTERRUPT) + await rig.settleAccepted(stoppedSend, 'stopped') + await turnOpenedBy(stoppedSend, 'running') + + expect(await evicted()).toEqual(['user-stop']) + expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await rig.handoff(card)).toBeUndefined() + await turnOpenedBy(stoppedSend, 'interrupted') + + expect(turn()).toMatchObject({ state: 'interrupted' }) + expect(turn()).not.toHaveProperty('outcome') }) }) @@ -223,7 +252,7 @@ describe('a Stop of a running turn', () => { await restartAndSettle() expect(answers()).toEqual([ - { answer: 'took', eventAt: latestEventAt(), text: 'Cancellation requested.' } + { answer: 'took', eventId: latestEventId(), text: 'Cancellation requested.' } ]) expect(turn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) }) @@ -235,7 +264,7 @@ describe('a Stop of a running turn', () => { expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(answers()).toEqual([ - { answer: 'end-owed', eventAt: latestEventAt(), text: 'Cancellation requested.' } + { answer: 'end-owed', eventId: latestEventId(), text: 'Cancellation requested.' } ]) }) @@ -249,7 +278,7 @@ describe('a Stop of a running turn', () => { expect(await stopNaming(TURN)).toMatchObject({ ok: true, value: { cancelled: false } }) expect(answers()).toEqual([ - { answer: 'declined', eventAt: latestEventAt(), text: expect.stringContaining("didn't stop") } + { answer: 'declined', eventId: latestEventId(), text: expect.stringContaining("didn't stop") } ]) await restartAndSettle() @@ -267,7 +296,7 @@ describe('a Stop of a running turn', () => { expect(stopEventCount()).toBe(1) expect(answers()).toEqual([ - { answer: 'took', eventAt: latestEventAt(), text: 'Cancellation requested.' } + { answer: 'took', eventId: latestEventId(), text: 'Cancellation requested.' } ]) await restartAndSettle() expect(turn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) 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..7030d6081912 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 @@ -95,7 +95,9 @@ describe('every Stop entry writes its event, with its reason, before it ends the await rig.host.close(HOST_TEST_SESSION, cause) - expect(atClose.events).toEqual([{ reason: cause, turnId: 'turn-1', at: expect.any(Number) }]) + expect(atClose.events).toEqual([ + { id: expect.any(String), reason: cause, turnId: 'turn-1', at: expect.any(Number) } + ]) }) it("a person's Stop of a running turn", async () => { @@ -131,7 +133,9 @@ describe('every Stop entry writes its event, with its reason, before it ends the await idleSweep().tick() - expect(atClose.events).toEqual([{ reason: 'host-stop', at: expect.any(Number) }]) + expect(atClose.events).toEqual([ + { id: expect.any(String), reason: 'host-stop', at: expect.any(Number) } + ]) }) it('writes nothing when it ends nothing: a close of a chat at rest', async () => { @@ -298,7 +302,9 @@ describe('every Stop entry writes its event, with its reason, before it ends the 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) }]) + expect(stopEvents()).toEqual([ + { id: expect.any(String), reason: cause, turnId: 'turn-1', at: expect.any(Number) } + ]) await idleSweep().tick() @@ -368,7 +374,7 @@ describe("a person's Stop pause and the Stop events after it", () => { 'starting' ) // A person's Stop of an earlier turn still pauses the queue. - await journal().appendStopEvent({ reason: 'user-stop', caller: 'client-1' }, 1) + await journal().appendStopEvent({ id: 'stop-1', reason: 'user-stop', caller: 'client-1' }, 1) expect(journal().queuedMessages.userStopInForce()).not.toBeNull() const atClose = stopEventsAtClose() 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..7a69708b8029 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 @@ -91,6 +91,7 @@ describe("a Stop's event", () => { expect(rig.cancelTurn).toHaveBeenCalledTimes(1) expect(atInterrupt).toEqual([ { + id: expect.any(String), reason: 'user-stop', turnId: 'turn-named', caller: QUEUED_RIG_CALLER.callerKey, @@ -150,7 +151,12 @@ describe("a Stop's event", () => { 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) } + { + id: expect.any(String), + reason: 'user-stop', + caller: QUEUED_RIG_CALLER.callerKey, + at: expect.any(Number) + } ]) }) @@ -165,7 +171,12 @@ describe("a Stop's event", () => { release() expect(rig.cancelTurn).not.toHaveBeenCalled() expect(stopEvents()).toEqual([ - { reason: 'user-stop', caller: QUEUED_RIG_CALLER.callerKey, at: expect.any(Number) } + { + id: expect.any(String), + reason: 'user-stop', + caller: QUEUED_RIG_CALLER.callerKey, + at: expect.any(Number) + } ]) }) @@ -279,7 +290,7 @@ describe("a Stop's event", () => { vi.spyOn(open, 'appendSubmission').mockImplementation(async (input, consume) => { if (input.origin === 'host' && consume?.messageId === draftId && !injected) { injected = true - await open.appendStopEvent({ reason: 'user-stop' }, input.fence) + await open.appendStopEvent({ id: 'stop-injected', reason: 'user-stop' }, input.fence) } return appendSubmission(input, consume) }) 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 2b49a5dabe8b..67a5eec9d8e3 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 @@ -9,15 +9,15 @@ import type { } from '../../../shared/agent-session-journal-types' import { agentJournalStopAnswerReplaces, - readAgentJournalStopAnswer + readAgentJournalStopAnswer, + structuredAgentSessionStopNoteIdentity } from '../../../shared/agent-session-stop-answer' 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 { answerCancelOfSettledPrompt, @@ -129,9 +129,9 @@ export async function performCancel( endSession?: (windDown: StructuredAgentSessionStopWindDown) => void /** The host already withdrew queued messages for this Stop. */ withdrewQueued?: boolean - /** The Stop event this Stop answers (`JournalStopEvent.at`): the one it wrote, or the one in - * force it repeated. Absent: its answer names none. */ - stopEventAt?: number + /** The id of the Stop event this Stop answers: the one it wrote, or the one in force it + * repeated. Absent: its answer names none. */ + stopEventId?: string /** The session's child records: a background Stop reaches the tasks they offer a stop. */ childWork?: () => readonly AgentChildWorkView[] | undefined } @@ -288,7 +288,7 @@ export async function performCancel( const identity = structuredAgentSessionStopNoteIdentity(stoppedTurnId ?? input.clientOperationId) const stop = { answer, - ...(input.stopEventAt !== undefined ? { eventAt: input.stopEventAt } : {}) + ...(input.stopEventId !== undefined ? { eventId: input.stopEventId } : {}) } const previous = readAgentJournalStopAnswer( ctx.journal.itemBody(agentJournalItemKey(identity)) ?? undefined 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 fc771000741d..756788c354a5 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 @@ -354,7 +354,7 @@ describe('/clear starts nothing', () => { 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' }, + { id: 'stop-host', reason: 'host-stop' }, store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence ) const commit = store.commitConversationClear diff --git a/src/shared/agent-session-journal-fact-schemas.ts b/src/shared/agent-session-journal-fact-schemas.ts index daa2cae53135..dd882b6443a3 100644 --- a/src/shared/agent-session-journal-fact-schemas.ts +++ b/src/shared/agent-session-journal-fact-schemas.ts @@ -35,5 +35,5 @@ export const AgentJournalFailureFactSchema = z.object({ * `readAgentJournalStopAnswer` reads an unknown one as no answer. */ export const AgentJournalStopNoteAnswerSchema = z.object({ answer: z.string().min(1), - eventAt: z.number().finite().optional() + eventId: z.string().min(1).optional() }) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index c326b87ebcdd..340100637f57 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -285,9 +285,9 @@ export type AgentJournalStopAnswer = (typeof AGENT_JOURNAL_STOP_ANSWERS)[number] /** A Stop's answer, on the note it writes. */ export type AgentJournalStopNoteAnswer = { answer: AgentJournalStopAnswer - /** The Stop event this answers, by its `at`, which a rewind's restatement keeps: the event the - * Stop wrote, or the one in force it repeated. Absent: it answers no recorded Stop. */ - eventAt?: number + /** The id of the Stop event this answers: the event the Stop wrote, or the one in force it + * repeated. Absent: it answers no recorded Stop. */ + eventId?: string } type AgentJournalStatusItemFields = { diff --git a/src/shared/agent-session-stop-answer.test.ts b/src/shared/agent-session-stop-answer.test.ts index a7f72c80af6f..73dbb9063064 100644 --- a/src/shared/agent-session-stop-answer.test.ts +++ b/src/shared/agent-session-stop-answer.test.ts @@ -4,8 +4,11 @@ import type { AgentJournalItemBody } from './agent-session-journal-types' import { agentJournalStopAnswerReplaces, agentJournalStopAnswerTook, - readAgentJournalStopAnswer + isStructuredAgentSessionStopNote, + readAgentJournalStopAnswer, + structuredAgentSessionStopNoteIdentity } from './agent-session-stop-answer' +import { agentJournalItemKey } from './agent-session-journal-item-key' /** A Stop's note as a host of some version wrote it. */ function note(stop: unknown): AgentJournalItemBody { @@ -15,29 +18,58 @@ function note(stop: unknown): AgentJournalItemBody { describe('readAgentJournalStopAnswer', () => { it('reads the answer and the event it answers', () => { - expect(readAgentJournalStopAnswer(note({ answer: 'declined', eventAt: 5 }))).toEqual({ + expect(readAgentJournalStopAnswer(note({ answer: 'declined', eventId: 'stop-a' }))).toEqual({ answer: 'declined', - eventAt: 5 + eventId: 'stop-a' }) expect(readAgentJournalStopAnswer(note({ answer: 'took' }))).toEqual({ answer: 'took' }) }) + it('keeps a row whose event id is malformed out of the journal, as any malformed fact', () => { + expect(isAdmissibleAgentJournalItemBody(note({ answer: 'took', eventId: '' }))).toBe(false) + expect(isAdmissibleAgentJournalItemBody(note({ answer: 'took', eventId: 5 }))).toBe(false) + }) + it("reads an older host's note, a newer host's unknown answer, and any other row as no answer", () => { expect( readAgentJournalStopAnswer({ kind: 'status', text: 'Cancellation requested.' }) ).toBeUndefined() - expect(readAgentJournalStopAnswer(note({ answer: 'withdrawn', eventAt: 5 }))).toBeUndefined() + expect( + readAgentJournalStopAnswer(note({ answer: 'withdrawn', eventId: 'stop-a' })) + ).toBeUndefined() expect( readAgentJournalStopAnswer({ kind: 'message', role: 'user', blocks: [] }) ).toBeUndefined() }) it("keeps a newer host's unknown answer admissible, so the row is never dropped as malformed", () => { - expect(isAdmissibleAgentJournalItemBody(note({ answer: 'withdrawn', eventAt: 5 }))).toBe(true) + expect(isAdmissibleAgentJournalItemBody(note({ answer: 'withdrawn', eventId: 'stop-a' }))).toBe( + true + ) expect(isAdmissibleAgentJournalItemBody(note({ answer: '' }))).toBe(false) }) }) +describe("a Stop's note key", () => { + it('tells a Stop note from any other row', () => { + expect( + isStructuredAgentSessionStopNote( + agentJournalItemKey(structuredAgentSessionStopNoteIdentity('turn-1')) + ) + ).toBe(true) + expect( + isStructuredAgentSessionStopNote( + agentJournalItemKey({ provider: 'orca', clientMessageId: 'stop-event' }) + ) + ).toBe(false) + expect( + isStructuredAgentSessionStopNote( + agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'stop:1', ordinal: 0 }) + ) + ).toBe(false) + }) +}) + describe('agentJournalStopAnswerTook', () => { it('counts a taken interrupt and an owed end, and nothing else', () => { expect( @@ -52,13 +84,16 @@ describe('agentJournalStopAnswerReplaces', () => { it('never hides an answer that the same Stop took behind one that it did not', () => { for (const next of ['declined', 'interrupt-unconfirmed', 'no-effect'] as const) { expect( - agentJournalStopAnswerReplaces({ answer: 'took', eventAt: 5 }, { answer: next, eventAt: 5 }) + agentJournalStopAnswerReplaces( + { answer: 'took', eventId: 'stop-a' }, + { answer: next, eventId: 'stop-a' } + ) ).toBe(false) } expect( agentJournalStopAnswerReplaces( - { answer: 'end-owed', eventAt: 5 }, - { answer: 'no-effect', eventAt: 5 } + { answer: 'end-owed', eventId: 'stop-a' }, + { answer: 'no-effect', eventId: 'stop-a' } ) ).toBe(false) }) @@ -66,8 +101,8 @@ describe('agentJournalStopAnswerReplaces', () => { it('lets an owed end that failed revise its answer', () => { expect( agentJournalStopAnswerReplaces( - { answer: 'end-owed', eventAt: 5 }, - { answer: 'interrupt-unconfirmed', eventAt: 5 } + { answer: 'end-owed', eventId: 'stop-a' }, + { answer: 'interrupt-unconfirmed', eventId: 'stop-a' } ) ).toBe(true) }) @@ -75,14 +110,14 @@ describe('agentJournalStopAnswerReplaces', () => { it('lets a later Stop, a taken answer, or a first answer write', () => { expect( agentJournalStopAnswerReplaces( - { answer: 'took', eventAt: 5 }, - { answer: 'no-effect', eventAt: 9 } + { answer: 'took', eventId: 'stop-a' }, + { answer: 'no-effect', eventId: 'stop-b' } ) ).toBe(true) expect( agentJournalStopAnswerReplaces( - { answer: 'declined', eventAt: 5 }, - { answer: 'took', eventAt: 5 } + { answer: 'declined', eventId: 'stop-a' }, + { answer: 'took', eventId: 'stop-a' } ) ).toBe(true) expect(agentJournalStopAnswerReplaces(undefined, { answer: 'no-effect' })).toBe(true) diff --git a/src/shared/agent-session-stop-answer.ts b/src/shared/agent-session-stop-answer.ts index a817832dab4d..70ec4706c4ae 100644 --- a/src/shared/agent-session-stop-answer.ts +++ b/src/shared/agent-session-stop-answer.ts @@ -1,13 +1,28 @@ -// A Stop's answer as its note records it (`AgentJournalStopNoteAnswer`): read from a row, whether it -// says the Stop took effect, and which answer may replace another on the same note. +// A Stop's note: the key it is written under, the answer it records (`AgentJournalStopNoteAnswer`), +// whether that answer says the Stop took effect, and which answer may replace another on the note. +import { parseAgentJournalItemKey } from './agent-session-journal-item-key' import { AGENT_JOURNAL_STOP_ANSWERS, type AgentJournalItemBody, + type AgentJournalItemIdentity, type AgentJournalStopAnswer, type AgentJournalStopNoteAnswer } from './agent-session-journal-types' +const STOP_NOTE_PREFIX = 'stop:' + +/** A Stop's note, keyed by the turn it stopped (or, with no turn, by its operation). */ +export function structuredAgentSessionStopNoteIdentity(stopKey: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `${STOP_NOTE_PREFIX}${stopKey}` } +} + +/** Whether a journal row 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) +} + function isAgentJournalStopAnswer(value: unknown): value is AgentJournalStopAnswer { return AGENT_JOURNAL_STOP_ANSWERS.some((answer) => answer === value) } @@ -21,9 +36,9 @@ export function readAgentJournalStopAnswer( if (!stop || !isAgentJournalStopAnswer(stop.answer)) { return undefined } - const { eventAt } = stop - return typeof eventAt === 'number' && Number.isFinite(eventAt) - ? { answer: stop.answer, eventAt } + const { eventId } = stop + return typeof eventId === 'string' && eventId.length > 0 + ? { answer: stop.answer, eventId } : { answer: stop.answer } } @@ -49,7 +64,7 @@ export function agentJournalStopAnswerReplaces( ): boolean { if ( previous === undefined || - previous.eventAt !== next.eventAt || + previous.eventId !== next.eventId || !agentJournalStopAnswerTook(previous.answer) || agentJournalStopAnswerTook(next.answer) ) { diff --git a/tests/e2e/cross-version-wire/agent-session-stop-answer-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-answer-downgrade.unit.test.ts index d1276f822773..43fee3a93d64 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-answer-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-answer-downgrade.unit.test.ts @@ -17,7 +17,7 @@ import { // A Stop's answer rides its note as an optional key (Rule 1). Whatever the newest release knows of // it, that release's host must keep the note when it replays this build's journal, and its client -// must admit the note it is published and print its text. Both hold for every version, so the +// must fold the note it is published and print its text. Both hold for every version, so the // baseline rolls with each cut. const JOURNAL = 'src/main/native-chat/agent-session-journal' const NOTE_TEXT = 'Codex had no turn running to stop.' @@ -40,6 +40,8 @@ function releaseExport(module: Record, name: string): T { return value as T } +type OldClientState = { status: string; items: { itemId: string; body: { text?: string } }[] } + type OldReplay = { state: { items: Map } corrupt: boolean @@ -47,20 +49,20 @@ type OldReplay = { truncateFrom?: number } -test("an older build keeps a Stop's note carrying its answer, folds the rows after it, and publishes it to a client that admits it", async () => { +test("an older build keeps a Stop's note carrying its answer, folds the rows after it, and publishes it to a client that folds it", async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-stop-answer-downgrade-')) const journals = createTrackedJournalOpener() try { // This build: a Stop's event, its note with the answer, then more history. const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - await journal.appendStopEvent({ reason: 'user-stop', caller: 'client-1' }, 1) + const eventId = 'stop-event-1' + await journal.appendStopEvent({ id: eventId, reason: 'user-stop', caller: 'client-1' }, 1) const before = journal.cursor() - const eventAt = journal.stopMarks.latest()!.event.at const note = { provider: 'orca' as const, clientMessageId: 'stop:operation-1' } await journal.appendItem( note, - { kind: 'status', text: NOTE_TEXT, stop: { answer: 'no-effect', eventAt } }, + { kind: 'status', text: NOTE_TEXT, stop: { answer: 'no-effect', eventId } }, scope ) await journal.appendItem( @@ -74,16 +76,23 @@ test("an older build keeps a Stop's note carrying its answer, folds the rows aft } const rows: JournalRow[] = since.rows const noteId = agentJournalItemKey(note) + // The rows under test carry what this build writes, or the test proves nothing. + expect(rows.find((row) => row.kind === 'item' && row.itemId === noteId)).toMatchObject({ + body: { stop: { answer: 'no-effect', eventId } } + }) + expect(rows.find((row) => row.kind === 'tombstone' && row.stopEvent)).toMatchObject({ + stopEvent: { id: eventId } + }) const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef()) - const [database, table, open, reducer, batch, schemas] = await Promise.all( + const [database, table, open, reducer, batch, client] = 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', - 'src/shared/agent-session-journal-schemas.ts' + 'src/shared/structured-agent-session-reducer.ts' ].map((path) => importReleaseCheckoutModule(checkout, path)) ) const openJournalDatabase = releaseExport<(path: string) => { db: { close: () => void } }>( @@ -108,13 +117,14 @@ test("an older build keeps a Stop's note carrying its answer, folds the rows aft const projectJournalBatch = releaseExport< (input: { rows: readonly JournalRow[]; snapshot: unknown; afterSequence: number }) => { ok: boolean - batch?: { items: { itemId: string; body: { text?: string } }[] } + batch?: { cursor: unknown; items: unknown[] } } >(batch, 'projectJournalBatch') - const isAdmissibleAgentJournalRenderItem = releaseExport<(value: unknown) => boolean>( - schemas, - 'isAdmissibleAgentJournalRenderItem' - ) + const reduceStructuredAgentSession = releaseExport< + (state: unknown, action: unknown, receivedAt?: number) => OldClientState + >(client, 'reduceStructuredAgentSession') + const emptyClientState = client.EMPTY_STRUCTURED_AGENT_SESSION + expect(emptyClientState).toMatchObject({ status: 'idle', items: [] }) const { db } = openJournalDatabase(join(directory, 'older-build-journal.sqlite')) try { @@ -129,18 +139,40 @@ test("an older build keeps a Stop's note carrying its answer, folds the rows aft expect(replayed?.state.items.get(noteId)?.body.text).toBe(NOTE_TEXT) expect(replayed?.state.items.size).toBe(2) - // Older client against a host publishing the note: it admits the item and reads its text. + // Older client against a host publishing the note: its reducer folds the batch from the + // Stop on, and the note shows its text. const projected = projectJournalBatch({ rows: rows.filter((row) => row.seq > before.sequence), snapshot: renderJournalState(replayed?.state), afterSequence: before.sequence }) expect(projected.ok).toBe(true) - const published = projected.batch?.items.find((item) => item.itemId === noteId) - expect(published?.body.text).toBe(NOTE_TEXT) - expect(isAdmissibleAgentJournalRenderItem(published)).toBe(true) - const current = journal.snapshot().items.find((item) => item.itemId === noteId) - expect(isAdmissibleAgentJournalRenderItem(current)).toBe(true) + const attached = reduceStructuredAgentSession(emptyClientState, { + type: 'event', + event: { + type: 'snapshot', + sessionId: IDENTITY.sessionId, + fence: 1, + page: { + sessionId: IDENTITY.sessionId, + epoch: journal.epoch, + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: before }, + liveCursor: before, + hasOlder: false, + hasNewer: false + } + } + }) + const folded = reduceStructuredAgentSession(attached, { + type: 'event', + event: { type: 'batch', sessionId: IDENTITY.sessionId, batch: projected.batch, fence: 1 } + }) + expect(folded.status).toBe('ready') + expect(folded.items.map((item) => item.body.text)).toEqual([NOTE_TEXT, 'after the Stop']) } finally { db.close() } 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 20b64c7f6dbb..beabaefc4bcb 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 @@ -17,7 +17,8 @@ import type { JournalRow } from '../../../src/main/native-chat/agent-session-jou 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. +// make it delete the journal from that row on, so both ride a tombstone it already reads, and any +// key the event carries, its id included, is one it never looks at. 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 @@ -68,8 +69,11 @@ 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) - await journal.appendStopEvent({ reason: 'user-close', turnId: 'turn-1' }, 1) + await journal.appendStopEvent( + { id: 'stop-1', reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, + 1 + ) + await journal.appendStopEvent({ id: 'stop-2', reason: 'user-close', turnId: 'turn-1' }, 1) await journal.appendQueueResume(1) const afterMarks = journal.cursor() await append(1, 'after the Stop') @@ -78,6 +82,10 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol throw new Error(`expected rows, got reset ${since.reset}`) } const rows: JournalRow[] = since.rows + // The events under test carry what this build writes, or the test proves nothing. + expect( + rows.flatMap((row) => (row.kind === 'tombstone' && row.stopEvent ? [row.stopEvent.id] : [])) + ).toEqual(['stop-1', 'stop-2']) // The older build, after a downgrade, replays the same rows from its own database. const checkout = await materializeReleaseCheckout(BASELINE_REF) @@ -182,7 +190,10 @@ test("an older build opens this build's journal writable and appends to it; the 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.appendStopEvent( + { id: 'stop-1', 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([ @@ -210,11 +221,13 @@ test("an older build opens this build's journal writable and appends to it; the 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. + // Upgraded again: the older build's row folds, and the person's Stop, still the same event, + // 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.stopMarks.latest()?.event.id).toBe('stop-1') expect(upgraded.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ 'stopped' ]) From 2fc82c0a66ef1f0d5cf1b56e42b13e551307643c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:58:25 -0700 Subject: [PATCH 5/9] ci(cross-version): run the Stop-answer downgrade journey --- .github/workflows/pr.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9edeeece8f3a..8984ca6434ab 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -874,6 +874,7 @@ jobs: 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/agent-session-stop-answer-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts managed_hook_node18: From b791285afeffbb176a98231baab236ce780d681a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:03:37 -0700 Subject: [PATCH 6/9] test(native-chat): the Codex Stop that finds no turn opening says so whenever it waits --- .../structured-agent-session-codex-stop-row.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 e3fdc988e379..dd2b38b1ecb3 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 @@ -358,13 +358,12 @@ describe('a Codex Stop whose interrupt failed', () => { } await vi.waitFor(() => expect(turns.turnId).toBe('turn-1')) - const stopping = stop() - // Codex goes idle without opening the turn it answered the send into. - setTimeout( + // Codex goes idle without opening the turn it answered the send into, whenever the Stop waits. + const idle = setInterval( () => notify('thread/status/changed', { threadId: THREAD, status: { type: 'idle' } }), - 20 + 10 ) - const stopped = await stopping + const stopped = await stop().finally(() => clearInterval(idle)) await host.flushStreamedEvents(SESSION) expect(stopped).toMatchObject({ ok: true, value: { cancelled: false } }) From f3087fb1856cc2125b9d977fc6e6145eaa08c1bf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:22:49 -0700 Subject: [PATCH 7/9] fix(native-chat): a later press of the same Stop never revises an owed end The rewrite rule let `interrupt-unconfirmed` replace `end-owed` for the same Stop, meant for a writer that reports the owed process end failing. No such writer exists yet, and performCancel itself could take it: a repeat press of a Claude Stop that finds the agent's adapter closed throws its interrupt and no longer owes an end, so it overwrote the owed end and the turn lost its "Interrupted" label. A press now never replaces an answer that the same Stop took. Also pins the restart's red "the agent stopped" row: written after a Stop that did not take, muted after one that took. --- ...session-queued-message-rig.test-fixture.ts | 11 +++-- ...ructured-agent-session-stop-answer.test.ts | 45 +++++++++++++++++-- src/shared/agent-session-stop-answer.test.ts | 4 +- src/shared/agent-session-stop-answer.ts | 11 ++--- 4 files changed, 56 insertions(+), 15 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 54cfbb470128..cbaa319a1f2f 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 @@ -44,8 +44,8 @@ export async function createQueuedMessageTestRig( starting?: true /** Lets a test sweep idle chats on its own `tick`. */ idleSweep?: { idleMs: number; intervalMs: number } - /** The provider's Stop ends its child, as Claude's does. */ - stopEndsSession?: true + /** The provider's Stop ends its child, as Claude's does; a function answers per Stop. */ + stopEndsSession?: true | (() => boolean) } = {} ) { const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) @@ -105,7 +105,12 @@ export async function createQueuedMessageTestRig( releaseAcquisition: vi.fn(async () => true), compact, cancelTurn, - ...(options.stopEndsSession ? { stopEndsSession: () => true } : {}), + ...(options.stopEndsSession + ? { + stopEndsSession: + options.stopEndsSession === true ? () => true : options.stopEndsSession + } + : {}), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts index d977eb401bdb..855144a8925d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts @@ -79,6 +79,19 @@ async function turnOpenedBy( ) } +/** The red row a settle writes when the agent stopped on its own. */ +const AGENT_STOPPED = + 'The agent stopped while this response was in progress. You can continue in this conversation.' + +/** Every red row's text. */ +function errorRows(): string[] { + return journal() + .snapshot() + .items.flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) +} + function turn() { return journal() .snapshot() @@ -143,13 +156,14 @@ describe('a Stop pressed before its send opened a turn, which interrupted nothin expect(turn()).not.toHaveProperty('outcome') }) - it('reads that turn as news when a restart settles it', async () => { + it('reads that turn as news when a restart settles it, with the red row saying the agent stopped', async () => { await stoppedBeforeTheTurn(NOTHING_TO_INTERRUPT) await restartAndSettle() expect(turn()).toMatchObject({ state: 'interrupted' }) expect(turn()).not.toHaveProperty('outcome') + expect(errorRows()).toEqual([AGENT_STOPPED]) }) it('reads the same turn as the cancellation once a second press of the same Stop took', async () => { @@ -235,7 +249,9 @@ describe("an eviction after a person's Stop that named no turn", () => { }) describe('a Stop of a running turn', () => { - async function runningTurn(options: { stopEndsSession?: true } = {}): Promise { + async function runningTurn( + options: { stopEndsSession?: true | (() => boolean) } = {} + ): Promise { rig = await createQueuedMessageTestRig(options) await rig.workingSend() await journal().appendItem( @@ -255,6 +271,8 @@ describe('a Stop of a running turn', () => { { answer: 'took', eventId: latestEventId(), text: 'Cancellation requested.' } ]) expect(turn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + // A person's cancellation is muted: no row says the agent stopped on its own. + expect(errorRows()).toEqual([]) }) it('answers end-owed when the Stop ends the provider process next, whatever it answered', async () => { @@ -268,7 +286,27 @@ describe('a Stop of a running turn', () => { ]) }) - it('answers declined when the interrupt failed and the child could not be ended, and a crash reads as news', async () => { + it('keeps an owed end when the same Stop, pressed again after its agent stopped taking Stops, cannot reach it', async () => { + let endsSession = true + await runningTurn({ stopEndsSession: () => endsSession }) + // Neither the owed end nor the second press's kill is proven, so the chat keeps its child. + rig.closeSession.mockResolvedValueOnce(false).mockResolvedValueOnce(false) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + // As when the agent's adapter has closed: its Stop ends nothing more, and its interrupt throws. + endsSession = false + rig.cancelTurn.mockRejectedValueOnce(new Error('no live structured adapter owns session-alpha')) + + await rig.stop() + + expect(stopEventCount()).toBe(1) + expect(answers()).toEqual([ + { answer: 'end-owed', eventId: latestEventId(), text: 'Cancellation requested.' } + ]) + await restartAndSettle() + expect(turn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + it('answers declined when the interrupt failed and the child could not be ended, and a crash reads as news with its red row', async () => { await runningTurn() rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, @@ -285,6 +323,7 @@ describe('a Stop of a running turn', () => { expect(turn()).toMatchObject({ state: 'interrupted' }) expect(turn()).not.toHaveProperty('outcome') + expect(errorRows()).toEqual([AGENT_STOPPED]) }) it('keeps the answer that it took when the same Stop, pressed again, finds nothing to interrupt', async () => { diff --git a/src/shared/agent-session-stop-answer.test.ts b/src/shared/agent-session-stop-answer.test.ts index 73dbb9063064..ad2adbbd3770 100644 --- a/src/shared/agent-session-stop-answer.test.ts +++ b/src/shared/agent-session-stop-answer.test.ts @@ -98,13 +98,13 @@ describe('agentJournalStopAnswerReplaces', () => { ).toBe(false) }) - it('lets an owed end that failed revise its answer', () => { + it('never lets a later press revise an owed end, whatever it met', () => { expect( agentJournalStopAnswerReplaces( { answer: 'end-owed', eventId: 'stop-a' }, { answer: 'interrupt-unconfirmed', eventId: 'stop-a' } ) - ).toBe(true) + ).toBe(false) }) it('lets a later Stop, a taken answer, or a first answer write', () => { diff --git a/src/shared/agent-session-stop-answer.ts b/src/shared/agent-session-stop-answer.ts index 70ec4706c4ae..21600d5091ba 100644 --- a/src/shared/agent-session-stop-answer.ts +++ b/src/shared/agent-session-stop-answer.ts @@ -56,19 +56,16 @@ export function agentJournalStopAnswerTook(answer: AgentJournalStopAnswer): bool } } -/** Whether `next` may replace `previous` on one note. A later answer that the Stop did not take - * never hides one that says the same Stop did; only the failure of an owed end revises that. */ +/** Whether a later press's answer `next` may replace `previous` on one note: never when the same + * Stop already took, so a press that reaches nothing never hides one that did, nor an end owed. */ export function agentJournalStopAnswerReplaces( previous: AgentJournalStopNoteAnswer | undefined, next: AgentJournalStopNoteAnswer ): boolean { - if ( + return ( previous === undefined || previous.eventId !== next.eventId || !agentJournalStopAnswerTook(previous.answer) || agentJournalStopAnswerTook(next.answer) - ) { - return true - } - return previous.answer === 'end-owed' && next.answer === 'interrupt-unconfirmed' + ) } From 9d85e94a2391380971c305fdfaa0af4f4a737529 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:22:50 -0700 Subject: [PATCH 8/9] test(native-chat): the unexpected-exit fake journal answers both Stop questions --- .../structured-agent-session-unexpected-exit.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 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 4f4e414657de..b33f3d8b9c26 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 @@ -99,7 +99,11 @@ describe('provider-exit settlement', () => { items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), itemFence: () => 7, - stopMarks: { latest: () => null, personStopDecides: () => false }, + stopMarks: { + latest: () => null, + personStopCovers: () => false, + personStopDecides: () => false + }, appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) } From 784b5942a47022f3989f9f94f70d79018c69f2ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:38:19 -0700 Subject: [PATCH 9/9] fix(native-chat): a Stop whose interrupt the provider took answers took, not end-owed A Stop that ends the provider's process next answered `end-owed` whatever the interrupt did. When the provider took the interrupt the turn already stopped and the process end is cleanup, so it now answers `took`. `end-owed` is left for a Stop whose interrupt was refused or went unanswered, whose effect really is the owed end: the one answer a later report of that end failing may revise. --- ...t-session-claude-stop-ends-session.test.ts | 5 +- ...ructured-agent-session-stop-answer.test.ts | 74 +++++++++++++------ .../structured-agent-session-turns-cancel.ts | 3 +- src/shared/agent-session-journal-types.ts | 6 +- 4 files changed, 62 insertions(+), 26 deletions(-) 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 d03d42aa8479..094ebe6e0f29 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 @@ -297,8 +297,8 @@ it('answers on the interrupt, ends the child once the stopped turn ends, and res // The Stop answered on the interrupt Claude took: the child waits for Claude to end the turn. expect(connection.closed).toBe(false) expect(await statusTexts()).toEqual(['Cancellation requested.']) - // The child's end is still owed to the Stop's next step. - expect(await stopAnswers()).toEqual([{ answer: 'end-owed', answersLatestStop: true }]) + // Claude took the interrupt; the child's end, still owed to the Stop's next step, is cleanup. + expect(await stopAnswers()).toEqual([{ answer: 'took', answersLatestStop: true }]) const ended = Date.now() frame(connection, INTERRUPTED_RESULT) await laneDrained() @@ -436,6 +436,7 @@ it('ends the child at once when Claude refuses the interrupt, and says only that const texts = await statusTexts() expect(texts).toContain('Cancellation requested.') expect(texts.some((text) => text.includes("didn't stop"))).toBe(false) + // Only the child's end stops the turn. expect(await stopAnswers()).toEqual([{ answer: 'end-owed', answersLatestStop: true }]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts index 855144a8925d..f586a373cf78 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts @@ -275,37 +275,69 @@ describe('a Stop of a running turn', () => { expect(errorRows()).toEqual([]) }) - it('answers end-owed when the Stop ends the provider process next, whatever it answered', async () => { - await runningTurn({ stopEndsSession: true }) - rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: {} }) + // The process end is owed either way; only an interrupt the provider did not take leaves the + // Stop's effect to that end. + it.each([ + ['took when the provider took the interrupt', { cancelled: true }, 'took'], + [ + 'end-owed when the provider refused the interrupt', + { cancelled: false, refusal: {} }, + 'end-owed' + ] + ] as const)( + 'answers a Stop that ends the provider process next %s', + async (_case, outcome, answer) => { + await runningTurn({ stopEndsSession: true }) + rig.cancelTurn.mockResolvedValueOnce(outcome) - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(answers()).toEqual([ - { answer: 'end-owed', eventId: latestEventId(), text: 'Cancellation requested.' } - ]) - }) + expect(answers()).toEqual([ + { answer, eventId: latestEventId(), text: 'Cancellation requested.' } + ]) + } + ) - it('keeps an owed end when the same Stop, pressed again after its agent stopped taking Stops, cannot reach it', async () => { - let endsSession = true - await runningTurn({ stopEndsSession: () => endsSession }) - // Neither the owed end nor the second press's kill is proven, so the chat keeps its child. - rig.closeSession.mockResolvedValueOnce(false).mockResolvedValueOnce(false) - expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - // As when the agent's adapter has closed: its Stop ends nothing more, and its interrupt throws. - endsSession = false - rig.cancelTurn.mockRejectedValueOnce(new Error('no live structured adapter owns session-alpha')) + it('answers end-owed when the Stop ends the provider process next and its interrupt went unanswered', async () => { + await runningTurn({ stopEndsSession: true }) + rig.cancelTurn.mockRejectedValueOnce(new Error('interrupt timed out')) - await rig.stop() + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(stopEventCount()).toBe(1) expect(answers()).toEqual([ { answer: 'end-owed', eventId: latestEventId(), text: 'Cancellation requested.' } ]) - await restartAndSettle() - expect(turn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) }) + it.each([ + ['took the interrupt', { cancelled: true }, 'took'], + ['refused the interrupt', { cancelled: false, refusal: {} }, 'end-owed'] + ] as const)( + 'keeps its answer when the same Stop, which %s, is pressed again after its agent stopped taking Stops', + async (_case, outcome, answer) => { + let endsSession = true + await runningTurn({ stopEndsSession: () => endsSession }) + // Neither the owed end nor the second press's kill is proven, so the chat keeps its child. + rig.closeSession.mockResolvedValueOnce(false).mockResolvedValueOnce(false) + rig.cancelTurn.mockResolvedValueOnce(outcome) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + // As when the agent's adapter has closed: its Stop ends nothing more, and its interrupt throws. + endsSession = false + rig.cancelTurn.mockRejectedValueOnce( + new Error('no live structured adapter owns session-alpha') + ) + + await rig.stop() + + expect(stopEventCount()).toBe(1) + expect(answers()).toEqual([ + { answer, eventId: latestEventId(), text: 'Cancellation requested.' } + ]) + await restartAndSettle() + expect(turn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + } + ) + it('answers declined when the interrupt failed and the child could not be ended, and a crash reads as news with its red row', async () => { await runningTurn() rig.cancelTurn.mockResolvedValueOnce({ 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 67a5eec9d8e3..8ee047a69467 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 @@ -240,7 +240,8 @@ export async function performCancel( // The child's end confirms the Stop, so a refused or unconfirmed interrupt says nothing more. if (note !== null) { note = { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } - answer = 'end-owed' + // A taken interrupt stopped the turn, and the end is cleanup; else only the owed end stops it. + answer = taken === true ? 'took' : 'end-owed' } } else if (runningCommand && !cancelled) { await input.stopChild?.() diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 340100637f57..3deabfc1fe51 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -268,8 +268,10 @@ export type AgentJournalThreadGoalState = /** What a Stop did, as its note says. Persisted and published: never rename an arm. Open like the * other persisted vocabularies: an arm a newer host writes must not turn the row malformed, and a * reader treats one it does not know as no answer (`readAgentJournalStopAnswer`). - * - `took`: the provider took the interrupt, or the host ended the child running the turn. - * - `end-owed`: the Stop ends the provider's process in its next step, which still owes it. + * - `took`: the provider took the interrupt (an end of its process still owed is cleanup), or the + * host ended the child running the turn. + * - `end-owed`: the provider did not take the interrupt, and the Stop's effect is the end of the + * provider's process that its next step still owes. * - `declined`: the provider could not interrupt the turn, and the turn may run on. * - `interrupt-unconfirmed`: the interrupt failed with no answer, and nothing ended the child. * - `no-effect`: the provider had nothing running for it to stop. */