From 80f5aae0f9f96babe221950280359a19bcfda9bf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:45:50 -0700 Subject: [PATCH] feat(agent-status): publish the main agent's own state beside the combined row state (#22452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-status): publish the lead agent's own state beside the combined row state Every status producer folded the main agent's state together with live child work into one `state`, so a lead that had finished while a subagent still ran read `working` and its own state was lost. The row now also carries `lead: { state, outcome?, stateStartedAt }`, admitted by the one payload normalizer on the relay wire, IPC and disk, and published from the Claude hook lane, the structured host ingest and renderer bridge, Grok (now on the shared fold) and Codex (own combine kept). The persisted child-only boundary flag is derived from `lead` plus child evidence and no longer written; old rows map onto `lead` at hydrate. Combined `state` and `workingMode` are unchanged for every reader; a cross-lane parity table pins that, with the cancelled-turn watch-loop story recorded as a known divergence. * fix(agent-status): make Orca's inferred interrupt the primary source of a Claude lead cancellation Current Claude Code sends no hook at all on a cancel and no is_interrupt on Stop, so the cancellation enters the lead record from the server's inferred interrupt and rides into the next real Stop; is_interrupt on a turn boundary stays as the secondary source for builds that send it. Comments, the store reference and the parity table say so; no suppression changes. * docs(agent-status): the child-only boundary comment now describes the persisted shell fact The old sentence said a hydrated row no longer carries the shell fact, which is the opposite of the mechanism: claudeRunningNonAgentTask is persisted precisely so hydration can read it, and only a pre-lead row lacks it — reading as shell-free, the same assertion its legacy flag made at write time. * rename the lead fact to mainAgent: the main agent's own state * docs(agent-status): the inferred cancel comes from Ctrl+C, not Esc * fix(agent-status): an inferred interrupt keeps an already settled main agent, and the row verdict docs name its inferred source * fix(agent-status): a child-induced wait publishes the main agent state it displaced * fix(agent-status): decide child-held Claude rows from the saved main agent fact Restart seeds the Claude main agent from the row's saved mainAgent whenever it settled and no shell held the row, instead of re-deriving a child-only shape. OSC cannot settle or repaint a row child agents hold open, including a row waiting on a child's permission prompt. A sticky child permission prompt still records the main agent's own progress, and OSC repaints and inferred answers keep the shell fact beside the main agent they preserve. * fix(agent-status): keep a finished turn's main agent verdict and clock with that turn A Claude SessionStart restarts the main agent's clock instead of inheriting the previous session's last Stop. A Grok idle prompt or session end, and a late Codex root Stop after an inferred cancel, restate the same finished turn, so they keep its recorded verdict; only a new turn clears it. * test(agent-status): publish the Grok verdict restatement past the late-event window * docs(agent-status): describe hydrate seeding and the OSC refusal from the saved main agent fact * fix(agent-status): push a held child permission row when its main agent changes * fix(agent-status): keep the shell fact on a held child permission row so restart does not settle it * docs(agent-status): note the held child permission row carries the shell fact and is pushed * fix(agent-status): pair the Claude shell fact with the main agent at the one row-build point Every non-hook rewrite (terminal-title repaint, inferred answer, held child permission) had to re-carry the shell fact beside `mainAgent`, and each one that forgot let a restart settle a row while a shell still ran. The row builder now pairs the fact once: a listener event restates it, any other write keeps it only while `mainAgent` is unchanged. Restart seeds a settled main agent only when the row says no shell ran, and legacy child-only rows map to that explicitly. A held child permission now also accepts the main agent event's background evidence, as it already accepts its `mainAgent`, so the child's drain no longer settles a row a shell still holds. The renderer keeps a previous `mainAgent` only for writers that never carry one, so a hook row without it matches the host snapshot. * test(agent-status): pin that restart never seeds a main agent from a row silent about its shell * docs(agent-status): the row builder pairs the shell fact with the main agent, and restart seeds only on an explicit no-shell * test(agent-status): name the legacy-row case parameter for what it holds * docs(agent-status): name which rows carry the main agent fact --- docs/reference/agent-status-store.md | 59 +++ .../server-ingest-structured-status.test.ts | 51 +++ .../server-ingest-terminal-status.test.ts | 45 ++ ...ver-interrupt-inference-validation.test.ts | 81 ++++ ...-last-status-child-held-main-agent.test.ts | 378 +++++++++++++++++ .../server-last-status-lead-boundary.test.ts | 46 +-- ...server-last-status-main-agent-fact.test.ts | 276 +++++++++++++ .../server-last-status-write.test.ts | 9 +- .../server-main-agent-turn-verdicts.test.ts | 153 +++++++ .../server/server-claude-status-rules.ts | 63 +-- .../agent-hooks/server/server-hydration.ts | 4 +- .../server/server-ingest-structured.ts | 22 +- .../server/server-ingest-terminal.ts | 23 +- .../server/server-persistence-validation.ts | 43 +- .../agent-hooks/server/server-persistence.ts | 5 +- .../server/server-status-inference.ts | 11 +- .../server/server-status-update.ts | 53 ++- src/main/agent-hooks/server/server-types.ts | 6 +- .../terminal-handle-row-identity.test.ts | 8 +- .../structured-agent-session-status-feed.ts | 1 + ...me-agent-row-main-agent-projection.test.ts | 32 ++ ...tructuredAgentSessionStatusBridge.test.tsx | 60 +++ .../StructuredAgentSessionStatusBridge.tsx | 21 +- .../normalize-agent-status-event.ts | 3 +- .../slices/agent-status-live-entry-builder.ts | 3 + .../agent-status-live-entry-main-agent.ts | 35 ++ .../agent-status-main-agent-fact.test.ts | 97 +++++ ...ent-hook-listener-claude-subagents.test.ts | 15 +- ...nt-hook-listener-claude-turn-state.test.ts | 110 +++++ ...ent-hook-listener-codex-main-agent.test.ts | 53 +++ ...stener-extraction-characterization.test.ts | 6 +- .../agent-hook-listener/listener-state.ts | 32 +- .../main-agent-turn-state.ts | 34 ++ .../providers/claude-events.ts | 37 +- .../providers/claude-lifecycle-events.ts | 7 +- .../providers/claude-roster-state.ts | 111 +++-- .../providers/claude-status-build.ts | 6 +- .../providers/codex-events.ts | 13 +- .../providers/codex-state.ts | 76 +++- .../providers/grok-events.ts | 69 +++- src/shared/agent-lead-status-fold.test.ts | 48 ++- src/shared/agent-lead-status-fold.ts | 33 +- src/shared/agent-session-journal-types.ts | 10 +- src/shared/agent-session-wire.ts | 14 +- src/shared/agent-status-types.test.ts | 54 +++ src/shared/agent-status-types.ts | 42 +- src/shared/agent-turn-outcome.ts | 14 + src/shared/main-agent-status-parity.test.ts | 383 ++++++++++++++++++ src/shared/main-agent-status.ts | 32 ++ ...uctured-agent-session-agent-status.test.ts | 40 +- .../structured-agent-session-agent-status.ts | 22 +- ...tructured-agent-session-projection.test.ts | 40 ++ .../structured-agent-session-projection.ts | 14 +- 53 files changed, 2663 insertions(+), 240 deletions(-) create mode 100644 src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts create mode 100644 src/main/agent-hooks/server-last-status-main-agent-fact.test.ts create mode 100644 src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts create mode 100644 src/main/runtime/runtime-agent-row-main-agent-projection.test.ts create mode 100644 src/renderer/src/store/slices/agent-status-live-entry-main-agent.ts create mode 100644 src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts create mode 100644 src/shared/agent-hook-listener-codex-main-agent.test.ts create mode 100644 src/shared/agent-hook-listener/main-agent-turn-state.ts create mode 100644 src/shared/agent-turn-outcome.ts create mode 100644 src/shared/main-agent-status-parity.test.ts create mode 100644 src/shared/main-agent-status.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 97fde3f7b91..3bec9b9e935 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -101,6 +101,7 @@ ingests the summary into the hook server as a status row: | `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) | | `state` | `structuredAgentSessionAgentStatus(summary).state`: the lead's own status folded with its live `backgroundTasks`, so a settled lead whose subagent still runs reads `working` | | `workingMode` | `'monitoring'` from the same fold when watch loops are the only live child work; omitted otherwise, which clears it on the row | +| `mainAgent` | the main agent's own state before the fold, its last-turn verdict (`summary.turnOutcome`, present only while idle) and its own clock; see "The main agent fact" below | | `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it | | prompt, tool, last message, model, provider session | the summary's fields | @@ -187,6 +188,64 @@ over `agentStatus:set` or `agentStatus:getSnapshot`. The renderer's feed bridge still writes those rows itself, and forwarding them too would give one pane key two writers. Removing that filter is the first step of PR 2. +### The main agent fact + +Claude, Codex and Grok hook rows and structured-session rows publish the combined +`state` and, beside it, the main agent's own state as `payload.mainAgent`. Other agents' +rows and terminal-title-only rows carry none, and readers fall back to `state`: + +```ts +mainAgent?: { state: AgentStatusState; outcome?: AgentJournalTurnOutcome; stateStartedAt: number } +``` + +`state` still answers "what should the user see" and folds live child work in, +so a settled main agent whose subagent still runs reads `working`. `mainAgent` answers +"what is the main agent itself doing", which the fold used to destroy at publish +time; every guard that reconstructed a fragment of it (`fromChildWork`, the +persisted `claudeLeadBoundaryChildOnly` flag) now reads `mainAgent` instead of a +stored copy. A Claude row whose `mainAgent` is `done` while a child agent still +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 +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 +inferred interrupt (`markClaudeLeadTurnInterrupted`), because current Claude +sends no hook at all on a cancel and no `is_interrupt` on Stop; that flag on a +turn boundary remains a secondary source for builds that send it, and +`StopFailure` maps to `failure`. + +Admission is one function, `normalizeAgentStatusPayload`, on the relay wire, +IPC and disk. A malformed `mainAgent` drops the field and keeps the row. Old hosts +send none and readers fall back to `state`. Hook rows persist it inside the +payload; hydration maps an older row's `claudeLeadBoundaryChildOnly: true` +onto `mainAgent: { state: 'done' }` when the row has no `mainAgent`, and never writes the +flag again. Hydration seeds the Claude main agent record straight from a saved +`mainAgent` that is `done`, so the children's drain can still settle the row after +a restart. `claudeRunningNonAgentTask` is persisted alongside because it is the one +child-work fact `mainAgent` cannot express: a shell running beside the main agent, +whose liveness hydration does not restore. Hydration seeds only a row that says +`false`; a row silent about it stays unseeded. The row builder pairs the two facts in +one place: a listener event restates the shell fact, and any other write (an OSC +repaint, an inferred answer) keeps it only while `mainAgent` is unchanged. A child's +sticky permission prompt still records the main agent's own progress and background +evidence in the held row, and pushes the held row to subscribers when `mainAgent` changes. + +Two combining rules remain outside the shared fold and are named so a reader +does not mistake them for drift: + +- Codex keeps `codexRosterEffectiveState` for its combined `state` (a waiting + child wins, a settled root with any live child reads `working`, never + monitoring) and publishes `mainAgent` from its root record; moving that combine + onto the fold needs a waiting-child input the fold does not have yet. +- A cancelled turn with a still-running shell reads `done` in the hook lane + and `monitoring` in the structured lane. The parity table in + `src/shared/main-agent-status-parity.test.ts` pins this as a known + divergence; the cancel policy that removes it flips that row. + ## PR 1b: the runtime's retained row store is deleted Landed. `RuntimeAgentRowStore` is gone, and with it the retained-versus-hook diff --git a/src/main/agent-hooks/server-ingest-structured-status.test.ts b/src/main/agent-hooks/server-ingest-structured-status.test.ts index 1b732f7021f..0dea55ea165 100644 --- a/src/main/agent-hooks/server-ingest-structured-status.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-status.test.ts @@ -378,3 +378,54 @@ describe('structured rows and last-status.json', () => { } }) }) + +describe('the main agent fact on a structured row', () => { + it('publishes the main agent beside the folded state, on the journal clock with continuity', () => { + const server = new AgentHookServer() + server.ingestStructuredStatus( + summary({ status: 'idle', backgroundTasks: [{ id: 'c', kind: 'agent', state: 'working' }] }), + SUBJECT + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', stateStartedAt: OBSERVED_AT } + }) + expect(server.getStatusSnapshot()[0]?.mainAgent).not.toHaveProperty('outcome') + + // The main agent is still done while its child drains: the main agent's clock does not move. + server.ingestStructuredStatus( + summary({ status: 'idle', updatedAt: OBSERVED_AT + 5, turnOutcome: 'failure' }), + SUBJECT + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + stateStartedAt: OBSERVED_AT + 5, + mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: OBSERVED_AT } + }) + + server.ingestStructuredStatus( + summary({ status: 'working', updatedAt: OBSERVED_AT + 9 }), + SUBJECT + ) + expect(server.getStatusSnapshot()[0]?.mainAgent).toEqual({ + state: 'working', + stateStartedAt: OBSERVED_AT + 9 + }) + }) + + it('reaches the enriched fanout every legacy subscriber reads', () => { + const server = new AgentHookServer() + const enriched = vi.fn() + server.subscribeEnrichedStatus(enriched) + server.ingestStructuredStatus(summary({ status: 'idle', turnOutcome: 'cancellation' }), SUBJECT) + expect(enriched).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: STRUCTURED_PANE, + payload: expect.objectContaining({ + state: 'done', + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: OBSERVED_AT } + }) + }) + ) + }) +}) diff --git a/src/main/agent-hooks/server-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index f54a5c26802..bbb6b9ae478 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -443,3 +443,48 @@ describe('AgentHookServer ingestTerminalStatus', () => { expect(server.getStatusSnapshot()).toEqual([]) }) }) + +describe('the main agent fact across an OSC repaint', () => { + it('carries the hook row main agent while OSC repaints the same state, and drops it on a state edge', () => { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + source: 'claude', + hookEventName: 'Stop', + payload: { + state: 'working', + workingMode: 'monitoring', + prompt: 'watch the build', + agentType: 'claude', + mainAgent: { state: 'done', stateStartedAt: 10 } + } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { + state: 'working', + prompt: 'watch the build', + agentType: 'claude', + toolName: 'Bash' + } + }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + toolName: 'Bash', + mainAgent: { state: 'done', stateStartedAt: 10 } + }) + + // OSC cannot date a turn edge: a different state is a main agent it has no fact about. + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { state: 'done', prompt: 'watch the build', agentType: 'claude' } + }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('mainAgent') + }) +}) diff --git a/src/main/agent-hooks/server-interrupt-inference-validation.test.ts b/src/main/agent-hooks/server-interrupt-inference-validation.test.ts index 971da33b22c..5818aa607bd 100644 --- a/src/main/agent-hooks/server-interrupt-inference-validation.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-validation.test.ts @@ -362,3 +362,84 @@ describe('AgentHookServer listener replay', () => { } }) }) + +describe('the main agent fact on an inferred interrupt', () => { + it('publishes the synthesized done as a cancelled main agent turn', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'long task', agentType: 'claude' } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'long task', + baselineAgentType: 'claude', + intent: 'ctrl-c' + }) + ).toBe(true) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_500 } + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps an already settled main agent behind a watch loop as it was', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + try { + const server = new AgentHookServer() + const settled = { state: 'done' as const, stateStartedAt: 800 } + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + state: 'working', + workingMode: 'monitoring', + prompt: 'watch it', + agentType: 'grok', + mainAgent: settled + } + }, + 'conn-1' + ) + const baseline = server.getStatusSnapshot()[0] + vi.setSystemTime(1_500) + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: 'watch it', + baselineAgentType: 'grok', + intent: 'ctrl-c' + }) + ).toBe(true) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: settled + }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts b/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts new file mode 100644 index 00000000000..c5b77fd086a --- /dev/null +++ b/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts @@ -0,0 +1,378 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AgentHookServer, _internals } from './server' +import { buildBody, postHookEvent, PANE, RUNNING_SHELL } from './server.test-fixtures' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +const CHILD_PERMISSION = { + hook_event_name: 'PermissionRequest', + agent_id: 'achild-a', + tool_name: 'Bash', + tool_input: { command: 'rm scratch' } +} + +// A Claude row whose main agent settled while child agents still work (or wait on a prompt) is +// decided by the row's `mainAgent` fact: OSC cannot settle it, and restart restores the settled +// main agent so the children's own lifecycle hooks can. +describe('Claude rows held open by child agents', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-child-held-main-agent-')) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + async function startServer(): Promise { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + return server + } + + async function restart(server: AgentHookServer): Promise { + server.flushStatusPersistSync() + server.stop() + return startServer() + } + + async function post(server: AgentHookServer, payload: Record): Promise { + await postHookEvent(server, buildBody(payload)) + } + + async function settleMainAgentBeside(server: AgentHookServer, childIds: string[]): Promise { + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + for (const id of childIds) { + await post(server, { hook_event_name: 'SubagentStart', agent_id: id }) + } + await post(server, { hook_event_name: 'Stop' }) + } + + function osc(server: AgentHookServer, state: 'working' | 'done'): void { + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: null, + payload: { state, prompt: '', agentType: 'claude' } + }) + } + + function row(server: AgentHookServer) { + return server.getStatusSnapshot()[0] + } + + it('keeps a child permission prompt visible when OSC reports done', async () => { + const server = await startServer() + try { + await settleMainAgentBeside(server, ['achild-a']) + await post(server, CHILD_PERMISSION) + osc(server, 'done') + + expect(row(server)).toMatchObject({ + state: 'waiting', + toolName: 'Bash', + mainAgent: { state: 'done' } + }) + } finally { + server.stop() + } + }) + + it('keeps a child-held working row when OSC reports done', async () => { + const server = await startServer() + try { + await settleMainAgentBeside(server, ['achild-a']) + osc(server, 'done') + + expect(row(server)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done' }, + subagents: [expect.objectContaining({ id: 'achild-a', state: 'working' })] + }) + } finally { + server.stop() + } + }) + + it('settles a restored child permission wait once the child is approved and stops', async () => { + let server = await startServer() + await settleMainAgentBeside(server, ['achild-a']) + await post(server, CHILD_PERMISSION) + server = await restart(server) + try { + await post(server, { + hook_event_name: 'PreToolUse', + agent_id: 'achild-a', + tool_name: 'Bash', + tool_input: { command: 'rm scratch' }, + tool_use_id: 'toolu-approved' + }) + expect(row(server)?.state).toBe('working') + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) + + expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + expect(row(server)?.restoredUnconfirmed).toBeUndefined() + } finally { + server.stop() + } + }) + + it('settles a restored drained row after a later child starts and stops', async () => { + let server = await startServer() + await settleMainAgentBeside(server, ['achild-a']) + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) + server = await restart(server) + try { + await post(server, { hook_event_name: 'SubagentStart', agent_id: 'achild-b' }) + expect(row(server)?.state).toBe('working') + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-b' }) + + expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + } finally { + server.stop() + } + }) + + it('settles a restored plain done row after a later child starts and stops', async () => { + let server = await startServer() + await settleMainAgentBeside(server, []) + server = await restart(server) + try { + await post(server, { hook_event_name: 'SubagentStart', agent_id: 'achild-b' }) + expect(row(server)?.state).toBe('working') + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-b' }) + + expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + } finally { + server.stop() + } + }) + + it('pushes the held child permission row when the main agent behind it changes', async () => { + const server = await startServer() + try { + await settleMainAgentBeside(server, ['achild-a']) + await post(server, CHILD_PERMISSION) + const pushed: { state: string; mainAgent?: { state: string } }[] = [] + server.subscribeEnrichedStatus((enriched) => pushed.push(enriched.payload)) + await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Read' }) + expect(pushed).toEqual([ + expect.objectContaining({ + state: 'waiting', + mainAgent: expect.objectContaining({ state: 'working' }) + }) + ]) + // The main agent keeps working: nothing it publishes changed, so nothing is pushed. + await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Grep' }) + expect(pushed).toHaveLength(1) + } finally { + server.stop() + } + }) + + it('does not settle a restored row whose main agent resumed behind a child permission', async () => { + let server = await startServer() + await settleMainAgentBeside(server, ['achild-a', 'achild-b']) + await post(server, CHILD_PERMISSION) + await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Read' }) + expect(row(server)).toMatchObject({ + state: 'waiting', + toolName: 'Bash', + mainAgent: { state: 'working' } + }) + server = await restart(server) + try { + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-b' }) + + expect(row(server)).toMatchObject({ state: 'working', restoredUnconfirmed: true }) + } finally { + server.stop() + } + }) + + it('does not settle a restored row a running shell held beside its child', async () => { + let server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + await post(server, { hook_event_name: 'SubagentStart', agent_id: 'achild-a' }) + await post(server, { + hook_event_name: 'Stop', + background_tasks: [{ id: 'achild-a', type: 'subagent', status: 'running' }, RUNNING_SHELL] + }) + server = await restart(server) + try { + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) + + expect(row(server)).toMatchObject({ state: 'working', restoredUnconfirmed: true }) + } finally { + server.stop() + } + }) + + it('does not settle a restored row whose main agent left a shell running behind a child permission', async () => { + let server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + await post(server, { hook_event_name: 'SubagentStart', agent_id: 'achild-a' }) + await post(server, CHILD_PERMISSION) + await post(server, { + hook_event_name: 'Stop', + background_tasks: [{ id: 'achild-a', type: 'subagent', status: 'running' }, RUNNING_SHELL] + }) + expect(row(server)).toMatchObject({ state: 'waiting', mainAgent: { state: 'done' } }) + server = await restart(server) + try { + await post(server, { + hook_event_name: 'PreToolUse', + agent_id: 'achild-a', + tool_name: 'Bash', + tool_input: { command: 'rm scratch' }, + tool_use_id: 'toolu-approved' + }) + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) + + expect(row(server)?.state).toBe('working') + } finally { + server.stop() + } + }) + + it('keeps a row open for the shell a main agent left behind a child permission', async () => { + const server = await startServer() + try { + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + await post(server, { hook_event_name: 'SubagentStart', agent_id: 'achild-a' }) + await post(server, CHILD_PERMISSION) + await post(server, { + hook_event_name: 'Stop', + background_tasks: [{ id: 'achild-a', type: 'subagent', status: 'running' }, RUNNING_SHELL] + }) + await post(server, { + hook_event_name: 'PreToolUse', + agent_id: 'achild-a', + tool_name: 'Bash', + tool_input: { command: 'rm scratch' }, + tool_use_id: 'toolu-approved' + }) + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) + + expect(row(server)?.state).toBe('working') + } finally { + server.stop() + } + }) + + it('settles a restored row after an inferred child answer when no shell ran', async () => { + let server = await startServer() + await settleMainAgentBeside(server, ['achild-a']) + await post(server, { + hook_event_name: 'PreToolUse', + agent_id: 'achild-a', + tool_name: 'AskUserQuestion', + tool_use_id: 'toolu-question' + }) + const waiting = row(server) + expect( + server.inferQuestionAnswered({ + paneKey: PANE, + baselineUpdatedAt: waiting?.receivedAt ?? 0, + baselineStateStartedAt: waiting?.stateStartedAt ?? 0, + baselinePrompt: waiting?.prompt ?? '', + baselineAgentType: 'claude' + }) + ).toBe(true) + server = await restart(server) + try { + await post(server, { hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) + + expect(row(server)).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + } finally { + server.stop() + } + }) + + // The shell fact rides beside `mainAgent`; a row rewritten without it would read as shell-free. + async function expectShellHeldRowStaysOpenAfterRestart( + server: AgentHookServer, + childEvents: Record[] + ): Promise { + const restarted = await restart(server) + try { + for (const event of childEvents) { + await post(restarted, event) + } + + expect(row(restarted)?.state).toBe('working') + } finally { + restarted.stop() + } + } + + it('keeps the shell fact when OSC repaints a shell-held row', async () => { + const server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'build in background' }) + await post(server, { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] }) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: null, + payload: { state: 'working', prompt: 'title text', agentType: 'claude' } + }) + expect(row(server)).toMatchObject({ state: 'working', prompt: 'title text' }) + + await expectShellHeldRowStaysOpenAfterRestart(server, [ + { hook_event_name: 'SubagentStart', agent_id: 'achild-b' }, + { hook_event_name: 'SubagentStop', agent_id: 'achild-b' } + ]) + }) + + it('keeps the shell fact when an answered child question is inferred', async () => { + const server = await startServer() + await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'delegate' }) + await post(server, { hook_event_name: 'SubagentStart', agent_id: 'achild-a' }) + await post(server, { + hook_event_name: 'Stop', + background_tasks: [{ id: 'achild-a', type: 'subagent', status: 'running' }, RUNNING_SHELL] + }) + await post(server, { + hook_event_name: 'PreToolUse', + agent_id: 'achild-a', + tool_name: 'AskUserQuestion', + tool_use_id: 'toolu-question' + }) + const waiting = row(server) + expect(waiting).toMatchObject({ state: 'waiting', toolName: 'AskUserQuestion' }) + expect( + server.inferQuestionAnswered({ + paneKey: PANE, + baselineUpdatedAt: waiting?.receivedAt ?? 0, + baselineStateStartedAt: waiting?.stateStartedAt ?? 0, + baselinePrompt: waiting?.prompt ?? '', + baselineAgentType: 'claude' + }) + ).toBe(true) + expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'done' } }) + + await expectShellHeldRowStaysOpenAfterRestart(server, [ + { hook_event_name: 'SubagentStop', agent_id: 'achild-a' } + ]) + }) +}) diff --git a/src/main/agent-hooks/server-last-status-lead-boundary.test.ts b/src/main/agent-hooks/server-last-status-lead-boundary.test.ts index 33e177910aa..7b7307d7915 100644 --- a/src/main/agent-hooks/server-last-status-lead-boundary.test.ts +++ b/src/main/agent-hooks/server-last-status-lead-boundary.test.ts @@ -5,6 +5,10 @@ import { join } from 'node:path' import { AgentHookServer, _internals } from './server' import { buildBody, postHookEvent, PANE, RUNNING_SHELL } from './server.test-fixtures' +function mainAgentState(server: AgentHookServer): string | undefined { + return server.getStatusSnapshot()[0]?.mainAgent?.state +} + const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), trackMock: vi.fn() @@ -186,24 +190,14 @@ describe('Persisted Claude lead boundaries', () => { buildBody({ hook_event_name: 'SubagentStart', agent_id: 'achildb' }) ) await postHookEvent(firstServer, buildBody({ hook_event_name: 'Stop' })) - expect( - ( - firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as - | { claudeLeadBoundaryChildOnly?: true } - | undefined - )?.claudeLeadBoundaryChildOnly - ).toBe(true) + expect(firstServer.getStatusSnapshot()[0]?.state).toBe('working') + expect(mainAgentState(firstServer)).toBe('done') await postHookEvent( firstServer, buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achilda' }) ) - expect( - ( - firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as - | { claudeLeadBoundaryChildOnly?: true } - | undefined - )?.claudeLeadBoundaryChildOnly - ).toBe(true) + expect(firstServer.getStatusSnapshot()[0]?.state).toBe('working') + expect(mainAgentState(firstServer)).toBe('done') firstServer.flushStatusPersistSync() firstServer.stop() @@ -253,13 +247,7 @@ describe('Persisted Claude lead boundaries', () => { buildBody({ hook_event_name: 'PreToolUse', tool_name: 'Read' }) ) expect(firstServer.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' }) - expect( - ( - firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as - | { claudeLeadBoundaryChildOnly?: true } - | undefined - )?.claudeLeadBoundaryChildOnly - ).toBeUndefined() + expect(mainAgentState(firstServer)).toBe('working') await postHookEvent( firstServer, buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achild-a' }) @@ -349,24 +337,12 @@ describe('Persisted Claude lead boundaries', () => { buildBody({ hook_event_name: 'PreToolUse', tool_name: 'Read' }) ) expect(firstServer.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' }) - expect( - ( - firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as - | { claudeLeadBoundaryChildOnly?: true } - | undefined - )?.claudeLeadBoundaryChildOnly - ).toBeUndefined() + expect(mainAgentState(firstServer)).toBe('working') await postHookEvent( firstServer, buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achilda' }) ) - expect( - ( - firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as - | { claudeLeadBoundaryChildOnly?: true } - | undefined - )?.claudeLeadBoundaryChildOnly - ).toBeUndefined() + expect(mainAgentState(firstServer)).toBe('working') firstServer.flushStatusPersistSync() firstServer.stop() diff --git a/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts b/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts new file mode 100644 index 00000000000..9d5345dc423 --- /dev/null +++ b/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts @@ -0,0 +1,276 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AgentHookServer, _internals } from './server' +import { buildBody, postHookEvent, recentTs, PANE } from './server.test-fixtures' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +// The main agent fact rides inside the persisted payload. Without these pins the field would look +// shipped while dying at every restart: hydration rebuilds only the fields it is taught. +describe('The main agent fact across a restart', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-main-agent-fact-')) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + function lastStatusPath(): string { + return join(userDataPath, 'agent-hooks', 'last-status.json') + } + + function writeEntry(entry: Record): void { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { [PANE]: { paneKey: PANE, tabId: 'tab-1', ...entry } } + }) + ) + } + + it('round-trips a settled main agent held open by a child through disk and back', async () => { + const firstServer = new AgentHookServer() + await firstServer.start({ env: 'production', userDataPath }) + await postHookEvent( + firstServer, + buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'finish after child' }) + ) + await postHookEvent( + firstServer, + buildBody({ hook_event_name: 'SubagentStart', agent_id: 'arestored-child' }) + ) + await postHookEvent(firstServer, buildBody({ hook_event_name: 'Stop', is_interrupt: true })) + const live = firstServer.getStatusSnapshot()[0] + expect(live).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: expect.any(Number) } + }) + firstServer.flushStatusPersistSync() + firstServer.stop() + const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + expect(file.entries[PANE].payload.mainAgent).toEqual(live?.mainAgent) + expect(file.entries[PANE]).not.toHaveProperty('claudeLeadBoundaryChildOnly') + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + restoredUnconfirmed: true, + mainAgent: live?.mainAgent + }) + // The seeded main agent record is what lets the child's drain settle the pane, verdict intact. + await postHookEvent( + server, + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'arestored-child' }) + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { + state: 'done', + outcome: 'cancellation', + stateStartedAt: live?.mainAgent?.stateStartedAt + } + }) + } finally { + server.stop() + } + }) + + it('maps the legacy child-only flag onto an absent main agent, dated by the turn end', async () => { + const receivedAt = recentTs() + writeEntry({ + receivedAt, + stateStartedAt: receivedAt - 5_000, + claudeLeadBoundaryChildOnly: true, + payload: { + state: 'working', + prompt: 'legacy row', + agentType: 'claude', + turnCompletedAt: receivedAt - 1_000, + subagents: [{ id: 'arestored-child', state: 'working', startedAt: receivedAt - 4_000 }] + } + }) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', stateStartedAt: receivedAt - 1_000 } + }) + await postHookEvent( + server, + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'arestored-child' }) + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + mainAgent: { state: 'done' } + }) + } finally { + server.stop() + } + }) + + it.each([ + ['a child permission wait', 'waiting', ['PreToolUse', 'SubagentStop']], + ['a drained row', 'done', ['SubagentStart', 'SubagentStop']] + ] as const)( + 'settles a legacy flagged row holding %s once its child finishes', + async (_heldRow, state, childEvents) => { + const receivedAt = recentTs() + writeEntry({ + receivedAt, + stateStartedAt: receivedAt - 5_000, + claudeLeadBoundaryChildOnly: true, + payload: { + state, + prompt: 'legacy row', + agentType: 'claude', + ...(state === 'waiting' + ? { + toolName: 'Bash', + subagents: [{ id: 'achild', state: 'working', startedAt: receivedAt - 4_000 }] + } + : {}) + } + }) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + for (const hookEventName of childEvents) { + await postHookEvent( + server, + buildBody({ + hook_event_name: hookEventName, + agent_id: 'achild', + ...(hookEventName === 'PreToolUse' + ? { tool_name: 'Bash', tool_use_id: 'toolu-approved' } + : {}) + }) + ) + } + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + mainAgent: { state: 'done' } + }) + } finally { + server.stop() + } + } + ) + + it('does not restore a settled main agent from a row that does not say whether a shell ran', async () => { + const receivedAt = recentTs() + writeEntry({ + receivedAt, + stateStartedAt: receivedAt - 5_000, + payload: { + state: 'working', + prompt: 'unknown shell', + agentType: 'claude', + mainAgent: { state: 'done', stateStartedAt: receivedAt - 2_000 }, + subagents: [{ id: 'achild', state: 'working', startedAt: receivedAt - 4_000 }] + } + }) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + await postHookEvent( + server, + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achild' }) + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + restoredUnconfirmed: true + }) + } finally { + server.stop() + } + }) + + it('prefers a persisted main agent over the legacy flag when a row carries both', async () => { + const receivedAt = recentTs() + const mainAgent = { state: 'done', outcome: 'cancellation', stateStartedAt: receivedAt - 2_000 } + writeEntry({ + receivedAt, + stateStartedAt: receivedAt - 5_000, + claudeLeadBoundaryChildOnly: true, + payload: { + state: 'working', + prompt: 'both', + agentType: 'claude', + turnCompletedAt: receivedAt - 1_000, + mainAgent, + subagents: [{ id: 'arestored-child', state: 'working', startedAt: receivedAt - 4_000 }] + } + }) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(server.getStatusSnapshot()[0]?.mainAgent).toEqual(mainAgent) + } finally { + server.stop() + } + }) + + it('drops a malformed persisted main agent and keeps the row', async () => { + const receivedAt = recentTs() + writeEntry({ + receivedAt, + stateStartedAt: receivedAt - 5_000, + payload: { state: 'done', prompt: 'survived', agentType: 'claude', mainAgent: 'done' } + }) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const row = server.getStatusSnapshot()[0] + expect(row).toMatchObject({ state: 'done', prompt: 'survived' }) + expect(row?.mainAgent).toBeUndefined() + } finally { + server.stop() + } + }) + + it('never invents a main agent for a non-Claude row from the legacy flag', async () => { + const receivedAt = recentTs() + writeEntry({ + receivedAt, + stateStartedAt: receivedAt - 5_000, + claudeLeadBoundaryChildOnly: true, + payload: { state: 'working', prompt: 'codex row', agentType: 'codex' } + }) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(server.getStatusSnapshot()[0]?.mainAgent).toBeUndefined() + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/server-last-status-write.test.ts b/src/main/agent-hooks/server-last-status-write.test.ts index b0bfb7db929..c1dcee3f697 100644 --- a/src/main/agent-hooks/server-last-status-write.test.ts +++ b/src/main/agent-hooks/server-last-status-write.test.ts @@ -87,8 +87,13 @@ describe('Last-status persistence', () => { expect(file.entries[PANE].launchTokenHash).toBe( createHash('sha256').update('launch-bearer-must-not-persist').digest('hex') ) - expect(file.entries[PANE].claudeRunningNonAgentTask).toBeUndefined() - expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('claudeRunningNonAgentTask') + // The shell fact is persisted: hydration reads it to decide whether a settled main agent held + // open by children may be seeded, which `mainAgent` alone cannot say (see server-types.ts). + expect(file.entries[PANE].claudeRunningNonAgentTask).toBe(true) + expect(file.entries[PANE].payload.mainAgent).toEqual({ + state: 'done', + stateStartedAt: expect.any(Number) + }) expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('launch-bearer-must-not-persist') } finally { server.stop() diff --git a/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts b/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts new file mode 100644 index 00000000000..abd8a78791f --- /dev/null +++ b/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer, _internals } from './server' +import { buildBody, postHookEvent, PANE } from './server.test-fixtures' + +const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ + getCohortAtEmitMock: vi.fn(), + trackMock: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) + +beforeEach(() => { + _internals.resetCachesForTests() + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) + // Only the wall clock is faked, so the hook server's real sockets keep working. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(1_000_000) +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +// A finished turn's verdict and clock belong to that turn: an event restating the same finished +// turn keeps them, and only a new turn or a new session replaces them. +describe('main agent turn verdicts and clocks', () => { + let server: AgentHookServer + + beforeEach(async () => { + server = new AgentHookServer() + await server.start({ env: 'production' }) + }) + + afterEach(() => { + server.stop() + }) + + async function post(path: string, payload: Record): Promise { + const response = await postHookEvent(server, buildBody(payload), path) + expect(response.status).toBe(204) + } + + function inferCtrlC(agentType: 'codex'): void { + const baseline = server.getStatusSnapshot()[0] + expect( + server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: agentType, + intent: 'ctrl-c' + }) + ).toBe(true) + } + + it('starts a new Claude session with its own main agent clock', async () => { + await post('/hook/claude', { hook_event_name: 'UserPromptSubmit', prompt: 'first' }) + await post('/hook/claude', { hook_event_name: 'Stop' }) + expect(server.getStatusSnapshot()[0]?.mainAgent).toEqual({ + state: 'done', + stateStartedAt: 1_000_000 + }) + + vi.setSystemTime(1_060_000) + await post('/hook/claude', { hook_event_name: 'SessionStart', source: 'clear' }) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + mainAgent: { state: 'done', stateStartedAt: 1_060_000 } + }) + }) + + it.each(['idle_prompt notification', 'session end'] as const)( + 'keeps a cancelled Grok turn verdict when a %s restates done', + async (restatement) => { + const turn = { sessionId: 'session-1', promptId: 'prompt-1' } + await post('/hook/grok', { hookEventName: 'user_prompt_submit', ...turn, prompt: 'go' }) + await post('/hook/grok', { hookEventName: 'stop_cancelled', ...turn }) + const cancelled = server.getStatusSnapshot()[0]?.mainAgent + expect(cancelled).toMatchObject({ state: 'done', outcome: 'cancellation' }) + + // Past the late-event suppression window, so the restatement itself is published. + vi.setSystemTime(1_030_000) + await post( + '/hook/grok', + restatement === 'session end' + ? { hookEventName: 'session_end', ...turn } + : { hookEventName: 'notification', ...turn, notificationType: 'idle_prompt' } + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', mainAgent: cancelled }) + } + ) + + it('keeps an inferred Codex cancellation across a late root Stop', async () => { + await post('/hook/codex', { hook_event_name: 'UserPromptSubmit', prompt: 'long task' }) + vi.setSystemTime(1_001_000) + inferCtrlC('codex') + const cancelled = server.getStatusSnapshot()[0]?.mainAgent + expect(cancelled).toMatchObject({ state: 'done', outcome: 'cancellation' }) + + vi.setSystemTime(1_002_000) + await post('/hook/codex', { hook_event_name: 'Stop' }) + // Past the late-event suppression window, a child's activity republishes the main agent. + vi.setSystemTime(1_060_000) + await post('/hook/codex', { hook_event_name: 'SubagentStart', agent_id: 'child-1' }) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', mainAgent: cancelled }) + }) + + it('keeps an inferred Codex cancellation across a late relayed root Stop', () => { + const relayed = ( + hookEventName: string, + payload: Record, + extra: Record = {} + ) => + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName, + ...extra, + payload: { prompt: 'long task', agentType: 'codex', ...payload } + }, + 'conn-1' + ) + relayed('UserPromptSubmit', { state: 'working' }, { hasExplicitPrompt: true }) + vi.setSystemTime(1_001_000) + inferCtrlC('codex') + const cancelled = server.getStatusSnapshot()[0]?.mainAgent + expect(cancelled).toMatchObject({ state: 'done', outcome: 'cancellation' }) + + vi.setSystemTime(1_002_000) + relayed('Stop', { state: 'done' }) + vi.setSystemTime(1_060_000) + relayed( + 'SubagentStart', + { + state: 'working', + subagents: [{ id: 'child-1', state: 'working', startedAt: 1_060_000 }] + }, + { toolAgentId: 'child-1' } + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', mainAgent: cancelled }) + }) +}) diff --git a/src/main/agent-hooks/server/server-claude-status-rules.ts b/src/main/agent-hooks/server/server-claude-status-rules.ts index 9607e4a9dc8..d8136837047 100644 --- a/src/main/agent-hooks/server/server-claude-status-rules.ts +++ b/src/main/agent-hooks/server/server-claude-status-rules.ts @@ -1,44 +1,49 @@ +import { mainAgentStatusEqual } from '../../../shared/main-agent-status' import { claudeTeammateIdMatchesName } from '../../../shared/claude-subagent-roster' import { isAskUserQuestionTool } from '../../../shared/agent-question-answered-intent' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { EnrichedAgentHookEventPayload } from './server-types' -export function attachClaudeChildOnlyBoundary( +/** The shell fact a Claude row stores beside its `mainAgent`; restart seeds a settled main agent only + * when it reads `false`. The listener restates it on every event it produces; any other write keeps + * the previous fact only while `mainAgent` is unchanged, since the fact was observed with that one. */ +export function pairedClaudeNonAgentWork( previous: EnrichedAgentHookEventPayload | undefined, next: AgentHookEventPayload -): AgentHookEventPayload & { claudeLeadBoundaryChildOnly?: true } { - const establishesBoundary = - next.payload.agentType === 'claude' && - (next.hookEventName === 'Stop' || next.hookEventName === 'StopFailure') && - !next.toolAgentId && - next.payload.state === 'working' && - next.payload.subagents?.some((subagent) => subagent.state === 'working') === true && - next.claudeRunningNonAgentTask === false - const carriesBoundary = - previous?.claudeLeadBoundaryChildOnly === true && - next.payload.agentType === 'claude' && - next.claudeRunningNonAgentTask === false && - (next.toolAgentId !== undefined || - next.hookEventName === 'SubagentStart' || - next.hookEventName === 'SubagentStop' || - next.hookEventName === 'TeammateIdle') - return establishesBoundary || carriesBoundary - ? { ...next, claudeLeadBoundaryChildOnly: true } - : next +): boolean | undefined { + if (next.claudeRunningNonAgentTask !== undefined) { + return next.claudeRunningNonAgentTask + } + return previous && mainAgentStatusEqual(previous.payload.mainAgent, next.payload.mainAgent) + ? previous.claudeRunningNonAgentTask + : undefined } -export function invalidateClaudeChildOnlyBoundary( - previous: EnrichedAgentHookEventPayload | undefined, +/** A child's permission prompt stays visible over the main agent's own progress, but the row must + * still carry that progress: restart seeds the main agent from it, and a stale `done` would let the + * children's drain settle a row whose main agent is working. Returns `previous` when nothing changed, + * and keeps `previous.payload` when only the unpublished shell fact did. */ +export function withHeldChildWaitMainAgent( + previous: EnrichedAgentHookEventPayload, next: AgentHookEventPayload -): EnrichedAgentHookEventPayload | undefined { - if ( - previous?.claudeLeadBoundaryChildOnly !== true || - attachClaudeChildOnlyBoundary(previous, next).claudeLeadBoundaryChildOnly === true - ) { +): EnrichedAgentHookEventPayload { + const mainAgent = next.payload.mainAgent + if (!previous.toolAgentId || !mainAgent) { return previous } - const { claudeLeadBoundaryChildOnly: _boundary, ...withoutBoundary } = previous - return withoutBoundary + const runningNonAgentTask = pairedClaudeNonAgentWork(previous, next) + const mainAgentChanged = !mainAgentStatusEqual(previous.payload.mainAgent, mainAgent) + if (!mainAgentChanged && runningNonAgentTask === previous.claudeRunningNonAgentTask) { + return previous + } + const { claudeRunningNonAgentTask: _unpaired, ...unpaired } = previous + return { + ...unpaired, + ...(runningNonAgentTask !== undefined + ? { claudeRunningNonAgentTask: runningNonAgentTask } + : {}), + payload: mainAgentChanged ? { ...previous.payload, mainAgent } : previous.payload + } } export function shouldKeepClaudePermissionVisible( diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts index cd88a46cb9f..140a48cfeb4 100644 --- a/src/main/agent-hooks/server/server-hydration.ts +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -123,9 +123,7 @@ export abstract class AgentHookServerHydration extends AgentHookServerReaping { if (entry.payload.agentType === 'codex') { seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) } else if (entry.payload.agentType === 'claude') { - seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry, { - childOnlyBoundary: entry.claudeLeadBoundaryChildOnly === true - }) + seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry) if (entry.payload.subagents) { seedClaudeSubagentRosterFromSnapshots( this.state, diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index cc7751511d4..b57b4d62040 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -9,6 +9,10 @@ import { structuredAgentSessionPaneKey, structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { + continueMainAgentStatus, + isAgentStatusHeldOpenByChildWork +} from '../../../shared/agent-lead-status-fold' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import { structuredStatusLegacyEvent } from './server-structured-status-row' import { AgentHookServerIngestTerminal } from './server-ingest-terminal' @@ -35,10 +39,19 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng } const previous = this.canonicalStatusStore.getParent(parsed) const priorStatus = previous?.status - const { state, workingMode, fromChildWork } = structuredAgentSessionAgentStatus({ + const agentStatus = structuredAgentSessionAgentStatus({ status: summary.status, - backgroundTasks: summary.backgroundTasks + backgroundTasks: summary.backgroundTasks, + turnOutcome: summary.turnOutcome }) + const { state, workingMode } = agentStatus + // The main agent's own clock keeps continuity the same way the combined row's does below, dated + // by the journal: a restart's republish is not a new main agent state either. + const mainAgent = continueMainAgentStatus( + priorStatus?.mainAgent, + agentStatus.mainAgent, + summary.updatedAt + ) const tabId = structuredAgentSessionTabId(parsed.sessionId) const paneKey = structuredAgentSessionPaneKey(tabId, parsed.sessionId) if (this.state.lastStatusByPaneKey.has(paneKey)) { @@ -55,6 +68,7 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), state, ...(workingMode ? { workingMode } : {}), + mainAgent, prompt: summary.latestPrompt, agentType: summary.agent, ...(summary.model ? { model: summary.model } : {}), @@ -68,7 +82,9 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng // cannot date child work: it stopped when the lead did, and reading it as the evidence age // retires a genuinely live roster at the 30-minute staleness window. Only then does the // host's own observation clock stand in, matching what the hook lane stamps for its rows. - evidenceObservedAt: fromChildWork ? observedAt : summary.updatedAt, + evidenceObservedAt: isAgentStatusHeldOpenByChildWork({ state, mainAgent }) + ? observedAt + : summary.updatedAt, // Continuity is the whole published work identity: `state` alone no longer means "a turn is // running", so monitoring that becomes a real turn must restart the clock, not inherit it. stateStartedAt: diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index e7e68115659..9a83465d3b4 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -4,6 +4,7 @@ import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable- import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from './server-types' +import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' import { AgentHookServerIngestNormalization } from './server-ingest-normalization' export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { @@ -82,11 +83,13 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges | EnrichedAgentHookEventPayload | undefined if ( - previous?.claudeLeadBoundaryChildOnly === true && - previous.payload.agentType === 'claude' && - event.payload.agentType === 'claude' + previous?.payload.agentType === 'claude' && + event.payload.agentType === 'claude' && + isAgentStatusHeldOpenByChildWork(previous.payload) && + previous.payload.subagents?.some((subagent) => subagent.state === 'working') === true ) { - // Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives. + // Why: OSC carries no child identity, so it cannot settle or repaint a row child agents hold open + // (working, or waiting on a child's prompt); their lifecycle hooks will. if (mutationBefore !== undefined) { this.commitStatusRowMutation(mutationBefore, previous) this.emitEnrichedStatus(previous) @@ -130,6 +133,14 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges (previous.payload.state !== 'done' || event.payload.state === 'done') ? previous.providerSession : undefined + // Why: OSC carries no main agent fact. While it repaints the state the hook row already holds, the + // main agent behind that state is unchanged too; a different state is a turn edge OSC cannot date. + const preservedMainAgent = + previous?.payload.mainAgent && + previous.payload.state === event.payload.state && + (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) + ? previous.payload.mainAgent + : undefined // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. this.applyNormalizedStatus( { @@ -139,7 +150,9 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges connectionId, ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), ...(terminalHandle ? { terminalHandle } : {}), - payload: event.payload + payload: preservedMainAgent + ? { ...event.payload, mainAgent: preservedMainAgent } + : event.payload }, undefined, 'osc', diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index d47fe0cddeb..f88766217c1 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto' import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' import { normalizeAgentStatusPayload, + type AgentMainAgentStatus, type ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import { isAgentHookSource } from '../../../shared/agent-hook-relay' @@ -31,6 +32,27 @@ export function dropHydratedIdleClaudeSubagents( } } +/** Rows written before `mainAgent` existed persisted `claudeLeadBoundaryChildOnly: true` instead: the + * main agent had settled and child agents alone held the row `working`. That is `mainAgent.state === 'done'` + * stored as a boolean, so it only fills an absent `mainAgent`; a row carrying both keeps `mainAgent`. The + * flag stays readable until every user's file has been rewritten without it. */ +function legacyChildOnlyBoundaryMainAgent( + payload: ParsedAgentStatusPayload, + record: Record, + stateStartedAt: number +): AgentMainAgentStatus | undefined { + if ( + payload.mainAgent !== undefined || + record.claudeLeadBoundaryChildOnly !== true || + payload.agentType !== 'claude' + ) { + return undefined + } + // Why: the gated working row stamps the main agent's end as `turnCompletedAt`; the row clock is the + // nearest fact an older row that lacks it can offer. + return { state: 'done', stateStartedAt: payload.turnCompletedAt ?? stateStartedAt } +} + export function sanitizeHydratedEntry( paneKey: string, rawEntry: unknown @@ -84,10 +106,25 @@ export function sanitizeHydratedEntry( } else { return null } - const payload = normalizeAgentStatusPayload(record.payload) - if (!payload) { + const normalizedPayload = normalizeAgentStatusPayload(record.payload) + if (!normalizedPayload) { return null } + const legacyBoundaryMainAgent = legacyChildOnlyBoundaryMainAgent( + normalizedPayload, + record, + stateStartedAt + ) + const payload = legacyBoundaryMainAgent + ? { ...normalizedPayload, mainAgent: legacyBoundaryMainAgent } + : normalizedPayload + const claudeRunningNonAgentTask = + typeof record.claudeRunningNonAgentTask === 'boolean' + ? record.claudeRunningNonAgentTask + : // Why: the legacy flag was only ever written while no shell ran beside the children. + legacyBoundaryMainAgent + ? false + : undefined const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined const providerSessionOnly = record.providerSessionOnly === true const retainedForLiveness = record.retainedForLiveness === true @@ -127,7 +164,7 @@ export function sanitizeHydratedEntry( toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, teammateName: typeof record.teammateName === 'string' ? record.teammateName : undefined, toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, - claudeLeadBoundaryChildOnly: record.claudeLeadBoundaryChildOnly === true ? true : undefined, + ...(claudeRunningNonAgentTask !== undefined ? { claudeRunningNonAgentTask } : {}), providerSession, providerSessionOnly: providerSessionOnly ? true : undefined, retainedForLiveness: retainedForLiveness ? true : undefined, diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index 6d811216f9f..74a37c441f2 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -32,9 +32,7 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio if (enrichedPayload.structuredHost) { continue } - const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true const { - claudeRunningNonAgentTask: _claudeRunningNonAgentTask, promptInteractionKey: _promptInteractionKey, // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. restoredUnconfirmed: _restoredUnconfirmed, @@ -51,9 +49,10 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio const launchTokenHash = launchToken?.trim() ? createHash('sha256').update(launchToken.trim()).digest('hex') : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) + // `payload.mainAgent` rides inside the payload; the legacy `claudeLeadBoundaryChildOnly` flag it + // replaced is read at hydrate and never written again. entries[paneKey] = { ...persistedPayload, - ...(childOnlyBoundary ? { claudeLeadBoundaryChildOnly: true } : {}), ...(launchTokenHash ? { launchTokenHash } : {}) } const commitment = this.toAuthorityEvidence(payload, launchTokenHash) diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 3f57e8d87d2..02b61595832 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -108,7 +108,13 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO ...(payload.model ? { model: payload.model } : {}), interrupted: true, // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. - ...(payload.subagents ? { subagents: payload.subagents } : {}) + ...(payload.subagents ? { subagents: payload.subagents } : {}), + // Why: the interrupt ends a running main agent's turn; one a watch loop held open had already + // settled, so it keeps its own clock and verdict. + mainAgent: + payload.mainAgent?.state === 'done' + ? payload.mainAgent + : { state: 'done', outcome: 'cancellation', stateStartedAt: Date.now() } } }) if (!inferred) { @@ -172,7 +178,8 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO ...(restored.turnCompletedAt !== undefined ? { turnCompletedAt: restored.turnCompletedAt } : {}), - ...(payload.subagents ? { subagents: payload.subagents } : {}) + ...(payload.subagents ? { subagents: payload.subagents } : {}), + mainAgent: restored.mainAgent } }) if (!inferred) { diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 1b4a0e75960..65fd1acc714 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -11,10 +11,10 @@ import type { EnrichedAgentHookEventPayload } from './server-types' import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' import { - attachClaudeChildOnlyBoundary, attachClaudePermissionToolUseId, - invalidateClaudeChildOnlyBoundary, - shouldKeepClaudePermissionVisible + pairedClaudeNonAgentWork, + shouldKeepClaudePermissionVisible, + withHeldChildWaitMainAgent } from './server-claude-status-rules' import { isStaleGrokTurnEnd } from './server-grok-status-rules' import { isToolProgressWorkingAfterInterrupt } from './server-status-identity' @@ -36,7 +36,8 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) } - let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. + const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined const rowBefore = mutationBefore ?? previous @@ -122,19 +123,6 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA : stateReconciledPayload.payload } : stateReconciledPayload - const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary( - previous, - rootContextPreservingPayload - ) - if (boundaryReconciledPrevious !== previous) { - previous = boundaryReconciledPrevious - if (previous) { - if (!this.writeLegacyStatusRow(previous)) { - return undefined - } - this.scheduleStatusPersist() - } - } const identity = resolveAgentStatusIdentity({ existing: previous ? { @@ -165,10 +153,25 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA payload: { ...rootContextPreservingPayload.payload, agentType: identity.agentType } } const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) - const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload) if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { - this.commitStatusRowMutation(rowBefore, previous) - return previous + const held = withHeldChildWaitMainAgent(previous, effectivePayload) + // Why: a child's prompt leaves the main agent running, so the held row takes its `mainAgent` and + // must take the same event's background evidence; a main agent's own prompt blocks it, so not there. + if (previous.toolAgentId) { + onAccepted?.() + } + if (held !== previous) { + if (!this.writeLegacyStatusRow(held)) { + return undefined + } + this.scheduleStatusPersist() + } + this.commitStatusRowMutation(rowBefore, held) + // Why: pushed readers must see the new `mainAgent` a snapshot reader already does. + if (held.payload !== previous.payload) { + this.emitEnrichedStatus(held) + } + return held } // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. if ( @@ -211,9 +214,15 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA } // Why carried forward only within one host: main's OSC parse resolves the handle, so a later // hook must not erase its terminal join; a connection change must not inherit another host's. + const { claudeRunningNonAgentTask: _unpaired, ...unpairedPayload } = effectivePayload + const runningNonAgentTask = pairedClaudeNonAgentWork(previous, effectivePayload) + const pairedPayload = + runningNonAgentTask === undefined + ? unpairedPayload + : { ...unpairedPayload, claudeRunningNonAgentTask: runningNonAgentTask } const enriched = { - ...this.attachStatusTiming(boundaryAwarePayload, now, observedAt), - observation: this.stampObservation(boundaryAwarePayload, origin, observedAt ?? now) + ...this.attachStatusTiming(pairedPayload, now, observedAt), + observation: this.stampObservation(pairedPayload, origin, observedAt ?? now) } if ( typeof enriched.payload.turnCompletedAt === 'number' && diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 8f5edf42106..d1466797798 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -25,14 +25,14 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { restoredUnconfirmed?: true /** User-hidden resume identity retained solely for destructive liveness checks. */ retainedForLiveness?: true - /** Persisted proof that a lead boundary was held working only by child agents. */ - claudeLeadBoundaryChildOnly?: true } +// `claudeRunningNonAgentTask` is persisted on purpose: it is the one child-work fact the row's +// `mainAgent` cannot express (a shell beside the agents), and hydration reads it to decide whether a +// settled main agent may be seeded. It replaced the derived `claudeLeadBoundaryChildOnly` flag. export type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, | 'authorityRestartId' - | 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed' diff --git a/src/main/agent-hooks/terminal-handle-row-identity.test.ts b/src/main/agent-hooks/terminal-handle-row-identity.test.ts index 3fba9484afb..3c361595a24 100644 --- a/src/main/agent-hooks/terminal-handle-row-identity.test.ts +++ b/src/main/agent-hooks/terminal-handle-row-identity.test.ts @@ -210,9 +210,15 @@ describe('the terminal handle a status row is stamped with', () => { if (!row) { throw new Error('expected seeded status row') } + // A settled main agent whose only live work is a child agent: the boundary is derived from these facts. const childOnlyRow = { ...row, - claudeLeadBoundaryChildOnly: true + claudeRunningNonAgentTask: false, + payload: { + ...row.payload, + mainAgent: { state: 'done' as const, stateStartedAt: 1 }, + subagents: [{ id: 'child-1', state: 'working' as const, startedAt: 1 }] + } } seedLegacyAgentStatusForTests(server._getStateForTests(), childOnlyRow) enriched.mockClear() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index cecd5455ca7..6ec254f1032 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -71,6 +71,7 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma a.toolName === b.toolName && a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && + a.turnOutcome === b.turnOutcome && agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) ) diff --git a/src/main/runtime/runtime-agent-row-main-agent-projection.test.ts b/src/main/runtime/runtime-agent-row-main-agent-projection.test.ts new file mode 100644 index 00000000000..97d8a3f6b8c --- /dev/null +++ b/src/main/runtime/runtime-agent-row-main-agent-projection.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { selectRuntimeHookAgentRowForPane } from './runtime-mobile-agent-status-projection' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function row(over: Partial = {}): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 1_000, + state: 'working', + prompt: 'ship it', + agentType: 'claude', + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: now - 2_000 }, + ...over + } +} + +// The mobile projection narrows a row through `pickParsedAgentStatusPayload`; this pins that +// the main agent fact survives the narrowing. The `worktree ps` row keeps its own explicit shape. +describe('the main agent fact through the runtime projections', () => { + it('reaches the mobile live row', () => { + const source = row() + const selected = selectRuntimeHookAgentRowForPane([source]) + expect(selected.live?.payload.mainAgent).toEqual(source.mainAgent) + }) +}) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 746817523e4..aeb5db3300f 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -697,3 +697,63 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(mocks.setAgentStatus).not.toHaveBeenCalled() }) }) + +describe('the main agent fact the bridge writes', () => { + beforeEach(() => { + vi.clearAllMocks() + resetStructuredAgentSessionStatusFeedsForTests() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.supportsCapability.mockResolvedValue(true) + mocks.store?.setState({ + agentStatusByPaneKey: {}, + testRuntimeOwner: null, + unifiedTabsByWorktree: { 'wt-1': [structuredTab] } + }) + }) + + afterEach(() => { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + }) + + it('stamps the main agent beside the folded state, with its verdict and its own clock', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + act(() => + feed().emit({ + type: 'snapshot', + sessions: [ + summary({ + status: 'idle', + updatedAt: 1, + turnOutcome: 'cancellation', + backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }] + }) + ] + }) + ) + expect(statuses()).toEqual([ + expect.objectContaining({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1 } + }) + ]) + + // The shell drains: the row settles, the main agent was done all along, so its clock holds. + act(() => + feed().emit({ + type: 'status', + session: summary({ status: 'idle', updatedAt: 2, turnOutcome: 'cancellation' }) + }) + ) + expect(statuses()).toEqual([ + expect.objectContaining({ + state: 'done', + stateStartedAt: 2, + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1 } + }) + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index e6a1c966ec2..8cd966ef9af 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -6,7 +6,11 @@ import { agentChildWorkProjectionCandidateFromBackgroundTask, projectAgentChildWorkLegacySubagents } from '../../../../shared/agent-status-child-work-projection' -import { agentSubagentsEqual } from '../../../../shared/agent-status-types' +import { + continueMainAgentStatus, + isAgentStatusHeldOpenByChildWork +} from '../../../../shared/agent-lead-status-fold' +import { mainAgentStatusEqual, agentSubagentsEqual } from '../../../../shared/agent-status-types' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { structuredAgentSessionAgentStatus } from '../../../../shared/structured-agent-session-agent-status' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -62,11 +66,20 @@ function projectStatus( // Shared with `worktree ps`, so the CLI and this row cannot disagree about one session. const agentStatus = structuredAgentSessionAgentStatus({ status: summary.status, - backgroundTasks: summary.backgroundTasks + backgroundTasks: summary.backgroundTasks, + turnOutcome: summary.turnOutcome }) + const current = store.agentStatusByPaneKey?.[paneKey] + // Same continuity rule as the host ingest, on the main agent's own clock. + const mainAgent = continueMainAgentStatus( + current?.mainAgent, + agentStatus.mainAgent, + summary.updatedAt + ) const desired = { state: agentStatus.state, ...(agentStatus.workingMode ? { workingMode: agentStatus.workingMode } : {}), + mainAgent, prompt: summary.latestPrompt, agentType: tab.agentSessionAgent, // The host projects these from the journal so the row reads like a hook-reported one: @@ -78,10 +91,10 @@ function projectStatus( ...(subagents ? { subagents, subagentObservation: observation } : {}), sessionBoundary: false } as const - const current = store.agentStatusByPaneKey?.[paneKey] if ( current?.state === desired.state && current.workingMode === desired.workingMode && + mainAgentStatusEqual(current.mainAgent, desired.mainAgent) && current.prompt === desired.prompt && current.agentType === desired.agentType && // A row keeps the last model it was told about, so only a reported one can differ. @@ -124,7 +137,7 @@ function projectStatus( : summary.updatedAt, // Same rule as the host ingest: the journal clock stopped when the lead's turn did, so a // row held open by child work alone is dated by when this client saw it instead. - evidenceObservedAt: agentStatus.fromChildWork ? Date.now() : summary.updatedAt + evidenceObservedAt: isAgentStatusHeldOpenByChildWork(desired) ? Date.now() : summary.updatedAt }, { tabId: tab.id, worktreeId: tab.worktreeId }, { diff --git a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts index 56d46657fe7..ee9504659ce 100644 --- a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts +++ b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts @@ -22,7 +22,8 @@ export function normalizeAgentStatusEvent( interrupted: data.interrupted, sessionBoundary: data.sessionBoundary, turnCompletedAt: data.turnCompletedAt, - subagents: data.subagents + subagents: data.subagents, + mainAgent: data.mainAgent }) } diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 843b3b42eb1..64bd5cc1a77 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -1,4 +1,5 @@ import type { AppState } from '../types' +import { resolveAgentStatusLiveEntryMainAgent } from './agent-status-live-entry-main-agent' import { AGENT_STATE_HISTORY_MAX, agentSubagentsEqual, @@ -217,6 +218,7 @@ export function buildAgentStatusLiveEntry( : undefined) ?? matchedRegistryLaunchConfig ?? matchedSleepingLaunchConfig + const mainAgent = resolveAgentStatusLiveEntryMainAgent(existing, payload, identity.agentType) const entry: AgentStatusEntry = { state: payload.state, workingMode: payload.workingMode, @@ -261,6 +263,7 @@ export function buildAgentStatusLiveEntry( subagents: agentSubagentsEqual(existing?.subagents, payload.subagents) ? existing?.subagents : payload.subagents, + ...(mainAgent ? { mainAgent } : {}), ...(providerSession ? { providerSession } : {}), ...(metadata?.terminalResumeEligible === false ? { terminalResumeEligible: false as const } diff --git a/src/renderer/src/store/slices/agent-status-live-entry-main-agent.ts b/src/renderer/src/store/slices/agent-status-live-entry-main-agent.ts new file mode 100644 index 00000000000..cb356805c51 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-entry-main-agent.ts @@ -0,0 +1,35 @@ +import { + mainAgentStatusEqual, + type AgentMainAgentStatus, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import type { AgentStatusObservationOrigin } from '../../../../shared/agent-status-observation' +import type { AgentStatusPayload } from './agent-status-contract' + +/** Byte- and launch-derived writers never carry a main agent fact; a hook row without one means the + * host has none, so keeping ours would diverge from what the host serves mobile and the CLI. */ +const MAIN_AGENT_BLIND_ORIGINS: ReadonlySet = new Set([ + 'osc', + 'title', + 'launch', + 'process' +]) + +/** The main agent fact a live entry carries. A writer with no main agent fact of its own (OSC bytes, launch + * seeds) repaints the state the row already holds, and the main agent behind an unchanged state is + * unchanged too — the same rule main's OSC ingest applies. The existing object is reused when + * nothing changed so subscribers can compare by reference. */ +export function resolveAgentStatusLiveEntryMainAgent( + existing: AgentStatusEntry | undefined, + payload: Pick, + agentType: AgentStatusEntry['agentType'] +): AgentMainAgentStatus | undefined { + const blindWriter = + payload.observation !== undefined && MAIN_AGENT_BLIND_ORIGINS.has(payload.observation.origin) + const next = + payload.mainAgent ?? + (blindWriter && existing?.state === payload.state && existing.agentType === agentType + ? existing.mainAgent + : undefined) + return mainAgentStatusEqual(existing?.mainAgent, next) ? existing?.mainAgent : next +} diff --git a/src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts b/src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts new file mode 100644 index 00000000000..bd00eaa5015 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-main-agent-fact.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { normalizeAgentStatusEvent } from '../../hooks/ipc-events/normalize-agent-status-event' +import { createTestStore } from './store-test-helpers' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function osc(revision: number) { + return { + origin: 'osc' as const, + authorityId: 'renderer', + incarnation: 0, + revision, + observedAt: revision, + kind: 'snapshot' as const + } +} + +describe('the main agent fact on a renderer status entry', () => { + it('lands on the entry from the IPC payload and is reused by reference when unchanged', () => { + const store = createTestStore() + const mainAgent = { state: 'done' as const, outcome: 'failure' as const, stateStartedAt: 5 } + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude', mainAgent }) + const first = store.getState().agentStatusByPaneKey[PANE] + expect(first.mainAgent).toEqual(mainAgent) + + store.getState().setAgentStatus(PANE, { + state: 'working', + prompt: 'go', + agentType: 'claude', + toolName: 'Read', + mainAgent: { ...mainAgent } + }) + expect(store.getState().agentStatusByPaneKey[PANE].mainAgent).toBe(first.mainAgent) + }) + + it('keeps the main agent behind an unchanged state when a writer carries none, and drops it on a state edge', () => { + const store = createTestStore() + const mainAgent = { state: 'done' as const, stateStartedAt: 5 } + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'go', agentType: 'claude', mainAgent }) + // A renderer-side OSC parse repaints the state with no main agent fact of its own. + store.getState().setAgentStatus(PANE, { + state: 'working', + prompt: 'go', + agentType: 'claude', + toolName: 'Bash', + observation: osc(1) + }) + expect(store.getState().agentStatusByPaneKey[PANE].mainAgent).toEqual(mainAgent) + + store + .getState() + .setAgentStatus(PANE, { + state: 'done', + prompt: 'go', + agentType: 'claude', + observation: osc(2) + }) + expect(store.getState().agentStatusByPaneKey[PANE].mainAgent).toBeUndefined() + }) + + it('drops the main agent when a hook row carries none, matching the host snapshot', () => { + const store = createTestStore() + store.getState().setAgentStatus(PANE, { + state: 'working', + prompt: 'go', + agentType: 'claude', + mainAgent: { state: 'done', stateStartedAt: 5 } + }) + // The host lost its main agent record (unseeded restart, relay restart, old relay). + store.getState().setAgentStatus(PANE, { + state: 'working', + prompt: 'go', + agentType: 'claude', + toolName: 'Bash' + }) + expect(store.getState().agentStatusByPaneKey[PANE].mainAgent).toBeUndefined() + }) + + it('survives the IPC event normalizer', () => { + const mainAgent = { state: 'working' as const, stateStartedAt: 9 } + expect( + normalizeAgentStatusEvent({ + paneKey: PANE, + connectionId: null, + receivedAt: 10, + stateStartedAt: 9, + state: 'working', + prompt: 'go', + mainAgent + })?.mainAgent + ).toEqual(mainAgent) + }) +}) diff --git a/src/shared/agent-hook-listener-claude-subagents.test.ts b/src/shared/agent-hook-listener-claude-subagents.test.ts index 00d95471d72..ec554205888 100644 --- a/src/shared/agent-hook-listener-claude-subagents.test.ts +++ b/src/shared/agent-hook-listener-claude-subagents.test.ts @@ -663,7 +663,10 @@ describe('shared agent-hook-listener', () => { expect(wait?.payload.state).toBe('waiting') expect(wait?.payload.interactivePrompt).toBeDefined() - expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ state: 'working' }) + expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ + state: 'working', + mainAgent: { state: 'working', stateStartedAt: expect.any(Number) } + }) // Why: a child-driven refresh re-emits the cached lead state; the linger // bug would come back if it could resurrect the dismissed question. @@ -714,10 +717,13 @@ describe('shared agent-hook-listener', () => { // emitted state is gated up to working only while that child still runs. expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ state: 'working', - turnCompletedAt: expect.any(Number) + turnCompletedAt: expect.any(Number), + // The main agent's own fact rides beside the gated state: it finished, no verdict was given. + mainAgent: { state: 'done', stateStartedAt: expect.any(Number) } }) expect(state.claudeLeadStateByPaneKey.get(PANE_KEY)).toEqual({ state: 'done', + stateStartedAt: expect.any(Number), turnCompletedAt: expect.any(Number) }) @@ -726,7 +732,10 @@ describe('shared agent-hook-listener', () => { }) it('falls back to working when no lead record exists', () => { - expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ state: 'working' }) + expect(clearClaudeAnsweredQuestionWait(state, PANE_KEY)).toEqual({ + state: 'working', + mainAgent: { state: 'working', stateStartedAt: expect.any(Number) } + }) }) }) }) diff --git a/src/shared/agent-hook-listener-claude-turn-state.test.ts b/src/shared/agent-hook-listener-claude-turn-state.test.ts index f7e40a52461..ad81b70a7b1 100644 --- a/src/shared/agent-hook-listener-claude-turn-state.test.ts +++ b/src/shared/agent-hook-listener-claude-turn-state.test.ts @@ -4,6 +4,7 @@ import { type HookListenerState } from './agent-hook-listener/listener-state' import { normalizeHookPayload } from './agent-hook-listener' +import { markClaudeLeadTurnInterrupted } from './agent-hook-listener/providers/claude-roster-state' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' import { CLAUDE_PREVIOUS_PROMPT_ID, @@ -400,3 +401,112 @@ describe('shared agent-hook-listener', () => { expect(latestPrompt).toBe('prompt 39') }) }) + +describe('the main agent verdict across a child-induced wait', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + function claude(payload: Record) { + return normalizeHookPayload(state, 'claude', { paneKey: PANE_KEY, payload }, 'production') + ?.payload + } + + it('restores the cancelled verdict when a child permission pause clears', () => { + claude({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + claude({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + const cancelled = claude({ hook_event_name: 'Stop', is_interrupt: true }) + expect(cancelled?.mainAgent).toEqual({ + state: 'done', + outcome: 'cancellation', + stateStartedAt: expect.any(Number) + }) + const settledAt = cancelled?.mainAgent?.stateStartedAt + + const wait = claude({ + hook_event_name: 'PermissionRequest', + agent_id: 'a1', + tool_name: 'Bash', + tool_input: { command: 'rm -rf build' } + }) + // The child's wait is child work: the main agent is still the cancelled turn, on its own clock. + expect(wait).toMatchObject({ + state: 'waiting', + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: settledAt } + }) + + // The child's pause displaced the main agent; clearing it must give the verdict and clock back. + const drained = claude({ hook_event_name: 'SubagentStop', agent_id: 'a1' }) + expect(drained).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: settledAt } + }) + }) + + it('publishes the displaced main agent, not the child, while a child owns the wait', () => { + claude({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + const running = claude({ hook_event_name: 'SubagentStart', agent_id: 'a1' }) + const childWait = claude({ + hook_event_name: 'PermissionRequest', + agent_id: 'a1', + tool_name: 'Bash', + tool_input: { command: 'rm -rf build' } + }) + expect(childWait).toMatchObject({ + state: 'waiting', + mainAgent: { state: 'working', stateStartedAt: running?.mainAgent?.stateStartedAt } + }) + + // A wait the main agent raised itself is its own state. + const ownWait = claude({ + hook_event_name: 'PermissionRequest', + tool_name: 'Bash', + tool_input: { command: 'rm -rf dist' } + }) + expect(ownWait).toMatchObject({ state: 'waiting', mainAgent: { state: 'waiting' } }) + }) + + it('clears the verdict when a new turn starts', () => { + claude({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + claude({ hook_event_name: 'StopFailure', error: 'invalid_request' }) + expect(claude({ hook_event_name: 'UserPromptSubmit', prompt: 'again' })?.mainAgent).toEqual({ + state: 'working', + stateStartedAt: expect.any(Number) + }) + }) +}) + +describe('the main agent verdict from an inferred interrupt', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + function claude(payload: Record) { + return normalizeHookPayload(state, 'claude', { paneKey: PANE_KEY, payload }, 'production') + ?.payload + } + + // Current Claude sends no hook on a cancel and no `is_interrupt` on Stop, so the cancellation + // enters the main agent record from Orca's inferred interrupt and rides into the next real Stop. + it('carries the inferred cancellation into the next plain Stop', () => { + claude({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) + markClaudeLeadTurnInterrupted(state, PANE_KEY) + expect(state.claudeLeadStateByPaneKey.get(PANE_KEY)).toMatchObject({ + state: 'done', + outcome: 'cancellation' + }) + const settledAt = state.claudeLeadStateByPaneKey.get(PANE_KEY)?.stateStartedAt + + const stop = claude({ hook_event_name: 'Stop' }) + expect(stop).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: settledAt } + }) + }) +}) diff --git a/src/shared/agent-hook-listener-codex-main-agent.test.ts b/src/shared/agent-hook-listener-codex-main-agent.test.ts new file mode 100644 index 00000000000..a83e992388c --- /dev/null +++ b/src/shared/agent-hook-listener-codex-main-agent.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + createHookListenerState, + type HookListenerState +} from './agent-hook-listener/listener-state' +import { + reconcileRemoteCodexState, + seedCodexStateFromSnapshot +} from './agent-hook-listener/providers/codex-state' +import { PANE_KEY } from './agent-hook-listener-test-harness' + +describe('the Codex root record seeded from a durable row', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + it("takes the row's own mainAgent fact over the inferred aggregate", () => { + seedCodexStateFromSnapshot(state, PANE_KEY, { + state: 'waiting', + model: 'gpt-5.4', + subagents: [{ id: 'child', state: 'working', startedAt: 1 }], + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 42 } + }) + expect(state.codexLeadStateByPaneKey.get(PANE_KEY)).toEqual({ + state: 'done', + outcome: 'cancellation', + stateStartedAt: 42, + model: 'gpt-5.4' + }) + }) + + it('still infers the root state from an older row that carries no main agent', () => { + seedCodexStateFromSnapshot(state, PANE_KEY, { + state: 'waiting', + subagents: [{ id: 'child', state: 'waiting', startedAt: 1 }] + }) + expect(state.codexLeadStateByPaneKey.get(PANE_KEY)).toMatchObject({ state: 'working' }) + }) + + it("republishes a relayed row with the mainAgent fact main holds, not the relay's", () => { + const reconciled = reconcileRemoteCodexState( + state, + PANE_KEY, + 'Stop', + undefined, + { state: 'done', prompt: 'ship', agentType: 'codex' }, + undefined + ) + expect(reconciled.mainAgent).toEqual({ state: 'done', stateStartedAt: expect.any(Number) }) + }) +}) diff --git a/src/shared/agent-hook-listener-extraction-characterization.test.ts b/src/shared/agent-hook-listener-extraction-characterization.test.ts index c521b6c7439..06b7f271697 100644 --- a/src/shared/agent-hook-listener-extraction-characterization.test.ts +++ b/src/shared/agent-hook-listener-extraction-characterization.test.ts @@ -213,8 +213,8 @@ describe('agent hook extraction boundaries', () => { state.ampCompletedCacheKeys.add(PANE) state.ampCompletedCacheKeys.add(scoped) state.ampCompletedCacheKeys.add(sibling) - state.claudeLeadStateByPaneKey.set(PANE, { state: 'working' }) - state.codexLeadStateByPaneKey.set(PANE, { state: 'working' }) + state.claudeLeadStateByPaneKey.set(PANE, { state: 'working', stateStartedAt: 1 }) + state.codexLeadStateByPaneKey.set(PANE, { state: 'working', stateStartedAt: 1 }) state.grokActiveTurnByPaneKey.set(PANE, { promptId: 'prompt-1' }) clearPaneCacheState(state, PANE) @@ -264,7 +264,7 @@ describe('agent hook extraction boundaries', () => { state.warnedEnvs.add('development->production') state.lastPromptByPaneKey.set(PANE, 'prompt') state.claudeRunningNonAgentTaskPaneKeys.add(PANE) - state.codexLeadStateByPaneKey.set(PANE, { state: 'working' }) + state.codexLeadStateByPaneKey.set(PANE, { state: 'working', stateStartedAt: 1 }) state.grokActiveTurnByPaneKey.set(PANE, { promptId: 'prompt-1' }) clearAllListenerCaches(state) diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 3e4ed0cae5d..53848ca707a 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -1,4 +1,7 @@ -import type { AgentStatusState } from '../agent-status-types' +import type { AgentMainAgentStatus } from '../agent-status-types' +import type { ClaudeLeadTurnState, CodexLeadTurnState } from './main-agent-turn-state' + +export type { ClaudeLeadTurnState, CodexLeadTurnState } from './main-agent-turn-state' import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE, createAgentStatusLegacyAdapter, @@ -30,7 +33,7 @@ export type HookListenerState = { ampCompletedCacheKeys: Set /** Live subagents/teammates per Claude pane; survives turn boundaries since background children outlive the lead turn. */ claudeSubagentRosterByPaneKey: Map - /** Last state from the LEAD session's own events (subagent events carry agent_id, excluded), so a SubagentStop can re-emit pane status; `interrupted` persists so the eventual done still carries it. */ + /** Last state from the LEAD session's own events (subagent events carry agent_id, excluded), so a SubagentStop can re-emit pane status; `outcome` persists so the eventual done still carries it. Published on every row as `mainAgent`. */ claudeLeadStateByPaneKey: Map /** One-normalization provenance marker for a status backed only by restored child state. */ claudeUnconfirmedRestoredStatusPaneKeys: Set @@ -52,6 +55,8 @@ export type HookListenerState = { codexLeadStateByPaneKey: Map /** Newest Grok turn per pane, used to reject end reports that arrive after a replacement prompt. */ grokActiveTurnByPaneKey: Map + /** The Grok main agent's own state as last published, so its clock keeps continuity across events. */ + grokMainAgentStatusByPaneKey: Map /** Muse child-session filter and session-log cursor per pane. */ musePaneStateByPaneKey: Map /** @@ -78,24 +83,6 @@ export type GrokActiveTurn = { sessionId?: string } -export type ClaudeLeadTurnState = { - state: AgentStatusState - interrupted?: true - /** Subagent that induced the wait; only its next tool activity may clear it, so other children's churn can't dismiss a pending human-input card. */ - waitingAgentId?: string - /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ - waitingToolUseId?: string - /** End time of the lead turn closed while background inventory kept the pane `working`. Repeated on the later all-clear `done`. */ - turnCompletedAt?: number - /** Lead state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ - stateBeforeWait?: Pick -} - -export type CodexLeadTurnState = { - state: 'working' | 'waiting' | 'done' - model?: string -} - const legacyStatusAdapterByState = new WeakMap() function legacyStatusAdapter(state: HookListenerState): AgentStatusLegacyAdapter { @@ -129,6 +116,7 @@ export function createHookListenerState( codexSubagentTranscriptByPaneKey: new Map(), codexLeadStateByPaneKey: new Map(), grokActiveTurnByPaneKey: new Map(), + grokMainAgentStatusByPaneKey: new Map(), musePaneStateByPaneKey: new Map(), opencodeSessionPaneBySessionId: new Map(), lastLaunchTokenByPaneKey: new Map() @@ -218,6 +206,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.codexSubagentTranscriptByPaneKey.delete(paneKey) state.codexLeadStateByPaneKey.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) + state.grokMainAgentStatusByPaneKey.delete(paneKey) state.musePaneStateByPaneKey.delete(paneKey) unbindOpenCodeSessionsOfPane(state, paneKey) deletePaneScopedCacheEntry(state.lastLaunchTokenByPaneKey, paneKey) @@ -295,6 +284,7 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.grokActiveTurnByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.grokMainAgentStatusByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.musePaneStateByPaneKey, fromPaneKey, toPaneKey) moveOpenCodeSessionBindings(state, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.lastLaunchTokenByPaneKey, fromPaneKey, toPaneKey) @@ -306,6 +296,7 @@ export function clearPaneTurnCacheState(state: HookListenerState, paneKey: strin state.antigravityCompletedTranscriptByPaneKey.delete(paneKey) state.ampCompletedCacheKeys.delete(paneKey) state.grokActiveTurnByPaneKey.delete(paneKey) + state.grokMainAgentStatusByPaneKey.delete(paneKey) } export function deletePaneScopedCacheEntry(map: Map, paneKey: string): void { @@ -347,6 +338,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.codexSubagentTranscriptByPaneKey.clear() state.codexLeadStateByPaneKey.clear() state.grokActiveTurnByPaneKey.clear() + state.grokMainAgentStatusByPaneKey.clear() state.opencodeSessionPaneBySessionId.clear() state.lastLaunchTokenByPaneKey.clear() } diff --git a/src/shared/agent-hook-listener/main-agent-turn-state.ts b/src/shared/agent-hook-listener/main-agent-turn-state.ts new file mode 100644 index 00000000000..2a9e261d31e --- /dev/null +++ b/src/shared/agent-hook-listener/main-agent-turn-state.ts @@ -0,0 +1,34 @@ +import type { AgentStatusState } from '../agent-status-types' +import type { AgentJournalTurnOutcome } from '../agent-turn-outcome' + +/** The Claude main agent's own turn record, published on every row as `mainAgent`. */ +export type ClaudeLeadTurnState = { + state: AgentStatusState + /** The recorded verdict on the turn this record closed (the provider's, or a `cancellation` + * Orca inferred from the interrupt keystroke); only meaningful while `state` is done. + * `cancellation` is what the fold reads as an interrupt. */ + outcome?: AgentJournalTurnOutcome + /** When `state` first appeared; the main agent's own clock, distinct from the gated row's. */ + stateStartedAt: number + /** Subagent that induced the wait; only its next tool activity may clear it, so other children's churn can't dismiss a pending human-input card. */ + waitingAgentId?: string + /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ + waitingToolUseId?: string + /** End time of the main agent turn closed while background inventory kept the pane `working`. Repeated on the later all-clear `done`. */ + turnCompletedAt?: number + /** Main agent state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ + stateBeforeWait?: Pick< + ClaudeLeadTurnState, + 'state' | 'outcome' | 'stateStartedAt' | 'turnCompletedAt' + > +} + +/** The Codex root's own record; its combined `state` still comes from `codexRosterEffectiveState`. */ +export type CodexLeadTurnState = { + state: 'working' | 'waiting' | 'done' + /** The turn verdict the server inferred; Codex's own Stop hook carries none. */ + outcome?: AgentJournalTurnOutcome + /** When `state` first appeared; the root's own clock, published as `mainAgent.stateStartedAt`. */ + stateStartedAt: number + model?: string +} diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts index d094afd3367..5d39b50aa63 100644 --- a/src/shared/agent-hook-listener/providers/claude-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -17,8 +17,10 @@ import { normalizeClaudeSubagentLifecycleEvent } from './claude-lifecycle-events' import { + claudeMainAgentTurnInterrupted, getOrCreateClaudeSubagentRoster, resolveClaudePaneStatus, + setClaudeMainAgentTurnState, updateClaudeRunningNonAgentTask, voidClaimsOfReplacedClaudeSession } from './claude-roster-state' @@ -62,7 +64,8 @@ export function normalizeClaudeEvent( state.claudeSubagentRosterByPaneKey.delete(paneKey) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) - state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done' }) + // Why: a new session's main agent starts its own clock, not the old session's last Stop. + setClaudeMainAgentTurnState(state, paneKey, { state: 'done', stateStartedAt: Date.now() }) return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { stateName: 'done', updateToolSnapshot: true, @@ -75,9 +78,19 @@ export function normalizeClaudeEvent( const interrupted = isTurnBoundary && ((eventAgentId === undefined && hookPayload['is_interrupt'] === true) || - previousLead?.interrupted === true) + claudeMainAgentTurnInterrupted(previousLead)) ? true : undefined + // Why: absent means unknown — a plain Stop never becomes `success`, so a cancel can never read as a + // success. Current Claude sends NO hook on a cancel and no `is_interrupt` on Stop, so the + // cancellation normally arrives through Orca's own inferred interrupt + // (`markClaudeLeadTurnInterrupted`) and is carried forward here; `is_interrupt` on a turn + // boundary is kept as the secondary source for builds that do send it. + const outcome = interrupted + ? ('cancellation' as const) + : isTurnBoundary && eventName === 'StopFailure' + ? ('failure' as const) + : undefined const backgroundTasks = readClaudeBackgroundAgentTasks(hookPayload) const sessionCrons = hookPayload['session_crons'] const sessionCronInventoryPresent = Array.isArray(sessionCrons) @@ -185,12 +198,15 @@ export function normalizeClaudeEvent( } // Why: approval granted — update the tool snapshot (drop the pending card) as the lead's own next tool event would. // Restore the stashed lead state, not this child's 'working': the lead may already be done, and the done-gate never upgrades working back to done once the roster drains. - const restored = lead.stateBeforeWait ?? { state: 'working' as const } - state.claudeLeadStateByPaneKey.set(paneKey, restored) + const restored = setClaudeMainAgentTurnState( + state, + paneKey, + lead.stateBeforeWait ?? { state: 'working' as const } + ) return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { ...resolveClaudePaneStatus(state, paneKey, restored), updateToolSnapshot: true, - interrupted: restored.interrupted, + interrupted: claudeMainAgentTurnInterrupted(restored), turnCompletedAt: restored.turnCompletedAt }) } @@ -223,7 +239,10 @@ export function normalizeClaudeEvent( ? previousLead.stateBeforeWait : { state: previousLead.state, - ...(previousLead.interrupted ? { interrupted: true as const } : {}), + // Why: the verdict and the main agent's own clock are that turn's facts; a child's permission + // pause after a cancelled turn must not erase them when the wait clears. + ...(previousLead.outcome ? { outcome: previousLead.outcome } : {}), + stateStartedAt: previousLead.stateStartedAt, // Why: a child's permission pause displaces an already-finished lead; keep the end time so the later drain is still that turn's tail. ...(previousLead.turnCompletedAt !== undefined ? { turnCompletedAt: previousLead.turnCompletedAt } @@ -255,7 +274,7 @@ export function normalizeClaudeEvent( const resolvedStatus = resolveClaudePaneStatus(state, paneKey, { state: reportedStateName, - interrupted + outcome }) // Why: #15202's compact-completion guard reads the resolved state; this branch replaced the // resolver with one that also reports workingMode, so bridge rather than resolve twice. @@ -270,9 +289,9 @@ export function normalizeClaudeEvent( ? Date.now() : undefined - state.claudeLeadStateByPaneKey.set(paneKey, { + setClaudeMainAgentTurnState(state, paneKey, { state: reportedStateName, - ...(interrupted ? { interrupted } : {}), + ...(outcome ? { outcome } : {}), ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), ...(isAskUserQuestionWait && waitingToolUseId !== undefined ? { waitingToolUseId } : {}), ...(stateBeforeWait ? { stateBeforeWait } : {}), diff --git a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts index 2e616ddbb8b..4d3f70a2b99 100644 --- a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts @@ -10,6 +10,7 @@ import { import type { HookListenerState } from '../listener-state' import { readString } from '../tool-input-preview' import { + claudeMainAgentTurnInterrupted, clearClaudePendingWaitForAgent, getOrCreateClaudeSubagentRoster, resolveClaudePaneStatus @@ -84,7 +85,7 @@ export function normalizeClaudeSubagentLifecycleEvent( const hasUnconfirmedChild = claudeRosterHasRestoredSnapshotSubagent(roster) const hasConfirmedDoneGate = cachedLead?.state === 'done' && - cachedLead.interrupted !== true && + !claudeMainAgentTurnInterrupted(cachedLead) && (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || state.claudeActiveSessionCronPaneKeys.has(paneKey)) const restoredOnlyDoneGate = @@ -134,10 +135,10 @@ export function buildClaudeCachedLeadStatusPayload( return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, { ...resolveClaudePaneStatus(state, paneKey, { state: leadState, - interrupted: lead?.interrupted + outcome: lead?.outcome }), updateToolSnapshot: false, - interrupted: lead?.interrupted, + interrupted: claudeMainAgentTurnInterrupted(lead), // Why: draining the last background child is this turn's all-clear; the stamp lets a consumer pair it with the announcement already sent. turnCompletedAt: lead?.turnCompletedAt }) diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts index a557ac4c31f..80c68037390 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -1,5 +1,13 @@ -import type { AgentSubagentSnapshot, AgentWorkingMode } from '../../agent-status-types' -import { foldAgentLeadStatus, type AgentLeadStatusResolution } from '../../agent-lead-status-fold' +import type { + AgentMainAgentStatus, + AgentSubagentSnapshot, + AgentWorkingMode +} from '../../agent-status-types' +import { + continueMainAgentStatus, + foldAgentLeadStatus, + type AgentLeadStatusResolution +} from '../../agent-lead-status-fold' import { agentChildWorkLivenessFromEvidence } from '../../agent-status-child-work-liveness' import { claudeRosterHasWorkingSubagent, @@ -118,14 +126,56 @@ export function updateClaudeRunningNonAgentTask( export type ClaudePaneStatusResolution = AgentLeadStatusResolution +/** A cancelled turn is the one verdict the display fold still reads. */ +export function claudeMainAgentTurnInterrupted( + record: Pick | undefined +): boolean { + return record?.outcome === 'cancellation' +} + +/** The only writer of the main agent record. The main agent's clock keeps continuity across + * same-state writes; a caller restoring a stash passes the stashed instant and wins. */ +export function setClaudeMainAgentTurnState( + state: HookListenerState, + paneKey: string, + next: Omit & { stateStartedAt?: number }, + now = Date.now() +): ClaudeLeadTurnState { + const previous = state.claudeLeadStateByPaneKey.get(paneKey) + const { state: nextState, outcome, stateStartedAt, ...rest } = next + const record: ClaudeLeadTurnState = { + ...rest, + ...continueMainAgentStatus(previous, { state: nextState, outcome, stateStartedAt }, now) + } + state.claudeLeadStateByPaneKey.set(paneKey, record) + return record +} + +/** The `mainAgent` fact a row publishes from its record: nothing invented, so a pane whose main + * agent was never observed publishes none and readers fall back to the combined `state`. + * A child-induced wait occupies the record but is child work, so the main agent is the state it displaced. */ +export function claudeMainAgentStatusForPayload( + record: ClaudeLeadTurnState +): AgentMainAgentStatus | undefined { + const own = record.waitingAgentId !== undefined ? record.stateBeforeWait : record + if (!own) { + return undefined + } + return { + state: own.state, + ...(own.state === 'done' && own.outcome ? { outcome: own.outcome } : {}), + stateStartedAt: own.stateStartedAt + } +} + export function resolveClaudePaneStatus( state: HookListenerState, paneKey: string, - lead: Pick + lead: Pick ): ClaudePaneStatusResolution { return foldAgentLeadStatus({ leadState: lead.state, - interrupted: lead.interrupted === true, + interrupted: claudeMainAgentTurnInterrupted(lead), childWorkLiveness: agentChildWorkLivenessFromEvidence({ hasLiveAgentWork: claudeRosterHasWorkingSubagent( state.claudeSubagentRosterByPaneKey.get(paneKey) @@ -136,9 +186,9 @@ export function resolveClaudePaneStatus( }) }) } -/** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with a missed Stop); else a later child lifecycle event resurrects the cancelled pane. */ +/** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with no Stop; current Claude sends no hook on a cancel, and a bare Esc is never inferred for Claude); else a later child lifecycle event resurrects the cancelled pane. This is the primary source of `mainAgent.outcome: 'cancellation'` in the CLI lane. */ export function markClaudeLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { - state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done', interrupted: true }) + setClaudeMainAgentTurnState(state, paneKey, { state: 'done', outcome: 'cancellation' }) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) } @@ -171,16 +221,25 @@ export function seedClaudeSubagentRosterFromSnapshots( } } +/** Restore a settled main agent so its children's drain can still complete the row after a restart. + * A running shell's liveness is not restored, so only a row that says no shell ran is seeded; one + * that says nothing (rewritten without the fact) stays unseeded rather than falsely settling. */ export function seedClaudeLeadTurnFromPersistedStatus( state: HookListenerState, paneKey: string, - status: Pick, - options: { childOnlyBoundary: boolean } + status: Pick ): void { - if (options.childOnlyBoundary && status.payload.agentType === 'claude') { - state.claudeLeadStateByPaneKey.set(paneKey, { + const mainAgent = status.payload.mainAgent + // Why: a row old enough to lack `mainAgent` was mapped from its legacy child-only flag at hydrate. + if ( + status.payload.agentType === 'claude' && + mainAgent?.state === 'done' && + status.claudeRunningNonAgentTask === false + ) { + setClaudeMainAgentTurnState(state, paneKey, { state: 'done', - ...(status.payload.interrupted === true ? { interrupted: true } : {}), + ...(mainAgent.outcome ? { outcome: mainAgent.outcome } : {}), + stateStartedAt: mainAgent.stateStartedAt, ...(status.payload.turnCompletedAt !== undefined ? { turnCompletedAt: status.payload.turnCompletedAt } : {}) @@ -226,7 +285,7 @@ export function clearClaudePendingWaitForAgent( if (lead?.state !== 'waiting' || !lead.waitingAgentId || !ownsWait(lead.waitingAgentId)) { return } - state.claudeLeadStateByPaneKey.set(paneKey, lead.stateBeforeWait ?? { state: 'working' }) + setClaudeMainAgentTurnState(state, paneKey, lead.stateBeforeWait ?? { state: 'working' }) const previousTool = state.lastToolByPaneKey.get(paneKey) state.lastToolByPaneKey.set( paneKey, @@ -243,15 +302,18 @@ export function clearClaudePendingWaitForAgent( export function clearClaudeAnsweredQuestionWait( state: HookListenerState, paneKey: string -): Pick & { +): Pick & { + interrupted?: true workingMode?: AgentWorkingMode + mainAgent?: AgentMainAgentStatus } { const lead = state.claudeLeadStateByPaneKey.get(paneKey) - const restored = + const stash = lead?.state === 'waiting' ? (lead.stateBeforeWait ?? { state: 'working' as const }) : { state: 'working' as const } - state.claudeLeadStateByPaneKey.set(paneKey, { ...restored }) + const restored = setClaudeMainAgentTurnState(state, paneKey, { ...stash }) + const publishedMainAgent = claudeMainAgentStatusForPayload(restored) const previousTool = state.lastToolByPaneKey.get(paneKey) state.lastToolByPaneKey.set( paneKey, @@ -263,14 +325,13 @@ export function clearClaudeAnsweredQuestionWait( : {} ) const resolved = resolveClaudePaneStatus(state, paneKey, restored) - return resolved.stateName === restored.state && resolved.workingMode === undefined - ? restored - : { - state: resolved.stateName, - ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), - ...(restored.interrupted ? { interrupted: true as const } : {}), - ...(restored.turnCompletedAt !== undefined - ? { turnCompletedAt: restored.turnCompletedAt } - : {}) - } + return { + state: resolved.stateName, + ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), + ...(claudeMainAgentTurnInterrupted(restored) ? { interrupted: true as const } : {}), + ...(restored.turnCompletedAt !== undefined + ? { turnCompletedAt: restored.turnCompletedAt } + : {}), + ...(publishedMainAgent ? { mainAgent: publishedMainAgent } : {}) + } } diff --git a/src/shared/agent-hook-listener/providers/claude-status-build.ts b/src/shared/agent-hook-listener/providers/claude-status-build.ts index e865a5c841d..9facff06d39 100644 --- a/src/shared/agent-hook-listener/providers/claude-status-build.ts +++ b/src/shared/agent-hook-listener/providers/claude-status-build.ts @@ -8,6 +8,7 @@ import { claudeRosterToSnapshots } from '../../claude-subagent-roster' import { resolvePrompt, resolveToolState } from '../prompt-fields' import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import type { HookListenerState } from '../listener-state' +import { claudeMainAgentStatusForPayload } from './claude-roster-state' export function buildClaudeStatusPayload( state: HookListenerState, @@ -31,6 +32,7 @@ export function buildClaudeStatusPayload( }) : (state.lastToolByPaneKey.get(paneKey) ?? {}) + const mainAgentRecord = state.claudeLeadStateByPaneKey.get(paneKey) // Why: validate directly — the JSON stringify/parse round trip other normalizers use is pure overhead on this hot per-hook path. // The normalizer clamps `interrupted` to done payloads, so a gated 'working' emit drops it; claudeLeadStateByPaneKey preserves it for the eventual done. return normalizeAgentStatusPayload({ @@ -49,6 +51,8 @@ export function buildClaudeStatusPayload( interrupted: options.interrupted, sessionBoundary: options.sessionBoundary, turnCompletedAt: options.turnCompletedAt, - subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey)) + subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey)), + // Why: every path writes the main agent record before building, so the row's `mainAgent` is that record. + mainAgent: mainAgentRecord ? claudeMainAgentStatusForPayload(mainAgentRecord) : undefined }) } diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts index ad2ce3db92f..d056b2de4db 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -22,9 +22,12 @@ import { resolvePrompt, resolveToolState } from '../prompt-fields' import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import { readString } from '../tool-input-preview' import { + codexMainAgentStatusForPayload, + codexOutcomeRestatedByStop, getOrCreateCodexSubagentRoster, getOrCreateCodexSubagentTranscriptState, - hasCodexTranscriptSubagents + hasCodexTranscriptSubagents, + setCodexMainAgentTurnState } from './codex-state' export function buildCodexStatusPayload( @@ -54,7 +57,8 @@ export function buildCodexStatusPayload( interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, - subagents: codexRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey)) + subagents: codexRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey)), + mainAgent: codexMainAgentStatusForPayload(lead) }) } @@ -224,12 +228,15 @@ export function normalizeCodexEvent( stateName ) const previousLead = state.codexLeadStateByPaneKey.get(paneKey) - state.codexLeadStateByPaneKey.set(paneKey, { + setCodexMainAgentTurnState(state, paneKey, { state: ownedState, + ...codexOutcomeRestatedByStop(previousLead, ownedState), model: normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ?? (eventName === 'SessionStart' ? undefined : previousLead?.model) }) + // The combined state keeps Codex's own rule (a waiting child wins, a done root with any live + // child reads working); folding it onto `foldAgentLeadStatus` is a separate slice. const effectiveState = codexRosterEffectiveState( state.codexSubagentRosterByPaneKey.get(paneKey), ownedState diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts index 9add1822099..bd86f77f8d8 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -1,4 +1,5 @@ -import type { ParsedAgentStatusPayload } from '../../agent-status-types' +import type { AgentMainAgentStatus, ParsedAgentStatusPayload } from '../../agent-status-types' +import { continueMainAgentStatus } from '../../agent-lead-status-fold' import { codexRosterEffectiveState, codexRosterToSnapshots, @@ -41,18 +42,74 @@ export function hasCodexTranscriptSubagents(state: HookListenerState, paneKey: s return hasTrackedCodexTranscriptSubagents(state.codexSubagentTranscriptByPaneKey.get(paneKey)) } +/** The only writer of the root record; the root's clock keeps continuity across same-state writes. */ +export function setCodexMainAgentTurnState( + state: HookListenerState, + paneKey: string, + next: Omit & { stateStartedAt?: number }, + now = Date.now() +): CodexLeadTurnState { + const previous = state.codexLeadStateByPaneKey.get(paneKey) + const continued = continueMainAgentStatus(previous, next, now) + const record: CodexLeadTurnState = { + state: next.state, + ...(continued.outcome ? { outcome: continued.outcome } : {}), + stateStartedAt: continued.stateStartedAt, + model: next.model + } + state.codexLeadStateByPaneKey.set(paneKey, record) + return record +} + +/** A root Stop that lands on an already finished turn (late, after an inferred cancel) restates + * that turn, so it keeps the recorded verdict; only a new turn clears it. */ +export function codexOutcomeRestatedByStop( + previous: CodexLeadTurnState | undefined, + nextState: CodexLeadTurnState['state'] +): Pick { + return nextState === 'done' && previous?.state === 'done' && previous.outcome + ? { outcome: previous.outcome } + : {} +} + +/** The `mainAgent` fact a Codex row publishes. Its combined `state` still comes from + * `codexRosterEffectiveState`, whose waiting-child rule the shared fold cannot express yet; + * moving that combine onto the fold is a separate slice with its own story table. */ +export function codexMainAgentStatusForPayload( + record: CodexLeadTurnState | undefined +): AgentMainAgentStatus | undefined { + return record + ? { + state: record.state, + ...(record.state === 'done' && record.outcome ? { outcome: record.outcome } : {}), + stateStartedAt: record.stateStartedAt + } + : undefined +} + export function seedCodexStateFromSnapshot( state: HookListenerState, paneKey: string, - payload: Pick + payload: Pick ): void { const snapshots = payload.subagents ?? [] if (snapshots.length > 0 && !state.codexSubagentRosterByPaneKey.has(paneKey)) { seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots) } if (!state.codexLeadStateByPaneKey.has(paneKey)) { + const mainAgent = payload.mainAgent // Why: child hooks after restart omit the root model; seed it from durable status before they can overwrite the cache. - state.codexLeadStateByPaneKey.set(paneKey, { + // A row that carries the root's own state is the fact; only an older row makes us infer it. + if (mainAgent && mainAgent.state !== 'blocked') { + setCodexMainAgentTurnState(state, paneKey, { + state: mainAgent.state, + ...(mainAgent.outcome ? { outcome: mainAgent.outcome } : {}), + stateStartedAt: mainAgent.stateStartedAt, + model: payload.model + }) + return + } + setCodexMainAgentTurnState(state, paneKey, { // Why: a child wait drives the aggregate waiting state, so it is not evidence that the root itself was waiting. state: payload.state === 'done' @@ -69,7 +126,11 @@ export function seedCodexStateFromSnapshot( /** Sync the Codex lead record when the server infers an interrupt, so delayed child events cannot restore stale working state. */ export function markCodexLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { const lead = state.codexLeadStateByPaneKey.get(paneKey) - state.codexLeadStateByPaneKey.set(paneKey, { state: 'done', model: lead?.model }) + setCodexMainAgentTurnState(state, paneKey, { + state: 'done', + outcome: 'cancellation', + model: lead?.model + }) } export function codexLeadStateForHookEvent( @@ -130,8 +191,9 @@ export function reconcileRemoteCodexState( } if (leadState) { const previousLead = state.codexLeadStateByPaneKey.get(paneKey) - state.codexLeadStateByPaneKey.set(paneKey, { + setCodexMainAgentTurnState(state, paneKey, { state: leadState, + ...codexOutcomeRestatedByStop(previousLead, leadState), model: payload.model ?? previousLead?.model }) } @@ -152,6 +214,8 @@ export function reconcileRemoteCodexState( prompt, state: codexRosterEffectiveState(roster, lead.state), model: lead.model ?? payload.model, - subagents: codexRosterToSnapshots(roster) + subagents: codexRosterToSnapshots(roster), + // Why: main's cache outlives a relay restart, so it is the main agent fact for a relayed row too. + mainAgent: codexMainAgentStatusForPayload(lead) } } diff --git a/src/shared/agent-hook-listener/providers/grok-events.ts b/src/shared/agent-hook-listener/providers/grok-events.ts index d80ba4bf94e..4b384a173d1 100644 --- a/src/shared/agent-hook-listener/providers/grok-events.ts +++ b/src/shared/agent-hook-listener/providers/grok-events.ts @@ -3,6 +3,8 @@ import { type ParsedAgentStatusPayload } from '../../agent-status-types' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { continueMainAgentStatus, foldAgentLeadStatus } from '../../agent-lead-status-fold' +import type { AgentChildWorkLiveness } from '../../agent-status-child-work-liveness' import { clearPaneTurnCacheState, type HookListenerState } from '../listener-state' import { normalizeGrokPromptId } from '../listener-limits' import { resolvePrompt, resolveToolState, stripGrokUserQueryWrapper } from '../prompt-fields' @@ -103,9 +105,16 @@ function grokHasRunningFiniteTask(hookPayload: Record): boolean }) } -function grokStopKeepsWorking(hookPayload: Record): boolean { +/** What a plain `stop` leaves running behind the main agent. Grok reports its finite tasks without a + * kind the roster could classify as agent work, and a still-active stop hook holds the turn the + * same way, so both read as watch work: the pane stays `working` in monitoring mode. */ +function grokChildWorkLivenessAfterStop( + hookPayload: Record +): AgentChildWorkLiveness { const stopHookActive = aliasedField(hookPayload, 'stopHookActive', 'stop_hook_active') return stopHookActive.value === true || grokHasRunningFiniteTask(hookPayload) + ? 'monitoring' + : null } function isGrokSessionBoundary(eventName: unknown, hookPayload: Record): boolean { @@ -130,6 +139,7 @@ export function normalizeGrokEvent( } if (isGrokEvent(eventName, 'session_start')) { // Why: SessionStart resets stale per-turn state but must not create a working row before any prompt/tool event. + // The main agent clock goes with it: a new process is a new main agent. clearPaneTurnCacheState(state, paneKey) return null } @@ -156,22 +166,16 @@ export function normalizeGrokEvent( if (isTurnEnd && !grokTurnEndApplies(state, paneKey, hookPayload)) { return null } - let stateName: 'working' | 'waiting' | 'done' | null = null + let leadState: 'working' | 'waiting' | 'done' | null = null if ( isGrokEvent(eventName, 'user_prompt_submit', 'post_tool_use', 'post_tool_use_failure') || (isGrokEvent(eventName, 'pre_tool_use') && !isUserInputPreTool) ) { - stateName = 'working' + leadState = 'working' } else if (isUserInputPreTool) { - stateName = 'waiting' - } else if ( - isGrokEvent(eventName, 'stop') && - !sessionBoundary && - grokStopKeepsWorking(hookPayload) - ) { - stateName = 'working' + leadState = 'waiting' } else if (isTurnEnd || isGrokEvent(eventName, 'session_end') || isIdlePrompt) { - stateName = 'done' + leadState = 'done' } else if ( isGrokEvent(eventName, 'notification') && isGrokEvent(notificationType, 'task_complete') @@ -191,11 +195,41 @@ export function normalizeGrokEvent( isGrokEvent(eventName, 'notification') && isGrokPermissionNotification(notificationMessage) ) { - stateName = 'waiting' + leadState = 'waiting' } - if (!stateName) { + if (!leadState) { return null } + // Why: only Grok's own cancel and failure events carry a verdict — a plain `stop` stays absent. + const outcome = isGrokEvent(eventName, 'stop_cancelled') + ? ('cancellation' as const) + : isGrokEvent(eventName, 'stop_failure') + ? ('failure' as const) + : undefined + // Only a plain end-of-turn `stop` reports what it left running; a cancel, a failure and a + // session boundary settle the pane whatever the inventory says, as they always have. + const resolution = foldAgentLeadStatus({ + leadState, + interrupted: outcome === 'cancellation', + childWorkLiveness: + isGrokEvent(eventName, 'stop') && !sessionBoundary + ? grokChildWorkLivenessAfterStop(hookPayload) + : null + }) + const stateName = resolution.stateName + const previousMainAgent = state.grokMainAgentStatusByPaneKey.get(paneKey) + // Why: an idle prompt or session end restates the same finished turn, so its verdict stands. + const mainAgentOutcome = + outcome ?? + (!isTurnEnd && leadState === 'done' && previousMainAgent?.state === 'done' + ? previousMainAgent.outcome + : undefined) + const mainAgent = continueMainAgentStatus( + previousMainAgent, + { state: leadState, outcome: mainAgentOutcome }, + Date.now() + ) + state.grokMainAgentStatusByPaneKey.set(paneKey, mainAgent) const snapshot = resolveToolState( state, @@ -220,10 +254,9 @@ export function normalizeGrokEvent( interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, - ...(stateName === 'working' && isGrokEvent(eventName, 'stop') - ? { workingMode: 'monitoring' as const } - : {}), - ...(isGrokEvent(eventName, 'stop_cancelled') ? { interrupted: true } : {}), - ...(sessionBoundary ? { sessionBoundary: true } : {}) + ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), + ...(outcome === 'cancellation' ? { interrupted: true } : {}), + ...(sessionBoundary ? { sessionBoundary: true } : {}), + mainAgent }) } diff --git a/src/shared/agent-lead-status-fold.test.ts b/src/shared/agent-lead-status-fold.test.ts index d39c5d78cb6..75ab3da89dd 100644 --- a/src/shared/agent-lead-status-fold.test.ts +++ b/src/shared/agent-lead-status-fold.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { foldAgentLeadStatus } from './agent-lead-status-fold' +import { + continueMainAgentStatus, + foldAgentLeadStatus, + isAgentStatusHeldOpenByChildWork +} from './agent-lead-status-fold' describe('foldAgentLeadStatus', () => { it('keeps a lead that is not settled, whatever its children do', () => { @@ -43,3 +47,45 @@ describe('foldAgentLeadStatus', () => { ).toEqual({ stateName: 'done' }) }) }) + +describe('isAgentStatusHeldOpenByChildWork', () => { + it('is true only when a settled main agent sits under a row that is not settled', () => { + expect( + isAgentStatusHeldOpenByChildWork({ state: 'working', mainAgent: { state: 'done' } }) + ).toBe(true) + expect(isAgentStatusHeldOpenByChildWork({ state: 'done', mainAgent: { state: 'done' } })).toBe( + false + ) + expect( + isAgentStatusHeldOpenByChildWork({ state: 'working', mainAgent: { state: 'working' } }) + ).toBe(false) + // No main agent fact means no claim: an old host's row is never read as child-held. + expect(isAgentStatusHeldOpenByChildWork({ state: 'working' })).toBe(false) + }) +}) + +describe('continueMainAgentStatus', () => { + it('keeps the clock across an unchanged state and restarts it on a change', () => { + const first = continueMainAgentStatus(undefined, { state: 'working' }, 10) + expect(first).toEqual({ state: 'working', stateStartedAt: 10 }) + expect(continueMainAgentStatus(first, { state: 'working' }, 20)).toEqual({ + state: 'working', + stateStartedAt: 10 + }) + expect(continueMainAgentStatus(first, { state: 'done', outcome: 'failure' }, 30)).toEqual({ + state: 'done', + outcome: 'failure', + stateStartedAt: 30 + }) + }) + + it('lets a caller that knows the instant win, and never carries a verdict onto a live state', () => { + expect(continueMainAgentStatus(undefined, { state: 'done', stateStartedAt: 4 }, 30)).toEqual({ + state: 'done', + stateStartedAt: 4 + }) + expect( + continueMainAgentStatus(undefined, { state: 'working', outcome: 'cancellation' }, 30) + ).toEqual({ state: 'working', stateStartedAt: 30 }) + }) +}) diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index 95ad86f5596..c74ae7df1f6 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -1,5 +1,5 @@ import type { AgentChildWorkLiveness } from './agent-status-child-work-liveness' -import type { AgentStatusState, AgentWorkingMode } from './agent-status-types' +import type { AgentMainAgentStatus, AgentStatusState, AgentWorkingMode } from './agent-status-types' export type AgentLeadStatusFoldInput = { /** The lead's own turn state. Anything but `done` wins outright. */ @@ -33,3 +33,34 @@ export function foldAgentLeadStatus(input: AgentLeadStatusFoldInput): AgentLeadS } return { stateName: 'done' } } + +/** The main agent settled and live child work is the only thing holding the row open. Derived, + * never stored: a stored copy could disagree with the two facts it is made of. */ +export function isAgentStatusHeldOpenByChildWork(row: { + state: AgentStatusState + mainAgent?: Pick +}): boolean { + return row.mainAgent?.state === 'done' && row.state !== 'done' +} + +/** The main agent's clock follows the same continuity rule as the row's: an unchanged main agent state + * keeps the instant it first appeared, a changed one starts at `now`. A caller that knows + * the real instant (a restored stash, a journal record) passes it and wins. */ +export function continueMainAgentStatus( + previous: Pick | undefined, + next: { + state: AgentStatusState + outcome?: AgentMainAgentStatus['outcome'] + stateStartedAt?: number + }, + now: number +): AgentMainAgentStatus { + const stateStartedAt = + next.stateStartedAt ?? + (previous && previous.state === next.state ? previous.stateStartedAt : now) + return { + state: next.state, + ...(next.state === 'done' && next.outcome ? { outcome: next.outcome } : {}), + stateStartedAt + } +} diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 8a49117eeea..d1b71c8fc2e 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -8,6 +8,7 @@ // journal rather than skipping or compacting past it. import type { AgentType } from './agent-status-types' +import type { AgentJournalTurnOutcome } from './agent-turn-outcome' import type { NativeChatToolMetadata } from './native-chat-tool-identity' import type { NativeChatBlock, NativeChatRole } from './native-chat-types' @@ -193,12 +194,9 @@ export const AGENT_JOURNAL_TURN_LIFECYCLE_STATES = [ ] as const export type AgentJournalTurnLifecycleState = (typeof AGENT_JOURNAL_TURN_LIFECYCLE_STATES)[number] -/** What the PROVIDER said became of a turn, kept separate from the lifecycle - * state so the four arms above stay a report on what the HOST observed. - * `cancellation` is a stop somebody asked for, `failure` is the provider's own - * error, and the two are never interchangeable: only `failure` is a fault. */ -export const AGENT_JOURNAL_TURN_OUTCOMES = ['success', 'failure', 'cancellation'] as const -export type AgentJournalTurnOutcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number] +// The turn verdict vocabulary lives in agent-turn-outcome.ts so the agent-status +// row can share it without importing the journal; re-exported to keep one import site. +export { AGENT_JOURNAL_TURN_OUTCOMES, type AgentJournalTurnOutcome } from './agent-turn-outcome' export type AgentJournalTurnLifecycle = { turnId: string diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 0bd97464af4..eae648968e0 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -219,6 +219,11 @@ export type AgentSessionStatusSummary = { toolInput?: string /** Preview of the newest assistant prose, so a settled row says what the agent said. */ lastAssistantMessage?: string + /** The provider's verdict on the newest settled root turn. Present only while `status` is + * `idle`: a running or attention-blocked turn has no verdict yet, and a stale one must not + * ride along. Absent means UNKNOWN, never success. Optional for mixed-version hosts; the + * agent-status row publishes it as `mainAgent.outcome`. */ + turnOutcome?: AgentJournalTurnOutcome /** Live provider-owned background tasks, so session lists can render * subagent children without holding a journal reader open. Optional for * mixed-version hosts. */ @@ -239,9 +244,12 @@ export type AgentSessionStatusEvent = /** * One root turn reaching a terminal outcome, derived by the EXECUTION HOST at journal commit. * - * Deliberately not a field on `AgentSessionStatusSummary`: that summary carries no turn identity - * and no outcome, it is re-broadcast on every status change, and adding an outcome would make - * every status consumer a completion consumer. A completion is a rare edge, not a state. + * This is the EDGE, with turn identity; `AgentSessionStatusSummary.turnOutcome` is the STATE. + * The summary carries the verdict only while the session is idle, as a fact about the main agent's + * last turn that a status reader may act on (attention alerts, the `mainAgent.outcome` row field), + * and never a turn id: a reader that needs to know WHICH turn finished, or to react exactly once + * per finish, subscribes here. Re-broadcasting the summary on every status change therefore + * repeats a state, not a completion. * * `outcome` is A0's provider verdict and is never inferred — a turn the host only observed ending * carries no outcome and produces no event at all, because absent means UNKNOWN, not success. diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index f79f3c974f3..55da23c2bb5 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, it, expect, vi } from 'vitest' import { + mainAgentStatusEqual, agentSubagentsEqual, isFreshNonDoneAgentStatus, parseAgentStatusPayload, @@ -707,3 +708,56 @@ describe('WellKnownAgentType', () => { expect(custom).toBe('some-in-house-agent') }) }) + +describe('the main agent field on a status payload', () => { + it('admits a well-formed main agent with its verdict only while the main agent is done', () => { + expect( + parseAgentStatusPayload( + '{"state":"working","mainAgent":{"state":"done","outcome":"cancellation","stateStartedAt":5}}' + )?.mainAgent + ).toEqual({ state: 'done', outcome: 'cancellation', stateStartedAt: 5 }) + // A verdict belongs to a finished turn; one riding on a live main agent state is stale. + expect( + parseAgentStatusPayload( + '{"state":"working","mainAgent":{"state":"working","outcome":"failure","stateStartedAt":5}}' + )?.mainAgent + ).toEqual({ state: 'working', stateStartedAt: 5 }) + expect( + parseAgentStatusPayload( + '{"state":"done","mainAgent":{"state":"done","outcome":"maybe","stateStartedAt":5}}' + )?.mainAgent + ).toEqual({ state: 'done', stateStartedAt: 5 }) + }) + + it('drops a malformed main agent but never the row it rides on', () => { + for (const mainAgent of [ + '"done"', + '{"state":"running","stateStartedAt":5}', + '{"state":"done"}', + '{"state":"done","stateStartedAt":"5"}', + '{"stateStartedAt":5}', + 'null' + ]) { + const parsed = parseAgentStatusPayload( + `{"state":"working","prompt":"keep me","mainAgent":${mainAgent}}` + ) + expect(parsed, mainAgent).toMatchObject({ state: 'working', prompt: 'keep me' }) + expect(parsed?.mainAgent, mainAgent).toBeUndefined() + } + }) + + it('is carried by the client-visible projection and compared structurally', () => { + const mainAgent = { state: 'done' as const, stateStartedAt: 7 } + expect( + pickParsedAgentStatusPayload({ state: 'working', prompt: '', mainAgent }).mainAgent + ).toEqual(mainAgent) + expect(pickParsedAgentStatusPayload({ state: 'working', prompt: '' })).not.toHaveProperty( + 'mainAgent' + ) + expect(mainAgentStatusEqual(mainAgent, { ...mainAgent })).toBe(true) + expect(mainAgentStatusEqual(mainAgent, { ...mainAgent, outcome: 'failure' })).toBe(false) + expect(mainAgentStatusEqual(mainAgent, { ...mainAgent, stateStartedAt: 8 })).toBe(false) + expect(mainAgentStatusEqual(undefined, undefined)).toBe(true) + expect(mainAgentStatusEqual(mainAgent, undefined)).toBe(false) + }) +}) diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index b863aa39c0c..e198f366b2b 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -3,6 +3,8 @@ // a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook. import type { AgentProviderSessionMetadata } from './agent-session-resume' +import type { AgentMainAgentStatus } from './main-agent-status' +import { isAgentJournalTurnOutcome } from './agent-turn-outcome' import type { OrchestrationFleetAttention } from './orchestration-fleet-attention' import type { AgentStatusRowFacets } from './agent-status-observation' import type { TuiAgent } from './tui-agent' @@ -22,10 +24,12 @@ export type { AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from './agent-status-ipc-payload' +export { mainAgentStatusEqual, type AgentMainAgentStatus } from './main-agent-status' export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number] export type AgentWorkingMode = 'monitoring' + // Why: agent types aren't a fixed set (custom agents exist); any non-empty string is // accepted — the well-known names are the launchable TuiAgent ids plus the 'unknown' // sentinel (no agent identified yet), a convenience union for pattern-matching. @@ -149,6 +153,9 @@ export type AgentStatusEntry = { /** Live in-process subagents/teammates of this pane's session. Absent when * none are tracked; the sidebar derives indented child rows from it. */ subagents?: AgentSubagentSnapshot[] + /** The main agent's own state; absent from old hosts and from writers that carry no main agent fact + * (OSC, launch seeds), where readers fall back to `state`. */ + mainAgent?: AgentMainAgentStatus /** Provider-owned conversation/session id captured from hook payloads. * Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */ providerSession?: AgentProviderSessionMetadata @@ -193,6 +200,9 @@ export type AgentStatusPayload = { turnCompletedAt?: number /** Live in-process children of the reporting session. See AgentStatusEntry. */ subagents?: AgentSubagentSnapshot[] + /** The main agent's own state and last-turn verdict. See AgentMainAgentStatus. Producers publish it + * beside the combined `state`; a reader that predates it keeps reading `state`. */ + mainAgent?: AgentMainAgentStatus } /** @@ -230,7 +240,8 @@ export function pickParsedAgentStatusPayload( ...(row.interrupted !== undefined ? { interrupted: row.interrupted } : {}), ...(row.sessionBoundary !== undefined ? { sessionBoundary: row.sessionBoundary } : {}), ...(row.turnCompletedAt !== undefined ? { turnCompletedAt: row.turnCompletedAt } : {}), - ...(row.subagents !== undefined ? { subagents: row.subagents } : {}) + ...(row.subagents !== undefined ? { subagents: row.subagents } : {}), + ...(row.mainAgent !== undefined ? { mainAgent: row.mainAgent } : {}) } } @@ -259,6 +270,10 @@ export { // Why: ReadonlySet so .has() accepts any string without a cast here; the narrowing cast stays on the return line where it's proven safe. const VALID_STATES: ReadonlySet = new Set(AGENT_STATUS_STATES) + +export function isAgentStatusState(value: unknown): value is AgentStatusState { + return typeof value === 'string' && VALID_STATES.has(value) +} /** Maximum character length for the agentType label. Truncated on parse. */ export const AGENT_TYPE_MAX_LENGTH = 40 export const AGENT_MODEL_MAX_LENGTH = 120 @@ -321,6 +336,28 @@ function normalizeSubagentsField(value: unknown): AgentSubagentSnapshot[] | unde return normalized.length > 0 ? normalized : undefined } +/** A malformed `mainAgent` drops the FIELD, never the row: the combined `state` is still valid + * evidence, and readers fall back to it exactly as they do for a host that predates the field. */ +function normalizeMainAgentStatusField(value: unknown): AgentMainAgentStatus | undefined { + if (typeof value !== 'object' || value === null) { + return undefined + } + const obj = value as Record + const state = obj.state + if (!isAgentStatusState(state)) { + return undefined + } + if (typeof obj.stateStartedAt !== 'number' || !Number.isFinite(obj.stateStartedAt)) { + return undefined + } + return { + state, + // Why: a verdict belongs to a finished turn; anything riding on a live state is stale. + ...(state === 'done' && isAgentJournalTurnOutcome(obj.outcome) ? { outcome: obj.outcome } : {}), + stateStartedAt: obj.stateStartedAt + } +} + /** Structural equality for subagent lists so stores can reuse the previous * array reference (and skip fanout) when nothing actually changed. */ export function agentSubagentsEqual( @@ -397,7 +434,8 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | interrupted: obj.interrupted === true && state === 'done' ? true : undefined, sessionBoundary: obj.sessionBoundary === true && state === 'done' ? true : undefined, turnCompletedAt: normalizeTurnCompletedAtField(obj.turnCompletedAt, state), - subagents: normalizeSubagentsField(obj.subagents) + subagents: normalizeSubagentsField(obj.subagents), + mainAgent: normalizeMainAgentStatusField(obj.mainAgent) } } diff --git a/src/shared/agent-turn-outcome.ts b/src/shared/agent-turn-outcome.ts new file mode 100644 index 00000000000..32fa936a457 --- /dev/null +++ b/src/shared/agent-turn-outcome.ts @@ -0,0 +1,14 @@ +/** The verdict on what became of a turn, kept separate from any lifecycle state + * so those stay a report on what the HOST observed. `cancellation` is a stop + * somebody asked for, `failure` is the provider's own error, and the two are + * never interchangeable: only `failure` is a fault. Shared by the journal's turn + * record, which holds only the provider's verdict and never infers one, and the + * agent-status row's `mainAgent.outcome`, which also records a `cancellation` + * Orca inferred from the user's own interrupt keystroke. Absent always means + * UNKNOWN, never success. */ +export const AGENT_JOURNAL_TURN_OUTCOMES = ['success', 'failure', 'cancellation'] as const +export type AgentJournalTurnOutcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number] + +export function isAgentJournalTurnOutcome(value: unknown): value is AgentJournalTurnOutcome { + return AGENT_JOURNAL_TURN_OUTCOMES.some((known) => known === value) +} diff --git a/src/shared/main-agent-status-parity.test.ts b/src/shared/main-agent-status-parity.test.ts new file mode 100644 index 00000000000..25112754034 --- /dev/null +++ b/src/shared/main-agent-status-parity.test.ts @@ -0,0 +1,383 @@ +// One story table driven through every lane that publishes a main agent's status. Each lane +// derives child liveness from its own evidence, but the published `{ state, workingMode, mainAgent }` +// must be what the shared fold says for that main agent and that evidence — a producer that folds +// differently is caught here structurally, not by review. +import { beforeEach, describe, expect, it } from 'vitest' +import { normalizeHookPayload } from './agent-hook-listener' +import { markClaudeLeadTurnInterrupted } from './agent-hook-listener/providers/claude-roster-state' +import { + createHookListenerState, + type HookListenerState +} from './agent-hook-listener/listener-state' +import { PANE_KEY } from './agent-hook-listener-test-harness' +import { foldAgentLeadStatus } from './agent-lead-status-fold' +import type { AgentSessionBackgroundTask } from './agent-session-background-task-wire' +import { + agentChildWorkLiveness, + agentChildWorkLivenessFromEvidence, + type AgentChildWorkLiveness +} from './agent-status-child-work-liveness' +import type { + AgentMainAgentStatus, + AgentStatusState, + AgentWorkingMode, + ParsedAgentStatusPayload +} from './agent-status-types' +import { codexRosterEffectiveState, seedCodexSubagentRoster } from './codex-subagent-roster' +import { structuredAgentSessionAgentStatus } from './structured-agent-session-agent-status' +import type { AgentJournalTurnOutcome } from './agent-turn-outcome' + +type Published = { + state: AgentStatusState + workingMode?: AgentWorkingMode + mainAgent: Omit +} + +const RUNNING_SHELL = { id: 'shell-1', type: 'shell', status: 'running' } +/** Not a hook: current Claude sends none on a cancel, so Orca infers it from the keystroke. */ +const ORCA_INFERRED_INTERRUPT = { orca_inferred_interrupt: true } +const RUNNING_AGENT = { id: 'agent-1', type: 'subagent', status: 'running' } +const AGENT_TASK: AgentSessionBackgroundTask = { id: 'agent-1', kind: 'agent', state: 'working' } +const SHELL_TASK: AgentSessionBackgroundTask = { id: 'shell-1', kind: 'command', state: 'working' } + +function published(payload: ParsedAgentStatusPayload | null | undefined): Published { + if (!payload?.mainAgent) { + throw new Error('the lane published no main agent fact') + } + const { stateStartedAt: _clock, ...mainAgent } = payload.mainAgent + return { + state: payload.state, + ...(payload.workingMode ? { workingMode: payload.workingMode } : {}), + mainAgent + } +} + +/** The main agent's own state and verdict, restated as the fold's inputs. */ +function refold( + mainAgent: Published['mainAgent'], + childWorkLiveness: AgentChildWorkLiveness +): Published { + const resolution = foldAgentLeadStatus({ + leadState: mainAgent.state, + interrupted: mainAgent.outcome === 'cancellation', + childWorkLiveness + }) + return { + state: resolution.stateName, + ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), + mainAgent + } +} + +type Story = { + name: string + claude?: { events: Record[]; expect: Published } + structured?: { + status: 'working' | 'attention' | 'idle' + backgroundTasks?: AgentSessionBackgroundTask[] + turnOutcome?: AgentJournalTurnOutcome + expect: Published + } + grok?: { events: Record[]; expect: Published } + codex?: { events: Record[]; expect: Published } +} + +const STORIES: Story[] = [ + { + name: 'main agent working', + claude: { + events: [{ hook_event_name: 'UserPromptSubmit', prompt: 'go' }], + expect: { state: 'working', mainAgent: { state: 'working' } } + }, + structured: { + status: 'working', + expect: { state: 'working', mainAgent: { state: 'working' } } + }, + grok: { + events: [{ hookEventName: 'user_prompt_submit', prompt: 'go' }], + expect: { state: 'working', mainAgent: { state: 'working' } } + }, + codex: { + events: [{ hook_event_name: 'UserPromptSubmit', prompt: 'go' }], + expect: { state: 'working', mainAgent: { state: 'working' } } + } + }, + { + name: 'done with a live subagent', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, + { hook_event_name: 'Stop', background_tasks: [RUNNING_AGENT] } + ], + expect: { state: 'working', mainAgent: { state: 'done' } } + }, + structured: { + status: 'idle', + backgroundTasks: [AGENT_TASK], + expect: { state: 'working', mainAgent: { state: 'done' } } + }, + grok: { + events: [ + { hookEventName: 'user_prompt_submit', prompt: 'go' }, + { hookEventName: 'stop', reason: 'end_turn', backgroundTasks: [RUNNING_AGENT] } + ], + // Grok reports no task kind the roster can classify as agent work, so its live subagent + // reads as watch work. Today's label, kept on purpose; a Grok-specific follow-up. + expect: { state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } } + }, + codex: { + // A root Stop with no transcript-tracked children clears the roster (Codex 0.144 could omit + // child Stop hooks), so the child proves it is still alive with its next tool event. + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, + { hook_event_name: 'Stop' }, + { hook_event_name: 'PreToolUse', agent_id: 'agent-1', tool_name: 'shell' } + ], + expect: { state: 'working', mainAgent: { state: 'done' } } + } + }, + { + name: 'done with only a watch loop', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] } + ], + expect: { state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } } + }, + structured: { + status: 'idle', + backgroundTasks: [SHELL_TASK], + expect: { state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } } + }, + grok: { + events: [ + { hookEventName: 'user_prompt_submit', prompt: 'go' }, + { hookEventName: 'stop', reason: 'end_turn', backgroundTasks: [RUNNING_SHELL] } + ], + expect: { state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } } + } + }, + { + name: 'blocked with a live subagent', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, + { hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: { command: 'rm' } } + ], + // The hook lane's vocabulary for "the main agent needs a human" is `waiting`. + expect: { state: 'waiting', mainAgent: { state: 'waiting' } } + }, + structured: { + status: 'attention', + backgroundTasks: [AGENT_TASK], + expect: { state: 'blocked', mainAgent: { state: 'blocked' } } + }, + grok: { + events: [ + { hookEventName: 'user_prompt_submit', prompt: 'go' }, + { hookEventName: 'pre_tool_use', toolName: 'ask_user_question' } + ], + expect: { state: 'waiting', mainAgent: { state: 'waiting' } } + }, + codex: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, + { hook_event_name: 'PermissionRequest', tool_name: 'shell' } + ], + expect: { state: 'waiting', mainAgent: { state: 'waiting' } } + } + }, + { + name: 'failed turn', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'StopFailure', error: 'invalid_request' } + ], + expect: { state: 'done', mainAgent: { state: 'done', outcome: 'failure' } } + }, + structured: { + status: 'idle', + turnOutcome: 'failure', + expect: { state: 'done', mainAgent: { state: 'done', outcome: 'failure' } } + }, + grok: { + events: [ + { hookEventName: 'user_prompt_submit', prompt: 'go' }, + { hookEventName: 'stop_failure' } + ], + expect: { state: 'done', mainAgent: { state: 'done', outcome: 'failure' } } + } + }, + { + // KNOWN DIVERGENCE, pinned on purpose. The hook lane hides a still-running shell after an + // interrupted turn; the structured lane never feeds the verdict into the fold and keeps + // showing the shell. The cancel policy (PR C) flips the hook-lane rows to monitoring and + // must update this story, not delete it. The Claude row here is the primary path: Orca's + // inferred cancel, carried by the main agent record into the next Stop, which lists the shell. + name: 'interrupted with a watch loop (known divergence: CLI done / structured monitoring)', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + ORCA_INFERRED_INTERRUPT, + { hook_event_name: 'Stop', background_tasks: [RUNNING_SHELL] } + ], + expect: { state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } } + }, + structured: { + status: 'idle', + turnOutcome: 'cancellation', + backgroundTasks: [SHELL_TASK], + expect: { + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + } + }, + grok: { + events: [ + { hookEventName: 'user_prompt_submit', prompt: 'go' }, + { hookEventName: 'stop_cancelled', backgroundTasks: [RUNNING_SHELL] } + ], + expect: { state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } } + } + }, + { + // Secondary source: a build that does send `is_interrupt` on its Stop. Same known divergence. + name: 'interrupted by a Stop that carries is_interrupt, with a watch loop (older builds)', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'Stop', is_interrupt: true, background_tasks: [RUNNING_SHELL] } + ], + expect: { state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } } + } + } +] + +/** Codex never reports a blocked root; the combine's input type says so. */ +function codexMainAgentState(state: AgentStatusState): 'working' | 'waiting' | 'done' { + if (state === 'blocked') { + throw new Error('Codex published a blocked main agent') + } + return state +} + +/** The stories a lane takes part in, as `it.each` rows. */ +function storiesFor( + lane: K +): [string, NonNullable][] { + const rows: [string, NonNullable][] = [] + for (const story of STORIES) { + const entry = story[lane] + if (entry !== undefined) { + rows.push([story.name, entry]) + } + } + return rows +} + +describe('mainAgent status parity across lanes', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + function drive( + source: 'claude' | 'grok' | 'codex', + events: Record[] + ): ParsedAgentStatusPayload { + let last: ParsedAgentStatusPayload | null = null + for (const payload of events) { + if (payload === ORCA_INFERRED_INTERRUPT) { + markClaudeLeadTurnInterrupted(state, PANE_KEY) + continue + } + const event = normalizeHookPayload( + state, + source, + { paneKey: PANE_KEY, payload }, + 'production' + ) + last = event?.payload ?? last + } + if (!last) { + throw new Error('the lane published nothing') + } + return last + } + + /** The hook lane's child evidence: the roster on the row, the shell and cron sets in memory. */ + function claudeChildWorkLiveness(payload: ParsedAgentStatusPayload): AgentChildWorkLiveness { + return agentChildWorkLivenessFromEvidence({ + hasLiveAgentWork: payload.subagents?.some((child) => child.state === 'working') === true, + hasLiveNonAgentWork: + state.claudeRunningNonAgentTaskPaneKeys.has(PANE_KEY) || + state.claudeActiveSessionCronPaneKeys.has(PANE_KEY) + }) + } + + describe('Claude hook lane', () => { + it.each(storiesFor('claude'))('%s', (_name, lane) => { + const payload = drive('claude', lane.events) + const row = published(payload) + expect(row).toEqual(lane.expect) + expect(row).toEqual(refold(row.mainAgent, claudeChildWorkLiveness(payload))) + }) + }) + + describe('structured lane', () => { + it.each(storiesFor('structured'))('%s', (_name, lane) => { + const row = structuredAgentSessionAgentStatus({ + status: lane.status, + backgroundTasks: lane.backgroundTasks, + turnOutcome: lane.turnOutcome + }) + expect(row).toEqual(lane.expect) + // This lane never feeds the verdict into the fold: refold with the verdict masked. + const masked = { state: row.mainAgent.state } + expect(refold(masked, agentChildWorkLiveness(lane.backgroundTasks))).toEqual({ + ...row, + mainAgent: masked + }) + }) + }) + + describe('Grok hook lane', () => { + it.each(storiesFor('grok'))('%s', (_name, lane) => { + const payload = drive('grok', lane.events) + const row = published(payload) + expect(row).toEqual(lane.expect) + // Grok's child evidence lives only on its final plain `stop`: a finite task or an active + // stop hook is watch work, and nothing else ever holds the pane. + const last = lane.events.at(-1) ?? {} + const tasks = Array.isArray(last.backgroundTasks) ? last.backgroundTasks : [] + const liveness: AgentChildWorkLiveness = + last.hookEventName === 'stop' && (tasks.length > 0 || last.stopHookActive === true) + ? 'monitoring' + : null + expect(row).toEqual(refold(row.mainAgent, liveness)) + }) + }) + + describe('Codex hook lane (own combine, not the shared fold)', () => { + it.each(storiesFor('codex'))('%s', (_name, lane) => { + const payload = drive('codex', lane.events) + const row = published(payload) + expect(row).toEqual(lane.expect) + // Codex keeps `codexRosterEffectiveState` until its combine moves onto the fold: a + // waiting child wins, a settled root with any live child reads working, no monitoring. + const roster = new Map() + seedCodexSubagentRoster(roster, payload.subagents ?? []) + expect(row.state).toBe( + codexRosterEffectiveState(roster, codexMainAgentState(row.mainAgent.state)) + ) + }) + }) +}) diff --git a/src/shared/main-agent-status.ts b/src/shared/main-agent-status.ts new file mode 100644 index 00000000000..5ca2051e8d4 --- /dev/null +++ b/src/shared/main-agent-status.ts @@ -0,0 +1,32 @@ +import type { AgentStatusState } from './agent-status-types' +import type { AgentJournalTurnOutcome } from './agent-turn-outcome' + +/** The main agent's OWN state, kept apart from the row's combined `state`. The row's + * `state` answers "what should the user see" and folds live child work in, so a settled main agent + * whose subagent still runs reads `working`; this answers "what is the main agent itself doing". + * Persisted on disk and carried on every wire, so its shape is permanent. */ +export type AgentMainAgentStatus = { + state: AgentStatusState + /** The recorded verdict on the main agent's most recent finished turn: reported by the + * provider, or `cancellation` inferred from the user's own interrupt keystroke. Present only + * while `state` is `done`; a new turn clears it. ABSENT MEANS UNKNOWN — a plain Stop never + * infers `success`, because an older provider that omits its interrupt flag would turn + * a cancel into a false success. */ + outcome?: AgentJournalTurnOutcome + /** When the main agent's own `state` first appeared (ms). The row's `stateStartedAt` dates the + * combined state instead, so the two differ while child work holds the row open. */ + stateStartedAt: number +} + +export function mainAgentStatusEqual( + a: AgentMainAgentStatus | undefined, + b: AgentMainAgentStatus | undefined +): boolean { + if (a === b) { + return true + } + if (!a || !b) { + return false + } + return a.state === b.state && a.outcome === b.outcome && a.stateStartedAt === b.stateStartedAt +} diff --git a/src/shared/structured-agent-session-agent-status.test.ts b/src/shared/structured-agent-session-agent-status.test.ts index 6df9d473c22..15dc6995748 100644 --- a/src/shared/structured-agent-session-agent-status.test.ts +++ b/src/shared/structured-agent-session-agent-status.test.ts @@ -14,20 +14,20 @@ describe('structuredAgentSessionAgentStatus', () => { it('maps a lead that is still working or needs attention without consulting children', () => { expect(structuredAgentSessionAgentStatus({ status: 'working' })).toEqual({ state: 'working', - fromChildWork: false + mainAgent: { state: 'working' } }) expect( structuredAgentSessionAgentStatus({ status: 'attention', backgroundTasks: [task({ kind: 'command' })] }) - ).toEqual({ state: 'blocked', fromChildWork: false }) + ).toEqual({ state: 'blocked', mainAgent: { state: 'blocked' } }) }) - it('keeps an idle lead working while a subagent runs', () => { + it('keeps an idle main agent working while a subagent runs, and says the main agent itself is done', () => { expect( structuredAgentSessionAgentStatus({ status: 'idle', backgroundTasks: [task()] }) - ).toEqual({ state: 'working', fromChildWork: true }) + ).toEqual({ state: 'working', mainAgent: { state: 'done' } }) }) it('reads an idle lead with only a backgrounded shell as monitoring', () => { @@ -36,14 +36,14 @@ describe('structuredAgentSessionAgentStatus', () => { status: 'idle', backgroundTasks: [task({ kind: 'command', description: 'sleep 180' })] }) - ).toEqual({ state: 'working', workingMode: 'monitoring', fromChildWork: true }) + ).toEqual({ state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } }) }) it('keeps an idle lead working while a subagent is blocked or out of contact', () => { for (const state of ['waiting', 'blocked', 'unverifiable'] as const) { expect( structuredAgentSessionAgentStatus({ status: 'idle', backgroundTasks: [task({ state })] }) - ).toEqual({ state: 'working', fromChildWork: true }) + ).toEqual({ state: 'working', mainAgent: { state: 'done' } }) } }) @@ -54,7 +54,7 @@ describe('structuredAgentSessionAgentStatus', () => { expect(structuredAgentSessionAgentStatus({ status: 'idle', backgroundTasks })).toEqual({ state: 'working', workingMode: 'monitoring', - fromChildWork: true + mainAgent: { state: 'done' } }) expect( projectAgentChildWorkLegacySubagents( @@ -72,10 +72,32 @@ describe('structuredAgentSessionAgentStatus', () => { task({ id: 'shell', kind: 'command', state: 'idle' }) ] }) - ).toEqual({ state: 'done', fromChildWork: false }) + ).toEqual({ state: 'done', mainAgent: { state: 'done' } }) expect(structuredAgentSessionAgentStatus({ status: 'idle' })).toEqual({ state: 'done', - fromChildWork: false + mainAgent: { state: 'done' } }) }) + + // The verdict is a fact about a finished turn; the fold never reads it, so a cancelled turn with + // a watch loop still reads monitoring here (the hook lane's known divergence, until PR C). + it('carries the turn verdict on the main agent only while the main agent is done', () => { + expect( + structuredAgentSessionAgentStatus({ status: 'idle', turnOutcome: 'cancellation' }) + ).toEqual({ state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } }) + expect( + structuredAgentSessionAgentStatus({ + status: 'idle', + turnOutcome: 'cancellation', + backgroundTasks: [task({ kind: 'command' })] + }) + ).toEqual({ + state: 'working', + workingMode: 'monitoring', + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + expect( + structuredAgentSessionAgentStatus({ status: 'working', turnOutcome: 'failure' }) + ).toEqual({ state: 'working', mainAgent: { state: 'working' } }) + }) }) diff --git a/src/shared/structured-agent-session-agent-status.ts b/src/shared/structured-agent-session-agent-status.ts index 937c6850645..78a455c4cfb 100644 --- a/src/shared/structured-agent-session-agent-status.ts +++ b/src/shared/structured-agent-session-agent-status.ts @@ -1,15 +1,15 @@ import type { AgentSessionStatusSummary } from './agent-session-wire' import { foldAgentLeadStatus } from './agent-lead-status-fold' import { agentChildWorkLiveness } from './agent-status-child-work-liveness' -import type { AgentStatusState, AgentWorkingMode } from './agent-status-types' +import type { AgentMainAgentStatus, AgentStatusState, AgentWorkingMode } from './agent-status-types' import type { StructuredAgentSessionProjectedStatus } from './structured-agent-session-projection' export type StructuredAgentSessionAgentStatus = { state: AgentStatusState workingMode?: AgentWorkingMode - /** The lead had settled and live child work is the only thing holding this row open. The - * journal cannot date such a row: its clock stopped when the lead's turn did. */ - fromChildWork: boolean + /** The main agent's own state and last-turn verdict, before child work is folded in. The caller + * stamps the clock: this projection has no view of when the main agent's state first appeared. */ + mainAgent: Omit } /** The lead state one projected session status stands for, before child work is folded in. */ @@ -23,22 +23,26 @@ function structuredAgentSessionLeadState( * work folded in the same way the hook lane folds a subagent roster. Shared across the * process boundary so `worktree ps`, mobile and the sidebar cannot disagree about one session. */ export function structuredAgentSessionAgentStatus( - summary: Pick & { + summary: Pick & { status: StructuredAgentSessionProjectedStatus } ): StructuredAgentSessionAgentStatus { const leadState = structuredAgentSessionLeadState(summary.status) const resolution = foldAgentLeadStatus({ leadState, - // Inert, not decided: a projected session status has no interrupted member, so this lane - // cannot express one. The hook lane's guard exists to distrust a stale inventory snapshot; - // here the task list is the provider's live roster and a settled task leaves it on its own. + // Known divergence from the hook lane, kept on purpose until the cancel policy lands: that + // lane hides a still-running shell after an interrupted turn (`updateClaudeRunningNonAgentTask` + // calls it a live-shell judgement), so a cancelled turn with a watch loop reads `done` there + // and `monitoring` here. This lane never feeds the verdict into the fold — see `mainAgent.outcome`. interrupted: false, childWorkLiveness: agentChildWorkLiveness(summary.backgroundTasks) }) return { state: resolution.stateName, ...(resolution.workingMode ? { workingMode: resolution.workingMode } : {}), - fromChildWork: leadState === 'done' && resolution.stateName !== 'done' + mainAgent: { + state: leadState, + ...(leadState === 'done' && summary.turnOutcome ? { outcome: summary.turnOutcome } : {}) + } } } diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 7b515f325a0..9c3a64a9010 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -701,3 +701,43 @@ describe("producer linkage — a subagent's output never speaks for the parent", ).toBe('legacy child line') }) }) + +describe('the turn verdict on the status summary', () => { + const user = item('u1', 1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'go' }] + }) + + it('carries the newest settled turn verdict only while the session is idle', () => { + const running = item('turn-running', 2, { + kind: 'turn', + turnId: 'turn-1', + state: 'running' + }) + expect(projectStructuredAgentSessionStatusSummary([user, running])).not.toHaveProperty( + 'turnOutcome' + ) + const cancelled = item('turn-cancelled', 3, { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + outcome: 'cancellation' + }) + expect(projectStructuredAgentSessionStatusSummary([user, cancelled])).toMatchObject({ + status: 'idle', + turnOutcome: 'cancellation' + }) + }) + + it('reports no verdict for a settled turn the provider never judged', () => { + const completed = item('turn-completed', 2, { + kind: 'turn', + turnId: 'turn-1', + state: 'completed' + }) + expect(projectStructuredAgentSessionStatusSummary([user, completed])).not.toHaveProperty( + 'turnOutcome' + ) + }) +}) diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 5f45d001c18..4b5bfb5d098 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -7,9 +7,11 @@ import { AGENT_JOURNAL_MESSAGE_SEND_MODES, type AgentJournalMessageSendMode, type AgentJournalRenderItem, - type AgentJournalSubmission + type AgentJournalSubmission, + type AgentJournalTurnOutcome } from './agent-session-journal-types' import { isRootAgentJournalItem } from './agent-session-journal-producer' +import { readAgentJournalTurnOutcome } from './agent-session-turn-record' import { AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_STATUS_TOOL_NAME_MAX_LENGTH @@ -17,6 +19,7 @@ import { import { describeToolInput } from './native-chat-tool-summary' import { activeStructuredAgentSessionTurnId, + newestStructuredAgentSessionTurn, statusStructuredAgentSessionToolCall } from './structured-agent-session-live-turn' import { @@ -308,6 +311,8 @@ export type StructuredAgentSessionStatusProjection = { toolName?: string toolInput?: string lastAssistantMessage?: string + /** The newest settled turn's provider verdict; present only while `status` is idle. */ + turnOutcome?: AgentJournalTurnOutcome } /** One projection shared by host and client: null status means "no turn yet", not idle. @@ -344,12 +349,17 @@ export function projectStructuredAgentSessionStatusSummary( latestStructuredAgentSessionAssistantMessage(items), AGENT_STATUS_MAX_FIELD_LENGTH ) + // A verdict is a fact about a finished turn: only an idle session has one to report, and + // `readAgentJournalTurnOutcome` already answers null for anything it cannot place. + const turnOutcome = + status === 'idle' ? readAgentJournalTurnOutcome(newestStructuredAgentSessionTurn(items)) : null return { status, latestPrompt: normalizePromptField(latestStructuredAgentSessionPrompt(items)), ...(toolName ? { toolName } : {}), ...(toolInput ? { toolInput } : {}), - ...(lastAssistantMessage ? { lastAssistantMessage } : {}) + ...(lastAssistantMessage ? { lastAssistantMessage } : {}), + ...(turnOutcome ? { turnOutcome } : {}) } }