diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 585c5ebc43c..b962df41edc 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -94,16 +94,16 @@ The structured feed keeps its job of projecting a session's journal into a summary and streaming it to subscribers. On every publish it additionally ingests the summary into the hook server as a status row: -| Row field | From | -| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it | -| `tabId` | `structuredAgentSessionTabId(sessionId)` | -| `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 | +| Row field | From | +| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it | +| `tabId` | `structuredAgentSessionTabId(sessionId)` | +| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) | +| `state` | `structuredAgentSessionAgentStatus(summary).state`: the lead's own status folded with the live child records the store holds for the session (not the summary's task list), 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 | Sessions with no request (`status === null`) produce no row. A request is a turn record, an assistant message, a user message the provider journaled itself diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index e6fc34699ff..59a4102471b 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -32,6 +32,7 @@ import { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' vi.mock('./launch', () => ({ launchOrcaApp: vi.fn() @@ -75,6 +76,7 @@ describe('CLI remote WebSocket transport', () => { clientCapabilities: [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, diff --git a/src/main/agent-hooks/server-ingest-structured-state-clock.test.ts b/src/main/agent-hooks/server-ingest-structured-state-clock.test.ts index eeb8ffab51e..8d5fae03545 100644 --- a/src/main/agent-hooks/server-ingest-structured-state-clock.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-state-clock.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { AgentHookServer, _internals } from './server' @@ -32,6 +33,22 @@ function summary(over: Partial = {}): AgentSessionSta } } +const CHILD = { idKind: 'task_id', id: 'child-1' } as const + +function liveChild(observedAt: number): AgentChildWorkEvidence { + return { + type: 'live', + observedAt, + child: { + handle: CHILD, + kind: 'agent', + residency: 'background', + state: 'working', + stoppable: true + } + } +} + function row(server: AgentHookServer) { return server.getStatusSnapshot()[0] } @@ -68,16 +85,16 @@ describe("the host row's state clock", () => { summary({ status: 'working', statusStartedAt: 10_000, updatedAt: 10_000 }), SUBJECT ) - server.ingestStructuredStatus( - summary({ - statusStartedAt: SETTLED, - updatedAt: 24_000, - backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }] - }), - SUBJECT - ) + // The row reads child liveness from the host's records, not the summary's task list. + server.ingestStructuredChildWork(SUBJECT, [liveChild(23_000)], 'codex') + server.ingestStructuredStatus(summary({ statusStartedAt: SETTLED, updatedAt: 24_000 }), SUBJECT) expect(row(server)).toMatchObject({ state: 'working', stateStartedAt: 10_000 }) + server.ingestStructuredChildWork( + SUBJECT, + [{ type: 'ended', observedAt: 25_000, handle: CHILD, outcome: 'succeeded' }], + 'codex' + ) server.ingestStructuredStatus(summary({ statusStartedAt: SETTLED, updatedAt: 26_000 }), SUBJECT) expect(row(server)).toMatchObject({ state: 'done', stateStartedAt: SETTLED }) }) 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 654160e6fef..3752a902dec 100644 --- a/src/main/agent-hooks/server-ingest-structured-status.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-status.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'nod import { tmpdir } from 'node:os' import { join } from 'node:path' import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-freshness' import { structuredAgentSessionPaneKey, @@ -39,6 +40,30 @@ const TAB = structuredAgentSessionTabId(SESSION) const STRUCTURED_PANE = structuredAgentSessionPaneKey(TAB, SESSION) const OBSERVED_AT = 1_757_030_400_000 +/** A child the provider reports live, as the structured producers hand it to the host. */ +function liveChild(id: string, kind: 'agent' | 'command'): AgentChildWorkEvidence { + return { + type: 'live', + observedAt: OBSERVED_AT, + child: { + handle: { idKind: 'task_id', id }, + kind, + residency: 'background', + state: 'working', + stoppable: true + } + } +} + +function endedChild(id: string): AgentChildWorkEvidence { + return { + type: 'ended', + observedAt: OBSERVED_AT + 1, + handle: { idKind: 'task_id', id }, + outcome: 'succeeded' + } +} + function summary(over: Partial = {}): AgentSessionStatusSummary { return { sessionId: SESSION, @@ -103,7 +128,34 @@ describe('AgentHookServer ingestStructuredStatus', () => { expect(server.getStatusSnapshot()[0]?.state).toBe('done') }) - it('folds live background tasks into an idle session the way the hook lane folds a roster', () => { + it("folds the host's live child records into an idle session the way the hook lane folds a roster", () => { + const server = new AgentHookServer() + server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT) + server.ingestStructuredChildWork(SUBJECT, [liveChild('child-1', 'agent')], 'codex') + server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 1 }), SUBJECT) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('workingMode') + + server.ingestStructuredChildWork( + SUBJECT, + [endedChild('child-1'), liveChild('shell-1', 'command')], + 'codex' + ) + server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 2 }), SUBJECT) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + workingMode: 'monitoring' + }) + + server.ingestStructuredChildWork(SUBJECT, [endedChild('shell-1')], 'codex') + server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 3 }), SUBJECT) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) + expect(server.getStatusSnapshot()[0]).not.toHaveProperty('workingMode') + }) + + // The summary's task list is derived from these records for older clients; the row reads the + // records themselves, so a list that disagrees with them cannot hold the parent open. + it("reads child liveness from the host's records, never from the summary's task list", () => { const server = new AgentHookServer() server.ingestStructuredStatus( summary({ @@ -112,45 +164,16 @@ describe('AgentHookServer ingestStructuredStatus', () => { }), SUBJECT ) - expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) - expect(server.getStatusSnapshot()[0]).not.toHaveProperty('workingMode') - - server.ingestStructuredStatus( - summary({ - status: 'idle', - updatedAt: OBSERVED_AT + 1, - backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }] - }), - SUBJECT - ) - expect(server.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - workingMode: 'monitoring' - }) - - server.ingestStructuredStatus( - summary({ - status: 'idle', - updatedAt: OBSERVED_AT + 2, - backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'done' }] - }), - SUBJECT - ) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) - expect(server.getStatusSnapshot()[0]).not.toHaveProperty('workingMode') }) // The timer beside the row belongs to the state it labels: monitoring that becomes a real turn // must not report the watch loop's age as how long the agent has been working. it('restarts the state clock when monitoring becomes a real turn', () => { const server = new AgentHookServer() - server.ingestStructuredStatus( - summary({ - status: 'idle', - backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }] - }), - SUBJECT - ) + server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT) + server.ingestStructuredChildWork(SUBJECT, [liveChild('shell-1', 'command')], 'codex') + server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', workingMode: 'monitoring', @@ -158,11 +181,7 @@ describe('AgentHookServer ingestStructuredStatus', () => { }) server.ingestStructuredStatus( - summary({ - status: 'working', - updatedAt: OBSERVED_AT + 2_700_000, - backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }] - }), + summary({ status: 'working', updatedAt: OBSERVED_AT + 2_700_000 }), SUBJECT ) expect(server.getStatusSnapshot()[0]).toMatchObject({ @@ -178,14 +197,9 @@ describe('AgentHookServer ingestStructuredStatus', () => { it('dates a task edge by when the host saw it, not by the journal clock', () => { const server = new AgentHookServer() const before = Date.now() - server.ingestStructuredStatus( - summary({ - status: 'idle', - updatedAt: OBSERVED_AT, - backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }] - }), - SUBJECT - ) + server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT }), SUBJECT) + server.ingestStructuredChildWork(SUBJECT, [liveChild('shell-1', 'command')], 'codex') + server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT }), SUBJECT) const row = server.getStatusSnapshot()[0] expect(row).toMatchObject({ state: 'working', workingMode: 'monitoring' }) expect(row?.evidenceObservedAt).toBeGreaterThanOrEqual(before) @@ -354,10 +368,9 @@ 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 - ) + server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT) + server.ingestStructuredChildWork(SUBJECT, [liveChild('c', 'agent')], 'codex') + server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', mainAgent: { state: 'done', stateStartedAt: OBSERVED_AT } @@ -365,6 +378,7 @@ describe('the main agent fact on a structured row', () => { 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.ingestStructuredChildWork(SUBJECT, [endedChild('c')], 'codex') server.ingestStructuredStatus( summary({ status: 'idle', updatedAt: OBSERVED_AT + 5, turnOutcome: 'failure' }), SUBJECT diff --git a/src/main/agent-hooks/server/server-ingest-structured-children.ts b/src/main/agent-hooks/server/server-ingest-structured-children.ts index ecc58b998d7..1c3eb992d45 100644 --- a/src/main/agent-hooks/server/server-ingest-structured-children.ts +++ b/src/main/agent-hooks/server/server-ingest-structured-children.ts @@ -7,6 +7,10 @@ import { reconcileAgentChildWorkEvidence, type AgentChildWorkReconcileOutcome } from '../../../shared/agent-status-child-work-reconciliation' +import { + projectAgentChildWorkViews, + type AgentChildWorkView +} from '../../../shared/agent-status-child-work-view' import { parseAgentStatusSubject, type AgentStatusStructuredSessionSubject @@ -57,4 +61,13 @@ export abstract class AgentHookServerIngestStructuredChildren extends AgentHookS const parent = parseAgentStatusSubject(subject) return parent ? this.canonicalStatusStore.getChildren(parent) : [] } + + /** The same records as the views every surface reads; the one record-to-view projection. */ + getStructuredChildWorkViews(subject: AgentStatusStructuredSessionSubject): AgentChildWorkView[] { + const children = this.getStructuredChildWork(subject) + return projectAgentChildWorkViews( + children, + children.flatMap((child) => this.canonicalStatusStore.getAliasesForChild(child.childWorkId)) + ) + } } diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index 19ee70e88af..6255c57b5ca 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -44,9 +44,11 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng } const previous = this.canonicalStatusStore.getParent(parsed) const priorStatus = previous?.status + // The host's own child records, not the summary's lossy task list: the summary is derived + // from these records, and reading them here keeps the row and every view of it on one source. const agentStatus = structuredAgentSessionAgentStatus({ status: summary.status, - backgroundTasks: summary.backgroundTasks, + childWork: this.canonicalStatusStore.getChildren(parsed), turnOutcome: summary.turnOutcome }) const { state, workingMode } = agentStatus diff --git a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs index 4f2a09425fd..799b6454624 100644 --- a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs +++ b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs @@ -5,7 +5,8 @@ // // Env contract (set by the test): // ORCA_SDK_CONTRACT_SCENARIO_PATH — JSON file -// { steps: Step[], controlResponses?: { [subtype]: } } where a Step is +// { steps: Step[], controlResponses?: { [subtype]: }, +// controlResponseLeadingFrames?: { [subtype]: [] } } where a Step is // { emit: } | { awaitUserMessage: true } | { stderr: } | // { awaitControlResponse: } | { delayMs: } | { exit: } // ORCA_SDK_CONTRACT_REPORT_PATH — where argv/env observations are written @@ -93,7 +94,7 @@ createInterface({ input: process.stdin }).on('line', (line) => { if (process.env.ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS) { return } - emit({ + const response = { type: 'control_response', response: { subtype: 'success', @@ -103,7 +104,10 @@ createInterface({ input: process.stdin }).on('line', (line) => { models: [] } } - }) + } + // Frames the CLI wrote just before its answer, in the same write: one read chunk. + const leading = scenario.controlResponseLeadingFrames?.[frame.request?.subtype] ?? [] + process.stdout.write([...leading, response].map((line) => `${JSON.stringify(line)}\n`).join('')) return } if (frame.type === 'control_response') { diff --git a/src/main/claude/claude-background-stop-records.test.ts b/src/main/claude/claude-background-stop-records.test.ts new file mode 100644 index 00000000000..0092d9e2f29 --- /dev/null +++ b/src/main/claude/claude-background-stop-records.test.ts @@ -0,0 +1,161 @@ +// A background Stop reaches what the host's child records say runs, the same records that draw the +// strip's Stop and decide whether /clear, /compact and rewind wait. Replayed from a captured frame +// order through the real adapter and a real hook server. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentChildWorkStopTargets } from '../../shared/agent-child-work-stop-targets' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { conversationCommandBlocked } from '../native-chat/agent-session-wire/structured-conversation-command-admission' +import type { CapturedFrame } from './claude-captured-frame-builders.test-fixture' +import { RESUMED_BY_MESSAGE } from './claude-captured-task-frames.test-fixture' +import { hostWithParent, parent, producer } from './claude-child-work-producer-harness.test-fixture' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) +afterEach(() => vi.restoreAllMocks()) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: admission reads only the lease and the command/rewind records, all absent here. +const RECORD = { lease: {} } as unknown as AgentSessionRecord + +/** A backgrounded agent the CLI's roster then drops with no ending of its own: the records keep + * it live, the provider tracker does not. */ +async function droppedFromRoster() { + const host = hostWithParent() + const run = await producer(host) + const launch: CapturedFrame[] = RESUMED_BY_MESSAGE.filter((captured) => captured.at < 5_077) + run.replay(launch) + run.replay([ + { at: 6_000, frame: { type: 'system', subtype: 'background_tasks_changed', tasks: [] } } + ]) + const records = () => host.getStructuredChildWorkViews(parent) + const blocked = () => + conversationCommandBlocked( + { + sessionId: 'session-1', + fence: 7, + journal: { snapshot: () => ({ items: [] }), submissions: () => [] }, + adapter: run.adapter + }, + RECORD, + records() + ) + const stopCalls = () => + run.claude.connections[0]!.calls.filter((call) => call.subtype === 'stop_task') + expect(records()).toMatchObject([ + { membership: 'live', stoppable: true, providerId: 'agent-1', kind: 'agent' } + ]) + expect(run.adapter.backgroundTaskState('session-1')).toBeNull() + expect(blocked()?.message).toBe('Stop background tasks before using this command.') + return { run, records, blocked, stopCalls } +} + +describe('a background Stop acts on the host child records', () => { + it("stops the row's own task, which the records keep after the roster dropped it", async () => { + const { run, records, blocked, stopCalls } = await droppedFromRoster() + + const outcome = await run.adapter.stopBackgroundTasks({ + sessionId: 'session-1', + fence: 7, + taskIds: agentChildWorkStopTargets(records(), 'agent-1') + }) + + expect(outcome).toEqual({ cancelled: true }) + expect(stopCalls()).toEqual([{ subtype: 'stop_task', params: { taskId: 'agent-1' } }]) + // The CLI acknowledges a task it no longer runs with no frame: the acknowledgement ends it. + expect(records()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) + expect(blocked()).toBeNull() + }) + + it('stops every task the strip offers a stop when none is named', async () => { + const { run, records, blocked, stopCalls } = await droppedFromRoster() + + const outcome = await run.adapter.stopBackgroundTasks({ + sessionId: 'session-1', + fence: 7, + taskIds: agentChildWorkStopTargets(records()) + }) + + expect(outcome).toEqual({ cancelled: true }) + expect(stopCalls()).toEqual([{ subtype: 'stop_task', params: { taskId: 'agent-1' } }]) + expect(records()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) + expect(blocked()).toBeNull() + }) + + it('leaves a task that ended on its own frame as it ended: its row offers no stop', async () => { + const { run, records, stopCalls } = await droppedFromRoster() + run.replay([ + { + at: 7_000, + frame: { + type: 'system', + subtype: 'task_updated', + task_id: 'agent-1', + patch: { status: 'completed' } + } + } + ]) + + const outcome = await run.adapter.stopBackgroundTasks({ + sessionId: 'session-1', + fence: 7, + taskIds: agentChildWorkStopTargets(records(), 'agent-1') + }) + + // The finished row offers no stop, so nothing reaches the CLI and the outcome stands. + expect(outcome).toEqual({ cancelled: false }) + expect(stopCalls()).toEqual([]) + expect(records()).toMatchObject([{ membership: 'settled', outcome: 'succeeded' }]) + }) + + it('lets the task’s own completion, written before the acknowledgement, replace the Stop', async () => { + const { run, records } = await droppedFromRoster() + await run.adapter.stopBackgroundTasks({ + sessionId: 'session-1', + fence: 7, + taskIds: agentChildWorkStopTargets(records(), 'agent-1') + }) + expect(records()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }]) + + // The completion the CLI wrote before its answer, which the SDK delivered after it. + run.replay([ + { + at: 7_000, + frame: { + type: 'system', + subtype: 'task_notification', + task_id: 'agent-1', + status: 'completed', + summary: 'Found 3 call sites' + } + } + ]) + expect(records()).toMatchObject([ + { membership: 'settled', outcome: 'succeeded', lastMessage: 'Found 3 call sites' } + ]) + }) + + it('never lets an acknowledged Stop replace an ending the task reported itself', async () => { + const { run, records } = await droppedFromRoster() + // The task's own failure lands; a Stop the user sent before it is acknowledged after. + run.replay([ + { + at: 7_000, + frame: { + type: 'system', + subtype: 'task_notification', + task_id: 'agent-1', + status: 'failed', + summary: 'Exit code 1' + } + } + ]) + await run.adapter.stopBackgroundTasks({ + sessionId: 'session-1', + fence: 7, + taskIds: ['agent-1'] + }) + expect(records()).toMatchObject([ + { membership: 'settled', outcome: 'failed', lastMessage: 'Exit code 1' } + ]) + }) +}) diff --git a/src/main/claude/claude-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts new file mode 100644 index 00000000000..313f9c5de59 --- /dev/null +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -0,0 +1,150 @@ +// A finished child leaves the strip and the sidebar at once (both list running children only), and +// its record stays in the host's store until the user's next turn. Claude wakes the agent on its +// own when a background task ends, which opens a root turn nobody sent and retires nothing: +// replayed from captured frame orders through the real adapter, a real hook server and the +// production status feed. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { structuredRunningChildWork } from '../../shared/agent-child-work-listing' +import type { AgentHookServer } from '../agent-hooks/server' +import type { StructuredAgentSessionJournalProjections } from '../native-chat/agent-session-wire/structured-agent-session-status-journal-projection' +import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import type { CapturedFrame } from './claude-captured-frame-builders.test-fixture' +import { backgroundWakeCapture } from './claude-captured-fold-steer-frames.test-fixture' +import { MOVED_TO_BACKGROUND } from './claude-captured-task-frames.test-fixture' +import { hostWithParent, parent, producer } from './claude-child-work-producer-harness.test-fixture' +import { PROVIDER_SESSION_ID } from './claude-structured-session-test-support' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) +afterEach(() => vi.restoreAllMocks()) + +type Journal = Parameters[0] + +/** The production feed over what the adapter journaled, with the user's sends as `submissions`. */ +async function wiredSession() { + const host: AgentHookServer = hostWithParent() + const run = await producer(host) + const submissions: AgentJournalSubmission[] = [] + let sequence = 0 + const journal = { + cursor: () => ({ epoch: 'epoch-1', sequence: ++sequence }), + isReadOnly: false, + lastActivityAt: () => 1, + snapshot: () => ({ + items: [...run.journalItems.values()] + .sort((a, b) => a.sequence - b.sequence) + .map((item) => ({ ...item, revision: 1 })), + submissions: submissions.map((submission) => ({ ...submission })) + }) + } + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([ + [ + parent.sessionId, + { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the cursor, read-only flag, activity clock and snapshot served here. + journal: journal as unknown as Journal, + params: { + location: { + executionHostId: parent.executionHostId, + wslDistro: parent.wslDistro, + workspaceId: parent.workspaceId, + workspaceKind: parent.workspaceKind + }, + provider: 'claude' as const + } + } + ] + ]), + getRecord: () => null, + now: () => Date.now(), + statusSink: () => ({ + publish: (summary, subject) => host.ingestStructuredStatus(summary, subject), + forget: (subject) => host.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + host.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => host.getStructuredChildWorkViews(subject) + }) + }) + const describe = (views: ReturnType) => + views.map((view) => `${view.description}:${view.membership}:${view.outcome ?? ''}`) + /** The store's records for the session. */ + const records = () => describe(host.getStructuredChildWorkViews(parent)) + /** What the strip and the sidebar list. */ + const running = () => + describe(structuredRunningChildWork(host.getStructuredChildWorkViews(parent))) + const replay = (frames: readonly CapturedFrame[]) => { + for (const captured of frames) { + run.replay([captured]) + feed.publish(parent.sessionId) + } + } + /** The user writes a message now, on the host clock; the provider takes it once `accepted`. */ + const send = (clientMessageId: string): AgentJournalSubmission => { + const submission: AgentJournalSubmission = { + clientMessageId, + fence: 7, + payloadFingerprint: clientMessageId, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: run.now(), + resolvedAt: null + } + submissions.push(submission) + feed.publish(parent.sessionId) + return submission + } + const accept = (submission: AgentJournalSubmission) => { + submission.dispatchState = 'accepted' + feed.publish(parent.sessionId) + } + const rootTurns = () => + [...run.journalItems.values()].filter((item) => !item.agentId && item.body.kind === 'turn') + return { run, feed, records, running, replay, send, accept, rootTurns } +} + +describe("a finished child's record stays until the user's next turn", () => { + it("keeps a background agent through Claude's own wake turn, then retires it at the next send", async () => { + const session = await wiredSession() + session.replay(MOVED_TO_BACKGROUND) + // The agent settled at +52,441 and Claude woke the agent at +54,303: a second root turn. + expect(session.rootTurns()).toHaveLength(2) + expect(session.records()).toContain('Run 45s sleep command:settled:succeeded') + // Nothing runs, so the strip and the sidebar list nothing. + expect(session.running()).toEqual([]) + + const next = session.send('next-send') + // Written, not yet taken: nothing retires on a send the provider may still refuse. + expect(session.records()).toContain('Run 45s sleep command:settled:succeeded') + session.accept(next) + expect(session.records()).toEqual([]) + }) + + it('keeps a background shell through the wake its completion opens, and a steer into that wake retires it', async () => { + const session = await wiredSession() + const { firstTurn, wake } = backgroundWakeCapture({ + sessionId: PROVIDER_SESSION_ID, + first: 'first-uuid', + steer: 'steer-uuid' + }) + session.accept(session.send('first-uuid')) + session.replay(firstTurn) + expect(session.running()).toEqual(['Sleep then print marker:live:']) + + const wakeInit = wake.findIndex((captured) => captured.frame.subtype === 'init') + session.replay(wake.slice(0, wakeInit + 1)) + // The completion frame opened Claude's own wake turn; the finished shell is still listed. + expect(session.rootTurns()).toHaveLength(2) + expect(session.records()).toEqual(['Sleep then print marker:settled:succeeded']) + expect(session.running()).toEqual([]) + + // The user steers while the wake runs; Claude folds it into that turn and takes it. + session.accept(session.send('steer-uuid')) + expect(session.records()).toEqual([]) + session.replay(wake.slice(wakeInit + 1)) + expect(session.records()).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-child-work-decoder.ts b/src/main/claude/claude-child-work-decoder.ts index 6de305194b0..c80c87710a6 100644 --- a/src/main/claude/claude-child-work-decoder.ts +++ b/src/main/claude/claude-child-work-decoder.ts @@ -9,7 +9,8 @@ import type { AgentChildWorkKind, - AgentChildWorkOperation + AgentChildWorkOperation, + AgentChildWorkOutcomeBasis } from '../../shared/agent-status-child-work' import type { AgentChildWorkEvidence, @@ -113,6 +114,16 @@ export class ClaudeChildWorkDecoder { } } + /** The CLI acknowledged a stop of this task, and one it no longer knows is acknowledged with no + * frame at all, so the acknowledgement ends a task still live here. Provisionally: the SDK hands + * the acknowledgement over as soon as it reads it, ahead of frames the CLI wrote before it, so + * the task's own ending may still arrive and replace this one. */ + stopAcknowledged(id: string): void { + if (this.live.has(id)) { + this.end(id, 'stopped', { basis: 'stop-acknowledged' }) + } + } + /** The provider session is gone: the host settles what it still holds live. */ clear(): void { this.live.clear() @@ -229,7 +240,12 @@ export class ClaudeChildWorkDecoder { private end( id: string, status: unknown, - reported: { runId?: string; lastMessage?: string; totalTokens?: number } + reported: { + runId?: string + lastMessage?: string + totalTokens?: number + basis?: AgentChildWorkOutcomeBasis + } ): void { const task = this.live.get(id) this.live.delete(id) @@ -251,6 +267,7 @@ export class ClaudeChildWorkDecoder { ...(reported.runId !== undefined ? { runId: reported.runId } : {}) }, outcome, + ...(reported.basis ? { basis: reported.basis } : {}), ...(reported.lastMessage ? { lastMessage: reported.lastMessage } : {}), ...(reported.totalTokens !== undefined ? { totalTokens: reported.totalTokens } : {}) })) diff --git a/src/main/claude/claude-child-work-producer-harness.test-fixture.ts b/src/main/claude/claude-child-work-producer-harness.test-fixture.ts index 8b1a8b0ee91..0b460d91b4a 100644 --- a/src/main/claude/claude-child-work-producer-harness.test-fixture.ts +++ b/src/main/claude/claude-child-work-producer-harness.test-fixture.ts @@ -2,6 +2,8 @@ // a store of its own or ingested by a real hook server. import { expect } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { createAgentChildWorkAdmission } from '../../shared/agent-status-child-work-admission' import type { AgentChildWorkRecord } from '../../shared/agent-status-child-work' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' @@ -70,7 +72,9 @@ export function toolResult( }) } -type Delivery = { kind: 'journal' | 'legacy' | 'evidence'; detail: string } +type Delivery = { kind: 'journal' | 'publish' | 'evidence'; detail: string } + +export type JournaledItem = Pick /** The host clock the adapter stamps evidence with; a replay moves it to each frame's time. */ export const T0 = 1_700_000_000_500 @@ -125,8 +129,6 @@ export async function producer(host?: AgentHookServer) { readProcessStartTime: async () => 1_700_000_000_000, now: () => clock, persistHandle: async () => {}, - onBackgroundTasksChanged: (_sessionId, state) => - deliveries.push({ kind: 'legacy', detail: String(state?.tasks?.length ?? 0) }), onChildWorkEvidence: (sessionId, evidence) => { expect(sessionId).toBe('session-1') deliveries.push({ kind: 'evidence', detail: evidence.map((edge) => edge.type).join(',') }) @@ -138,15 +140,25 @@ export async function producer(host?: AgentHookServer) { } } }) + /** What the adapter journaled, keyed by identity in first-write order, as the journal folds it. */ + const journalItems = new Map() const journal: StructuredAgentSessionEventSink = { - appendItem: (identity, _body, options) => { + appendItem: (identity, body, options) => { deliveries.push({ kind: 'journal', detail: JSON.stringify(identity) }) + const itemId = agentJournalItemKey(identity) + journalItems.set(itemId, { + itemId, + body, + sequence: journalItems.get(itemId)?.sequence ?? journalItems.size + 1, + ...(options?.agentId ? { agentId: options.agentId } : {}) + }) if (options?.agentId !== undefined) { stamps.push(options) } }, appendTombstone: () => {}, - publish: () => {} + // Production's journal publication is what republishes the parent's own row. + publish: () => deliveries.push({ kind: 'publish', detail: '' }) } await adapter.acquire({ identity: identityFor(), @@ -170,5 +182,19 @@ export async function producer(host?: AgentHookServer) { host ? host.getStructuredChildWork(parent) : store.getChildren(parent) const byDescription = (description: string) => records().find((record) => record.description === description) - return { adapter, store, send, replay, records, byDescription, evidenceLog, stamps, ingested } + return { + adapter, + claude, + /** The host clock the replay has reached. */ + now: () => clock, + store, + send, + replay, + records, + byDescription, + evidenceLog, + stamps, + ingested, + journalItems + } } diff --git a/src/main/claude/claude-stop-ack-race.test.ts b/src/main/claude/claude-stop-ack-race.test.ts new file mode 100644 index 00000000000..5f2f60d3a69 --- /dev/null +++ b/src/main/claude/claude-stop-ack-race.test.ts @@ -0,0 +1,144 @@ +// A task that finishes on its own just as the user presses Stop keeps its own ending. The CLI +// writes the task's completion before its answer to `stop_task`, but the SDK hands Orca a control +// answer as soon as it reads it and queues every other frame, so the acknowledgement can arrive +// first. Driven through the real SDK and Orca's stream-json connection against the scripted CLI. + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { spawnProcess } from '../../shared/child-process/run-process' +import { createAgentChildWorkAdmission } from '../../shared/agent-status-child-work-admission' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' +import { reconcileAgentChildWorkEvidence } from '../../shared/agent-status-child-work-reconciliation' +import { createAgentStatusStore } from '../../shared/agent-status-store' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' +import { + openClaudeStreamJsonConnection, + type ClaudeStreamJsonConnection +} from './claude-stream-json-connection' +import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' +import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import type { ClaudeSession } from './claude-structured-session-state' + +const FAKE_CLI = join(__dirname, '__fixtures__', 'claude-agent-sdk-scripted-cli.mjs') +const SESSION_ID = '5348c19f-6a54-4c2e-9c68-9c2b1a3d4e5f' +const parent = makeStructuredAgentStatusSubject( + { executionHostId: 'local', wslDistro: null, workspaceId: 'ws-1', workspaceKind: 'folder' }, + 'session-1' +) +const STARTED = { + type: 'system', + subtype: 'task_started', + task_id: 'task-1', + tool_use_id: 'toolu_1', + task_type: 'local_bash', + is_backgrounded: true, + description: 'npm test', + session_id: SESSION_ID, + uuid: 'started-1' +} +const COMPLETED = { + type: 'system', + subtype: 'task_notification', + task_id: 'task-1', + tool_use_id: 'toolu_1', + status: 'completed', + summary: 'all 12 tests passed', + session_id: SESSION_ID, + uuid: 'completed-1' +} + +const scratch: string[] = [] +const connections: ClaudeStreamJsonConnection[] = [] +afterEach(async () => { + for (const connection of connections.splice(0)) { + await connection.close() + } + for (const dir of scratch.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +/** Streamed text the CLI wrote ahead of the completion, as while Claude is answering. */ +function streamed(count: number): Record[] { + return Array.from({ length: count }, (_, index) => ({ + type: 'stream_event', + event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'x' } }, + session_id: SESSION_ID, + uuid: `stream-${index}` + })) +} + +async function until(done: () => boolean): Promise { + for (let attempt = 0; attempt < 500 && !done(); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + expect(done()).toBe(true) +} + +/** The task's record after the user stops it and the CLI answers behind `ahead` streamed frames + * and the task's own completion, all in one write. */ +async function stoppedAsItCompleted(ahead: number) { + const dir = mkdtempSync(join(tmpdir(), 'claude-stop-ack-race-')) + scratch.push(dir) + const scenarioPath = join(dir, 'scenario.json') + writeFileSync( + scenarioPath, + JSON.stringify({ + steps: [{ emit: STARTED }, { delayMs: 10_000 }], + controlResponses: { stop_task: {} }, + controlResponseLeadingFrames: { stop_task: [...streamed(ahead), COMPLETED] } + }) + ) + const decoder = new ClaudeChildWorkDecoder() + const evidence: AgentChildWorkEvidence[] = [] + const seen: string[] = [] + const connection = await openClaudeStreamJsonConnection( + { + pathToClaudeCodeExecutable: FAKE_CLI, + options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, sessionId: SESSION_ID }, + cwd: dir, + env: { + PATH: process.env.PATH ?? '', + ORCA_SDK_CONTRACT_SCENARIO_PATH: scenarioPath, + ORCA_SDK_CONTRACT_REPORT_PATH: join(dir, 'report.json') + } + }, + { + onMessage: (message) => { + seen.push(`${String(message.type)}:${String(message.subtype ?? '')}`) + decoder.observe(message) + evidence.push(...decoder.drain(seen.length)) + } + }, + spawnProcess + ) + connections.push(connection) + await until(() => seen.includes('system:task_started')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a background stop reads only the session's connection and its child-work decoder. + const session = { connection, childWork: decoder } as unknown as ClaudeSession + await stopClaudeBackgroundTasks(session, 5_000, () => true, ['task-1']) + evidence.push(...decoder.drain(seen.length)) + await until(() => seen.includes('system:task_notification')) + + const store = createAgentStatusStore({ epoch: 'epoch-1', mode: 'authority' }) + store.applyMutation({ parent: { subject: parent } }) + let minted = 0 + const admission = createAgentChildWorkAdmission(store, { + mintChildWorkId: () => `child-${++minted}` + }) + reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'claude', evidence }) + return store.getChildren(parent) +} + +describe('a Stop acknowledged ahead of the task’s own earlier completion', () => { + for (const ahead of [0, 1, 5, 50]) { + it(`keeps the task's own outcome and summary with ${ahead} frames ahead of it`, async () => { + expect(await stoppedAsItCompleted(ahead)).toMatchObject([ + { membership: 'settled', outcome: 'succeeded', lastMessage: 'all 12 tests passed' } + ]) + }, 20_000) + } +}) diff --git a/src/main/claude/claude-structured-acquisition-release.ts b/src/main/claude/claude-structured-acquisition-release.ts index 3df76971df1..d29879bb73e 100644 --- a/src/main/claude/claude-structured-acquisition-release.ts +++ b/src/main/claude/claude-structured-acquisition-release.ts @@ -23,7 +23,6 @@ export async function releaseClaudeAcquisition(input: { onExitProven?: (sessionId: string, exit: ClaudeSessionExit) => Promise persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'] onEvent?: ClaudeStructuredSessionAdapterDeps['onEvent'] - onBackgroundTasksChanged?: ClaudeStructuredSessionAdapterDeps['onBackgroundTasksChanged'] }): Promise { const exit = input.exits.get(input.sessionId) if (!exit || input.sessions.has(input.sessionId) || input.acquisitions.get(input.sessionId)) { diff --git a/src/main/claude/claude-structured-child-work-producer.test.ts b/src/main/claude/claude-structured-child-work-producer.test.ts index d47d07b4335..7c493f9e8c8 100644 --- a/src/main/claude/claude-structured-child-work-producer.test.ts +++ b/src/main/claude/claude-structured-child-work-producer.test.ts @@ -16,7 +16,7 @@ vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => afterEach(() => vi.restoreAllMocks()) describe('Claude structured child-work producer', () => { - it('delivers evidence only after the journal wrote the frame and the legacy row republished', async () => { + it('delivers evidence only after the journal wrote and published the frame', async () => { const { send, records } = await producer() send(toolUse('toolu_bg', 'Agent', { description: 'Audit the build' })) const deliveries = send( @@ -32,7 +32,9 @@ describe('Claude structured child-work producer', () => { const kinds = deliveries.map((delivery) => delivery.kind) // The frame's own rows, then the parent's republished row, and only then its children. expect(kinds.filter((kind) => kind === 'journal').length).toBeGreaterThan(0) - expect(kinds.slice(kinds.indexOf('legacy'))).toEqual(['legacy', 'evidence']) + expect(kinds.lastIndexOf('publish')).toBeGreaterThan(kinds.lastIndexOf('journal')) + expect(kinds.at(-1)).toBe('evidence') + expect(kinds.filter((kind) => kind === 'evidence')).toHaveLength(1) expect(records()).toEqual([ expect.objectContaining({ description: 'Audit the build', membership: 'live' }) ]) diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index e4aa15352c3..d2e29801020 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -6,9 +6,13 @@ import { } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { + ClaudeControlRequestTimeoutError, + runClaudeControl +} from './claude-agent-sdk-control-requests' import { buildClaudePromptReply, ClaudePromptRegistry } from './claude-structured-prompt-replies' import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' -import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' +import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { sessionFor, userMessage } from './claude-structured-dispatch-test-support' type InterruptResult = Awaited> @@ -290,82 +294,126 @@ describe('answerClaudePrompt', () => { }) describe('stopClaudeBackgroundTasks', () => { - it('stops each live SDK task id and never depends on an active turn id', async () => { - const backgroundTasks = new ClaudeBackgroundTaskTracker() - backgroundTasks.observe({ - type: 'system', - subtype: 'background_tasks_changed', - tasks: [ - { task_id: 'task-agent', task_type: 'local_agent', description: 'agent' }, - { task_id: 'task-bash', task_type: 'local_bash', description: 'bash' } - ] - }) - const stopTask = vi.fn(async (_taskId: string, _options?: { timeoutMs?: number }) => {}) - const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession - - await expect(stopClaudeBackgroundTasks(session, 5_000)).resolves.toEqual({ cancelled: true }) - expect(stopTask.mock.calls).toEqual([ - ['task-agent', { timeoutMs: 5_000 }], - ['task-bash', { timeoutMs: 5_000 }] - ]) - }) - - it('stops issuing requests when ownership changes between tasks', async () => { - const backgroundTasks = new ClaudeBackgroundTaskTracker() - for (const taskId of ['task-1', 'task-2']) { - backgroundTasks.observe({ + /** A decoder holding `ids` live, as backgrounded agents. */ + function liveChildWork(ids: string[]): ClaudeChildWorkDecoder { + const childWork = new ClaudeChildWorkDecoder() + for (const id of ids) { + childWork.observe({ type: 'system', subtype: 'task_started', - task_id: taskId, + task_id: id, task_type: 'local_agent', is_backgrounded: true }) } + childWork.drain(1) + return childWork + } + + function stoppingSession( + childWork: ClaudeChildWorkDecoder, + stopTask: (taskId: string, options?: { timeoutMs?: number }) => Promise + ): ClaudeSession { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a background stop reads only the session's child-work decoder and its connection's stopTask. + return { childWork, connection: { stopTask } } as unknown as ClaudeSession + } + + it('stops exactly the ids the host named, and ends each acknowledged one', async () => { + const childWork = liveChildWork(['task-agent', 'task-bash']) + const stopTask = vi.fn(async (_taskId: string, _options?: { timeoutMs?: number }) => {}) + const session = stoppingSession(childWork, stopTask) + + await expect( + stopClaudeBackgroundTasks(session, 5_000, () => true, ['task-agent', 'task-bash']) + ).resolves.toEqual({ cancelled: true }) + expect(stopTask.mock.calls).toEqual([ + ['task-agent', { timeoutMs: 5_000 }], + ['task-bash', { timeoutMs: 5_000 }] + ]) + expect(childWork.drain(2)).toEqual([ + { + type: 'ended', + observedAt: 2, + handle: { idKind: 'task_id', id: 'task-agent' }, + outcome: 'cancelled', + basis: 'stop-acknowledged' + }, + { + type: 'ended', + observedAt: 2, + handle: { idKind: 'task_id', id: 'task-bash' }, + outcome: 'cancelled', + basis: 'stop-acknowledged' + } + ]) + }) + + it('stops issuing requests when ownership changes between tasks', async () => { + const childWork = liveChildWork(['task-1', 'task-2']) let current = true const stopTask = vi.fn(async (_taskId: string) => { current = false }) - const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession + const session = stoppingSession(childWork, stopTask) - await stopClaudeBackgroundTasks(session, undefined, () => current) + await stopClaudeBackgroundTasks(session, undefined, () => current, ['task-1', 'task-2']) expect(stopTask).toHaveBeenCalledTimes(1) }) - it('stops only the requested live task id', async () => { - const backgroundTasks = new ClaudeBackgroundTaskTracker() - backgroundTasks.observe({ - type: 'system', - subtype: 'background_tasks_changed', - tasks: [ - { task_id: 'task-one', task_type: 'local_agent' }, - { task_id: 'task-two', task_type: 'local_bash' } - ] + it('leaves a task live when the CLI refuses its stop, and makes no call for no ids', async () => { + const childWork = liveChildWork(['task-live']) + const stopTask = vi.fn(async (_taskId: string) => { + throw new ClaudeControlRequestError('stop_task', 'Task task-live is owned by another agent') }) - const stopTask = vi.fn(async (_taskId: string) => {}) - const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession + const session = stoppingSession(childWork, stopTask) await expect( - stopClaudeBackgroundTasks(session, 5_000, () => true, 'task-two') - ).resolves.toEqual({ cancelled: true }) - expect(stopTask).toHaveBeenCalledWith('task-two', { timeoutMs: 5_000 }) - expect(stopTask).toHaveBeenCalledTimes(1) - }) - - it('refuses a stale or unknown task id without a provider call', async () => { - const backgroundTasks = new ClaudeBackgroundTaskTracker() - backgroundTasks.observe({ - type: 'system', - subtype: 'task_started', - task_id: 'task-live', - task_type: 'local_agent', - is_backgrounded: true - }) - const stopTask = vi.fn(async (_taskId: string) => {}) - const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession - - await expect( - stopClaudeBackgroundTasks(session, undefined, () => true, 'task-stale') + stopClaudeBackgroundTasks(session, undefined, () => true, ['task-live']) ).resolves.toEqual({ cancelled: false }) - expect(stopTask).not.toHaveBeenCalled() + expect(childWork.drain(2)).toEqual([]) + await expect(stopClaudeBackgroundTasks(session, undefined, () => true, [])).resolves.toEqual({ + cancelled: false + }) + expect(stopTask).toHaveBeenCalledTimes(1) + }) + + it('stops the remaining tasks after one request fails, then reports the failure', async () => { + const childWork = liveChildWork(['task-1', 'task-2']) + const lost = new Error('connection reset') + const stopTask = vi.fn(async (taskId: string) => { + if (taskId === 'task-1') { + throw lost + } + }) + const session = stoppingSession(childWork, stopTask) + + await expect( + stopClaudeBackgroundTasks(session, undefined, () => true, ['task-1', 'task-2']) + ).rejects.toBe(lost) + expect(stopTask.mock.calls.map(([taskId]) => taskId)).toEqual(['task-1', 'task-2']) + expect(childWork.drain(2)).toMatchObject([ + { type: 'ended', handle: { id: 'task-2' }, outcome: 'cancelled' } + ]) + }) + + it('stops asking after a request times out: a CLI not answering costs one deadline, not one per task', async () => { + const childWork = liveChildWork(['task-0', 'task-1', 'task-2', 'task-3', 'task-4']) + // Never answered, behind the real deadline wrapper, with a short deadline. + const stopTask = vi.fn((_taskId: string, options?: { timeoutMs?: number }) => + runClaudeControl('stop_task', () => new Promise(() => {}), options?.timeoutMs) + ) + const session = stoppingSession(childWork, stopTask) + + await expect( + stopClaudeBackgroundTasks(session, 50, () => true, [ + 'task-0', + 'task-1', + 'task-2', + 'task-3', + 'task-4' + ]) + ).rejects.toBeInstanceOf(ClaudeControlRequestTimeoutError) + expect(stopTask).toHaveBeenCalledTimes(1) + expect(childWork.drain(2)).toEqual([]) }) }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 412439a0be9..37d2158f765 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -1,6 +1,7 @@ import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' import type { ClaudePromptClaim } from './claude-structured-prompt-replies' import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests' import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import type { ClaudeSession } from './claude-structured-session-state' @@ -69,29 +70,39 @@ export async function cancelClaudeTurn( } } +/** Stops each task the host named. An acknowledged stop ends the task's record: the CLI answers + * success for a task it no longer knows without sending that task any frame. */ export async function stopClaudeBackgroundTasks( session: ClaudeSession, timeoutMs: number | undefined, - isCurrent: ClaudeTurnCancellationGuard = () => true, - taskId?: string + isCurrent: ClaudeTurnCancellationGuard, + taskIds: readonly string[] ): Promise<{ cancelled: boolean }> { - const stoppableTaskIds = session.backgroundTasks.stoppableTaskIds - const taskIds = - taskId === undefined ? stoppableTaskIds : stoppableTaskIds.includes(taskId) ? [taskId] : [] let cancelled = false + // A failed request for one task still leaves the others to stop; it is reported after them. A + // timeout ends the loop: a CLI that is not answering would make each id wait out its own + // deadline while the session's other actions queue behind this one. + let failure: { error: unknown } | undefined for (const taskId of taskIds) { if (!isCurrent()) { break } try { await session.connection.stopTask(taskId, { timeoutMs }) + session.childWork.stopAcknowledged(taskId) cancelled = true } catch (error) { - if (!(error instanceof ClaudeControlRequestError)) { + if (error instanceof ClaudeControlRequestTimeoutError) { throw error } + if (!(error instanceof ClaudeControlRequestError)) { + failure ??= { error } + } } } + if (failure) { + throw failure.error + } return { cancelled } } diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 43576cc44e9..cd241b5a33e 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -142,33 +142,25 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda }) private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void { + // The host's child records, fed by the decoder's evidence drained below, are what every surface + // and every Stop reads; the tracker's roster is kept only for tests that compare the two. if (event.type === 'ended') { session?.childWork.clear() + session?.backgroundTasks.clear() } else if (event.type === 'message') { session?.childWork.observe(event.message) + session?.backgroundTasks.observe(event.message, event.startsTurn === true) } - const backgroundTasksChanged = - event.type === 'ended' - ? (session?.backgroundTasks.clear() ?? false) - : event.type === 'message' - ? (session?.backgroundTasks.observe(event.message, event.startsTurn === true) ?? false) - : false if (event.type === 'message' && session?.commands.observe(event.message)) { session.events?.publish() } session?.translator?.handle(event) this.deps.onEvent?.(event) - if (backgroundTasksChanged) { - this.deps.onBackgroundTasksChanged?.( - event.sessionId, - session ? backgroundTaskState(session) : null - ) - } this.publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null) } - /** After the journal handled the frame and the parent's own row was republished: the host - * never holds a child record ahead of the rows that frame wrote, and never before its parent. */ + /** After the journal handled the frame, which republished the parent's own row: the host never + * holds a child record ahead of the rows that frame wrote, and never before its parent. */ private publishChildWork( sessionId: string, session: ClaudeSession | null | undefined, @@ -205,28 +197,39 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.deps.onDispatchSettledLate?.({ sessionId: request.sessionId, ...settlement }), ...(this.deps.requestTimeoutMs === undefined ? {} : { timeoutMs: this.deps.requestTimeoutMs }) }) - stopBackgroundTasks: StructuredAgentSessionAdapter['stopBackgroundTasks'] = (input) => { + stopBackgroundTasks: NonNullable = async ( + input + ) => { const session = this.session(input.sessionId) const acquisitionGeneration = session.acquisitionGeneration - return stopClaudeBackgroundTasks( - session, - this.deps.requestTimeoutMs, - () => - Boolean( - this.sessions.get(input.sessionId) === session && - session.fence === input.fence && - session.acquisitionGeneration === acquisitionGeneration && - session.backgroundTasks.state - ), - input.taskId - ) + const isCurrent = () => + this.sessions.get(input.sessionId) === session && + session.fence === input.fence && + session.acquisitionGeneration === acquisitionGeneration + try { + return await stopClaudeBackgroundTasks( + session, + this.deps.requestTimeoutMs, + isCurrent, + input.taskIds + ) + } finally { + if (isCurrent()) { + this.publishChildWork(input.sessionId, session) + } + } } - backgroundTaskState: NonNullable = ( - sessionId - ) => { + /** The tracker's own roster, for the tests that compare it with the host's child records. No + * production code reads it: what runs, what a Stop reaches and what blocks a command are all + * read from the host's child records. */ + backgroundTaskState = (sessionId: string): AgentSessionBackgroundTaskState | null | undefined => { const session = this.sessions.get(sessionId) return session ? backgroundTaskState(session) : undefined } + backgroundTaskStops: NonNullable = ( + sessionId + ) => + this.sessions.has(sessionId) ? { supportsTaskStop: true, supportsStopAll: true } : undefined readCommands: NonNullable = (sessionId) => this.sessions.get(sessionId)?.commands.commands holdsDispatch = (sessionId: string): boolean => { @@ -277,9 +280,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda onExitProven: (sessionId, exit) => settleClaudeUnexpectedExit(this.exitLifecycle, sessionId, exit), ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), - ...(this.deps.onBackgroundTasksChanged - ? { onBackgroundTasksChanged: this.deps.onBackgroundTasksChanged } - : {}), ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) }) @@ -310,9 +310,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda sessions: this.sessions, acquisitions: this.acquisitions, ...(this.deps.persistHandle ? { persistHandle: this.deps.persistHandle } : {}), - ...(this.deps.onBackgroundTasksChanged - ? { onBackgroundTasksChanged: this.deps.onBackgroundTasksChanged } - : {}), ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) }) diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 873b90c9416..34e4b6320f8 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -10,7 +10,7 @@ import { fakeClaude, identityFor } from './claude-structured-session-test-support' -import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { closeClaudeSession } from './claude-structured-session-close' @@ -88,7 +88,7 @@ describe('Claude published session close lifecycle', () => { .fn>() .mockRejectedValueOnce(persistenceError) .mockResolvedValueOnce(undefined) - const backgroundStates: (AgentSessionBackgroundTaskState | null)[] = [] + const childWork: AgentChildWorkEvidence['type'][] = [] const adapter = adapterFor( claude, {}, @@ -96,7 +96,7 @@ describe('Claude published session close lifecycle', () => { [], undefined, persistHandle, - (_sessionId, state) => backgroundStates.push(state) + (_sessionId, evidence) => childWork.push(...evidence.map((edge) => edge.type)) ) const journalSink: StructuredAgentSessionEventSink = { appendItem: () => {}, @@ -118,15 +118,7 @@ describe('Claude published session close lifecycle', () => { task_type: 'local_agent', is_backgrounded: true }) - expect(backgroundStates).toEqual([ - { - state: 'monitoring', - tasks: [ - { id: 'background-1', kind: 'agent', state: 'working', startedAt: expect.any(Number) } - ], - supportsTaskStop: true - } - ]) + expect(childWork).toEqual(['live']) const session = ( adapter as unknown as { sessions: Map void } | null }> @@ -139,16 +131,8 @@ describe('Claude published session close lifecycle', () => { expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) expect(events.filter((event) => event.type === 'handle')).toHaveLength(0) expect(disposeTranslator).toHaveBeenCalledOnce() - expect(backgroundStates).toEqual([ - { - state: 'monitoring', - tasks: [ - { id: 'background-1', kind: 'agent', state: 'working', startedAt: expect.any(Number) } - ], - supportsTaskStop: true - }, - null - ]) + // The host's child records hear the session end, which settles the child still running. + expect(childWork).toEqual(['live', 'session-ended']) await expect(adapter.closeSession('session-1')).resolves.toBe(true) expect(persistHandle).toHaveBeenCalledTimes(2) diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 91a0df1e2b1..f1735125197 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -15,7 +15,6 @@ import { import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' import { retireClaudeDispatchWaiters } from './claude-structured-dispatch' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' @@ -87,10 +86,6 @@ type CloseClaudePublishedSessionInput = { fence: number }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void } async function finalizeClaudePublishedSession( @@ -119,10 +114,9 @@ async function finalizeClaudePublishedSession( } rootExitVerdict = cleanupError } + // Queues the session's ending for the host's child records; the adapter delivers it after close. session.childWork.clear() - if (session.backgroundTasks.clear()) { - input.onBackgroundTasksChanged?.(input.sessionId, null) - } + session.backgroundTasks.clear() const leafUuid = await settledClaudeTurnEndLeaf(session) const persistence = session.closePersistence ?? @@ -235,10 +229,6 @@ export function closeClaudePublishedSessionForDeps( fence: number }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void } ): Promise { return closeClaudePublishedSession({ sessions, sessionId, ...deps }) @@ -256,10 +246,6 @@ export async function closeClaudeSession(input: { fence: number }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void }): Promise { const attempt = input.acquisitions.get(input.sessionId) if (!(await cancelClaudeAcquisitionAttempt(attempt))) { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index fd51d2987e0..8ddbe7aed01 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -22,10 +22,7 @@ import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import type { - AgentSessionBackgroundTaskState, - AgentSessionFastModeState -} from '../../shared/agent-session-wire' +import type { AgentSessionFastModeState } from '../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import type { ClaudeChildWorkDecoder } from './claude-child-work-decoder' @@ -104,10 +101,6 @@ export type ClaudeStructuredSessionAdapterDeps = { onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => void /** The CLI reported `session_state_changed idle`: its turn is over. */ onSessionIdle?: (input: { sessionId: string }) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void /** What the session's child work did, delivered after the journal handled the frame. */ onChildWorkEvidence?: (sessionId: string, evidence: AgentChildWorkEvidence[]) => void openConnection?: typeof openClaudeStreamJsonConnection diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 0380d5ac12f..dd7ad1e8d8c 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -260,7 +260,7 @@ export function adapterAtPublishFor( persistedHandles: unknown[] = [], requestTimeoutMs?: number, persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle'], - onBackgroundTasksChanged?: ClaudeStructuredSessionAdapterDeps['onBackgroundTasksChanged'], + onChildWorkEvidence?: ClaudeStructuredSessionAdapterDeps['onChildWorkEvidence'], onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] ): ClaudeStructuredSessionAdapter { return new ClaudeStructuredSessionAdapter({ @@ -285,7 +285,7 @@ export function adapterAtPublishFor( (async (handle) => { persistedHandles.push(handle) }), - ...(onBackgroundTasksChanged ? { onBackgroundTasksChanged } : {}), + ...(onChildWorkEvidence ? { onChildWorkEvidence } : {}), ...(onDispatchSettledLate ? { onDispatchSettledLate } : {}) }) } diff --git a/src/main/codex/codex-background-command-tracker.ts b/src/main/codex/codex-background-command-tracker.ts index 922743f1a83..e7d19e3bbcc 100644 --- a/src/main/codex/codex-background-command-tracker.ts +++ b/src/main/codex/codex-background-command-tracker.ts @@ -1,4 +1,8 @@ import type { AgentSessionBackgroundTask } from '../../shared/agent-session-wire' +import { + boundCodexCommandDescription, + codexChildCommandDescription +} from '../../shared/codex-child-command-description' import type { CodexBackgroundTaskEvent } from './codex-background-task-frames' import { readRecord, readString } from './codex-item-field-readers' import { readCodexThreadItem } from './codex-structured-item-translation' @@ -6,7 +10,6 @@ import { MAX_CODEX_ITEM_STREAM_METADATA_BYTES } from './codex-item-stream-retent import type { CodexAbandonedCommand } from './codex-prompt-registry' const MAX_SETTLED_COMMANDS = 128 -const MAX_DESCRIPTION_CHARS = 512 type Command = { threadId: string; task: AgentSessionBackgroundTask; bytes: number } @@ -15,35 +18,6 @@ export type CodexBackgroundCommandChange = | { type: 'started'; threadId: string; task: AgentSessionBackgroundTask } | { type: 'ended'; threadId: string; taskId: string } -/** The label's reserved share of the description. Reserved, not merely capped: - * a label free to spend the whole budget clips away the command it qualifies, - * leaving a command row naming an agent and no command — the failure this - * qualification exists to remove, in the other direction. `bytes` is counted - * before qualification, so this share is also what a published row may exceed - * the admitted count by. */ -const MAX_LABEL_CHARS = 96 - -/** Every cut in this file goes through here, clipped the way `boundSubagentField` - * clips the same provider string on the agent row: never mid surrogate pair, - * since a lone surrogate is lossy through any non-JSON UTF-8 hop. A composed - * row is cut a SECOND time, so a clip that is safe only where the label is - * bounded is not safe. No ordinal, because a row's identity is its `id`. */ -function boundText(value: string, max: number): string { - if (value.length <= max) { - return value - } - const keep = max - 1 - const last = value.charCodeAt(keep - 1) - const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep - return `${value.slice(0, end)}…` -} - -/** Resolved on read, and capped at the bound the admitted description already respects. */ -function qualifiedDescription(label: string, description: string | undefined): string { - const name = boundText(label, MAX_LABEL_CHARS) - return boundText(description ? `${name} — ${description}` : name, MAX_DESCRIPTION_CHARS) -} - export class CodexBackgroundCommandTracker { private readonly commands = new Map() private readonly settled = new Map() @@ -115,7 +89,7 @@ export class CodexBackgroundCommandTracker { // a label registered after the command still lands. const label = threadId === this.primaryThreadId ? null : childLabel?.(threadId) return label - ? { ...task, description: qualifiedDescription(label, task.description) } + ? { ...task, description: codexChildCommandDescription(label, task.description) } : task }) } @@ -189,7 +163,7 @@ export class CodexBackgroundCommandTracker { } const key = JSON.stringify([event.threadId, item.id]) const completed = event.method === 'item/completed' || item.status !== 'inProgress' - const description = boundText(readString(item, 'command') ?? '', MAX_DESCRIPTION_CHARS) + const description = boundCodexCommandDescription(readString(item, 'command') ?? '') .replace(/\s+/g, ' ') .trim() const value = { diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 611b7f75b57..4317b5669d7 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -1,6 +1,7 @@ // A Codex session's frames, through the real adapter, into the host's child records: the order the -// host receives them in, and whether the parent row the records imply is today's row. +// host receives them in, and the parent row they fold to, written out step by step. +import { isDeepStrictEqual } from 'node:util' import { describe, expect, it } from 'vitest' import { foldAgentLeadStatus, @@ -40,10 +41,10 @@ const TESTER = 'thread-tester' const LINTER = 'thread-linter' type Frame = { method: string; params: Record } -type Delivery = { kind: 'journal' | 'legacy' | 'evidence'; detail: string } +type Delivery = { kind: 'journal' | 'publish' | 'evidence'; detail: string } type Liveness = ReturnType -/** What the records and today's strip each said when the journal wrote or published a row. */ -type JournalMoment = { recorded: Liveness; legacy: Liveness } +/** What the records said when the journal wrote or published a row. */ +type JournalMoment = { recorded: Liveness; commandLive: boolean } const turn = ( method: 'turn/started' | 'turn/completed', @@ -99,7 +100,9 @@ async function producer() { const moment = () => moments.push({ recorded: recordedLiveness(), - legacy: agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks) + commandLive: records().some( + (record) => record.kind === 'command' && record.membership === 'live' + ) }) const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -112,8 +115,6 @@ async function producer() { openConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, now: () => 1_700_000_000_500, - onBackgroundTasksChanged: (_sessionId, state) => - deliveries.push({ kind: 'legacy', detail: String(state?.tasks?.length ?? 0) }), onChildWorkEvidence: (sessionId, evidence) => { expect(sessionId).toBe('session-1') deliveries.push({ kind: 'evidence', detail: evidence.map((edge) => edge.type).join(',') }) @@ -128,7 +129,11 @@ async function producer() { moment() }, appendTombstone: () => {}, - publish: moment + // Production's journal publication is what republishes the parent's own row. + publish: () => { + deliveries.push({ kind: 'publish', detail: '' }) + moment() + } } await adapter.acquire({ identity: identityFor('session-1'), @@ -178,8 +183,6 @@ const fold = ( leadState: 'working' | 'done', childWorkLiveness: Liveness ): AgentLeadStatusResolution => foldAgentLeadStatus({ leadState, childWorkLiveness }) -/** The strip has no word for a child waiting on a human: to it, that child is working. */ -const asStrip = (liveness: Liveness): Liveness => (liveness === 'waiting' ? 'working' : liveness) const shellFrame = ( method: 'item/started' | 'item/completed', threadId: string, @@ -198,7 +201,7 @@ const shellFrame = ( }) describe('Codex structured child-work producer', () => { - it('delivers evidence only after the journal wrote the frame and the legacy row republished', async () => { + it('delivers evidence only after the journal wrote and published the frame', async () => { const { send, records } = await producer() send(turn('turn/started', THREAD_ID, 'p1')) send(turn('turn/started', REVIEWER, 'r1')) @@ -206,34 +209,51 @@ describe('Codex structured child-work producer', () => { const kinds = deliveries.map((delivery) => delivery.kind) // The frame's own rows, then the parent's republished row, and only then its children. expect(kinds.filter((kind) => kind === 'journal').length).toBeGreaterThan(0) - expect(kinds.slice(kinds.indexOf('legacy'))).toEqual(['legacy', 'evidence']) + expect(kinds.lastIndexOf('publish')).toBeGreaterThan(kinds.lastIndexOf('journal')) + expect(kinds.at(-1)).toBe('evidence') + expect(kinds.filter((kind) => kind === 'evidence')).toHaveLength(1) expect(records()).toEqual([ expect.objectContaining({ description: 'review', membership: 'live' }) ]) }) - it('records the parent state today reads, at every journal write, while adding outcome and activity', async () => { + it("folds the parent from the records, at every journal write, with each child's outcome and activity", async () => { const { adapter, momentsOf, records, recordedLiveness, byDescription, display } = await producer() const steps: { frame: Frame lead: 'working' | 'done' - childWaits?: true + live: Liveness + parent: AgentLeadStatusResolution check?: () => void }[] = [ - { frame: turn('turn/started', THREAD_ID, 'p1'), lead: 'working' }, + { + frame: turn('turn/started', THREAD_ID, 'p1'), + lead: 'working', + live: null, + parent: { stateName: 'working' } + }, // Codex reports the child's turn before its announcement. { frame: turn('turn/started', REVIEWER, 'r1'), lead: 'working', + live: null, + parent: { stateName: 'working' }, check: () => expect(records()).toEqual([]) }, - { frame: spawned(REVIEWER, 'review', 'p1'), lead: 'working' }, + { + frame: spawned(REVIEWER, 'review', 'p1'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, // An approved command: Codex starts it on the approval path and reports its exit from // unified exec. { frame: shellFrame('item/started', REVIEWER, 'r1', 'cmd-1', 'npm test', 'agent'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => { expect(byDescription('review')?.operation).toMatchObject({ toolName: 'Bash', @@ -250,6 +270,8 @@ describe('Codex structured child-work producer', () => { { frame: shellFrame('item/started', REVIEWER, 'r1', 'exec-1', 'npm run dev'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('npm run dev')).toMatchObject({ membership: 'live', @@ -259,6 +281,8 @@ describe('Codex structured child-work producer', () => { { frame: shellFrame('item/completed', REVIEWER, 'r1', 'cmd-1', 'npm test'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => { // A finished command leaves nothing behind. expect(byDescription('npm test')).toBeUndefined() @@ -275,32 +299,39 @@ describe('Codex structured child-work producer', () => { id: 'msg-1', text: 'Dev server is up' }), - lead: 'working' + lead: 'working', + live: 'working', + parent: { stateName: 'working' } }, // The parent's turn ends first; its child keeps running. { frame: turn('turn/completed', THREAD_ID, 'p1'), lead: 'done', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('review')).toMatchObject({ membership: 'live', state: 'working' }) }, - // The legacy task list carries no child state, so only the records can say a child waits, - // and the shared fold ranks that wait above the parent's own state. + // Only the records say a child waits, and the shared fold ranks that wait above the parent's + // own state. { frame: status(REVIEWER, ['waitingOnApproval']), lead: 'done', - childWaits: true, - check: () => { - expect(byDescription('review')?.state).toBe('waiting') - expect(agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks)).toBe( - 'working' - ) - } + live: 'waiting', + parent: { stateName: 'waiting' }, + check: () => expect(byDescription('review')?.state).toBe('waiting') + }, + { + frame: status(REVIEWER, []), + lead: 'done', + live: 'working', + parent: { stateName: 'working' } }, - { frame: status(REVIEWER, []), lead: 'done' }, { frame: turn('turn/completed', REVIEWER, 'r1'), lead: 'done', + live: 'monitoring', + parent: { stateName: 'working', workingMode: 'monitoring' }, check: () => { expect(byDescription('review')).toMatchObject({ membership: 'settled', @@ -315,11 +346,18 @@ describe('Codex structured child-work producer', () => { expect(display('review')).toBe('monitoring') } }, - { frame: turn('turn/started', THREAD_ID, 'p2'), lead: 'working' }, + { + frame: turn('turn/started', THREAD_ID, 'p2'), + lead: 'working', + live: 'monitoring', + parent: { stateName: 'working' } + }, // The parent asks the finished child a follow-up: the same record, a new run. { frame: turn('turn/started', REVIEWER, 'r2'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('review')).toMatchObject({ childWorkId: 'child-1', @@ -328,10 +366,30 @@ describe('Codex structured child-work producer', () => { previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })] }) }, - { frame: spawned(TESTER, 'test', 'p2'), lead: 'working' }, - { frame: turn('turn/started', TESTER, 't1'), lead: 'working' }, - { frame: spawned(LINTER, 'lint', 'p2'), lead: 'working' }, - { frame: turn('turn/started', LINTER, 'l1'), lead: 'working' }, + { + frame: spawned(TESTER, 'test', 'p2'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, + { + frame: turn('turn/started', TESTER, 't1'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, + { + frame: spawned(LINTER, 'lint', 'p2'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, + { + frame: turn('turn/started', LINTER, 'l1'), + lead: 'working', + live: 'working', + parent: { stateName: 'working' } + }, // Codex ends this child's turn with an error it will not retry, then a failed completion. { frame: { @@ -339,33 +397,48 @@ describe('Codex structured child-work producer', () => { params: { threadId: LINTER, turnId: 'l1', willRetry: false, error: { message: 'boom' } } }, lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('lint')).toMatchObject({ membership: 'live' }) }, { frame: turn('turn/completed', LINTER, 'l1', 'failed'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('lint')).toMatchObject({ membership: 'settled', outcome: 'failed' }) }, { frame: turn('turn/completed', REVIEWER, 'r2', 'interrupted'), lead: 'working', + live: 'working', + parent: { stateName: 'working' }, check: () => expect(byDescription('review')).toMatchObject({ membership: 'settled', outcome: 'cancelled' }) }, - { frame: turn('turn/completed', THREAD_ID, 'p2'), lead: 'done' }, + { + frame: turn('turn/completed', THREAD_ID, 'p2'), + lead: 'done', + live: 'working', + parent: { stateName: 'working' } + }, { frame: turn('turn/completed', TESTER, 't1', 'failed'), lead: 'done', + live: 'monitoring', + parent: { stateName: 'working', workingMode: 'monitoring' }, check: () => expect(byDescription('test')).toMatchObject({ membership: 'settled', outcome: 'failed' }) }, { frame: shellFrame('item/completed', REVIEWER, 'r1', 'exec-1', 'npm run dev'), lead: 'done', + live: null, + parent: { stateName: 'done' }, check: () => { expect(byDescription('npm run dev')).toBeUndefined() expect(display('review')).toBe('interrupted') @@ -373,24 +446,32 @@ describe('Codex structured child-work producer', () => { } ] let journalMoments = 0 + let before = steps[0].parent for (const [index, step] of steps.entries()) { const frameMoments = momentsOf(step.frame) journalMoments += frameMoments.length - for (const [at, { recorded, legacy }] of frameMoments.entries()) { - expect({ index, at, parent: fold(step.lead, asStrip(recorded)) }).toEqual({ + // Every row the journal publishes mid-frame reads the parent from before the frame or after + // it, and never done while a command still runs. + for (const [at, { recorded, commandLive }] of frameMoments.entries()) { + const parent = fold(step.lead, recorded) + expect({ index, at, - parent: fold(step.lead, legacy) + parent: [before, step.parent].some((p) => isDeepStrictEqual(p, parent)) + }).toEqual({ index, at, parent: true }) + expect({ index, at, done: commandLive && parent.stateName === 'done' }).toEqual({ + index, + at, + done: false }) } - const legacy = agentChildWorkLiveness(adapter.backgroundTaskState('session-1')?.tasks) - const recorded = recordedLiveness() - const expected = step.childWaits ? 'waiting' : legacy - expect({ index, parent: fold(step.lead, recorded) }).toEqual({ + const live = recordedLiveness() + expect({ index, live, parent: fold(step.lead, live) }).toEqual({ index, - parent: fold(step.lead, expected) + live: step.live, + parent: step.parent }) - expect({ index, liveness: recorded }).toEqual({ index, liveness: expected }) + before = step.parent step.check?.() } expect(journalMoments).toBeGreaterThan(steps.length) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 895e64447d1..37c34a4bf3a 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -171,7 +171,6 @@ export async function acquireCodexStructuredSession(input: { connection: acquisition.connection, error, prompts: acquisition.prompts, - onBackgroundTasksChanged: deps.onBackgroundTasksChanged, ...(deps.onEvent ? { onEvent: deps.onEvent } : {}) }) } finally { diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 2ab884d7cd1..9d2202076fa 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -3,6 +3,7 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { AgentSessionAcquisition, @@ -76,9 +77,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap sessions: this.sessions, acquisitions: this.acquisitions, ...(deps.onEvent ? { onEvent: deps.onEvent } : {}), - ...(deps.onBackgroundTasksChanged - ? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged } - : {}), forgetNotificationRetries: (sessionId) => this.notificationRetries.clear(sessionId, null) }) } @@ -138,11 +136,9 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap settleCodexSendsInEndedTurn(session, event.method, event.params, (settlement) => this.deps.onDispatchSettledLate?.({ sessionId: event.sessionId, ...settlement }) ) - // After the admission check, so a refused frame is observed by the strip + // After the admission check, so a refused frame is observed by the child records // only on the retry that also reaches the journal. - if (session.backgroundTasks.observe(event, session.prompts.takeAbandonedCommands())) { - this.deps.onBackgroundTasksChanged?.(event.sessionId, session.backgroundTasks.state) - } + session.backgroundTasks.observe(event, session.prompts.takeAbandonedCommands()) // After the journal and the parent's republished row, never ahead of either. session.backgroundTasks.publishChildWork() } @@ -169,9 +165,16 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap ) } - backgroundTaskState: NonNullable = ( + /** The tracker's own roster. No host decision reads it: the host's child records are the one + * owner of "what runs", and this stays only so tests can hold the two rule sets side by side. */ + backgroundTaskState = (sessionId: string): AgentSessionBackgroundTaskState | null | undefined => + this.sessions.get(sessionId)?.backgroundTasks.state + + // Codex exposes no honest stop for a child thread or a persistent command. + backgroundTaskStops: NonNullable = ( sessionId - ) => this.sessions.get(sessionId)?.backgroundTasks.state + ) => + this.sessions.has(sessionId) ? { supportsTaskStop: false, supportsStopAll: false } : undefined bindPromptItemId = ( sessionId: string, diff --git a/src/main/codex/codex-structured-session-background-tasks.test.ts b/src/main/codex/codex-structured-session-background-tasks.test.ts index 97b6f4e47a5..4e2515da7d4 100644 --- a/src/main/codex/codex-structured-session-background-tasks.test.ts +++ b/src/main/codex/codex-structured-session-background-tasks.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { CodexAppServerConnection, CodexAppServerConnectionHandlers, @@ -11,9 +11,11 @@ import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexStructuredSessionEvent } from './codex-structured-session-state' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -// Proves the strip is actually REACHED from provider traffic: the tracker is -// unit-tested separately, and a producer that is correct but unwired publishes -// nothing while every one of its own tests stays green. +// Proves the host's child records are actually REACHED from provider traffic: the tracker is +// unit-tested separately, and a producer that is correct but unwired publishes nothing while +// every one of its own tests stays green. Every surface, the strip included, reads those records. + +type Published = { sessionId: string; evidence: AgentChildWorkEvidence['type'][] } const THREAD_ID = '01a07d54-3785-71d0-b065-82c8ebbc572a' const PARENT_TURN = '01a07d54-37be-72e1-8206-8f0c23dd2cef' @@ -76,7 +78,7 @@ const TURN_COMPLETED = { } async function adapterWithSession( - published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[], + published: Published[], events?: StructuredAgentSessionEventSink, onEvent?: (event: CodexStructuredSessionEvent) => void, close?: () => Promise @@ -93,7 +95,8 @@ async function adapterWithSession( openConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, onEvent, - onBackgroundTasksChanged: (sessionId, state) => published.push({ sessionId, state }) + onChildWorkEvidence: (sessionId, evidence) => + published.push({ sessionId, evidence: evidence.map((edge) => edge.type) }) }) await adapter.acquire({ identity: identity('session-1'), @@ -112,9 +115,9 @@ async function adapterWithSession( return { adapter, codex } } -describe('codex background tasks reach the strip', () => { +describe("codex background tasks reach the host's child records", () => { it('clears natural-exit state before lifecycle observers can read it', async () => { - const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const published: Published[] = [] const onEvent = vi.fn() const { adapter, codex } = await adapterWithSession(published, undefined, onEvent) const spawn = subagentNotification('started') @@ -129,12 +132,12 @@ describe('codex background tasks reach the strip', () => { }) codex.handlers().onExit?.(new Error('provider exited')) expect(adapter.backgroundTaskState('session-1')).toBeNull() - expect(published).toEqual([{ sessionId: 'session-1', state: null }]) + expect(published).toEqual([{ sessionId: 'session-1', evidence: ['session-ended'] }]) await adapter.closeSession('session-1') }) it('keeps live tasks when close is refused', async () => { - const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const published: Published[] = [] const close = vi.fn(async () => false) const { adapter, codex } = await adapterWithSession(published, undefined, undefined, close) const spawn = subagentNotification('started') @@ -150,7 +153,7 @@ describe('codex background tasks reach the strip', () => { }) it('does not let an old exit callback clear a replacement roster', async () => { - const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const published: Published[] = [] const { adapter, codex } = await adapterWithSession(published) const oldExit = codex.handlers().onExit await adapter.acquire({ identity: identity('session-1'), fence: 8, spawnToken: 'spawn-10' }) @@ -170,7 +173,7 @@ describe('codex background tasks reach the strip', () => { }) it('recovers the exact provider generation when command metadata cannot be admitted', async () => { - const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const published: Published[] = [] const observed: CodexStructuredSessionEvent[] = [] const appendItem = vi.fn() const { adapter, codex } = await adapterWithSession( @@ -209,7 +212,7 @@ describe('codex background tasks reach the strip', () => { reason: 'notification admission failed (failed)' }) ]) - expect(published).toEqual([{ sessionId: 'session-1', state: null }]) + expect(published).toEqual([{ sessionId: 'session-1', evidence: ['session-ended'] }]) } finally { admission.mockRestore() await adapter.closeSession('session-1') @@ -217,7 +220,7 @@ describe('codex background tasks reach the strip', () => { }) it('publishes the fan-out while it runs and keeps it past the spawning turn', async () => { - const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const published: Published[] = [] const { adapter, codex } = await adapterWithSession(published) const running = { state: 'monitoring', @@ -227,20 +230,20 @@ describe('codex background tasks reach the strip', () => { const spawn = subagentNotification('started') codex.handlers().onNotification?.(spawn.method, spawn.params) - // Mid-turn: the child is running, so the strip reports it now. - expect(published).toEqual([{ sessionId: 'session-1', state: running }]) + // Mid-turn: the child is running, so its record is live now. + expect(published).toEqual([{ sessionId: 'session-1', evidence: ['live'] }]) expect(adapter.backgroundTaskState('session-1')).toEqual(running) published.length = 0 codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params) - // Turn end is not the child's outcome: no republish and no settle. + // Turn end is not the child's outcome: nothing reaches its record. expect(published).toEqual([]) expect(adapter.backgroundTaskState('session-1')).toEqual(running) }) - it('clears the strip when the session closes', async () => { - const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + it("hands the session's end to its child records when the session closes", async () => { + const published: Published[] = [] const { adapter, codex } = await adapterWithSession(published) const spawn = subagentNotification('started') codex.handlers().onNotification?.(spawn.method, spawn.params) @@ -249,9 +252,8 @@ describe('codex background tasks reach the strip', () => { expect(await adapter.closeSession('session-1')).toBe(true) - // Explicit null, not silence: the reader answers `undefined` once the - // session is gone, which every channel treats as "unchanged". - expect(published).toEqual([{ sessionId: 'session-1', state: null }]) + // The records hear the session end, which settles the child still running. + expect(published).toEqual([{ sessionId: 'session-1', evidence: ['session-ended'] }]) expect(adapter.backgroundTaskState('session-1')).toBeUndefined() }) }) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index 0a017f37f19..198f2114a65 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -5,7 +5,6 @@ import { cancelCodexAcquisitionAttempt, type CodexAcquisitionRegistry, type CodexSession, - type CodexStructuredSessionAdapterDeps, type CodexStructuredSessionEvent } from './codex-structured-session-state' import type { @@ -24,7 +23,6 @@ export function handleCodexSessionExit(input: { prompts?: CodexSession['prompts'] allowFailedSettlement?: boolean onEvent?: (event: CodexStructuredSessionEvent) => void - onBackgroundTasksChanged?: CodexStructuredSessionAdapterDeps['onBackgroundTasksChanged'] }): boolean { const session = input.sessions.get(input.sessionId) if (!session || session.connection !== input.connection || session.ended) { @@ -63,7 +61,6 @@ export function handleCodexSessionExit(input: { // recovery is what settles the sends these were armed for. session.dispatchEchoes.clear() session.backgroundTasks.clear() - input.onBackgroundTasksChanged?.(input.sessionId, null) // Every close path funnels here, so the session's children end with it on each one. session.backgroundTasks.publishChildWork() session.unbindReadingControl?.() diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 78864fe9044..896d2b352af 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -15,7 +15,6 @@ import { type CodexTurnOpenWaits } from './codex-structured-turn-open-wait' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' -import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' @@ -81,10 +80,6 @@ export type CodexStructuredSessionAdapterDeps = { /** Host capability seam; production uses the native Windows process table. */ isWindowsProcessStartTimeAvailable?: () => boolean onEvent?: (event: CodexStructuredSessionEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void /** What the session's child work did, delivered after the journal handled the frame. */ onChildWorkEvidence?: (sessionId: string, evidence: AgentChildWorkEvidence[]) => void /** A send admitted earlier: its identity once Codex echoes it, or its rejection when the turn diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index f302e15b367..05eb1e4b15e 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -4,7 +4,6 @@ // session owns are cleared exactly once, and only when the child was actually // proven stopped — a refused close leaves the session indexed for a retry. -import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import type { StructuredAgentSessionStopCause } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { closeAllCodexSessions, @@ -21,10 +20,6 @@ export type CodexStructuredSessionTeardownDeps = { sessions: Map acquisitions: CodexAcquisitionRegistry onEvent?: (event: CodexStructuredSessionEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void forgetNotificationRetries: (sessionId: string) => void } @@ -86,10 +81,6 @@ export class CodexStructuredSessionTeardown { private settled(sessionId: string, closed: boolean): boolean { if (closed) { this.deps.forgetNotificationRetries(sessionId) - // Explicit null, not silence: the state reader answers `undefined` once - // the session leaves the map, which every channel reads as "unchanged" - // and would leave the last roster on screen. - this.deps.onBackgroundTasksChanged?.(sessionId, null) } return closed } diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 135b0af68f6..a8adaacc0a3 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -27,6 +27,7 @@ import { WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' +import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' @@ -55,11 +56,12 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ ] /** Gates the renderer must pass against its own main process. The Electron remote list omits all - * five; mobile advertises the structured ones, so this is an Electron-remote gap rather than a + * six; mobile advertises the structured ones, so this is an Electron-remote gap rather than a * statement that no remote client wants them. Why it is one is not recorded here. */ const LOCAL_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 0bcfd1bd686..8fe881e0061 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -9,6 +9,7 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' +import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' /** * What the desktop renderer advertises when it calls its own main process over `runtime:call`. @@ -29,6 +30,7 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, // Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 72291de4824..9531adb0318 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -111,9 +111,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return stop ? stop(input) : Promise.resolve({ cancelled: false }) } - backgroundTaskState: NonNullable = ( + backgroundTaskStops: NonNullable = ( sessionId - ) => this.liveOwnerOrNull(sessionId)?.backgroundTaskState?.(sessionId) + ) => this.liveOwnerOrNull(sessionId)?.backgroundTaskStops?.(sessionId) holdsDispatch = (sessionId: string): boolean => this.liveOwnerOrNull(sessionId)?.holdsDispatch?.(sessionId) ?? false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 6d19c8523bb..129f363aa8c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -10,6 +10,7 @@ import type { // take this?" — and it answers `unknown` rather than guessing, because the // journal renders that as delivery unconfirmed instead of as failure. +import type { AgentSessionBackgroundTaskStops } from '../../../shared/agent-child-work-stop-targets' import type { AgentJournalItemIdentity, AgentJournalItemBody, @@ -25,7 +26,6 @@ import type { AgentSessionProcessIdentity } from '../../../shared/agent-session-record' import type { - AgentSessionBackgroundTaskState, AgentSessionOptionsResult, AgentSessionSlashCommand, AgentSessionThreadGoalChange @@ -332,12 +332,15 @@ export type StructuredAgentSessionAdapter = { supportsThreadGoal?(sessionId: string, agent?: string): boolean /** Whether this session writes context facts to its turn rows; `agent` answers one at rest. */ recordsContextUsage?(sessionId: string, agent?: string): boolean + /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. */ stopBackgroundTasks?(input: { sessionId: string fence: number - taskId?: string + taskIds: readonly string[] }): Promise<{ cancelled: boolean }> - backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined + /** The stops this provider honours for a live session's background work; undefined when the + * adapter holds no live session for it. */ + backgroundTaskStops?(sessionId: string): AgentSessionBackgroundTaskStops | undefined /** The provider reported taking a send it has neither answered nor ended, as a queued follow-up * or a silent retry does. Derived from the live child; false with none. */ holdsDispatch?(sessionId: string): boolean diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts new file mode 100644 index 00000000000..11505b6c204 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts @@ -0,0 +1,114 @@ +// The strip channel keeps one fingerprint per conversation to skip an unchanged roster; a closed +// conversation's goes with it. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel' +import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' + +const child: AgentChildWorkView = { + id: 'child-1', + providerId: 'task-1', + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 1, + observedAt: 1, + stoppable: true, + invocation: { invocationId: 'spawn-1', generation: 1 } +} + +type Stops = { supportsTaskStop: boolean; supportsStopAll: boolean } | undefined + +function channelOver( + children: () => AgentChildWorkView[] = () => [child], + stops: () => Stops = () => ({ supportsTaskStop: true, supportsStopAll: true }) +) { + const sessions = new StructuredAgentSessionConversations({ + deliver: () => {}, + onDeliveryError: () => {}, + now: () => 1 + }) + const sent = vi.fn() + const channel = new StructuredAgentSessionBackgroundTaskChannel( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the channel reads only the record store's fence and the adapter's stop capability. + { + store: { getRecord: () => null }, + adapter: { backgroundTaskStops: stops } + } as unknown as StructuredAgentSessionHostDeps, + sessions, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: publish sends through `backgroundTasks` only. + { backgroundTasks: sent } as unknown as AgentSessionSubscribers, + async () => { + throw new Error('not opened here') + }, + children + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the map reads only the journal's commit observer and the session's provider. + const session = { + journal: { observeCommits: () => {} }, + params: { provider: 'claude' } + } as unknown as StructuredAgentSessionHostSession + return { sessions, sent, channel, session } +} + +describe('the background-task channel', () => { + it("forgets a closed conversation's roster, so reopening it sends the roster again", () => { + const { sessions, sent, channel, session } = channelOver() + sessions.set('session-1', session) + channel.publish('session-1') + channel.publish('session-1') + // An unchanged roster sends nothing. + expect(sent).toHaveBeenCalledTimes(1) + + sessions.delete('session-1') + expect(channel['published'].size).toBe(0) + sessions.set('session-1', session) + channel.publish('session-1') + expect(sent).toHaveBeenCalledTimes(2) + }) + + it('sends "no children" once, not again on every change that leaves none', () => { + let views: AgentChildWorkView[] = [] + const { sessions, sent, channel, session } = channelOver(() => views) + sessions.set('session-1', session) + channel.publish('session-1') + channel.publish('session-1') + expect(sent.mock.calls.map(([, state]) => state)).toEqual([null]) + + views = [child] + channel.publish('session-1') + views = [] + channel.publish('session-1') + channel.publish('session-1') + expect(sent.mock.calls.map(([, state]) => state?.children?.length ?? null)).toEqual([ + null, + 1, + null + ]) + }) + + // Claude's release path settles the last child after the adapter let go of the session, so no + // provider answers for it any more: the strip must still hide. + it('hides the strip when its last child settles after the provider let go of the session', () => { + let views: AgentChildWorkView[] = [child] + let held: Stops = { supportsTaskStop: true, supportsStopAll: true } + const { sessions, sent, channel, session } = channelOver( + () => views, + () => held + ) + sessions.set('session-1', session) + channel.publish('session-1') + expect(sent.mock.calls.at(-1)?.[1]?.children).toHaveLength(1) + + held = undefined + views = [{ ...child, state: 'done', membership: 'settled', outcome: 'unknown', settledAt: 2 }] + channel.publish('session-1') + expect(sent.mock.calls.at(-1)?.[1]).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts index 076be2340f1..c5fd3f81b59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.ts @@ -3,6 +3,9 @@ import type { AgentSessionHistoryRequest, AgentSessionHistoryResult } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { structuredChildWorkLegacyTasks } from '../../../shared/structured-agent-session-child-work-legacy' +import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing' import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' import { tryReadQueuePublication } from './structured-agent-session-queued-publication' import type { AgentSessionHistoryScope } from './agent-session-history-page' @@ -10,25 +13,32 @@ import type { AgentSessionSubscribers, AgentSessionSubscribeInput } from './structured-agent-session-subscribers' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +/** The chat strip's roster: the host's running child records for one session, the same selection + * the session list carries, with the legacy task rows an older client reads derived from them. No + * running child is `null`, and the strip hides. */ export class StructuredAgentSessionBackgroundTaskChannel { + private readonly published = new Map() + constructor( private readonly deps: StructuredAgentSessionHostDeps, - private readonly sessions: Map, + private readonly sessions: StructuredAgentSessionConversations, private readonly subscribers: AgentSessionSubscribers, /** The host's accessor: opens a conversation at rest, and never starts an agent. */ private readonly conversation: ( sessionId: string ) => Promise, - /** Task edges change the status summary too; the feed's equality check - * keeps a no-op re-projection from reaching subscribers. */ - private readonly onPublished: (sessionId: string) => void - ) {} + private readonly readChildWork: (sessionId: string) => AgentChildWorkView[] | undefined + ) { + // A closed conversation's last roster is not kept for the host's lifetime. + sessions.observeClose((sessionId) => this.published.delete(sessionId)) + } /** `scope` is for in-process readers; a wire request reads every agent's rows. */ async history( @@ -72,20 +82,51 @@ export class StructuredAgentSessionBackgroundTaskChannel { }) } - publish(sessionId: string, publishedState?: AgentSessionBackgroundTaskState | null): void { + /** Re-read after the session's child records changed; an unchanged roster sends nothing. */ + publish(sessionId: string): void { const session = this.sessions.get(sessionId) - const state = publishedState !== undefined ? publishedState : this.state(sessionId) - if (session && state !== undefined) { - this.subscribers.backgroundTasks( - sessionId, - state, - structuredAgentSessionConversationFence(this.deps.store, sessionId) - ) - this.onPublished(sessionId) + // Explicit null once a roster was sent, not silence: a reader keeps its last roster on + // `undefined`, and a closing provider stops answering before its records are gone. + const read = this.state(sessionId) + const state = read === undefined && this.published.has(sessionId) ? null : read + if (!session || state === undefined) { + return } + const fingerprint = JSON.stringify(state) + if (this.published.get(sessionId) === fingerprint) { + return + } + // "None" is remembered too, so a session with no children sends it once, not on every change; + // the entry goes when the conversation closes. + this.published.set(sessionId, fingerprint) + this.subscribers.backgroundTasks( + sessionId, + state, + structuredAgentSessionConversationFence(this.deps.store, sessionId) + ) } private state(sessionId: string): AgentSessionBackgroundTaskState | null | undefined { - return this.deps.adapter.backgroundTaskState?.(sessionId) + const session = this.sessions.get(sessionId) + const stored = session ? this.readChildWork(sessionId) : undefined + if (!session || stored === undefined) { + return undefined + } + const views = structuredRunningChildWork(stored) + const stops = this.deps.adapter.backgroundTaskStops?.(sessionId) + if (views.length === 0) { + // As before: a session no live provider holds says nothing, a live one says "none". + return stops === undefined ? undefined : null + } + const { tasks, settledTasks } = structuredChildWorkLegacyTasks(views, session.params.provider) + return { + state: 'monitoring', + ...(tasks ? { tasks } : {}), + ...(settledTasks ? { settledTasks } : {}), + ...(stops?.supportsTaskStop ? { supportsTaskStop: true } : {}), + // A session no live provider holds has nothing a stop could reach. + ...(stops?.supportsStopAll ? {} : { supportsStopAll: false }), + children: views + } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts new file mode 100644 index 00000000000..32b4abf5a21 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts @@ -0,0 +1,85 @@ +// What the sidebar and the chat's strip list of a session's child records: running children only, +// by one rule. A finished child is in the transcript, not in either list. + +import { describe, expect, it } from 'vitest' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing' +import { + attach, + hostTestState, + serveHostTestChildWork +} from './structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' + +function running(id: string, over: Partial = {}): AgentChildWorkView { + return { + id, + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 1, + observedAt: 1, + stoppable: false, + invocation: { invocationId: `spawn-${id}`, generation: 1 }, + ...over + } +} + +function finished(id: string, settledAt: number): AgentChildWorkView { + return running(id, { + state: 'done', + membership: 'settled', + outcome: 'succeeded', + observedAt: settledAt, + settledAt + }) +} + +const ids = (views: readonly AgentChildWorkView[]) => views.map((view) => view.id) + +describe('the sidebar and the strip list running children only', () => { + it('drops finished and failed children, and keeps a finished one whose shell still runs', () => { + const failed = { ...finished('failed', 5), outcome: 'failed' as const } + const views = [ + running('working'), + finished('done', 4), + failed, + finished('owner', 3), + running('shell', { kind: 'command', parentChildWorkId: 'owner' }) + ] + expect(ids(structuredRunningChildWork(views))).toEqual(['working', 'owner', 'shell']) + }) +}) + +describe("the host's strip channel and summary list the same running children", () => { + it('sends both the running children, and the strip nothing once none runs', async () => { + let records = [ + finished('done', 5), + running('run'), + finished('owner', 6), + running('shell', { kind: 'command', parentChildWorkId: 'owner' }) + ] + serveHostTestChildWork(() => records) + await attach() + const { host } = hostTestState() + const strip = async () => { + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + return page.ok ? page.page.backgroundTasks : undefined + } + expect(ids((await strip())?.children ?? [])).toEqual(['run', 'owner', 'shell']) + const summaries: string[][] = [] + host.subscribeStatus({ + id: 'list', + emit: (event) => { + if (event.type === 'snapshot') { + summaries.push(event.sessions.flatMap((session) => ids(session.children ?? []))) + } + } + }) + expect(summaries.at(-1)).toEqual(['run', 'owner', 'shell']) + + // Nothing runs: no strip at all (a host that holds no provider says nothing). + records = [finished('done', 5), finished('run', 7)] + expect(await strip()).toBeUndefined() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index fee7f9e1d3d..2efe7dd48f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -1,5 +1,6 @@ import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { tryReadQueuePublication } from './structured-agent-session-queued-publication' @@ -31,13 +32,16 @@ export class StructuredAgentSessionClientDelivery { now: () => number, deps: () => StructuredAgentSessionHostDeps, private readonly onJournalActivity?: (sessionId: string) => void, - onAgentStarted?: (sessionId: string) => void + onAgentStarted?: (sessionId: string) => void, + /** A session's child records changed; the chat strip republishes from them. */ + onChildWorkChanged?: (sessionId: string) => void ) { this.statusFeed = createStructuredAgentSessionHostStatusFeed({ sessions, now, deps, - ...(onAgentStarted ? { onAgentStarted } : {}) + ...(onAgentStarted ? { onAgentStarted } : {}), + ...(onChildWorkChanged ? { onChildWorkChanged } : {}) }) this.turnCompletionFeed = new StructuredAgentSessionTurnCompletionFeed({ sessions, @@ -61,6 +65,9 @@ export class StructuredAgentSessionClientDelivery { publishChildWork = (sessionId: string, evidence: AgentChildWorkEvidence[]): void => this.statusFeed.publishChildWork(sessionId, evidence) + readChildWork = (sessionId: string): AgentChildWorkView[] | undefined => + this.statusFeed.readChildWork(sessionId) + publishStatusAndSettlement = (sessionId: string): void => { this.statusFeed.publish(sessionId) const journal = this.sessions.get(sessionId)?.journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts index 016819a9607..ef007e03d6b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts @@ -12,6 +12,7 @@ import { projectStructuredAgentSessionStatusSummary } from '../../../shared/stru import { projectTurnItemHistory } from '../../runtime/rpc/methods/structured-agent-session-turn-item-capability' import { structuredAgentSessionWorkingAtStop } from './structured-agent-session-working-at-teardown' import { + childRecord, journal, NOW, record, @@ -144,8 +145,8 @@ describe('restart resume around /compact (B14)', () => { sessionId: SESSION, session: { journal: journal(items), child: { fence: 1 } }, getRecord: () => record(), - backgroundTasks: () => - tasks ? [{ id: 'task-a', kind: 'agent', description: 'Review', state: 'working' }] : [], + childWork: () => + tasks ? [childRecord({ id: 'task-a', kind: 'agent', description: 'Review' })] : [], trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts index cb83dba870a..25b6addb3c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -29,6 +29,7 @@ import { } from '../../../shared/agent-session-journal-types' import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' import { agentJournalTurnBody, @@ -153,6 +154,8 @@ export type StructuredAgentSessionCommandHandoverContext = { /** Who a failure the handover meets names, as the start's own row does. */ failureTextContext?: AgentSessionFailureWordsContext record: () => AgentSessionRecord | null + /** The session's child records, the same read the strip and conversation-command admission use. */ + childWork: () => readonly AgentChildWorkView[] | undefined flushStreamedEvents: () => Promise now: () => number } @@ -313,7 +316,7 @@ function commandBlocked( if (!record) { return agentSessionFailureFact('hostFault') } - const refusal = conversationCommandBlocked(ctx, record, 'handover') + const refusal = conversationCommandBlocked(ctx, record, ctx.childWork(), 'handover') return refusal ? agentSessionFailureFact('commandRefused', { refusal: agentSessionRefusalReference(refusal) }) : null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index 5fd28ff3927..2dd2b5229aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -311,7 +311,7 @@ describe('the wind-down retry with a message queued (P2-31)', () => { now: () => IDLE_MS + 1, isDisposed: () => false, deliveryActive: () => true, - backgroundTaskState: () => undefined, + childWork: () => undefined, hasOpenDispatch: () => false, providerHoldsDispatch: () => false, stopAgent, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 1d06a61f88c..1980616f176 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -9,6 +9,7 @@ import { AgentSessionRefusalError, agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { createJournalOpenReadRefusals } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import { @@ -38,6 +39,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { deliveryActive: (sessionId: string) => boolean /** The handle closed: `listed` keeps the chat's row in the agent-status store for its tab. */ closeStatus: (sessionId: string, options: { listed: boolean }) => void + /** The session's child records, the host's one read of them. */ + readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined }) { let disposed = false const { sessions, serialize } = host @@ -70,7 +73,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { now: () => host.context().now(), isDisposed: () => disposed, deliveryActive: host.deliveryActive, - backgroundTaskState: (sessionId) => deps().adapter.backgroundTaskState?.(sessionId), + childWork: host.readChildWork, hasOpenDispatch: (sessionId) => { const record = deps().store.getRecord(sessionId) return record !== null && deps().hasOpenDispatch?.(record) === true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts index c312d6013fd..70fe538ce20 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.ts @@ -17,6 +17,7 @@ export class StructuredAgentSessionConversations extends Map< StructuredAgentSessionHostSession > { private readonly activity = new Map() + private readonly closeObservers = new Set<(sessionId: string) => void>() constructor( private readonly delivery: { @@ -58,7 +59,18 @@ export class StructuredAgentSessionConversations extends Map< override delete(sessionId: string): boolean { this.activity.delete(sessionId) - return super.delete(sessionId) + const deleted = super.delete(sessionId) + if (deleted) { + for (const observer of this.closeObservers) { + observer(sessionId) + } + } + return deleted + } + + /** Told when a conversation leaves the map, so state kept per conversation dies with it. */ + observeClose(observer: (sessionId: string) => void): void { + this.closeObservers.add(observer) } touch(sessionId: string): void { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 433505bbd69..9740bac146e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -12,6 +12,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureFact, type SubmissionRejectionFact @@ -63,6 +64,7 @@ export type StructuredAgentSessionDeliveryLoopDeps = { failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext onError: (sessionId: string, error: unknown) => void record: (sessionId: string) => AgentSessionRecord | null + readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined flushStreamedEvents: (sessionId: string) => Promise now: () => number } @@ -228,6 +230,7 @@ export class StructuredAgentSessionDeliveryLoop { providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, failureTextContext: this.deps.failureTextContext(sessionId), record: () => this.deps.record(sessionId), + childWork: () => this.deps.readChildWork(sessionId), flushStreamedEvents: () => this.deps.flushStreamedEvents(sessionId), now: this.deps.now }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index cdb7b473ae2..5d8a4d0429b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -12,6 +12,7 @@ import { type OpenedStructuredAgentSessionConversation, type StructuredAgentSessionConversationOpenOptions } from './structured-agent-session-conversation-open' +import type { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' @@ -54,7 +55,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { startedFor: string ) => Promise reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void - publishRestored: (sessionId: string) => void + clientDelivery: Pick flushStreamedEvents: (sessionId: string) => Promise }): StructuredAgentSessionConversationDelivery { const { deps, sessions } = input @@ -79,6 +80,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { ), onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }), record: (sessionId) => deps.store.getRecord(sessionId), + readChildWork: input.clientDelivery.readChildWork, flushStreamedEvents: input.flushStreamedEvents, now: () => deps.now?.() ?? Date.now() }) @@ -91,7 +93,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { if (reset) { input.reset(sessionId, session.journal, reset) } - input.publishRestored(sessionId) + input.clientDelivery.publishRestored(sessionId) await settleInterruptedCommands(deps, sessionId, session) if (session.journal.submissions().some(isQueuedAgentJournalSubmission)) { loop.wake(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 3055bb2d878..c88ae3a2f52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -21,6 +21,7 @@ import type { import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { threadGoalPlan } from './structured-agent-session-thread-goal' import { isMainAgentWorkingOnceFlushed, @@ -64,6 +65,8 @@ export type StructuredAgentSessionMutationContext = { flushStreamedEvents: (sessionId: string) => Promise /** The host's accessor, for a caller outside the session's serialize. */ conversation: (sessionId: string) => Promise + /** The session's child records, as the strip reads them; what command admission decides on. */ + readChildWork: (sessionId: string) => AgentChildWorkView[] | undefined serialize: (sessionId: string, task: () => Promise) => Promise /** The session's conversation, opened when closed; inside the caller's serialize. */ openConversation: (sessionId: string) => Promise @@ -159,7 +162,7 @@ export function cancelStructuredAgentSessionTurn( context, caller, params.envelope, - cancelPlan(params), + cancelPlan({ ...params, childWork: () => context.readChildWork(params.envelope.sessionId) }), openForWrite(context, params.envelope) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index 20a76aabfd3..f29d1b68e69 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -7,6 +7,7 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-ses import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { @@ -185,6 +186,12 @@ export function replaceHostTestState(next: { host = next.host } +/** Serves `records` as the session's child records through the status sink, as the host's store + * does. Call before `attach`: only the attach's publish carries the address a row lands under. */ +export function serveHostTestChildWork(records: () => AgentChildWorkView[]): void { + host.deps.statusSink = { publish: () => {}, forget: () => {}, readChildWork: records } +} + /** The live per-test state. Read it in a `beforeEach` so a suite's test bodies * keep using bare `host` / `store` / `dispatch` exactly as they did when this * setup was inline. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 62603707ded..c7e91ec8dde 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -81,7 +81,8 @@ export class StructuredAgentSessionHost { () => this.now(), () => this.deps, (sessionId) => this.queued.onJournalActivity(sessionId), - (sessionId) => this.restartResume.onAgentStarted(sessionId) + (sessionId) => this.restartResume.onAgentStarted(sessionId), + (sessionId) => this.backgroundTasks.publish(sessionId) ) private readonly subscribers = this.clientDelivery.subscribers private readonly tasks = new StructuredAgentSessionTaskQueue() @@ -105,7 +106,7 @@ export class StructuredAgentSessionHost { this.sessions, this.subscribers, (sessionId) => this.lifetime.conversation(sessionId), - this.clientDelivery.publishStatus + this.clientDelivery.readChildWork ) this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (sessionId, error) => this.eventRecovery.recoverAfterSinkFailure(sessionId, error) @@ -131,7 +132,7 @@ export class StructuredAgentSessionHost { reset, structuredAgentSessionConversationFence(deps.store, sessionId) ), - publishRestored: this.clientDelivery.publishRestored, + clientDelivery: this.clientDelivery, flushStreamedEvents: (sessionId) => this.flushStreamedEvents(sessionId) }) this.restore = createStructuredAgentSessionHostRestore(deps, { @@ -159,18 +160,18 @@ export class StructuredAgentSessionHost { now: () => this.now(), onBarrierError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) }) - this.restartResume = createStructuredAgentSessionRestartResume( - deps, - this.sessions, - structuredAgentSessionRestartResumeSurfaces(this, this.now) - ) + this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { + ...structuredAgentSessionRestartResumeSurfaces(this, this.now), + readChildWork: this.clientDelivery.readChildWork + }) this.lifetime = createStructuredAgentSessionConversationLifetime({ context: () => this.lifetimeContext(), sessions: this.sessions, serialize: (sessionId, task) => this.serialize(sessionId, task), open: (sessionId) => this.conversationDelivery.open(sessionId), deliveryActive: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), - closeStatus: (sessionId, options) => this.clientDelivery.closeSession(sessionId, options) + closeStatus: (sessionId, options) => this.clientDelivery.closeSession(sessionId, options), + readChildWork: this.clientDelivery.readChildWork }) this.runtimeState.startLeaseRenewal() this.lifetime.idleSweep.start() @@ -275,6 +276,7 @@ export class StructuredAgentSessionHost { publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), flushStreamedEvents: this.flushStreamedEvents, conversation: this.lifetime.conversation, + readChildWork: this.clientDelivery.readChildWork, serialize: (sessionId, task) => this.serialize(sessionId, task), openConversation: this.conversationDelivery.open, ensureAgent: (sessionId) => @@ -334,8 +336,6 @@ export class StructuredAgentSessionHost { input: Parameters[1] ) => releaseStructuredAgentSessionUnansweredDispatches(this.mutationContext(), input) - publishBackgroundTaskState: StructuredAgentSessionBackgroundTaskChannel['publish'] = (...args) => - this.backgroundTasks.publish(...args) publishChildWorkEvidence = this.clientDelivery.publishChildWork unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 533904d5fa0..cad1d17d94f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -4,6 +4,7 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { collectSubscriber, createRestTestRig, @@ -43,6 +44,25 @@ function lastStatus(): AgentSessionStatusSummary | undefined { .findLast((summary) => summary.sessionId === SESSION) } +/** A child record as the host's store serves it; a `done` one has settled. */ +function childView( + id: string, + kind: AgentChildWorkView['kind'], + state: 'working' | 'idle' | 'done' +): AgentChildWorkView { + return { + id, + kind, + state, + membership: state === 'done' ? 'settled' : 'live', + ...(state === 'done' ? { outcome: 'succeeded' as const, settledAt: rig.clock.now } : {}), + firstObservedAt: rig.clock.now, + observedAt: rig.clock.now, + stoppable: false, + invocation: { invocationId: `spawn-${id}`, generation: 1 } + } +} + function fence(): number { return rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 } @@ -102,25 +122,13 @@ describe('the idle sweep', () => { it('never stops an agent whose background work still runs (P2-09)', async () => { await foundRestTestChat(rig) - // A Codex subagent thread is one of these tasks; the tracker projects both kinds alike. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a background roster fixture; the sweep reads only whether live tasks exist. - rig.adapter.backgroundTaskState.mockReturnValue({ - state: 'monitoring', - tasks: [ - { - taskId: 'subagent-1', - kind: 'subagent', - status: 'running', - title: 'reviewer', - startedAt: rig.clock.now - } - ] - } as never) + // A Codex subagent thread is one of these records; the host holds both kinds alike. + rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'working')]) rig.clock.now += IDLE_MS + 1 await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() - rig.adapter.backgroundTaskState.mockReturnValue(undefined) + rig.sink.readChildWork.mockReturnValue([]) rig.clock.now += IDLE_MS + 1 await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) @@ -129,16 +137,10 @@ describe('the idle sweep', () => { // agent in that gap would lose the wake-up. Owed work is activity, as main's release clock had it. it('gives an agent a full idle window after its background work ends', async () => { await foundRestTestChat(rig) - rig.adapter.backgroundTaskState.mockReturnValue({ - state: 'monitoring', - tasks: [{ id: 'subagent-1', kind: 'agent', state: 'working' }] - }) + rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'working')]) rig.clock.now += IDLE_MS + 1 await sweepTicks() - rig.adapter.backgroundTaskState.mockReturnValue({ - state: 'monitoring', - tasks: [{ id: 'subagent-1', kind: 'agent', state: 'done' }] - }) + rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'done')]) await sweepTicks() expect(rig.adapter.closeSession).not.toHaveBeenCalled() @@ -150,16 +152,12 @@ describe('the idle sweep', () => { // have closed still leaves the agent a full window after it. it('gives a full window after background work that ends late in a window', async () => { await foundRestTestChat(rig) - const subagent = (state: 'working' | 'done') => ({ - state: 'monitoring' as const, - tasks: [{ id: 'subagent-1', kind: 'agent' as const, state }] - }) - rig.adapter.backgroundTaskState.mockReturnValue(subagent('working')) + rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'working')]) rig.clock.now += IDLE_MS + 1 await sweepTicks() rig.clock.now += IDLE_MS - 60_000 await sweepTicks() - rig.adapter.backgroundTaskState.mockReturnValue(subagent('done')) + rig.sink.readChildWork.mockReturnValue([childView('subagent-1', 'agent', 'done')]) rig.clock.now += 60_000 + 1 await sweepTicks() @@ -168,15 +166,12 @@ describe('the idle sweep', () => { await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) }) - it('stops an agent whose roster holds only children that went idle or finished', async () => { + it('stops an agent whose child records hold only children that went idle or finished', async () => { await foundRestTestChat(rig) - rig.adapter.backgroundTaskState.mockReturnValue({ - state: 'monitoring', - tasks: [ - { id: 'subagent-1', kind: 'agent', state: 'idle' }, - { id: 'command-1', kind: 'command', state: 'done' } - ] - }) + rig.sink.readChildWork.mockReturnValue([ + childView('subagent-1', 'agent', 'idle'), + childView('subagent-2', 'agent', 'done') + ]) rig.clock.now += IDLE_MS + 1 await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict')) @@ -319,7 +314,7 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { now: () => IDLE_MS + 1, isDisposed: () => false, deliveryActive: () => false, - backgroundTaskState: () => undefined, + childWork: () => undefined, hasOpenDispatch: () => false, providerHoldsDispatch: () => false, stopAgent, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index cdf412a7bcd..14cc0e56030 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -12,7 +12,7 @@ import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work- import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' export const STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60_000 @@ -27,7 +27,8 @@ export type StructuredAgentSessionIdleSweepDeps = { now: () => number isDisposed: () => boolean deliveryActive: (sessionId: string) => boolean - backgroundTaskState: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined + /** The session's child records, the host's one read of them. */ + childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined /** An orchestration dispatch that still owns this session's worker; derived from its database. */ hasOpenDispatch: (sessionId: string) => boolean /** The provider holds a send it took and has not answered; see `holdsDispatch`. */ @@ -145,7 +146,7 @@ export class StructuredAgentSessionIdleSweep { return ( activeStructuredAgentSessionTurnId(items) !== null || this.queuedOrDelivering(sessionId, session) || - agentChildWorkLiveness(this.deps.backgroundTaskState(sessionId)?.tasks) !== null || + agentChildWorkLiveness(this.deps.childWork(sessionId)) !== null || this.deps.hasOpenDispatch(sessionId) || this.deps.providerHoldsDispatch(sessionId) || hasPendingStructuredAgentSessionPrompt(items) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index 333b5bf3e01..aa4a9547001 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -8,12 +8,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-background-task-wire' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' @@ -46,7 +46,8 @@ let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let dispatch: Mock let events: StructuredAgentSessionEventSink | undefined -let backgroundTasks: AgentSessionBackgroundTaskState | null +/** The session's child records, as the host's store serves them to every surface. */ +let records: AgentChildWorkView[] let chat: StructuredAgentSessionState let listed: AgentSessionStatusSummary | undefined @@ -54,7 +55,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-main-agent-working-')) resetHostTestOperationIds() events = undefined - backgroundTasks = null + records = [] chat = EMPTY_STRUCTURED_AGENT_SESSION listed = undefined // Written, and the provider has neither opened a turn for it nor answered it. @@ -88,9 +89,9 @@ beforeEach(async () => { releaseAcquisition: vi.fn(async () => true), cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined), - backgroundTaskState: () => backgroundTasks + setOption: vi.fn(async () => undefined) }, + statusSink: { publish: () => {}, forget: () => {}, readChildWork: () => records }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', @@ -163,7 +164,11 @@ function listReads(): { mainAgent: string; row: string } { if (!summary?.status) { return { mainAgent: 'none', row: 'none' } } - const status = structuredAgentSessionAgentStatus({ ...summary, status: summary.status }) + const status = structuredAgentSessionAgentStatus({ + ...summary, + status: summary.status, + childWork: summary.children + }) return { mainAgent: status.mainAgent.state, row: status.state } } @@ -197,14 +202,24 @@ describe('the main agent read by the chat and by a session list', () => { expect(listReads().mainAgent).toBe('working') await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'success' }) - backgroundTasks = { - state: 'monitoring', - tasks: [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working' }] - } - host.publishBackgroundTaskState(SESSION) + records = [ + { + id: 'child-1', + providerId: 'task-1', + kind: 'agent', + name: 'deep_review', + state: 'working', + membership: 'live', + firstObservedAt: NOW, + observedAt: NOW, + stoppable: false, + invocation: { invocationId: 'task-1', generation: 1 } + } + ] + host.publishChildWorkEvidence(SESSION, []) await host.flushStreamedEvents(SESSION) - expect(chat.backgroundTasks?.tasks).toHaveLength(1) + expect(chat.backgroundTasks?.children).toHaveLength(1) // The row reads Working for the subagent; the main agent, and so Stop, does not. expect(listReads()).toEqual({ mainAgent: 'done', row: 'working' }) expect(chatReadsWorking()).toBe(false) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 7eecbd50db9..e18e65e6ab6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -6,6 +6,7 @@ // journal. Send is fail-closed: admission alone cannot prove non-delivery. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' import type { AgentSessionCancelResult, @@ -173,6 +174,8 @@ export function cancelPlan(params: { taskId?: string prompt?: { itemId: string; expectedRevision: number } stopChild?: () => Promise + /** The session's child records, which name the tasks a background Stop reaches. */ + childWork?: () => readonly AgentChildWorkView[] | undefined }): MutationPlan { return { method: 'agentSession.cancel', @@ -193,7 +196,8 @@ export function cancelPlan(params: { ...(params.scope ? { scope: params.scope } : {}), ...(params.taskId ? { taskId: params.taskId } : {}), ...(params.prompt ? { prompt: params.prompt } : {}), - ...(params.stopChild ? { stopChild: params.stopChild } : {}) + ...(params.stopChild ? { stopChild: params.stopChild } : {}), + ...(params.childWork ? { childWork: params.childWork } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a // replay reports the turn as already handled. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 6ad1506e2aa..24e9dc66f44 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AgentHookServer, _internals } from '../../agent-hooks/server' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, @@ -44,9 +45,11 @@ let claude: ReturnType let landInit: () => void let lifecycle: Promise[] let clock: number +const server = new AgentHookServer() beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-owed-work-release-')) + _internals.resetCachesForTests() resetHostTestOperationIds() claude = fakeClaude() lifecycle = [] @@ -73,6 +76,8 @@ beforeEach(async () => { }, // As the runtime wires it: an admitted prompt's outcome reaches the journal out of band. onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + onChildWorkEvidence: (sessionId, evidence) => + host.publishChildWorkEvidence(sessionId, evidence), // Initialize answers only when the test says so. openConnection: async (launch, handlers) => { const connection = await claude.openConnection(launch, handlers) @@ -94,7 +99,15 @@ beforeEach(async () => { claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, - now: () => clock + now: () => clock, + // The host's status row and child records, as the runtime wires them. + statusSink: { + publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), + forget: (subject) => server.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + server.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => server.getStructuredChildWorkViews(subject) + } }) }) @@ -263,8 +276,9 @@ describe('a chat whose settled lead still has background work running', () => { }) frame({ type: 'result', subtype: 'success', uuid: 'result-1', is_error: false, result: 'ok' }) await host.flushStreamedEvents(SESSION) - expect(adapter.backgroundTaskState(SESSION)?.tasks).toEqual([ - expect.objectContaining({ id: 'task-1' }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok ? page.page.backgroundTasks?.children : null).toEqual([ + expect.objectContaining({ providerId: 'task-1', membership: 'live' }) ]) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 9e5a136d4a4..e441a156b49 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -13,6 +13,7 @@ import type { AgentSessionStatusEvent, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { @@ -44,7 +45,6 @@ export type RestTestAdapter = { acknowledgeSessionRelease: Mock< NonNullable > - backgroundTaskState: Mock> holdsDispatch: Mock> readOptions: Mock> } @@ -56,7 +56,12 @@ export type RestTestRig = { adapter: RestTestAdapter clock: { now: number } statusEvents: AgentSessionStatusEvent[] - sink: { publish: Mock; forget: Mock } + /** `readChildWork` serves the session's child records, as the host's store does. */ + sink: { + publish: Mock + forget: Mock + readChildWork: Mock<(subject: unknown) => AgentChildWorkView[]> + } /** Opens a fresh host over the same store and journals: what a restart leaves behind. */ restart: (deps?: Partial) => Promise dispose: () => Promise @@ -127,7 +132,11 @@ export async function createRestTestRig( const root = await mkdtemp(join(tmpdir(), 'orca-rest-')) const clock = { now: HOST_TEST_NOW } const statusEvents: AgentSessionStatusEvent[] = [] - const sink = { publish: vi.fn(), forget: vi.fn() } + const sink: RestTestRig['sink'] = { + publish: vi.fn(), + forget: vi.fn(), + readChildWork: vi.fn((): AgentChildWorkView[] => []) + } let store = await openTestAgentSessionRecordStore(root) const adapter: RestTestAdapter = { acquire: vi.fn(async ({ fence, spawnToken }) => ({ @@ -146,7 +155,6 @@ export async function createRestTestRig( closeSession: vi.fn(async () => true), dispatch: vi.fn(async () => acceptedDispatch()), acknowledgeSessionRelease: vi.fn(), - backgroundTaskState: vi.fn(() => undefined), holdsDispatch: vi.fn(() => false), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 3bf122afde3..9bb2173f42f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -12,16 +12,19 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' +import { childRecord } from './structured-agent-session-restart-resume-test-harness' import { adapter, attach, CALLER, envelope, hostTestState, - replaceHostTestState + replaceHostTestState, + serveHostTestChildWork } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, @@ -54,6 +57,10 @@ export async function interruptedRestart( }) ) { const previous = hostTestState() + let children: AgentChildWorkView[] = [] + if (work === 'children') { + serveHostTestChildWork(() => children) + } await attach() const events = previous.acquire.mock.calls[0]?.[0].events if (!events) { @@ -98,10 +105,7 @@ export async function interruptedRestart( ] }) events.appendItem(group, roster('working'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - previous.host.deps.adapter.backgroundTaskState = () => ({ - state: 'monitoring', - tasks: [{ id: 'child-1', kind: 'agent', description: 'Review loop 4', state: 'working' }] - }) + children = [childRecord({ id: 'child-1', kind: 'agent', description: 'Review loop 4' })] // As the real adapters do: the child's own close settles the children it can no longer hear. previous.host.deps.adapter.closeSession = async () => { events.appendItem(group, roster('unverifiable'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index d053caf006b..af2a0970a6f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -87,7 +87,7 @@ export function createStructuredAgentSessionRestartResume( const witnesses = createStructuredAgentSessionRestartWitnesses({ sessions, getRecord: deps.store.getRecord, - backgroundTasks: (sessionId) => deps.adapter.backgroundTaskState?.(sessionId)?.tasks, + childWork: surfaces.readChildWork, ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), teardownId: randomUUID(), now: surfaces.now, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts index e359e31157b..93c33146743 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts @@ -11,6 +11,7 @@ import type { } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { structuredAgentSessionResumableSet } from './structured-agent-session-restart-resume-set' export const SESSION = 'session-working-1' @@ -195,6 +196,21 @@ export function submission( } } +/** A child record as the host's store serves it; live unless the test settles it. */ +export function childRecord( + child: Pick & Partial +): AgentChildWorkView { + return { + state: 'working', + membership: child.state === 'done' || child.state === 'idle' ? 'settled' : 'live', + firstObservedAt: NOW, + observedAt: NOW, + stoppable: false, + invocation: { invocationId: `spawn-${child.id}`, generation: 1 }, + ...child + } +} + /** The epoch the fake journal reports. */ export const EPOCH = 'epoch-1' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 4004502e408..bb9eb500d1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -11,6 +11,7 @@ import type { AgentSessionMutationResult, AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { MAX_TIMER_DELAY_MS } from '../../../shared/timer-delay' import type { SendSettlementWaitOptions } from './structured-agent-session-send-settlement' @@ -30,6 +31,8 @@ export type StructuredAgentSessionRestartResumeSurfaces = { clientMessageId: string ) => Promise<{ value: AgentSessionSendResult } | undefined> onNoteFailed: (sessionId: string, error: unknown) => void + /** The session's child records, the host's one read of them. */ + readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined now: () => number } @@ -60,7 +63,7 @@ type RestartResumeHostBindings = { export function structuredAgentSessionRestartResumeSurfaces( host: RestartResumeHostBindings, now: () => number -): StructuredAgentSessionRestartResumeSurfaces { +): Omit { return { revealSession: host.revealSession, send: (params) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index efcd70345a8..a2ed07db291 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -15,6 +15,7 @@ import { } from './structured-agent-session-restart-resume-runner' import { structuredAgentSessionWorkingAtStop } from './structured-agent-session-working-at-teardown' import { + childRecord, CLAUDE_ROOT, claudeRecord, EPOCH, @@ -52,7 +53,7 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -79,7 +80,7 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'update', teardownId: TEARDOWN_CURRENT, now: NOW @@ -93,7 +94,7 @@ describe('deriving what was working at teardown', () => { markersAtTeardown({ sessions: new Map([[SESSION, { journal: journal([]), child: { fence: 1 } }]]), getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -108,7 +109,7 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -125,7 +126,7 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: null }] ]), getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -148,7 +149,7 @@ describe('deriving what was working at teardown', () => { ] ]), getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -170,9 +171,7 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), - backgroundTasks: () => [ - { id: 'task-a', kind: 'agent', description: 'Review loop 4', state: 'working' } - ], + childWork: () => [childRecord({ id: 'task-a', kind: 'agent', description: 'Review loop 4' })], trigger: 'update', teardownId: TEARDOWN_CURRENT, now: NOW @@ -193,14 +192,15 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), - backgroundTasks: () => [ - { id: 'gone', kind: 'agent', description: 'Finished agent', state: 'done' }, - ...Array.from({ length: 20 }, (_, index) => ({ - id: `live-${index}`, - kind: 'command' as const, - description: `Shell ${index}${'x'.repeat(300)}`, - state: 'working' as const - })) + childWork: () => [ + childRecord({ id: 'gone', kind: 'agent', description: 'Finished agent', state: 'done' }), + ...Array.from({ length: 20 }, (_, index) => + childRecord({ + id: `live-${index}`, + kind: 'command', + description: `Shell ${index}${'x'.repeat(300)}` + }) + ) ], trigger: 'quit', teardownId: TEARDOWN_CURRENT, @@ -218,7 +218,9 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), - backgroundTasks: () => [{ id: 'task-m', kind: 'monitor', name: 'ci-watch' }], + childWork: () => [ + childRecord({ id: 'task-m', kind: 'monitor', name: 'ci-watch', state: 'monitoring' }) + ], trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -235,9 +237,9 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), child: { fence: 1 } }] ]), getRecord: () => record(), - backgroundTasks: () => [ - { id: 'task-a', kind: 'agent', state: 'done' }, - { id: 'task-b', kind: 'command', state: 'idle' } + childWork: () => [ + childRecord({ id: 'task-a', kind: 'agent', state: 'done' }), + childRecord({ id: 'task-b', kind: 'command', state: 'idle' }) ], trigger: 'quit', teardownId: TEARDOWN_CURRENT, @@ -253,7 +255,7 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => claudeRecord(null), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -269,7 +271,7 @@ describe('deriving what was working at teardown', () => { [SESSION, { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }] ]), getRecord: () => record({ chain: [] }), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -292,7 +294,7 @@ describe('deriving what was working at teardown', () => { ] ]), getRecord: () => claudeRecord(null), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -317,7 +319,7 @@ describe('deriving what was working at teardown', () => { ] ]), getRecord: () => claudeRecord(null), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -342,7 +344,7 @@ describe('deriving what was working at teardown', () => { ] ]), getRecord: () => claudeRecord(null), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -375,7 +377,7 @@ describe('deriving what was working at teardown', () => { ] ]), getRecord: () => claudeRecord(null), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW @@ -403,7 +405,7 @@ describe('deriving what was working at teardown', () => { ] ]), getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts index 2ea9e6f57b7..d611bdb1589 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts @@ -1,8 +1,16 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' -import { attach, hostTestState } from './structured-agent-session-host-test-harness' -import { pendingApproval } from './structured-agent-session-restart-resume-test-harness' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { + attach, + hostTestState, + serveHostTestChildWork +} from './structured-agent-session-host-test-harness' +import { + childRecord, + pendingApproval +} from './structured-agent-session-restart-resume-test-harness' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -118,8 +126,10 @@ it.each(['approval', 'question', 'completed'] as const)( } ) -// The roster is read off the live adapter at teardown: eviction clears it moments later. +// The child records are read at teardown: eviction settles them moments later. it('marks a settled chat whose subagent was still running', async () => { + let children: AgentChildWorkView[] = [] + serveHostTestChildWork(() => children) await attach() const { host, root, acquire } = hostTestState() const events = acquire.mock.calls[0]?.[0].events @@ -132,15 +142,12 @@ it('marks a settled chat whose subagent was still running', async () => { { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) - host.deps.adapter.backgroundTaskState = () => ({ - state: 'monitoring', - tasks: [{ id: 'task-a', kind: 'agent', description: 'Review loop 4', state: 'working' }] - }) + children = [childRecord({ id: 'task-a', kind: 'agent', description: 'Review loop 4' })] await host.flushAllStreamedEvents() const [offered] = await new AgentSessionRecoveryCapsule(root).list(NOW) expect(offered?.work).toEqual({ kind: 'turn', id: 'settled' }) - // The description is captured BEFORE the stop, off the roster the sidebar was still showing; - // eviction clears that roster and settles the rows moments later. + // The description is captured BEFORE the stop, off the records the sidebar was still showing; + // eviction settles them moments later. expect(offered?.activity).toEqual({ state: 'done', prompts: [], diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts index 6cd19475d69..2beee1b5a59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts @@ -31,7 +31,7 @@ export type StructuredAgentSessionRestartWitnesses = { export function createStructuredAgentSessionRestartWitnesses(deps: { sessions: ReadonlyMap> getRecord: (sessionId: string) => AgentSessionRecord | null - backgroundTasks: WorkingAtStopInput['backgroundTasks'] + childWork: WorkingAtStopInput['childWork'] capsule?: Pick teardownId: string now: () => number @@ -59,7 +59,7 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { sessionId, session: deps.sessions.get(sessionId), getRecord: deps.getRecord, - backgroundTasks: deps.backgroundTasks, + childWork: deps.childWork, trigger, teardownId: deps.teardownId, now: deps.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 3290ba05f0d..2ea475cf3ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -250,7 +250,8 @@ async function restore(sessionIds: readonly string[]) { serialize: async (_sessionId, task) => task(), open, deliveryActive: () => false, - closeStatus: () => undefined + closeStatus: () => undefined, + readChildWork: () => undefined }) const lifetime = lifetimeOver(sessions, async (sessionId) => sessions.get(sessionId) ?? null) return { sessions, lifetime, lifetimeOver } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index e855ae97ab2..6358cdfe0f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -74,7 +74,7 @@ export async function rewindStructuredAgentSession( ) { return rewindRefusal('outcome-unknown') } - if (conversationCommandBlocked(ctx, record)) { + if (conversationCommandBlocked(ctx, record, context.readChildWork(sessionId))) { return rewindRefusal('busy') } if (ctx.journal.isReadOnly) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts index 819c242fdfb..3fc7dbee0a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { structuredAgentSessionShownStatus } from './structured-agent-session-shown-work' -import { submission } from './structured-agent-session-restart-resume-test-harness' +import { childRecord, submission } from './structured-agent-session-restart-resume-test-harness' describe('whether a session shows as working', () => { // The status feed scopes unanswered sends to the lease fence; a stale one would otherwise offer a @@ -16,7 +16,14 @@ describe('whether a session shows as working', () => { expect( structuredAgentSessionShownStatus( journal, - [{ id: 'watch', kind: 'monitor', description: 'Watch CI', state: 'working' }], + [ + childRecord({ + id: 'watch', + kind: 'monitor', + description: 'Watch CI', + state: 'monitoring' + }) + ], 1 ).state ).not.toBe('done') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts index d50f0570987..4ed41e8c688 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.ts @@ -1,15 +1,15 @@ // Whether a structured session is doing work, asked the one way the product answers it. // -// The status feed publishes the lead's journal status beside the provider's live background roster, -// and the store ingest folds the two into the row the sidebar shows. The quit snapshot composes the +// The status feed publishes the lead's journal status beside the host's child records, and the +// store ingest folds the two into the row the sidebar shows. The quit snapshot composes the // same two inputs through the same fold, so it cannot offer a different set of chats than the one // the user saw working. -import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-background-task-wire' import type { AgentJournalRenderItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { structuredAgentSessionAgentStatus, type StructuredAgentSessionAgentStatus @@ -23,13 +23,13 @@ export function structuredAgentSessionShownStatus( items: readonly AgentJournalRenderItem[] submissions: readonly AgentJournalSubmission[] }, - backgroundTasks: readonly AgentSessionBackgroundTask[] | null | undefined, + childWork: readonly AgentChildWorkView[] | undefined, /** The session's lease fence, as the status feed passes it: a send from an older one is not work. */ fence: number | undefined ): StructuredAgentSessionAgentStatus { const status = projectStructuredAgentSessionStatus(journal.items, journal.submissions, fence) return structuredAgentSessionAgentStatus({ status, - ...(backgroundTasks ? { backgroundTasks: [...backgroundTasks] } : {}) + ...(childWork ? { childWork } : {}) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts new file mode 100644 index 00000000000..10a44729262 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts @@ -0,0 +1,65 @@ +// What the status summary carries of a session's child records, and when a change to them is +// worth re-broadcasting to every session list. +// +// The summary reaches every subscriber, remote ones included, so it carries each child's facts but +// not its per-tick freshness: usage never, and an evidence clock only once it has moved far enough +// to change the minute a "no update" reading shows. The background-task channel, which only an +// open chat subscribes to, carries every tick. + +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { agentChildWorkViewsEqual } from '../../../shared/agent-status-child-work-view-wire' +import { structuredChildWorkLegacyTasks } from '../../../shared/structured-agent-session-child-work-legacy' +import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing' + +/** An evidence clock that advanced by less than this does not re-broadcast a summary. + * Invariant: every reader of the summary's child clocks shows staleness no finer than this (today, + * whole minutes); a reader that needs finer freshness reads the background-task channel, which + * carries every tick. The comparison is index-wise, so it relies on the store's read keeping + * insertion order: a read that sorted would turn every reorder into a broadcast. */ +export const SUMMARY_CHILD_CLOCK_TOLERANCE_MS = 60_000 + +export type StructuredStatusChildWork = { + children?: AgentChildWorkView[] + backgroundTasks?: AgentSessionBackgroundTask[] +} + +/** The summary's child fields: the running views without usage, since the sidebar lists running + * children only, and the live legacy tasks an older client reads, derived from the same views so + * the two cannot disagree. */ +export function structuredStatusChildWork( + views: readonly AgentChildWorkView[] | undefined, + provider: AgentSessionHandleProvider +): StructuredStatusChildWork { + const running = views ? structuredRunningChildWork(views) : [] + if (running.length === 0) { + return {} + } + const children = running.map(({ totalTokens: _totalTokens, ...view }) => view) + // `tasks` only, the live rows: an old client folds every listed task into the parent, and + // reads a failed one's legacy `blocked` as still running. Dies with the legacy shapes; see the + // death condition in `structured-agent-session-child-work-legacy`. + const { tasks } = structuredChildWorkLegacyTasks(children, provider) + return { children, ...(tasks ? { backgroundTasks: tasks } : {}) } +} + +export function structuredStatusChildrenEqual( + a: readonly AgentChildWorkView[] | undefined, + b: readonly AgentChildWorkView[] | undefined +): boolean { + return agentChildWorkViewsEqual(a, b, SUMMARY_CHILD_CLOCK_TOLERANCE_MS) +} + +/** The user's newest send the provider accepted (a message, a steer or a command), keyed by the + * journal epoch: its change is the user's next turn. A turn the provider opens itself, such as + * Claude waking the agent when a background task ends, carries no send, and a subagent's turn is + * never one; a rewind replaces the epoch. */ +export function newestAcceptedSendKey( + epoch: string, + submissions: readonly AgentJournalSubmission[] +): string { + const accepted = submissions.findLast((submission) => submission.dispatchState === 'accepted') + return `${epoch}:${accepted?.clientMessageId ?? ''}` +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts new file mode 100644 index 00000000000..c4e8100c30a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts @@ -0,0 +1,349 @@ +// The status summary reads a session's child records from the sink its row landed in, and derives +// the legacy task list an older client folds from those same records. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { + StructuredAgentSessionStatusFeed, + type StructuredAgentSessionStatusSink +} from './structured-agent-session-status-feed' +import { + indexedStatusFeedSession, + statusFeedChildView as childView +} from './structured-agent-session-status-feed-test-session' + +const SESSION = 'status-session' +const USER_IDENTITY = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 1 +} as const +/** The legacy row an older client reads for `childView()`, derived from it. These sessions are + * Codex's, so a subagent keeps the id its row carried before views. */ +const LEGACY_TASK = { + id: 'codex-agent:task-1', + kind: 'agent', + name: 'deep_review', + state: 'working', + startedAt: 100, + stoppable: true +} as const + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-agent-status-feed-child-records-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function feedWithChildren(provider: AgentSessionHandleProvider = 'codex') { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + stateDirectory: join(root, SESSION) + }) + const views: { current: () => AgentChildWorkView[] } = { current: () => [] } + const admitted: AgentChildWorkEvidence[][] = [] + const changed: string[] = [] + const sink: StructuredAgentSessionStatusSink = { + publish: () => {}, + forget: () => {}, + publishChildWork: (_subject, evidence) => admitted.push(evidence), + readChildWork: () => views.current() + } + let now = 1_000 + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([[SESSION, indexedStatusFeedSession({ journal, provider })]]), + getRecord: () => null, + now: () => (now += 1), + statusSink: () => sink, + onChildWorkChanged: (sessionId) => changed.push(sessionId) + }) + const events: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'list-1', emit: (event) => events.push(event) }) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return { journal, feed, events, views, admitted, changed } +} + +function lastSummary(events: AgentSessionStatusEvent[]): AgentSessionStatusSummary | undefined { + const last = events.at(-1) + return last?.type === 'status' ? last.session : undefined +} + +describe('structured status summary child records', () => { + it('projects live child records and republishes a record-only state change', async () => { + const { journal, feed, events, views: read } = await feedWithChildren() + let views = [childView()] + read.current = () => views + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + feed.publish(SESSION, journal) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ backgroundTasks: [LEGACY_TASK], children: views }) + }) + + // No journal change: only the record's state moved. + views = [childView({ state: 'waiting' })] + const before = events.length + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 1) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ + backgroundTasks: [expect.objectContaining({ state: 'waiting' })] + }) + }) + + // An identical projection is suppressed. + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 1) + }) + + it('omits usage and a ticking evidence clock so a progress tick never re-broadcasts', async () => { + const { journal, feed, events, views: read } = await feedWithChildren() + const operation = { toolName: 'Bash', input: 'npm test', basis: 'reported' as const } + let views = [childView({ totalTokens: 10, operation: { ...operation, observedAt: 100 } })] + read.current = () => views + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + feed.publish(SESSION, journal) + const before = events.length + + // A `task_progress` frame moves usage, which no status-summary reader renders, and the + // child's evidence clocks; re-broadcasting the whole summary per frame would cost every + // remote subscriber. The chat strip's channel carries both per tick. + views = [ + childView({ + totalTokens: 4_200, + observedAt: 59_000, + operation: { ...operation, observedAt: 59_000 } + }) + ] + feed.publish(SESSION, journal) + expect(events).toHaveLength(before) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ + backgroundTasks: [LEGACY_TASK], + children: [childView({ operation: { ...operation, observedAt: 100 } })] + }) + }) + + // The clock re-broadcasts once it moved far enough to change a "no update" minute. + views = [childView({ observedAt: 60_101, operation: { ...operation, observedAt: 60_101 } })] + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 1) + + // What the child is doing, or its state, re-broadcasts at once. + views = [ + childView({ + observedAt: 60_200, + operation: { ...operation, toolName: 'Read', observedAt: 60_200 } + }) + ] + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 2) + views = [childView({ state: 'waiting', observedAt: 60_300 })] + feed.publish(SESSION, journal) + expect(events).toHaveLength(before + 3) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ + backgroundTasks: [expect.objectContaining({ state: 'waiting' })] + }) + }) + }) + + // The sidebar and the strip list running children only; a finished one is on neither. + it('lists only running children on the summary, and no finished one in any of its lists', async () => { + const { feed, events, views } = await feedWithChildren() + const failed = childView({ + id: 'child-2', + providerId: 'task-2', + state: 'done', + membership: 'settled', + outcome: 'failed', + settledAt: 150, + observedAt: 150, + lastMessage: 'Exit code 1' + }) + views.current = () => [childView(), failed] + feed.publish(SESSION) + // An older client reads a failed task's legacy `blocked` as still running. + expect(lastSummary(events)).toMatchObject({ + backgroundTasks: [LEGACY_TASK], + children: [childView()] + }) + views.current = () => [failed] + feed.publish(SESSION) + expect(lastSummary(events)).not.toHaveProperty('children') + expect(lastSummary(events)).not.toHaveProperty('backgroundTasks') + }) + + it('keeps a finished child on the summary while a shell it launched still runs', async () => { + const { feed, events, views } = await feedWithChildren() + const owner = childView({ state: 'done', membership: 'settled', outcome: 'succeeded' }) + const shell = childView({ + id: 'child-2', + providerId: 'task-2', + kind: 'command', + parentChildWorkId: 'child-1' + }) + views.current = () => [owner, shell] + feed.publish(SESSION) + expect(lastSummary(events)?.children).toEqual([owner, shell]) + }) + + it("keeps a Claude child's own id, and a Codex subagent's the id its row always carried", async () => { + const shell = childView({ + id: 'child-2', + providerId: 'codex-command:primary:cmd-1', + kind: 'command' + }) + for (const [provider, agentId] of [ + ['claude', 'task-1'], + ['codex', 'codex-agent:task-1'] + ] as const) { + const { feed, events, views } = await feedWithChildren(provider) + views.current = () => [childView(), shell] + feed.publish(SESSION) + expect(lastSummary(events)?.backgroundTasks?.map((task) => task.id)).toEqual([ + agentId, + 'codex-command:primary:cmd-1' + ]) + // The views name the child as its records do; only the legacy shape is mapped. + expect(lastSummary(events)?.children?.map((view) => view.providerId)).toEqual([ + 'task-1', + 'codex-command:primary:cmd-1' + ]) + await journals.closeAll() + await rm(join(root, SESSION), { recursive: true, force: true }) + } + }) + + it('re-reads the records once child work is admitted, and tells the strip', async () => { + const { feed, events, views, admitted, changed } = await feedWithChildren() + const edge: AgentChildWorkEvidence = { type: 'session-ended', observedAt: 5 } + feed.publish(SESSION) + const before = events.length + views.current = () => [childView()] + feed.publishChildWork(SESSION, [edge]) + expect(admitted).toEqual([[edge]]) + expect(changed).toContain(SESSION) + expect(events).toHaveLength(before + 1) + expect(lastSummary(events)?.children).toEqual([childView()]) + }) + + it("retires finished children at the user's next accepted send, not at a turn nobody sent", async () => { + const { journal, feed, admitted } = await feedWithChildren() + const turn = (turnId: string, state: 'running' | 'completed', agentId?: string) => + journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 0 }, + { kind: 'turn', turnId, state }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...(agentId ? { agentId } : {}) } + ) + const send = async (clientMessageId: string) => { + await journal.appendSubmission({ + clientMessageId, + payloadFingerprint: clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + fence: 1 + }) + feed.publish(SESSION) + } + const accept = async (clientMessageId: string) => { + await journal.resolveDispatch({ + clientMessageId, + state: 'accepted', + providerIdentity: { ...USER_IDENTITY, ordinal: USER_IDENTITY.ordinal + 1 }, + fence: 1 + }) + feed.publish(SESSION) + } + const retirements = () => admitted.flat().filter((edge) => edge.type === 'turn-started') + feed.publish(SESSION) + await send('send-1') + // Written but not taken: the provider may still refuse it. + expect(retirements()).toHaveLength(0) + await accept('send-1') + expect(retirements()).toHaveLength(1) + await turn('turn-1', 'running') + feed.publish(SESSION) + await turn('turn-1', 'completed') + feed.publish(SESSION) + // Neither a subagent's turn nor a turn the provider opened on its own is the user's. + await turn('child-turn', 'running', 'child-thread') + feed.publish(SESSION) + await turn('wake-turn', 'running') + feed.publish(SESSION) + expect(retirements()).toHaveLength(1) + + await send('send-2') + await accept('send-2') + feed.publish(SESSION) + expect(retirements()).toHaveLength(2) + + // A rewind replaces the conversation, as the user's own action. + await journal.replaceEpochItems('handle_forked', 1, []) + feed.publish(SESSION) + expect(retirements()).toHaveLength(3) + }) + + it("forgets a closed session's children in the projection it keeps", async () => { + const { feed, events, views } = await feedWithChildren() + views.current = () => [childView()] + feed.publish(SESSION) + expect(lastSummary(events)?.children).toHaveLength(1) + feed.close(SESSION) + expect(lastSummary(events)).not.toHaveProperty('children') + expect(lastSummary(events)).not.toHaveProperty('backgroundTasks') + }) + + it('stops listing children once the row leaves the sink, even with no close', async () => { + const { feed, events, views } = await feedWithChildren() + views.current = () => [childView()] + feed.publish(SESSION) + expect(lastSummary(events)?.children).toHaveLength(1) + feed.forget(SESSION) + expect(lastSummary(events)).not.toHaveProperty('children') + // A reload reads the retained projection: it must not list what the store no longer holds. + feed.subscribe({ id: 'list-2', emit: (event) => events.push(event) }) + const snapshot = events.at(-1) + expect(snapshot?.type === 'snapshot' ? snapshot.sessions[0] : null).not.toHaveProperty( + 'children' + ) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts index da7275cc641..130f5961bc0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session.ts @@ -1,9 +1,12 @@ +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' -export function indexedStatusFeedSession(session: { +export function indexedStatusFeedSession

(session: { journal: AgentSessionJournal child?: (StructuredAgentSessionProviderChildIdentity & { phase: 'starting' | 'ready' }) | null + provider?: P }) { return { journal: session.journal, @@ -15,7 +18,25 @@ export function indexedStatusFeedSession(session: { workspaceId: 'workspace-1', workspaceKind: 'git-worktree' as const }, - provider: 'codex' as const + provider: session.provider ?? ('codex' as const) } } } + +/** One child record as the views a status sink hands the feed. */ +export function statusFeedChildView(over: Partial = {}): AgentChildWorkView { + return { + id: 'child-1', + providerId: 'task-1', + kind: 'agent', + name: 'deep_review', + agentType: 'deep_review', + state: 'working', + membership: 'live', + firstObservedAt: 100, + observedAt: 100, + stoppable: true, + invocation: { invocationId: 'toolu_1', generation: 1 }, + ...over + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index ad23177bdd3..8062b740bcb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -6,15 +6,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { - AgentSessionBackgroundTask, AgentSessionStatusEvent, AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' import { publishCodexTurnLifecycle } from '../../codex/codex-structured-journal-translation-turns' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' -import { indexedStatusFeedSession as indexed } from './structured-agent-session-status-feed-test-session' +import { + indexedStatusFeedSession as indexed, + statusFeedChildView +} from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionStatusFeed, type StructuredAgentSessionStatusFeedDeps, @@ -67,14 +70,17 @@ function feedFor( sessions: Map[0]>, record: Partial | null = null, onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'], - readBackgroundTasks?: StructuredAgentSessionStatusFeedDeps['readBackgroundTasks'], + readChildWork?: () => AgentChildWorkView[], statusSink?: StructuredAgentSessionStatusSink ) { let now = 1_000 + // The summary reads child records where the host keeps them: the sink its row landed in. + const sink = + statusSink ?? + (readChildWork ? { publish: () => {}, forget: () => {}, readChildWork } : undefined) const feed = new StructuredAgentSessionStatusFeed({ ...(onStatusChanged ? { onStatusChanged } : {}), - ...(statusSink ? { statusSink: () => statusSink } : {}), - ...(readBackgroundTasks ? { readBackgroundTasks } : {}), + ...(sink ? { statusSink: () => sink } : {}), sessions: { get: (sessionId: string) => { const session = sessions.get(sessionId) @@ -638,10 +644,9 @@ describe('StructuredAgentSessionStatusFeed', () => { ) const snapshot = vi.spyOn(journal, 'snapshot') let taskState: 'working' | 'waiting' = 'working' - const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({ - state: 'monitoring', - tasks: [{ id: 'child', kind: 'agent', state: taskState }] - })) + const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => [ + statusFeedChildView({ state: taskState }) + ]) for (let tick = 1; tick <= 100; tick++) { taskState = tick % 2 === 1 ? 'waiting' : 'working' feed.publish(SESSION) @@ -680,90 +685,6 @@ describe('StructuredAgentSessionStatusFeed', () => { feed.publish(SESSION) expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: 'idle' } }) }) - - it('projects live background tasks and republishes a task-only state change', async () => { - const journal = await openJournal() - let tasks = [ - { id: 'task-1', kind: 'agent' as const, name: 'deep_review', state: 'working' as const } - ] - const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({ - state: 'monitoring', - tasks - })) - await journal.appendItem( - USER_IDENTITY, - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - feed.publish(SESSION, journal) - expect(events.at(-1)).toEqual({ - type: 'status', - session: expect.objectContaining({ - backgroundTasks: [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working' }] - }) - }) - - // No journal change: only the task state moved. - tasks = [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'waiting' as never }] - const before = events.length - feed.publish(SESSION, journal) - expect(events).toHaveLength(before + 1) - expect(events.at(-1)).toEqual({ - type: 'status', - session: expect.objectContaining({ - backgroundTasks: [expect.objectContaining({ state: 'waiting' })] - }) - }) - - // An identical projection is suppressed. - feed.publish(SESSION, journal) - expect(events).toHaveLength(before + 1) - }) - - it('omits task usage so a progress tick never re-broadcasts the summary', async () => { - const journal = await openJournal() - let tasks: AgentSessionBackgroundTask[] = [ - { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', totalTokens: 10 } - ] - const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), null, undefined, () => ({ - state: 'monitoring', - tasks - })) - await journal.appendItem( - USER_IDENTITY, - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - feed.publish(SESSION, journal) - const before = events.length - - // A `task_progress` frame moves only usage, which no status-summary reader renders; - // re-broadcasting the whole summary per frame would cost every remote subscriber. - tasks = [ - { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working', totalTokens: 4_200 } - ] - feed.publish(SESSION, journal) - expect(events).toHaveLength(before) - expect(events.at(-1)).toEqual({ - type: 'status', - session: expect.objectContaining({ - backgroundTasks: [{ id: 'task-1', kind: 'agent', name: 'deep_review', state: 'working' }] - }) - }) - - // A state change on the same task still reaches subscribers. - tasks = [ - { id: 'task-1', kind: 'agent', name: 'deep_review', state: 'waiting', totalTokens: 4_200 } - ] - feed.publish(SESSION, journal) - expect(events).toHaveLength(before + 1) - expect(events.at(-1)).toEqual({ - type: 'status', - session: expect.objectContaining({ - backgroundTasks: [expect.objectContaining({ state: 'waiting' })] - }) - }) - }) }) /** 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 22a61a006bb..4f717d2c31b 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 @@ -10,23 +10,24 @@ // each chat is reopened. Restart is the one boundary that forgets, and restoring readable sessions // republishes them. -import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization' -import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types' -import { - agentSessionBackgroundTasksEqual, - type AgentSessionBackgroundTaskState, - type AgentSessionStatusEvent, - type AgentSessionStatusSummary +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' -import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' -import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result' +import { structuredStatusChildWork } from './structured-agent-session-status-child-work' +import { + StructuredAgentSessionJournalProjections, + type StructuredAgentSessionStatusState +} from './structured-agent-session-status-journal-projection' +import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' import { StructuredAgentSessionStatusOwnership, type StructuredAgentSessionStatusSink @@ -34,9 +35,7 @@ import { export type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-ownership' -export type StructuredAgentSessionStatusState = ReturnType< - typeof projectStructuredAgentSessionStatusState -> +export type { StructuredAgentSessionStatusState } from './structured-agent-session-status-journal-projection' export type StructuredAgentSessionStatusSubscriber = { id: string @@ -57,56 +56,14 @@ export type StructuredAgentSessionStatusFeedDeps = { * of state the host already knew (restore, an arriving subscriber) rather than a journal edge. */ onStatusChanged?: (summary: AgentSessionStatusSummary, options: { replay: boolean }) => void /** Resolved on every call: the host builds this feed in a field initializer, before its own - * deps are assigned. */ + * deps are assigned. The sink holds the session's child records; the summary reads them there. */ statusSink?: () => StructuredAgentSessionStatusSink | undefined - /** Live provider-owned background tasks for the summary, so session lists can - * render subagent children. Optional: a provider without the hook projects none. */ - readBackgroundTasks?: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined + /** The session's child records changed, so every other reader of them republishes. */ + onChildWorkChanged?: (sessionId: string) => void /** The session's agent proved a start: its row's phase became `ready`. */ onAgentStarted?: (sessionId: string) => void } -function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean { - return ( - a.workspaceId === b.workspaceId && - a.agent === b.agent && - a.status === b.status && - a.hostExecutionOwned === b.hostExecutionOwned && - a.hostExecutionPhase === b.hostExecutionPhase && - a.hostExecutionChild?.generation === b.hostExecutionChild?.generation && - a.hostExecutionChild?.fence === b.hostExecutionChild?.fence && - a.rewindBlockedReason === b.rewindBlockedReason && - // A moved state clock changes ranking; row activity alone, including a subagent's, does not. - // An idle state the journal cannot date still republishes, since readers date it by `updatedAt`, - // and so does one live child work holds open: readers take each publish as its evidence. - a.statusStartedAt === b.statusStartedAt && - (a.status !== 'idle' || - a.updatedAt === b.updatedAt || - (a.statusStartedAt !== undefined && !isIdleHeldOpenByChildWork(b))) && - a.latestPrompt === b.latestPrompt && - a.model === b.model && - 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) - ) -} - -function isIdleHeldOpenByChildWork(summary: AgentSessionStatusSummary): boolean { - return ( - summary.status === 'idle' && - isAgentStatusHeldOpenByChildWork( - structuredAgentSessionAgentStatus({ - status: summary.status, - backgroundTasks: summary.backgroundTasks, - turnOutcome: summary.turnOutcome - }) - ) - ) -} - /** Wire the host's own deps into a feed; keeps the host at one call site. * `deps` is a thunk because the host builds the feed in a field initializer, * before its constructor parameters are assigned. */ @@ -115,26 +72,22 @@ export function createStructuredAgentSessionHostStatusFeed(args: { now: () => number deps: () => { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } - adapter: { - backgroundTaskState?: ( - sessionId: string - ) => AgentSessionBackgroundTaskState | null | undefined - } onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] statusSink?: StructuredAgentSessionStatusSink } onAgentStarted?: (sessionId: string) => void + onChildWorkChanged?: (sessionId: string) => void }): StructuredAgentSessionStatusFeed { return new StructuredAgentSessionStatusFeed({ sessions: args.sessions, getRecord: (sessionId) => args.deps().store.getRecord(sessionId), now: args.now, onStatusChanged: (summary, options) => args.deps().onSessionStatusChanged?.(summary, options), - readBackgroundTasks: (sessionId) => args.deps().adapter.backgroundTaskState?.(sessionId), // Resolved per call for the same reason the other deps are: the host builds this feed in a // field initializer, before its constructor parameters are assigned. statusSink: () => args.deps().statusSink, - ...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}) + ...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}), + ...(args.onChildWorkChanged ? { onChildWorkChanged: args.onChildWorkChanged } : {}) }) } @@ -144,17 +97,10 @@ export class StructuredAgentSessionStatusFeed { ) private readonly subscribers = new Map() private readonly published = new Map() - // Task progress must not sort and scan an unchanged conversation. Journal identity owns cleanup. - private readonly journalProjections = new WeakMap< - AgentSessionJournal, - { - epoch: string - sequence: number - readOnly: boolean - fence: number | undefined - state: StructuredAgentSessionStatusState - } - >() + /** The user's newest accepted send each session was last projected with; a new one retires + * settled children. */ + private readonly acceptedSends = new Map() + private readonly projections = new StructuredAgentSessionJournalProjections() constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {} @@ -171,19 +117,30 @@ export class StructuredAgentSessionStatusFeed { } /** The host stopped holding the session: ownership leaves the retained projection, and the - * row leaves the sink. `published` keeps the projection for reload history. */ + * row, with every child record under it, leaves the sink. `published` keeps the projection for + * reload history. */ close(sessionId: string): void { this.revokeLive(sessionId) this.forget(sessionId) } - /** The sink lists what is running; a forgotten session must not be in it. */ + /** The sink lists what is running; a forgotten session must not be in it. Its child records + * leave the store with its row, and the retained projection re-reads them like any summary. */ forget(sessionId: string): void { + this.acceptedSends.delete(sessionId) try { this.ownership.forget(sessionId) } catch (error) { console.warn('[structured-session-status] status sink forget failed', error) } + const previous = this.published.get(sessionId) + if (!previous || (!previous.children && !previous.backgroundTasks)) { + return + } + const { children: _children, backgroundTasks: _backgroundTasks, ...rest } = previous + const retained = { ...rest, ...this.childWorkFields(sessionId, previous.agent) } + this.published.set(sessionId, retained) + this.broadcast({ type: 'status', session: retained }) } unsubscribe(id: string): void { @@ -227,7 +184,7 @@ export class StructuredAgentSessionStatusFeed { ): StructuredAgentSessionStatusState | null { const session = this.deps.sessions.get(sessionId) const source = journal ?? session?.journal - return source ? this.projectionFor(source, this.deps.getRecord(sessionId)) : null + return source ? this.projections.read(source, this.deps.getRecord(sessionId)).state : null } /** Re-projects one session after its journal changed; equal projections are not re-sent. */ @@ -236,9 +193,13 @@ export class StructuredAgentSessionStatusFeed { if (!session) { return } - const summary = this.summaryFor(sessionId, session, journal ?? session.journal) + const source = journal ?? session.journal + const record = this.deps.getRecord(sessionId) + const projection = this.projections.read(source, record) + this.retireSettledChildrenOnNewTurn(sessionId, session, projection.acceptedSendKey) + const summary = this.summaryFor(sessionId, session, source, record, projection.state) const previous = this.published.get(sessionId) - if (previous && summariesEqual(previous, summary)) { + if (previous && structuredStatusSummariesEqual(previous, summary)) { if (!this.ownership.matchesLocation(sessionId, session.params.location)) { this.sink(summary, session.params.location) } @@ -258,23 +219,44 @@ export class StructuredAgentSessionStatusFeed { } } + /** A finished child's record stays, with its outcome, until the user's next turn: the next send + * the provider accepts (see `newestAcceptedSendKey`), unless it still owns live work. No surface + * lists it (they list running children only); it stays so a late frame of its run cannot bring + * it back as live, so an acknowledged Stop's provisional ending can be replaced by the task's + * own, and so its live work keeps its owner. The provider ending the session removes nothing: + * children still live settle `unknown`. The one earlier death is the host letting go of the + * session, whose row takes every child record with it (see `forget`). A command never settles: + * its record goes when it stops. */ + private retireSettledChildrenOnNewTurn( + sessionId: string, + session: StatusFeedSession, + acceptedSendKey: string | null + ): void { + // An unreadable journal says nothing about the user's turns: the last send read stands. + if (acceptedSendKey === null) { + return + } + const seen = this.acceptedSends.has(sessionId) + const previous = this.acceptedSends.get(sessionId) + this.acceptedSends.set(sessionId, acceptedSendKey) + if (!seen || acceptedSendKey === previous) { + return + } + this.admitChildWork(sessionId, session, [{ type: 'turn-started', observedAt: this.deps.now() }]) + } + private summaryFor( sessionId: string, session: StatusFeedSession, - journal: AgentSessionJournal + journal: AgentSessionJournal, + record: AgentSessionRecord | null, + state: StructuredAgentSessionStatusState ): AgentSessionStatusSummary { - const record = this.deps.getRecord(sessionId) - const { summary: projected } = this.projectionFor(journal, record) + const projected = state.summary const providerSession = structuredAgentSessionProviderSessionMetadata(record) // The journal has no model: the record's acknowledged options are where a mid-session // switch lands, so the row follows whichever is in force. const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH) - // Usage is dropped here on purpose: a `task_progress` tick would otherwise fail the - // equality check and re-broadcast a full summary to every remote subscriber for a - // number no session list renders. Tokens stay live on the background-task channel. - const backgroundTasks = this.deps - .readBackgroundTasks?.(sessionId) - ?.tasks?.map(({ totalTokens: _totalTokens, ...task }) => task) return { sessionId, workspaceId: session.params.location.workspaceId, @@ -291,58 +273,70 @@ export class StructuredAgentSessionStatusFeed { ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), ...(model ? { model } : {}), - ...(backgroundTasks && backgroundTasks.length > 0 ? { backgroundTasks } : {}), + ...this.childWorkFields(sessionId, session.params.provider), ...(providerSession ? { providerSession } : {}), updatedAt: journal.lastActivityAt() || this.deps.now() } } - /** Child-work evidence for a session this feed publishes; a failing sink costs nothing else. */ + /** The summary's child fields, from the records the store holds for the session. Usage is + * dropped on purpose: a `task_progress` tick would otherwise fail the equality check and + * re-broadcast a full summary to every remote subscriber for a number no session list renders. + * Tokens stay live on the background-task channel. */ + private childWorkFields( + sessionId: string, + provider: StatusFeedSession['params']['provider'] + ): Pick { + const { children, backgroundTasks } = structuredStatusChildWork( + this.readChildWork(sessionId), + provider + ) + return { + ...(backgroundTasks && backgroundTasks.length > 0 + ? { backgroundTasks: backgroundTasks.map(({ totalTokens: _tokens, ...task }) => task) } + : {}), + ...(children ? { children } : {}) + } + } + + /** Child-work evidence for a session this feed publishes; a failing sink costs nothing else. + * The summary then re-reads the records, which is also what re-folds the parent's row. */ publishChildWork(sessionId: string, evidence: AgentChildWorkEvidence[]): void { const session = this.deps.sessions.get(sessionId) - if (!session) { + if (!session || !this.admitChildWork(sessionId, session, evidence)) { return } + this.publish(sessionId) + } + + /** The session's child records as views, once its parent row has landed; the one read every + * surface of them shares. */ + readChildWork(sessionId: string): AgentChildWorkView[] | undefined { + try { + return this.ownership.readChildWork(sessionId) + } catch (error) { + console.warn('[structured-session-status] child work read failed', error) + return undefined + } + } + + private admitChildWork( + sessionId: string, + session: StatusFeedSession, + evidence: AgentChildWorkEvidence[] + ): boolean { try { this.ownership.publishChildWork(sessionId, evidence, session.params.provider) } catch (error) { console.warn('[structured-session-status] child work publish failed', error) + return false } - } - - private projectionFor( - journal: AgentSessionJournal, - record: AgentSessionRecord | null - ): StructuredAgentSessionStatusState { - // An unreadable journal projects as "no turn": the chat itself shows the reset. - const cursor = journal.cursor() - const readOnly = journal.isReadOnly - // The conversation's fence, which a child's end moves: its unanswered sends stop counting. - const fence = record?.lease.runtimeFence - let projection = this.journalProjections.get(journal) - if ( - !projection || - projection.epoch !== cursor.epoch || - projection.sequence !== cursor.sequence || - projection.readOnly !== readOnly || - projection.fence !== fence - ) { - // A journalled submission bumps `lastSequence`, so the send-time working - // signal reaches the cache; the lease fence does not, hence the extra key. - const snapshot = readOnly ? null : journal.snapshot() - projection = { - ...cursor, - readOnly, - fence, - state: projectStructuredAgentSessionStatusState( - snapshot?.items ?? [], - snapshot?.submissions ?? [], - fence - ) - } - this.journalProjections.set(journal, projection) + try { + this.deps.onChildWorkChanged?.(sessionId) + } catch (error) { + console.warn('[structured-session-status] child work observer failed', error) } - return projection.state + return true } /** A failing sink must never cost the subscribers their status event. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts new file mode 100644 index 00000000000..04ce86d36bc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts @@ -0,0 +1,67 @@ +// The status feed's per-journal projection, cached per commit: what a session's journal says its +// row is, and the user's newest send the provider accepted. + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { newestAcceptedSendKey } from './structured-agent-session-status-child-work' + +export type StructuredAgentSessionStatusState = ReturnType< + typeof projectStructuredAgentSessionStatusState +> + +export type StructuredAgentSessionJournalProjection = { + epoch: string + sequence: number + readOnly: boolean + fence: number | undefined + state: StructuredAgentSessionStatusState + /** Null for an unreadable journal, which says nothing about the user's turns. */ + acceptedSendKey: string | null +} + +export class StructuredAgentSessionJournalProjections { + // Task progress must not sort and scan an unchanged conversation. Journal identity owns cleanup. + private readonly byJournal = new WeakMap< + AgentSessionJournal, + StructuredAgentSessionJournalProjection + >() + + read( + journal: AgentSessionJournal, + record: AgentSessionRecord | null + ): StructuredAgentSessionJournalProjection { + // An unreadable journal projects as "no turn": the chat itself shows the reset. + const cursor = journal.cursor() + const readOnly = journal.isReadOnly + // The conversation's fence, which a child's end moves: its unanswered sends stop counting. + const fence = record?.lease.runtimeFence + let projection = this.byJournal.get(journal) + if ( + !projection || + projection.epoch !== cursor.epoch || + projection.sequence !== cursor.sequence || + projection.readOnly !== readOnly || + projection.fence !== fence + ) { + // A journalled submission bumps `lastSequence`, so the send-time working + // signal reaches the cache; the lease fence does not, hence the extra key. + const snapshot = readOnly ? null : journal.snapshot() + projection = { + ...cursor, + readOnly, + fence, + state: projectStructuredAgentSessionStatusState( + snapshot?.items ?? [], + snapshot?.submissions ?? [], + fence + ), + acceptedSendKey: snapshot + ? newestAcceptedSendKey(cursor.epoch, snapshot.submissions ?? []) + : null + } + this.byJournal.set(journal, projection) + } + return projection + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts index e7a270d10c4..faf9022f586 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts @@ -4,6 +4,7 @@ import type { } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { parseAgentStatusSubject, serializeAgentStatusSubject, @@ -22,6 +23,8 @@ export type StructuredAgentSessionStatusSink = { evidence: AgentChildWorkEvidence[], provider: AgentSessionRecord['provider'] ) => void + /** The child records the sink holds for that subject, as the views every surface reads. */ + readChildWork?: (subject: AgentStatusStructuredSessionSubject) => AgentChildWorkView[] } /** Retain the owner address because record removal may precede the final status callback. */ @@ -86,6 +89,12 @@ export class StructuredAgentSessionStatusOwnership { } } + /** Undefined until the parent row has landed: a sink holds no children for a parent it lacks. */ + readChildWork(sessionId: string): AgentChildWorkView[] | undefined { + const subject = this.subjects.get(sessionId) + return subject && this.landed.has(sessionId) ? this.sink()?.readChildWork?.(subject) : undefined + } + forget(sessionId: string): void { const subject = this.subjects.get(sessionId) if (!subject) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts index 091e1f70feb..fff0ae2383b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { AgentHookServer } from '../../agent-hooks/server' @@ -52,7 +53,7 @@ async function createFeed() { string, { journal: AgentSessionJournal - params: { location: AgentSessionExecutionLocation; provider: 'codex' } + params: { location: AgentSessionExecutionLocation; provider: AgentSessionHandleProvider } } >([[SESSION, session]]) const server = new AgentHookServer() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts new file mode 100644 index 00000000000..739d9156c2d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -0,0 +1,57 @@ +// Which status-summary changes reach every session list. The summary is broadcast to remote +// subscribers too, so only a change a session list shows may re-send it. + +import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' +import { + agentSessionBackgroundTasksEqual, + type AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-status-fold' +import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' +import { structuredStatusChildrenEqual } from './structured-agent-session-status-child-work' + +export function structuredStatusSummariesEqual( + a: AgentSessionStatusSummary, + b: AgentSessionStatusSummary +): boolean { + return ( + a.workspaceId === b.workspaceId && + a.agent === b.agent && + a.status === b.status && + a.hostExecutionOwned === b.hostExecutionOwned && + a.hostExecutionPhase === b.hostExecutionPhase && + a.hostExecutionChild?.generation === b.hostExecutionChild?.generation && + a.hostExecutionChild?.fence === b.hostExecutionChild?.fence && + a.rewindBlockedReason === b.rewindBlockedReason && + // A moved state clock changes ranking; row activity alone, including a subagent's, does not. + // An idle state the journal cannot date still republishes, since readers date it by `updatedAt`, + // and so does one live child work holds open: readers take each publish as its evidence. + a.statusStartedAt === b.statusStartedAt && + (a.status !== 'idle' || + a.updatedAt === b.updatedAt || + (a.statusStartedAt !== undefined && !isIdleHeldOpenByChildWork(b))) && + a.latestPrompt === b.latestPrompt && + a.model === b.model && + a.toolName === b.toolName && + a.toolInput === b.toolInput && + a.lastAssistantMessage === b.lastAssistantMessage && + a.turnOutcome === b.turnOutcome && + agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && + structuredStatusChildrenEqual(a.children, b.children) && + agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) + ) +} + +/** Held open by the host's child records, read the way every row reads them. */ +function isIdleHeldOpenByChildWork(summary: AgentSessionStatusSummary): boolean { + return ( + summary.status === 'idle' && + isAgentStatusHeldOpenByChildWork( + structuredAgentSessionAgentStatus({ + status: summary.status, + childWork: summary.children ?? summary.backgroundTasks, + turnOutcome: summary.turnOutcome + }) + ) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index 20b5038f173..dafe54e1790 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -10,10 +10,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentSessionBackgroundTask, - AgentSessionStatusEvent -} from '../../../shared/agent-session-wire' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types' import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' import { AgentHookServer, _internals } from '../../agent-hooks/server' @@ -60,8 +57,7 @@ async function openSession() { now: tick, stateDirectory: join(root, SESSION) }) - // The roster the provider adapter reports, and the host's status row the feed writes into. - const roster: { tasks: AgentSessionBackgroundTask[] } = { tasks: [] } + // The host's status row and child records, which the feed writes into and reads back. const server = new AgentHookServer() const feed = new StructuredAgentSessionStatusFeed({ sessions: new Map([ @@ -75,10 +71,12 @@ async function openSession() { ]), getRecord: () => null, now: () => 1, - readBackgroundTasks: () => ({ state: 'monitoring', tasks: roster.tasks }), statusSink: () => ({ publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), - forget: (subject) => server.dropStructuredStatus(subject) + forget: (subject) => server.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + server.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => server.getStructuredChildWorkViews(subject) }) }) const events: AgentSessionStatusEvent[] = [] @@ -109,7 +107,7 @@ async function openSession() { } return { journal, - roster, + feed, server, projected, tick, @@ -279,7 +277,21 @@ describe("a subagent's work and the recency of the session that spawned it", () } }) ) - session.roster.tasks = [{ id: 'task-1', kind: 'agent', state: 'working' }] + // The spawn's frame lands in the journal before its child's record, as the host orders them. + await session.drain() + session.feed.publishChildWork(SESSION, [ + { + type: 'live', + observedAt: session.tick(), + child: { + handle: { idKind: 'task_id', id: 'task-1', runId: 'toolu_1' }, + kind: 'agent', + residency: 'background', + state: 'working', + stoppable: true + } + } + ]) handle(claudeResult('result-1')) await session.drain() const settled = session.latestStatus() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index eb3784b0a1d..ee6e7bf1179 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -1,4 +1,6 @@ +import { agentChildWorkStopTargets } from '../../../shared/agent-child-work-stop-targets' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' @@ -43,6 +45,8 @@ export async function performCancel( stopChild?: () => Promise /** The host already withdrew queued messages for this Stop. */ withdrewQueued?: boolean + /** The session's child records: a background Stop reaches the tasks they offer a stop. */ + childWork?: () => readonly AgentChildWorkView[] | undefined } ): Promise> { if (input.prompt) { @@ -77,7 +81,7 @@ export async function performCancel( await ctx.adapter.stopBackgroundTasks?.({ sessionId: ctx.sessionId, fence: ctx.fence, - ...(input.taskId ? { taskId: input.taskId } : {}) + taskIds: agentChildWorkStopTargets(input.childWork?.(), input.taskId) }) )?.cancelled === true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 2a9ed84012b..7c5ecf1ee15 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -196,14 +197,20 @@ describe('performCancel', () => { const result = await performCancel(ctx, { clientOperationId: 'cancel-background-tasks', turnId: 'background-tasks', - scope: 'background-tasks' + scope: 'background-tasks', + childWork: () => [liveTask('task-1'), liveTask('task-2', { stoppable: false })] }) expect(result).toEqual({ ok: true, value: { turnId: 'background-tasks', cancelled: true } }) - expect(stopBackgroundTasks).toHaveBeenCalledWith({ sessionId: 'session-1', fence: 1 }) + // Every task the records offer a stop, and nothing else. + expect(stopBackgroundTasks).toHaveBeenCalledWith({ + sessionId: 'session-1', + fence: 1, + taskIds: ['task-1'] + }) expect(cancelTurn).not.toHaveBeenCalled() expect(journal.snapshot().items).toEqual([]) }) @@ -229,7 +236,8 @@ describe('performCancel', () => { clientOperationId: 'cancel-background-task-2', turnId: 'background-tasks', scope: 'background-tasks', - taskId: 'task-2' + taskId: 'task-2', + childWork: () => [liveTask('task-1'), liveTask('task-2')] }) expect(result).toEqual({ @@ -239,13 +247,32 @@ describe('performCancel', () => { expect(stopBackgroundTasks).toHaveBeenCalledWith({ sessionId: 'session-1', fence: 1, - taskId: 'task-2' + taskIds: ['task-2'] }) expect(cancelTurn).not.toHaveBeenCalled() expect(journal.snapshot().items).toEqual([]) }) }) +/** A live child record the strip would offer a stop, named by the provider as `providerId`. */ +function liveTask( + providerId: string, + overrides: Partial = {} +): AgentChildWorkView { + return { + id: `child-${providerId}`, + providerId, + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 1, + observedAt: 1, + stoppable: true, + invocation: { invocationId: `spawn-${providerId}`, generation: 1 }, + ...overrides + } +} + describe('what a conversation Stop reports when the provider stopped nothing', () => { async function cancelWith( outcome: Awaited>, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index c3186d00daf..11647f2eefd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -28,7 +28,6 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-background-task-wire' import { AGENT_SESSION_RESTART_ACTIVITY_MAX_LABEL_LENGTH, AGENT_SESSION_RESTART_ACTIVITY_MAX_PROMPTS, @@ -38,6 +37,7 @@ import { type AgentSessionRestartTask } from '../../../shared/agent-session-restart-activity' import { isLiveChildWork } from '../../../shared/agent-status-child-work-liveness' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { @@ -148,14 +148,14 @@ function pendingPrompts(items: readonly AgentJournalRenderItem[]): AgentSessionR return prompts } -/** The live rows of the provider's roster, by the same liveness rule the sidebar's fold counts. */ +/** The live child records, by the same liveness rule the sidebar's fold counts. */ function liveTasks( - roster: readonly AgentSessionBackgroundTask[] | null | undefined + childWork: readonly AgentChildWorkView[] | undefined ): AgentSessionRestartTask[] { - return (roster ?? []) - .filter((task) => isLiveChildWork(task)) + return (childWork ?? []) + .filter((child) => isLiveChildWork(child)) .slice(0, AGENT_SESSION_RESTART_ACTIVITY_MAX_TASKS) - .map((task) => ({ kind: task.kind, label: boundedLabel(task.description ?? task.name) })) + .map((child) => ({ kind: child.kind, label: boundedLabel(child.description ?? child.name) })) } type WorkingCandidateSession = { @@ -170,8 +170,8 @@ export function structuredAgentSessionWorkingAtStop(input: { sessionId: string session: WorkingCandidateSession | undefined getRecord: (sessionId: string) => AgentSessionRecord | null - /** The provider's live child roster, the same one the status feed publishes. */ - backgroundTasks: (sessionId: string) => readonly AgentSessionBackgroundTask[] | null | undefined + /** The session's child records, the same read the status feed publishes. */ + childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined trigger: AgentSessionResumeTrigger /** Stable teardown identity for continuation deduplication, not launch ancestry. */ teardownId: string @@ -187,10 +187,10 @@ export function structuredAgentSessionWorkingAtStop(input: { const handedOver = snapshot.submissions.filter( (submission) => !isQueuedAgentJournalSubmission(submission) ) - const roster = input.backgroundTasks(sessionId) + const childWork = input.childWork(sessionId) const status = structuredAgentSessionShownStatus( { items: snapshot.items, submissions: handedOver }, - roster, + childWork, session.child.fence ) if (status.state === 'done') { @@ -208,7 +208,7 @@ export function structuredAgentSessionWorkingAtStop(input: { // and carries them in `tasks`, which is how the dialog tells the two apart. state: status.mainAgent.state, prompts: pendingPrompts(snapshot.items), - tasks: liveTasks(roster) + tasks: liveTasks(childWork) } return { sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts index 0acdb2dbbce..5c2ff366243 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -1,56 +1,111 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' -import { conversationCommandBlocked } from './structured-conversation-command-admission' -import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { + conversationCommandBlocked, + type ConversationCommandAdmissionContext +} from './structured-conversation-command-admission' +import type { AgentSessionBackgroundTaskStops } from '../../../shared/agent-child-work-stop-targets' + +const TARGETED: AgentSessionBackgroundTaskStops = { supportsTaskStop: true, supportsStopAll: true } +const UNTARGETED: AgentSessionBackgroundTaskStops = { + supportsTaskStop: false, + supportsStopAll: true +} +const NO_STOP: AgentSessionBackgroundTaskStops = { supportsTaskStop: false, supportsStopAll: false } function contextWith( - backgroundTasks: AgentSessionBackgroundTaskState | null -): AgentSessionTurnContext { + stops: AgentSessionBackgroundTaskStops | undefined +): ConversationCommandAdmissionContext { + const adapter = { + backgroundTaskStops: () => stops, + // The provider tracker's roster, claiming live work: admission must not consult it. + backgroundTaskState: () => ({ state: 'monitoring', tasks: [{ id: 'tracker-only' }] }) + } return { sessionId: 'session-1', - journal: { - snapshot: () => ({ items: [] }), - submissions: () => [] - }, - adapter: { backgroundTaskState: () => backgroundTasks } - } as unknown as AgentSessionTurnContext + fence: 1, + journal: { snapshot: () => ({ items: [] }), submissions: () => [] }, + adapter + } } +function child(overrides: Partial = {}): AgentChildWorkView { + return { + id: 'child-1', + providerId: 'task-1', + kind: 'command', + description: 'npm run dev', + state: 'working', + membership: 'live', + firstObservedAt: 100, + observedAt: 200, + stoppable: true, + invocation: { invocationId: 'spawn-1', generation: 1 }, + ...overrides + } +} + +// A finished subagent: a command's record goes when it stops, so only a child that settles lists. +const SETTLED = child({ + id: 'child-0', + kind: 'agent', + description: 'review', + state: 'done', + membership: 'settled', + outcome: 'succeeded', + settledAt: 300 +}) const RECORD = { lease: {} } as unknown as AgentSessionRecord +const STOP = 'Stop background tasks before using this command.' +const WAIT = 'Wait for background tasks to finish before using this command.' -describe('conversationCommandBlocked background tasks', () => { - it('admits the command when nothing is being monitored', () => { - expect(conversationCommandBlocked(contextWith(null), RECORD)).toBeNull() +describe('conversationCommandBlocked background work', () => { + it('admits the command when the strip lists nothing, whatever the provider tracker holds', () => { + expect(conversationCommandBlocked(contextWith(TARGETED), RECORD, [])).toBeNull() + // The parent row has not landed, so the strip has no records to show either. + expect(conversationCommandBlocked(contextWith(TARGETED), RECORD, undefined)).toBeNull() }) - it('asks for a stop when the host accepts targeted stops', () => { - const blocked = conversationCommandBlocked( - contextWith({ state: 'monitoring', supportsTaskStop: true }), - RECORD - ) - expect(blocked?.message).toBe('Stop background tasks before using this command.') + it('admits the command when the strip lists only finished children', () => { + expect(conversationCommandBlocked(contextWith(TARGETED), RECORD, [SETTLED])).toBeNull() }) - it('asks for a stop on a host that predates the stop-capability field', () => { - const blocked = conversationCommandBlocked(contextWith({ state: 'monitoring' }), RECORD) - expect(blocked?.message).toBe('Stop background tasks before using this command.') + it('asks for a stop when the strip offers one on a live row', () => { + expect( + conversationCommandBlocked(contextWith(TARGETED), RECORD, [SETTLED, child()])?.message + ).toBe(STOP) + }) + + it('asks for a stop when the strip offers its single untargeted stop', () => { + const live = child({ stoppable: false }) + expect(conversationCommandBlocked(contextWith(UNTARGETED), RECORD, [live])?.message).toBe(STOP) + }) + + it('asks the user to wait when no live row on the strip has a stop', () => { + // A targeted-stop provider renders no untargeted stop, so these rows offer nothing. + const unstoppable = child({ stoppable: false }) + const unaddressable = child({ id: 'child-2', providerId: undefined }) + expect( + conversationCommandBlocked(contextWith(TARGETED), RECORD, [unstoppable, unaddressable]) + ?.message + ).toBe(WAIT) }) it('asks the user to wait when the provider exposes no stop at all', () => { // Codex: an instruction to stop would name a control that does not exist. - const blocked = conversationCommandBlocked( - contextWith({ state: 'monitoring', supportsStopAll: false }), - RECORD + expect(conversationCommandBlocked(contextWith(NO_STOP), RECORD, [child()])?.message).toBe(WAIT) + // No live provider session answers for it either. + expect(conversationCommandBlocked(contextWith(undefined), RECORD, [child()])?.message).toBe( + WAIT ) - expect(blocked?.message).toBe('Wait for background tasks to finish before using this command.') }) it('still refuses on the open turn, not on the work the strip now shows', () => { // The strip reports subagents while a turn runs. That must not change which // refusal the user sees: an open turn already refuses, and it refuses first, // so a live fan-out never re-labels the reason or blocks anything new. - const ctx = contextWith({ state: 'monitoring', supportsTaskStop: true }) + const ctx = contextWith(TARGETED) ctx.journal.snapshot = () => ({ items: [ @@ -63,7 +118,7 @@ describe('conversationCommandBlocked background tasks', () => { } ] }) as unknown as ReturnType - expect(conversationCommandBlocked(ctx, RECORD)?.message).toBe( + expect(conversationCommandBlocked(ctx, RECORD, [child()])?.message).toBe( 'Wait for the current turn to finish before using this command.' ) }) @@ -75,11 +130,11 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => } it("does not refuse over a dead generation's running turn, which the start sweeps", () => { - const ctx = contextWith(null) + const ctx = contextWith(undefined) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only each item's body. ctx.journal.snapshot = () => staleTurn as never - expect(conversationCommandBlocked(ctx, RECORD, 'at-rest')).toBeNull() - expect(conversationCommandBlocked(ctx, RECORD)).toMatchObject({ + expect(conversationCommandBlocked(ctx, RECORD, [], 'at-rest')).toBeNull() + expect(conversationCommandBlocked(ctx, RECORD, [])).toMatchObject({ details: { reason: 'turnActive' }, message: 'Wait for the current turn to finish before using this command.' }) @@ -91,7 +146,7 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => lease: {}, conversationCommand: { command: 'compact', phase: 'prepared', state: 'unknown' } } as unknown as AgentSessionRecord - expect(conversationCommandBlocked(contextWith(null), record)).toBeNull() + expect(conversationCommandBlocked(contextWith(undefined), record, [])).toBeNull() }) // A clear's commit is its only durable write, so a record short of it never changed the chat. @@ -106,7 +161,7 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => replacementSessionId: 'clear-replacement' } } as unknown as AgentSessionRecord - expect(conversationCommandBlocked(contextWith(null), record)).toBeNull() + expect(conversationCommandBlocked(contextWith(undefined), record, [])).toBeNull() }) it('refuses on a committed clear', () => { @@ -120,20 +175,20 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => replacementSessionId: 'clear-replacement' } } as unknown as AgentSessionRecord - expect(conversationCommandBlocked(contextWith(null), record)).toMatchObject({ + expect(conversationCommandBlocked(contextWith(undefined), record, [])).toMatchObject({ code: 'agent_session_operation_invalid', details: { reason: 'conversationCleared' } }) }) it('at handover, lets the command itself and messages queued behind it wait', () => { - const ctx = contextWith(null) + const ctx = contextWith(undefined) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the dispatch fields. const queued = [ { clientMessageId: 'command', dispatchState: 'pending', handoverRecorded: true }, { clientMessageId: 'behind', dispatchState: 'pending', handoverRecorded: true } ] as never ctx.journal.submissions = () => queued - expect(conversationCommandBlocked(ctx, RECORD, 'handover')).toBeNull() + expect(conversationCommandBlocked(ctx, RECORD, [], 'handover')).toBeNull() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index 937f63733c5..86bf4994692 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts @@ -1,13 +1,31 @@ +import { + agentChildWorkViewOffersStop, + type AgentSessionBackgroundTaskStops +} from '../../../shared/agent-child-work-stop-targets' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' -import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { refuse, type AgentSessionRefusalReason, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals' +/** What admission reads of a turn: the journal, its fence, and only the provider's stop capability. */ +export type ConversationCommandAdmissionContext = { + sessionId: string + fence: number + journal: { + snapshot(): Pick, 'items'> + submissions: AgentSessionJournal['submissions'] + } + adapter: Pick +} + function blocked( reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>, message: string @@ -22,13 +40,16 @@ export function conversationCommandInFlight(): AgentSessionWireRefusal { /** * Why a conversation command may not run now; null when it may. * + * `childWork` is the session's child records as the chat strip reads them: a refusal may only + * cite work the strip lists, and ask for a stop only when the strip offers one. * `at-rest`: a command accepted with no child running. A running turn on record then belongs to a * dead generation, which the start before handover sweeps, so it refuses nothing yet. * `handover`: the command is the oldest queued message, and those queued behind it wait for it. */ export function conversationCommandBlocked( - ctx: Pick, + ctx: ConversationCommandAdmissionContext, record: AgentSessionRecord, + childWork: readonly AgentChildWorkView[] | undefined, admission?: 'at-rest' | 'handover' ): AgentSessionWireRefusal | null { const items = ctx.journal.snapshot().items @@ -63,14 +84,11 @@ export function conversationCommandBlocked( 'Resolve the pending question or approval before using this command.' ) } - const backgroundTasks = ctx.adapter.backgroundTaskState?.(ctx.sessionId) - if (backgroundTasks?.state === 'monitoring') { - // Only ask for a stop the host can actually perform. A provider that - // exposes neither a targeted nor an untargeted stop would otherwise leave - // the command refused behind an instruction nobody can follow. + // The same liveness fold the strip's monitoring indicator reads: settled rows block nothing. + if (agentChildWorkLiveness(childWork) !== null) { return blocked( 'backgroundTasksRunning', - backgroundTasks.supportsTaskStop || backgroundTasks.supportsStopAll !== false + stripOffersStop(childWork ?? [], ctx.adapter.backgroundTaskStops?.(ctx.sessionId)) ? 'Stop background tasks before using this command.' : 'Wait for background tasks to finish before using this command.' ) @@ -91,3 +109,17 @@ export function conversationCommandBlocked( } return null } + +/** The strip's own stop controls: a per-row stop where the provider can target one, else its + * single untargeted stop. Asking for a stop it does not render names a control nobody can use. */ +function stripOffersStop( + childWork: readonly AgentChildWorkView[], + stops: AgentSessionBackgroundTaskStops | undefined +): boolean { + if (!stops) { + return false + } + return stops.supportsTaskStop + ? childWork.some(agentChildWorkViewOffersStop) + : stops.supportsStopAll +} diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts new file mode 100644 index 00000000000..c44236ce34b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts @@ -0,0 +1,180 @@ +// A conversation command is refused for background work only when the chat strip lists that work: +// both read the host's child records through the one sink read. The provider tracker's own roster +// is present and claims live work throughout; nothing here may be decided by it. + +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { structuredAgentSessionCommandTurn } from './structured-agent-session-command-turn' +import { + attach, + CALLER, + envelope, + hostTestState, + serveHostTestChildWork +} from './structured-agent-session-host-test-harness' +import { HOST_TEST_NOW, HOST_TEST_SESSION } from './structured-agent-session-host-test-data' + +let state: ReturnType +let compact: Mock> +/** What the store holds for the session; the sink serves it to every reader. */ +let records: AgentChildWorkView[] = [] + +function compactParams() { + return { + command: 'compact' as const, + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) + } +} + +function devServer(overrides: Partial = {}): AgentChildWorkView { + return { + id: 'child-dev', + providerId: 'task-dev', + kind: 'command', + description: 'npm run dev', + state: 'working', + membership: 'live', + firstObservedAt: HOST_TEST_NOW, + observedAt: HOST_TEST_NOW, + stoppable: true, + invocation: { invocationId: 'spawn-dev', generation: 1 }, + ...overrides + } +} + +/** The provider ends the command's turn, as the child's journal translator does. */ +function finish(): void { + const { command } = compact.mock.calls.at(-1)![0] + const events = state.acquire.mock.calls.at(-1)![0].events! + events.appendLifecycleBatch!( + `turn-completed:${command.clientMessageId}`, + [ + { + kind: 'item', + identity: command.identity, + body: { ...command.running, state: 'completed', outcome: 'success' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { lifecycle: true } + ) +} + +async function commandTurnState(clientMessageId: string): Promise { + const { itemId } = structuredAgentSessionCommandTurn(clientMessageId) + const item = (await state.host.journalSnapshot(HOST_TEST_SESSION)).items.find( + (entry) => entry.itemId === itemId + ) + return item?.body.kind === 'turn' ? item.body.state : undefined +} + +beforeEach(() => { + records = [] + state = hostTestState() + compact = vi.fn(async () => ({ state: 'accepted' as const, providerIdentity: null })) + const trackerRoster: AgentSessionBackgroundTaskState = { + state: 'monitoring', + tasks: [{ id: 'tracker-only', kind: 'command', description: 'not on the strip' }] + } + Object.assign(state.host.deps.adapter, { + compact, + backgroundTaskStops: () => ({ supportsTaskStop: true, supportsStopAll: true }), + // A tracker that drifted from the records: it still claims work the strip does not list. + backgroundTaskState: () => trackerRoster + }) + serveHostTestChildWork(() => records) +}) + +describe('conversation command admission reads the strip’s child records', () => { + it('refuses only while the strip lists live work, and names the stop the strip offers', async () => { + await attach() + const strip: (AgentSessionBackgroundTaskState | null)[] = [] + await state.host.subscribe({ + id: 'strip', + sessionId: HOST_TEST_SESSION, + emit: (event) => { + if ('backgroundTasks' in event && event.backgroundTasks !== undefined) { + strip.push(event.backgroundTasks) + } + } + }) + const stripRows = () => + (strip.at(-1)?.children ?? []).map((row) => ({ + description: row.description, + membership: row.membership, + stoppable: row.stoppable, + providerId: row.providerId + })) + + // Nothing on the strip: the drifted tracker's roster refuses nothing. + expect(stripRows()).toEqual([]) + const first = compactParams() + expect(await state.host.conversationCommand(CALLER, first)).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1)) + finish() + await vi.waitFor(async () => + expect(await commandTurnState(first.envelope.clientOperationId)).toBe('completed') + ) + + records = [devServer()] + state.host.publishChildWorkEvidence(HOST_TEST_SESSION, []) + expect(stripRows()).toEqual([ + { description: 'npm run dev', membership: 'live', stoppable: true, providerId: 'task-dev' } + ]) + expect(await state.host.conversationCommand(CALLER, compactParams())).toMatchObject({ + ok: false, + refusal: { message: 'Stop background tasks before using this command.' } + }) + + records = [devServer({ state: 'done', membership: 'settled', outcome: 'succeeded' })] + state.host.publishChildWorkEvidence(HOST_TEST_SESSION, []) + // Finished: the strip hides (it lists running children only), and the record blocks nothing. + expect(strip.at(-1)).toBeNull() + expect(stripRows()).toEqual([]) + expect(await state.host.conversationCommand(CALLER, compactParams())).toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(2)) + }) + + it('refuses a queued command at handover once the strip lists live work', async () => { + const acquireChild = state.acquire.getMockImplementation()! + // Each child names its generation, so the exit below ends exactly the one it names. + state.acquire.mockImplementation(async (input) => ({ + ...(await acquireChild(input)), + acquisitionGeneration: `generation-${input.fence}` + })) + await attach() + // The provider exits, so the next command is accepted at rest and waits for a new start. + const fence = state.store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence + await state.host.handleAdapterEvent({ + type: 'ended', + sessionId: HOST_TEST_SESSION, + fence, + acquisitionGeneration: `generation-${fence}`, + reason: 'provider exited', + cause: 'unexpected-exit' + }) + await vi.waitFor(() => expect(state.host['sessions'].get(HOST_TEST_SESSION)?.child).toBeNull()) + // The records gain live work while that start runs, after the command was accepted. + const start = state.acquire.getMockImplementation()! + state.acquire.mockImplementationOnce(async (input) => { + records = [devServer()] + return start(input) + }) + const params = compactParams() + expect(await state.host.conversationCommand(CALLER, params)).toMatchObject({ ok: true }) + + await vi.waitFor(async () => { + const submission = (await state.host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + expect(submission?.dispatchState).toBe('rejected') + }) + expect(state.acquire).toHaveBeenCalledTimes(2) + expect(compact).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 065ead6e343..bbc7aea52fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -177,7 +177,7 @@ export function runStructuredConversationCommand( run: async (ctx) => { await host.flushStreamedEvents(sessionId) const record = store.getRecord(sessionId)! - const blocked = conversationCommandBlocked(ctx, record) + const blocked = conversationCommandBlocked(ctx, record, context.readChildWork(sessionId)) if (blocked) { return { ok: false, refusal: blocked } } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts index 7ad7f9bb1bc..066afdc08db 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts @@ -140,6 +140,7 @@ function acceptStructuredConversationCommand( conversationCommandBlocked( ctx, record, + context.readChildWork(ctx.sessionId), context.sessions.get(ctx.sessionId)?.child ? undefined : 'at-rest' ) const blocked = diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 872fbb0a127..6c0e9bbad22 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -233,7 +233,8 @@ async function startOrcadRuntime( publish: (summary, subject) => agentHookServer.ingestStructuredStatus(summary, subject), forget: (subject) => agentHookServer.dropStructuredStatus(subject), publishChildWork: (subject, evidence, provider) => - agentHookServer.ingestStructuredChildWork(subject, evidence, provider) + agentHookServer.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject) }, checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 3de3fecc16f..dc88bf858af 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -15,6 +15,7 @@ import { CLAUDE_SPAWN_TOKEN_ENV } from '../claude/claude-structured-owner-identi import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { AgentHookServer, _internals } from '../agent-hooks/server' import type { OrcaRuntimeService } from './orca-runtime' import type { RpcRequest, RpcResponse } from './rpc/core' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' @@ -138,6 +139,7 @@ let shellEnv: NodeJS.ProcessEnv let shellEnvironmentPolicy: NativeChatShellEnvironmentPolicy /** What the host handed its status sink as child work. */ let childWork: Parameters>[] +let hookServer: AgentHookServer async function call(method: string, params: unknown): Promise { const replies: RpcResponse[] = [] @@ -218,6 +220,8 @@ beforeEach(async () => { claude = fakeClaude(PROVIDER_SESSION) cleanups = new Map() childWork = [] + _internals.resetCachesForTests() + hookServer = new AgentHookServer() const runtime = { getRuntimeId: () => 'runtime-1', getClientSettings: () => ({ experimentalStructuredNativeChat: true }), @@ -243,10 +247,15 @@ beforeEach(async () => { resolveShellEnvironmentPolicy: () => shellEnvironmentPolicy, resolveClaudeAuthPolicy: () => claudeAuthPolicy, openClaudeConnection: claude.openConnection, + // Production's sink wiring onto a real hook server, whose records a Stop reaches. statusSink: { - publish: () => {}, - forget: () => {}, - publishChildWork: (...args) => childWork.push(args) + publish: (summary, subject) => hookServer.ingestStructuredStatus(summary, subject), + forget: (subject) => hookServer.dropStructuredStatus(subject), + publishChildWork: (...args) => { + childWork.push(args) + hookServer.ingestStructuredChildWork(...args) + }, + readChildWork: (subject) => hookServer.getStructuredChildWorkViews(subject) } }).then(() => undefined), registerSubscriptionCleanup: (id: string, dispose: () => void) => cleanups.set(id, dispose), @@ -656,16 +665,22 @@ describe('a structured Claude session over agentSession.*', () => { }) await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) - claude.live().handlers.onMessage?.({ - type: 'system', - subtype: 'background_tasks_changed', - session_id: PROVIDER_SESSION, - uuid: 'background-roster', - tasks: [ - { task_id: 'task-one', task_type: 'local_agent', description: 'First task' }, - { task_id: 'task-two', task_type: 'local_bash', description: 'Second task' } - ] - }) + for (const [taskId, taskType, description] of [ + ['task-one', 'local_agent', 'First task'], + ['task-two', 'local_bash', 'Second task'] + ]) { + claude.live().handlers.onMessage?.({ + type: 'system', + subtype: 'task_started', + session_id: PROVIDER_SESSION, + uuid: `started-${taskId}`, + task_id: taskId, + task_type: taskType, + description, + is_backgrounded: true + }) + } + await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) const itemsBeforeTaskStop = itemsOf(stream) const targetedStopFields = { turnId: 'background-tasks', @@ -682,6 +697,16 @@ describe('a structured Claude session over agentSession.*', () => { { subtype: 'stop_task', params: { taskId: 'task-two' } } ]) expect(itemsOf(stream)).toEqual(itemsBeforeTaskStop) + // The acknowledged stop ends the record it reached, and only that one. + const subject = childWork.at(-1)![0] + expect( + hookServer + .getStructuredChildWorkViews(subject) + .map((view) => [view.providerId, view.membership, view.outcome]) + ).toEqual([ + ['task-one', 'live', undefined], + ['task-two', 'settled', 'cancelled'] + ]) const staleStopFields = { turnId: 'background-tasks', diff --git a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts index 8b048f168d4..b931af57463 100644 --- a/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts +++ b/src/main/runtime/orca-runtime-structured-status-sink-wiring.test.ts @@ -69,6 +69,10 @@ describe('every host that constructs a runtime wires the agent-status store', () // A sink without it leaves the host holding no child records for that entry point. expect(construction).toContain('publishChildWork: (subject, evidence, provider) =>') expect(construction).toContain('ingestStructuredChildWork(subject, evidence, provider)') + // Without it the summary and the chat strip read no child records on that entry point. + expect(construction).toContain( + 'readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject)' + ) } ) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts index 8a9e2267a5a..34f8aa8220d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionBackgroundTaskState } from '../../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../../../shared/agent-session-background-task-child-views-capability' import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities' import type { AgentSessionSubscribeInput } from '../../../native-chat/agent-session-wire/structured-agent-session-subscribers' import { @@ -170,4 +172,81 @@ describe('background-task stop capability at the RPC boundary', () => { ).toMatchObject({ ok: true, result: { page: { backgroundTasks: stoppable } } }) } }) + + it('advertises reading child views on remote requests and subscriptions', () => { + expect(CURRENT_CLIENT.clientCapabilities).toContain( + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY + ) + }) +}) + +describe('child views at the RPC boundary', () => { + const view = (membership: 'live' | 'settled'): AgentChildWorkView => ({ + id: `child-${membership}`, + providerId: `task-${membership}`, + kind: 'agent', + state: membership === 'live' ? 'working' : 'done', + membership, + ...(membership === 'settled' ? { outcome: 'succeeded' as const, settledAt: 2 } : {}), + firstObservedAt: 1, + observedAt: 2, + stoppable: true, + invocation: { invocationId: `toolu-${membership}`, generation: 1 } + }) + const LIVE_ROW = { id: 'task-live', kind: 'agent', state: 'working' } as const + const SETTLED_ROW = { id: 'task-settled', kind: 'agent', state: 'done' } as const + /** Predates child views: reads any roster as live work. */ + const PRE_VIEWS_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: remoteRuntimeClientCapabilities( + STRUCTURED_CLIENT.clientCapabilities + ).filter((capability) => capability !== AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY) + } + + async function historyFor( + backgroundTasks: AgentSessionBackgroundTaskState, + client: Parameters[2] + ) { + hostCalls.history.mockReturnValue({ ok: true, page: { items: [], backgroundTasks } }) + return call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client) + } + + it('hands a reader that predates views its live roster, without the views', async () => { + const mixed: AgentSessionBackgroundTaskState = { + state: 'monitoring', + supportsTaskStop: true, + tasks: [LIVE_ROW], + settledTasks: [SETTLED_ROW], + children: [view('live'), view('settled')] + } + const result = await historyFor(mixed, PRE_VIEWS_CLIENT) + expect(result).toMatchObject({ + ok: true, + result: { + page: { + backgroundTasks: { + state: 'monitoring', + supportsTaskStop: true, + tasks: [LIVE_ROW], + settledTasks: [SETTLED_ROW] + } + } + } + }) + expect(JSON.stringify(result)).not.toContain('children') + }) + + // A reader draws a per-row stop only when the host offers a targeted one, so a roster with no + // targeted stop (a Codex child, a persistent command) has no dead button to withhold. + it('keeps unstoppable rows for a stop-only reader when no targeted stop is offered', async () => { + const codexRoster: AgentSessionBackgroundTaskState = { + state: 'monitoring', + supportsStopAll: false, + tasks: [{ id: 'codex-agent:thread-a', kind: 'agent', state: 'working', stoppable: false }] + } + expect(await historyFor(codexRoster, STOP_ONLY_CLIENT)).toMatchObject({ + ok: true, + result: { page: { backgroundTasks: codexRoster } } + }) + }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts index ae69de4a069..1e32325e53b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts @@ -7,6 +7,7 @@ import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../../../shared/agent-session-background-task-child-views-capability' import type { RpcContext } from '../core' type BackgroundTaskReader = Pick @@ -25,15 +26,33 @@ function honoursRowStop(ctx: BackgroundTaskReader): boolean { ) } +function readsChildViews(ctx: BackgroundTaskReader): boolean { + return ( + ctx.clientKind === undefined || + ctx.clientCapabilities?.includes(AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY) === true + ) +} + +/** A reader that predates child views never gets the views it cannot read. Every roster it is + * handed lists running work (`tasks`), so its reading of any roster as live work holds. Deleted + * with the legacy shapes: see `structured-agent-session-child-work-legacy`. */ +function withoutChildViews( + state: AgentSessionBackgroundTaskState +): AgentSessionBackgroundTaskState { + const { children: _children, ...legacy } = state + return legacy +} + /** A reader that predates `stoppable` draws a per-row stop on every row it is * handed, and the host cannot honour one on a row marked unstoppable — the * dead button the field exists to remove. The host publishing such rows at all * is new, so withholding them hands that reader exactly its pre-feature view; - * a state whose every row is withheld becomes no strip, as it was. */ + * a state whose every row is withheld becomes no strip, as it was. A host that + * offers no targeted stop draws no per-row stop, so it has nothing to withhold. */ function withoutUnstoppableRows( state: AgentSessionBackgroundTaskState ): AgentSessionBackgroundTaskState | null { - if (!state.tasks?.some((task) => task.stoppable === false)) { + if (!state.supportsTaskStop || !state.tasks?.some((task) => task.stoppable === false)) { return state } const tasks = state.tasks.filter((task) => task.stoppable !== false) @@ -44,7 +63,8 @@ function projectState( state: AgentSessionBackgroundTaskState | null | undefined, ctx: BackgroundTaskReader ): AgentSessionBackgroundTaskState | null | undefined { - const rows = !state || honoursRowStop(ctx) ? state : withoutUnstoppableRows(state) + const views = !state || readsChildViews(ctx) ? state : withoutChildViews(state) + const rows = !views || honoursRowStop(ctx) ? views : withoutUnstoppableRows(views) // Legacy readers always offer a stop; retain their pre-producer empty strip. return rows?.supportsStopAll === false && !rows.supportsTaskStop && !supportsReadOnlyTasks(ctx) ? null diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts index 875f77a754b..0d9180dc3e5 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import type { @@ -89,9 +90,14 @@ function publishedSummary(journal: AgentSessionJournal): AgentSessionStatusSumma return summary } -function ingest(summary: AgentSessionStatusSummary) { +/** `children` is child-work evidence the host's records admit under the row before it re-folds. */ +function ingest(summary: AgentSessionStatusSummary, children: AgentChildWorkEvidence[] = []) { const store = new AgentHookServer() store.ingestStructuredStatus(summary, SUBJECT) + if (children.length > 0) { + store.ingestStructuredChildWork(SUBJECT, children, 'codex') + store.ingestStructuredStatus({ ...summary, updatedAt: summary.updatedAt + 1 }, SUBJECT) + } const hookSnapshots = store.getStatusSnapshot() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attaching agent rows reads and writes only `worktreeId`, `status`, `hasHostSidebarActivity` and `agents`. const row = { @@ -183,12 +189,20 @@ describe('a request that failed reads as failed through the feed, the ingest and { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'failure', completedAt: 5 }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - const summary: AgentSessionStatusSummary = { - ...publishedSummary(journal), - backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }] - } - - const { status, ps } = ingest(summary) + // The row folds the host's live child records, never the summary's task list. + const { status, ps } = ingest(publishedSummary(journal), [ + { + type: 'live', + observedAt: 1_000, + child: { + handle: { idKind: 'task_id', id: 'child-1' }, + kind: 'agent', + residency: 'background', + state: 'working', + stoppable: true + } + } + ]) expect(status).toMatchObject({ state: 'working', mainAgent: { state: 'done', outcome: 'failure' } diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 60743010d31..aecf034b370 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -275,8 +275,6 @@ async function installOnJournal( ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), modelCatalog: agentModelCatalogStore, - onBackgroundTasksChanged: (sessionId, state) => - host?.publishBackgroundTaskState(sessionId, state), onChildWorkEvidence: (sessionId, evidence) => host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, @@ -304,8 +302,6 @@ async function installOnJournal( } : {}), onLifecycleEvent: (event) => lifecycle.deliver(event), - onBackgroundTasksChanged: (sessionId, state) => - host?.publishBackgroundTaskState(sessionId, state), onChildWorkEvidence: (sessionId, evidence) => host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts new file mode 100644 index 00000000000..ea3559e3059 --- /dev/null +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -0,0 +1,390 @@ +// The switch, end to end on a host with no renderer: a Codex session's frames reach the host's +// canonical store; the status summary every session list reads and the chat strip's channel both +// carry the running children, and the parent row is folded from the same records. +// +// Deliberately NOT a comparison against the provider tracker's task list: that tracker and the +// record producer read the same child executions, so agreeing with it could not catch a defect in +// what those executions observe. Every expectation below is written out literally. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionBackgroundTaskState, + AgentSessionStatusSummary +} from '../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../shared/agent-status-child-work-view' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { AgentHookServer, _internals } from '../agent-hooks/server' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) + +const THREAD = 'thread-switch' +const REVIEWER = 'thread-reviewer' +const ROUTES: Record = { + 'thread/start': { thread: { id: THREAD } }, + 'model/list': { + data: [ + { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true + } + ], + nextCursor: null + } +} + +type Row = Pick & { + outcome?: AgentChildWorkView['outcome'] + tool?: string + owner?: string +} + +/** What a surface would show of each child, independent of which channel carried it. */ +function rows(views: readonly AgentChildWorkView[] | undefined): Row[] { + const byId = new Map((views ?? []).map((view) => [view.id, view])) + return (views ?? []).map((view) => ({ + kind: view.kind, + ...(view.description ? { description: view.description } : {}), + state: view.state, + membership: view.membership, + ...(view.outcome ? { outcome: view.outcome } : {}), + ...(view.operation ? { tool: `${view.operation.toolName}: ${view.operation.input}` } : {}), + ...(view.parentChildWorkId + ? { owner: byId.get(view.parentChildWorkId)?.description ?? '?' } + : {}) + })) +} + +describe('the chat strip and the session list read the same host child records', () => { + let root: string + let server: AgentHookServer + + beforeEach(async () => { + _internals.resetCachesForTests() + root = await mkdtemp(join(tmpdir(), 'orca-child-records-switch-')) + server = new AgentHookServer() + }) + + afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) + }) + + it('carries running children to both, hides the strip when none runs, and folds the parent row from them', async () => { + const connections: CodexAppServerConnectionHandlers[] = [] + const openConnection: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => { + connections.push(handlers) + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method) => (method in ROUTES ? ROUTES[method] : {}), + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } + return connection + } + // Production's sink wiring, onto a real hook server: no renderer is involved anywhere. + const statusSink: StructuredAgentSessionStatusSink = { + publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), + forget: (subject) => server.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + server.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => server.getStructuredChildWorkViews(subject) + } + const host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + statusSink + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` + const attached = await host.attach({ callerKey: 'switch-test' }, attachParams) + expect(attached).toMatchObject({ ok: true }) + const fence = attached.ok ? attached.value.fence : 0 + const summaries: AgentSessionStatusSummary[] = [] + host.subscribeStatus({ + id: 'session-list', + emit: (event) => { + if (event.type === 'status') { + summaries.push(event.session) + } else if (event.type === 'snapshot') { + summaries.push(...event.sessions.filter((session) => session.sessionId === SESSION)) + } + } + }) + const strip: (AgentSessionBackgroundTaskState | null)[] = [] + await host.subscribe({ + id: 'strip', + sessionId: SESSION, + emit: (event) => { + if ('backgroundTasks' in event && event.backgroundTasks !== undefined) { + strip.push(event.backgroundTasks) + } + } + }) + // Journal writes land asynchronously; let each frame's rows publish before the next. + const settle = async () => { + for (let tick = 0; tick < 5; tick += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + const notify = async (method: string, params: Record) => { + connections[0]?.onNotification?.(method, params) + await settle() + } + const item = ( + method: 'item/started' | 'item/completed', + threadId: string, + turnId: string, + value: Record + ) => notify(method, { threadId, turnId, item: value }) + const parentRow = () => server.getStatusSnapshot().find((row) => row.structuredHost) + const summaryRows = () => rows(summaries.at(-1)?.children) + const stripRows = () => rows(strip.at(-1)?.children ?? undefined) + const both = (expected: Row[]) => { + expect(summaryRows()).toEqual(expected) + expect(stripRows()).toEqual(expected) + } + + await notify('turn/started', { threadId: THREAD, turn: { id: 'p1', status: 'inProgress' } }) + await item('item/completed', THREAD, 'p1', { + type: 'agentMessage', + id: 'lead-1', + text: 'Spawning a reviewer' + }) + await notify('turn/started', { threadId: REVIEWER, turn: { id: 'r1', status: 'inProgress' } }) + await item('item/started', THREAD, 'p1', { + type: 'subAgentActivity', + id: 'spawn-1', + kind: 'started', + agentThreadId: REVIEWER, + agentPath: '/root/review' + }) + both([{ kind: 'agent', description: 'review', state: 'working', membership: 'live' }]) + + await item('item/started', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'cmd-1', + command: 'npm test', + source: 'agent', + status: 'inProgress' + }) + await item('item/started', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'exec-1', + command: 'npm run dev', + source: 'unifiedExecStartup', + status: 'inProgress' + }) + // Every command the child runs is a live record under it, whatever Codex tagged it. + both([ + { + kind: 'agent', + description: 'review', + state: 'working', + membership: 'live', + tool: 'Bash: npm run dev' + }, + { + kind: 'command', + description: 'npm test', + state: 'working', + membership: 'live', + owner: 'review' + }, + { + kind: 'command', + description: 'npm run dev', + state: 'working', + membership: 'live', + owner: 'review' + } + ]) + + // A command that exits leaves nothing behind. + await item('item/completed', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'cmd-1', + command: 'npm test', + source: 'unifiedExecStartup', + status: 'completed', + exitCode: 0 + }) + both([ + { + kind: 'agent', + description: 'review', + state: 'working', + membership: 'live', + tool: 'Bash: npm run dev' + }, + { + kind: 'command', + description: 'npm run dev', + state: 'working', + membership: 'live', + owner: 'review' + } + ]) + + // The parent's turn ends first: its own work is done, a subagent's is not. + await notify('turn/completed', { threadId: THREAD, turn: { id: 'p1', status: 'completed' } }) + expect(parentRow()).toMatchObject({ state: 'working' }) + expect(parentRow()).not.toHaveProperty('workingMode') + + // The child finishes; the shell it launched still runs. + await notify('turn/completed', { threadId: REVIEWER, turn: { id: 'r1', status: 'completed' } }) + both([ + { + kind: 'agent', + description: 'review', + state: 'done', + membership: 'settled', + outcome: 'succeeded' + }, + { + kind: 'command', + description: 'npm run dev', + state: 'working', + membership: 'live', + owner: 'review' + } + ]) + expect(parentRow()).toMatchObject({ state: 'working', workingMode: 'monitoring' }) + // An older client folds the summary's task list: only live work is on it. + expect(summaries.at(-1)?.backgroundTasks?.map((task) => task.kind)).toEqual(['command']) + + await item('item/completed', REVIEWER, 'r1', { + type: 'commandExecution', + id: 'exec-1', + command: 'npm run dev', + source: 'unifiedExecStartup', + status: 'completed', + exitCode: 0 + }) + expect(parentRow()).toMatchObject({ state: 'done' }) + // Nothing runs: the sidebar and the strip list nothing, and the strip hides. The finished + // child's record stays in the store until the user's next turn; the dev server's record went + // when its process exited. + expect(summaries.at(-1)).not.toHaveProperty('children') + expect(strip.at(-1)).toBeNull() + const stored = () => rows(server.getStructuredChildWorkViews(parentSubject(summaries))) + expect(stored()).toEqual([ + { + kind: 'agent', + description: 'review', + state: 'done', + membership: 'settled', + outcome: 'succeeded' + } + ]) + + // A turn nobody sent retires nothing: only the user's next message does. + await notify('turn/started', { threadId: THREAD, turn: { id: 'p-own', status: 'inProgress' } }) + await notify('turn/completed', { threadId: THREAD, turn: { id: 'p-own', status: 'completed' } }) + expect(stored()).toHaveLength(1) + // The user's next message, which Codex takes into its next turn. + const body = hostTestMessage('check it again') + const clientOperationId = `${Date.now()}-${'2'.padStart(32, '0')}` + await host.send( + { callerKey: 'switch-test' }, + { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } + ) + await notify('turn/started', { threadId: THREAD, turn: { id: 'p2', status: 'inProgress' } }) + await notify('item/started', { + threadId: THREAD, + turn: { id: 'p2' }, + item: { type: 'userMessage', id: 'user-2', clientId: clientOperationId } + }) + await vi.waitFor(() => expect(stored()).toEqual([])) + expect(strip.at(-1)).toBeNull() + expect(summaries.at(-1)).not.toHaveProperty('children') + + // The reviewer's next run is still going when the provider exits: it settles with an outcome + // nobody reported, and leaves both lists. + await notify('turn/started', { threadId: REVIEWER, turn: { id: 'r2', status: 'inProgress' } }) + both([{ kind: 'agent', description: 'review', state: 'working', membership: 'live' }]) + connections[0]?.onExit?.(new Error('scripted provider exit')) + await settle() + expect(summaries.at(-1)).not.toHaveProperty('children') + expect(strip.at(-1)).toBeNull() + expect(stored()).toEqual([ + { + kind: 'agent', + description: 'review', + state: 'done', + membership: 'settled', + outcome: 'unknown' + } + ]) + + // Closing its tab lets go of the session: its row, and every child record with it, leave the + // store. + const subject = parentSubject(summaries) + await host.setSessionTabVisibility(SESSION, false) + await host.close(SESSION, 'user-close') + expect(server.getStructuredChildWorkViews(subject)).toEqual([]) + expect(summaries.at(-1)).not.toHaveProperty('children') + expect(summaries.at(-1)).not.toHaveProperty('backgroundTasks') + }) +}) + +function parentSubject(summaries: AgentSessionStatusSummary[]) { + const summary = summaries.at(-1) + if (!summary) { + throw new Error('no summary') + } + return makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: summary.workspaceId, + workspaceKind: 'git-worktree' + }, + summary.sessionId + ) +} diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 761a6835a55..d6aa6a7278e 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -1,6 +1,5 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { resolveClaudeCommand } from '../codex-cli/command' import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy' import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution' @@ -35,10 +34,6 @@ export type StructuredClaudeRuntimeAdapterDeps = { readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] modelCatalog?: ClaudeStructuredSessionAdapterDeps['modelCatalog'] onLifecycleEvent: (event: StructuredAgentSessionLifecycleEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] onSessionIdle?: ClaudeStructuredSessionAdapterDeps['onSessionIdle'] onChildWorkEvidence?: ClaudeStructuredSessionAdapterDeps['onChildWorkEvidence'] @@ -129,9 +124,6 @@ export function createStructuredClaudeRuntimeAdapter( deps.onLifecycleEvent(lifecycle) } }, - ...(deps.onBackgroundTasksChanged - ? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged } - : {}), ...(deps.onDispatchSettledLate ? { onDispatchSettledLate: deps.onDispatchSettledLate } : {}), ...(deps.onSessionIdle ? { onSessionIdle: deps.onSessionIdle } : {}), ...(deps.onChildWorkEvidence ? { onChildWorkEvidence: deps.onChildWorkEvidence } : {}), diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 7ab3bad8957..cfd0fa29402 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -101,7 +101,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { publish: (summary, subject) => agentHookServer.ingestStructuredStatus(summary, subject), forget: (subject) => agentHookServer.dropStructuredStatus(subject), publishChildWork: (subject, evidence, provider) => - agentHookServer.ingestStructuredChildWork(subject, evidence, provider) + agentHookServer.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject) }, // Why captured rather than resolved at read: the fleet snapshot remints cached rows on every // read, so a row observed under one process otherwise acquires whatever the pane owns now. diff --git a/src/renderer/src/components/agent-child-row-parity.test.tsx b/src/renderer/src/components/agent-child-row-parity.test.tsx index c8e3b4bd190..eb7721d2f2f 100644 --- a/src/renderer/src/components/agent-child-row-parity.test.tsx +++ b/src/renderer/src/components/agent-child-row-parity.test.tsx @@ -7,6 +7,7 @@ import { } from '../../../shared/agent-child-row-model' import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-wire' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing' import type { AgentStatusEntry } from '../../../shared/agent-status-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow' @@ -133,17 +134,23 @@ function sidebarRows(parent: AgentStatusEntry, parentIsFresh = true): RenderedRo ) } +/** The strip's rows over what the host sends it: the running children, and no strip at all when + * none runs. */ function stripRows( children: AgentChildWorkView[] | undefined, childRowContext?: AgentChildRowContext, tasks: AgentSessionBackgroundTask[] = [] ): RenderedRow[] { + const sent = children ? structuredRunningChildWork(children) : undefined + if (sent?.length === 0 && tasks.length === 0) { + return [] + } const root = mount( renderToStaticMarkup( = { 'parked, still live': { shows: [] } } +// The sidebar and the strip list running children only; a finished child is in the transcript. A +// finished child whose shell still runs reads monitoring, so it stays on both. +const FINISHED = new Set(['finished', 'failed', 'cancelled', 'ended, outcome unknown']) + describe('a child reads the same in the sidebar and the chat strip', () => { it.each(SCENARIOS)('%s', (name, children, expected) => { - const [sidebar] = sidebarRows(parentWith(children)) + if (FINISHED.has(name)) { + expect(stripRows(children)).toEqual([]) + expect(sidebarRows(parentWith(children))).toEqual([]) + return + } const [strip] = stripRows(children) - expect(sidebar).toEqual(expected) expect(strip).toEqual(expected) + const [sidebar] = sidebarRows(parentWith(children)) + expect(sidebar).toEqual(expected) const full = fullRow(parentWith(children)) expect(full.labels).toContain(expected.dot) expect(full.text).toContain(expected.lead === expected.dot ? expected.trail : expected.lead) @@ -300,15 +316,21 @@ describe('a child reads the same in the sidebar and the chat strip', () => { } }) - it('names an unlabeled child by the same state on every surface', () => { + it('lists a finished child on neither surface', () => { const children = [ settled('failed', { description: undefined, agentType: undefined, lastMessage: 'Exit 2' }) ] + expect(stripRows(children)).toEqual([]) + expect(sidebarRows(parentWith(children))).toEqual([]) + }) + + it('names an unlabeled running child by the same state on every surface', () => { + const children = [view('child', { description: undefined, agentType: undefined })] const [sidebar] = sidebarRows(parentWith(children)) const [strip] = stripRows(children) - expect(sidebar.lead).toBe('Failed') - expect(strip.lead).toBe('Failed') - expect(fullRow(parentWith(children)).text).toContain('Failed') + expect(sidebar.lead).toBe('Working') + expect(strip.lead).toBe('Working') + expect(fullRow(parentWith(children)).text).toContain('Working') }) it('shows the monitoring icon on the full sidebar row too', () => { @@ -524,40 +546,15 @@ describe('sibling child rows keep their own clocks', () => { ]) }) - it('times a settled child from when it ended in the sidebar, and freezes its run in the strip', () => { - const [agent] = buildSubagentChildRows({ - parentEntry: parentWith([settled('succeeded')]), - tab, - parentIsFresh: true - }) - const text = mount( - renderToStaticMarkup( - - {}} /> - - ) - ).textContent - expect(text?.endsWith('3m')).toBe(true) - const strip = mount( - renderToStaticMarkup( - {}} - onStop={() => {}} - /> - ) - ) - // Ran from 5m ago until it settled 3m ago; a finished row never ticks. - expect(strip.querySelector('li')?.textContent).toMatch(/2m 0s$/) + it('leaves a finished child off the sidebar and the strip', () => { + expect( + buildSubagentChildRows({ + parentEntry: parentWith([settled('succeeded')]), + tab, + parentIsFresh: true + }) + ).toEqual([]) + expect(stripRows([settled('succeeded')])).toEqual([]) }) }) @@ -609,12 +606,13 @@ describe('the chat strip from views', () => { }) }) +// A finished child is on neither surface (`null`: no row for it). describe('one lifecycle word for a child, on the sidebar row and the strip header', () => { - it.each<[string, AgentChildWorkView[], string, string]>([ - ['failed', [settled('failed')], 'blocked', 'blocked'], - ['cancelled', [settled('cancelled')], 'idle', 'idle'], - ['ended, outcome unknown', [settled('unknown')], 'idle', 'idle'], - ['finished', [settled('succeeded')], 'done', 'done'], + it.each<[string, AgentChildWorkView[], string | null, string | null]>([ + ['failed', [settled('failed')], null, null], + ['cancelled', [settled('cancelled')], null, null], + ['ended, outcome unknown', [settled('unknown')], null, null], + ['finished', [settled('succeeded')], null, null], ['monitoring its own shell', [settled('succeeded'), OWNED_SHELL], 'working', 'monitoring'], ['waiting', [view('child', { state: 'waiting' })], 'waiting', 'waiting'] ])('%s', (_name, children, sidebarState, headerState) => { @@ -623,9 +621,9 @@ describe('one lifecycle word for a child, on the sidebar row and the strip heade tab, parentIsFresh: true }) - const [group] = buildBackgroundTaskGroupsFromViews(children) + const [group] = buildBackgroundTaskGroupsFromViews(structuredRunningChildWork(children)) // A CLI row carries monitoring as `working` plus its working mode; every other word is shared. - expect(sidebar.state).toBe(sidebarState) - expect(group.tasks[0].state).toBe(headerState) + expect(sidebar?.state ?? null).toBe(sidebarState) + expect(group?.tasks[0].state ?? null).toBe(headerState) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index c78c27419a1..2920e1bae6d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -316,6 +316,7 @@ export function NativeChatStructuredSession( agentLabel={agentLabel} startupPhase={hostExecution.phase} startupChildKey={hostExecution.childKey} + paneKey={paneKey} // Said once: on the pane when the failure took it, else here beside the transcript. A // failure that names nothing is only the pane reconnecting. error={ diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx index c89ed5b5f4c..0f6e1e4c76c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.test.tsx @@ -35,6 +35,7 @@ function statusElement( agentLabel="Claude" startupPhase={startupPhase} startupChildKey={startupChildKey} + paneKey="pane-1" error={null} composerError={null} isVisible diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx index 31518886150..c6f8316444f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx @@ -3,6 +3,7 @@ import { translate } from '@/i18n/i18n' import { useDelayedStatus } from '@/hooks/use-delayed-status' import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus' import type { StructuredSessionBackgroundTasksView } from './structured-session-background-tasks-view' +import { useStructuredSessionChildRowContext } from './use-structured-session-child-row-context' type StoppingBackgroundTasks = { sessionId: string @@ -22,6 +23,8 @@ export function NativeChatStructuredSessionStatus(props: { /** The host's word on the provider child; `starting` is published but not yet answering. */ startupPhase: 'starting' | 'ready' | null startupChildKey: string | number | null + /** The session's own status row, whose verdict the strip's children read. */ + paneKey: string error: string | null /** A read the pane is reconnecting on its own: said plainly, not as an error. */ reconnecting?: boolean @@ -38,6 +41,7 @@ export function NativeChatStructuredSessionStatus(props: { props.startupPhase === 'starting' ? 'starting' : null, SLOW_STARTUP_NOTICE_DELAY_MS ) + const childRowContext = useStructuredSessionChildRowContext(props.paneKey) const onStop = (taskId?: string) => { const sessionId = props.sessionId @@ -95,6 +99,9 @@ export function NativeChatStructuredSessionStatus(props: { isVisible={props.isVisible} tasks={props.backgroundTasks.tasks} settledTasks={props.backgroundTasks.settledTasks} + {...(props.backgroundTasks.children + ? { childViews: props.backgroundTasks.children, childRowContext } + : {})} indicatorActive={props.backgroundTasks.isMonitoring} supportsTaskStop={props.backgroundTasks.supportsStop} supportsStopAll={props.backgroundTasks.supportsStopAll} diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 8205d96a5af..72669bf936c 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -11,7 +11,17 @@ import { isAgentStatusHeldOpenByChildWork, mainAgentTurnInterrupted } from '../../../../shared/agent-lead-status-fold' -import { mainAgentStatusEqual, agentSubagentsEqual } from '../../../../shared/agent-status-types' +import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' +import { + agentChildWorkViewsEqual, + decodeAgentChildWorkViews +} from '../../../../shared/agent-status-child-work-view-wire' +import { + agentSubagentsEqual, + mainAgentStatusEqual, + type AgentSubagentSnapshot +} from '../../../../shared/agent-status-types' +import { structuredChildWorkLegacySubagents } from '../../../../shared/structured-agent-session-child-work-legacy' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { structuredAgentSessionAgentStatus } from '../../../../shared/structured-agent-session-agent-status' import { @@ -73,6 +83,25 @@ export function useStructuredAgentSessionHostExecution( return { phase, childKey } } +/** The host's child records for the row, and the legacy roster readers of `subagents` keep. A host + * that publishes views is copied verbatim; only an older host's task list is converted here. */ +function childWorkFor(summary: AgentSessionStatusSummary): { + children?: AgentChildWorkView[] + subagents?: AgentSubagentSnapshot[] +} { + const children = decodeAgentChildWorkViews(summary.children) + if (children) { + const subagents = structuredChildWorkLegacySubagents(children, summary.agent) + return { children, ...(subagents ? { subagents } : {}) } + } + const subagents = summary.backgroundTasks + ? projectAgentChildWorkLegacySubagents( + summary.backgroundTasks.map(agentChildWorkProjectionCandidateFromBackgroundTask) + ) + : undefined + return subagents ? { subagents } : {} +} + function projectStatus( tab: StructuredTab, summary: AgentSessionStatusSummary | null, @@ -87,17 +116,11 @@ function projectStatus( } return } - // Sidebar children are the agent-kind tasks, projected by the same code every - // child-work reader uses; a backgrounded shell never counts as a subagent. - const subagents = summary.backgroundTasks - ? projectAgentChildWorkLegacySubagents( - summary.backgroundTasks.map(agentChildWorkProjectionCandidateFromBackgroundTask) - ) - : undefined + const { children, subagents } = childWorkFor(summary) // Shared with `worktree ps`, so the CLI and this row cannot disagree about one session. const agentStatus = structuredAgentSessionAgentStatus({ status: summary.status, - backgroundTasks: summary.backgroundTasks, + childWork: children ?? summary.backgroundTasks, turnOutcome: summary.turnOutcome }) const current = store.agentStatusByPaneKey?.[paneKey] @@ -121,7 +144,9 @@ function projectStatus( ...(summary.toolName ? { toolName: summary.toolName } : {}), ...(summary.toolInput ? { toolInput: summary.toolInput } : {}), ...(summary.lastAssistantMessage ? { lastAssistantMessage: summary.lastAssistantMessage } : {}), - ...(subagents ? { subagents, subagentObservation: observation } : {}), + ...(subagents ? { subagents } : {}), + ...(children ? { children } : {}), + ...(subagents || children ? { subagentObservation: observation } : {}), sessionBoundary: false } as const if ( @@ -136,6 +161,7 @@ function projectStatus( current.toolInput === summary.toolInput && current.lastAssistantMessage === summary.lastAssistantMessage && agentSubagentsEqual(current.subagents, subagents) && + agentChildWorkViewsEqual(current.children, children) && current.subagentObservation === desired.subagentObservation && current.sessionBoundary === desired.sessionBoundary && current.updatedAt === summary.updatedAt && diff --git a/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx b/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx new file mode 100644 index 00000000000..ea325e9b155 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx @@ -0,0 +1,349 @@ +// @vitest-environment happy-dom + +// The switch, wired end to end in the renderer: a host that publishes its child records reaches the +// sidebar through the status bridge and the chat strip through the session channel, and both read +// one child the same way — including once the parent's transport is lost or its row goes stale. + +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionBackgroundTaskState, + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import type { Tab } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' +import type { AppState } from '@/store/types' +import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' + +type TestStore = { + getState: () => AppState + setState: (state: Partial & { testRuntimeOwner?: string | null }) => void +} + +const mocks = vi.hoisted(() => { + const holder: { store: TestStore | null } = { store: null } + return { + holder, + subscribeStatus: + vi.fn< + ( + target: unknown, + emit: (event: AgentSessionStatusEvent) => void + ) => Promise<{ unsubscribe: () => void }> + >(), + unsubscribe: vi.fn() + } +}) + +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + const useAppStore = createTestStore() + mocks.holder.store = useAppStore + return { useAppStore } +}) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => null +})) + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + runtimeEnvironmentSupportsCapability: vi.fn(async () => true) +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus +})) + +vi.mock('@/components/sidebar/CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownForPane: () => null +})) + +import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' +import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSessionStatus' +import { structuredSessionBackgroundTasksView } from './structured-session-background-tasks-view' +import { CompactAgentRow } from '@/components/sidebar/worktree-card-compact-agent-row' +import { buildSubagentChildRows } from '@/components/sidebar/worktree-subagent-child-rows' +import { TooltipProvider } from '@/components/ui/tooltip' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' + +const MINUTE = 60_000 +const tab = { + id: 'structured-tab-1', + worktreeId: 'wt-1', + groupId: 'group-1', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' +} satisfies Tab +const PANE_KEY = structuredAgentSessionPaneKey(tab.id, tab.entityId) + +function view(id: string, overrides: Partial = {}): AgentChildWorkView { + const now = Date.now() + return { + id, + providerId: `thread-${id}`, + kind: 'agent', + description: `Audit ${id}`, + state: 'working', + membership: 'live', + firstObservedAt: now - 5 * MINUTE, + observedAt: now - 2 * MINUTE, + stoppable: false, + invocation: { invocationId: `turn-${id}`, generation: 1 }, + ...overrides + } +} + +function summary(over: Partial = {}): AgentSessionStatusSummary { + return { + sessionId: tab.entityId, + workspaceId: tab.worktreeId, + agent: 'codex', + status: 'idle', + hostExecutionOwned: true, + latestPrompt: 'fan out', + updatedAt: Date.now(), + ...over + } +} + +function feed(): (event: AgentSessionStatusEvent) => void { + const call = mocks.subscribeStatus.mock.calls[0] + if (!call) { + throw new Error('status feed not subscribed') + } + return call[1] +} + +function row(): AgentStatusEntry { + const entry = mocks.holder.store?.getState().agentStatusByPaneKey[PANE_KEY] + if (!entry) { + throw new Error('no structured row') + } + return entry +} + +type RenderedRow = { dot: string; lead: string; trail: string } + +function readRow(root: Element, separator: string): RenderedRow { + const [lead, trail] = [...(root.querySelector('span.truncate')?.children ?? [])].map( + (span) => span.textContent ?? '' + ) + return { + dot: root.querySelector('[aria-label]')?.getAttribute('aria-label') ?? '', + lead: lead ?? '', + trail: trail?.startsWith(separator) ? trail.slice(separator.length) : (trail ?? '') + } +} + +/** The sidebar's rows for the session, built exactly as the worktree card builds them. */ +function sidebarRows(): RenderedRow[] { + const entry = row() + const terminalTab: TerminalTab = { + id: tab.id, + ptyId: null, + worktreeId: tab.worktreeId, + title: tab.label, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + const parentIsFresh = isExplicitAgentStatusFresh(entry, Date.now(), AGENT_STATUS_STALE_AFTER_MS) + return buildSubagentChildRows({ parentEntry: entry, tab: terminalTab, parentIsFresh }).map( + (agent) => { + const container = document.createElement('div') + container.innerHTML = renderToStaticMarkup( + + {}} /> + + ) + return readRow(container, ' - ') + } + ) +} + +/** The strip's rows, from the real session-status component over the channel's roster. */ +function stripRows(container: HTMLElement): RenderedRow[] { + return [...container.querySelectorAll('[data-native-chat-background-tasks] li')] + .filter((item) => item.querySelector(':scope > span.truncate')) + .map((item) => readRow(item, ' · ')) +} + +function Surfaces(props: { roster: AgentSessionBackgroundTaskState | null }): React.JSX.Element { + return ( + + + undefined} + /> + + ) +} + +describe('the switch: both surfaces read the host child records', () => { + beforeEach(() => { + vi.clearAllMocks() + resetStructuredAgentSessionStatusFeedsForTests() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.holder.store?.setState({ + agentStatusByPaneKey: {}, + testRuntimeOwner: null, + unifiedTabsByWorktree: { 'wt-1': [tab] } + }) + }) + + afterEach(() => { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + }) + + async function mountWith(children: AgentChildWorkView[]) { + // The summary and the channel carry the same views; the summary omits usage. + const roster: AgentSessionBackgroundTaskState = { + state: 'monitoring', + supportsStopAll: false, + children + } + const rendered = render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed()({ type: 'snapshot', sessions: [summary({ children })] })) + const header = rendered.container.querySelector('[aria-expanded]') + if (header) { + fireEvent.click(header) + } + return rendered + } + + it("copies the host's views onto the row and derives the legacy roster from them", async () => { + const shell = view('shell', { + kind: 'command', + providerId: 'codex-command:thread:thread-a:cmd-1', + description: 'npm run dev', + state: 'monitoring', + parentChildWorkId: 'a' + }) + const finished = view('a', { + state: 'done', + membership: 'settled', + outcome: 'succeeded', + settledAt: Date.now() - MINUTE, + lastMessage: 'Parser is clean' + }) + const running = view('b') + await mountWith([finished, shell, running]) + expect(row().children).toEqual([finished, shell, running]) + // The legacy roster lists live agents only, under the id a Codex subagent's row always had. + expect(row().subagents).toEqual([ + expect.objectContaining({ id: 'codex-agent:thread-b', state: 'working' }) + ]) + // An idle lead held open by a live subagent reads working, from the records. + expect(row()).toMatchObject({ state: 'working' }) + }) + + // The live-entry builder copies fields one by one; an unchanged list keeps its identity so the + // rows derived from it do not re-render, and a changed one replaces it. + it("keeps the row's child list when a summary repeats it, and replaces it when it changes", async () => { + const children = [view('a')] + await mountWith(children) + const first = row().children + act(() => feed()({ type: 'status', session: summary({ children: structuredClone(children) }) })) + expect(row().children).toBe(first) + act(() => + feed()({ + type: 'status', + session: summary({ children: [{ ...children[0]!, lastMessage: 'Found 3 call sites' }] }) + }) + ) + expect(row().children).not.toBe(first) + expect(row().children?.[0]?.lastMessage).toBe('Found 3 call sites') + }) + + it('shows one child the same way on both surfaces, live, lost, and stale', async () => { + const children = [ + view('a', { + operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: Date.now() } + }) + ] + const { container } = await mountWith(children) + const live = { dot: 'Working', lead: 'Audit a', trail: 'Bash: npm test' } + expect(sidebarRows()).toEqual([live]) + expect(stripRows(container)).toEqual([live]) + + // The status stream drops: the host is not heard from, so neither surface vouches for it. + act(() => feed()({ type: 'end' })) + const lost = { dot: 'No recent update', lead: 'Audit a', trail: 'No update in 2m' } + await waitFor(() => expect(sidebarRows()).toEqual([lost])) + expect(stripRows(container)).toEqual([lost]) + }) + + it('reads a stale parent row the same way on both surfaces', async () => { + const { container } = await mountWith([view('a')]) + // The host no longer owns the session, and its last word is older than the freshness window. + act(() => + feed()({ + type: 'status', + session: summary({ + hostExecutionOwned: undefined, + status: 'working', + updatedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - MINUTE, + children: [view('a')] + }) + }) + ) + await waitFor(() => + expect(isExplicitAgentStatusFresh(row(), Date.now(), AGENT_STATUS_STALE_AFTER_MS)).toBe(false) + ) + const stale = { dot: 'No recent update', lead: 'Audit a', trail: 'No update in 2m' } + expect(sidebarRows()).toEqual([stale]) + expect(stripRows(container)).toEqual([stale]) + }) + + it('hides the strip and the sidebar rows once nothing runs, on a new host and an older one', async () => { + const { container, rerender } = await mountWith([view('a')]) + expect(stripRows(container)).toHaveLength(1) + const strip = () => container.querySelector('[data-native-chat-background-tasks]') + + // The child finishes: the host sends no running children, so no roster and no summary rows. + rerender() + act(() => feed()({ type: 'status', session: summary({ children: undefined }) })) + expect(strip()).toBeNull() + expect(sidebarRows()).toEqual([]) + + // An older host sends its own task list, and clears it the same way. + rerender( + + ) + expect(strip()).not.toBeNull() + rerender() + expect(strip()).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-session-background-tasks-view.test.ts b/src/renderer/src/components/native-chat/structured-session-background-tasks-view.test.ts new file mode 100644 index 00000000000..f814f89e2fd --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-session-background-tasks-view.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' +import { structuredSessionBackgroundTasksView } from './structured-session-background-tasks-view' + +function view(membership: 'live' | 'settled', kind: AgentChildWorkView['kind'] = 'agent') { + return { + id: `${kind}-${membership}`, + kind, + state: membership === 'live' ? 'working' : 'done', + membership, + ...(membership === 'settled' ? { outcome: 'succeeded', settledAt: 2 } : {}), + firstObservedAt: 1, + observedAt: 2, + stoppable: false, + invocation: { invocationId: 'run-1', generation: 1 } + } satisfies AgentChildWorkView +} + +describe('structuredSessionBackgroundTasksView', () => { + it('shows finished children until the next turn, but they hold nothing open', () => { + const finished = structuredSessionBackgroundTasksView( + { state: 'monitoring', children: [view('settled')] }, + null + ) + expect(finished).toMatchObject({ show: true, isMonitoring: false, children: [view('settled')] }) + }) + + it('hands the strip the decoded roster it was given, so its grouped rows stay memoized', () => { + const roster = { state: 'monitoring' as const, children: [view('live')] } + expect(structuredSessionBackgroundTasksView(roster, null).children).toBe(roster.children) + expect(structuredSessionBackgroundTasksView(roster, 'turn-1').children).toBe(roster.children) + }) + + it('keeps the same empty lists for a roster that omits one, so the strip memo holds', () => { + for (const roster of [ + { state: 'monitoring' as const, tasks: [{ id: 'task-1', kind: 'agent' as const }] }, + { state: 'monitoring' as const, settledTasks: [{ id: 'task-2', kind: 'agent' as const }] }, + null + ]) { + const first = structuredSessionBackgroundTasksView(roster, null) + const again = structuredSessionBackgroundTasksView(roster, null) + expect(again.tasks).toBe(first.tasks) + expect(again.settledTasks).toBe(first.settledTasks) + } + }) + + it('reads live work beneath an idle session as monitoring, and a running turn as its own', () => { + const roster = { + state: 'monitoring' as const, + children: [view('settled'), view('live', 'command')] + } + expect(structuredSessionBackgroundTasksView(roster, null).isMonitoring).toBe(true) + expect(structuredSessionBackgroundTasksView(roster, 'turn-1').isMonitoring).toBe(false) + }) + + it("shows an older host's running tasks only, and nothing once only finished ones are left", () => { + const tasks = [{ id: 'task-1', kind: 'agent' as const }] + const settledTasks = [{ id: 'task-2', kind: 'agent' as const }] + expect( + structuredSessionBackgroundTasksView({ state: 'monitoring', tasks, settledTasks }, null) + ).toMatchObject({ show: true, isMonitoring: true, tasks, settledTasks: [] }) + expect( + structuredSessionBackgroundTasksView({ state: 'monitoring', settledTasks }, null) + ).toMatchObject({ show: false, isMonitoring: false }) + // A host that reports state only still says something runs. + expect(structuredSessionBackgroundTasksView({ state: 'monitoring' }, null).show).toBe(true) + }) + + it('reads an older host that publishes no views exactly as before', () => { + const tasks = [{ id: 'task-1', kind: 'agent' as const }] + expect(structuredSessionBackgroundTasksView({ state: 'monitoring', tasks }, null)).toEqual({ + show: true, + isMonitoring: true, + tasks, + settledTasks: [], + supportsStop: false, + supportsStopAll: true + }) + expect(structuredSessionBackgroundTasksView(null, null)).toMatchObject({ + show: false, + isMonitoring: false + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts b/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts index a79e97f705a..f9782d9beb1 100644 --- a/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts +++ b/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts @@ -5,35 +5,54 @@ // foreground fan-out is running work while the turn is still open. It stays // mounted through a running turn — turn state is not a filter on the rows, // because the producers publish only tasks they still have live evidence for. -// Only an idle session lets it animate or speak for itself. +// A host that publishes its child records sends the running ones only, and no +// roster once none runs, so the strip hides. Only an idle session with live +// work lets the strip animate or speak for itself. import type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskState } from '../../../../shared/agent-session-wire' +import { agentChildWorkLiveness } from '../../../../shared/agent-status-child-work-liveness' +import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' export type StructuredSessionBackgroundTasksView = { - /** The strip renders whenever the host reports monitoring — mid-turn included. */ + /** The strip renders whenever the host reports rows — mid-turn included. */ show: boolean /** Idle-only: gates the animated monitoring indicator and conversation * commands, never the strip itself. A running turn owns the voice. */ isMonitoring: boolean tasks: AgentSessionBackgroundTask[] settledTasks: AgentSessionBackgroundTask[] + /** The host's child records, when it publishes them; the rows then read these. */ + children?: AgentChildWorkView[] supportsStop: boolean supportsStopAll: boolean } +/** One shared empty list: a roster with no rows of a kind keeps the strip's memo on every render. */ +const NO_TASKS: AgentSessionBackgroundTask[] = [] + export function structuredSessionBackgroundTasksView( backgroundTasks: AgentSessionBackgroundTaskState | null | undefined, turnId: string | null ): StructuredSessionBackgroundTasksView { const monitoring = backgroundTasks?.state === 'monitoring' + // Decoded once where the frame entered the client's state, so its identity holds between frames. + const children = monitoring ? backgroundTasks.children : undefined + // Only running children arrive; a roster with none holds nothing open either way. + // An older host's roster whose listed rows have all finished shows nothing that runs. + const onlyFinished = + !children && !backgroundTasks?.tasks?.length && Boolean(backgroundTasks?.settledTasks?.length) + const show = monitoring && !onlyFinished + const liveWork = children ? agentChildWorkLiveness(children) !== null : show return { - show: monitoring, - isMonitoring: turnId === null && monitoring, - tasks: backgroundTasks?.tasks ?? [], - settledTasks: backgroundTasks?.settledTasks ?? [], + show, + isMonitoring: turnId === null && liveWork, + tasks: backgroundTasks?.tasks ?? NO_TASKS, + // Running work only, from any host: an older host's finished rows are not shown either. + settledTasks: NO_TASKS, + ...(children ? { children } : {}), supportsStop: backgroundTasks?.supportsTaskStop === true, // Absent means the host predates the field and does accept an untargeted // stop; only a host that says `false` has none to offer. diff --git a/src/renderer/src/components/native-chat/use-structured-session-child-row-context.ts b/src/renderer/src/components/native-chat/use-structured-session-child-row-context.ts new file mode 100644 index 00000000000..b0a7f42001a --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-session-child-row-context.ts @@ -0,0 +1,55 @@ +import { useMemo } from 'react' +import { + agentChildRowContextForParent, + type AgentChildRowContext +} from '../../../../shared/agent-child-row-model' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { useNow } from '@/hooks/use-now' +import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' +import { useAppStore } from '@/store' + +/** The verdict a session's status row gives its children, exactly as the sidebar derives it. */ +export function structuredSessionChildRowContext( + parent: AgentStatusEntry, + now: number +): AgentChildRowContext { + return agentChildRowContextForParent( + parent, + isExplicitAgentStatusFresh(parent, now, AGENT_STATUS_STALE_AFTER_MS) + ) +} + +/** The strip's children read their parent's verdict from the same status row the sidebar reads, + * so one child never says "No update" in one place and "Working" in the other. */ +export function useStructuredSessionChildRowContext( + paneKey: string +): AgentChildRowContext | undefined { + const parent = useAppStore((state) => state.agentStatusByPaneKey[paneKey]) + // The freshness window is thirty minutes; a coarse shared tick is well inside its precision. + const now = useNow(30_000, parent !== undefined) + const context = parent ? structuredSessionChildRowContext(parent, now) : undefined + // Rebuilt from its values, not the row: a status row that changed none of them keeps the strip's + // grouped rows memoized. + const fresh = context?.parentEvidenceFresh + const observation = context?.transportObservation + const observedAt = context?.parentObservedAt + const offset = context?.hostClockOffsetMs + return useMemo( + () => + fresh === undefined || + observation === undefined || + observedAt === undefined || + offset === undefined + ? undefined + : { + parentEvidenceFresh: fresh, + transportObservation: observation, + parentObservedAt: observedAt, + hostClockOffsetMs: offset + }, + [fresh, observation, observedAt, offset] + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts index 56c399cc18b..9f070c355bd 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts @@ -50,28 +50,28 @@ function renderCompactAgentRow(props: React.ComponentProps { - it('keeps a failed child in the groups and the counts', () => { - const failed: DashboardAgentRowData = { + // The sidebar lists running children only, so a child row it counts is one that runs. + it("counts a running child by its own row's state", () => { + const running: DashboardAgentRowData = { ...monitoringAgent(), paneKey: 'tab-1:leaf-1\u0000subagent:child', rowSource: 'subagent', - state: 'blocked', + state: 'working', childRow: { id: 'child', kind: 'agent', - displayState: 'failed', + displayState: 'waiting', name: 'Fuzz the tokenizer', detail: null, firstObservedAt: 1, recencyAt: 1, canStop: false, - settled: true, + settled: false, owned: [] } } - expect(getAgentDotState(failed)).toBe('failed') - expect(buildSummaryAgentGroups([failed]).map((group) => group.state)).toEqual(['failed']) - expect(summarizeAgents([failed], 'Agent')).toBe('Agent failed') + expect(getAgentDotState(running)).toBe('waiting') + expect(buildSummaryAgentGroups([running]).map((group) => group.state)).toEqual(['waiting']) }) it('presents passive working as monitoring', () => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-running-children.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-running-children.test.ts new file mode 100644 index 00000000000..6bf9fa0a969 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-running-children.test.ts @@ -0,0 +1,130 @@ +// The worktree sidebar and the chat's strip list running children only, by one rule, whichever +// source they read them from, an older host's own task roster included. + +import { describe, expect, it } from 'vitest' +import { + agentChildWorkIsRunning, + structuredRunningChildWork +} from '../../../../shared/agent-child-work-listing' +import { buildLegacyTaskRowModels } from '../../../../shared/agent-child-row-model' +import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' +import type { AgentStatusEntry, AgentSubagentSnapshot } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { + buildBackgroundTaskGroups, + buildBackgroundTaskGroupsFromViews +} from '@/components/native-chat/background-task-roster' +import { structuredSessionBackgroundTasksView } from '@/components/native-chat/structured-session-background-tasks-view' +import { buildSubagentChildRows } from './worktree-subagent-child-rows' + +const NOW = 1_000_000 + +const tab: TerminalTab = { + id: 'parent-tab', + ptyId: null, + worktreeId: 'wt-1', + title: 'Parent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 +} + +function parent(fields: Pick): AgentStatusEntry { + return { + paneKey: 'parent-tab:leaf-1', + tabId: tab.id, + worktreeId: tab.worktreeId, + state: 'done', + prompt: 'parent prompt', + updatedAt: NOW, + stateStartedAt: NOW - 60_000, + stateHistory: [], + ...fields + } +} + +function view(id: string, over: Partial = {}): AgentChildWorkView { + return { + id, + providerId: `task-${id}`, + kind: 'agent', + description: `child ${id}`, + state: 'working', + membership: 'live', + firstObservedAt: NOW - 5_000, + observedAt: NOW - 1_000, + stoppable: false, + invocation: { invocationId: `spawn-${id}`, generation: 1 }, + ...over + } +} + +const finished = (id: string): AgentChildWorkView => + view(id, { state: 'done', membership: 'settled', outcome: 'succeeded', settledAt: NOW - 2_000 }) + +const sidebarNames = (entry: AgentStatusEntry) => + buildSubagentChildRows({ parentEntry: entry, tab, parentIsFresh: true }).map( + (row) => row.entry.prompt + ) + +describe('the worktree sidebar lists running children only', () => { + it("drops a chat session's finished child from the sidebar and the strip alike", () => { + const children = [view('a'), finished('b')] + expect(sidebarNames(parent({ children }))).toEqual(['child a']) + const [agents] = buildBackgroundTaskGroupsFromViews(structuredRunningChildWork(children)) + expect(agents.tasks.map((entry) => entry.row.name)).toEqual(['child a']) + expect(structuredRunningChildWork([finished('b')])).toEqual([]) + }) + + it('keeps a finished child whose shell still runs: it reads monitoring', () => { + const children = [ + finished('owner'), + view('shell', { kind: 'command', description: 'npm run dev', parentChildWorkId: 'owner' }) + ] + const [row] = buildSubagentChildRows({ + parentEntry: parent({ children }), + tab, + parentIsFresh: true + }) + expect(row?.childRow?.displayState).toBe('monitoring') + }) + + // A terminal agent's hook roster holds live children only: its finished child left the roster on + // its own stop, so the sidebar has none to list. Every child it does hold runs, and all of them + // show, a teammate between turns and a child gone quiet included. + it("lists every child a terminal agent's hook roster holds", () => { + const subagents: AgentSubagentSnapshot[] = [ + { id: 'working', state: 'working', startedAt: NOW, description: 'Working child' }, + { id: 'waiting', state: 'waiting', startedAt: NOW, description: 'Waiting child' }, + { id: 'teammate', state: 'idle', startedAt: NOW, description: 'Idle teammate' }, + { id: 'quiet', state: 'unverifiable', startedAt: NOW, description: 'Quiet child' } + ] + expect(sidebarNames(parent({ subagents }))).toEqual([ + 'Working child', + 'Waiting child', + 'Idle teammate', + 'Quiet child' + ]) + expect(sidebarNames(parent({ subagents: [] }))).toEqual([]) + }) + + it("applies the one rule to a finished child from an older host's task roster, on both surfaces", () => { + const tasks = [ + { id: 'live', kind: 'agent' as const, description: 'Live task', state: 'working' as const } + ] + const settledTasks = [{ id: 'done', kind: 'agent' as const, description: 'Done task' }] + const rows = buildLegacyTaskRowModels(tasks, settledTasks) + expect( + rows + .filter((row) => agentChildWorkIsRunning({ settled: row.settled, ownsLiveWork: false })) + .map((row) => row.name) + ).toEqual(['Live task']) + const strip = structuredSessionBackgroundTasksView( + { state: 'monitoring', tasks, settledTasks }, + null + ) + const [agents] = buildBackgroundTaskGroups(strip.tasks, strip.settledTasks) + expect(agents.tasks.map((entry) => entry.row.name)).toEqual(['Live task']) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts index 729038d46ef..1b3ad745648 100644 --- a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts @@ -12,6 +12,7 @@ import { agentChildRunStateFor, type AgentChildDisplayState } from '../../../../shared/agent-status-child-work-display' +import { agentChildWorkIsRunning } from '../../../../shared/agent-child-work-listing' import type { TerminalTab } from '../../../../shared/terminal-tab-types' /** Row-identity key for an in-process subagent child row. The NUL separator @@ -80,6 +81,11 @@ function childDashboardRow( } } +/** Whether any work a child owns, at any depth, still runs. */ +function ownsLiveWork(row: AgentChildRowModel): boolean { + return row.owned.some((owned) => !owned.settled || ownsLiveWork(owned)) +} + /** * Derive indented child rows for the subagents/teammates a pane's agent has * spawned. These children have no PTY or tab of their own: the rows reuse the @@ -101,8 +107,13 @@ export function buildSubagentChildRows(args: { parentEntry.children !== undefined ? buildAgentChildRowModels(parentEntry.children, context) : buildLegacyAgentChildRowModels(parentEntry.subagents ?? [], context) - // Shells and monitors show through their owner's dot; the sidebar lists agents. + // Shells and monitors show through their owner's dot; the sidebar lists running agents only, from + // every source, by the same rule as the chat's strip. return flattenAgentChildRowModels(rows) - .filter((row) => row.kind === 'agent') + .filter( + (row) => + row.kind === 'agent' && + agentChildWorkIsRunning({ settled: row.settled, ownsLiveWork: ownsLiveWork(row) }) + ) .map((row) => childDashboardRow(row, parentEntry, args.tab)) } diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index 64ff3e10dcf..cd066a63a99 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -73,6 +73,8 @@ export type AgentLaunchConfigRegistryEntry = { export type AgentStatusPayload = ParsedAgentStatusPayload & { subagentObservation?: AgentStatusEntry['subagentObservation'] + /** The host's child views for a structured session; see `AgentStatusEntry.children`. */ + children?: AgentStatusEntry['children'] orchestration?: AgentStatusOrchestrationContext promptInteractionKey?: string restoredUnconfirmed?: boolean 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 ec80692f95e..cd4c6da3d47 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,10 +1,9 @@ import type { AppState } from '../types' import { resolveAgentStatusLiveEntryMainAgent } from './agent-status-live-entry-main-agent' import { resolveAgentStatusLiveEntryStateHistory } from './agent-status-live-entry-state-history' -import { - agentSubagentsEqual, - type MigrationUnsupportedPtyEntry, - type AgentStatusEntry +import type { + MigrationUnsupportedPtyEntry, + AgentStatusEntry } from '../../../../shared/agent-status-types' import { agentProviderSessionsEqual, @@ -29,6 +28,7 @@ import { registryEntryMatchesStatus } from './agent-status-launch-config' import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-key-tab-binding' import { mergeCurrentOrchestrationContext } from './agent-status-orchestration-context' import { deriveAgentStatusLiveFacts } from './agent-status-live-facts' +import { liveEntryChildFields } from './agent-status-live-entry-children' export type AgentStatusLiveEntryBuild = { entry: AgentStatusEntry @@ -240,9 +240,7 @@ export function buildAgentStatusLiveEntry( ...(lastCompletedAssistantMessage ? { lastCompletedAssistantMessage } : {}), orchestration, ...(payload.subagentObservation ? { subagentObservation: payload.subagentObservation } : {}), - subagents: agentSubagentsEqual(existing?.subagents, payload.subagents) - ? existing?.subagents - : payload.subagents, + ...liveEntryChildFields(existing, payload), ...(mainAgent ? { mainAgent } : {}), ...(providerSession ? { providerSession } : {}), ...(metadata?.terminalResumeEligible === false diff --git a/src/renderer/src/store/slices/agent-status-live-entry-children.ts b/src/renderer/src/store/slices/agent-status-live-entry-children.ts new file mode 100644 index 00000000000..8087712925d --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-live-entry-children.ts @@ -0,0 +1,24 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { agentSubagentsEqual } from '../../../../shared/agent-status-types' +import { agentChildWorkViewsEqual } from '../../../../shared/agent-status-child-work-view-wire' +import type { AgentStatusPayload } from './agent-status-contract' + +/** A row's child fields, reusing the previous arrays when unchanged so the rows derived from them + * keep their identity. `children` is the host's views; `subagents` the legacy roster. */ +export function liveEntryChildFields( + existing: AgentStatusEntry | undefined, + payload: AgentStatusPayload +): Pick { + return { + subagents: agentSubagentsEqual(existing?.subagents, payload.subagents) + ? existing?.subagents + : payload.subagents, + ...(payload.children + ? { + children: agentChildWorkViewsEqual(existing?.children, payload.children) + ? existing?.children + : payload.children + } + : {}) + } +} diff --git a/src/renderer/src/store/slices/agent-status-live-facts.ts b/src/renderer/src/store/slices/agent-status-live-facts.ts index 9d8c19742f7..9ebc3a005ab 100644 --- a/src/renderer/src/store/slices/agent-status-live-facts.ts +++ b/src/renderer/src/store/slices/agent-status-live-facts.ts @@ -96,6 +96,7 @@ export function deriveAgentStatusLiveFacts(args: AgentStatusLiveFactsArgs): Agen entry.lastAssistantMessageIsToolOutput !== existing.lastAssistantMessageIsToolOutput || entry.orchestration !== existing.orchestration || entry.subagents !== existing.subagents || + entry.children !== existing.children || entry.providerSession !== existing.providerSession) // A verdict moves no clock: a failure keeps a done's completion time, and a main agent that fails // while its subagents keep the row working leaves the row's state and start as they were. diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index db94537297b..cd290606fb2 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -22,6 +22,7 @@ import { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../../shared/agent-session-background-task-child-views-capability' const fakeSockets: FakeWebSocket[] = [] @@ -90,6 +91,7 @@ describe('WebRuntimeClient', () => { deviceToken: 'token', clientCapabilities: [ AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, diff --git a/src/renderer/src/web/web-runtime-connection-frame-router.ts b/src/renderer/src/web/web-runtime-connection-frame-router.ts index df56f143892..626b49f5612 100644 --- a/src/renderer/src/web/web-runtime-connection-frame-router.ts +++ b/src/renderer/src/web/web-runtime-connection-frame-router.ts @@ -11,6 +11,7 @@ import { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../../shared/agent-session-background-task-child-views-capability' import { createWebRuntimeUnauthorizedError } from './web-runtime-client-error' import { decrypt, decryptBytes } from './web-e2ee' import type { WebRuntimeTransportSubscription } from './web-runtime-subscription-contract' @@ -62,6 +63,7 @@ export async function routeWebRuntimeConnectionFrame( deviceToken: context.pairingToken, clientCapabilities: [ AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, diff --git a/src/shared/agent-child-row-model.ts b/src/shared/agent-child-row-model.ts index 8e1cf606eea..c0dcfed2d04 100644 --- a/src/shared/agent-child-row-model.ts +++ b/src/shared/agent-child-row-model.ts @@ -19,6 +19,7 @@ import { deriveAgentChildDisplayState, type AgentChildDisplayState } from './agent-status-child-work-display' +import { agentChildWorkViewOffersStop } from './agent-child-work-stop-targets' import type { AgentChildWorkView } from './agent-status-child-work-view' import { agentStatusAuthorityObservedAt, @@ -242,7 +243,7 @@ function rowFromView( recencyAt: view.observedAt + context.hostClockOffsetMs, ...(view.settledAt !== undefined ? { settledAt: view.settledAt } : {}), ...(view.totalTokens !== undefined ? { totalTokens: view.totalTokens } : {}), - canStop: !settled && view.stoppable && view.providerId !== undefined, + canStop: agentChildWorkViewOffersStop(view), settled, owned: (ownedByOwner.get(view.id) ?? []) .filter((owned) => !nextPath.has(owned.id)) diff --git a/src/shared/agent-child-work-listing.ts b/src/shared/agent-child-work-listing.ts new file mode 100644 index 00000000000..f13a3bb6ad7 --- /dev/null +++ b/src/shared/agent-child-work-listing.ts @@ -0,0 +1,36 @@ +// Which child work the worktree sidebar and the chat's strip list: running children only, by one +// rule, from every source they read (a CLI pane's hook roster and a chat session's records alike). +// A finished child is in the chat's transcript, not in either list. The host keeps every record; +// this picks from them on each read, so nothing here is stored and nothing can disagree with the +// store. + +import { settledOwnersOfLiveWork } from './agent-status-child-work-liveness' +import type { AgentChildWorkView } from './agent-status-child-work-view' + +/** The one rule for what the sidebar and the strip list: a child that runs. A finished child whose + * own work still runs counts, since it reads monitoring and keeps that work's owner on screen. */ +export function agentChildWorkIsRunning(child: { + settled: boolean + ownsLiveWork: boolean +}): boolean { + return !child.settled || child.ownsLiveWork +} + +/** A session's running children, in store order: what the sidebar and the strip list. */ +export function structuredRunningChildWork( + views: readonly AgentChildWorkView[] +): AgentChildWorkView[] { + const owners = settledOwnersOfLiveWork( + views.map((view) => ({ + id: view.id, + membership: view.membership, + ...(view.parentChildWorkId ? { ownerId: view.parentChildWorkId } : {}) + })) + ) + return views.filter((view) => + agentChildWorkIsRunning({ + settled: view.membership === 'settled', + ownsLiveWork: owners.has(view.id) + }) + ) +} diff --git a/src/shared/agent-child-work-stop-targets.ts b/src/shared/agent-child-work-stop-targets.ts new file mode 100644 index 00000000000..4d49d1dc56f --- /dev/null +++ b/src/shared/agent-child-work-stop-targets.ts @@ -0,0 +1,34 @@ +// What a background Stop reaches, read from the host's child records: the strip draws a row's Stop +// by this rule, conversation-command admission asks for a stop by it, and the host resolves the +// provider ids a Stop sends by it. + +import type { AgentChildWorkView } from './agent-status-child-work-view' + +/** Which stop controls a provider honours. Provider capability, not a fact about any one task. */ +export type AgentSessionBackgroundTaskStops = { + /** A stop can name one task. */ + supportsTaskStop: boolean + /** An untargeted "stop everything" exists. */ + supportsStopAll: boolean +} + +/** The strip offers this child its own stop: it is live, stoppable, and addressable by the id a + * targeted stop names. The host's command admission asks the same question of the same views. */ +export function agentChildWorkViewOffersStop(view: AgentChildWorkView): boolean { + return view.membership !== 'settled' && view.stoppable && view.providerId !== undefined +} + +/** The provider ids a background Stop reaches: the one `taskId` names, or, with none, every child + * the strip offers its own stop. Resolved from the same records the strip and admission read. */ +export function agentChildWorkStopTargets( + views: readonly AgentChildWorkView[] | undefined, + taskId?: string +): string[] { + return (views ?? []).flatMap((view) => + agentChildWorkViewOffersStop(view) && + view.providerId !== undefined && + (taskId === undefined || view.providerId === taskId) + ? [view.providerId] + : [] + ) +} diff --git a/src/shared/agent-session-background-task-child-views-capability.ts b/src/shared/agent-session-background-task-child-views-capability.ts new file mode 100644 index 00000000000..d772456895b --- /dev/null +++ b/src/shared/agent-session-background-task-child-views-capability.ts @@ -0,0 +1,4 @@ +// Why: the strip's roster now carries the host's running child records as views. A reader that +// predates it gets only the legacy task rows derived from them, never the views. +export const AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY = + 'agent-session.background-task-child-views.v1' as const diff --git a/src/shared/agent-session-background-task-state-admission.ts b/src/shared/agent-session-background-task-state-admission.ts new file mode 100644 index 00000000000..839bdb595fe --- /dev/null +++ b/src/shared/agent-session-background-task-state-admission.ts @@ -0,0 +1,21 @@ +// The background-task roster as a client keeps it: `children` decoded once, where the frame enters +// the client's state, so equality and every reader see decoded rows and nothing reads raw wire rows. + +import type { AgentSessionBackgroundTaskState } from './agent-session-wire' +import { backgroundTaskStatesEqual } from './agent-session-background-task-state-equality' +import { decodeAgentChildWorkViews } from './agent-status-child-work-view-wire' + +/** `previous` is kept, identity included, when the frame repeats it, so readers memoized on the + * roster skip an unchanged frame. */ +export function admitAgentSessionBackgroundTaskState( + next: AgentSessionBackgroundTaskState | null | undefined, + previous?: AgentSessionBackgroundTaskState | null +): AgentSessionBackgroundTaskState | null | undefined { + if (!next) { + return next + } + const { children: raw, ...rest } = next + const children = decodeAgentChildWorkViews(raw) + const decoded = { ...rest, ...(children ? { children } : {}) } + return previous && backgroundTaskStatesEqual(decoded, previous) ? previous : decoded +} diff --git a/src/shared/agent-session-background-task-state-equality.ts b/src/shared/agent-session-background-task-state-equality.ts index aa93bb1b9ad..2748375d501 100644 --- a/src/shared/agent-session-background-task-state-equality.ts +++ b/src/shared/agent-session-background-task-state-equality.ts @@ -2,6 +2,7 @@ import { agentSessionBackgroundTasksEqual, type AgentSessionBackgroundTaskState } from './agent-session-wire' +import { agentChildWorkViewsEqual } from './agent-status-child-work-view-wire' /** Structural equality so a republished roster never churns transcript identity. */ export function backgroundTaskStatesEqual( @@ -22,6 +23,7 @@ export function backgroundTaskStatesEqual( } return ( agentSessionBackgroundTasksEqual(left.tasks, right.tasks) && - agentSessionBackgroundTasksEqual(left.settledTasks, right.settledTasks) + agentSessionBackgroundTasksEqual(left.settledTasks, right.settledTasks) && + agentChildWorkViewsEqual(left.children, right.children) ) } diff --git a/src/shared/agent-session-background-task-wire.ts b/src/shared/agent-session-background-task-wire.ts index c5e6893fb0b..a6f2dc846e8 100644 --- a/src/shared/agent-session-background-task-wire.ts +++ b/src/shared/agent-session-background-task-wire.ts @@ -3,6 +3,8 @@ // and the host status feed compare with. Split out of `agent-session-wire.ts` // when that file reached its line budget; the definitions below are unchanged. +import type { AgentChildWorkView } from './agent-status-child-work-view' + /** Per-task run state, reusing the agent-state vocabulary the dashboard already * renders. Optional on the wire: an old host sends none and clients fall back * to kind-derived defaults. */ @@ -48,6 +50,11 @@ export type AgentSessionBackgroundTaskState = { * read absence as "no stop" would hide a working control on those hosts. * A host whose provider exposes no honest stop sends `false`. */ supportsStopAll?: boolean + /** The host's child records for this session as views, live and recently settled; `tasks` and + * `settledTasks` are derived from them. Absent from older hosts. A reader that does not + * advertise `agent-session.background-task-child-views.v1` never receives a state whose only + * rows are settled: to it, as before, the roster is live work. */ + children?: AgentChildWorkView[] } function backgroundTaskFieldsEqual( diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index a1a0746db02..3100087faa1 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -4,6 +4,7 @@ import type { } from './agent-session-background-task-wire' import type { AgentSessionRewindReason, AgentSessionRewindSupport } from './agent-session-rewind' import type { AgentSessionWireRefusal } from './agent-session-wire-refusals' +import type { AgentChildWorkView } from './agent-status-child-work-view' import type { AgentSessionQueuedMessage, AgentSessionQueuePause @@ -246,8 +247,14 @@ export type AgentSessionStatusSummary = { turnOutcome?: AgentTurnOutcome /** Live provider-owned background tasks, so session lists can render * subagent children without holding a journal reader open. Optional for - * mixed-version hosts. */ + * mixed-version hosts. Derived from `children` on hosts that publish it. */ backgroundTasks?: AgentSessionBackgroundTask[] + /** The host's running child records for this session, as views: live ones, and a finished one + * whose own work still runs (it reads monitoring); finished children ride the background-task + * channel only. Absent from older hosts; decode with `decodeAgentChildWorkViews`. Usage is + * omitted, and an evidence clock that only ticked does not republish: per-tick freshness rides + * the background-task channel. */ + children?: AgentChildWorkView[] providerSession?: AgentProviderSessionMetadata updatedAt: number /** When the session's own agent entered `status`, dated by its own lifecycle edges and never by diff --git a/src/shared/agent-status-child-work-admission-core.ts b/src/shared/agent-status-child-work-admission-core.ts index 31c9db7cd42..6ce390c4261 100644 --- a/src/shared/agent-status-child-work-admission-core.ts +++ b/src/shared/agent-status-child-work-admission-core.ts @@ -1,3 +1,7 @@ +import { + agentChildWorkEndingConflicts, + mergedAgentChildWorkEnding +} from './agent-status-child-work-ending' import { serializeAgentChildWorkAliasKey, type AgentChildWorkAliasInput, @@ -167,6 +171,10 @@ function parseObservationFacts( : parseAgentChildWorkProviderTiming(request.providerTiming) return { outcome: request.outcome, + outcomeBasis: + request.membership === 'settled' && request.outcomeBasis === 'stop-acknowledged' + ? request.outcomeBasis + : undefined, name: normalizeChildWorkText(request.name, AGENT_CHILD_WORK_LABEL_MAX_LENGTH), description: normalizeChildWorkText( request.description, @@ -196,11 +204,7 @@ function mergeObservationFacts( run: AgentChildWorkRecord | undefined ): AgentChildWorkFacts { return { - // Refine-only: an `unknown` ending claims nothing, so a definite one stands. - outcome: - said.outcome === undefined || said.outcome === 'unknown' - ? (run?.outcome ?? said.outcome) - : said.outcome, + ...mergedAgentChildWorkEnding(said, run), name: said.name ?? prior?.name, description: said.description ?? prior?.description, agentType: said.agentType ?? prior?.agentType, @@ -270,21 +274,6 @@ export function commitAgentChildWork( : rejectAgentChildWorkAdmission('store-rejected') } -/** Settled history only gains precision: an `unknown` ending may become a definite one (a roster - * omission can land a tick before the frame naming the outcome), and a definite ending never - * changes to another. An omitted outcome counts as `unknown`. */ -function conflictsWithSettled( - child: AgentChildWorkRecord, - request: AgentChildWorkAnnounceRequest | AgentChildWorkAdoptRequest -): boolean { - const requested = request.outcome ?? 'unknown' - return ( - request.membership !== 'settled' || - request.state !== child.state || - (requested !== 'unknown' && child.outcome !== 'unknown' && requested !== child.outcome) - ) -} - export function updateExistingAgentChildWork( store: AgentStatusStore, request: AgentChildWorkAnnounceRequest | AgentChildWorkAdoptRequest, @@ -292,7 +281,7 @@ export function updateExistingAgentChildWork( aliases: AgentChildWorkAliasInput[], removeAliases: string[] = [] ): AgentChildWorkAdmissionResult { - if (child.membership === 'settled' && conflictsWithSettled(child, request)) { + if (child.membership === 'settled' && agentChildWorkEndingConflicts(child, request)) { return rejectAgentChildWorkAdmission('stale-invocation') } const updated = buildAgentChildWork( diff --git a/src/shared/agent-status-child-work-admission.ts b/src/shared/agent-status-child-work-admission.ts index cf839485be8..087f687c313 100644 --- a/src/shared/agent-status-child-work-admission.ts +++ b/src/shared/agent-status-child-work-admission.ts @@ -6,6 +6,7 @@ import type { AgentChildWorkMembership, AgentChildWorkOperation, AgentChildWorkOutcome, + AgentChildWorkOutcomeBasis, AgentChildWorkProviderTiming, AgentChildWorkProvenance, AgentChildWorkRecord, @@ -37,6 +38,8 @@ export type AgentChildWorkObservationFields = { state: AgentChildWorkState membership: AgentChildWorkMembership outcome?: AgentChildWorkOutcome + /** Only with a settled `outcome`; see `AgentChildWorkOutcomeBasis`. */ + outcomeBasis?: AgentChildWorkOutcomeBasis name?: string description?: string agentType?: string diff --git a/src/shared/agent-status-child-work-codec.ts b/src/shared/agent-status-child-work-codec.ts index f335bbae37e..e5551514b9d 100644 --- a/src/shared/agent-status-child-work-codec.ts +++ b/src/shared/agent-status-child-work-codec.ts @@ -162,6 +162,7 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | ], [ 'outcome', + 'outcomeBasis', 'name', 'description', 'agentType', @@ -182,6 +183,8 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | !isState(value.state) || !isMembership(value.membership) || (value.outcome !== undefined && !isOutcome(value.outcome)) || + (value.outcomeBasis !== undefined && + (value.outcomeBasis !== 'stop-acknowledged' || value.membership !== 'settled')) || (value.settledAt !== undefined && !isTimestamp(value.settledAt)) || !isTimestamp(value.firstObservedAt) || !isTimestamp(value.observedAt) || @@ -266,6 +269,7 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput | state: value.state, membership: value.membership, ...(outcome !== undefined ? { outcome } : {}), + ...(value.outcomeBasis === 'stop-acknowledged' ? { outcomeBasis: value.outcomeBasis } : {}), ...(labels.name ? { name: labels.name } : {}), ...(labels.description ? { description: labels.description } : {}), ...(labels.agentType ? { agentType: labels.agentType } : {}), diff --git a/src/shared/agent-status-child-work-ending.ts b/src/shared/agent-status-child-work-ending.ts new file mode 100644 index 00000000000..a9c784f0701 --- /dev/null +++ b/src/shared/agent-status-child-work-ending.ts @@ -0,0 +1,58 @@ +// How a child's ending may change once recorded. Settled history only gains precision: an +// `unknown` ending may become a definite one (a roster omission can land a tick before the frame +// naming the outcome), and a definite ending never changes to another, except that the child's own +// ending replaces one an acknowledged Stop gave it: the provider can hand Orca the acknowledgement +// ahead of the child's earlier frame. An omitted outcome counts as `unknown`. + +import type { + AgentChildWorkMembership, + AgentChildWorkOutcome, + AgentChildWorkOutcomeBasis, + AgentChildWorkRecord, + AgentChildWorkState +} from './agent-status-child-work' + +type Ending = { outcome?: AgentChildWorkOutcome; outcomeBasis?: AgentChildWorkOutcomeBasis } +/** Both said: `undefined` is "no ending", as every admission fact reads it. */ +type KeptEnding = { + outcome: AgentChildWorkOutcome | undefined + outcomeBasis: AgentChildWorkOutcomeBasis | undefined +} + +/** Whether an observation of a settled child contradicts what it settled with. */ +export function agentChildWorkEndingConflicts( + child: AgentChildWorkRecord, + request: Ending & { membership: AgentChildWorkMembership; state: AgentChildWorkState } +): boolean { + const requested = request.outcome ?? 'unknown' + const reportedOverProvisional = + child.outcomeBasis === 'stop-acknowledged' && request.outcomeBasis === undefined + return ( + request.membership !== 'settled' || + request.state !== child.state || + (requested !== 'unknown' && + child.outcome !== 'unknown' && + requested !== child.outcome && + !reportedOverProvisional) + ) +} + +/** The ending a record keeps. `run` is the stored record only when the observation continues its + * invocation. */ +export function mergedAgentChildWorkEnding( + said: Ending, + run: (Ending & { membership?: string }) | undefined +): KeptEnding { + const saidDefinite = said.outcome !== undefined && said.outcome !== 'unknown' + // The child's own definite ending stands against an acknowledged Stop that says the same. + const reportedStands = + run?.membership === 'settled' && + run.outcome !== undefined && + run.outcome !== 'unknown' && + run.outcomeBasis === undefined + return { + // Refine-only: an `unknown` ending claims nothing, so a definite one stands. + outcome: saidDefinite ? said.outcome : (run?.outcome ?? said.outcome), + outcomeBasis: saidDefinite && !reportedStands ? said.outcomeBasis : run?.outcomeBasis + } +} diff --git a/src/shared/agent-status-child-work-evidence-admission.ts b/src/shared/agent-status-child-work-evidence-admission.ts index b78859ff840..24d06c0cdb3 100644 --- a/src/shared/agent-status-child-work-evidence-admission.ts +++ b/src/shared/agent-status-child-work-evidence-admission.ts @@ -8,6 +8,7 @@ import type { import type { AgentChildWorkOperation, AgentChildWorkOutcome, + AgentChildWorkOutcomeBasis, AgentChildWorkRecord } from './agent-status-child-work' import type { AgentChildWorkLiveObservation } from './agent-status-child-work-evidence' @@ -198,7 +199,11 @@ export function settleAgentChildWork( existing: AgentChildWorkRecord, outcome: AgentChildWorkOutcome, observedAt: number, - reported: { lastMessage?: string; totalTokens?: number } = {} + reported: { + lastMessage?: string + totalTokens?: number + basis?: AgentChildWorkOutcomeBasis + } = {} ): void { const current = currentAgentChildWorkAliases(ctx, existing) const handleId = current.stableId ?? existing.childWorkId @@ -212,6 +217,7 @@ export function settleAgentChildWork( state: 'done', membership: 'settled', outcome, + ...(reported.basis !== undefined ? { outcomeBasis: reported.basis } : {}), ...(reported.totalTokens !== undefined ? { totalTokens: reported.totalTokens } : {}), ...(reported.lastMessage !== undefined ? { lastMessage: reported.lastMessage } : {}), observedAt: Math.max(observedAt, existing.observedAt), diff --git a/src/shared/agent-status-child-work-evidence.ts b/src/shared/agent-status-child-work-evidence.ts index 8df08126827..f05be66c3d0 100644 --- a/src/shared/agent-status-child-work-evidence.ts +++ b/src/shared/agent-status-child-work-evidence.ts @@ -10,6 +10,7 @@ import type { AgentChildWorkKind, AgentChildWorkOperation, AgentChildWorkOutcome, + AgentChildWorkOutcomeBasis, AgentChildWorkResidency, AgentChildWorkState } from './agent-status-child-work' @@ -61,18 +62,27 @@ export type AgentChildWorkOperationEvidence = { operation: AgentChildWorkOperation | null } -/** The child's own terminal frame. `unknown` is an ending whose status the provider did not say. */ +/** The child's own terminal frame. `unknown` is an ending whose status the provider did not say. + * With `basis`, the ending is the provider acknowledging a Stop, which the child's own terminal + * frame of the same run still replaces. */ export type AgentChildWorkEndedEvidence = { type: 'ended' observedAt: number handle: AgentChildWorkEvidenceHandle outcome: AgentChildWorkOutcome + basis?: AgentChildWorkOutcomeBasis lastMessage?: string totalTokens?: number } +/** The user's next turn began: the provider accepted their next send. A turn the provider opens + * on its own is not one. Settled children are kept, with their outcome, until then; a settled + * child that still owns live work, at any depth, stays so that work keeps its owners. */ +export type AgentChildWorkTurnStartedEvidence = { type: 'turn-started'; observedAt: number } + /** The provider session is gone: a child still live can no longer end on its own, so it settles - * with an outcome nobody reported. Settled children stay; the parent's removal drops them. */ + * with an outcome nobody reported. Settled children stay until the user's next turn or the + * parent's removal. */ export type AgentChildWorkSessionEndedEvidence = { type: 'session-ended'; observedAt: number } /** Work that leaves nothing to report once it stops, such as a command whose process exited: its @@ -88,4 +98,5 @@ export type AgentChildWorkEvidence = | AgentChildWorkOperationEvidence | AgentChildWorkEndedEvidence | AgentChildWorkRemovedEvidence + | AgentChildWorkTurnStartedEvidence | AgentChildWorkSessionEndedEvidence diff --git a/src/shared/agent-status-child-work-liveness.ts b/src/shared/agent-status-child-work-liveness.ts index 693b4f29560..3a359e12978 100644 --- a/src/shared/agent-status-child-work-liveness.ts +++ b/src/shared/agent-status-child-work-liveness.ts @@ -1,4 +1,8 @@ -import type { AgentChildWorkKind, AgentChildWorkState } from './agent-status-child-work' +import type { + AgentChildWorkKind, + AgentChildWorkMembership, + AgentChildWorkState +} from './agent-status-child-work' /** Three live arms by design, ranked: a child waiting on a human is `waiting`; otherwise any live * agent work reads as `working`; `monitoring` only when shells and monitors are the sole live @@ -73,3 +77,35 @@ export function agentChildWorkLiveness( hasLiveNonAgentWork }) } + +/** One child in the ownership tree: its id, the id of the child that owns it, its membership. */ +export type AgentChildWorkOwnershipNode = { + id: string + ownerId?: string + membership: AgentChildWorkMembership +} + +/** Settled children that still own live work, through any depth of ownership. The one rule for + * "a finished child whose work still runs": it lists as monitoring and outlives the next turn, so + * that work keeps every owner up to the child the user launched. */ +export function settledOwnersOfLiveWork( + children: readonly AgentChildWorkOwnershipNode[] +): Set { + const byId = new Map(children.map((child) => [child.id, child])) + const owners = new Set() + for (const child of children) { + if (child.membership !== 'live') { + continue + } + const seen = new Set([child.id]) + let owner = child.ownerId ? byId.get(child.ownerId) : undefined + while (owner && !seen.has(owner.id)) { + seen.add(owner.id) + if (owner.membership === 'settled') { + owners.add(owner.id) + } + owner = owner.ownerId ? byId.get(owner.ownerId) : undefined + } + } + return owners +} diff --git a/src/shared/agent-status-child-work-reconciliation.test.ts b/src/shared/agent-status-child-work-reconciliation.test.ts index 79ffc1c5761..818f809638a 100644 --- a/src/shared/agent-status-child-work-reconciliation.test.ts +++ b/src/shared/agent-status-child-work-reconciliation.test.ts @@ -8,6 +8,8 @@ import type { import { reconcileAgentChildWorkEvidence } from './agent-status-child-work-reconciliation' import { STRUCTURED_CHILD_WORK_MAX_LIVE } from './agent-status-child-work-evidence-admission' import { createAgentStatusStore, type AgentStatusStore } from './agent-status-store' +import { projectAgentChildWorkViews } from './agent-status-child-work-view' +import { structuredRunningChildWork } from './agent-child-work-listing' import { makeStructuredAgentStatusSubject } from './agent-status-subject' const parent = makeStructuredAgentStatusSubject( @@ -425,4 +427,119 @@ describe('structured child-work reconciliation', () => { ).toMatchObject({ removed: 0 }) expect(records(store)).toHaveLength(1) }) + + // The product rule: a finished subagent stays listed, with how it ended, until the parent's next + // turn begins. A finished child whose shell still runs stays so that shell keeps its owner. + it("keeps finished children until the session's next turn starts, then only owners of live work", () => { + const { store, apply } = harness() + const ended = (id: string, observedAt: number): AgentChildWorkEvidence => ({ + type: 'ended', + observedAt, + handle: { idKind: 'task_id', id }, + outcome: 'succeeded' + }) + apply(live(child('finished')), ended('finished', 101)) + apply(live(child('owner')), live(child('shell', { kind: 'command', ownerId: 'owner' }))) + apply(ended('owner', 102), live(child('running'))) + expect(records(store).map((record) => [record.description, record.membership])).toEqual([ + ['Task finished', 'settled'], + ['Task owner', 'settled'], + ['Task shell', 'live'], + ['Task running', 'live'] + ]) + + expect(apply({ type: 'turn-started', observedAt: 104 })).toMatchObject({ removed: 1 }) + expect(records(store).map((record) => record.description)).toEqual([ + 'Task owner', + 'Task shell', + 'Task running' + ]) + }) + + it('keeps every finished owner above live work at the next turn, however deep', () => { + const { store, apply } = harness() + const ended = (id: string, observedAt: number): AgentChildWorkEvidence => ({ + type: 'ended', + observedAt, + handle: { idKind: 'task_id', id }, + outcome: 'succeeded' + }) + // A settled agent owns a settled agent that owns a live command. + apply( + live(child('top')), + live(child('nested', { ownerId: 'top' })), + live(child('shell', { kind: 'command', ownerId: 'nested' })), + ended('nested', 101), + ended('top', 102) + ) + const tree = () => { + const all = records(store) + return all.map((record) => [ + record.description, + record.membership, + all.find((owner) => owner.childWorkId === record.parentChildWorkId)?.description ?? null + ]) + } + const before = tree() + expect(before).toEqual([ + ['Task top', 'settled', null], + ['Task nested', 'settled', 'Task top'], + ['Task shell', 'live', 'Task nested'] + ]) + const surfaces = () => { + const all = records(store) + const views = projectAgentChildWorkViews( + all, + all.flatMap((record) => store.getAliasesForChild(record.childWorkId)) + ) + return { + running: structuredRunningChildWork(views).map((view) => view.description) + } + } + expect(surfaces().running).toEqual(['Task top', 'Task nested', 'Task shell']) + + expect(apply({ type: 'turn-started', observedAt: 200 })).toMatchObject({ removed: 0 }) + expect(tree()).toEqual(before) + expect(surfaces()).toEqual({ running: ['Task top', 'Task nested', 'Task shell'] }) + }) + + it("lets the child's own ending replace an acknowledged Stop's, and nothing else replace its own", () => { + const ended = ( + id: string, + outcome: 'succeeded' | 'failed' | 'cancelled', + observedAt: number, + extra: Partial> = {} + ): AgentChildWorkEvidence => ({ + type: 'ended', + observedAt, + handle: { idKind: 'task_id', id }, + outcome, + ...extra + }) + const stopped = { basis: 'stop-acknowledged' as const } + const { store, apply } = harness() + apply(live(child('replaced')), live(child('kept')), live(child('same'))) + apply( + ended('replaced', 'cancelled', 101, stopped), + ended('replaced', 'succeeded', 102, { lastMessage: 'all tests passed' }), + ended('kept', 'failed', 101, { lastMessage: 'Exit code 1' }), + ended('kept', 'cancelled', 102, stopped), + ended('same', 'cancelled', 101, stopped), + ended('same', 'cancelled', 102) + ) + expect( + records(store).map((record) => [ + record.description, + record.outcome, + record.outcomeBasis ?? 'reported', + record.lastMessage ?? null + ]) + ).toEqual([ + ['Task replaced', 'succeeded', 'reported', 'all tests passed'], + ['Task kept', 'failed', 'reported', 'Exit code 1'], + ['Task same', 'cancelled', 'reported', null] + ]) + // Once the child reported its ending, a later acknowledged Stop changes nothing either. + expect(apply(ended('same', 'failed', 103, stopped))).toMatchObject({ settled: 0 }) + }) }) diff --git a/src/shared/agent-status-child-work-reconciliation.ts b/src/shared/agent-status-child-work-reconciliation.ts index 43f23b85483..c37b5b7422e 100644 --- a/src/shared/agent-status-child-work-reconciliation.ts +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -2,11 +2,12 @@ // // The store holds the only current record per child; evidence patches it. A child settles on its // own ending, or `unknown` when its session ends while it is still live; work with nothing to -// report once it stops is removed instead. Settled children stay until the host drops the parent's -// row. It owns only the records its own producer admitted, and never claims an outcome the -// evidence did not report. +// report once it stops is removed instead. Settled children stay until the user's next turn (the +// next send the provider accepts) or the host drops the parent's row. It owns only the records its +// own producer admitted, and never claims an outcome the evidence did not report. import type { AgentChildWorkAdmission } from './agent-status-child-work-admission' +import { settledOwnersOfLiveWork } from './agent-status-child-work-liveness' import type { AgentChildWorkEndedEvidence, AgentChildWorkEvidence, @@ -55,7 +56,8 @@ function applyEnded(ctx: ReconcileContext, edge: AgentChildWorkEndedEvidence): v // lands its evidence; a conflicting definite one is refused as `stale-invocation`. settleAgentChildWork(ctx, existing, edge.outcome, edge.observedAt, { ...(edge.lastMessage !== undefined ? { lastMessage: edge.lastMessage } : {}), - ...(edge.totalTokens !== undefined ? { totalTokens: edge.totalTokens } : {}) + ...(edge.totalTokens !== undefined ? { totalTokens: edge.totalTokens } : {}), + ...(edge.basis !== undefined ? { basis: edge.basis } : {}) }) } @@ -98,6 +100,12 @@ function settleLive(ctx: ReconcileContext, observedAt: number): void { } } +function removeChildren(ctx: ReconcileContext, childWorkIds: string[]): void { + if (childWorkIds.length > 0 && ctx.store.applyMutation({ removeChildren: childWorkIds })) { + ctx.outcome.removed += childWorkIds.length + } +} + /** The work is gone and has no ending to keep: its record, and the handles it answered to, go. */ function applyRemoved(ctx: ReconcileContext, edge: AgentChildWorkRemovedEvidence): void { const resolution = resolveAgentChildWorkHandle(ctx, [edge.handle.idKind], edge.handle.id) @@ -109,9 +117,23 @@ function applyRemoved(ctx: ReconcileContext, edge: AgentChildWorkRemovedEvidence if (!existing || agentChildWorkRunVerdict(ctx, existing, edge.handle.runId) === 'previous') { return } - if (ctx.store.applyMutation({ removeChildren: [existing.childWorkId] })) { - ctx.outcome.removed += 1 - } + removeChildren(ctx, [existing.childWorkId]) +} + +/** Settled children, less any that still owns live work at any depth: those stay so that work + * keeps every owner, by the same rule the surfaces list them by. */ +function removableSettled(ctx: ReconcileContext): string[] { + const owned = ownedStructuredChildWork(ctx) + const owners = settledOwnersOfLiveWork( + owned.map((record) => ({ + id: record.childWorkId, + membership: record.membership, + ...(record.parentChildWorkId ? { ownerId: record.parentChildWorkId } : {}) + })) + ) + return owned + .filter((record) => record.membership === 'settled' && !owners.has(record.childWorkId)) + .map((record) => record.childWorkId) } /** Apply one batch of evidence. The parent must already be held: the store refuses a child whose @@ -135,6 +157,8 @@ export function reconcileAgentChildWorkEvidence( applyEnded(ctx, edge) } else if (edge.type === 'removed') { applyRemoved(ctx, edge) + } else if (edge.type === 'turn-started') { + removeChildren(ctx, removableSettled(ctx)) } else { settleLive(ctx, edge.observedAt) } diff --git a/src/shared/agent-status-child-work-view-wire.test.ts b/src/shared/agent-status-child-work-view-wire.test.ts new file mode 100644 index 00000000000..cc393b93941 --- /dev/null +++ b/src/shared/agent-status-child-work-view-wire.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import type { AgentChildWorkView } from './agent-status-child-work-view' +import { + agentChildWorkViewsEqual, + decodeAgentChildWorkViews +} from './agent-status-child-work-view-wire' + +const LIVE: AgentChildWorkView = { + id: 'child-1', + providerId: 'task-1', + kind: 'agent', + name: 'Explore', + description: 'Map call sites', + agentType: 'Explore', + state: 'working', + membership: 'live', + operation: { toolName: 'Read', input: 'src/a.ts', basis: 'open', observedAt: 150 }, + lastMessage: 'Reading', + firstObservedAt: 100, + observedAt: 150, + totalTokens: 900, + stoppable: true, + invocation: { invocationId: 'toolu_1', generation: 1 } +} + +const SETTLED: AgentChildWorkView = { + id: 'child-2', + kind: 'command', + state: 'done', + membership: 'settled', + outcome: 'failed', + parentChildWorkId: 'child-1', + firstObservedAt: 100, + observedAt: 200, + settledAt: 200, + stoppable: false, + invocation: { invocationId: 'task-2', generation: 2 } +} + +describe('decodeAgentChildWorkViews', () => { + it('reads what this build publishes unchanged', () => { + expect(decodeAgentChildWorkViews(JSON.parse(JSON.stringify([LIVE, SETTLED])))).toEqual([ + LIVE, + SETTLED + ]) + }) + + it('reads an older host that publishes no views as absent, not as "no children"', () => { + expect(decodeAgentChildWorkViews(undefined)).toBeUndefined() + expect(decodeAgentChildWorkViews({ not: 'a list' })).toBeUndefined() + expect(decodeAgentChildWorkViews([])).toEqual([]) + }) + + // Rules 1 and 4 of the remote-wire contract: a newer host's view is read, never refused. + it('ignores keys and degrades arms a newer host may add, keeping the row', () => { + const newer = { + ...LIVE, + futureField: { nested: true }, + kind: 'teammate', + state: 'paused', + operation: { ...LIVE.operation, basis: 'streamed', extra: 1 } + } + expect(decodeAgentChildWorkViews([newer])).toEqual([ + { + ...LIVE, + kind: 'unknown', + state: 'unverifiable', + operation: { ...LIVE.operation, basis: 'reported' } + } + ]) + const settledNewer = { ...SETTLED, outcome: 'timed-out', membership: 'settled' } + expect(decodeAgentChildWorkViews([settledNewer])).toEqual([{ ...SETTLED, outcome: 'unknown' }]) + // A membership this build cannot place asserts nothing about the child. + expect(decodeAgentChildWorkViews([{ ...LIVE, membership: 'archived' }])).toEqual([ + { ...LIVE, state: 'unverifiable' } + ]) + }) + + it('withholds a stop it cannot prove and drops only rows it cannot identify', () => { + const { stoppable: _stoppable, ...unsure } = LIVE + expect(decodeAgentChildWorkViews([unsure])).toEqual([{ ...LIVE, stoppable: false }]) + expect( + decodeAgentChildWorkViews([ + { ...LIVE, id: '' }, + { ...LIVE, observedAt: 'soon' }, + { ...LIVE, invocation: null }, + 'not a view', + SETTLED + ]) + ).toEqual([SETTLED]) + }) +}) + +describe('agentChildWorkViewsEqual', () => { + it('compares every field, and tolerates only a small evidence-clock advance when asked', () => { + const ticked = { + ...LIVE, + observedAt: 30_150, + operation: { ...LIVE.operation!, observedAt: 30_150 } + } + expect(agentChildWorkViewsEqual([LIVE], [{ ...LIVE }])).toBe(true) + expect(agentChildWorkViewsEqual([LIVE], [ticked])).toBe(false) + expect(agentChildWorkViewsEqual([LIVE], [ticked], 60_000)).toBe(true) + expect(agentChildWorkViewsEqual([LIVE], [{ ...ticked, observedAt: 60_151 }], 60_000)).toBe( + false + ) + expect(agentChildWorkViewsEqual([LIVE], [{ ...LIVE, lastMessage: 'Done' }], 60_000)).toBe(false) + expect(agentChildWorkViewsEqual(undefined, [])).toBe(false) + }) +}) diff --git a/src/shared/agent-status-child-work-view-wire.ts b/src/shared/agent-status-child-work-view-wire.ts new file mode 100644 index 00000000000..0eb98fe225d --- /dev/null +++ b/src/shared/agent-status-child-work-view-wire.ts @@ -0,0 +1,189 @@ +// How a child-work view crosses a client/host boundary. +// +// Views reach clients from hosts of any version, so they are read permissively: unknown keys are +// ignored and an unknown enum arm degrades to one every reader already handles, never rejecting the +// row (docs/reference/remote-wire-compatibility.md, Rules 1 and 4). The record codec stays +// host-internal; nothing here loads it. Type-only references keep this loadable in the renderer. + +import type { + AgentChildWorkKind, + AgentChildWorkMembership, + AgentChildWorkOperation, + AgentChildWorkOperationBasis, + AgentChildWorkOutcome, + AgentChildWorkState +} from './agent-status-child-work' +import type { AgentChildWorkView } from './agent-status-child-work-view' + +const KINDS: readonly AgentChildWorkKind[] = ['agent', 'workflow', 'command', 'monitor', 'unknown'] +const STATES: readonly AgentChildWorkState[] = [ + 'working', + 'monitoring', + 'waiting', + 'blocked', + 'done', + 'idle', + 'unverifiable' +] +const OUTCOMES: readonly AgentChildWorkOutcome[] = ['succeeded', 'failed', 'cancelled', 'unknown'] +const BASES: readonly AgentChildWorkOperationBasis[] = ['open', 'reported'] +const MEMBERSHIPS: readonly AgentChildWorkMembership[] = ['live', 'settled'] + +function arm(arms: readonly T[], value: unknown): T | undefined { + return arms.find((candidate) => candidate === value) +} + +function text(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function clock(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined +} + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value)) + : null +} + +function decodeOperation(value: unknown, observedAt: number): AgentChildWorkOperation | undefined { + const operation = record(value) + const toolName = text(operation?.toolName) + if (!operation || !toolName) { + return undefined + } + const input = text(operation.input) + return { + toolName, + ...(input ? { input } : {}), + // Only `open` promises an end edge; an arm this build does not know promises nothing. + basis: arm(BASES, operation.basis) ?? 'reported', + observedAt: clock(operation.observedAt) ?? observedAt + } +} + +function decodeView(value: unknown): AgentChildWorkView | null { + const view = record(value) + const invocation = record(view?.invocation) + const id = text(view?.id) + const firstObservedAt = clock(view?.firstObservedAt) + const observedAt = clock(view?.observedAt) + const invocationId = text(invocation?.invocationId) + const generation = clock(invocation?.generation) + if ( + !view || + !id || + firstObservedAt === undefined || + observedAt === undefined || + !invocationId || + generation === undefined + ) { + return null + } + // A membership this build cannot place is kept as a live row that asserts nothing. + const membership = arm(MEMBERSHIPS, view.membership) + const settled = membership === 'settled' + const state = membership ? (arm(STATES, view.state) ?? 'unverifiable') : 'unverifiable' + const operation = settled ? undefined : decodeOperation(view.operation, observedAt) + const settledAt = settled ? (clock(view.settledAt) ?? observedAt) : undefined + const providerId = text(view.providerId) + const name = text(view.name) + const description = text(view.description) + const agentType = text(view.agentType) + const model = text(view.model) + const lastMessage = text(view.lastMessage) + const parentChildWorkId = text(view.parentChildWorkId) + const totalTokens = clock(view.totalTokens) + return { + id, + ...(providerId ? { providerId } : {}), + ...(name ? { name } : {}), + ...(description ? { description } : {}), + ...(agentType ? { agentType } : {}), + ...(model ? { model } : {}), + ...(lastMessage ? { lastMessage } : {}), + ...(parentChildWorkId ? { parentChildWorkId } : {}), + kind: arm(KINDS, view.kind) ?? 'unknown', + state: settled ? 'done' : state, + membership: membership ?? 'live', + ...(settled ? { outcome: arm(OUTCOMES, view.outcome) ?? 'unknown' } : {}), + ...(operation ? { operation } : {}), + firstObservedAt, + observedAt, + ...(settledAt !== undefined ? { settledAt } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}), + // Withholding a stop is a degrade; offering one the host cannot honour is not. + stoppable: view.stoppable === true, + invocation: { invocationId, generation } + } +} + +/** A host's published views, or undefined when it published none (an older host). A malformed + * entry is dropped; one carrying an arm this build does not know is kept, degraded. */ +export function decodeAgentChildWorkViews(value: unknown): AgentChildWorkView[] | undefined { + if (!Array.isArray(value)) { + return undefined + } + return value.flatMap((entry) => { + const view = decodeView(entry) + return view ? [view] : [] + }) +} + +function operationsEqual( + a: AgentChildWorkOperation | undefined, + b: AgentChildWorkOperation | undefined, + clockToleranceMs: number +): boolean { + if (!a || !b) { + return a === b + } + return ( + a.toolName === b.toolName && + a.input === b.input && + a.basis === b.basis && + Math.abs(a.observedAt - b.observedAt) <= clockToleranceMs + ) +} + +function viewsEqual(a: AgentChildWorkView, b: AgentChildWorkView, clockToleranceMs: number) { + return ( + a.id === b.id && + a.providerId === b.providerId && + a.kind === b.kind && + a.name === b.name && + a.description === b.description && + a.agentType === b.agentType && + a.model === b.model && + a.state === b.state && + a.membership === b.membership && + a.outcome === b.outcome && + operationsEqual(a.operation, b.operation, clockToleranceMs) && + a.lastMessage === b.lastMessage && + a.parentChildWorkId === b.parentChildWorkId && + a.firstObservedAt === b.firstObservedAt && + Math.abs(a.observedAt - b.observedAt) <= clockToleranceMs && + a.settledAt === b.settledAt && + a.totalTokens === b.totalTokens && + a.stoppable === b.stoppable && + a.invocation.invocationId === b.invocation.invocationId && + a.invocation.generation === b.invocation.generation + ) +} + +/** Field equality for view lists. `clockToleranceMs` lets a reader that does not need per-tick + * freshness treat an evidence clock that only advanced by less than that as unchanged. */ +export function agentChildWorkViewsEqual( + a: readonly AgentChildWorkView[] | undefined, + b: readonly AgentChildWorkView[] | undefined, + clockToleranceMs = 0 +): boolean { + if (a === b) { + return true + } + if (!a || !b || a.length !== b.length) { + return false + } + return a.every((view, index) => viewsEqual(view, b[index], clockToleranceMs)) +} diff --git a/src/shared/agent-status-child-work.ts b/src/shared/agent-status-child-work.ts index c537bae4acc..1012d3e47a8 100644 --- a/src/shared/agent-status-child-work.ts +++ b/src/shared/agent-status-child-work.ts @@ -35,6 +35,9 @@ export type AgentChildWorkResidency = (typeof AGENT_CHILD_WORK_RESIDENCIES)[numb /** `open`: a start edge was seen and no end yet. `reported`: the provider's latest heartbeat * named it; no end edge will come, so the next report or the settlement replaces it. */ export type AgentChildWorkOperationBasis = (typeof AGENT_CHILD_WORK_OPERATION_BASES)[number] +/** An ending the child did not report itself: the provider acknowledged a Stop. The child's own + * ending of the same run may still replace it; nothing else may. */ +export type AgentChildWorkOutcomeBasis = 'stop-acknowledged' /** What the child is doing now, in the vocabulary a hook-reported row uses for its own tool. */ export type AgentChildWorkOperation = { @@ -74,6 +77,8 @@ export type AgentChildWorkInput = { state: AgentChildWorkState membership: AgentChildWorkMembership outcome?: AgentChildWorkOutcome + /** Host-only, and only on a settled record: absent means the child reported its own ending. */ + outcomeBasis?: AgentChildWorkOutcomeBasis name?: string description?: string agentType?: string diff --git a/src/shared/codex-child-command-description.ts b/src/shared/codex-child-command-description.ts new file mode 100644 index 00000000000..212a420d705 --- /dev/null +++ b/src/shared/codex-child-command-description.ts @@ -0,0 +1,38 @@ +// How a Codex command reads on a flat task row: bounded, and, for a child agent's command, where +// nothing nests it under its agent, as " — ". + +const MAX_DESCRIPTION_CHARS = 512 + +/** The label's reserved share of the description. Reserved, not merely capped: + * a label free to spend the whole budget clips away the command it qualifies, + * leaving a command row naming an agent and no command — the failure this + * qualification exists to remove, in the other direction. */ +const MAX_LABEL_CHARS = 96 + +/** Every cut goes through here, clipped the way `boundSubagentField` clips the same provider + * string on the agent row: never mid surrogate pair, since a lone surrogate is lossy through any + * non-JSON UTF-8 hop. A composed row is cut a SECOND time, so a clip that is safe only where the + * label is bounded is not safe. */ +function boundText(value: string, max: number): string { + if (value.length <= max) { + return value + } + const keep = max - 1 + const last = value.charCodeAt(keep - 1) + const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep + return `${value.slice(0, end)}…` +} + +/** A command's own description, bounded as the tracker admits it. */ +export function boundCodexCommandDescription(command: string): string { + return boundText(command, MAX_DESCRIPTION_CHARS) +} + +/** Capped at the bound an admitted command description already respects. */ +export function codexChildCommandDescription( + label: string, + description: string | undefined +): string { + const name = boundText(label, MAX_LABEL_CHARS) + return boundText(description ? `${name} — ${description}` : name, MAX_DESCRIPTION_CHARS) +} diff --git a/src/shared/main-agent-status-parity.test.ts b/src/shared/main-agent-status-parity.test.ts index b85fed42592..523e13c3518 100644 --- a/src/shared/main-agent-status-parity.test.ts +++ b/src/shared/main-agent-status-parity.test.ts @@ -461,7 +461,7 @@ describe('mainAgent status parity across lanes', () => { it.each(storiesFor('structured'))('%s', (_name, lane) => { const row = structuredAgentSessionAgentStatus({ status: lane.status, - backgroundTasks: lane.backgroundTasks, + childWork: lane.backgroundTasks, turnOutcome: lane.turnOutcome }) expect(row).toEqual(lane.expect) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 5e0df0b25d1..55c99feed2f 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,3 +1,4 @@ +import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' import { SKILL_BUNDLE_INSTALL_CAPABILITY, @@ -423,6 +424,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client-capabilities.ts b/src/shared/remote-runtime-client-capabilities.ts index 445f7c7a533..b156684c177 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -11,6 +11,7 @@ import { WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, type RuntimeCapability } from './protocol-version' +import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' import { SKILL_INSTALL_RESULT_V2_CAPABILITY } from './skill-install-capability' // Keep every remote request transport on the same mixed-version capability contract. @@ -21,6 +22,7 @@ export function remoteRuntimeClientCapabilities( new Set([ AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, diff --git a/src/shared/structured-agent-session-agent-status.test.ts b/src/shared/structured-agent-session-agent-status.test.ts index 1e9b58e40b9..43bae879867 100644 --- a/src/shared/structured-agent-session-agent-status.test.ts +++ b/src/shared/structured-agent-session-agent-status.test.ts @@ -19,22 +19,23 @@ describe('structuredAgentSessionAgentStatus', () => { expect( structuredAgentSessionAgentStatus({ status: 'attention', - backgroundTasks: [task({ kind: 'command' })] + childWork: [task({ kind: 'command' })] }) ).toEqual({ state: 'blocked', mainAgent: { state: 'blocked' } }) }) 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', mainAgent: { state: 'done' } }) + expect(structuredAgentSessionAgentStatus({ status: 'idle', childWork: [task()] })).toEqual({ + state: 'working', + mainAgent: { state: 'done' } + }) }) it('reads an idle lead with only a backgrounded shell as monitoring', () => { expect( structuredAgentSessionAgentStatus({ status: 'idle', - backgroundTasks: [task({ kind: 'command', description: 'sleep 180' })] + childWork: [task({ kind: 'command', description: 'sleep 180' })] }) ).toEqual({ state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } }) }) @@ -42,7 +43,7 @@ describe('structuredAgentSessionAgentStatus', () => { it('keeps an idle main agent working while a subagent failed in place or is out of contact', () => { for (const state of ['blocked', 'unverifiable'] as const) { expect( - structuredAgentSessionAgentStatus({ status: 'idle', backgroundTasks: [task({ state })] }) + structuredAgentSessionAgentStatus({ status: 'idle', childWork: [task({ state })] }) ).toEqual({ state: 'working', mainAgent: { state: 'done' } }) } }) @@ -51,7 +52,7 @@ describe('structuredAgentSessionAgentStatus', () => { expect( structuredAgentSessionAgentStatus({ status: 'idle', - backgroundTasks: [task({ state: 'waiting' })] + childWork: [task({ state: 'waiting' })] }) ).toEqual({ state: 'waiting', mainAgent: { state: 'done' } }) }) @@ -60,7 +61,9 @@ describe('structuredAgentSessionAgentStatus', () => { // not claim a running agent that `projectAgentChildWorkLegacySubagents` then refuses to render. it('reads a lead whose only live task is a workflow as monitoring, with no children to show', () => { const backgroundTasks = [task({ id: 'flow-1', kind: 'workflow' })] - expect(structuredAgentSessionAgentStatus({ status: 'idle', backgroundTasks })).toEqual({ + expect( + structuredAgentSessionAgentStatus({ status: 'idle', childWork: backgroundTasks }) + ).toEqual({ state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } @@ -76,10 +79,7 @@ describe('structuredAgentSessionAgentStatus', () => { expect( structuredAgentSessionAgentStatus({ status: 'idle', - backgroundTasks: [ - task({ state: 'done' }), - task({ id: 'shell', kind: 'command', state: 'idle' }) - ] + childWork: [task({ state: 'done' }), task({ id: 'shell', kind: 'command', state: 'idle' })] }) ).toEqual({ state: 'done', mainAgent: { state: 'done' } }) expect(structuredAgentSessionAgentStatus({ status: 'idle' })).toEqual({ @@ -98,7 +98,7 @@ describe('structuredAgentSessionAgentStatus', () => { structuredAgentSessionAgentStatus({ status: 'idle', turnOutcome: 'cancellation', - backgroundTasks: [task({ kind: 'command' })] + childWork: [task({ kind: 'command' })] }) ).toEqual({ state: 'working', diff --git a/src/shared/structured-agent-session-agent-status.ts b/src/shared/structured-agent-session-agent-status.ts index 618c97ff34a..9e424c62165 100644 --- a/src/shared/structured-agent-session-agent-status.ts +++ b/src/shared/structured-agent-session-agent-status.ts @@ -1,6 +1,9 @@ import type { AgentSessionStatusSummary } from './agent-session-wire' import { foldAgentLeadStatus } from './agent-lead-status-fold' -import { agentChildWorkLiveness } from './agent-status-child-work-liveness' +import { + agentChildWorkLiveness, + type AgentChildWorkLivenessCandidate +} from './agent-status-child-work-liveness' import type { AgentMainAgentStatus, AgentStatusState, AgentWorkingMode } from './agent-status-types' import type { StructuredAgentSessionProjectedStatus } from './structured-agent-session-projection' @@ -21,16 +24,19 @@ function structuredAgentSessionLeadState( /** The agent-status state one structured session summary stands for, with its live child * 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. */ + * process boundary so `worktree ps`, mobile and the sidebar cannot disagree about one session. + * `childWork` is the host's child records (or their views); only an older host's summary, which + * publishes none, is read by its live background tasks. */ export function structuredAgentSessionAgentStatus( - summary: Pick & { + summary: Pick & { status: StructuredAgentSessionProjectedStatus + childWork?: readonly AgentChildWorkLivenessCandidate[] } ): StructuredAgentSessionAgentStatus { const leadState = structuredAgentSessionLeadState(summary.status) const resolution = foldAgentLeadStatus({ leadState, - childWorkLiveness: agentChildWorkLiveness(summary.backgroundTasks) + childWorkLiveness: agentChildWorkLiveness(summary.childWork) }) return { state: resolution.stateName, diff --git a/src/shared/structured-agent-session-child-work-legacy.test.ts b/src/shared/structured-agent-session-child-work-legacy.test.ts new file mode 100644 index 00000000000..cb65897cc18 --- /dev/null +++ b/src/shared/structured-agent-session-child-work-legacy.test.ts @@ -0,0 +1,74 @@ +// What a client that predates child views reads of a Codex child agent that launched a shell: the +// flat roster it always read, derived from the host's views. + +import { describe, expect, it } from 'vitest' +import type { AgentChildWorkView } from './agent-status-child-work-view' +import { structuredChildWorkLegacyTasks } from './structured-agent-session-child-work-legacy' + +const invocation = { invocationId: 'spawn', generation: 1 } +const reviewer: AgentChildWorkView = { + id: 'child-agent', + providerId: 'thread-A', + kind: 'agent', + description: 'reviewer', + state: 'working', + membership: 'live', + firstObservedAt: 1, + observedAt: 5, + stoppable: false, + invocation +} +const shell: AgentChildWorkView = { + id: 'child-shell', + providerId: 'cmd-1', + kind: 'command', + description: 'npm test --watch', + parentChildWorkId: 'child-agent', + state: 'working', + membership: 'live', + firstObservedAt: 2, + observedAt: 5, + stoppable: false, + invocation +} +const lead: AgentChildWorkView = { + ...shell, + id: 'lead-shell', + providerId: 'cmd-0', + description: 'ls' +} +const { parentChildWorkId: _owner, ...leadShell } = lead + +function rows(views: AgentChildWorkView[]) { + const { tasks, settledTasks } = structuredChildWorkLegacyTasks(views, 'codex') + const row = ({ id, description }: { id: string; description?: string }) => `${id}: ${description}` + return { tasks: tasks?.map(row), settledTasks: settledTasks?.map(row) } +} + +describe("a Codex child agent's shell on an older client's flat roster", () => { + it('is hidden while its agent runs: the agent row stands for it', () => { + expect(rows([reviewer, shell, leadShell])).toEqual({ + tasks: ['codex-agent:thread-A: reviewer', 'cmd-0: ls'], + settledTasks: undefined + }) + }) + + it('names its agent once the agent has finished', () => { + const finished: AgentChildWorkView = { + ...reviewer, + state: 'done', + membership: 'settled', + outcome: 'succeeded', + settledAt: 6 + } + expect(rows([finished, shell])).toEqual({ + tasks: ['cmd-1: reviewer — npm test --watch'], + settledTasks: ['codex-agent:thread-A: reviewer'] + }) + }) + + it('leaves another provider’s owned commands as they are', () => { + const { tasks } = structuredChildWorkLegacyTasks([reviewer, shell], 'claude') + expect(tasks?.map((task) => task.id)).toEqual(['thread-A', 'cmd-1']) + }) +}) diff --git a/src/shared/structured-agent-session-child-work-legacy.ts b/src/shared/structured-agent-session-child-work-legacy.ts new file mode 100644 index 00000000000..72c0d0db94c --- /dev/null +++ b/src/shared/structured-agent-session-child-work-legacy.ts @@ -0,0 +1,74 @@ +// A structured session's legacy child shapes (`tasks`/`settledTasks`, `subagents`), derived from the +// host's child views so the old and new wire shapes cannot disagree. Clients that predate views read +// these, and they key rows by the id each lane published before views existed. +// +// Death condition: this is a bridge for clients that do not advertise +// `agent-session.background-task-child-views.v1`. Once MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION is raised +// past the RUNTIME_PROTOCOL_VERSION in force when that capability first shipped (3), every admitted +// client reads `children`, and these go together: `structuredChildWorkLegacyTasks` with +// `codexAgentBackgroundTaskId`, the status summary's `backgroundTasks` derivation, the channel's +// `tasks`/`settledTasks`, and the capability gate's `withoutChildViews`. Until then a new child fact +// must be decided here for those older readers too. `structuredChildWorkLegacySubagents` is not wire: +// it feeds in-app readers of `AgentStatusEntry.subagents`, and dies when they read `children`. + +import type { AgentSessionHandleProvider } from './agent-session-provider-handle' +import { codexChildCommandDescription } from './codex-child-command-description' +import { + projectAgentChildWorkLegacyBackgroundTasks, + projectAgentChildWorkLegacySubagents, + type AgentChildWorkLegacyBackgroundProjection +} from './agent-status-child-work-projection' +import type { AgentChildWorkView } from './agent-status-child-work-view' +import type { AgentSubagentSnapshot } from './agent-status-types' + +/** The id a Codex subagent's background-task row has carried since before views; its view names + * the child by its bare thread id, the key its journal rows are linked by. */ +export function codexAgentBackgroundTaskId(threadId: string): string { + return `codex-agent:${threadId}` +} + +function withLegacyIds( + views: readonly AgentChildWorkView[], + provider: AgentSessionHandleProvider +): AgentChildWorkView[] { + return views.map((view) => + provider === 'codex' && view.kind === 'agent' && view.providerId !== undefined + ? { ...view, providerId: codexAgentBackgroundTaskId(view.providerId) } + : view + ) +} + +/** A Codex child agent's commands as the flat roster showed them before views: hidden while the + * agent runs (its row stands for them), then named " — " once it has finished. */ +function withCodexChildCommands(views: readonly AgentChildWorkView[]): AgentChildWorkView[] { + const byId = new Map(views.map((view) => [view.id, view])) + return views.flatMap((view) => { + const owner = view.parentChildWorkId ? byId.get(view.parentChildWorkId) : undefined + if (view.kind !== 'command' || owner?.kind !== 'agent') { + return [view] + } + if (owner.membership !== 'settled') { + return [] + } + const label = owner.description ?? owner.name + return [ + label ? { ...view, description: codexChildCommandDescription(label, view.description) } : view + ] + }) +} + +export function structuredChildWorkLegacyTasks( + views: readonly AgentChildWorkView[], + provider: AgentSessionHandleProvider +): AgentChildWorkLegacyBackgroundProjection { + return projectAgentChildWorkLegacyBackgroundTasks( + withLegacyIds(provider === 'codex' ? withCodexChildCommands(views) : views, provider) + ) +} + +export function structuredChildWorkLegacySubagents( + views: readonly AgentChildWorkView[], + provider: AgentSessionHandleProvider +): AgentSubagentSnapshot[] | undefined { + return projectAgentChildWorkLegacySubagents(withLegacyIds(views, provider)) +} diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts index ca108710bcf..391875f8a0f 100644 --- a/src/shared/structured-agent-session-reducer.test.ts +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -158,6 +158,50 @@ describe('structured agent session reducer', () => { ]) }) + it('decodes child rows once as they arrive: a malformed row is dropped, and a repeat keeps identity', () => { + const view = { + id: 'child-1', + providerId: 'task-1', + kind: 'agent', + state: 'working', + membership: 'live', + firstObservedAt: 1, + observedAt: 2, + stoppable: true, + invocation: { invocationId: 'spawn-1', generation: 1 } + } + // A row a newer host shapes differently: no invocation, which equality would dereference. + const { invocation: _invocation, ...malformed } = { ...view, id: 'child-2' } + const roster = { state: 'monitoring', tasks: [], children: [view, malformed] } + const batch = (backgroundTasks: unknown, sequence: number) => + reduceStructuredAgentSession(state, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence }, + items: sequence > 0 ? [item(`item-${sequence}`, sequence)] : [], + removedItemIds: [], + submissions: [] + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a wire frame as it arrives, before the client decodes it. + backgroundTasks: structuredClone(backgroundTasks) as never + } + }) + let state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { type: 'snapshot', sessionId: 'session-a', fence: 1, page: hydrationPage([]) } + }) + state = batch(roster, 0) + expect(state.backgroundTasks?.children).toEqual([view]) + const kept = state.backgroundTasks + // The same roster again, beside a journal change: the roster keeps its identity. + state = batch(roster, 1) + expect(state.items.map(({ itemId }) => itemId)).toEqual(['item-1']) + expect(state.backgroundTasks).toBe(kept) + }) + it("keeps a failed read's refusal beside its text until the read recovers", () => { const refusal = { code: 'agent_session_journal_unreadable', diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 7c2f514c4be..dde31b09418 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -14,6 +14,7 @@ import type { } from './agent-session-wire' import type { AgentSessionRefusalReference } from './agent-session-wire-refusals' import { backgroundTaskStatesEqual } from './agent-session-background-task-state-equality' +import { admitAgentSessionBackgroundTaskState } from './agent-session-background-task-state-admission' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { MAX_RETAINED_ITEMS, @@ -136,7 +137,7 @@ function replacePage( ...(backgroundTasks !== undefined ? { backgroundTasks } : page.backgroundTasks !== undefined - ? { backgroundTasks: page.backgroundTasks } + ? { backgroundTasks: admitAgentSessionBackgroundTaskState(page.backgroundTasks) } : {}) } } @@ -254,7 +255,12 @@ export function reduceStructuredAgentSession( } if (event.type === 'snapshot' || event.type === 'reset') { return { - ...replacePage(event.page, event.fence, event.backgroundTasks, event.activity), + ...replacePage( + event.page, + event.fence, + admitAgentSessionBackgroundTaskState(event.backgroundTasks), + event.activity + ), commands: event.commands, // A snapshot omits the list when unchanged since the last frame sent to this subscriber. ...queuePublicationField(event, event.page, state), @@ -268,7 +274,9 @@ export function reduceStructuredAgentSession( return state } const backgroundTasks = - event.backgroundTasks !== undefined ? event.backgroundTasks : state.backgroundTasks + event.backgroundTasks !== undefined + ? admitAgentSessionBackgroundTaskState(event.backgroundTasks, state.backgroundTasks) + : state.backgroundTasks const activity = event.activity !== undefined ? event.activity : state.activity const liveItems = liveItemsWithinWindow(state, event.batch.items) // Every roster revision, the window's or not: a trimmed roster row keeps its sequence. diff --git a/tests/e2e/codex-child-approval-activity-row.unit.test.ts b/tests/e2e/codex-child-approval-activity-row.unit.test.ts index dbb2a5d8e9c..c2df9b1f3e3 100644 --- a/tests/e2e/codex-child-approval-activity-row.unit.test.ts +++ b/tests/e2e/codex-child-approval-activity-row.unit.test.ts @@ -147,8 +147,7 @@ async function openHost() { [SESSION, indexedStatusFeedSession({ journal, child: { phase: 'ready' } })] ]), getRecord: () => null, - now: () => 1, - readBackgroundTasks: () => ({ state: 'monitoring', tasks: [] }) + now: () => 1 }) const events: AgentSessionStatusEvent[] = [] feed.subscribe({ id: 'renderer', emit: (event) => events.push(event) }) diff --git a/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts index f7f23d528aa..9d1f1a601e0 100644 --- a/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts @@ -25,7 +25,7 @@ test('an older build reads the restart offer this build records at quit', async sessionId: SESSION, session: { journal: journal([turnItem('turn-1', 'running')]), child: { fence: 1 } }, getRecord: () => record(), - backgroundTasks: () => undefined, + childWork: () => undefined, trigger: 'quit', teardownId: TEARDOWN_CURRENT, now: NOW