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 } : {}) } }