diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index b7ce4fa16dd..585c5ebc43c 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -112,7 +112,10 @@ its start refused; a send that was withdrawn, or left undelivered by a restart or a close, fails nobody and makes nothing listable. `summary.turnOutcome` is the latest request's verdict: its turn's outcome, or `failure` for a send the agent or its start refused (a send that joined a running -turn is answered by that turn). The row also publishes `interrupted` from +turn is answered by that turn). A turn the provider gave no outcome reads as its +host-observed end through `agentTurnVerdict`: `interruption` for an `interrupted` +lifecycle, `unconfirmed` for an `unverifiable` one. It is derived on each read, +never journaled. The row also publishes `interrupted` from `mainAgent.outcome`, exactly as the hook lanes do. When the host revokes live ownership the row is re-set without the flag; when the host closes or evicts the session the row is dropped. Both already exist as feed events (`revokeLive` and the roster @@ -202,7 +205,7 @@ Claude, Codex and Grok hook rows and structured-session rows publish the combine rows and terminal-title-only rows carry none, and readers fall back to `state`: ```ts -mainAgent?: { state: AgentStatusState; outcome?: AgentJournalTurnOutcome; stateStartedAt: number } +mainAgent?: { state: AgentStatusState; outcome?: AgentTurnOutcome; stateStartedAt: number } ``` `state` still answers "what should the user see" and folds live child work in, @@ -215,8 +218,12 @@ works (including a child's permission wait) refuses OSC, which carries no child identity; the children's own lifecycle hooks settle it. `outcome` is the recorded verdict on the main agent's most recent finished turn, present only while `mainAgent.state` is `done`. It is reported by the provider, or is a `cancellation` Orca inferred -from the user's own interrupt keystroke (the journal's turn outcome, by -contrast, is never inferred). A plain end of turn carries none, because absent +from the user's own interrupt keystroke, or a `superseded` the host recorded when +a newer request replaced a structured Claude turn before it ended (it names no +sender, and sets no legacy flag), or, on a structured row whose turn the +provider gave no verdict, is what the host observed of its end: `interruption` +(a proven death nobody asked for) or `unconfirmed` (an end it cannot prove, +never success). The journal's turn outcome, by contrast, stores only recorded verdicts: the provider's, a `cancellation`, or the host's `superseded`; `interruption` and `unconfirmed` are derived from the turn's lifecycle state and never stored. A plain end of turn carries none, because absent means unknown and a provider that omits its interrupt flag must not turn a cancel into a success. In the Claude hook lane the cancellation comes primarily from Orca's own @@ -235,21 +242,31 @@ the verdict through `agentVerdictFields`, which carries `interrupted` and the whole `mainAgent` (state, outcome and its own clock) together, so a copy agrees with the row and can date a failure by `mainAgent.stateStartedAt`. -Display reads the verdict through `agentVerdictDisplayMark`: a failure marks the +Display reads the verdict through `agentVerdictDisplayMark`. A fault marks the agent failed whatever the combined state, because it is news the user must see -even while subagents run; a stop marks it interrupted only on a `done` row, so -a stopped or finished main agent with live child work still reads working. +even while subagents run: a `failure`, and an `interruption`, a turn cut short +by anything other than the user or a newer request. A user's stop (`cancellation`) +marks it interrupted, drawn in the muted tone with the row text "Interrupted by user"; +a turn a newer request replaced (`superseded`) marks it interrupted in the same muted +tone with the row text "Interrupted"; and `unconfirmed` marks it unconfirmed, all only +on a `done` row, so a stopped +or finished main agent with live child work still reads working. The folded +turn header follows the same mark: "Failed after N", "Interrupted after N", or +"Worked for N". Each subagent keeps its own row and state. Container rollups (worktree card, terminal tab, Cmd+J) rank a pending question first, then a failure, then live -work, then a stop, then done. On the worktree card, a failure retained after its -agent's pane went away has no expiry, so it ranks below live work and above a -stop. Lifecycle waiters keep reading the combined `state`. +work, then an unconfirmed end, then a user's stop, then done. On the worktree +card, a failure retained after its agent's pane went away has no expiry, so it +ranks below live work and above an unconfirmed end. Lifecycle waiters keep +reading the combined `state`. Policy splits the verdict two ways. Clean-finish policy (hibernation, pane -ownership, the star-nag value moment) treats a failure like a cancellation -(`agentTurnEndedUncleanly`). Attention (completion time, Smart Sort, sticky -retention, Cmd+J Recent) demotes only a turn the user stopped -(`agentTurnStoppedByUser`); a failure ranks like a completion. +ownership, the star-nag value moment) treats a failure, an interruption and an +unconfirmed end like a cancellation (`agentTurnEndedUncleanly`). Attention +(completion time, Smart Sort, sticky retention, Cmd+J Recent) demotes only a +turn ended on purpose, the user's stop or a newer request that replaced it +(`agentTurnEndedOnPurpose`); a failure, an interruption or +an unconfirmed end ranks like a completion. Admission is one function, `normalizeAgentStatusPayload`, on the relay wire, IPC and disk. A malformed `mainAgent` drops the field and keeps the row. Old hosts diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 80ef9d9d239..6912f6c1b2a 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -2,18 +2,21 @@ import { useEffect, useRef } from 'react' import { Activity } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' import type { AgentDotState } from '../worktree/agent-row-display' +import { colors } from '../theme/mobile-theme' // Per-agent state indicator, 1:1 with desktop AgentStateDot // (src/renderer/src/components/AgentStateDot.tsx): yellow spinner for 'working', -// emerald for 'done', red for blocked/waiting/interrupted/failed (attention), neutral -// for idle. Distinct from the worktree-level AgentSpinner, which collapses the -// agent vocabulary into the 5-state rollup the sidebar dot uses. +// emerald for 'done', red for blocked/waiting/failed (attention), muted for a user's Stop +// ('interrupted'), amber for 'unconfirmed' (desktop's missing-evidence tone), neutral for idle. Distinct from the +// worktree-level AgentSpinner, which collapses the agent vocabulary into the 5-state +// rollup the sidebar dot uses. const DOT_COLORS: Record, string> = { done: '#10b981', blocked: '#ef4444', waiting: '#ef4444', - interrupted: '#ef4444', + interrupted: colors.textMuted, failed: '#ef4444', + unconfirmed: colors.statusAmber, idle: 'rgba(115,115,115,0.4)' } const WORKING_COLOR = '#eab308' diff --git a/mobile/src/components/agent-monitoring-indicators.test.ts b/mobile/src/components/agent-monitoring-indicators.test.ts index eefa2137f14..16c453b27cf 100644 --- a/mobile/src/components/agent-monitoring-indicators.test.ts +++ b/mobile/src/components/agent-monitoring-indicators.test.ts @@ -3,12 +3,14 @@ import { act, create } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentSpinner } from './AgentSpinner' import { AgentStateDot } from './AgentStateDot' +import { colors } from '../theme/mobile-theme' const DESKTOP_WORKING_COLOR = '#eab308' type MonitoringTestRenderer = { readonly root: { findByType(type: string): { props: Record } + findAllByType(type: string): { props: Record }[] } unmount(): void } @@ -84,4 +86,17 @@ describe('mobile monitoring indicators', () => { expect(animationTiming).toHaveBeenCalledOnce() expect(animationLoop).toHaveBeenCalledOnce() }) + + it.each([ + // A user's Stop is not news: muted, never the fault red a failure draws. + ['interrupted', colors.textMuted], + ['failed', '#ef4444'] + ] as const)('draws %s with its own dot colour', async (state, color) => { + await act(async () => { + renderer = create(createElement(AgentStateDot, { state })) + }) + + const dot = renderer?.root.findAllByType('View').find((view) => Array.isArray(view.props.style)) + expect(dot?.props.style).toContainEqual({ backgroundColor: color }) + }) }) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index bb9d962107a..9c894e3cd7f 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -123,6 +123,7 @@ function MobileNativeChatMessageImpl({ onToggleTurn(turnKey) : undefined} /> diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index e99412b42bf..d2069a134b2 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -47,6 +47,7 @@ describe('MobileNativeChatTurnStatus', () => { function render(props: { startedAt: number | null workedSeconds?: number | null + verdict?: 'interruption' | 'cancellation' expanded?: boolean onToggleExpanded?: () => void }): ReactTestRenderer { @@ -77,6 +78,24 @@ describe('MobileNativeChatTurnStatus', () => { expect(onToggleExpanded).toHaveBeenCalledOnce() }) + it('heads a turn a crash cut off as failed, and a turn the user stopped as interrupted', () => { + const crashed = render({ + startedAt: Date.now(), + workedSeconds: 12, + verdict: 'interruption', + onToggleExpanded: vi.fn() + }) + expect(labels(crashed.root)).toEqual(['Failed after 12s']) + act(() => crashed.unmount()) + const stopped = render({ + startedAt: Date.now(), + workedSeconds: 12, + verdict: 'cancellation', + onToggleExpanded: vi.fn() + }) + expect(labels(stopped.root)).toEqual(['Interrupted after 12s']) + }) + it('stays a plain row when the settled turn has nothing to disclose', () => { const tree = render({ startedAt: Date.now(), workedSeconds: 5 }) expect(tree.root.findAllByType('Pressable' as never)).toHaveLength(0) diff --git a/mobile/src/session/MobileNativeChatTurnStatus.tsx b/mobile/src/session/MobileNativeChatTurnStatus.tsx index 25003685240..a39e404c639 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.tsx +++ b/mobile/src/session/MobileNativeChatTurnStatus.tsx @@ -7,6 +7,7 @@ import { NATIVE_CHAT_TURN_STATUS_COPY, nativeChatElapsedSeconds } from '../../../src/shared/native-chat-turn-status' +import type { AgentTurnOutcome } from '../../../src/shared/agent-turn-outcome' import { colors, spacing, typography } from '../theme/mobile-theme' /** Seconds tick only while a turn is actually counting, so a settled transcript @@ -27,22 +28,26 @@ function useElapsedSeconds(startedAt: number | null, counting: boolean): number } /** The turn bar under the user's message: "Working for 12s" while the turn runs, - * settling in place to a tappable "Worked for 3m 4s" that discloses the turn's - * tool activity. Desktop parity: `NativeChatWorkingStatus`. */ + * settling in place to a tappable "Worked for 3m 4s" ("Interrupted after" for a Stop, + * "Failed after" for a fault) that discloses the turn's tool activity. Desktop parity: + * `NativeChatWorkingStatus`. */ export function MobileNativeChatTurnStatus({ startedAt, workedSeconds, + verdict, expanded = false, onToggleExpanded }: { startedAt: number | null workedSeconds?: number | null + /** How a settled turn ended; it picks the settled label. */ + verdict?: AgentTurnOutcome expanded?: boolean onToggleExpanded?: () => void }): React.JSX.Element { const settled = workedSeconds != null const elapsedSeconds = useElapsedSeconds(startedAt, !settled) - const label = formatNativeChatTurnStatusLabel({ workedSeconds, elapsedSeconds }) + const label = formatNativeChatTurnStatusLabel({ workedSeconds, elapsedSeconds, verdict }) if (settled && onToggleExpanded) { return ( diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx b/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx index bc14028c872..9795c008540 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx @@ -115,7 +115,7 @@ describe('useMobileStructuredAgentTurnTiming', () => { expect(timing?.workingStartedAt).toBe(CLIENT_NOW - 2_500) // The row's provider key resolves through the submission alias, not journal order. expect([...timing!.settledTurns]).toEqual([ - ['orca:first', { startedAt: HOST_START, workedSeconds: 61 }], + ['orca:first', { startedAt: HOST_START, workedSeconds: 61, verdict: 'interruption' }], ['u2', null] ]) diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index 47898017ff8..c3fa8c84b6d 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -4,7 +4,7 @@ import { agentMainAgentVerdict, agentVerdictDisplayMark } from '../../../src/shared/agent-main-agent-verdict' -import { AGENT_JOURNAL_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome' +import { AGENT_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome' import { AGENT_STATUS_STALE_AFTER_MS, agentDisplayLabel, @@ -16,7 +16,7 @@ import { formatTimeAgo } from './agent-row-display' -type Outcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number] +type Outcome = (typeof AGENT_TURN_OUTCOMES)[number] const mainAgentDone = (outcome: Outcome, stateStartedAt = 0) => ({ mainAgent: { state: 'done' as const, outcome, stateStartedAt } }) @@ -50,19 +50,48 @@ describe('agentDotState', () => { expect(agentDotState(row({ state: 'unknown-state' as never }), 0)).toBe('idle') }) - it('reports the verdict of a done row: failed, interrupted, or an old host legacy flag', () => { + it("reports the verdict of a done row: failed, or a user's Stop (also an old host's flag) as interrupted", () => { expect(agentDotState(row({ state: 'done', interrupted: true }), 0)).toBe('interrupted') expect(agentDotState(row({ state: 'done', ...mainAgentDone('failure') }), 0)).toBe('failed') expect( agentDotState(row({ state: 'done', ...mainAgentDone('cancellation'), interrupted: true }), 0) ).toBe('interrupted') expect(agentDotState(row({ state: 'done', ...mainAgentDone('success') }), 0)).toBe('done') + // A turn a newer request replaced reads as a Stop does. + expect(agentDotState(row({ state: 'done', ...mainAgentDone('superseded') }), 0)).toBe( + 'interrupted' + ) + }) + + it('reads a crash-cut turn as failed and an unproven end as unconfirmed', () => { + expect(agentDotState(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe( + 'failed' + ) + expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe( + 'Failed' + ) + expect(agentDotState(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe( + 'unconfirmed' + ) + expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe( + 'Couldn’t confirm' + ) + }) + + // Rows arrive unparsed, so an arm a newer host adds must read as the done it always did. + it('reads a done row carrying an outcome it cannot name as done', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an arm from a newer host, which the unparsed wire row can carry. + const future = mainAgentDone('from-a-newer-host' as Outcome) + expect(agentDotState(row({ state: 'done', ...future }), 0)).toBe('done') }) it('shows a main agent that failed while its subagents still run as failed', () => { expect(agentDotState(row({ state: 'working', ...mainAgentDone('failure') }), 0)).toBe('failed') expect(agentDotState(row({ state: 'waiting', ...mainAgentDone('failure') }), 0)).toBe('failed') - // Only a failure outranks live work; a success or a stop with live subagents reads working. + expect(agentDotState(row({ state: 'working', ...mainAgentDone('interruption') }), 0)).toBe( + 'failed' + ) + // Only a fault outranks live work; a success or a stop with live subagents reads working. expect(agentDotState(row({ state: 'working', ...mainAgentDone('success') }), 0)).toBe('working') expect( agentDotState( @@ -78,7 +107,7 @@ describe('agentDotState', () => { const mainAgents = [ undefined, ...states.flatMap((state) => - [undefined, ...AGENT_JOURNAL_TURN_OUTCOMES].map((outcome) => ({ + [undefined, ...AGENT_TURN_OUTCOMES].map((outcome) => ({ state, ...(outcome ? { outcome } : {}), stateStartedAt: 0 @@ -112,9 +141,9 @@ describe('agentDotState', () => { ).toBe('working') // 'done' never decays, and neither does its verdict. expect(agentDotState(row({ state: 'done', updatedAt: 0 }), stale)).toBe('done') - expect(agentDotState(row({ state: 'done', updatedAt: 0, interrupted: true }), stale)).toBe( - 'interrupted' - ) + expect( + agentDotState(row({ state: 'done', updatedAt: 0, ...mainAgentDone('interruption') }), stale) + ).toBe('failed') }) }) diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index da93aa39220..87b07abfbfb 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -1,5 +1,5 @@ import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' -import type { AgentJournalTurnOutcome } from '../../../src/shared/agent-turn-outcome' +import type { AgentTurnOutcome } from '../../../src/shared/agent-turn-outcome' // Mirrors the desktop AGENT_STATUS_STALE_AFTER_MS (src/shared/agent-status-types.ts: // 30 min). Defined locally rather than imported because a runtime-value import @@ -19,26 +19,55 @@ export type AgentDotState = | 'idle' | 'interrupted' | 'failed' + | 'unconfirmed' type AgentRowVerdictSource = Pick +// Mirrors AGENT_TURN_OUTCOMES (src/shared/agent-turn-outcome.ts), which mobile cannot import as a +// value (see AGENT_STATUS_STALE_AFTER_MS above). The rows arrive unparsed, so a newer host's arm +// has to read as no verdict here. +const AGENT_TURN_OUTCOMES: readonly AgentTurnOutcome[] = [ + 'success', + 'failure', + 'cancellation', + 'superseded', + 'interruption', + 'unconfirmed' +] + // Mirrors desktop agentMainAgentVerdict and agentVerdictDisplayMark // (src/shared/agent-main-agent-verdict.ts); a parity test runs both over one table. `mainAgent` is // the main agent's own status, sent also while subagents hold the row working; an old host sends none. -export function agentRowVerdict(row: AgentRowVerdictSource): AgentJournalTurnOutcome | null { +export function agentRowVerdict(row: AgentRowVerdictSource): AgentTurnOutcome | null { if (row.mainAgent && row.mainAgent.state !== 'done') { return null } - return row.mainAgent?.outcome ?? (row.state === 'done' && row.interrupted ? 'cancellation' : null) + const outcome = row.mainAgent?.outcome + if (outcome !== undefined) { + return AGENT_TURN_OUTCOMES.find((known) => known === outcome) ?? null + } + return row.state === 'done' && row.interrupted ? 'cancellation' : null } -// A failure outranks every state; a stop marks only a row that is itself done. -export function agentRowVerdictMark(row: AgentRowVerdictSource): 'failed' | 'interrupted' | null { - const verdict = agentRowVerdict(row) - if (verdict === 'failure') { - return 'failed' +// A fault (a failure, or a turn cut short by something other than the user) reads failed and +// outranks every state; a user's Stop, a turn a newer request replaced, and an unproven end mark +// only a row that is itself done. +export function agentRowVerdictMark( + row: AgentRowVerdictSource +): 'failed' | 'interrupted' | 'unconfirmed' | null { + switch (agentRowVerdict(row)) { + case 'failure': + case 'interruption': + return 'failed' + case 'cancellation': + case 'superseded': + return row.state === 'done' ? 'interrupted' : null + case 'unconfirmed': + return row.state === 'done' ? 'unconfirmed' : null + case 'success': + case null: + return null } - return verdict === 'cancellation' && row.state === 'done' ? 'interrupted' : null } export function agentDotState( @@ -85,6 +114,8 @@ export function agentStateLabel(state: AgentDotState): string { return 'Interrupted' case 'failed': return 'Failed' + case 'unconfirmed': + return 'Couldn’t confirm' case 'done': return 'Done' case 'idle': diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index 7f351193029..bac1da60fe7 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -123,7 +123,7 @@ describe('a Claude retrying a refused request', () => { // Once the frames stop, the same clock does let the sweep close it. clock += STRUCTURED_AGENT_SESSION_IDLE_MS await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) }) }) diff --git a/src/main/claude/claude-close-stop-cause.test.ts b/src/main/claude/claude-close-stop-cause.test.ts new file mode 100644 index 00000000000..ba93c53a3d8 --- /dev/null +++ b/src/main/claude/claude-close-stop-cause.test.ts @@ -0,0 +1,92 @@ +// Closing a Claude child settles its open turn in the adapter, with the cause the host handed the +// close. Only a stop the user aimed at this chat reads as their cancellation; an exit the adapter +// saw before the close settles as that exit. + +import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalTurnLifecycle +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { + PROVIDER_SESSION_ID, + adapterFor, + fakeClaude, + identityFor, + tick +} from './claude-structured-session-test-support' + +function turnSink() { + const turns: AgentJournalTurnLifecycle[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (_identity, body: AgentJournalItemBody) => { + const turn = readAgentJournalTurn(body) + if (turn) { + turns.push(turn) + } + }, + appendTombstone: () => {}, + publish: () => {} + } + return { sink, turns } +} + +/** A live Claude child inside a turn the provider opened on its own (a background-task wake). */ +async function childInsideTurn() { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) + const { sink, turns } = turnSink() + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + const connection = claude.connections[0]! + connection.handlers.onMessage?.({ + session_id: PROVIDER_SESSION_ID, + type: 'assistant', + uuid: 'assistant-1', + parent_tool_use_id: null, + message: { role: 'assistant', content: [{ type: 'text', text: 'working' }] } + }) + await tick() + expect(turns.at(-1)).toMatchObject({ state: 'running' }) + return { adapter, connection, events, turns } +} + +describe('a Claude close settles the open turn with the host-named cause', () => { + it("records the user's close of this chat as their cancellation", async () => { + const { adapter, events, turns } = await childInsideTurn() + + await expect(adapter.closeSession('session-1', 'user-close')).resolves.toBe(true) + + expect(turns.at(-1)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(events.find((event) => event.type === 'ended')).toMatchObject({ + stopCause: 'user-close' + }) + }) + + it('leaves an eviction as news', async () => { + const { adapter, turns } = await childInsideTurn() + + await adapter.closeSession('session-1', 'evict') + + expect(turns.at(-1)).toMatchObject({ state: 'interrupted' }) + expect(turns.at(-1)).not.toHaveProperty('outcome') + }) + + it('leaves a crash it saw before the user closed the chat as news', async () => { + const { adapter, connection, events, turns } = await childInsideTurn() + // The child dies on its own first; the user's close arrives while that exit is still settling. + connection.handlers.onExit?.(new Error('provider exited')) + + await adapter.closeSession('session-1', 'user-close') + await tick() + + const settled = turns.filter((turn) => turn.state !== 'running') + expect(settled).toEqual([expect.objectContaining({ state: 'interrupted' })]) + expect(settled[0]).not.toHaveProperty('outcome') + expect(events.filter((event) => event.type === 'ended')).toEqual([ + expect.objectContaining({ cause: 'unexpected-exit' }) + ]) + }) +}) diff --git a/src/main/claude/claude-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts index 45feeb84364..ed508b30846 100644 --- a/src/main/claude/claude-journal-translator-contract.ts +++ b/src/main/claude/claude-journal-translator-contract.ts @@ -2,6 +2,7 @@ import type { AgentSessionContextReport } from '../../shared/agent-session-context-usage' import type { StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeChildToolQueries } from './claude-child-tool-queries' import type { ClaudeContextReportPart, ClaudeContextReportTarget } from './claude-context-facts' import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts' @@ -14,6 +15,11 @@ export type ClaudeJournalTranslator = { /** The open turn's provider id — the same id its journal row carries, and the one * a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */ readonly currentTurnId: string | null + /** Orca is stopping this turn; its error end reads as the user's cancellation when they asked. + * False when the turn is no longer open. */ + recordTurnStop: (turnId: string, cause: StructuredAgentSessionStopCause) => boolean + /** The provider refused the stop. */ + withdrawTurnStop: (turnId: string) => void /** The open turn's id while it is a conversation command's. */ readonly commandTurnId: string | null /** Makes the host's command turn the open one until the command's result ends it. */ diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 864624fe59e..5aca644143b 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -12,6 +12,7 @@ import { type AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeCurrentTurnIdentity, claudeTurnLifecycleItem, @@ -32,6 +33,9 @@ export type ClaudeOpenTurnDeps = { export class ClaudeOpenTurn { private current: ClaudeCurrentTurn | null = null + /** The stop Orca sent, held against the turn it was sent to: it reads only while that turn is open. */ + private sentStop: { turn: ClaudeCurrentTurn; cause: StructuredAgentSessionStopCause } | null = + null /** Provider output may not reopen a turn after the session ended or a turn * failed: nothing would ever close the turn it opened, and the row would read * working for the life of the session. Only an accepted send lifts it. */ @@ -86,6 +90,26 @@ export class ClaudeOpenTurn { return this.current !== null } + get stop(): StructuredAgentSessionStopCause | null { + return this.current && this.sentStop?.turn === this.current ? this.sentStop.cause : null + } + + /** Orca is stopping `turnId`. False when that turn is no longer the open one. */ + recordStop(turnId: string, cause: StructuredAgentSessionStopCause): boolean { + if (this.current?.turnId !== turnId) { + return false + } + this.sentStop = { turn: this.current, cause } + return true + } + + /** The provider refused the stop, so the turn goes on as if none was sent. */ + withdrawStop(turnId: string): void { + if (this.current?.turnId === turnId) { + this.sentStop = null + } + } + /** Whether a turn is open inside a provider request cycle that has already * done work — the state in which the CLI folds an arriving send into it. A * cycle's first send is its opener, never a fold. */ @@ -105,12 +129,17 @@ export class ClaudeOpenTurn { /** Open a turn, ending whichever one was still open. A new turn starting is the * only end the previous one gets when its result never arrives; settling it - * later would sweep THIS turn. */ + * later would sweep THIS turn. The replaced turn is recorded superseded: a newer + * request ended it, whoever sent that request. */ open(turn: ClaudeCurrentTurn, observedAt: number): void { this.deps.onOpen?.() if (this.current) { this.deps.settleChildren(this.groupKey) - this.publish(this.current, { state: 'interrupted', completedAt: observedAt }) + this.publish(this.current, { + state: 'interrupted', + completedAt: observedAt, + outcome: 'superseded' + }) } this.current = turn this.publish(turn) @@ -123,7 +152,11 @@ export class ClaudeOpenTurn { this.deps.onOpen?.() if (this.current) { this.deps.settleChildren(this.groupKey) - this.publish(this.current, { state: 'interrupted', completedAt: turn.startedAt }) + this.publish(this.current, { + state: 'interrupted', + completedAt: turn.startedAt, + outcome: 'superseded' + }) } this.current = turn this.deps.sink.setActivity?.(null) diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index 14fd2e94eb6..1fe79b62606 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -56,13 +56,15 @@ export function journalClaudeResult( } // Read before the settle below closes it: the result reports that turn's end. const endedTurnScope = turn.turnScope + // The stop Orca sent that turn: after the user's own, an error end is their cancellation. + const stop = settlesTurn ? turn.stop : null if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) // The turn is over however it ended, so a foreground child still // reported as working will never be settled by an event. subagents.settleTurn(turn.groupKey) - context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt)) + context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt, stop)) // The turn is over. A block still awaiting its final keeps the text the // flush above journaled, but its live state goes: an interrupted turn // would otherwise retain that text for the life of the session. @@ -70,7 +72,7 @@ export function journalClaudeResult( streamedText.settle() } const kind = claudeProviderFrameKind(message) - const failure = claudeResultFailure(message) + const failure = claudeResultFailure(message, stop) if (failure || !isSettledClaudeResultKind(kind)) { providerFallback.append( kind, diff --git a/src/main/claude/claude-result-outcome.ts b/src/main/claude/claude-result-outcome.ts index 5dbfbfffc71..cdbf5a529bf 100644 --- a/src/main/claude/claude-result-outcome.ts +++ b/src/main/claude/claude-result-outcome.ts @@ -6,6 +6,8 @@ // the user's or the provider's. import type { AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { stopIsTheUsers } from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import { claudeText } from './claude-structured-item-translation' /** The SDK reports the user's stop as an error result, so `is_error` alone cannot @@ -13,11 +15,18 @@ import { claudeText } from './claude-structured-item-translation' const CLAUDE_ABORTED_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools']) /** A success-subtype result still carries `is_error` for an API error, so the flag - * is what decides, never the subtype. */ -export function claudeResultOutcome(message: Record): AgentJournalTurnOutcome { + * is what decides, never the subtype. `stop` is the stop Orca sent for this turn: an error + * end after the user's own is their cancellation, since older CLIs name no reason. */ +export function claudeResultOutcome( + message: Record, + stop: StructuredAgentSessionStopCause | null = null +): AgentJournalTurnOutcome { if (message.is_error !== true) { return 'success' } + if (stop !== null && stopIsTheUsers(stop)) { + return 'cancellation' + } const reason = claudeText(message.terminal_reason) return reason !== null && CLAUDE_ABORTED_TERMINAL_REASONS.has(reason) ? 'cancellation' : 'failure' } diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 4ba08d3b642..e4aa15352c3 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -249,6 +249,8 @@ describe('answerClaudePrompt', () => { resolve: resolvePrompt }, currentTurnId: null, + recordTurnStop: () => true, + withdrawTurnStop: () => {}, commandTurnId: null, beginCommand: vi.fn(), forgetCommand: vi.fn(), diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 8ec1aa2e9c4..412439a0be9 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -4,6 +4,7 @@ import { ClaudeControlRequestError } from './claude-stream-json-connection' import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import type { ClaudeSession } from './claude-structured-session-state' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' @@ -25,7 +26,8 @@ export async function cancelClaudeTurn( session: ClaudeSession, timeoutMs: number | undefined, isCurrent: ClaudeTurnCancellationGuard = () => true, - onDispatchSettledLate?: ClaudeLateDispatchSettlement + onDispatchSettledLate?: ClaudeLateDispatchSettlement, + stopped?: { turnId: string; cause: StructuredAgentSessionStopCause } ): Promise<{ cancelled: boolean }> { // The SDK interrupt is session-scoped. Re-check the caller's turn/fence // immediately before issuing it so a delayed request cannot stop a later turn. @@ -33,6 +35,10 @@ export async function cancelClaudeTurn( return { cancelled: false } } const cancelQueued = supportsClaudeQueuedInterruptCancellation(session) + // Recorded before the interrupt goes out, so the result it provokes finds it. + if (stopped) { + session.translator?.recordTurnStop(stopped.turnId, stopped.cause) + } try { const receipt = await session.connection.interrupt({ ...(cancelQueued ? { cancelQueued: true } : {}), @@ -52,6 +58,11 @@ export async function cancelClaudeTurn( return { cancelled: true } } catch (error) { if (error instanceof ClaudeControlRequestError) { + // The CLI refused, so the turn runs on and its own end means what it says. Any other error + // leaves the interrupt's effect unknown, and the stop the user asked for stands. + if (stopped) { + session.translator?.withdrawTurnStop(stopped.turnId) + } return { cancelled: false } } throw error diff --git a/src/main/claude/claude-structured-in-turn-stop.test.ts b/src/main/claude/claude-structured-in-turn-stop.test.ts new file mode 100644 index 00000000000..c5bbc51d8bc --- /dev/null +++ b/src/main/claude/claude-structured-in-turn-stop.test.ts @@ -0,0 +1,182 @@ +// A user's Stop inside a live Claude chat interrupts the turn and keeps the session. The turn's end +// then comes from the CLI's result frame, which CLIs before 2.1.91 send with no terminal_reason. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { + PROVIDER_SESSION_ID, + USER_MESSAGE, + adapterFor, + fakeClaude, + identityFor, + type FakeConnection +} from './claude-structured-session-test-support' + +const CUT_SHORT = { + type: 'result', + subtype: 'error_during_execution', + is_error: true, + session_id: PROVIDER_SESSION_ID, + parent_tool_use_id: null +} + +async function runningChat(claude: ReturnType): Promise<{ + adapter: ReturnType + bodies: Map + connection: FakeConnection + turnId: string +}> { + const bodies = new Map() + const adapter = adapterFor(claude) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: { + appendItem: (identity, body) => bodies.set(agentJournalItemKey(identity), body), + appendTombstone: (identity) => bodies.delete(agentJournalItemKey(identity)), + publish: vi.fn() + } + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-a', + body: USER_MESSAGE, + fence: 7 + }) + const connection = claude.connections[0]! + // Claude adopts the client uuid for the echo that opens the turn. + connection.handlers.onMessage?.({ ...connection.sent.at(-1)! }) + const turnId = [...bodies.values()] + .map((body) => readAgentJournalTurn(body)) + .find((turn) => turn?.state === 'running')?.turnId + if (!turnId) { + throw new Error('expected a running turn') + } + return { adapter, bodies, connection, turnId } +} + +function settled(bodies: Map, turnId: string) { + return [...bodies.values()] + .map((body) => readAgentJournalTurn(body)) + .find((turn) => turn?.turnId === turnId && turn.state !== 'running') +} + +function providerRows(bodies: Map): string[] { + return [...bodies.keys()].filter((key) => key.includes('provider-frame')) +} + +describe("a user's Stop inside a live Claude chat", () => { + it('records the turn the interrupt cut as their cancellation when the CLI names no reason', async () => { + const claude = fakeClaude({ + routes: { + // The CLI aborts the turn, then acknowledges the interrupt. + interrupt: () => { + claude.connections[0]!.handlers.onMessage?.(CUT_SHORT) + return undefined + } + } + }) + const { adapter, bodies, turnId } = await runningChat(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', turnId, fence: 7 })).resolves.toEqual( + { cancelled: true } + ) + + expect(settled(bodies, turnId)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + expect(providerRows(bodies)).toEqual([]) + }) + + // The chat's Stop button names no turn: it stops whatever the conversation has open. + it('records the open turn a Stop naming no turn cut as their cancellation', async () => { + const claude = fakeClaude({ + routes: { + interrupt: () => { + claude.connections[0]!.handlers.onMessage?.(CUT_SHORT) + return undefined + } + } + }) + const { adapter, bodies, turnId } = await runningChat(claude) + + await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + cancelled: true + }) + + expect(settled(bodies, turnId)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + expect(providerRows(bodies)).toEqual([]) + }) + + it('reads the same result with no Stop as a failure', async () => { + const { bodies, connection, turnId } = await runningChat(fakeClaude()) + + connection.handlers.onMessage?.(CUT_SHORT) + + expect(settled(bodies, turnId)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(providerRows(bodies)).toHaveLength(1) + }) + + it('keeps a Stop naming no turn off the turn after it', async () => { + const claude = fakeClaude({ + routes: { + interrupt: () => { + claude.connections[0]!.handlers.onMessage?.(CUT_SHORT) + return undefined + } + } + }) + const { adapter, bodies, connection } = await runningChat(claude) + await adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-b', + body: USER_MESSAGE, + fence: 7 + }) + connection.handlers.onMessage?.({ ...connection.sent.at(-1)! }) + const nextTurnId = [...bodies.values()] + .map((body) => readAgentJournalTurn(body)) + .find((turn) => turn?.state === 'running')?.turnId + if (!nextTurnId) { + throw new Error('expected the next turn running') + } + connection.handlers.onMessage?.(CUT_SHORT) + + expect(settled(bodies, nextTurnId)).toMatchObject({ state: 'completed', outcome: 'failure' }) + }) + + it.each([ + ['naming the turn', true], + ['naming no turn', false] + ] as const)( + 'keeps a failure the turn reaches after the CLI refused the interrupt, %s', + async (_label, named) => { + const claude = fakeClaude({ + routes: { + interrupt: () => { + throw new ClaudeControlRequestError('interrupt', 'not running') + } + } + }) + const { adapter, bodies, connection, turnId } = await runningChat(claude) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', ...(named ? { turnId } : {}), fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + connection.handlers.onMessage?.(CUT_SHORT) + + expect(settled(bodies, turnId)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(providerRows(bodies)).toHaveLength(1) + } + ) +}) diff --git a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts index ee0f4970f4d..35f3422c187 100644 --- a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts +++ b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts @@ -256,6 +256,7 @@ describe('Claude structured turn timing', () => { { turnId: 'user-1', state: 'interrupted', + outcome: 'superseded', startedAt: 1_000, completedAt: 3_000, userItemId: USER_1_KEY diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index f49eed242d5..dcf3a3a6720 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -1,3 +1,7 @@ +import { + childEndCauseOfEndedEvent, + turnVerdictForChildEnd +} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' @@ -185,8 +189,11 @@ export function createClaudeJournalTranslator( streamedText.flush() subagents.settleSession() backgroundTasks.settleSession() - // The host saw the child end, so the turn's end is observed, not lost. - turn.settle({ state: 'interrupted', completedAt: event.observedAt ?? Date.now() }) + // The host saw the child end, so the turn's end is observed, not lost; its verdict is only + // what the host's own cause says, a user's stop of this chat or else news. + turn.settle( + turnVerdictForChildEnd(childEndCauseOfEndedEvent(event), event.observedAt ?? Date.now()) + ) // A frame that arrives after the child is gone must not open a turn no // event can close. turn.suppressReopen() @@ -275,6 +282,8 @@ export function createClaudeJournalTranslator( get currentTurnId() { return turn.id }, + recordTurnStop: (turnId, cause) => turn.recordStop(turnId, cause), + withdrawTurnStop: (turnId) => turn.withdrawStop(turnId), get commandTurnId() { return turn.command ? turn.id : null }, diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 78c63af8075..1014455324e 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -75,7 +75,16 @@ function cancelClaudeConversation( session.fence === request.fence && session.acquisitionGeneration === acquisitionGeneration && (claudeLiveTurnId(session, request) !== null || session.dispatchWaiters.length > 0) - return cancelClaudeTurn(session, timeoutMs, isCurrent, onDispatchSettledLate) + // A turn is cancelled only at a client's request, so the stop is the user's: on the named turn, + // else on whatever turn is open. + const stoppedTurnId = request.turnId ?? session.translator?.currentTurnId ?? null + return cancelClaudeTurn( + session, + timeoutMs, + isCurrent, + onDispatchSettledLate, + stoppedTurnId === null ? undefined : { turnId: stoppedTurnId, cause: 'user-stop' } + ) } export async function cancelClaudeStructuredTurn(input: { @@ -166,6 +175,7 @@ export async function cancelClaudeStructuredTurn(input: { : compactionOwnsTurn() || (ownsRequestedTurn() && dispatchAdmissionAllowsCancellation())) let interruptConfirmed = false try { + // A turn is cancelled only at a client's request, so the stop is the user's. const result = await cancelClaudeTurn( session, timeoutMs, @@ -177,7 +187,8 @@ export async function cancelClaudeStructuredTurn(input: { } return current }, - input.onDispatchSettledLate + input.onDispatchSettledLate, + { turnId: requestedTurnId, cause: 'user-stop' } ) if (result.cancelled && claim && cancellationObserved) { interruptConfirmed = true diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index cd5441d7ec1..0da42846fe5 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -16,6 +16,7 @@ import { type ClaudeMessageEnvelope } from './claude-structured-item-translation' import { claudeResultOutcome } from './claude-result-outcome' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeRowStamp } from './claude-provisional-row-corrections' import { CLAUDE_API_RETRY_FRAME_KIND, @@ -51,11 +52,12 @@ export function isSettledClaudeResultKind(kind: string): boolean { * would only be noise. */ export function claudeResultFailure( - message: Record + message: Record, + stop: StructuredAgentSessionStopCause | null = null ): { text: string | null } | null { // A cancellation is not a fault and earns no error row; the outcome classifier // owns that distinction so this reader cannot drift from the turn's verdict. - if (claudeResultOutcome(message) !== 'failure') { + if (claudeResultOutcome(message, stop) !== 'failure') { return null } const result = claudeText(message.result)?.trim() diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index d2788bf4027..43576cc44e9 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -3,7 +3,8 @@ import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, StructuredAgentSessionAcquireInput, - StructuredAgentSessionAdapter + StructuredAgentSessionAdapter, + StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' @@ -282,20 +283,30 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) }) - closeSession = (sessionId: string): Promise => + closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => // After the close, not before: releasing an exit still settling settles it on the way. - this.closeSessionProcess(sessionId).finally(() => this.settledExitErrors.delete(sessionId)) + this.closeSessionProcess(sessionId, cause).finally(() => + this.settledExitErrors.delete(sessionId) + ) - private closeSessionProcess(sessionId: string): Promise { + private closeSessionProcess( + sessionId: string, + cause: StructuredAgentSessionStopCause | undefined + ): Promise { + // An exit seen first settles as that exit, whoever asked for the close after it. if (this.exits.has(sessionId)) { return this.releaseAcquisition({ sessionId }) } - return this.afterClose(sessionId, () => this.closeProviderSession(sessionId)) + return this.afterClose(sessionId, () => this.closeProviderSession(sessionId, cause)) } - private closeProviderSession = (sessionId: string): Promise => + private closeProviderSession = ( + sessionId: string, + stopCause?: StructuredAgentSessionStopCause + ): Promise => closeClaudeSession({ sessionId, + ...(stopCause ? { stopCause } : {}), sessions: this.sessions, acquisitions: this.acquisitions, ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index f49b6a8d88f..91a0df1e2b1 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -6,6 +6,7 @@ import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { cancelClaudeAcquisitionAttempt } from './claude-structured-session-state' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRootExitObservedError, @@ -77,6 +78,8 @@ export function settleClaudeExitedSession(session: ClaudeSession): void { type CloseClaudePublishedSessionInput = { sessions: Map sessionId: string + /** Who asked for the close; the translator settles the open turn with it. */ + stopCause?: StructuredAgentSessionStopCause persistHandle?: (handle: { sessionId: string providerSessionId: string @@ -135,6 +138,7 @@ async function finalizeClaudePublishedSession( type: 'ended', sessionId: input.sessionId, reason: 'claude session closed', + ...(input.stopCause ? { stopCause: input.stopCause } : {}), observedAt: Date.now() } as const let callbackError: unknown @@ -242,6 +246,7 @@ export function closeClaudePublishedSessionForDeps( export async function closeClaudeSession(input: { sessionId: string + stopCause?: StructuredAgentSessionStopCause sessions: Map acquisitions: ClaudeAcquisitionRegistry persistHandle?: (handle: { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 0d43dbb99ec..fd51d2987e0 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -5,7 +5,10 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { StructuredAgentSessionStartedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionStartedEvent, + StructuredAgentSessionStopCause +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection @@ -73,6 +76,8 @@ export type ClaudeStructuredSessionEvent = failure?: SubmissionRejectionFact /** Present for first-hand child exits so the host can fence recovery. */ cause?: 'unexpected-exit' | 'requested-close' + /** Who asked for a close; the translator settles the open turn with it. */ + stopCause?: StructuredAgentSessionStopCause fence?: number acquisitionGeneration?: string /** Host clock when the end was observed. */ diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index be7150531fe..da3af882309 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -5,6 +5,7 @@ import type { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeResultOutcome } from './claude-result-outcome' import type { ClaudeCommandTurn } from './claude-command-turn' @@ -31,9 +32,9 @@ export function claudeCurrentTurnIdentity(turn: ClaudeCurrentTurn): AgentJournal export type ClaudeTurnEnd = { state: 'completed' | 'interrupted' completedAt: number - /** Only an end the PROVIDER reported carries one. An end the host inferred — - * the child going away, a new turn superseding this one — leaves it absent, - * which reads as unknown rather than claiming the turn worked. */ + /** An end the provider reported carries its verdict, and a turn a newer one + * replaced carries `superseded`. The child going away leaves it absent, which + * reads as unknown rather than claiming the turn worked. */ outcome?: AgentJournalTurnOutcome /** The SDK's own measured turn duration; only a result frame carries one. */ durationMs?: number @@ -44,9 +45,10 @@ export type ClaudeTurnEnd = { * is still a turn the host watched finish, and only `outcome` says it failed. */ export function claudeTurnEndForResult( message: Record, - completedAt: number + completedAt: number, + stop: StructuredAgentSessionStopCause | null = null ): ClaudeTurnEnd { - const outcome = claudeResultOutcome(message) + const outcome = claudeResultOutcome(message, stop) const durationMs = message.duration_ms return { state: outcome === 'cancellation' ? 'interrupted' : 'completed', diff --git a/src/main/claude/claude-turn-outcome.test.ts b/src/main/claude/claude-turn-outcome.test.ts index 8223652458b..c8de34cee08 100644 --- a/src/main/claude/claude-turn-outcome.test.ts +++ b/src/main/claude/claude-turn-outcome.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody, - AgentJournalItemIdentity + AgentJournalItemIdentity, + AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from '../../shared/agent-session-turn-record' +import { describeNativeChatTurnStatus } from '../../shared/native-chat-turn-status' +import { selectStructuredAgentSettledTurns } from '../../shared/structured-agent-session-turn-timing' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeResultOutcome } from './claude-result-outcome' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' @@ -162,23 +166,12 @@ describe('claudeTurnLifecycleItem', () => { }) describe('a turn end the host inferred', () => { - it.each([ - [ - 'the child ending', - (translator: ReturnType) => - translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) - ], - [ - 'a new turn superseding it', - (translator: ReturnType) => - translator.handle(userTurn('user-2')) - ] - ])('records no outcome for a turn ended by %s', (_label, end) => { + it('records no outcome for a turn ended by the child ending', () => { const state = sinkState() const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle(userTurn('user-1')) - end(translator) + translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) // No result frame arrived, so the provider never said what became of the // turn. The host observed the END — the arm stays `interrupted` — but the @@ -194,4 +187,173 @@ describe('a turn end the host inferred', () => { expect(settled?.body).not.toHaveProperty('outcome') expect(readAgentJournalTurnOutcome(readAgentJournalTurn(settled?.body))).toBeNull() }) + + // The supersede fires for any send Orca dispatched, and nothing here says whether the user or + // another agent sent it, so it is recorded as replaced, never as the user's stop or a fault. + it('records a turn a newer send superseded as superseded, folded as interrupted', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + let observedAt = 1_000 + translator.handle({ ...userTurn('user-1'), observedAt }) + observedAt = 13_000 + translator.handle({ ...userTurn('user-2'), observedAt }) + + // The journal as a reader holds it: each turn row at its newest revision, after the user row + // it names (the host journals that row, not this translator). + const rows = new Map() + state.items.forEach((item, index) => { + const itemId = agentJournalItemKey(item.identity) + rows.set(itemId, { itemId, revision: index, sequence: index, observedAt, body: item.body }) + }) + const items = [...rows.values()].flatMap((row): AgentJournalRenderItem[] => { + const userItemId = readAgentJournalTurn(row.body)?.userItemId + return userItemId + ? [ + { + ...row, + itemId: userItemId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } + }, + row + ] + : [row] + }) + const superseded = items.find((item) => readAgentJournalTurn(item.body)?.turnId === 'user-1') + expect(readAgentJournalTurn(superseded?.body)).toMatchObject({ + state: 'interrupted', + outcome: 'superseded' + }) + const settled = selectStructuredAgentSettledTurns(items).get( + readAgentJournalTurn(superseded?.body)?.userItemId ?? '' + ) + expect(settled).toMatchObject({ verdict: 'superseded', workedSeconds: 12 }) + expect( + settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) + ).toMatchObject({ key: 'interruptedAfter', duration: '12s' }) + }) +}) + +describe("a user's Stop inside a live turn", () => { + // Claude CLIs before 2.1.91 send no terminal_reason, and later ones may omit it. + const cutShort = { type: 'result', subtype: 'error_during_execution', is_error: true } + + function settledTurn(items: ReturnType['items'], turnId: string) { + return items + .map((item) => readAgentJournalTurn(item.body)) + .findLast((turn) => turn?.turnId === turnId && turn.state !== 'running') + } + + function providerRows(items: ReturnType['items']): number { + return items.filter( + (item) => + item.identity.provider === 'orca' && + item.identity.clientMessageId.startsWith('provider-frame:') + ).length + } + + it('reads an error result with no terminal reason as the cancellation it asked for', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + expect(translator.recordTurnStop('user-1', 'user-stop')).toBe(true) + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + expect(settledTurn(state.items, 'user-1')).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + expect(providerRows(state.items)).toBe(0) + }) + + it.each([ + ['no terminal reason', cutShort], + [ + 'a failure terminal reason', + { ...cutShort, terminal_reason: 'api_error', result: 'API Error' } + ] + ])('keeps a result with %s and no Stop a failure', (_label, frame) => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.handle({ type: 'message', sessionId: 'orca-session', message: frame }) + + expect(settledTurn(state.items, 'user-1')).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + expect(providerRows(state.items)).toBe(1) + }) + + it('keeps a turn that finished during the Stop a success', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.recordTurnStop('user-1', 'user-stop') + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'result', subtype: 'success', is_error: false } + }) + + expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'success' }) + }) + + it('does not carry a Stop onto the next turn', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + translator.recordTurnStop('user-1', 'user-stop') + // Superseded before its result: the Stop was for user-1 only. + translator.handle(userTurn('user-2')) + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + // A late Stop names a turn that already ended. + expect(translator.recordTurnStop('user-2', 'user-stop')).toBe(false) + translator.handle(userTurn('user-3')) + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + expect(settledTurn(state.items, 'user-2')).toMatchObject({ outcome: 'failure' }) + expect(settledTurn(state.items, 'user-3')).toMatchObject({ outcome: 'failure' }) + }) + + it('ends the Stop with its turn, so a result after the turn settled reads on its own', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + translator.recordTurnStop('user-1', 'user-stop') + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { type: 'system', subtype: 'session_state_changed', state: 'idle' } + }) + + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + expect(providerRows(state.items)).toBe(1) + }) + + it('forgets a Stop the CLI refused', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.recordTurnStop('user-1', 'user-stop') + translator.withdrawTurnStop('user-1') + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'failure' }) + }) + + it('does not read a host stop as the user asking', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + translator.handle(userTurn('user-1')) + + translator.recordTurnStop('user-1', 'host-stop') + translator.handle({ type: 'message', sessionId: 'orca-session', message: cutShort }) + + expect(settledTurn(state.items, 'user-1')).toMatchObject({ outcome: 'failure' }) + }) }) diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts index 6bfcb32cb86..a7255aab558 100644 --- a/src/main/claude/claude-turn-ownership.test.ts +++ b/src/main/claude/claude-turn-ownership.test.ts @@ -95,6 +95,8 @@ function sessionHoldingTurn(turnId: string | null): ReturnType true, + withdrawTurnStop: () => {}, commandTurnId: null, beginCommand: vi.fn(), forgetCommand: vi.fn(), diff --git a/src/main/codex/codex-close-stop-cause.test.ts b/src/main/codex/codex-close-stop-cause.test.ts new file mode 100644 index 00000000000..69618f22dd8 --- /dev/null +++ b/src/main/codex/codex-close-stop-cause.test.ts @@ -0,0 +1,158 @@ +// Closing a Codex child settles its open turn in the adapter, with the cause the host handed the +// close. Only a stop the user aimed at this chat reads as their cancellation. + +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { CodexPromptRegistry } from './codex-structured-prompt-replies' +import { + closeCodexPublishedSession, + handleCodexSessionExit +} from './codex-structured-session-close' +import { CodexAcquisitionRegistry, type CodexSession } from './codex-structured-session-state' +import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' + +/** A live Codex child whose primary thread is inside `turn-1`, and what its ended batch wrote. */ +function sessionWithRunningTurn() { + const turnBodies: AgentJournalItemBody[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendLifecycleBatch: (_id, mutations) => { + for (const mutation of mutations) { + if (mutation.kind === 'item' && mutation.body.kind === 'turn') { + turnBodies.push(mutation.body) + } + } + return { accepted: true } + } + } + const translator = createCodexJournalTranslator({ + sink, + sessionId: 'session-1', + primaryThreadId: () => 'thread-1', + now: () => 2_000 + }) + translator.handle({ + type: 'notification', + sessionId: 'session-1', + threadId: 'thread-1', + method: 'turn/started', + params: { turn: { id: 'turn-1' } }, + observedAt: 1_000 + }) + const session: CodexSession = { + connection: { + pid: 4321, + closed: false, + request: async () => ({}), + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + }, + backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), + ended: false, + requestedClose: false, + fence: 7, + acquisitionGeneration: 'generation-1', + threadId: 'thread-1', + historyPath: null, + prompts: new CodexPromptRegistry(), + options: new Map(), + reportedOptions: {}, + fastModeTierByModel: new Map(), + dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), + translator + } + return { sessions: new Map([['session-1', session]]), session, turnBodies } +} + +describe('a Codex close settles the open turn with the host-named cause', () => { + it.each(['user-close', 'user-stop'] satisfies StructuredAgentSessionStopCause[])( + "records the user's %s as their cancellation", + async (stopCause) => { + const { sessions, turnBodies } = sessionWithRunningTurn() + const onEvent = vi.fn() + + await expect( + closeCodexPublishedSession(sessions, 'session-1', onEvent, { stopCause }) + ).resolves.toBe(true) + + expect(turnBodies).toEqual([ + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', outcome: 'cancellation' }) + ]) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ended', cause: 'requested-close', stopCause }) + ) + } + ) + + it.each([['evict'], ['host-stop'], [undefined]] as const)( + 'leaves a close for %s as news', + async (stopCause) => { + const { sessions, turnBodies } = sessionWithRunningTurn() + + await closeCodexPublishedSession( + sessions, + 'session-1', + undefined, + stopCause ? { stopCause } : {} + ) + + expect(turnBodies).toEqual([ + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) + ]) + expect(turnBodies[0]).not.toHaveProperty('outcome') + } + ) + + it('leaves a crash it saw before the user closed the chat as news', async () => { + const { sessions, session, turnBodies } = sessionWithRunningTurn() + // The child died on its own first; the user's close then finds it already ended. + handleCodexSessionExit({ + sessions, + sessionId: 'session-1', + connection: session.connection, + error: new Error('app-server exited') + }) + + await closeCodexPublishedSession(sessions, 'session-1', undefined, { stopCause: 'user-close' }) + + expect(turnBodies).toEqual([ + expect.objectContaining({ turnId: 'turn-1', state: 'interrupted' }) + ]) + expect(turnBodies[0]).not.toHaveProperty('outcome') + }) + + it('never carries a user cause onto a close forced after a sink failure', async () => { + const { sessions, turnBodies } = sessionWithRunningTurn() + + await closeCodexPublishedSession(sessions, 'session-1', undefined, { + requestedClose: false, + stopCause: 'user-close' + }) + + expect(turnBodies[0]).not.toHaveProperty('outcome') + }) + + it("carries the host's cause from the adapter's close to the ended batch", async () => { + const { sessions, turnBodies } = sessionWithRunningTurn() + const teardown = new CodexStructuredSessionTeardown({ + sessions, + acquisitions: new CodexAcquisitionRegistry(), + forgetNotificationRetries: () => {} + }) + + await expect(teardown.close('session-1', 'user-close')).resolves.toBe(true) + + expect(turnBodies[0]).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) +}) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index be5e5368088..0129a56319b 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -1,3 +1,7 @@ +import { + childEndCauseOfEndedEvent, + turnVerdictForChildEnd +} from '../native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict' import { createCodexProviderActivityReader } from '../native-chat/agent-session-wire/provider-frame-activity' import { CODEX_TOKEN_USAGE_METHOD } from './codex-subagent-activity' import { @@ -157,14 +161,18 @@ export function createCodexJournalTranslator( currentTurnIds: activeTurns.byThread, primaryThreadId: deps.primaryThreadId?.() ?? null, ordinals: items.ordinals, - // The host saw the child go, not what Codex made of the turn, so the row - // carries no outcome: the end is observed, the verdict is unknown. + // The host saw the child go, not what Codex made of the turn: the verdict is only what + // the host's own cause says, a user's stop of this chat or else news. settledTurnLifecycle: (threadId, turnId) => turnBoundaries.ownsRecord(threadId, turnId) - ? turnBoundaries.settled(threadId, turnId, { - state: 'interrupted', - completedAt: event.observedAt ?? deps.now?.() ?? Date.now() - }) + ? turnBoundaries.settled( + threadId, + turnId, + turnVerdictForChildEnd( + childEndCauseOfEndedEvent(event), + event.observedAt ?? deps.now?.() ?? Date.now() + ) + ) : null, attributionFor }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index e0b1a93b395..2ab884d7cd1 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -9,7 +9,8 @@ import type { AgentSessionDispatchOutcome, StructuredAgentSessionAcquireInput, StructuredAgentSessionAdapter, - StructuredAgentSessionSetOptionInput + StructuredAgentSessionSetOptionInput, + StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' @@ -285,9 +286,11 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap identity: AgentSessionJournalIdentity }): Promise => this.sessions.get(input.identity.sessionId)?.historyPath ?? null - closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) + closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => + this.teardown.close(sessionId, cause) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) - disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) + disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => + this.teardown.close(sessionId, cause) closeAll = (): Promise => this.teardown.closeAll() releaseAcquisition = (input: { sessionId: string }): Promise => this.teardown.close(input.sessionId) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 5bf973308b1..0a017f37f19 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -8,7 +8,10 @@ import { type CodexStructuredSessionAdapterDeps, type CodexStructuredSessionEvent } from './codex-structured-session-state' -import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionEndedEvent, + StructuredAgentSessionStopCause +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' export function handleCodexSessionExit(input: { sessions: Map @@ -40,6 +43,9 @@ export function handleCodexSessionExit(input: { ? agentSessionFailureFact('hostFault') : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), cause: session.requestedClose ? 'requested-close' : 'unexpected-exit', + ...(session.requestedClose && session.closeStopCause + ? { stopCause: session.closeStopCause } + : {}), fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, observedAt: session.exitObservedAt @@ -74,6 +80,8 @@ export async function closeCodexPublishedSession( options?: { allowFailedSettlement?: boolean requestedClose?: boolean + /** Who asked for a requested close; the translator settles the open turn with it. */ + stopCause?: StructuredAgentSessionStopCause expectedFence?: number expectedAcquisitionGeneration?: string unexpectedReason?: Error @@ -93,6 +101,7 @@ export async function closeCodexPublishedSession( // Sink-failure recovery force-closes the child but must preserve the // observed-exit cause so host lease settlement runs as an unexpected death. session.requestedClose = options?.requestedClose ?? true + session.closeStopCause = options?.stopCause // Keep the session indexed until the child exit is observed. A timeout or // failed kill must leave the live connection available for a safe retry. const exited = await session.connection.close() @@ -124,7 +133,8 @@ export async function closeCodexSession( sessionId: string, sessions: Map, acquisitions: CodexAcquisitionRegistry, - onEvent?: (event: CodexStructuredSessionEvent) => void + onEvent?: (event: CodexStructuredSessionEvent) => void, + stopCause?: StructuredAgentSessionStopCause ): Promise { const attempt = acquisitions.get(sessionId) if (!(await cancelCodexAcquisitionAttempt(attempt))) { @@ -133,7 +143,7 @@ export async function closeCodexSession( if (attempt) { acquisitions.deleteIfCurrent(sessionId, attempt) } - return closeCodexPublishedSession(sessions, sessionId, onEvent) + return closeCodexPublishedSession(sessions, sessionId, onEvent, stopCause ? { stopCause } : {}) } export async function closeAllCodexSessions( diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index fcfb120fe82..78864fe9044 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -19,7 +19,10 @@ import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' -import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionEndedEvent, + StructuredAgentSessionStopCause +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import type { AgentModelCatalogSessionAccess, @@ -111,6 +114,8 @@ export type CodexSession = { /** First observed child exit survives rejected settlement admission. */ exitObservedAt?: number requestedClose: boolean + /** Who asked for the requested close in flight, carried onto its `ended`. */ + closeStopCause?: StructuredAgentSessionStopCause fence: number acquisitionGeneration: string threadId: string diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index dcd38eb82fe..f302e15b367 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -5,6 +5,7 @@ // proven stopped — a refused close leaves the session indexed for a retry. import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { closeAllCodexSessions, closeCodexPublishedSession, @@ -30,12 +31,13 @@ export type CodexStructuredSessionTeardownDeps = { export class CodexStructuredSessionTeardown { constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {} - close = async (sessionId: string): Promise => { + close = async (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => { const closed = await closeCodexSession( sessionId, this.deps.sessions, this.deps.acquisitions, - this.deps.onEvent + this.deps.onEvent, + cause ) return this.settled(sessionId, closed) } diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index 24d43545e13..b06987c4b75 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -76,12 +76,24 @@ function formatAgentNotificationStatusText(args: NotificationDispatchRequest): s if (args.agentState === 'working') { return translateMain('notifications.agentStatus.working', 'working') } - if (args.agentState === 'done' && args.agentTurnOutcome === 'failure') { - return translateMain('notifications.agentStatus.failed', 'failed') + if (args.agentState !== 'done') { + return translateMain('notifications.agentStatus.finished', 'finished') + } + switch (args.agentTurnOutcome) { + // A turn cut short by anything but the user is a fault, as a failure is. + case 'failure': + case 'interruption': + return translateMain('notifications.agentStatus.failed', 'failed') + // Why: a Stop the user asked for, a turn a newer request replaced, or an end Orca cannot + // prove, still never reads finished. + case 'cancellation': + case 'superseded': + case 'unconfirmed': + return translateMain('notifications.agentStatus.stopped', 'stopped') + case 'success': + case undefined: + return translateMain('notifications.agentStatus.finished', 'finished') } - return args.agentState === 'done' && args.agentTurnOutcome === 'cancellation' - ? translateMain('notifications.agentStatus.stopped', 'stopped') - : translateMain('notifications.agentStatus.finished', 'finished') } function formatNotificationWorktreeContext(args: NotificationDispatchRequest): string { diff --git a/src/main/ipc/notifications-message-formatting.test.ts b/src/main/ipc/notifications-message-formatting.test.ts index 3d519293f3c..bf96745fb4f 100644 --- a/src/main/ipc/notifications-message-formatting.test.ts +++ b/src/main/ipc/notifications-message-formatting.test.ts @@ -319,6 +319,10 @@ describe('registerNotificationHandlers', () => { it.each([ { agentTurnOutcome: 'cancellation', word: 'stopped' }, { agentTurnOutcome: 'failure', word: 'failed' }, + // A turn cut short by anything but the user is a fault, worded as one. + { agentTurnOutcome: 'interruption', word: 'failed' }, + { agentTurnOutcome: 'unconfirmed', word: 'stopped' }, + { agentTurnOutcome: 'superseded', word: 'stopped' }, { agentTurnOutcome: 'success', word: 'finished' }, { agentTurnOutcome: undefined, word: 'finished' } ] as const)('words a $agentTurnOutcome finish as $word', async ({ agentTurnOutcome, word }) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index b7371ee40f3..a80c147607d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -231,7 +231,7 @@ function deferred() { async function writeAsEarlierProcess( write: (journal: AgentSessionJournal, fence: number) => Promise ): Promise { - await host.close(SESSION) + await host.close(SESSION, 'evict') const record = store.getRecord(SESSION)! const params = attachParamsForRecord(record, { clientOperationId: 'earlier', @@ -261,7 +261,7 @@ function earlierSubmission(id: string, text: string, handoverRecorded?: true) { describe('a send is answered at acceptance', () => { it('answers before the child starts, then an open chat sees the handover and the reply (W2)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const starting = deferred() acquire.mockImplementationOnce(async (input) => { await starting.promise @@ -289,7 +289,7 @@ describe('a send is answered at acceptance', () => { }) it('accepts a second send while the first one starts the child, before handing either over (W6)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const starting = deferred() acquire.mockImplementationOnce(async (input) => { await starting.promise @@ -320,7 +320,7 @@ describe('a send is answered at acceptance', () => { describe('a start the chat needed and did not get', () => { it('writes one error row and rejects every queued message with it; the next send starts (W3)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) const first = await accept('first') const second = await accept('second') @@ -350,7 +350,7 @@ describe('a start the chat needed and did not get', () => { }) it('draws the messages it failed above the error row, since they were accepted first', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) const first = await accept('first') const second = await accept('second') @@ -369,7 +369,7 @@ describe('a start the chat needed and did not get', () => { }) it('notifies failed once for the queued messages one start failure refused', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) const completions: AgentSessionTurnCompletionEvent[] = [] host.subscribeTurnCompletions({ id: 'dot-1', emit: (event) => completions.push(event) }) @@ -434,7 +434,7 @@ describe('a start the chat needed and did not get', () => { } ] ])('writes one row a live chat sees for a %s refusal (W14)', async (_source, arrange, row) => { - await host.close(SESSION) + await host.close(SESSION, 'evict') arrange() await host.flushAllStreamedEvents() await startHost() @@ -455,7 +455,7 @@ describe('a start the chat needed and did not get', () => { }) it('names the start failure on queued messages when the attach fails after acquiring (W4′a)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const id = await accept('hello') // The attach's own success record is the post-acquisition step that fails. const record = vi.spyOn(store, 'recordOperationOutcome') @@ -487,7 +487,7 @@ describe('a start the chat needed and did not get', () => { describe('an attach that fails after indexing its child', () => { it('leaves no child behind, so the next send starts one and is delivered', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const owned: boolean[] = [] host.subscribeStatus({ id: 'list-1', @@ -578,7 +578,7 @@ describe('a child that exits before its message is handed over', () => { }) }) adapterExtras = { awaitStarted } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() const id = await accept('hello') @@ -602,7 +602,7 @@ describe('a start whose failure the delivery loop settles before the exit is pub ...(await spawnChild(input)), providerChildPhase: 'starting' as const })) - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() const first = await accept('first') @@ -659,7 +659,7 @@ describe('Stop withdraws what is queued', () => { }) it('withdraws a message whose start holds the queue: nothing is handed over (W17b)', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') const starting = deferred() acquire.mockImplementationOnce(async (input) => { await starting.promise @@ -685,7 +685,7 @@ describe('Stop withdraws what is queued', () => { return true }) adapterExtras = { awaitStarted, closeSession } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => ({ ...(await spawnChild(input)), @@ -713,12 +713,12 @@ describe('an eviction between acceptance and handover', () => { it('rejects the message as not sent, never leaves it in doubt (W24)', async () => { const started = deferred() adapterExtras = { awaitStarted: () => started.promise } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) // Between the delivery loop's start step and its handover step. - await host.close(SESSION) + await host.close(SESSION, 'evict') started.resolve() expect(await reopened(id)).toMatchObject({ @@ -732,7 +732,7 @@ describe('an eviction between acceptance and handover', () => { const second = deferred() const awaitStarted = vi.fn(async (): Promise => undefined) adapterExtras = { awaitStarted } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() dispatch.mockResolvedValueOnce({ state: 'admitted' }) const handed = await accept('handed over') @@ -741,7 +741,7 @@ describe('an eviction between acceptance and handover', () => { const queued = await accept('still queued') await eventually(async () => expect(awaitStarted).toHaveBeenCalledTimes(2)) - await host.close(SESSION) + await host.close(SESSION, 'evict') second.resolve() expect(await reopened(queued)).toMatchObject({ @@ -756,7 +756,7 @@ describe('an eviction between acceptance and handover', () => { idleMs = 0 const started = deferred() adapterExtras = { awaitStarted: () => started.promise } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) @@ -775,7 +775,7 @@ describe('an eviction between acceptance and handover', () => { // leaves every queued message rejected as closed, never blamed on the provider. describe('a close that stops the child and then fails', () => { const END_CHILD = { - evict: () => host.close(SESSION) + evict: () => host.close(SESSION, 'evict') } satisfies Partial Promise>> it.each([ @@ -792,7 +792,7 @@ describe('a close that stops the child and then fails', () => { throw new Error('release acknowledgement failed') }) } - await host.close(SESSION) + await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => ({ ...(await spawnChild(input)), @@ -816,7 +816,7 @@ describe('a close that stops the child and then fails', () => { describe('a compaction or rewind an earlier child left prepared', () => { async function leftPrepared(prepare: (fence: number) => Promise): Promise { - await host.close(SESSION) + await host.close(SESSION, 'evict') await prepare(store.getRecord(SESSION)!.lease.runtimeFence) // A new process: nothing is open and no view attaches. await host.flushAllStreamedEvents() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index e08c289c87a..fb6c001b320 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -161,7 +161,8 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => const stopSession = router[method] await expect(stopSession('session-1')).resolves.toBe(true) - expect(closeSession).toHaveBeenCalledWith('session-1') + // A host with no cause to name passes none; the adapter settles its turn as news. + expect(closeSession).toHaveBeenCalledWith('session-1', undefined) } ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 9a759ba0411..72291de4824 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -4,7 +4,10 @@ import type { AgentSessionAccountHome, AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionStopCause +} from './structured-agent-session-adapter' type RoutedAgent = 'claude' | 'codex' type SessionRoute = { adapter: StructuredAgentSessionAdapter; state: 'live' | 'stopped' } @@ -148,20 +151,21 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi accountHome: AgentSessionAccountHome }) => this.requireAgent(input.identity).providerHistoryWindow?.(input) ?? Promise.resolve(null) - closeSession = (sessionId: string): Promise => - this.stopSession(sessionId, (adapter) => adapter.closeSession) + closeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => + this.stopSession(sessionId, (adapter) => adapter.closeSession, cause) forceCloseSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => adapter.forceCloseSession ?? adapter.closeSession) - disposeSession = (sessionId: string): Promise => - this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession) + disposeSession = (sessionId: string, cause?: StructuredAgentSessionStopCause): Promise => + this.stopSession(sessionId, (adapter) => adapter.disposeSession ?? adapter.closeSession, cause) private async stopSession( sessionId: string, selectStop: ( adapter: StructuredAgentSessionAdapter - ) => NonNullable | undefined + ) => NonNullable | undefined, + cause?: StructuredAgentSessionStopCause ): Promise { const route = this.routes.get(sessionId) if (!route) { @@ -174,7 +178,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return true } const stop = selectStop(route.adapter) - const stopped = await stop?.call(route.adapter, sessionId) + const stopped = await stop?.call(route.adapter, sessionId, cause) if (stopped === true) { route.state = 'stopped' return true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 5b8a9b190ff..6d19c8523bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -38,6 +38,11 @@ import type { ProviderDiagnostic, SubmissionRejectionFact } from '../../../shared/agent-session-failure' +import type { StructuredAgentSessionStopCause } from './structured-agent-session-stop-cause' +export type { + StructuredAgentSessionChildEndCause, + StructuredAgentSessionStopCause +} from './structured-agent-session-stop-cause' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' @@ -186,6 +191,8 @@ export type StructuredAgentSessionEndedEvent = { * Orca fault. Absent reads as a provider exit with nothing to add. */ failure?: SubmissionRejectionFact cause: 'unexpected-exit' | 'requested-close' + /** With `requested-close`: who asked for it. Absent when the host named no cause. */ + stopCause?: StructuredAgentSessionStopCause fence: number acquisitionGeneration: string /** Host receipt of the child exit: the end time of a turn it interrupted. */ @@ -375,11 +382,11 @@ export type StructuredAgentSessionAdapter = { /** Gracefully stops the structured owner after its event stream is drained. */ /** Returns true only after the provider child exit is proven. A root-exit or processless verdict * is thrown only once the session is finalized; read it through `stopAgentSessionProviderRoot`. */ - closeSession?(sessionId: string): Promise + closeSession?(sessionId: string, cause?: StructuredAgentSessionStopCause): Promise /** Stops a provider after a sink failure; the resulting exit is recovered as unexpected. */ forceCloseSession?(sessionId: string): Promise /** Stops a provider child for teardown without requiring a future-resume cursor. */ - disposeSession?(sessionId: string): Promise + disposeSession?(sessionId: string, cause?: StructuredAgentSessionStopCause): Promise /** Host acknowledgement that the proven-dead child, lease and journal owner are released. */ acknowledgeSessionRelease?(sessionId: string): void } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index f209a333e97..778bd3cc2fa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -135,7 +135,8 @@ describe('Claude root-exit stop', () => { now: () => NOW + 30 * 60_000, publishStatus }, - 'session-1' + 'session-1', + { cause: 'evict' } ) ).resolves.toBeUndefined() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts new file mode 100644 index 00000000000..83d80437de3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -0,0 +1,340 @@ +// A turn the host cuts short by closing its provider is the user's cancellation only when the user +// closed this chat. A quit, an idle eviction or a teardown aimed elsewhere leaves it news: the user +// needs to learn it did not finish. The adapter settles its own open turn with the cause the host +// hands its close, and the host's fallback settles any turn no adapter did, through one mapping. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status' +import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' +import { + adapter, + attach, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' +import { + childEndCauseOfEndedEvent, + turnVerdictForChildEnd +} from './structured-agent-session-stale-turn-verdict' + +const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 } + +let host: StructuredAgentSessionHost +/** What the provider writes on the open turn as it exits: settled through the mapping with the + * cause its close was handed, as both adapters do, or its own verdict; null writes nothing. */ +let providerEnd: + | 'mapped' + | Pick + | null +/** The provider already saw its own exit when the close arrived. */ +let exitObservedFirst: boolean +let closeCalls = 0 + +beforeEach(() => { + const state = hostTestState() + providerEnd = 'mapped' + exitObservedFirst = false + closeCalls = 0 + host = new StructuredAgentSessionHost({ + store: state.store, + adapter: { + ...adapter(), + closeSession: async (_sessionId, cause) => { + closeCalls += 1 + const events = state.acquire.mock.calls.at(-1)?.[0].events + if (providerEnd === null) { + return true + } + const end = + providerEnd === 'mapped' + ? turnVerdictForChildEnd( + childEndCauseOfEndedEvent({ + type: 'ended', + cause: exitObservedFirst ? 'unexpected-exit' : 'requested-close', + ...(cause ? { stopCause: cause } : {}) + }), + 1_500 + ) + : providerEnd + events?.appendItem( + CUT_TURN, + { + kind: 'turn', + turnId: 'cut-turn', + startedAt: 1_000, + requestedAt: 1_000, + ...end + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return true + } + }, + journalDatabase: openTestJournalHostDatabase(state.root), + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + replaceHostTestState({ store: state.store, host }) +}) + +/** A running turn, anchored to its user row, with a status list watching the session. */ +async function runningTurn(): Promise { + await attach() + const events = hostTestState().acquire.mock.calls[0]?.[0].events + if (!events) { + throw new Error('missing provider event sink') + } + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'cut-turn', ordinal: 0 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + events.appendItem( + CUT_TURN, + { + kind: 'turn', + turnId: 'cut-turn', + state: 'running', + startedAt: 1_000, + requestedAt: 1_000 + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) + const statuses: AgentSessionStatusEvent[] = [] + host.subscribeStatus({ id: 'list', emit: (event) => statuses.push(event) }) + return statuses +} + +/** What the settle wrote, read back from the journal the next reader opens. */ +async function settledTurn() { + await host.restoreReadableSessions([SESSION]) + const { items } = await host.journalSnapshot(SESSION) + const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) + const [settled] = [...selectStructuredAgentSettledTurns(items).values()] + return { turn, settled } +} + +function lastSummary(statuses: AgentSessionStatusEvent[]) { + const last = statuses.at(-1) + return last?.type === 'status' ? last.session : null +} + +describe('a turn cut short by closing its provider', () => { + it("records the user's close of this chat as their cancellation", async () => { + const statuses = await runningTurn() + + await host.close(SESSION, 'user-close') + + expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) + const { turn, settled } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect( + settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) + ).toMatchObject({ key: 'interruptedAfter' }) + }) + + it('leaves a close the user did not aim at this chat as news', async () => { + const statuses = await runningTurn() + + // What an idle eviction, a worktree teardown or an orchestration stop issues. + await host.close(SESSION, 'evict') + + expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'interruption' }) + const { turn, settled } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + expect( + settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) + ).toMatchObject({ key: 'failedAfter' }) + }) + + it("records the user's close on a turn no adapter settled, through the host's fallback", async () => { + providerEnd = null + const statuses = await runningTurn() + + await host.close(SESSION, 'user-close') + + expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + it("records the user's close on a turn whose start landed only as the provider stopped", async () => { + // A send echo still in flight when the close arrives: the journal has no turn yet. + await attach() + await host.flushStreamedEvents(SESSION) + + await host.close(SESSION, 'user-close') + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + it('leaves a turn cut off before the close as news', async () => { + providerEnd = null + await attach() + const events = hostTestState().acquire.mock.calls[0]?.[0].events + // An earlier death the user already saw as Interrupted, then closed. + events?.appendItem( + CUT_TURN, + { + kind: 'turn', + turnId: 'cut-turn', + state: 'interrupted', + startedAt: 1_000, + requestedAt: 1_000, + completedAt: 1_200 + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) + + await host.close(SESSION, 'user-close') + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', completedAt: 1_200 }) + expect(turn).not.toHaveProperty('outcome') + }) + + it('keeps a turn the provider finished during the stop as finished', async () => { + providerEnd = { state: 'completed', outcome: 'success', completedAt: 1_500 } + await runningTurn() + + await host.close(SESSION, 'user-close') + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'completed', outcome: 'success' }) + }) + + it('keeps a verdict the provider gave on its way out', async () => { + // How Codex records a turn it reports failed. + providerEnd = { state: 'interrupted', outcome: 'failure', completedAt: 1_500 } + await runningTurn() + + await host.close(SESSION, 'user-close') + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'failure' }) + }) + + it('leaves a quit as news', async () => { + const statuses = await runningTurn() + + await host.flushAllStreamedEvents({ trigger: 'quit' }) + + expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({ + session: { status: 'idle', turnOutcome: 'interruption' } + }) + const { settled } = await settledTurn() + expect( + settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) + ).toMatchObject({ key: 'failedAfter' }) + }) + + it("keeps the user's cancellation when a close aborts after the provider settled, then retries", async () => { + await runningTurn() + const sink = host['runtimeState'].eventSinkFor(SESSION) + const drained = sink.drained.bind(sink) + let failed = false + vi.spyOn(sink, 'drained').mockImplementation(async () => { + if (closeCalls > 0 && !failed) { + failed = true + return { ok: false, error: new Error('drain failed') } + } + return drained() + }) + // The adapter settled the turn, then the close aborted at the drain after it. + await expect(host.close(SESSION, 'user-close')).rejects.toThrow() + expect(closeCalls).toBe(1) + await host.close(SESSION, 'user-close') + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + it.each([ + ['user-close', { outcome: 'cancellation' }], + ['evict', { outcome: undefined }] + ] as const)( + "keeps a %s's cause when the idle sweep finishes a wind-down it could not", + async (cause, verdict) => { + providerEnd = null + await runningTurn() + const sink = host['runtimeState'].eventSinkFor(SESSION) + const drained = sink.drained.bind(sink) + let failed = false + vi.spyOn(sink, 'drained').mockImplementation(async () => { + if (closeCalls > 0 && !failed) { + failed = true + return { ok: false, error: new Error('drain failed') } + } + return drained() + }) + // The provider is proven gone, then the close aborts before the host settles its turn. + await expect(host.close(SESSION, cause)).rejects.toThrow() + + await host.collaboratorsForTests().lifetime.idleSweep.tick() + + expect(closeCalls).toBe(1) + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn?.outcome).toBe(verdict.outcome) + } + ) + + it("leaves a quit's cut on a turn no adapter settled as news", async () => { + providerEnd = null + await runningTurn() + + await host.flushAllStreamedEvents({ trigger: 'quit' }) + + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + }) + + it('leaves a crash the provider saw before the user closed the chat as news', async () => { + exitObservedFirst = true + await runningTurn() + // The provider reports its own exit, which it saw first, as it closes: no verdict. + await host.close(SESSION, 'user-close') + const { turn } = await settledTurn() + expect(turn).toMatchObject({ state: 'interrupted' }) + expect(turn).not.toHaveProperty('outcome') + }) + + it.each(['user-close', 'evict'] as const)( + 'closes the conversation of a chat a %s ends, as any close does', + async (cause) => { + await runningTurn() + + await host.close(SESSION, cause) + + expect(host.hasSession(SESSION)).toBe(false) + } + ) +}) + +it('requires every stop to name its cause', () => { + type StopArgs = Parameters + // @ts-expect-error a stop that names no cause must not compile, or it would default to one + const omitted: StopArgs = [host['lifetimeContext'](), SESSION] + expect(omitted).toHaveLength(2) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts index 993ee3fd258..016819a9607 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts @@ -131,7 +131,7 @@ describe('the completion feed around /compact (B6)', () => { expect(latestStructuredAgentSessionRequest(items, submissions)).toMatchObject({ kind: 'turn', id: 'turn-1', - running: false, + turnState: 'completed', outcome: 'success' }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 977b0ff55cf..5fd28ff3927 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -137,8 +137,8 @@ describe('closing the handle', () => { it('keeps the row when a chat with an open tab is evicted, and forgets it once the tab closes', async () => { await foundRestTestChat(rig) - await rig.host.close(SESSION) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + await rig.host.close(SESSION, 'evict') + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict') // The stop says not-running; the row belongs to the tab, so nothing forgets it. expect(rig.sink.forget).not.toHaveBeenCalled() expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) @@ -261,7 +261,7 @@ describe('a start that never finishes (P2-15)', () => { rig.clock.now += IDLE_MS + 1 await sweepOnce(rig.host) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'host-stop') await vi.waitFor(() => expect(readerSaw(reader.events).submissions).toContainEqual( expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 4ecfea0381b..1d06a61f88c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -15,8 +15,10 @@ import { abandonQueuedStructuredAgentSessionMessages, closeStructuredAgentSessionConversationUnderSerialize, stopStructuredAgentSessionAgentUnderSerialize, + type StructuredAgentSessionCloseCause, type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' +import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' @@ -46,9 +48,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { session.journal.whenImported().catch((error: unknown) => { throw readRefusals.refusal(sessionId, error) }) - // The sweep's stop puts an idle agent to rest: nothing is queued, so no loop reads its cause. - const stopAgent = (sessionId: string) => - stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId) + const stopAgent = (sessionId: string, cause: StructuredAgentSessionStopCause) => + stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) const closeConversation = (sessionId: string): Promise => closeStructuredAgentSessionConversationUnderSerialize( @@ -75,7 +76,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { return record !== null && deps().hasOpenDispatch?.(record) === true }, providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, - stopAgent, + // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. + stopAgent: (sessionId) => stopAgent(sessionId, 'evict'), // A host stop: the delivery loop waiting on this child writes the one error row and rejects // what is queued with it, both worded from the hostStopped fact. stopStartingAgent: (sessionId) => @@ -146,7 +148,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { }, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is * still queued will not be sent. */ - close: (sessionId: string): Promise => + close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => serialize(sessionId, async () => { readRefusals.forget(sessionId) const session = sessions.get(sessionId) @@ -154,9 +156,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { // Abandoned before the stop, so no start delivers it. await abandonQueuedStructuredAgentSessionMessages(deps(), sessionId, session.journal) } - await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { - cause: 'evict' - }) + await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) await closeConversation(sessionId) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index c7f8f09631a..9d396b58ffa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -10,10 +10,14 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionStatusSummary, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status' import { completedStructuredAgentTurnSeconds, selectStructuredAgentTurnTimings @@ -291,6 +295,36 @@ describe('a turn a read reached before the reconcile proved its owner dead', () unsubscribe() }) + it('reports the revision to the status feed as an interruption, which the chat folds as failed', async () => { + const published: AgentSessionStatusSummary[] = [] + openHost({ + probeOwner: async () => ({ outcome: 'pid-absent' }), + statusSink: { publish: (summary) => published.push(summary), forget: () => {} } + }) + await host.history({ sessionId: SESSION, direction: 'tail' }) + const outcomes = () => + published + .filter((summary) => summary.sessionId === SESSION && summary.turnOutcome) + .map((summary) => summary.turnOutcome) + expect(outcomes().at(-1)).toBe('unconfirmed') + + await host.reconcileRestartLeases() + await drainSession() + + // The sidebar's red Failed, then the folded "Failed after 27s". + await vi.waitFor(() => expect(outcomes().at(-1)).toBe('interruption')) + const [timing] = selectStructuredAgentTurnTimings( + (await host.journalSnapshot(SESSION)).items + ).values() + expect( + describeNativeChatTurnStatus({ + elapsedSeconds: 0, + workedSeconds: completedStructuredAgentTurnSeconds(timing), + verdict: timing?.verdict + }) + ).toEqual({ key: 'failedAfter', duration: '27s' }) + }) + it('revises nothing twice, whoever re-runs the settle', async () => { openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) await host.history({ sessionId: SESSION, direction: 'tail' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 373e2ae3c36..433505bbd69 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -10,6 +10,7 @@ // would have written them. Stop and the conversation's close are the only other writers of a // queued message: a child's exit only ends the child, and this loop reads why. +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionFailureFact, @@ -53,6 +54,11 @@ export type StructuredAgentSessionDeliveryLoopDeps = { ) => Promise /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number + /** Rejects queued messages as a completed close of the chat does; false when that failed. */ + abandonQueued: ( + sessionId: string, + which: (submission: AgentJournalSubmission) => boolean + ) => Promise /** Who the chat's failure sentences name. */ failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext onError: (sessionId: string, error: unknown) => void @@ -149,6 +155,10 @@ export class StructuredAgentSessionDeliveryLoop { // A handle closes only with nothing queued, so one an earlier handle wrote is a leftover. (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) ) + if (!(await this.closeWhatTheUserClosed(sessionId, session))) { + // Never start an agent for a message the user closed; the next wake re-derives and retries. + return this.stop(sessionId) + } const oldest = oldestQueuedSubmission(session) // A running command takes no input while its child carries it; its end is a commit, which // wakes the loop again. With no child it is a gone generation's, which the start below settles. @@ -193,8 +203,8 @@ export class StructuredAgentSessionDeliveryLoop { // The child waited on is gone, replaced by another, or settled its start without proving it. const ended = awaitedChild ? undefined : session.lastEndedChild const endedFailure = ended ? structuredAgentSessionEndedChildFailure(ended) : undefined - // A user's Stop is not a failure: the next step starts, or waits on, a child for what is - // queued. + // A user's Stop or close is not a failure: the next step starts, or waits on, a child for + // what is queued, after closing what a close of the chat closed. if (endedFailure === null) { return 'continue' } @@ -243,6 +253,27 @@ export class StructuredAgentSessionDeliveryLoop { return this.stop(sessionId) } + /** A close of this chat that stopped its child and then did not complete still closed what was + * queued before it, so no child starts for those. Ordered, not latched: a later send goes on. + * False when those could not be closed. */ + private async closeWhatTheUserClosed( + sessionId: string, + session: StructuredAgentSessionHostSession + ): Promise { + const ended = session.lastEndedChild + if (session.child || ended?.cause !== 'user-close') { + return true + } + const { epoch } = session.journal.cursor() + return this.deps.abandonQueued( + sessionId, + (submission) => + ended.endedAt.epoch === epoch && + submission.acceptedSequence !== undefined && + submission.acceptedSequence <= ended.endedAt.sequence + ) + } + /** Inside the serialized step that found nothing to do, so an accept after it wakes anew. */ private stop(sessionId: string): 'stop' { this.running.delete(sessionId) @@ -281,6 +312,8 @@ function providerEndFailure( // Every end cause, so a new one does not compile until it says whether it fails what is queued. const ENDED_CHILD_FAILURE = { 'user-stop': () => null, + // The user closing this chat closes what was queued before it; see `closeWhatTheUserClosed`. + 'user-close': () => null, // The host stopping the child is Orca's cause, never the provider's: a start that never finished. 'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }), exit: providerEndFailure, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts index 8fc810de2a4..38349f95115 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -76,7 +76,7 @@ describe('structured agent session eviction', () => { await evictStructuredAgentSession(ctx) - expect(disposeSession).toHaveBeenCalledWith('session-1') + expect(disposeSession).toHaveBeenCalledWith('session-1', undefined) expect(closeSession).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts index 226781117bd..fc45a2c7715 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -19,7 +19,8 @@ import { stopAgentSessionProviderRoot, - type StructuredAgentSessionAdapter + type StructuredAgentSessionAdapter, + type StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { StructuredAgentSessionStopVerdict } from './structured-agent-session-host-types' @@ -27,6 +28,8 @@ import { withTimeout } from '../../../shared/promise-timeout-fallback' export type StructuredAgentSessionEvictionContext = { sessionId: string + /** Why the host stops the child; the adapter settles the turn it cuts with it. */ + stopCause?: StructuredAgentSessionStopCause hasProviderChild?: boolean eventSink: DeferredStructuredAgentSessionEventSink adapter: StructuredAgentSessionAdapter @@ -86,7 +89,9 @@ export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSe // An adapter with no close has nothing to stop; anything else must PROVE the exit. const stop = context.adapter.disposeSession ?? context.adapter.closeSession const rootGone = stop - ? await stopAgentSessionProviderRoot(() => stop.call(context.adapter, context.sessionId)) + ? await stopAgentSessionProviderRoot(() => + stop.call(context.adapter, context.sessionId, context.stopCause) + ) : true if (!rootGone) { throw new Error('provider child exit was not proven') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index ed2e48fd562..cdb7b473ae2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -3,6 +3,7 @@ // with a message queued has a delivery loop — and the open is where a loop for leftovers wakes. import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { abandonQueuedStructuredAgentSessionMessages } from './structured-agent-session-host-lifetime' import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -65,6 +66,12 @@ export function createStructuredAgentSessionConversationDelivery(input: { ensureProviderChild: input.ensureProviderChild, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), + abandonQueued: async (sessionId, which) => { + const session = sessions.get(sessionId) + return session + ? abandonQueuedStructuredAgentSessionMessages(deps, sessionId, session.journal, which) + : true + }, failureTextContext: (sessionId) => structuredAgentSessionFailureWordsContext( deps.store.getRecord(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index f2140c98fbc..719540da5da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -9,6 +9,7 @@ // reentrant, so every public entry point takes it once and calls these. import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { @@ -19,7 +20,6 @@ import { import { withStructuredAgentSessionEvictionDeadline } from './structured-agent-session-eviction-deadline' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { - StructuredAgentSessionChildEndCause, StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, StructuredAgentSessionProviderChildIdentity @@ -30,6 +30,8 @@ import { } from './structured-agent-session-provider-child' import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' +import { turnVerdictForChildEnd } from './structured-agent-session-stale-turn-verdict' +import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' export type StructuredAgentSessionLifetimeContext = { deps: StructuredAgentSessionHostDeps @@ -51,18 +53,27 @@ type ConversationCloseDeps = Pick { - await journal + journal: StructuredAgentSessionHostSession['journal'], + which?: (submission: AgentJournalSubmission) => boolean +): Promise { + return journal .rejectQueuedSubmissions( structuredAgentSessionConversationFence(deps.store, sessionId), - agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) + agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }), + which + ) + .then( + () => true, + (error: unknown) => { + deps.onEventSinkError?.({ sessionId, error }) + return false + } ) - .catch((error: unknown) => deps.onEventSinkError?.({ sessionId, error })) } /** The wind-down this host owes for the session's child. A live child always owes one, whatever a @@ -85,20 +96,22 @@ function owedProviderChildWindDown( export async function stopStructuredAgentSessionAgentUnderSerialize( context: StructuredAgentSessionLifetimeContext, sessionId: string, - ending: { - cause: Extract - reason?: string - } = { cause: 'user-stop' } + // Required: an omitted cause must not default to the user's cancellation. + ending: { cause: StructuredAgentSessionStopCause; reason?: string } ): Promise { const session = context.sessions.get(sessionId) if (!session) { return } + // A retry finishes the stop that ended the child, so the turn that stop cut keeps its cause. + const cause = session.child + ? ending.cause + : (session.owesProviderChildWindDown?.cause ?? ending.cause) // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, // so a step that aborts after that point would otherwise leave the retry reading "no child // here" and skipping the settlement and the lease release it still owes. const owed = owedProviderChildWindDown(session) - session.owesProviderChildWindDown = owed + session.owesProviderChildWindDown = owed ? { ...owed, cause } : undefined const stopping = session.child let settlementError: unknown const eviction: StructuredAgentSessionEvictionContext = { @@ -108,6 +121,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( owesProviderChildWindDown: owed !== undefined, eventSink: context.runtimeState.eventSinkFor(sessionId), adapter: context.deps.adapter, + // The adapter settles its own open turn with this, so who asked travels with the stop. + stopCause: cause, ...(context.restartWitness ? { beforeProviderChildStop: () => context.restartWitness?.beforeStop(sessionId) } : {}), @@ -117,7 +132,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( endProviderChild(session, { generation: stopping.generation, fence: stopping.fence, - cause: ending.cause, + cause, reason: ending.reason ?? null, duringStartup: stopping.phase === 'starting', ...verdict @@ -136,7 +151,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( fence, settlementId: `expected-close:${sessionId}:${fence}:${owed?.generation ?? 'unknown'}`, pendingSubmissionReason: 'provider_closed_before_acknowledgement', - verdict: { state: 'interrupted', completedAt: context.now() }, + // Only a turn no adapter settled: one with no close, or whose settle threw. + verdict: turnVerdictForChildEnd(cause, context.now()), showUnexpectedExitOutcome: false, onError: (id, error) => { settlementError = error @@ -170,6 +186,12 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( ) } +/** A close's cause: the user closing this chat, or the host evicting it (quit, idle, teardown). */ +export type StructuredAgentSessionCloseCause = Extract< + StructuredAgentSessionStopCause, + 'user-close' | 'evict' +> + /** Whether the conversation's handle is only a cache now: no child, no wind-down owed, and nothing * queued or waiting on the provider. */ export function structuredAgentSessionConversationClosable( @@ -228,7 +250,7 @@ export async function evictOwnedStructuredAgentSessions( ownedSessionIds.map(async (sessionId) => { try { await context.serialize(sessionId, () => - stopStructuredAgentSessionAgentUnderSerialize(context, sessionId) + stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { cause: 'evict' }) ) retainOnFailure.delete(sessionId) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 4ce0b3885ad..e6700777227 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -174,7 +174,9 @@ export async function flushStructuredAgentSessionHost( retainSessionIds, acknowledgeSessionRelease: (sessionId) => context.deps.adapter.acknowledgeSessionRelease?.(sessionId), - abandonQueued: (sessionId, session) => - abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal) + // Quit's is best effort: a failure is reported, and the next open rejects the leftover. + abandonQueued: async (sessionId, session) => { + await abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal) + } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index 4d064cbdfaa..2971ce4f79c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -80,7 +80,7 @@ describe('abandoning a structured agent-session host', () => { expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) // The conversation stays and its provider child does not, so the next send makes the delivery // loop start one — the shape the refusal-oracle spec ends on. - await host.close(SESSION) + await host.close(SESSION, 'evict') gate = new Promise((resolve) => { openGate = resolve }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index f69bc3fb06a..c2ec1833288 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -10,7 +10,9 @@ import type { JournalHostDatabase } from '../agent-session-journal/journal-host- import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter, - StructuredAgentSessionProviderChildPhase + StructuredAgentSessionChildEndCause, + StructuredAgentSessionProviderChildPhase, + StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' @@ -35,6 +37,11 @@ export type StructuredAgentSessionProviderChildIdentity = { readonly fence: number } +/** A wind-down still owed, with the cause of the stop that owes it: a retry finishes that stop. */ +export type StructuredAgentSessionOwedWindDown = StructuredAgentSessionProviderChildIdentity & { + readonly cause: StructuredAgentSessionStopCause +} + /** The provider process behind a conversation. Written only in * `structured-agent-session-provider-child`. */ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & { @@ -50,12 +57,7 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider * `stopAgentSessionProviderRoot`; an observed exit's root is gone by definition. */ export type StructuredAgentSessionStopVerdict = { rootGone: boolean } -export type StructuredAgentSessionChildEndCause = - | 'user-stop' - | 'host-stop' - | 'exit' - | 'attach-failed' - | 'evict' +export type { StructuredAgentSessionChildEndCause } /** How the conversation's last child ended. In memory only: the delivery loop reads it to tell a * Stop from a failure. */ @@ -87,7 +89,7 @@ export type StructuredAgentSessionHostSession = { /** The wind-down this host still owes for a child it started: settling that generation's work * and handing the lease back. Outlives `child`, which ends the moment the adapter proves the * exit — an eviction that aborts after that point must still finish it on the next close. */ - owesProviderChildWindDown?: StructuredAgentSessionProviderChildIdentity + owesProviderChildWindDown?: StructuredAgentSessionOwedWindDown lastEndedChild?: StructuredAgentSessionEndedChild } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index ba3b7581734..62603707ded 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -184,14 +184,15 @@ export class StructuredAgentSessionHost { handleAdapterEvent = (event: Parameters[0]) => this.eventRecovery.handle(event) - private lifetimeContext(): StructuredAgentSessionLifetimeContext { + // Inferred, so the attach context's spread keeps `publishStatus` required. + private lifetimeContext() { return { deps: this.deps, runtimeState: this.runtimeState, sessions: this.sessions, now: () => this.now(), publishStatus: this.clientDelivery.publishStatus - } + } satisfies StructuredAgentSessionLifetimeContext } /** The host's half of attaching, named so it cannot grow dependencies unnoticed. */ @@ -202,12 +203,13 @@ export class StructuredAgentSessionHost { tasks: this.tasks, reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), - publishStatus: this.clientDelivery.publishStatus, openConversation: this.conversationDelivery.open } } - /** Releases a session's resources without ending the conversation; see the lifetime's close. */ - close = (sessionId: string): Promise => this.lifetime.close(sessionId) + /** Releases a session's resources without ending the conversation; see the lifetime's close. + * `user-close` makes a turn it cuts short the user's cancellation; an `evict` leaves it news. */ + close: StructuredAgentSessionConversationLifetime['close'] = (sessionId, cause) => + this.lifetime.close(sessionId, cause) supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) @@ -278,7 +280,7 @@ export class StructuredAgentSessionHost { ensureAgent: (sessionId) => ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), - stopAgent: this.lifetime.stopAgent, + stopAgent: (sessionId) => this.lifetime.stopAgent(sessionId, 'user-stop'), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), now: () => this.now() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 1345adc574f..533904d5fa0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -54,7 +54,7 @@ describe('the idle sweep', () => { await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) await vi.waitFor(() => expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released')) await vi.waitFor(() => expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledOnce()) expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledWith(SESSION) @@ -122,7 +122,7 @@ describe('the idle sweep', () => { expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.adapter.backgroundTaskState.mockReturnValue(undefined) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) // A finished child reads done before the lead's wake-up turn writes its first row; stopping the @@ -143,7 +143,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) // Owed work is read every tick, not once a window: work that ends just before a window would @@ -165,7 +165,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) it('stops an agent whose roster holds only children that went idle or finished', async () => { @@ -179,7 +179,7 @@ describe('the idle sweep', () => { }) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) it('never stops an agent while its lead turn runs, however quiet (P2-10)', async () => { @@ -202,7 +202,7 @@ describe('the idle sweep', () => { await rig.host.subscribe({ id: 'on-screen', sessionId: SESSION, emit: reader.emit }) rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) expect(reader.events.some((event) => event.type === 'end')).toBe(false) }) @@ -221,7 +221,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) it('never stops a worker whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => { @@ -237,7 +237,7 @@ describe('the idle sweep', () => { expect(hasOpenDispatch).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION })) open = false rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) // A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row @@ -254,7 +254,7 @@ describe('the idle sweep', () => { await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() rig.clock.now += IDLE_MS + 1 - await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) it('keeps a child an unanswered prompt waits on (P2-22 i)', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 510384f3e48..44615a27ee8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -129,7 +129,7 @@ beforeEach(async () => { afterEach(async () => { await host.flushAllStreamedEvents() - await host.close(SESSION) + await host.close(SESSION, 'evict') await rm(root, { recursive: true, force: true }) }) @@ -202,7 +202,7 @@ describe('settling a send the provider proves it received after the ack window', return true }) - await host.close(SESSION) + await host.close(SESSION, 'evict') await expect(settlement).resolves.toBeUndefined() await host.revealSession(SESSION) expect(await submissions()).toMatchObject([{ dispatchState: 'accepted' }]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 1ec8f2784a2..aa7b8a17295 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -212,9 +212,9 @@ describe('structured session options and close', () => { it('stops the provider child and forgets the session when the chat closes', async () => { expect(host.hasSession(SESSION)).toBe(true) - await host.close(SESSION) + await host.close(SESSION, 'evict') - expect(closeNativeSession).toHaveBeenCalledWith(SESSION) + expect(closeNativeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null, @@ -222,15 +222,15 @@ describe('structured session options and close', () => { }) expect(host.hasSession(SESSION)).toBe(false) - await expect(host.close(SESSION)).resolves.toBeUndefined() + await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() expect(closeNativeSession).toHaveBeenCalledOnce() }) it('is a no-op for a session it does not hold', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') closeNativeSession.mockClear() - await expect(host.close(SESSION)).resolves.toBeUndefined() + await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() expect(closeNativeSession).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 75061435a1f..230709e5d21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -28,6 +28,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { HOST_TEST_LOCATION, HOST_TEST_NOW as NOW, @@ -41,6 +42,9 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } +const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { + surface: 'rejection' +}) let root: string let store: AgentSessionRecordStore @@ -119,7 +123,7 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) startHost() expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - await host.close(SESSION) + await host.close(SESSION, 'evict') }) afterEach(async () => { @@ -642,7 +646,7 @@ describe('a quit with a message still queued', () => { starting.resolve() await quit - expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) expect(dispatch).not.toHaveBeenCalled() expect(await afterRelaunch(id)).toMatchObject({ @@ -715,6 +719,98 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(await statusRows()).toEqual([]) }) + /** The close's stop alone: a close that aborts after it leaves the conversation indexed. */ + function closeStopOnly() { + return host['serialize'](SESSION, () => + stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { + cause: 'user-close' + }) + ) + } + + /** A message queued behind a starting child when the close's stop cut it. `beforeStart` runs + * after the stop and before the loop looks again. */ + async function closedWhileStarting(beforeStart: () => void = () => undefined) { + const start = deferred() + adapterExtras = { + awaitStarted: vi.fn(() => start.promise), + closeSession: vi.fn(async () => true) + } + await restartHost() + acquire.mockImplementationOnce(spawnStartingChild) + const first = await accept('first') + await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + await closeStopOnly() + const starts = acquire.mock.calls.length + beforeStart() + start.resolve() + await settleLoop() + return { first, starts } + } + + it('closes what was queued when the user closed the chat, and starts no child for it', async () => { + const { first, starts } = await closedWhileStarting() + + expect(await submission(first)).toMatchObject({ + dispatchState: 'rejected', + ...CHAT_CLOSED + }) + expect(acquire).toHaveBeenCalledTimes(starts) + expect(dispatch).not.toHaveBeenCalled() + expect(await statusRows()).toEqual([]) + }) + + it('starts no child when closing what was queued fails, and closes it on the next wake', async () => { + const { first, starts } = await closedWhileStarting(() => { + const journal = conversation()!.journal + const reject = journal.rejectQueuedSubmissions.bind(journal) + vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation(async (...args) => { + if (args[1].rejection.kind === 'chatClosed') { + vi.mocked(journal.rejectQueuedSubmissions).mockImplementation(reject) + throw new Error('disk full') + } + return reject(...args) + }) + }) + + expect((await submission(first))?.dispatchState).toBe('pending') + expect(acquire).toHaveBeenCalledTimes(starts) + expect(dispatch).not.toHaveBeenCalled() + + const second = await accept('second') + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) + expect(await submission(first)).toMatchObject({ + dispatchState: 'rejected', + ...CHAT_CLOSED + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('goes on with a message sent in a later epoch, whose sequence restarts at or below the close', async () => { + await closedWhileStarting() + const session = conversation()! + await session.journal.rollEpoch( + 'corruption', + structuredAgentSessionConversationFence(store, SESSION) + ) + + const second = await accept('second') + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) + expect(session.lastEndedChild).toMatchObject({ cause: 'user-close' }) + expect((await submission(second))!.acceptedSequence).toBeLessThanOrEqual( + session.lastEndedChild!.endedAt.sequence + ) + }) + + it('goes on with a message sent after the close, never failing it', async () => { + const second = await stoppedWhileStarting(closeStopOnly) + + await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) + expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-close' }) + expect(await statusRows()).toEqual([]) + }) + it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => { const reason = 'the start watchdog fired' const second = await stoppedWhileStarting(() => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index ff4b5e9c4ee..f5e0e14b09f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -140,7 +140,7 @@ describe('a publish-first Claude create whose init is slow', () => { await host.attach(CALLER, { ...params, options: { model: 'opus' } }) await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) - await host.close(SESSION) + await host.close(SESSION, 'evict') const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 // Starting the chat again resumes the session under a new fence. 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 9aed0d4acb8..d63af3ba7d2 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 @@ -245,7 +245,7 @@ export async function createQueuedMessageTestRig() { /** A host-process restart, as the queue sees it: the conversation closes, and * opens afresh under a new instance id while its rows survive. */ async function restartHostProcess(): Promise { - await host.close(SESSION) + await host.close(SESSION, 'evict') rotateStructuredAgentSessionHostInstanceForTests() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index aa9d31cbf10..e43897826a1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -270,7 +270,7 @@ describe('held drafts', () => { // The queue is paused, not the card: it carries no hold of its own. expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() @@ -418,7 +418,7 @@ describe('Stop and Delete', () => { await settleAccepted(working, 'a') // Evict the handle and reopen (the history read opens the conversation at // rest): the pause is derived from what the journal holds, so nothing drains. - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts index 2c5332db1bb..2dd2f567b87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts @@ -70,7 +70,7 @@ async function twoCardsBehindWork() { describe("a newer Orca's journal", () => { it('shows the cards, and refuses a queued send, Send-now and Delete with the update words', async () => { const { first, second, cards } = await twoCardsBehindWork() - await reopenOnNewerOrcaDatabase(() => rig.host.close(HOST_TEST_SESSION)) + await reopenOnNewerOrcaDatabase(() => rig.host.close(HOST_TEST_SESSION, 'evict')) expect(await readOnlyQueue()).toEqual({ cards, pause: null }) // The waiting cards would queue this send behind them; the journal takes no new draft. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 1349a6a178e..63a12313bf6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -342,7 +342,7 @@ describe("a restart's pause", () => { await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) // Reopened, that turn is "before this open", yet the pause it ended stays ended: // the lift adopted the rows into this process. - await rig.host.close(HOST_TEST_SESSION) + await rig.host.close(HOST_TEST_SESSION, 'evict') expect(await rig.queuePause()).toBeNull() expect(await rig.drafts()).toContainEqual({ messageId: second, state: 'waiting' }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index f7d26c154f1..538fb0b9dbc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -1,8 +1,9 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A turn that was running when its host went away ends when recovery settles it. That settlement is // the edge the user needs to see — their work stopped — so the session reads as newly done then, -// and nothing along the way may call it a success. Every hop is the real one: durable journal, -// recovery settlement, status feed, the host's status row, and the turn-completion feed. +// with what the host observed of the end as its verdict, and nothing along the way may call it a +// success. Every hop is the real one: durable journal, recovery settlement, status feed, the host's +// status row, and the turn-completion feed. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -12,6 +13,12 @@ import type { AgentSessionStatusSummary, AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' +import { + agentTurnEndedOnPurpose, + agentVerdictDisplayMark +} from '../../../shared/agent-main-agent-verdict' +import { formatNativeChatTurnStatusLabel } from '../../../shared/native-chat-turn-status' +import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' import { AgentHookServer, _internals } from '../../agent-hooks/server' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -19,7 +26,11 @@ import { settleStaleStructuredAgentSessionState, settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' -import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' +import { + childEndCauseOfEndedEvent, + turnVerdictForChildEnd, + type StructuredAgentSessionTurnVerdict +} from './structured-agent-session-stale-turn-verdict' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionTurnCompletionFeed } from './structured-agent-session-turn-completion-feed' @@ -131,11 +142,22 @@ function settleDeadGeneration( describe('a turn recovery settled after its host went away', () => { it.each([ - ['an unverifiable end', { state: 'unverifiable' } as const], - ['an exit observed before the restart', { state: 'interrupted', completedAt: EXIT_OBSERVED }] - ] satisfies [string, StructuredAgentSessionTurnVerdict][])( - 'is done as of the recovery, never as a success: %s', - async (_label, verdict) => { + ['an unverifiable end', { state: 'unverifiable' } as const, 'unconfirmed', 'unconfirmed'], + [ + 'an exit observed before the restart', + { state: 'interrupted', completedAt: EXIT_OBSERVED }, + 'interruption', + // A turn the user did not stop is a fault, marked as a failure is. + 'failed' + ] + ] satisfies [ + string, + StructuredAgentSessionTurnVerdict, + 'unconfirmed' | 'interruption', + 'unconfirmed' | 'failed' + ][])( + 'is done as of the recovery with the end the host observed, never a success: %s', + async (_label, verdict, outcome, mark) => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) expect(await settleDeadGeneration(session.journal, verdict)).toBe(true) @@ -143,22 +165,75 @@ describe('a turn recovery settled after its host went away', () => { expect(session.summaries.at(-1)).toMatchObject({ status: 'idle', - statusStartedAt: RECOVERED + statusStartedAt: RECOVERED, + turnOutcome: outcome }) - expect(session.summaries.at(-1)).not.toHaveProperty('turnOutcome') const [row] = session.server.getStatusSnapshot() // A done row dated at the recovery is a completion the user has not read yet. expect(row).toMatchObject({ state: 'done', stateStartedAt: RECOVERED, - mainAgent: { state: 'done', stateStartedAt: RECOVERED } + mainAgent: { state: 'done', outcome, stateStartedAt: RECOVERED } }) - expect(row?.mainAgent).not.toHaveProperty('outcome') + // Nobody stopped it: the flag older readers take as a user's stop stays down. + expect(row?.interrupted ?? false).toBe(false) + // The sidebar and tab read the published row, with no user action in between. + expect(row && agentVerdictDisplayMark(row)).toBe(mark) + expect(row && agentTurnEndedOnPurpose(row)).toBe(false) // The dot and the OS notification come only from a completion event, and none is sent. expect(session.completionEvents).toEqual([]) } ) + // The chat's turn bar and the tab's mark read one verdict: a turn nobody stopped failed, and + // must never show the done tick of a finished turn. + it.each([ + [ + 'a restart', + (journal: AgentSessionJournal) => + settleDeadGeneration(journal, { state: 'interrupted', completedAt: EXIT_OBSERVED }) + ], + [ + 'quitting Orca', + // A quit evicts the child, and its adapter settles the open turn through the one mapping. + (journal: AgentSessionJournal) => + journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 9 }, + { + kind: 'turn', + turnId: 'turn-1', + startedAt: TURN_STARTED, + ...turnVerdictForChildEnd( + childEndCauseOfEndedEvent({ + type: 'ended', + cause: 'requested-close', + stopCause: 'evict' + }), + EXIT_OBSERVED + ) + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + ] + ] as const)( + 'reads Failed after N, marked failed, for a turn cut off by %s', + async (_label, cut) => { + const session = await sessionWithRunningTurn() + session.recoverAt(RECOVERED) + await cut(session.journal) + session.publish() + + const [row] = session.server.getStatusSnapshot() + expect(row && agentVerdictDisplayMark(row)).toBe('failed') + const [settled] = [ + ...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values() + ] + expect(settled && formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...settled })).toBe( + 'Failed after 1s' + ) + } + ) + it('is dated the same way when a new provider child finds the turn still running', async () => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) @@ -173,12 +248,17 @@ describe('a turn recovery settled after its host went away', () => { expect(session.summaries.at(-1)).toMatchObject({ status: 'idle', - statusStartedAt: RECOVERED + statusStartedAt: RECOVERED, + // No evidence of the old owner's death: the end cannot be proven. + turnOutcome: 'unconfirmed' }) - expect(session.server.getStatusSnapshot()[0]).toMatchObject({ + const [row] = session.server.getStatusSnapshot() + expect(row).toMatchObject({ state: 'done', - stateStartedAt: RECOVERED + stateStartedAt: RECOVERED, + mainAgent: { state: 'done', outcome: 'unconfirmed' } }) + expect(row && agentVerdictDisplayMark(row)).toBe('unconfirmed') expect(session.completionEvents).toEqual([]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 09ba97ee8e3..83082bf923a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -346,7 +346,7 @@ describe('agentSessionRefusalOperationState host oracle', () => { } const unreadable = await createHarness() - await unreadable.host.close(SESSION) + await unreadable.host.close(SESSION, 'evict') unreadable.host.deps.adapter.historyFilePath = async () => { throw new Error('transcript unreadable') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index fbe81be21ac..86873faf6b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -145,7 +145,7 @@ async function errorStatuses(): Promise { /** The child timed out or exited: its lease is handed back and the host holds no session. */ async function loseOwner(): Promise { - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index 97c951b4517..d08f536daef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -17,11 +17,16 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' +import type { + StructuredAgentSessionChildEndCause, + StructuredAgentSessionEndedEvent +} from './structured-agent-session-adapter' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export type StructuredAgentSessionTurnVerdict = - | { state: 'interrupted'; completedAt: number } + /** `cancellation` only for a stop the user aimed at this chat: every other cut is news. */ + | { state: 'interrupted'; completedAt: number; outcome?: 'cancellation' } | { state: 'unverifiable' } export const UNVERIFIABLE_TURN_VERDICT: StructuredAgentSessionTurnVerdict = { @@ -56,6 +61,45 @@ export function turnVerdictFromDeathEvidence( } } +/** + * The one mapping from why a provider child ended to what the turn it cut reads as. Each adapter + * settles its own open turn through it on `ended`, and the host's fallback settles through it any + * turn no adapter did. Only a stop the user aimed at this chat is their cancellation. + */ +export function turnVerdictForChildEnd( + cause: StructuredAgentSessionChildEndCause, + completedAt: number +): Extract { + return stopIsTheUsers(cause) + ? { state: 'interrupted', completedAt, outcome: 'cancellation' } + : { state: 'interrupted', completedAt } +} + +/** Whether the user asked for this end. Only then is a cut turn their cancellation. */ +export function stopIsTheUsers(cause: StructuredAgentSessionChildEndCause): boolean { + switch (cause) { + case 'user-stop': + case 'user-close': + return true + case 'host-stop': + case 'evict': + case 'exit': + case 'attach-failed': + return false + } +} + +/** Why the child an `ended` event reports ended: who asked for a close, else an exit it had. A + * requested close with no cause named is the host's own. */ +export function childEndCauseOfEndedEvent( + event: { type: 'ended' } & Partial> +): StructuredAgentSessionChildEndCause { + if (event.cause === 'unexpected-exit') { + return 'exit' + } + return event.stopCause ?? 'host-stop' +} + /** Revises every still-running lifecycle item in place, keeping its identity and start. */ export function runningTurnLifecycleRevisions( items: readonly AgentJournalRenderItem[], @@ -125,5 +169,10 @@ function settledLifecycle( } // A renewal can predate the turn, which started with its owner alive; it never ends before that. const began = Math.max(lifecycle.requestedAt ?? 0, lifecycle.startedAt ?? 0) - return { ...kept, state: verdict.state, completedAt: Math.max(verdict.completedAt, began) } + return { + ...kept, + state: verdict.state, + completedAt: Math.max(verdict.completedAt, began), + ...(verdict.outcome ? { outcome: verdict.outcome } : {}) + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts new file mode 100644 index 00000000000..8b3078df8df --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts @@ -0,0 +1,15 @@ +/** Why a provider child ended. In memory only: never journaled, persisted or sent. */ +export type StructuredAgentSessionChildEndCause = + | 'user-stop' + /** The user closed this chat: its tab, its launch, or a `/clear` that replaces it. */ + | 'user-close' + | 'host-stop' + | 'exit' + | 'attach-failed' + | 'evict' + +/** Why the host asked a child to stop. The adapter carries it onto the `ended` it settles with. */ +export type StructuredAgentSessionStopCause = Extract< + StructuredAgentSessionChildEndCause, + 'user-stop' | 'user-close' | 'host-stop' | 'evict' +> diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index a06c70bf30a..871c05a6e33 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -132,7 +132,7 @@ function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): voi /** The sweep stops the child first and closes the conversation last. */ function waitForEviction(): Promise { return vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) }) } @@ -245,9 +245,9 @@ describe('a chat that closes', () => { it('stops the provider child it started', async () => { await attach() - await host.close(SESSION) + await host.close(SESSION, 'evict') - expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(closeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) expect(hostErrors).toEqual([]) // The record and its journal stay; only the process and the claim on it go. @@ -264,7 +264,7 @@ describe('a chat that closes', () => { it('answers a read from the pane that outlived it without starting a child', async () => { await attach() - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) @@ -297,7 +297,7 @@ describe('a chat that closes', () => { await vi.waitFor(() => expect(dispatch).toHaveBeenCalled()) const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) - await host.close(SESSION) + await host.close(SESSION, 'evict') // Eviction's settlement is a journal write, so the wait sees it rather than timing out. await expect(settlement).resolves.toMatchObject({ @@ -321,14 +321,14 @@ describe('a chat that closes', () => { // The child is stopped and the lease released before the handle closes; the entry is dropped // before that close, so a lost result leaves no closing handle for a reader to find. - await expect(host.close(SESSION)).rejects.toThrow('journal close result lost') + await expect(host.close(SESSION, 'evict')).rejects.toThrow('journal close result lost') expect(host.hasSession(SESSION)).toBe(false) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null }) - await expect(host.close(SESSION)).resolves.toBeUndefined() + await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() expect(closeSession).toHaveBeenCalledOnce() }) @@ -349,12 +349,12 @@ describe('a chat that closes', () => { }) const settled = captureSettledSubmissions() - await expect(host.close(SESSION)).rejects.toMatchObject({ step: 'drain-published' }) + await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' }) // The child is proven gone, but the wind-down it owes is not done: nothing settled, no release. expect(session!.child).toBeNull() expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') - await expect(host.close(SESSION)).resolves.toBeUndefined() + await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() expect(closeSession).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', @@ -462,7 +462,7 @@ describe('startup', () => { describe('a session closed and started again', () => { it('publishes provider events to the reattached chat', async () => { await attach() - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) await startAgent() @@ -820,7 +820,7 @@ describe('a quit over an eviction that never got its retry', () => { const settled = captureSettledSubmissions() failNextDrain() - await expect(host.close(SESSION)).rejects.toMatchObject({ step: 'drain-published' }) + await expect(host.close(SESSION, 'evict')).rejects.toMatchObject({ step: 'drain-published' }) expect(host['sessions'].get(SESSION)?.child).toBeNull() expect(store.getRecord(SESSION)?.lease.claimStatus).not.toBe('released') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts index ca9f64c0573..a90a65c1c83 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts @@ -49,7 +49,7 @@ type RequestMark = Pick type SessionBaseline = CompletionFeedCursor & { settled: RequestMark | null } function settledMark(request: StructuredAgentSessionLatestRequest | null): RequestMark | null { - return request && !request.running ? { kind: request.kind, id: request.id } : null + return request && request.turnState !== 'running' ? { kind: request.kind, id: request.id } : null } export class StructuredAgentSessionTurnCompletionFeed { @@ -115,7 +115,7 @@ export class StructuredAgentSessionTurnCompletionFeed { return } baseline.sequence = cursor.sequence - if (request?.running) { + if (request?.turnState === 'running') { // A running turn clears the mark, so this detector fires on each running → settled // transition rather than on an id it happens not to have seen. baseline.settled = null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 3ae6da5fa49..2981815c1f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -353,10 +353,10 @@ describe('already-wedged profiles become usable on load', () => { // What the sidebar reads: every status this restart published says the chat is not working. expect(published.filter((summary) => summary.sessionId === SESSION)).not.toEqual([]) expect(published.map((summary) => summary.status)).not.toContain('working') - // A crash is not something the user did: no outcome is claimed, so no reader files it as a - // cancellation the user already knows about. + // A crash is not something the user did: a proven one reads as an interruption and an + // unprovable one as unconfirmed, so no reader files it as a cancellation the user knows about. expect(published.map((summary) => summary.turnOutcome)).toEqual( - published.map(() => undefined) + published.map(() => (verdict.state === 'interrupted' ? 'interruption' : 'unconfirmed')) ) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 28eae2dff68..283f7c4f934 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -370,7 +370,7 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B it('leaves a command whose start failed not sent, beside one start-failure row (B3)', async () => { await attach() - await state.host.close(SESSION) + await state.host.close(SESSION, 'evict') state.acquire.mockRejectedValue(new Error('not signed in')) const params = compactParams() diff --git a/src/main/runtime/claude-structured-resumed-start-failure.test.ts b/src/main/runtime/claude-structured-resumed-start-failure.test.ts index 7a0c66e1b85..9d274cd68a8 100644 --- a/src/main/runtime/claude-structured-resumed-start-failure.test.ts +++ b/src/main/runtime/claude-structured-resumed-start-failure.test.ts @@ -40,7 +40,7 @@ describe('a reopened Claude chat whose CLI dies before initialize', () => { ok: true }) await waitForStructuredAgentSessionRecovery() - await host.close(SESSION) + await host.close(SESSION, 'evict') // The user reopens it and sends; this time the CLI never answers, then dies, and its tree is // unprovable. Opening starts nothing: the send does. diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 82db5d6c542..3de3fecc16f 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -504,7 +504,7 @@ describe('a structured Claude session over agentSession.*', () => { } const host = getStructuredAgentSessionHost() // The lease follows the root, so the host lets go. - await host?.close(SESSION) + await host?.close(SESSION, 'evict') expect(host?.hasSession(SESSION)).toBe(false) // The user comes back and sends: that send is what starts Claude again. diff --git a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts index 3f7047c6487..6f4248a4673 100644 --- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts @@ -20,6 +20,7 @@ import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close' import { rendererPublicationThrottle } from '../window/renderer-publication-throttle' +import { structuredAgentSessionTabCloseCause } from './structured-agent-session-tab-close-cause' export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose { async closeMobileSessionTab( @@ -300,7 +301,10 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU } } } - await this.closeStructuredAgentSessionTab(tab) + await this.closeStructuredAgentSessionTab( + tab, + structuredAgentSessionTabCloseCause(options.reason) + ) } else { if (!this.notifier?.closeSessionTab) { throw new Error('runtime_unavailable') diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index b0a584182e0..3b3510916ba 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -13,10 +13,14 @@ import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-pub import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import { applyBrowserSessionTabSelection } from './browser-session-tab-selection-snapshot' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionCloseCause } from '../native-chat/agent-session-wire/structured-agent-session-host-lifetime' import { retireStructuredAgentSessionTabFrom } from './structured-agent-session-tab-retirement' export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithCloseMobileSessionTab { - protected async closeStructuredAgentSessionTab(tab: RuntimeMobileSessionAgentTab): Promise { + protected async closeStructuredAgentSessionTab( + tab: RuntimeMobileSessionAgentTab, + cause: StructuredAgentSessionCloseCause + ): Promise { const host = getStructuredAgentSessionHost() if (host) { if (typeof host.setSessionTabVisibility === 'function') { @@ -30,7 +34,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi // Retire durable visibility and the runtime snapshot before stopping the provider. this.retireStructuredAgentSessionTabFromSnapshot(tab.sessionId) if (typeof host?.close === 'function') { - await host.close(tab.sessionId) + await host.close(tab.sessionId, cause) } } diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 929ee82b6c8..92c39dda283 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -308,7 +308,8 @@ describe('structured session cold restoration', () => { }) expect(closeSessionTab).toHaveBeenCalledWith('agent-session:restored-session', 'workspace-1') - expect(closeStructuredSession).toHaveBeenCalledWith('restored-session') + // The user closed this chat, so a turn the close cuts short is their cancellation. + expect(closeStructuredSession).toHaveBeenCalledWith('restored-session', 'user-close') expect(setSessionTabVisibility).toHaveBeenCalledWith('restored-session', false) expect(setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan( closeStructuredSession.mock.invocationCallOrder[0]! diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index f5684f1ba1b..a5a79b6be15 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -188,7 +188,7 @@ export async function discardStructuredWorkerSession( } try { await host.setSessionTabVisibility?.(sessionId, false) - await host.close(sessionId) + await host.close(sessionId, 'evict') } catch (error) { console.warn( '[orchestration] failed to discard a half-started structured worker', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts index 554c0912ff0..a1f05657e32 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts @@ -52,7 +52,7 @@ describe('admission revoked while a session is still open', () => { }) expect(response).toMatchObject({ ok: true, result: { ok: true } }) - expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') // The durable tab has to be retired too, or the chat comes back on the next sync. expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false) }) @@ -80,7 +80,7 @@ describe('admission revoked while a session is still open', () => { ) expect(response).toMatchObject({ ok: true }) - expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') } ) @@ -93,7 +93,7 @@ describe('admission revoked while a session is still open', () => { ) expect(response).toMatchObject({ ok: true }) - expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') }) it.each(ADMISSION_METHODS)( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 90700a5a817..64c7ec1c8b1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -103,7 +103,7 @@ beforeEach(async () => { afterEach(async () => { setStructuredAgentSessionHost(null) await host?.flushAllStreamedEvents() - await host?.close(SESSION) + await host?.close(SESSION, 'evict') await rm(root, { recursive: true, force: true }) vi.restoreAllMocks() vi.unstubAllEnvs() diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 000192cc183..45c01c2c63a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -123,7 +123,7 @@ afterEach(async () => { describe('the hold surface, for clients that still call it', () => { it('answers a hold without starting an agent or registering a cleanup', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) const registered = vi.spyOn(runtime, 'registerOwnedSubscriptionCleanup') const acquiresBefore = acquire.mock.calls.length @@ -163,7 +163,7 @@ describe('the hold surface, for clients that still call it', () => { }) it('answers a hold even when no agent could be started', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') acquire.mockRejectedValue(new Error('provider unavailable')) const acquiresBefore = acquire.mock.calls.length @@ -176,7 +176,7 @@ describe('the hold surface, for clients that still call it', () => { describe('a stream', () => { it('reads a closed conversation without starting its agent', async () => { - await host.close(SESSION) + await host.close(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) const acquiresBefore = acquire.mock.calls.length const frames: unknown[] = [] diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index d9d430f08e3..cc0677b134a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -133,7 +133,7 @@ describe('capability gating', () => { const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT) expect(response).toMatchObject({ ok: true, result: { ok: true } }) - expect(hostCalls.close).toHaveBeenCalledWith(SESSION) + expect(hostCalls.close).toHaveBeenCalledWith(SESSION, 'user-close') expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false) expect(hostCalls.setSessionTabVisibility.mock.invocationCallOrder[0]).toBeLessThan( hostCalls.close.mock.invocationCallOrder[0]! diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 463772d3337..b97da008a40 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -125,7 +125,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ if (replacement) { ctx.runtime.replaceStructuredAgentSessionTab(replacement) } - await host.close(params.envelope.sessionId) + await host.close(params.envelope.sessionId, 'user-close') } return result } @@ -221,7 +221,8 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ if (typeof host.setSessionTabVisibility === 'function') { await host.setSessionTabVisibility(params.sessionId, false) } - await host.close(params.sessionId) + // Clients call this only when the user closes this chat's tab or cancels its launch. + await host.close(params.sessionId, 'user-close') return { ok: true as const } } }), diff --git a/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts b/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts index aebb420fa8b..d6f41acdeee 100644 --- a/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-stop-receipt.test.ts @@ -113,7 +113,7 @@ describe('worker-stop on a structured worker this runtime cannot reach', () => { state: 'stop_unknown' } ) - expect(close).toHaveBeenCalledWith(SESSION) + expect(close).toHaveBeenCalledWith(SESSION, 'evict') expect(db.getWorkerDispatch(dispatchId)?.state).toBe('stop_unknown') }) diff --git a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts index b48dccf52a3..cee7adf6425 100644 --- a/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts +++ b/src/main/runtime/rpc/methods/structured-worker-tab-retirement.test.ts @@ -323,7 +323,7 @@ describe('structured worker discard retires the chat tab', () => { }) ).rejects.toThrow(/was refused/) - expect(close).toHaveBeenCalledWith(createdSessionId) + expect(close).toHaveBeenCalledWith(createdSessionId, 'evict') expect(await structuredTabIds(runtime)).toEqual([]) }) }) diff --git a/src/main/runtime/structured-agent-session-close.ts b/src/main/runtime/structured-agent-session-close.ts index 668b7f21890..3ee8f265c4d 100644 --- a/src/main/runtime/structured-agent-session-close.ts +++ b/src/main/runtime/structured-agent-session-close.ts @@ -75,7 +75,8 @@ export async function closeStructuredAgentSessionChild( try { await host.setSessionTabVisibility?.(sessionId, false) closeAttempted = true - await host.close(sessionId) + // A worktree teardown or an orchestration stop: not the user closing this chat. + await host.close(sessionId, 'evict') } catch (error) { // Only `closeAttempted` proves the hide landed: the store transaction restores its own state on // failure, so a `setSessionTabVisibility` that threw hid nothing and has nothing to undo. diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 990c33850ef..85761bf04d9 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -271,7 +271,7 @@ describe('a Stop in that window that the turn never opens for', () => { it('lets a chat closed behind it close within its bound and one eviction', async () => { const { stopping } = await waitingStop() - const closing = host.close(SESSION) + const closing = host.close(SESSION, 'evict') expect( await settledWithin(closing, CODEX_STOP_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS) @@ -305,7 +305,7 @@ describe('a cold send with no Stop behind it', () => { it('never delays closing the chat', async () => { await answeredColdSend() - expect(await settledWithin(host.close(SESSION), PROMPTLY_MS)).not.toBe('held') + expect(await settledWithin(host.close(SESSION, 'evict'), PROMPTLY_MS)).not.toBe('held') expect(childCloses).toBe(1) }) diff --git a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts index 91bf91738b5..9bce63de54d 100644 --- a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts @@ -50,7 +50,7 @@ describe('a Claude child proving its start', () => { expect(claude.child(STALLED).calls).toEqual(['get_settings']) let closed = false - void host.close(STALLED).then(() => { + void host.close(STALLED, 'evict').then(() => { closed = true }) await vi.waitFor(() => expect(closed).toBe(true)) diff --git a/src/main/runtime/structured-agent-session-tab-close-cause.test.ts b/src/main/runtime/structured-agent-session-tab-close-cause.test.ts new file mode 100644 index 00000000000..f90a8674ff8 --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-close-cause.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { structuredAgentSessionTabCloseCause } from './structured-agent-session-tab-close-cause' + +describe('structuredAgentSessionTabCloseCause', () => { + it.each([ + { label: "an older client's reasonless close", reason: undefined, cause: 'user-close' }, + { label: 'a user close', reason: 'user', cause: 'user-close' }, + { label: 'a cleanup echo', reason: 'cleanup', cause: 'evict' }, + { label: 'a pty-exit echo', reason: 'pty-exit', cause: 'evict' } + ] as const)('closes the chat with $cause for $label', ({ reason, cause }) => { + expect(structuredAgentSessionTabCloseCause(reason)).toBe(cause) + }) +}) diff --git a/src/main/runtime/structured-agent-session-tab-close-cause.ts b/src/main/runtime/structured-agent-session-tab-close-cause.ts new file mode 100644 index 00000000000..143b33831eb --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-close-cause.ts @@ -0,0 +1,17 @@ +import type { RuntimeSessionTabCloseReason } from '../../shared/runtime-types' +import type { StructuredAgentSessionCloseCause } from '../native-chat/agent-session-wire/structured-agent-session-host-lifetime' + +/** Who a chat's `session.tabs.close` speaks for. A reasonless close is an older client's user + * close; a lifecycle echo is not the user's. */ +export function structuredAgentSessionTabCloseCause( + reason: RuntimeSessionTabCloseReason | undefined +): StructuredAgentSessionCloseCause { + switch (reason) { + case undefined: + case 'user': + return 'user-close' + case 'pty-exit': + case 'cleanup': + return 'evict' + } +} diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 1490f9868e3..4ffbec11118 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -463,7 +463,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = it('leaves a pointer the person stopped while its agent was starting stopped', async () => { await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() - await host.close(COORDINATOR) + await host.close(COORDINATOR, 'evict') providerFaults.startDelayMs = 400 const before = providerFaults.starts await finishWorker(taskId) @@ -528,7 +528,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = ): Promise<{ runId: string; starts: number }> { await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() - await host.close(COORDINATOR) + await host.close(COORDINATOR, 'evict') providerFaults.refuseStart = refusal const before = providerFaults.starts await finishWorker(taskId) @@ -672,7 +672,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = await openChat(COORDINATOR) const { taskId } = await coordinatorRunAndTask() // What the idle sweep leaves of a chat nobody is looking at: agent stopped, no map entry. - await host.close(COORDINATOR) + await host.close(COORDINATOR, 'evict') expect(host.hasSession(COORDINATOR)).toBe(false) const before = codex.connections.length diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 137b7ff6961..c521ef8b6d9 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -100,7 +100,7 @@ async function seedPendingRewind(phase: 'prepared' | 'provider-succeeded') { } async function reattach() { - await host.close(HOST_TEST_SESSION) + await host.close(HOST_TEST_SESSION, 'evict') expect(await host.attach(caller, attachParams(fence()))).toMatchObject({ ok: true }) } diff --git a/src/main/runtime/structured-worker-at-rest.test.ts b/src/main/runtime/structured-worker-at-rest.test.ts index adfb181524d..669a655b2bc 100644 --- a/src/main/runtime/structured-worker-at-rest.test.ts +++ b/src/main/runtime/structured-worker-at-rest.test.ts @@ -337,7 +337,7 @@ describe('a task dispatched into a worker whose own dispatch settled', () => { await vi.waitFor(() => expect(observeStructuredWorker({ sessionId: REST_TEST_SESSION }).status).toBe('exited') ) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION, 'evict') } finally { hostRef.current = null await rig.dispose() diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index a767a61ccf4..97488e50fd6 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -96,7 +96,7 @@ describe('AgentStateDot', () => { expect(markup).not.toContain('data-agent-spinner') }) - it.each(['blocked', 'interrupted'] satisfies AgentDotState[])( + it.each(['blocked', 'failed'] satisfies AgentDotState[])( 'renders %s as a red attention dot', (state) => { const classNames = renderDotClassNames(state) @@ -106,6 +106,14 @@ describe('AgentStateDot', () => { } ) + it("renders a user's Stop as a muted dot, neither the fault red nor the idle grey", () => { + const classNames = renderDotClassNames('interrupted') + + expect(classNames).toContain('bg-muted-foreground') + expect(classNames).not.toContain('bg-red-500') + expect(classNames).not.toContain('bg-neutral-500/40') + }) + const ALL_STATES = [ 'working', 'monitoring', @@ -116,6 +124,7 @@ describe('AgentStateDot', () => { 'done', 'idle', 'unverifiable', + 'unconfirmed', 'permission' ] satisfies AgentDotState[] diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index af8ac4efd84..44fb3dc7c10 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -35,6 +35,9 @@ export type AgentDotState = // held there, and never rendered as 'done' or 'working' — it asserts nothing about // the agent, only about what Orca last heard. | 'unverifiable' + // Why: the turn ended and Orca cannot prove how. An outcome like 'failed', drawn with the + // 'unverifiable' glyph because it too reports missing evidence, never a finish. + | 'unconfirmed' // Why: the sidebar's title-based status flow (StatusIndicator/WorktreeCard) // collapses blocked + waiting into a single "needs attention" state. Keep // this as a distinct member so that flow can render without inventing a new @@ -63,6 +66,8 @@ export function agentStateLabel(state: AgentDotState): string { return 'Idle' case 'unverifiable': return 'No recent update' + case 'unconfirmed': + return 'Couldn’t confirm' case 'permission': return 'Needs attention' } @@ -123,7 +128,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({