From 1f702e91fb29500a75e8c70b67a8057f321b8570 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:42:22 -0700 Subject: [PATCH 01/34] feat(native-chat): publish the host's child records to the status summary and the chat strip The status summary and the background-task channel now read a session's child records from the host's canonical store, through the status sink its row landed in, and derive the legacy task and subagent shapes from the same views. The parent row folds its child-work liveness from those records at ingest, not from the summary's task list. The adapters no longer push their task DTO to clients: the onBackgroundTasksChanged path is gone, and a child-work ingest is what republishes both the summary and the strip. Finished children stay listed until the session's own next turn starts. A reader that predates child views never receives a roster whose rows are all settled. --- ...rver-ingest-structured-state-clock.test.ts | 33 +- .../server-ingest-structured-status.test.ts | 98 +++--- .../server-ingest-structured-children.ts | 13 + .../server/server-ingest-structured.ts | 4 +- ...hild-work-producer-harness.test-fixture.ts | 7 +- .../claude-structured-acquisition-release.ts | 1 - ...ude-structured-child-work-producer.test.ts | 6 +- .../claude-structured-session-adapter.ts | 30 +- .../claude-structured-session-close.test.ts | 28 +- .../claude/claude-structured-session-close.ts | 18 +- .../claude/claude-structured-session-state.ts | 9 +- .../claude-structured-session-test-support.ts | 4 +- ...dex-structured-child-work-producer.test.ts | 16 +- .../codex/codex-structured-session-acquire.ts | 1 - .../codex/codex-structured-session-adapter.ts | 15 +- ...tructured-session-background-tasks.test.ts | 44 +-- .../codex/codex-structured-session-close.ts | 3 - .../codex/codex-structured-session-state.ts | 5 - .../codex-structured-session-teardown.ts | 9 - .../desktop-renderer-runtime-capabilities.ts | 2 + ...structured-agent-session-adapter-router.ts | 4 + .../structured-agent-session-adapter.ts | 14 + ...d-agent-session-background-task-channel.ts | 62 +++- ...tructured-agent-session-client-delivery.ts | 11 +- .../structured-agent-session-host.ts | 7 +- ...uctured-agent-session-status-child-work.ts | 62 ++++ ...agent-session-status-feed-children.test.ts | 285 ++++++++++++++++++ ...-agent-session-status-feed-test-session.ts | 23 +- ...ructured-agent-session-status-feed.test.ts | 107 +------ .../structured-agent-session-status-feed.ts | 132 +++++--- ...ructured-agent-session-status-ownership.ts | 9 + ...tured-agent-session-status-reentry.test.ts | 3 +- ...red-agent-session-subagent-recency.test.ts | 32 +- src/main/orcad/orcad-entry.ts | 3 +- ...time-structured-status-sink-wiring.test.ts | 4 + ...gent-session-background-task-capability.ts | 26 +- .../structured-agent-session-runtime.ts | 4 - .../structured-claude-runtime-adapter.ts | 8 - .../startup/main-process-runtime-service.ts | 3 +- .../StructuredAgentSessionStatusBridge.tsx | 46 ++- .../src/store/slices/agent-status-contract.ts | 2 + .../slices/agent-status-live-entry-builder.ts | 12 +- .../agent-status-live-entry-children.ts | 24 ++ .../store/slices/agent-status-live-facts.ts | 1 + .../web-runtime-connection-frame-router.ts | 2 + ...-session-background-task-state-equality.ts | 4 +- .../agent-session-background-task-wire.ts | 7 + src/shared/agent-session-wire.ts | 7 +- .../agent-status-child-work-evidence.ts | 5 + ...t-status-child-work-reconciliation.test.ts | 28 ++ .../agent-status-child-work-reconciliation.ts | 32 +- .../agent-status-child-work-view-wire.ts | 189 ++++++++++++ src/shared/main-agent-status-parity.test.ts | 2 +- src/shared/protocol-version.ts | 7 + .../remote-runtime-client-capabilities.ts | 2 + ...uctured-agent-session-agent-status.test.ts | 26 +- .../structured-agent-session-agent-status.ts | 14 +- ...uctured-agent-session-child-work-legacy.ts | 43 +++ 58 files changed, 1200 insertions(+), 398 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts create mode 100644 src/renderer/src/store/slices/agent-status-live-entry-children.ts create mode 100644 src/shared/agent-status-child-work-view-wire.ts create mode 100644 src/shared/structured-agent-session-child-work-legacy.ts 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 eeb8ffab51ef..8d5fae035458 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 0dea55ea1654..3254ee092c97 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,54 +128,52 @@ 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', - backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }] - }), - SUBJECT - ) + 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.ingestStructuredStatus( - summary({ - status: 'idle', - updatedAt: OBSERVED_AT + 1, - backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }] - }), - SUBJECT + 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({ status: 'idle', - updatedAt: OBSERVED_AT + 2, - backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'done' }] + backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }] }), 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) @@ -382,10 +396,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 } @@ -393,6 +406,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 ecc58b998d77..1c3eb992d451 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 19ee70e88af3..6255c57b5ca9 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/claude-child-work-producer-harness.test-fixture.ts b/src/main/claude/claude-child-work-producer-harness.test-fixture.ts index 8b1a8b0ee917..e0642040d978 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 @@ -70,7 +70,7 @@ export function toolResult( }) } -type Delivery = { kind: 'journal' | 'legacy' | 'evidence'; detail: string } +type Delivery = { kind: 'journal' | 'publish' | 'evidence'; detail: string } /** 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 +125,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(',') }) @@ -146,7 +144,8 @@ export async function producer(host?: AgentHookServer) { } }, 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(), diff --git a/src/main/claude/claude-structured-acquisition-release.ts b/src/main/claude/claude-structured-acquisition-release.ts index 3df76971df16..d29879bb73ee 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 d47d07b43355..7c493f9e8c8a 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-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 29b51e730ae9..83256f12ddf6 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 tracker's roster serves this provider's own stops; the host's child records, fed by the + // decoder's evidence drained below, are what every surface reads. 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() } observeClaudeCompaction(this.compactions, event, session?.translator) 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, @@ -228,6 +220,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda 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 answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => @@ -274,9 +270,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 } : {}) }) @@ -297,9 +290,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 873b90c94168..7984f8fc9d9b 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, so no surface keeps the child listed. + 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 e27b2a41e93f..0389374384d6 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -14,7 +14,6 @@ import { import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' 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' @@ -84,10 +83,6 @@ type CloseClaudePublishedSessionInput = { fence: number }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void } async function finalizeClaudePublishedSession( @@ -116,10 +111,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 ?? @@ -231,10 +225,6 @@ export function closeClaudePublishedSessionForDeps( fence: number }) => Promise onEvent?: (event: ClaudeStructuredSessionEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void } ): Promise { return closeClaudePublishedSession({ sessions, sessionId, ...deps }) @@ -251,10 +241,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 6848d9a19437..5e8d8312b9d1 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -19,10 +19,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' @@ -95,10 +92,6 @@ export type ClaudeStructuredSessionAdapterDeps = { onEvent?: (event: ClaudeStructuredSessionEvent) => void /** Direct settlement path for provider-proven late dispatch outcomes. */ onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => 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 0380d5ac12fc..dd7ad1e8d8c5 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-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index fc011d48bb9e..b39119dc1842 100644 --- a/src/main/codex/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/codex-structured-child-work-producer.test.ts @@ -40,7 +40,7 @@ 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 } @@ -112,8 +112,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 +126,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'), @@ -198,7 +200,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,7 +208,9 @@ 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' }) ]) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 0cbe62224c56..1cacd874b1e4 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -180,7 +180,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 eebebe3aa441..a8c2204df3fc 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -76,9 +76,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) }) this.turnCancellation = new CodexStructuredTurnCancellation({ @@ -159,11 +156,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() } @@ -197,6 +192,12 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap sessionId ) => this.sessions.get(sessionId)?.backgroundTasks.state + // Codex exposes no honest stop for a child thread or a persistent command. + backgroundTaskStops: NonNullable = ( + sessionId + ) => + this.sessions.has(sessionId) ? { supportsTaskStop: false, supportsStopAll: false } : undefined + bindPromptItemId = ( sessionId: string, journalItemId: 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 97b6f4e47a53..ddd3da6e8f53 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 }[] = [] + 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 end with the session, so no surface keeps the child listed. + 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 91d6c8ff04d2..76cca467947e 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 { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -21,7 +20,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) { @@ -55,7 +53,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 7f40407e3ffd..a1e8c0017508 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -11,7 +11,6 @@ import type { } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' 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' @@ -75,10 +74,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 dcd38eb82fe2..80b06ab1c36a 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 { closeAllCodexSessions, closeCodexPublishedSession, @@ -20,10 +19,6 @@ export type CodexStructuredSessionTeardownDeps = { sessions: Map acquisitions: CodexAcquisitionRegistry onEvent?: (event: CodexStructuredSessionEvent) => void - onBackgroundTasksChanged?: ( - sessionId: string, - state: AgentSessionBackgroundTaskState | null - ) => void forgetNotificationRetries: (sessionId: string) => void } @@ -84,10 +79,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.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 0bcfd1bd686d..66b045ba948a 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -1,6 +1,7 @@ import { AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, @@ -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 bb003f93427d..2ab75eb8e0db 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 @@ -112,6 +112,10 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi sessionId ) => this.liveOwnerOrNull(sessionId)?.backgroundTaskState?.(sessionId) + backgroundTaskStops: NonNullable = ( + sessionId + ) => this.liveOwnerOrNull(sessionId)?.backgroundTaskStops?.(sessionId) + readCommands: NonNullable = (sessionId) => this.liveOwnerOrNull(sessionId)?.readCommands?.(sessionId) 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 bc3c27ca5b13..a7bec3f8cb58 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 @@ -216,6 +216,14 @@ export type StructuredAgentSessionSetOptionInput = { fence: number } +/** 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 +} + export type StructuredAgentSessionAdapter = { /** Provider-aware capability check for hosts that route more than one adapter. */ supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean @@ -302,7 +310,13 @@ export type StructuredAgentSessionAdapter = { fence: number taskId?: string }): Promise<{ cancelled: boolean }> + /** The provider's own roster of background work it can stop, for provider-side decisions + * (targeted stops, conversation-command admission). No surface reads it: every surface reads + * the host's child records. */ 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 `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined 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 a88949a296f5..6464863f6132 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,8 @@ 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 { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' import type { AgentSessionSubscribers, @@ -14,7 +16,11 @@ import type { } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +/** The chat strip's roster: the host's child records for one session, as the same views the status + * summary carries, with the legacy task rows an older client reads derived from them. */ export class StructuredAgentSessionBackgroundTaskChannel { + private readonly published = new Map() + constructor( private readonly deps: StructuredAgentSessionHostDeps, private readonly sessions: Map, @@ -23,9 +29,7 @@ export class StructuredAgentSessionBackgroundTaskChannel { 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 ) {} async history(request: AgentSessionHistoryRequest): Promise { @@ -58,20 +62,52 @@ 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 rows were 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 } + if (state === null) { + this.published.delete(sessionId) + } else { + 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 views = session ? this.readChildWork(sessionId) : undefined + if (!session || views === undefined) { + return undefined + } + 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-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index f5c7fb825f3d..37d2b8f13073 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 type { @@ -30,13 +31,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, @@ -58,6 +62,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-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 26697d3f6819..34c41b61560f 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 @@ -73,7 +73,8 @@ export class StructuredAgentSessionHost { () => this.now(), () => this.deps, (sessionId) => this.sessions.touch(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() @@ -97,7 +98,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) @@ -319,8 +320,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-status-child-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts new file mode 100644 index 000000000000..14db7a64a00d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts @@ -0,0 +1,62 @@ +// 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 { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +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' + +/** An evidence clock that advanced by less than this does not re-broadcast a summary. */ +export const SUMMARY_CHILD_CLOCK_TOLERANCE_MS = 60_000 + +export type StructuredStatusChildWork = { + children?: AgentChildWorkView[] + backgroundTasks?: AgentSessionBackgroundTask[] +} + +/** The summary's child fields: the views without usage, 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 { + if (!views || views.length === 0) { + return {} + } + const children = views.map(({ totalTokens: _totalTokens, ...view }) => view) + // Live only: an old client folds every listed task into the parent, and reads a failed one's + // legacy `blocked` as still running. + const { tasks } = structuredChildWorkLegacyTasks( + children.filter((view) => view.membership === 'live'), + 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 session's own newest turn, whatever state it is in; a subagent's rows never count. */ +export function newestRootTurnId(items: readonly AgentJournalRenderItem[]): string | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] + const turn = isRootAgentJournalItem(item) ? readAgentJournalTurn(item?.body) : null + if (turn) { + return turn.turnId + } + } + return null +} 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 000000000000..6e6ad529ef79 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts @@ -0,0 +1,285 @@ +// 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 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-store-test-open' +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' } + }, + journalDir: 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 } + ) + 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 } + ) + 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 } + ) + 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' })] + }) + }) + }) + + it('lists a finished child on the summary, never in the task list an older client folds', 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(), failed] + }) + }) + + 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 when the session's own next turn begins", 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, ...(agentId ? { agentId } : {}) } + ) + const retirements = () => admitted.flat().filter((edge) => edge.type === 'turn-started') + // The session was first projected before it had a turn, so its first turn is a new one. + await turn('turn-1', 'running') + feed.publish(SESSION) + expect(retirements()).toHaveLength(1) + await turn('turn-1', 'completed') + feed.publish(SESSION) + // A subagent's own turn is not the session's. + await turn('child-turn', 'running', 'child-thread') + feed.publish(SESSION) + expect(retirements()).toHaveLength(1) + + await turn('turn-2', 'running') + feed.publish(SESSION) + feed.publish(SESSION) + expect(retirements()).toHaveLength(2) + }) + + 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') + }) +}) 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 da7275cc6418..3d7fb944735f 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: { journal: AgentSessionJournal child?: (StructuredAgentSessionProviderChildIdentity & { phase: 'starting' | 'ready' }) | null + provider?: AgentSessionHandleProvider }) { 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 eda619694e35..78e23e644966 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 @@ -5,15 +5,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-store-test-open' -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, @@ -66,14 +69,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) @@ -637,10 +643,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) @@ -679,90 +684,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 } - ) - 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 } - ) - 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 22a61a006bb6..f3740d9232ac 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 @@ -17,16 +17,21 @@ import { isAgentStatusHeldOpenByChildWork } from '../../../shared/agent-lead-sta import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types' import { agentSessionBackgroundTasksEqual, - type AgentSessionBackgroundTaskState, type AgentSessionStatusEvent, 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 { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' 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 { + newestRootTurnId, + structuredStatusChildrenEqual, + structuredStatusChildWork +} from './structured-agent-session-status-child-work' import { StructuredAgentSessionStatusOwnership, type StructuredAgentSessionStatusSink @@ -57,11 +62,10 @@ 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 } @@ -89,6 +93,7 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && a.turnOutcome === b.turnOutcome && + structuredStatusChildrenEqual(a.children, b.children) && agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession) ) @@ -115,46 +120,44 @@ 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 } : {}) }) } +type JournalProjection = { + epoch: string + sequence: number + readOnly: boolean + fence: number | undefined + state: StructuredAgentSessionStatusState + rootTurnId: string | null +} + export class StructuredAgentSessionStatusFeed { private readonly ownership = new StructuredAgentSessionStatusOwnership(() => this.deps.statusSink?.() ) private readonly subscribers = new Map() private readonly published = new Map() + /** The newest root turn each session was last projected with; a new one retires settled children. */ + private readonly rootTurns = 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 - } - >() + private readonly journalProjections = new WeakMap() constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {} @@ -179,6 +182,7 @@ export class StructuredAgentSessionStatusFeed { /** The sink lists what is running; a forgotten session must not be in it. */ forget(sessionId: string): void { + this.rootTurns.delete(sessionId) try { this.ownership.forget(sessionId) } catch (error) { @@ -205,10 +209,14 @@ export class StructuredAgentSessionStatusFeed { if (!previous) { return } + // The store forgets a closed session's child records with its row, so the retained + // projection must not keep listing them. const { hostExecutionOwned: _hostExecutionOwned, hostExecutionPhase: _hostExecutionPhase, hostExecutionChild: _hostExecutionChild, + children: _children, + backgroundTasks: _backgroundTasks, ...retained } = previous this.published.set(sessionId, retained) @@ -227,7 +235,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.projectionFor(source, this.deps.getRecord(sessionId)).state : null } /** Re-projects one session after its journal changed; equal projections are not re-sent. */ @@ -236,7 +244,11 @@ 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.projectionFor(source, record) + this.retireSettledChildrenOnNewTurn(sessionId, session, projection.rootTurnId) + const summary = this.summaryFor(sessionId, session, source, record, projection.state) const previous = this.published.get(sessionId) if (previous && summariesEqual(previous, summary)) { if (!this.ownership.matchesLocation(sessionId, session.params.location)) { @@ -258,13 +270,29 @@ export class StructuredAgentSessionStatusFeed { } } + /** Finished children stay listed, with their outcome, until the session's own next turn. */ + private retireSettledChildrenOnNewTurn( + sessionId: string, + session: StatusFeedSession, + rootTurnId: string | null + ): void { + const seen = this.rootTurns.has(sessionId) + const previous = this.rootTurns.get(sessionId) + this.rootTurns.set(sessionId, rootTurnId) + if (!seen || rootTurnId === null || rootTurnId === 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. @@ -272,9 +300,10 @@ export class StructuredAgentSessionStatusFeed { // 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) + const { children, backgroundTasks } = structuredStatusChildWork( + this.readChildWork(sessionId), + session.params.provider + ) return { sessionId, workspaceId: session.params.location.workspaceId, @@ -291,29 +320,59 @@ export class StructuredAgentSessionStatusFeed { ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), ...(model ? { model } : {}), - ...(backgroundTasks && backgroundTasks.length > 0 ? { backgroundTasks } : {}), + ...(backgroundTasks && backgroundTasks.length > 0 + ? { backgroundTasks: backgroundTasks.map(({ totalTokens: _tokens, ...task }) => task) } + : {}), + ...(children ? { children } : {}), ...(providerSession ? { providerSession } : {}), updatedAt: journal.lastActivityAt() || this.deps.now() } } - /** Child-work evidence for a session this feed publishes; a failing sink costs nothing else. */ + /** 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 + } + try { + this.deps.onChildWorkChanged?.(sessionId) + } catch (error) { + console.warn('[structured-session-status] child work observer failed', error) } + return true } private projectionFor( journal: AgentSessionJournal, record: AgentSessionRecord | null - ): StructuredAgentSessionStatusState { + ): JournalProjection { // An unreadable journal projects as "no turn": the chat itself shows the reset. const cursor = journal.cursor() const readOnly = journal.isReadOnly @@ -338,11 +397,12 @@ export class StructuredAgentSessionStatusFeed { snapshot?.items ?? [], snapshot?.submissions ?? [], fence - ) + ), + rootTurnId: newestRootTurnId(snapshot?.items ?? []) } this.journalProjections.set(journal, projection) } - return projection.state + return projection } /** 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-ownership.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts index e7a270d10c4a..faf9022f586f 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 57218d0f4bdc..ae4615d021d3 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 @@ -3,6 +3,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-store-test-open' import { AgentHookServer } from '../../agent-hooks/server' @@ -51,7 +52,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-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index cb8b3d7d7c53..5b3a0a6d9f4b 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 @@ -9,10 +9,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' @@ -59,8 +56,7 @@ async function openSession() { now: tick, journalDir: 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([ @@ -74,10 +70,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[] = [] @@ -108,7 +106,7 @@ async function openSession() { } return { journal, - roster, + feed, server, projected, tick, @@ -278,7 +276,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/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 02533588e9dd..9f64f5e03c05 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) }, reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), 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 8b048f168d49..b931af574632 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.ts b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts index ae69de4a069d..5a9fd7c4566a 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 @@ -4,6 +4,7 @@ import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' import { + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version' @@ -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 reads any roster as live work: it animates the monitoring + * indicator and refuses conversation commands on one. A roster of settled rows alone is new, so + * that reader gets its pre-feature view of it — no strip — and never the views it cannot read. */ +function withoutChildViews( + state: AgentSessionBackgroundTaskState +): AgentSessionBackgroundTaskState | null { + const { children: _children, ...legacy } = state + return legacy.tasks && legacy.tasks.length > 0 ? legacy : null +} + /** 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/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 1659fcd512ea..44656ec89aec 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -228,8 +228,6 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise - host?.publishBackgroundTaskState(sessionId, state), onChildWorkEvidence: (sessionId, evidence) => host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, @@ -269,8 +267,6 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise 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-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 4160b81c4dea..f1c0ddb72c9f 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'] onChildWorkEvidence?: ClaudeStructuredSessionAdapterDeps['onChildWorkEvidence'] } @@ -126,9 +121,6 @@ export function createStructuredClaudeRuntimeAdapter( deps.onLifecycleEvent(lifecycle) } }, - ...(deps.onBackgroundTasksChanged - ? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged } - : {}), ...(deps.onDispatchSettledLate ? { onDispatchSettledLate: deps.onDispatchSettledLate } : {}), ...(deps.onChildWorkEvidence ? { onChildWorkEvidence: deps.onChildWorkEvidence } : {}), ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 20ea8262c9a2..cffd9656f091 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/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 8205d96a5af5..72669bf936c0 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/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index afff64d33037..884c203e554c 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 28bf952460c3..282e138315f7 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 @@ -234,9 +234,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 000000000000..8087712925d9 --- /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 9d8c19742f7f..9ebc3a005ab8 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-connection-frame-router.ts b/src/renderer/src/web/web-runtime-connection-frame-router.ts index e9fa20fa0150..fe5a723ddc30 100644 --- a/src/renderer/src/web/web-runtime-connection-frame-router.ts +++ b/src/renderer/src/web/web-runtime-connection-frame-router.ts @@ -1,6 +1,7 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { isKeepaliveFrame } from '../../../shared/runtime-rpc-envelope' import { + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, @@ -61,6 +62,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-session-background-task-state-equality.ts b/src/shared/agent-session-background-task-state-equality.ts index aa93bb1b9ada..2748375d5014 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 c5e6893fb0be..a6f2dc846e8b 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 513243f8f543..65914a6026ba 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' export * from './agent-session-wire-refusals' import type { AgentSessionConversationCommand } from './agent-session-conversation-command' @@ -206,8 +207,12 @@ export type AgentSessionStatusSummary = { turnOutcome?: AgentJournalTurnOutcome /** Live provider-owned background tasks, so session lists can render * subagent children without holding a journal reader open. Optional for - * mixed-version hosts. */ + * mixed-version hosts. Derived from `children` on hosts that publish it. */ backgroundTasks?: AgentSessionBackgroundTask[] + /** The host's child records for this session, live and recently settled, as views. 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-evidence.ts b/src/shared/agent-status-child-work-evidence.ts index 8df08126827c..6a65936cdbe7 100644 --- a/src/shared/agent-status-child-work-evidence.ts +++ b/src/shared/agent-status-child-work-evidence.ts @@ -71,6 +71,10 @@ export type AgentChildWorkEndedEvidence = { totalTokens?: number } +/** The session's own next turn began. Settled children are kept, with their outcome, until then; + * a settled child that still owns live work stays so that work keeps an owner. */ +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. */ export type AgentChildWorkSessionEndedEvidence = { type: 'session-ended'; observedAt: number } @@ -88,4 +92,5 @@ export type AgentChildWorkEvidence = | AgentChildWorkOperationEvidence | AgentChildWorkEndedEvidence | AgentChildWorkRemovedEvidence + | AgentChildWorkTurnStartedEvidence | AgentChildWorkSessionEndedEvidence diff --git a/src/shared/agent-status-child-work-reconciliation.test.ts b/src/shared/agent-status-child-work-reconciliation.test.ts index 79ffc1c57616..dea2c8ee1e02 100644 --- a/src/shared/agent-status-child-work-reconciliation.test.ts +++ b/src/shared/agent-status-child-work-reconciliation.test.ts @@ -425,4 +425,32 @@ describe('structured child-work reconciliation', () => { ).toMatchObject({ removed: 0 }) expect(records(store)).toHaveLength(1) }) + + // Brennan's call: 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' + ]) + }) }) diff --git a/src/shared/agent-status-child-work-reconciliation.ts b/src/shared/agent-status-child-work-reconciliation.ts index 43f23b85483a..88d5232b4cff 100644 --- a/src/shared/agent-status-child-work-reconciliation.ts +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -2,9 +2,9 @@ // // 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 session's own next turn +// starts 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 type { @@ -98,6 +98,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 +115,21 @@ 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: that one stays so its work keeps an + * owner. */ +function removableSettled(ctx: ReconcileContext): string[] { + const owned = ownedStructuredChildWork(ctx) + const owners = new Set( + owned.flatMap((record) => + record.membership === 'live' && record.parentChildWorkId ? [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 +153,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.ts b/src/shared/agent-status-child-work-view-wire.ts new file mode 100644 index 000000000000..acf80647d92a --- /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 imports it. Type-only imports 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/main-agent-status-parity.test.ts b/src/shared/main-agent-status-parity.test.ts index b85fed425924..523e13c35187 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 3a877a809004..5f6a88084be0 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -228,6 +228,12 @@ export const AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY = // stop capability above, which a client can advertise while predating this. export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY = 'agent-session.background-task-row-stop.v1' as const +// Why: the strip's roster now carries the host's child records, and keeps a finished child listed +// until the parent's next turn. A reader that predates it reads any roster as live work (it +// animates the monitoring indicator and blocks conversation commands), so it never receives one +// whose rows are all settled. +export const AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY = + 'agent-session.background-task-child-views.v1' as const // Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an // older host answers the unknown member with invalid_argument — a code the launch fallback does // not retry on — so clients must probe before taking the host-authority path. @@ -399,6 +405,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 445f7c7a5337..9356c6ca75bd 100644 --- a/src/shared/remote-runtime-client-capabilities.ts +++ b/src/shared/remote-runtime-client-capabilities.ts @@ -1,4 +1,5 @@ import { + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, @@ -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 1e9b58e40b92..43bae879867e 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 618c97ff34a6..9e424c621657 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.ts b/src/shared/structured-agent-session-child-work-legacy.ts new file mode 100644 index 000000000000..6a21b5223809 --- /dev/null +++ b/src/shared/structured-agent-session-child-work-legacy.ts @@ -0,0 +1,43 @@ +// 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. + +import type { AgentSessionHandleProvider } from './agent-session-provider-handle' +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 + ) +} + +export function structuredChildWorkLegacyTasks( + views: readonly AgentChildWorkView[], + provider: AgentSessionHandleProvider +): AgentChildWorkLegacyBackgroundProjection { + return projectAgentChildWorkLegacyBackgroundTasks(withLegacyIds(views, provider)) +} + +export function structuredChildWorkLegacySubagents( + views: readonly AgentChildWorkView[], + provider: AgentSessionHandleProvider +): AgentSubagentSnapshot[] | undefined { + return projectAgentChildWorkLegacySubagents(withLegacyIds(views, provider)) +} From 3bd627fafcda32b4f87ea9da8cf0bb01b118108b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:00:43 -0700 Subject: [PATCH 02/34] feat(native-chat): the chat strip reads the host's child records with its parent's verdict The strip's roster now renders from the child views its channel carries, and passes the verdict the session's own status row gives its children, built the way the sidebar builds it (the row's freshness and the status feed's observation). So one child reads the same in the strip and the sidebar, live, after the transport drops, and once the row goes stale. A roster of finished children stays shown until the next turn but no longer animates the monitoring indicator or blocks conversation commands. Tests: an end-to-end run on a host with no renderer (a real hook server as the status sink) shows the summary and the strip channel carrying the same records at every step, the parent row folded from them, retention, and an older client's task list holding live work only; a wired renderer test shows both surfaces agree when live, lost and stale. --- ...ktop-renderer-runtime-capabilities.test.ts | 4 +- ...-agent-session-status-feed-test-session.ts | 4 +- .../structured-child-records-switch.test.ts | 303 ++++++++++++++++++ .../NativeChatStructuredSession.tsx | 1 + ...NativeChatStructuredSessionStatus.test.tsx | 1 + .../NativeChatStructuredSessionStatus.tsx | 7 + .../structured-child-records-switch.test.tsx | 299 +++++++++++++++++ ...tructured-session-background-tasks-view.ts | 17 +- ...se-structured-session-child-row-context.ts | 37 +++ 9 files changed, 667 insertions(+), 6 deletions(-) create mode 100644 src/main/runtime/structured-child-records-switch.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-session-child-row-context.ts diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 68f6e97e6323..93d646b7b640 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest' import { AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, @@ -51,11 +52,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/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 3d7fb944735f..130f5961bc0f 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 @@ -3,10 +3,10 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work 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?: AgentSessionHandleProvider + provider?: P }) { return { journal: session.journal, 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 000000000000..48358892d819 --- /dev/null +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -0,0 +1,303 @@ +// The switch, end to end on a host with no renderer: a Codex session's frames reach the host's +// canonical store, and the status summary every session list reads and the chat strip's channel +// both carry the same child records from it, while the parent row is folded from them too. +// +// 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 { + HOST_TEST_SESSION as SESSION, + hostTestAttachParams +} 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 one record per child to both, 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')}` + expect(await host.attach({ callerKey: 'switch-test' }, attachParams)).toMatchObject({ + ok: true + }) + await host.hold(SESSION, 'desktop-chat:1') + 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)[] = [] + 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 notify = async (method: string, params: Record) => { + connections[0]?.onNotification?.(method, params) + for (let tick = 0; tick < 5; tick += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + 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' + }) + both([ + { + kind: 'agent', + description: 'review', + state: 'working', + membership: 'live', + tool: 'Bash: npm test' + }, + { + 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' }) + // Finished children stay listed, with how they ended, until the session's next turn. + both([ + { + kind: 'agent', + description: 'review', + state: 'done', + membership: 'settled', + outcome: 'succeeded' + }, + { + kind: 'command', + description: 'npm run dev', + state: 'done', + membership: 'settled', + outcome: 'succeeded', + owner: 'review' + } + ]) + + await notify('turn/started', { threadId: THREAD, turn: { id: 'p2', status: 'inProgress' } }) + expect(summaries.at(-1)).not.toHaveProperty('children') + expect(strip.at(-1)).toBeNull() + + // The provider exits: the records go with the session, on both. + connections[0]?.onExit?.(new Error('scripted provider exit')) + expect(server.getStructuredChildWorkViews(parentSubject(summaries))).toEqual([]) + }) +}) + +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/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 26f04ae2e3ce..db4de629d012 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -302,6 +302,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 da53ca1914cf..638ed4f054de 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 315188861506..c6f8316444f2 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/structured-child-records-switch.test.tsx b/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx new file mode 100644 index 000000000000..966d8a7b0b11 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx @@ -0,0 +1,299 @@ +// @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' + +const mocks = vi.hoisted(() => ({ + store: null as null | { + getState: () => AppState + setState: (state: Partial & { testRuntimeOwner?: string | null }) => void + }, + subscribeStatus: vi.fn(), + unsubscribe: vi.fn() +})) + +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + const useAppStore = createTestStore() + mocks.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] as (event: AgentSessionStatusEvent) => void +} + +function row(): AgentStatusEntry { + const entry = mocks.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 }): 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.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' }) + }) + + 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]) + }) +}) 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 a79e97f705a3..a42227f56cf9 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,21 +5,28 @@ // 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 also keeps a finished child listed, +// with how it ended, until the session's next turn. 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' +import { decodeAgentChildWorkViews } from '../../../../shared/agent-status-child-work-view-wire' 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 } @@ -29,11 +36,15 @@ export function structuredSessionBackgroundTasksView( turnId: string | null ): StructuredSessionBackgroundTasksView { const monitoring = backgroundTasks?.state === 'monitoring' + const children = monitoring ? decodeAgentChildWorkViews(backgroundTasks.children) : undefined + // A roster of finished children is shown, but nothing in it is running. + const liveWork = children ? agentChildWorkLiveness(children) !== null : monitoring return { show: monitoring, - isMonitoring: turnId === null && monitoring, + isMonitoring: turnId === null && liveWork, tasks: backgroundTasks?.tasks ?? [], settledTasks: backgroundTasks?.settledTasks ?? [], + ...(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 000000000000..c0703f5db1f7 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-session-child-row-context.ts @@ -0,0 +1,37 @@ +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) + return useMemo( + () => (parent ? structuredSessionChildRowContext(parent, now) : undefined), + [parent, now] + ) +} From f132afc9456d3fa29db0079ce9bf549b5f31a25e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:04:11 -0700 Subject: [PATCH 03/34] test(native-chat): a newer host's view degrades, an older reader keeps its live roster, a finished roster holds nothing open - The view decoder ignores unknown keys, degrades unknown kinds, states, outcomes and memberships, and drops only rows it cannot identify. - At the RPC boundary a reader that predates child views gets no strip for a roster of finished children and never the views themselves; a stop-only reader keeps rows whose host offers no targeted stop. - The strip shows finished children without reading them as live work. - The row keeps its child list's identity when a summary repeats it. --- ...session-background-task-capability.test.ts | 97 +++++++++++++++ .../structured-child-records-switch.test.tsx | 18 +++ ...ured-session-background-tasks-view.test.ts | 52 +++++++++ .../agent-status-child-work-view-wire.test.ts | 110 ++++++++++++++++++ 4 files changed, 277 insertions(+) create mode 100644 src/renderer/src/components/native-chat/structured-session-background-tasks-view.test.ts create mode 100644 src/shared/agent-status-child-work-view-wire.test.ts 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 8a9e2267a5a8..f2e87eecaa5d 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,6 +1,8 @@ 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_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version' @@ -170,4 +172,99 @@ 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: unknown) { + hostCalls.history.mockReturnValue({ ok: true, page: { items: [], backgroundTasks } }) + return call( + 'agentSession.history', + { sessionId: SESSION, direction: 'tail' }, + client as typeof CURRENT_CLIENT + ) + } + + it('hands a reader that predates views no strip for a roster of finished children', async () => { + const finishedOnly: AgentSessionBackgroundTaskState = { + state: 'monitoring', + supportsTaskStop: true, + settledTasks: [SETTLED_ROW], + children: [view('settled')] + } + expect(await historyFor(finishedOnly, PRE_VIEWS_CLIENT)).toMatchObject({ + ok: true, + result: { page: { backgroundTasks: null } } + }) + expect(await historyFor(finishedOnly, CURRENT_CLIENT)).toMatchObject({ + ok: true, + result: { page: { backgroundTasks: finishedOnly } } + }) + }) + + 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/renderer/src/components/native-chat/structured-child-records-switch.test.tsx b/src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx index 966d8a7b0b11..f86d13a4884d 100644 --- 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 @@ -257,6 +257,24 @@ describe('the switch: both surfaces read the host child 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', { 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 000000000000..72bfbe825fb8 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-session-background-tasks-view.test.ts @@ -0,0 +1,52 @@ +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('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('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/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 000000000000..cc393b939418 --- /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) + }) +}) From b70d652ccea0e3053c3e3e70ee9c28c1c5b76531 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:33:48 -0700 Subject: [PATCH 04/34] refactor(native-chat): the summary's task list is the legacy projection's live rows, unfiltered --- .../structured-agent-session-status-child-work.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) 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 index 14db7a64a00d..53990784ffd3 100644 --- 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 @@ -33,12 +33,9 @@ export function structuredStatusChildWork( return {} } const children = views.map(({ totalTokens: _totalTokens, ...view }) => view) - // Live only: an old client folds every listed task into the parent, and reads a failed one's - // legacy `blocked` as still running. - const { tasks } = structuredChildWorkLegacyTasks( - children.filter((view) => view.membership === 'live'), - provider - ) + // `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. + const { tasks } = structuredChildWorkLegacyTasks(children, provider) return { children, ...(tasks ? { backgroundTasks: tasks } : {}) } } From a83f0308f97a3f6bb83d5b9eea7c7f5dc3817c96 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:35:51 -0700 Subject: [PATCH 05/34] test(native-chat): type the switch tests' mocks instead of asserting them --- ...session-background-task-capability.test.ts | 11 +++--- .../structured-child-records-switch.test.tsx | 35 ++++++++++++------- 2 files changed, 28 insertions(+), 18 deletions(-) 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 f2e87eecaa5d..a15d4cd8f72e 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 @@ -203,13 +203,12 @@ describe('child views at the RPC boundary', () => { ).filter((capability) => capability !== AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY) } - async function historyFor(backgroundTasks: AgentSessionBackgroundTaskState, client: unknown) { + 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 as typeof CURRENT_CLIENT - ) + return call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client) } it('hands a reader that predates views no strip for a roster of finished children', async () => { 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 index f86d13a4884d..2e7be9dd7ccd 100644 --- 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 @@ -24,19 +24,30 @@ import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import type { AppState } from '@/store/types' import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' -const mocks = vi.hoisted(() => ({ - store: null as null | { - getState: () => AppState - setState: (state: Partial & { testRuntimeOwner?: string | null }) => void - }, - subscribeStatus: vi.fn(), - unsubscribe: vi.fn() -})) +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.store = useAppStore + mocks.holder.store = useAppStore return { useAppStore } }) @@ -120,11 +131,11 @@ function feed(): (event: AgentSessionStatusEvent) => void { if (!call) { throw new Error('status feed not subscribed') } - return call[1] as (event: AgentSessionStatusEvent) => void + return call[1] } function row(): AgentStatusEntry { - const entry = mocks.store?.getState().agentStatusByPaneKey[PANE_KEY] + const entry = mocks.holder.store?.getState().agentStatusByPaneKey[PANE_KEY] if (!entry) { throw new Error('no structured row') } @@ -202,7 +213,7 @@ describe('the switch: both surfaces read the host child records', () => { vi.clearAllMocks() resetStructuredAgentSessionStatusFeedsForTests() mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) - mocks.store?.setState({ + mocks.holder.store?.setState({ agentStatusByPaneKey: {}, testRuntimeOwner: null, unifiedTabsByWorktree: { 'wt-1': [tab] } From 14c2b00932403a37517da2edd01554bd24fd3168 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:45:28 -0700 Subject: [PATCH 06/34] test: remote clients advertise reading child views --- src/cli/runtime/websocket-transport.test.ts | 2 ++ src/renderer/src/web/web-runtime-client.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index e6fc34699ff5..4f2540d0fefd 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -18,6 +18,7 @@ import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, @@ -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/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 7e17e914433f..59dbb952c025 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -12,6 +12,7 @@ import { } from '../../../shared/e2ee-crypto' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { + AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, @@ -89,6 +90,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, From d296499e235fe4f6e727ab2955ac17e7aed3f9c5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:45:58 -0700 Subject: [PATCH 07/34] docs(agent-status): the structured row folds the store's child records --- docs/reference/agent-status-store.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index b7ce4fa16dde..9970b90b21b5 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 From 2ece5c204d90a9d359271a4a8d7e3c5973b59ca2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:09:14 -0700 Subject: [PATCH 08/34] fix(agent-status): keep the view reader's header from reading as a value import to the renderer boundary The renderer node-builtin boundary test scans raw text, so a header comment that said "imports" ahead of the import block turned the type-only import of agent-status-child-work into a value edge that reaches node:crypto. --- src/shared/agent-status-child-work-view-wire.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/agent-status-child-work-view-wire.ts b/src/shared/agent-status-child-work-view-wire.ts index acf80647d92a..0eb98fe225d7 100644 --- a/src/shared/agent-status-child-work-view-wire.ts +++ b/src/shared/agent-status-child-work-view-wire.ts @@ -3,7 +3,7 @@ // 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 imports it. Type-only imports keep this loadable in the renderer. +// host-internal; nothing here loads it. Type-only references keep this loadable in the renderer. import type { AgentChildWorkKind, From abfce6d769db4f68d9ede652d06165b233f085e5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:06:49 -0700 Subject: [PATCH 09/34] refactor(native-chat): the status summary's broadcast equality gets its own module The status feed crossed the file-size limit once the summary gained the main agent's turn outcome beside the child views. Which summary changes reach every session list now lives in structured-agent-session-status-summary-equality.ts. --- .../structured-agent-session-status-feed.ts | 56 ++---------------- ...d-agent-session-status-summary-equality.ts | 57 +++++++++++++++++++ 2 files changed, 62 insertions(+), 51 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts 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 f3740d9232ac..9e11d585ea31 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,28 +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 AgentSessionStatusEvent, - type AgentSessionStatusSummary +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 { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' -import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' 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 { newestRootTurnId, - structuredStatusChildrenEqual, structuredStatusChildWork } from './structured-agent-session-status-child-work' +import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' import { StructuredAgentSessionStatusOwnership, type StructuredAgentSessionStatusSink @@ -70,48 +66,6 @@ export type StructuredAgentSessionStatusFeedDeps = { 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 && - structuredStatusChildrenEqual(a.children, b.children) && - 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. */ @@ -250,7 +204,7 @@ export class StructuredAgentSessionStatusFeed { this.retireSettledChildrenOnNewTurn(sessionId, session, projection.rootTurnId) 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) } 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 000000000000..739d9156c2dc --- /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 + }) + ) + ) +} From 38ecf1f779a79792ef836b4f100e9626825345a4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:23:15 -0700 Subject: [PATCH 10/34] fix(native-chat): command admission reads the strip's child records A conversation command was refused on the provider tracker's own roster while the strip read the host's child records, so a drift between the two rule sets could refuse /clear with a stop instruction the strip had no button for. Admission now reads the same records through the same read as the strip, uses the strip's liveness fold, and asks for a stop only when the strip renders one. The adapter contract no longer exposes the tracker roster, so no host decision can read it. Also records when the legacy child shapes die, every earlier death of a settled child, and the display-precision invariant behind the summary's clock tolerance. --- .../claude-structured-session-adapter.ts | 6 +- .../codex/codex-structured-session-adapter.ts | 8 +- ...structured-agent-session-adapter-router.ts | 4 - .../structured-agent-session-adapter.ts | 5 - ...structured-agent-session-host-mutations.ts | 3 + .../structured-agent-session-host.ts | 1 + .../structured-agent-session-rewind.ts | 2 +- ...uctured-agent-session-status-child-work.ts | 9 +- .../structured-agent-session-status-feed.ts | 7 +- ...red-conversation-command-admission.test.ts | 94 +++++++--- ...ructured-conversation-command-admission.ts | 32 +++- ...ed-conversation-command-child-work.test.ts | 173 ++++++++++++++++++ .../structured-conversation-command.ts | 2 +- ...gent-session-background-task-capability.ts | 3 +- src/shared/agent-child-row-model.ts | 8 +- ...uctured-agent-session-child-work-legacy.ts | 9 + 16 files changed, 313 insertions(+), 53 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 83256f12ddf6..3e62f1fd267e 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -214,9 +214,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda input.taskId ) } - backgroundTaskState: NonNullable = ( - sessionId - ) => { + /** 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 => { const session = this.sessions.get(sessionId) return session ? backgroundTaskState(session) : undefined } diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index a8c2204df3fc..80bbc12fcb48 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 { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { @@ -188,9 +189,10 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap ) } - backgroundTaskState: NonNullable = ( - sessionId - ) => this.sessions.get(sessionId)?.backgroundTasks.state + /** 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 = ( 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 2ab75eb8e0db..56ab202fd2f6 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 @@ -108,10 +108,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return stop ? stop(input) : Promise.resolve({ cancelled: false }) } - backgroundTaskState: NonNullable = ( - sessionId - ) => this.liveOwnerOrNull(sessionId)?.backgroundTaskState?.(sessionId) - backgroundTaskStops: NonNullable = ( sessionId ) => this.liveOwnerOrNull(sessionId)?.backgroundTaskStops?.(sessionId) 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 a7bec3f8cb58..7c1b5cde6dc4 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 @@ -24,7 +24,6 @@ import type { AgentSessionProcessIdentity } from '../../../shared/agent-session-record' import type { - AgentSessionBackgroundTaskState, AgentSessionOptionsResult, AgentSessionSlashCommand, AgentSessionThreadGoalChange @@ -310,10 +309,6 @@ export type StructuredAgentSessionAdapter = { fence: number taskId?: string }): Promise<{ cancelled: boolean }> - /** The provider's own roster of background work it can stop, for provider-side decisions - * (targeted stops, conversation-command admission). No surface reads it: every surface reads - * the host's child records. */ - 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 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 35782b676c2e..e5a92bf16a33 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 @@ -27,6 +27,7 @@ import { } 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 { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { @@ -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 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 34c41b61560f..53bb78287071 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 @@ -266,6 +266,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) => 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 45dd7244896e..0f85041bcc43 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 @@ -72,7 +72,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-status-child-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.ts index 53990784ffd3..3561ee077e6c 100644 --- 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 @@ -15,7 +15,11 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import { agentChildWorkViewsEqual } from '../../../shared/agent-status-child-work-view-wire' import { structuredChildWorkLegacyTasks } from '../../../shared/structured-agent-session-child-work-legacy' -/** An evidence clock that advanced by less than this does not re-broadcast a summary. */ +/** 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 = { @@ -34,7 +38,8 @@ export function structuredStatusChildWork( } const children = views.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. + // 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 } : {}) } } 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 9e11d585ea31..2fb01a090688 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 @@ -164,7 +164,8 @@ export class StructuredAgentSessionStatusFeed { return } // The store forgets a closed session's child records with its row, so the retained - // projection must not keep listing them. + // projection must not keep listing them — settled ones included, or the session list would + // show rows the strip no longer has. const { hostExecutionOwned: _hostExecutionOwned, hostExecutionPhase: _hostExecutionPhase, @@ -224,7 +225,9 @@ export class StructuredAgentSessionStatusFeed { } } - /** Finished children stay listed, with their outcome, until the session's own next turn. */ + /** Finished children stay listed, with their outcome, until the session's own next turn at the + * latest; the host closing or releasing the session drops them sooner (see `revokeLive`). Nothing + * caps how many settle within a turn. A command never settles: its record goes when it stops. */ private retireSettledChildrenOnNewTurn( sessionId: string, session: StatusFeedSession, 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 fe3e443c4959..992bb4ac0557 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,104 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { conversationCommandBlocked } from './structured-conversation-command-admission' +import type { AgentSessionBackgroundTaskStops } from './structured-agent-session-adapter' import type { AgentSessionTurnContext } from './structured-agent-session-turns' -function contextWith( - backgroundTasks: AgentSessionBackgroundTaskState | null -): AgentSessionTurnContext { +const TARGETED: AgentSessionBackgroundTaskStops = { supportsTaskStop: true, supportsStopAll: true } +const UNTARGETED: AgentSessionBackgroundTaskStops = { + supportsTaskStop: false, + supportsStopAll: true +} +const NO_STOP: AgentSessionBackgroundTaskStops = { supportsTaskStop: false, supportsStopAll: false } + +function contextWith(stops: AgentSessionBackgroundTaskStops | undefined): AgentSessionTurnContext { return { sessionId: 'session-1', journal: { snapshot: () => ({ items: [] }), submissions: () => [] }, - adapter: { backgroundTaskState: () => backgroundTasks } + adapter: { + backgroundTaskStops: () => stops, + // The provider tracker's roster, claiming live work: admission must not consult it. + backgroundTaskState: () => ({ state: 'monitoring', tasks: [{ id: 'tracker-only' }] }) + } } as unknown as AgentSessionTurnContext } +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 + } +} + +const SETTLED = child({ + 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 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 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 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 +111,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.' ) }) 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 cecdbbf0b139..27b2a064f0a7 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,5 +1,9 @@ +import { agentChildWorkViewOffersStop } from '../../../shared/agent-child-row-model' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +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 { AgentSessionBackgroundTaskStops } from './structured-agent-session-adapter' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { refuse, @@ -14,9 +18,12 @@ function blocked( return refuse('agent_session_operation_invalid', { reason }, message) } +/** `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. */ export function conversationCommandBlocked( ctx: AgentSessionTurnContext, - record: AgentSessionRecord + record: AgentSessionRecord, + childWork: readonly AgentChildWorkView[] | undefined ): AgentSessionWireRefusal | null { const items = ctx.journal.snapshot().items if (record.rewind?.phase === 'prepared' || record.rewind?.phase === 'provider-succeeded') { @@ -59,14 +66,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.' ) @@ -86,3 +90,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 000000000000..e0044717d195 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts @@ -0,0 +1,173 @@ +// 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 { 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 { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION, + hostTestAttachParams, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const caller = { callerKey: 'desktop' } +let directory: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +const compact = vi.fn>() +/** 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: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId: HOST_TEST_SESSION, + fields: { 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 + } +} + +beforeEach(async () => { + records = [] + resetHostTestOperationIds() + compact.mockReset().mockResolvedValue({}) + directory = await mkdtemp(join(tmpdir(), 'orca-command-child-work-')) + store = await AgentSessionRecordStore.open({ + directory: join(directory, 'store'), + hostId: 'local' + }) + const statusSink: StructuredAgentSessionStatusSink = { + publish: () => {}, + forget: () => {}, + publishChildWork: () => {}, + readChildWork: () => records + } + const trackerRoster: AgentSessionBackgroundTaskState = { + state: 'monitoring', + tasks: [{ id: 'tracker-only', kind: 'command', description: 'not on the strip' }] + } + const adapter = { + supportsLocation: () => true, + acquire: vi.fn(async (input: Parameters[0]) => ({ + process: { + hostId: 'local', + pid: 4001, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + link: { + linkId: 'link-1', + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: 'created' as const, + handle: { provider: 'codex' as const, threadId: '00000000-0000-4000-8000-000000000001' } + } + })), + dispatch: vi.fn(async () => ({ state: 'unknown' as const, reason: 'test' })), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: async () => {}, + setOption: async () => {}, + compact, + releaseAcquisition: async () => true, + closeSession: async () => true, + readOptions: async () => ({ models: [], current: { model: 'test-model', effort: 'high' } }), + backgroundTaskStops: () => ({ supportsTaskStop: true, supportsStopAll: true }), + // A tracker that drifted from the records: it still claims work the strip does not list. + backgroundTaskState: () => trackerRoster + } + host = new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: directory, + claimKeyId: 'key', + now: () => HOST_TEST_NOW, + mintSpawnToken: () => 'spawn-1', + statusSink + }) + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(directory, { recursive: true, force: true }) +}) + +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 () => { + const strip: (AgentSessionBackgroundTaskState | null)[] = [] + 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([]) + expect(await host.conversationCommand(caller, compactParams())).toMatchObject({ ok: true }) + expect(compact).toHaveBeenCalledTimes(1) + + records = [devServer()] + host.publishChildWorkEvidence(HOST_TEST_SESSION, []) + expect(stripRows()).toEqual([ + { description: 'npm run dev', membership: 'live', stoppable: true, providerId: 'task-dev' } + ]) + expect(await host.conversationCommand(caller, compactParams())).toMatchObject({ + ok: false, + refusal: { message: 'Stop background tasks before using this command.' } + }) + + records = [devServer({ state: 'done', membership: 'settled', outcome: 'succeeded' })] + host.publishChildWorkEvidence(HOST_TEST_SESSION, []) + // Still listed, as finished: a finished row blocks nothing. + expect(stripRows()).toEqual([ + { description: 'npm run dev', membership: 'settled', stoppable: true, providerId: 'task-dev' } + ]) + expect(await host.conversationCommand(caller, compactParams())).toMatchObject({ ok: true }) + expect(compact).toHaveBeenCalledTimes(2) + }) +}) 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 2075c24d7bb0..ee44ce369bfe 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 @@ -136,7 +136,7 @@ export function runStructuredConversationCommand( const blocked = prior?.phase === 'prepared' && command === 'clear' ? null - : conversationCommandBlocked(ctx, record) + : conversationCommandBlocked(ctx, record, context.readChildWork(sessionId)) if (blocked) { return { ok: false, refusal: blocked } } 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 5a9fd7c4566a..be95818fa7f0 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 @@ -35,7 +35,8 @@ function readsChildViews(ctx: BackgroundTaskReader): boolean { /** A reader that predates child views reads any roster as live work: it animates the monitoring * indicator and refuses conversation commands on one. A roster of settled rows alone is new, so - * that reader gets its pre-feature view of it — no strip — and never the views it cannot read. */ + * that reader gets its pre-feature view of it — no strip — and never the views it cannot read. + * Deleted with the legacy shapes: see `structured-agent-session-child-work-legacy`. */ function withoutChildViews( state: AgentSessionBackgroundTaskState ): AgentSessionBackgroundTaskState | null { diff --git a/src/shared/agent-child-row-model.ts b/src/shared/agent-child-row-model.ts index 8e1cf606eea1..6d7bb95eb304 100644 --- a/src/shared/agent-child-row-model.ts +++ b/src/shared/agent-child-row-model.ts @@ -203,6 +203,12 @@ function viewName(view: AgentChildWorkView): string { ) } +/** 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 +} + function rowFromView( view: AgentChildWorkView, views: readonly AgentChildWorkView[], @@ -242,7 +248,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/structured-agent-session-child-work-legacy.ts b/src/shared/structured-agent-session-child-work-legacy.ts index 6a21b5223809..11441e458d4b 100644 --- a/src/shared/structured-agent-session-child-work-legacy.ts +++ b/src/shared/structured-agent-session-child-work-legacy.ts @@ -1,6 +1,15 @@ // 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 { From 46b695a26ba973bd25364e52cdce78955648e2af Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:28:34 -0700 Subject: [PATCH 11/34] refactor(native-chat): command admission takes only what it reads of a turn --- ...red-conversation-command-admission.test.ts | 29 ++++++++++--------- ...ructured-conversation-command-admission.ts | 20 +++++++++++-- 2 files changed, 33 insertions(+), 16 deletions(-) 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 992bb4ac0557..b5546e33b502 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,9 +1,11 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { conversationCommandBlocked } from './structured-conversation-command-admission' +import { + conversationCommandBlocked, + type ConversationCommandAdmissionContext +} from './structured-conversation-command-admission' import type { AgentSessionBackgroundTaskStops } from './structured-agent-session-adapter' -import type { AgentSessionTurnContext } from './structured-agent-session-turns' const TARGETED: AgentSessionBackgroundTaskStops = { supportsTaskStop: true, supportsStopAll: true } const UNTARGETED: AgentSessionBackgroundTaskStops = { @@ -12,19 +14,20 @@ const UNTARGETED: AgentSessionBackgroundTaskStops = { } const NO_STOP: AgentSessionBackgroundTaskStops = { supportsTaskStop: false, supportsStopAll: false } -function contextWith(stops: AgentSessionBackgroundTaskStops | undefined): AgentSessionTurnContext { +function contextWith( + 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: { - backgroundTaskStops: () => stops, - // The provider tracker's roster, claiming live work: admission must not consult it. - backgroundTaskState: () => ({ state: 'monitoring', tasks: [{ id: 'tracker-only' }] }) - } - } as unknown as AgentSessionTurnContext + fence: 1, + journal: { snapshot: () => ({ items: [] }), submissions: () => [] }, + adapter + } } function child(overrides: Partial = {}): AgentChildWorkView { 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 27b2a064f0a7..e1f33fa9b808 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 @@ -3,14 +3,28 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' 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 { AgentSessionBackgroundTaskStops } from './structured-agent-session-adapter' -import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + AgentSessionBackgroundTaskStops, + 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 @@ -21,7 +35,7 @@ function blocked( /** `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. */ export function conversationCommandBlocked( - ctx: AgentSessionTurnContext, + ctx: ConversationCommandAdmissionContext, record: AgentSessionRecord, childWork: readonly AgentChildWorkView[] | undefined ): AgentSessionWireRefusal | null { From abc964b9242a706309838e35ffa6753f86f0bc2d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:13:20 -0700 Subject: [PATCH 12/34] fix(native-chat): the session list drops a session's children when the store does A session's end no longer removes its child records: a child still running settles with an outcome nobody reported, and a finished one stays listed. Records now leave only at the session's own next turn, at the cap on settled records, or when the host lets go of the session and its row leaves the store. The summary kept after the host lets go used to strip its children on close, a rule of its own. It now re-reads them from the store when the row leaves, through the same read every live summary uses, so the session list and the chat strip list the same children at each step, including a forget with no close. Closing only revokes ownership, as before the child records existed. --- .../claude-structured-session-close.test.ts | 2 +- ...tructured-session-background-tasks.test.ts | 4 +- ...agent-session-status-feed-children.test.ts | 17 ++++++ .../structured-agent-session-status-feed.ts | 58 ++++++++++++------- .../structured-child-records-switch.test.ts | 30 ++++++++-- .../agent-status-child-work-evidence.ts | 3 +- 6 files changed, 86 insertions(+), 28 deletions(-) diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 7984f8fc9d9b..34e4b6320f88 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -131,7 +131,7 @@ 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() - // The host's child records hear the session end, so no surface keeps the child listed. + // 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) 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 ddd3da6e8f53..4e2515da7d43 100644 --- a/src/main/codex/codex-structured-session-background-tasks.test.ts +++ b/src/main/codex/codex-structured-session-background-tasks.test.ts @@ -242,7 +242,7 @@ describe("codex background tasks reach the host's child records", () => { expect(adapter.backgroundTaskState('session-1')).toEqual(running) }) - it('clears the strip when the session closes', async () => { + 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') @@ -252,7 +252,7 @@ describe("codex background tasks reach the host's child records", () => { expect(await adapter.closeSession('session-1')).toBe(true) - // The records end with the session, so no surface keeps the child listed. + // 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/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 index 6e6ad529ef79..8455fb5cdcbd 100644 --- 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 @@ -282,4 +282,21 @@ describe('structured status summary child records', () => { 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({ membership: 'settled', state: 'done', outcome: 'succeeded' }) + ] + 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.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 2fb01a090688..d7ef31d26ac8 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 @@ -128,13 +128,15 @@ 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.rootTurns.delete(sessionId) try { @@ -142,6 +144,14 @@ export class StructuredAgentSessionStatusFeed { } 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 { @@ -163,15 +173,10 @@ export class StructuredAgentSessionStatusFeed { if (!previous) { return } - // The store forgets a closed session's child records with its row, so the retained - // projection must not keep listing them — settled ones included, or the session list would - // show rows the strip no longer has. const { hostExecutionOwned: _hostExecutionOwned, hostExecutionPhase: _hostExecutionPhase, hostExecutionChild: _hostExecutionChild, - children: _children, - backgroundTasks: _backgroundTasks, ...retained } = previous this.published.set(sessionId, retained) @@ -225,9 +230,12 @@ export class StructuredAgentSessionStatusFeed { } } - /** Finished children stay listed, with their outcome, until the session's own next turn at the - * latest; the host closing or releasing the session drops them sooner (see `revokeLive`). Nothing - * caps how many settle within a turn. A command never settles: its record goes when it stops. */ + /** A finished child stays listed, with its outcome, until the session's own next turn begins, + * unless it still owns live work. The provider ending the session removes nothing: children + * still live settle `unknown`, and settled ones stay. The one earlier death is the host letting + * go of the session, whose row takes every child record with it (see `forget`). Nothing caps how + * many settle within a turn, and a command never settles: its record goes when it stops. The + * summary and the strip both re-read the store, so each step removes a child from both. */ private retireSettledChildrenOnNewTurn( sessionId: string, session: StatusFeedSession, @@ -254,13 +262,6 @@ export class StructuredAgentSessionStatusFeed { // 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 { children, backgroundTasks } = structuredStatusChildWork( - this.readChildWork(sessionId), - session.params.provider - ) return { sessionId, workspaceId: session.params.location.workspaceId, @@ -277,12 +278,29 @@ export class StructuredAgentSessionStatusFeed { ? { rewindBlockedReason: 'outcome-unknown' as const } : {}), ...(model ? { model } : {}), + ...this.childWorkFields(sessionId, session.params.provider), + ...(providerSession ? { providerSession } : {}), + updatedAt: journal.lastActivityAt() || this.deps.now() + } + } + + /** 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 } : {}), - ...(providerSession ? { providerSession } : {}), - updatedAt: journal.lastActivityAt() || this.deps.now() + ...(children ? { children } : {}) } } diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index 48358892d819..3e22447f8bf9 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -154,12 +154,15 @@ describe('the chat strip and the session list read the same host child records', } }) // Journal writes land asynchronously; let each frame's rows publish before the next. - const notify = async (method: string, params: Record) => { - connections[0]?.onNotification?.(method, params) + 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, @@ -280,9 +283,28 @@ describe('the chat strip and the session list read the same host child records', expect(summaries.at(-1)).not.toHaveProperty('children') expect(strip.at(-1)).toBeNull() - // The provider exits: the records go with the session, on both. + // The reviewer's next run is still going when the provider exits: it settles with an outcome + // nobody reported, and both keep listing it. + 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')) - expect(server.getStructuredChildWorkViews(parentSubject(summaries))).toEqual([]) + await settle() + both([ + { + kind: 'agent', + description: 'review', + state: 'done', + membership: 'settled', + outcome: 'unknown' + } + ]) + + // Letting go of the session drops its row, and every child record with it, from both. + const subject = parentSubject(summaries) + await host.close(SESSION) + expect(server.getStructuredChildWorkViews(subject)).toEqual([]) + expect(summaries.at(-1)).not.toHaveProperty('children') + expect(summaries.at(-1)).not.toHaveProperty('backgroundTasks') }) }) diff --git a/src/shared/agent-status-child-work-evidence.ts b/src/shared/agent-status-child-work-evidence.ts index 6a65936cdbe7..f443c7751cea 100644 --- a/src/shared/agent-status-child-work-evidence.ts +++ b/src/shared/agent-status-child-work-evidence.ts @@ -76,7 +76,8 @@ export type AgentChildWorkEndedEvidence = { 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 session'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 From bb1b369be0ddcb0ef1208d41690a5487856c4232 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:13:22 -0700 Subject: [PATCH 13/34] test(native-chat): write the Codex frame script's parent row out step by step Once every surface reads the child records, the provider tracker's roster is no oracle: it and the records read the same child executions, so agreeing with it cannot catch a defect in either. Each frame now states the child liveness and the parent row it must fold to. --- ...dex-structured-child-work-producer.test.ts | 147 +++++++++++++----- 1 file changed, 111 insertions(+), 36 deletions(-) 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 b39119dc1842..175b6cfb7341 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, @@ -42,8 +43,8 @@ const LINTER = 'thread-linter' type Frame = { method: string; params: Record } 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 () => ({ @@ -180,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, @@ -216,28 +217,43 @@ describe('Codex structured child-work producer', () => { ]) }) - 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', @@ -254,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', @@ -263,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() @@ -279,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', @@ -319,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', @@ -332,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, and no turn/completed. { frame: { @@ -343,28 +397,41 @@ 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: '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') @@ -372,24 +439,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: [before, step.parent].some((p) => isDeepStrictEqual(p, parent)) + }).toEqual({ index, at, parent: true }) + expect({ index, at, done: commandLive && parent.stateName === 'done' }).toEqual({ index, at, - parent: fold(step.lead, legacy) + 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) From 6b2020e583d05d307ed6574ff64cb16ad21b9e82 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:15:28 -0700 Subject: [PATCH 14/34] fix(native-chat): the idle sweep and the restart snapshot read the host's child records The idle sweep (keep an agent running while its subagents or commands run) and the restart-resume snapshot (what a chat was doing when Orca stopped it) both read the provider tracker's roster through the adapter interface, which no longer carries it. Both now take the host's one child-record read, the same one the status summary, the chat strip and command admission use. The snapshot's working test also folded that roster through the shared fold's old `backgroundTasks` input, which the fold no longer reads, so a settled lead whose subagent was still running would have been offered nothing. It now hands the fold the records. --- ...d-agent-session-conversation-close.test.ts | 2 +- ...red-agent-session-conversation-lifetime.ts | 5 +- ...uctured-agent-session-host-test-harness.ts | 7 ++ .../structured-agent-session-host.ts | 12 ++-- ...tructured-agent-session-idle-sweep.test.ts | 71 +++++++++---------- .../structured-agent-session-idle-sweep.ts | 7 +- ...ed-agent-session-owed-work-release.test.ts | 20 +++++- .../structured-agent-session-rest-test-rig.ts | 16 +++-- ...ssion-restart-interruption-test-harness.ts | 14 ++-- ...tured-agent-session-restart-resume-host.ts | 2 +- ...ent-session-restart-resume-test-harness.ts | 16 +++++ ...red-agent-session-restart-resume-wiring.ts | 5 +- ...tured-agent-session-restart-resume.test.ts | 58 +++++++-------- ...red-agent-session-restart-teardown.test.ts | 25 ++++--- ...uctured-agent-session-restart-witnesses.ts | 4 +- ...tructured-agent-session-shown-work.test.ts | 11 ++- .../structured-agent-session-shown-work.ts | 10 +-- ...tured-agent-session-working-at-teardown.ts | 22 +++--- 18 files changed, 187 insertions(+), 120 deletions(-) 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 bf3cd1f96a60..5f05a5f78bb5 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, stopAgent, stopStartingAgent: 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 0fbd1a0a233c..cae2a8c39540 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 { @@ -36,6 +37,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 @@ -64,7 +67,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-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index 1e0d77b0d524..87091073ec0a 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 { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { @@ -167,6 +168,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 53bb78287071..86529312b90e 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 @@ -151,18 +151,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() 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 ebc2009fa7db..798c9b148090 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 @@ -3,6 +3,7 @@ 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, @@ -42,6 +43,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 } @@ -101,25 +121,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)) }) @@ -128,16 +136,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() @@ -149,16 +151,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() @@ -167,15 +165,12 @@ describe('the idle sweep', () => { await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) - 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)) @@ -298,7 +293,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, stopAgent, stopStartingAgent: 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 2394b604171c..05e297a5b886 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 /** Each of these runs inside the session's serialize and never takes it again. */ @@ -143,7 +144,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) || hasPendingStructuredAgentSessionPrompt(items) ) 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 9da47ae1f22d..f8aec7d5d3b0 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, @@ -42,9 +43,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 = [] @@ -71,6 +74,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) @@ -92,7 +97,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) + } }) }) @@ -261,8 +274,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 3ea116fa361d..3113323b7406 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 { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionDispatchOutcome, @@ -42,7 +43,6 @@ export type RestTestAdapter = { acknowledgeSessionRelease: Mock< NonNullable > - backgroundTaskState: Mock> readOptions: Mock> } @@ -53,7 +53,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 @@ -124,7 +129,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 AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' @@ -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), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) } const hostFor = (overrides: Partial) => 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 d88a6330e789..2e785fc34a03 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 @@ -10,16 +10,19 @@ import { } from '../../runtime/agent-session-recovery-capsule' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' 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, @@ -51,6 +54,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) { @@ -93,10 +100,7 @@ export async function interruptedRestart( ] }) events.appendItem(group, roster('working')) - 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')) 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 d053caf006b2..af2a0970a6fc 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 e359e31157b9..93c33146743b 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 4004502e4081..bb9eb500d1db 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 efcd70345a83..a2ed07db2916 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 bf740ac405b2..8c3fdbf3aa77 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,7 +1,15 @@ 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, @@ -113,8 +121,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 @@ -126,15 +136,12 @@ it('marks a settled chat whose subagent was still running', async () => { { kind: 'turn', turnId: 'settled', state: 'completed' } ) 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 6cd19475d69f..2beee1b5a59c 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-shown-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-shown-work.test.ts index 819c242fdfb7..3fc7dbee0a99 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 d50f05709879..4ed41e8c6884 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-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 366ce1cf948e..ece336266a98 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 { activeStructuredAgentSessionTurnId, newestStructuredAgentSessionTurn @@ -123,14 +123,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 = { @@ -145,8 +145,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 @@ -162,10 +162,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') { @@ -183,7 +183,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, From 0b5293a8cf2708683e9df35a81ace06621e67d32 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:13:20 -0700 Subject: [PATCH 15/34] test(native-chat): the child-record tests follow the merged command lifecycle A command is a live child record from its start and is removed, not settled, when it stops, whatever Codex tagged it. The end-to-end switch now shows the child's `npm test` as a live row beside its dev server, and both are gone once they exit; only the finished subagent stays listed until the next turn. Letting go of the session is its tab closing, since a closed conversation whose tab remains keeps its row. Command admission's finished row is a subagent, the one kind that settles, and the failed-verdict row test admits its live subagent as a host record, the only thing the row folds. --- ...red-conversation-command-admission.test.ts | 4 ++ ...untime-worktree-agent-rows-verdict.test.ts | 28 +++++++--- .../structured-child-records-switch.test.ts | 53 ++++++++++++++----- ...t-status-child-work-reconciliation.test.ts | 2 +- 4 files changed, 66 insertions(+), 21 deletions(-) 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 b5546e33b502..bf5c72fc9386 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 @@ -46,7 +46,11 @@ function child(overrides: Partial = {}): AgentChildWorkView } } +// 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', 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 7b22a45d09d5..ef245466b5a0 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -4,6 +4,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 { @@ -88,9 +89,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 = { @@ -182,12 +188,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 } ) - 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-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index 3e22447f8bf9..4ba3c0149a44 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -131,7 +131,6 @@ describe('the chat strip and the session list read the same host child records', expect(await host.attach({ callerKey: 'switch-test' }, attachParams)).toMatchObject({ ok: true }) - await host.hold(SESSION, 'desktop-chat:1') const summaries: AgentSessionStatusSummary[] = [] host.subscribeStatus({ id: 'session-list', @@ -144,7 +143,7 @@ describe('the chat strip and the session list read the same host child records', } }) const strip: (AgentSessionBackgroundTaskState | null)[] = [] - host.subscribe({ + await host.subscribe({ id: 'strip', sessionId: SESSION, emit: (event) => { @@ -207,13 +206,47 @@ describe('the chat strip and the session list read the same host child records', 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 test' + 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', @@ -260,7 +293,8 @@ describe('the chat strip and the session list read the same host child records', exitCode: 0 }) expect(parentRow()).toMatchObject({ state: 'done' }) - // Finished children stay listed, with how they ended, until the session's next turn. + // The finished child stays listed, with how it ended, until the session's next turn; the dev + // server's record went when its process exited. both([ { kind: 'agent', @@ -268,14 +302,6 @@ describe('the chat strip and the session list read the same host child records', state: 'done', membership: 'settled', outcome: 'succeeded' - }, - { - kind: 'command', - description: 'npm run dev', - state: 'done', - membership: 'settled', - outcome: 'succeeded', - owner: 'review' } ]) @@ -299,8 +325,9 @@ describe('the chat strip and the session list read the same host child records', } ]) - // Letting go of the session drops its row, and every child record with it, from both. + // Closing its tab lets go of the session: its row, and every child record with it, leave both. const subject = parentSubject(summaries) + await host.setSessionTabVisibility(SESSION, false) await host.close(SESSION) expect(server.getStructuredChildWorkViews(subject)).toEqual([]) expect(summaries.at(-1)).not.toHaveProperty('children') diff --git a/src/shared/agent-status-child-work-reconciliation.test.ts b/src/shared/agent-status-child-work-reconciliation.test.ts index dea2c8ee1e02..1be313826525 100644 --- a/src/shared/agent-status-child-work-reconciliation.test.ts +++ b/src/shared/agent-status-child-work-reconciliation.test.ts @@ -426,7 +426,7 @@ describe('structured child-work reconciliation', () => { expect(records(store)).toHaveLength(1) }) - // Brennan's call: a finished subagent stays listed, with how it ended, until the parent's next + // 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() From f6061875d2a545a3ba5eb589df13f7aca6c7f55e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:41:45 -0700 Subject: [PATCH 16/34] refactor(native-chat): the status feed's journal projection cache gets its own module The status feed crossed the file-size limit once the child records joined the agent-start signal and the completion feed's status read. The per-journal projection, cached per commit, now lives in structured-agent-session-status-journal-projection.ts. --- .../structured-agent-session-status-feed.ts | 64 +++---------------- ...agent-session-status-journal-projection.ts | 64 +++++++++++++++++++ 2 files changed, 72 insertions(+), 56 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts 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 d7ef31d26ac8..339a66f28249 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 @@ -19,14 +19,14 @@ import type { } 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 { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' 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 { - newestRootTurnId, - structuredStatusChildWork -} from './structured-agent-session-status-child-work' + StructuredAgentSessionJournalProjections, + type StructuredAgentSessionStatusState +} from './structured-agent-session-status-journal-projection' import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality' import { StructuredAgentSessionStatusOwnership, @@ -35,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 @@ -93,15 +91,6 @@ export function createStructuredAgentSessionHostStatusFeed(args: { }) } -type JournalProjection = { - epoch: string - sequence: number - readOnly: boolean - fence: number | undefined - state: StructuredAgentSessionStatusState - rootTurnId: string | null -} - export class StructuredAgentSessionStatusFeed { private readonly ownership = new StructuredAgentSessionStatusOwnership(() => this.deps.statusSink?.() @@ -110,8 +99,7 @@ export class StructuredAgentSessionStatusFeed { private readonly published = new Map() /** The newest root turn each session was last projected with; a new one retires settled children. */ private readonly rootTurns = new Map() - // Task progress must not sort and scan an unchanged conversation. Journal identity owns cleanup. - private readonly journalProjections = new WeakMap() + private readonly projections = new StructuredAgentSessionJournalProjections() constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {} @@ -195,7 +183,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)).state : 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. */ @@ -206,7 +194,7 @@ export class StructuredAgentSessionStatusFeed { } const source = journal ?? session.journal const record = this.deps.getRecord(sessionId) - const projection = this.projectionFor(source, record) + const projection = this.projections.read(source, record) this.retireSettledChildrenOnNewTurn(sessionId, session, projection.rootTurnId) const summary = this.summaryFor(sessionId, session, source, record, projection.state) const previous = this.published.get(sessionId) @@ -344,42 +332,6 @@ export class StructuredAgentSessionStatusFeed { return true } - private projectionFor( - journal: AgentSessionJournal, - record: AgentSessionRecord | null - ): JournalProjection { - // 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 - ), - rootTurnId: newestRootTurnId(snapshot?.items ?? []) - } - this.journalProjections.set(journal, projection) - } - return projection - } - /** A failing sink must never cost the subscribers their status event. */ private sink( summary: AgentSessionStatusSummary, 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 000000000000..fbf8cd5ea418 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts @@ -0,0 +1,64 @@ +// The status feed's per-journal projection, cached per commit: what a session's journal says its +// row is, and the newest root turn it holds. + +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 { newestRootTurnId } 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 + rootTurnId: 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 + ), + rootTurnId: newestRootTurnId(snapshot?.items ?? []) + } + this.byJournal.set(journal, projection) + } + return projection + } +} From e528daea6c4fa596b543a9d2216a69bd6bdb33ac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:06:35 -0700 Subject: [PATCH 17/34] test(native-chat): the admission test's compaction resolves with a real outcome Main's compaction result is a tagged outcome; the host-level admission test resolved its mock compaction with an empty object. --- .../structured-conversation-command-child-work.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e0044717d195..0d4b630a4f57 100644 --- 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 @@ -64,7 +64,7 @@ function devServer(overrides: Partial = {}): AgentChildWorkV beforeEach(async () => { records = [] resetHostTestOperationIds() - compact.mockReset().mockResolvedValue({}) + compact.mockReset().mockResolvedValue({ outcome: 'compacted' }) directory = await mkdtemp(join(tmpdir(), 'orca-command-child-work-')) store = await AgentSessionRecordStore.open({ directory: join(directory, 'store'), From 690580968b47d0e5bb619e87cbb383dc5e2b2101 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:40:46 -0700 Subject: [PATCH 18/34] feat(native-chat): the sidebar lists running subagents; the strip, running then the newest finished The host keeps every child record; what each surface lists is picked from them on every read, so nothing is stored twice. The status summary, which every session list reads, now carries only running children (and a finished one whose shell still runs, which reads monitoring): a finished or failed subagent leaves the sidebar and stays in the chat's strip. The strip lists every running child, then the newest finished ones, 100 rows in all; more than 100 running all show. This matches common practice: sidebars show live subagents, and finished ones stay in the chat's panel, newest first. No wire field is added. An older client reads fewer rows: its legacy task lists were already live-only in the summary, and the strip's settled tasks come from the same bounded roster. --- ...d-agent-session-background-task-channel.ts | 11 +- ...agent-session-child-work-selection.test.ts | 110 ++++++++++++++++++ ...uctured-agent-session-status-child-work.ts | 11 +- ...agent-session-status-feed-children.test.ts | 27 ++++- .../structured-agent-session-status-feed.ts | 7 +- .../structured-child-records-switch.test.ts | 22 ++-- .../agent-child-row-parity.test.tsx | 29 +++-- .../worktree-card-agent-summary.test.ts | 16 +-- ...ured-agent-session-child-work-selection.ts | 65 +++++++++++ 9 files changed, 258 insertions(+), 40 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts create mode 100644 src/shared/structured-agent-session-child-work-selection.ts 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 6464863f6132..5ad0697456c8 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 @@ -5,6 +5,7 @@ import type { } 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 { structuredStripChildWork } from '../../../shared/structured-agent-session-child-work-selection' import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' import type { AgentSessionSubscribers, @@ -16,8 +17,9 @@ import type { } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -/** The chat strip's roster: the host's child records for one session, as the same views the status - * summary carries, with the legacy task rows an older client reads derived from them. */ +/** The chat strip's roster: the host's child records for one session (every running child, then + * the newest finished ones, up to the strip's row budget), with the legacy task rows an older + * client reads derived from them. */ export class StructuredAgentSessionBackgroundTaskChannel { private readonly published = new Map() @@ -90,10 +92,11 @@ export class StructuredAgentSessionBackgroundTaskChannel { private state(sessionId: string): AgentSessionBackgroundTaskState | null | undefined { const session = this.sessions.get(sessionId) - const views = session ? this.readChildWork(sessionId) : undefined - if (!session || views === undefined) { + const stored = session ? this.readChildWork(sessionId) : undefined + if (!session || stored === undefined) { return undefined } + const views = structuredStripChildWork(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". 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 000000000000..ceb41dc4ad4e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts @@ -0,0 +1,110 @@ +// What each surface lists of a session's child records: the sidebar its running children, the +// chat's strip every running child and then the newest finished ones, up to 100 rows. + +import { describe, expect, it } from 'vitest' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { + STRUCTURED_STRIP_CHILD_WORK_LIMIT as LIMIT, + structuredSidebarChildWork, + structuredStripChildWork +} from '../../../shared/structured-agent-session-child-work-selection' +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 lists 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(structuredSidebarChildWork(views))).toEqual(['working', 'owner', 'shell']) + }) +}) + +describe('the strip lists running children, then the newest finished, up to 100 rows', () => { + it('lists every child while they fit', () => { + const views = [running('a'), finished('b', 2), finished('c', 3)] + expect(ids(structuredStripChildWork(views))).toEqual(['a', 'b', 'c']) + }) + + it('keeps every running child and the newest finished ones, in the order the store holds them', () => { + const finishedViews = Array.from({ length: 150 }, (_, index) => + finished(`done-${index}`, 1_000 + index) + ) + const runningViews = Array.from({ length: 10 }, (_, index) => running(`run-${index}`)) + const listed = structuredStripChildWork([...finishedViews, ...runningViews]) + expect(listed).toHaveLength(LIMIT) + // The 90 newest finished children (settled latest), then the running ones, as the store orders. + expect(ids(listed)).toEqual([ + ...Array.from({ length: 90 }, (_, index) => `done-${index + 60}`), + ...ids(runningViews) + ]) + }) + + it('shows every running child when more than 100 run, and no finished one', () => { + const runningViews = Array.from({ length: 120 }, (_, index) => running(`run-${index}`)) + const listed = structuredStripChildWork([finished('done', 5), ...runningViews]) + expect(ids(listed)).toEqual(ids(runningViews)) + }) +}) + +describe("the host's strip channel and summary list what these pick", () => { + it('sends the strip the bounded roster and the sidebar the running children', async () => { + const records = [ + ...Array.from({ length: 150 }, (_, index) => finished(`done-${index}`, 1_000 + index)), + running('run') + ] + serveHostTestChildWork(() => records) + await attach() + const { host } = hostTestState() + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + const children = page.ok ? (page.page.backgroundTasks?.children ?? []) : [] + expect(children).toHaveLength(LIMIT) + expect(children.at(-1)?.id).toBe('run') + expect(children[0]?.id).toBe('done-51') + 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']) + }) +}) 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 index 3561ee077e6c..0f4318e6b3c0 100644 --- 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 @@ -14,6 +14,7 @@ import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-w 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 { structuredSidebarChildWork } from '../../../shared/structured-agent-session-child-work-selection' /** 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, @@ -27,16 +28,18 @@ export type StructuredStatusChildWork = { backgroundTasks?: AgentSessionBackgroundTask[] } -/** The summary's child fields: the views without usage, and the live legacy tasks an older client - * reads, derived from the same views so the two cannot disagree. */ +/** 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 { - if (!views || views.length === 0) { + const running = views ? structuredSidebarChildWork(views) : [] + if (running.length === 0) { return {} } - const children = views.map(({ totalTokens: _totalTokens, ...view }) => view) + 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`. 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 index 8455fb5cdcbd..1a69132e03f8 100644 --- 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 @@ -186,7 +186,8 @@ describe('structured status summary child records', () => { }) }) - it('lists a finished child on the summary, never in the task list an older client folds', async () => { + // The sidebar lists running children only; a finished one stays in the chat's strip. + 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', @@ -203,8 +204,26 @@ describe('structured status summary child records', () => { // An older client reads a failed task's legacy `blocked` as still running. expect(lastSummary(events)).toMatchObject({ backgroundTasks: [LEGACY_TASK], - children: [childView(), failed] + 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 () => { @@ -285,9 +304,7 @@ describe('structured status summary child records', () => { it('stops listing children once the row leaves the sink, even with no close', async () => { const { feed, events, views } = await feedWithChildren() - views.current = () => [ - childView({ membership: 'settled', state: 'done', outcome: 'succeeded' }) - ] + views.current = () => [childView()] feed.publish(SESSION) expect(lastSummary(events)?.children).toHaveLength(1) feed.forget(SESSION) 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 339a66f28249..e69164c4406d 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 @@ -218,12 +218,13 @@ export class StructuredAgentSessionStatusFeed { } } - /** A finished child stays listed, with its outcome, until the session's own next turn begins, + /** A finished child's record stays, with its outcome, until the session's own next turn begins, * unless it still owns live work. The provider ending the session removes nothing: children * still live settle `unknown`, and settled ones stay. The one earlier death is the host letting * go of the session, whose row takes every child record with it (see `forget`). Nothing caps how - * many settle within a turn, and a command never settles: its record goes when it stops. The - * summary and the strip both re-read the store, so each step removes a child from both. */ + * many records a turn keeps, and a command never settles: its record goes when it stops. What + * each surface lists of them is picked on every read (`structured-agent-session-child-work- + * selection`): the sidebar only running children, the strip running then newest finished. */ private retireSettledChildrenOnNewTurn( sessionId: string, session: StatusFeedSession, diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index 4ba3c0149a44..ca209d6f966f 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -1,6 +1,7 @@ // The switch, end to end on a host with no renderer: a Codex session's frames reach the host's -// canonical store, and the status summary every session list reads and the chat strip's channel -// both carry the same child records from it, while the parent row is folded from them too. +// canonical store; the status summary every session list reads carries the running children, the +// chat strip's channel carries those and the finished ones, 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 @@ -91,7 +92,7 @@ describe('the chat strip and the session list read the same host child records', await rm(root, { recursive: true, force: true }) }) - it('carries one record per child to both, and folds the parent row from them', async () => { + it('carries running children to both and finished ones to the strip, and folds the parent row from them', async () => { const connections: CodexAppServerConnectionHandlers[] = [] const openConnection: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => { connections.push(handlers) @@ -293,9 +294,10 @@ describe('the chat strip and the session list read the same host child records', exitCode: 0 }) expect(parentRow()).toMatchObject({ state: 'done' }) - // The finished child stays listed, with how it ended, until the session's next turn; the dev - // server's record went when its process exited. - both([ + // The sidebar lists running children only; the finished child stays in the strip, with how it + // ended, until the session's next turn. The dev server's record went when its process exited. + expect(summaries.at(-1)).not.toHaveProperty('children') + expect(stripRows()).toEqual([ { kind: 'agent', description: 'review', @@ -310,12 +312,13 @@ describe('the chat strip and the session list read the same host child records', expect(strip.at(-1)).toBeNull() // The reviewer's next run is still going when the provider exits: it settles with an outcome - // nobody reported, and both keep listing it. + // nobody reported, and leaves the sidebar while the strip keeps listing it. 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() - both([ + expect(summaries.at(-1)).not.toHaveProperty('children') + expect(stripRows()).toEqual([ { kind: 'agent', description: 'review', @@ -325,7 +328,8 @@ describe('the chat strip and the session list read the same host child records', } ]) - // Closing its tab lets go of the session: its row, and every child record with it, leave both. + // 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) 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 c8e3b4bd1900..9d64e5363a06 100644 --- a/src/renderer/src/components/agent-child-row-parity.test.tsx +++ b/src/renderer/src/components/agent-child-row-parity.test.tsx @@ -8,6 +8,7 @@ import { import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-wire' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { structuredSidebarChildWork } from '../../../shared/structured-agent-session-child-work-selection' import type { TerminalTab } from '../../../shared/terminal-tab-types' import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow' import { NativeChatBackgroundTasksStatus } from '@/components/native-chat/NativeChatBackgroundTasksStatus' @@ -283,13 +284,20 @@ const FULL_ROW_DETAIL: Record = { 'parked, still live': { shows: [] } } +// The sidebar lists what the host's summary carries: running children only. A finished child is +// read on the strip, and leaves the sidebar. 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)) + const listed = structuredSidebarChildWork(children) const [strip] = stripRows(children) - expect(sidebar).toEqual(expected) expect(strip).toEqual(expected) - const full = fullRow(parentWith(children)) + if (listed.length === 0) { + expect(sidebarRows(parentWith(listed))).toEqual([]) + return + } + const [sidebar] = sidebarRows(parentWith(listed)) + expect(sidebar).toEqual(expected) + const full = fullRow(parentWith(listed)) expect(full.labels).toContain(expected.dot) expect(full.text).toContain(expected.lead === expected.dot ? expected.trail : expected.lead) for (const text of FULL_ROW_DETAIL[name].shows) { @@ -300,15 +308,22 @@ 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('names an unlabeled finished child by its state on the strip, and leaves it off the sidebar', () => { const children = [ settled('failed', { description: undefined, agentType: undefined, lastMessage: 'Exit 2' }) ] - 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(sidebarRows(parentWith(structuredSidebarChildWork(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(structuredSidebarChildWork(children))) + const [strip] = stripRows(children) + 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', () => { 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 62a7d54c6d57..ef3751976ae2 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/shared/structured-agent-session-child-work-selection.ts b/src/shared/structured-agent-session-child-work-selection.ts new file mode 100644 index 000000000000..6f1a9c8650ef --- /dev/null +++ b/src/shared/structured-agent-session-child-work-selection.ts @@ -0,0 +1,65 @@ +// Which of a session's child records each surface lists. The store keeps every record; these pick +// from them on each read, so nothing here is stored and nothing can disagree with the store. +// +// The sidebar (the status summary) lists running children only; a finished child stays in the +// chat's strip, which lists every running child and then the newest finished ones. + +import type { AgentChildWorkView } from './agent-status-child-work-view' + +/** The strip's row budget. Running children always show, even past it. */ +export const STRUCTURED_STRIP_CHILD_WORK_LIMIT = 100 + +/** Settled children that still own live work: they read as monitoring, not finished, and keep + * that work's owner on screen. */ +function settledOwnersOfLiveWork(views: readonly AgentChildWorkView[]): Set { + const byId = new Map(views.map((view) => [view.id, view])) + const owners = new Set() + for (const view of views) { + if (view.membership !== 'live') { + continue + } + const seen = new Set([view.id]) + let owner = view.parentChildWorkId ? byId.get(view.parentChildWorkId) : undefined + while (owner && !seen.has(owner.id)) { + seen.add(owner.id) + if (owner.membership === 'settled') { + owners.add(owner.id) + } + owner = owner.parentChildWorkId ? byId.get(owner.parentChildWorkId) : undefined + } + } + return owners +} + +function isRunning(view: AgentChildWorkView, owners: ReadonlySet): boolean { + return view.membership === 'live' || owners.has(view.id) +} + +/** The sidebar's children: the running ones, in store order. */ +export function structuredSidebarChildWork( + views: readonly AgentChildWorkView[] +): AgentChildWorkView[] { + const owners = settledOwnersOfLiveWork(views) + return views.filter((view) => isRunning(view, owners)) +} + +/** The strip's children, in store order: every running child, then the newest finished ones, up to + * the row budget in all. */ +export function structuredStripChildWork( + views: readonly AgentChildWorkView[] +): AgentChildWorkView[] { + if (views.length <= STRUCTURED_STRIP_CHILD_WORK_LIMIT) { + return [...views] + } + const owners = settledOwnersOfLiveWork(views) + const running = views.filter((view) => isRunning(view, owners)) + const room = Math.max(0, STRUCTURED_STRIP_CHILD_WORK_LIMIT - running.length) + const newestFinished = new Set( + views + .filter((view) => !isRunning(view, owners)) + .sort((a, b) => (b.settledAt ?? b.observedAt) - (a.settledAt ?? a.observedAt)) + .slice(0, room) + .map((view) => view.id) + ) + return views.filter((view) => isRunning(view, owners) || newestFinished.has(view.id)) +} From 896ad6b38b6e2ee1d10d7b503e2b7d0fe8aa7530 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:49:30 -0700 Subject: [PATCH 19/34] fix(sidebar): one rule for what the worktree sidebar lists: running children, from every source `worktreeSidebarListsChild` is the one definition: a child that runs, counting a finished one whose own shell still runs (it reads monitoring). The sidebar's row builder applies it to every child source it reads, a terminal agent's hook roster and a chat session's records alike, and the host's status summary applies the same predicate, so the sidebar's payload stays small. The chat's strip keeps finished children, newest first. A terminal agent's hook roster already drops a child on its own stop, so nothing changes there: a teammate between turns and a child gone quiet still run, and still show. The selection module moves to `agent-child-work-listing.ts`, since it now covers every source, not only chat sessions. --- ...d-agent-session-background-task-channel.ts | 2 +- ...agent-session-child-work-selection.test.ts | 2 +- ...uctured-agent-session-status-child-work.ts | 2 +- .../structured-agent-session-status-feed.ts | 4 +- .../agent-child-row-parity.test.tsx | 55 ++++---- .../worktree-sidebar-running-children.test.ts | 121 ++++++++++++++++++ .../sidebar/worktree-subagent-child-rows.ts | 15 ++- ...lection.ts => agent-child-work-listing.ts} | 38 ++++-- 8 files changed, 189 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-sidebar-running-children.test.ts rename src/shared/{structured-agent-session-child-work-selection.ts => agent-child-work-listing.ts} (53%) 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 5ad0697456c8..d1c2e8dd917b 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 @@ -5,7 +5,7 @@ import type { } 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 { structuredStripChildWork } from '../../../shared/structured-agent-session-child-work-selection' +import { structuredStripChildWork } from '../../../shared/agent-child-work-listing' import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' import type { AgentSessionSubscribers, 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 index ceb41dc4ad4e..072fbf89a6ca 100644 --- 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 @@ -7,7 +7,7 @@ import { STRUCTURED_STRIP_CHILD_WORK_LIMIT as LIMIT, structuredSidebarChildWork, structuredStripChildWork -} from '../../../shared/structured-agent-session-child-work-selection' +} from '../../../shared/agent-child-work-listing' import { attach, hostTestState, 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 index 0f4318e6b3c0..bd944d6a8c11 100644 --- 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 @@ -14,7 +14,7 @@ import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-w 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 { structuredSidebarChildWork } from '../../../shared/structured-agent-session-child-work-selection' +import { structuredSidebarChildWork } 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, 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 e69164c4406d..40effa604d45 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 @@ -223,8 +223,8 @@ export class StructuredAgentSessionStatusFeed { * still live settle `unknown`, and settled ones stay. The one earlier death is the host letting * go of the session, whose row takes every child record with it (see `forget`). Nothing caps how * many records a turn keeps, and a command never settles: its record goes when it stops. What - * each surface lists of them is picked on every read (`structured-agent-session-child-work- - * selection`): the sidebar only running children, the strip running then newest finished. */ + * each surface lists of them is picked on every read (`agent-child-work-listing`): the sidebar + * only running children, the strip running then newest finished. */ private retireSettledChildrenOnNewTurn( sessionId: string, session: StatusFeedSession, 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 9d64e5363a06..e4cab6c23344 100644 --- a/src/renderer/src/components/agent-child-row-parity.test.tsx +++ b/src/renderer/src/components/agent-child-row-parity.test.tsx @@ -8,7 +8,6 @@ import { import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-wire' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentStatusEntry } from '../../../shared/agent-status-types' -import { structuredSidebarChildWork } from '../../../shared/structured-agent-session-child-work-selection' import type { TerminalTab } from '../../../shared/terminal-tab-types' import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow' import { NativeChatBackgroundTasksStatus } from '@/components/native-chat/NativeChatBackgroundTasksStatus' @@ -284,20 +283,21 @@ const FULL_ROW_DETAIL: Record = { 'parked, still live': { shows: [] } } -// The sidebar lists what the host's summary carries: running children only. A finished child is -// read on the strip, and leaves the sidebar. +// The worktree sidebar lists running children only; a finished child is read on the strip. 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 listed = structuredSidebarChildWork(children) const [strip] = stripRows(children) expect(strip).toEqual(expected) - if (listed.length === 0) { - expect(sidebarRows(parentWith(listed))).toEqual([]) + if (FINISHED.has(name)) { + expect(sidebarRows(parentWith(children))).toEqual([]) return } - const [sidebar] = sidebarRows(parentWith(listed)) + const [sidebar] = sidebarRows(parentWith(children)) expect(sidebar).toEqual(expected) - const full = fullRow(parentWith(listed)) + const full = fullRow(parentWith(children)) expect(full.labels).toContain(expected.dot) expect(full.text).toContain(expected.lead === expected.dot ? expected.trail : expected.lead) for (const text of FULL_ROW_DETAIL[name].shows) { @@ -314,12 +314,12 @@ describe('a child reads the same in the sidebar and the chat strip', () => { ] const [strip] = stripRows(children) expect(strip.lead).toBe('Failed') - expect(sidebarRows(parentWith(structuredSidebarChildWork(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(structuredSidebarChildWork(children))) + const [sidebar] = sidebarRows(parentWith(children)) const [strip] = stripRows(children) expect(sidebar.lead).toBe('Working') expect(strip.lead).toBe('Working') @@ -539,20 +539,14 @@ 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) + it("freezes a finished child's run in the strip, and leaves it off the sidebar", () => { + expect( + buildSubagentChildRows({ + parentEntry: parentWith([settled('succeeded')]), + tab, + parentIsFresh: true + }) + ).toEqual([]) const strip = mount( renderToStaticMarkup( { }) }) +// A finished child is on the strip only (`null`: the sidebar lists 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]>([ + ['failed', [settled('failed')], null, 'blocked'], + ['cancelled', [settled('cancelled')], null, 'idle'], + ['ended, outcome unknown', [settled('unknown')], null, 'idle'], + ['finished', [settled('succeeded')], null, 'done'], ['monitoring its own shell', [settled('succeeded'), OWNED_SHELL], 'working', 'monitoring'], ['waiting', [view('child', { state: 'waiting' })], 'waiting', 'waiting'] ])('%s', (_name, children, sidebarState, headerState) => { @@ -640,7 +635,7 @@ describe('one lifecycle word for a child, on the sidebar row and the strip heade }) const [group] = buildBackgroundTaskGroupsFromViews(children) // A CLI row carries monitoring as `working` plus its working mode; every other word is shared. - expect(sidebar.state).toBe(sidebarState) + expect(sidebar?.state ?? null).toBe(sidebarState) expect(group.tasks[0].state).toBe(headerState) }) }) 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 000000000000..ae87bef15a3a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-running-children.test.ts @@ -0,0 +1,121 @@ +// The worktree sidebar lists running children only, whichever source it reads them from; the chat's +// strip keeps the finished ones. + +import { describe, expect, it } from 'vitest' +import { worktreeSidebarListsChild } 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 { 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, which the strip keeps", () => { + const children = [view('a'), finished('b')] + expect(sidebarNames(parent({ children }))).toEqual(['child a']) + const [agents] = buildBackgroundTaskGroupsFromViews(children) + expect(agents.tasks.map((entry) => entry.row.name)).toEqual(['child a', 'child b']) + }) + + 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, which the strip keeps", () => { + 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) => worktreeSidebarListsChild({ settled: row.settled, ownsLiveWork: false })) + .map((row) => row.name) + ).toEqual(['Live task']) + const [agents] = buildBackgroundTaskGroups(tasks, settledTasks) + expect(agents.tasks.map((entry) => entry.row.name).sort()).toEqual(['Done task', '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 729038d46ef2..d18d0b817e14 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 { worktreeSidebarListsChild } 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. A finished one stays in the chat's strip. return flattenAgentChildRowModels(rows) - .filter((row) => row.kind === 'agent') + .filter( + (row) => + row.kind === 'agent' && + worktreeSidebarListsChild({ settled: row.settled, ownsLiveWork: ownsLiveWork(row) }) + ) .map((row) => childDashboardRow(row, parentEntry, args.tab)) } diff --git a/src/shared/structured-agent-session-child-work-selection.ts b/src/shared/agent-child-work-listing.ts similarity index 53% rename from src/shared/structured-agent-session-child-work-selection.ts rename to src/shared/agent-child-work-listing.ts index 6f1a9c8650ef..f5b3834727c1 100644 --- a/src/shared/structured-agent-session-child-work-selection.ts +++ b/src/shared/agent-child-work-listing.ts @@ -1,16 +1,25 @@ -// Which of a session's child records each surface lists. The store keeps every record; these pick -// from them on each read, so nothing here is stored and nothing can disagree with the store. +// Which child work each surface lists. The host keeps every record; these pick from them on each +// read, so nothing here is stored and nothing can disagree with the store. // -// The sidebar (the status summary) lists running children only; a finished child stays in the -// chat's strip, which lists every running child and then the newest finished ones. +// The worktree sidebar lists running children only, from every source it reads (a CLI pane's hook +// roster and a chat session's records alike); a finished child stays in the chat's strip, which +// lists every running child and then the newest finished ones. import type { AgentChildWorkView } from './agent-status-child-work-view' /** The strip's row budget. Running children always show, even past it. */ export const STRUCTURED_STRIP_CHILD_WORK_LIMIT = 100 -/** Settled children that still own live work: they read as monitoring, not finished, and keep - * that work's owner on screen. */ +/** The one rule for what the worktree sidebar lists: 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 worktreeSidebarListsChild(child: { + settled: boolean + ownsLiveWork: boolean +}): boolean { + return !child.settled || child.ownsLiveWork +} + +/** Settled views that still own live work, through any depth of ownership. */ function settledOwnersOfLiveWork(views: readonly AgentChildWorkView[]): Set { const byId = new Map(views.map((view) => [view.id, view])) const owners = new Set() @@ -31,16 +40,19 @@ function settledOwnersOfLiveWork(views: readonly AgentChildWorkView[]): Set): boolean { - return view.membership === 'live' || owners.has(view.id) +function viewRuns(view: AgentChildWorkView, owners: ReadonlySet): boolean { + return worktreeSidebarListsChild({ + settled: view.membership === 'settled', + ownsLiveWork: owners.has(view.id) + }) } -/** The sidebar's children: the running ones, in store order. */ +/** The sidebar's children from a session's records: the running ones, in store order. */ export function structuredSidebarChildWork( views: readonly AgentChildWorkView[] ): AgentChildWorkView[] { const owners = settledOwnersOfLiveWork(views) - return views.filter((view) => isRunning(view, owners)) + return views.filter((view) => viewRuns(view, owners)) } /** The strip's children, in store order: every running child, then the newest finished ones, up to @@ -52,14 +64,14 @@ export function structuredStripChildWork( return [...views] } const owners = settledOwnersOfLiveWork(views) - const running = views.filter((view) => isRunning(view, owners)) + const running = views.filter((view) => viewRuns(view, owners)) const room = Math.max(0, STRUCTURED_STRIP_CHILD_WORK_LIMIT - running.length) const newestFinished = new Set( views - .filter((view) => !isRunning(view, owners)) + .filter((view) => !viewRuns(view, owners)) .sort((a, b) => (b.settledAt ?? b.observedAt) - (a.settledAt ?? a.observedAt)) .slice(0, room) .map((view) => view.id) ) - return views.filter((view) => isRunning(view, owners) || newestFinished.has(view.id)) + return views.filter((view) => viewRuns(view, owners) || newestFinished.has(view.id)) } From c01de9752d6aafd291e28a301533ce9f26e43e96 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:17:33 -0700 Subject: [PATCH 20/34] test(native-chat): the switch test passes the startup child key main's status bar takes --- .../native-chat/structured-child-records-switch.test.tsx | 1 + 1 file changed, 1 insertion(+) 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 index 2e7be9dd7ccd..6a4dc2cf499a 100644 --- 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 @@ -197,6 +197,7 @@ function Surfaces(props: { roster: AgentSessionBackgroundTaskState }): React.JSX sessionId={tab.entityId} agentLabel="Claude" startupPhase="ready" + startupChildKey={null} paneKey={PANE_KEY} error={null} composerError={null} From 02b3521a9a62577a0000e62f13c123200b297beb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:05:38 -0700 Subject: [PATCH 21/34] fix(native-chat): a finished child stays until the user's next send, not a turn Claude opens on its own Claude wakes the agent on its own when a background task ends, and that wake is a new root turn. Keying retention on the newest root turn retired every finished child about two seconds after a background agent or shell finished, so its outcome never showed in the strip. Retention now keys on the user's newest send the provider accepted (a message, a steer or a command), with the journal epoch so a rewind still retires. A turn the provider opens itself and a subagent's turn carry no send. Replayed captured wake orders through the real adapter, hook server and status feed. --- .../claude-background-wake-retention.test.ts | 142 ++++++++++++++++++ ...hild-work-producer-harness.test-fixture.ts | 29 +++- ...uctured-agent-session-status-child-work.ts | 24 ++- ...agent-session-status-feed-children.test.ts | 41 ++++- .../structured-agent-session-status-feed.ts | 23 +-- ...agent-session-status-journal-projection.ts | 9 +- .../structured-child-records-switch.test.ts | 40 ++++- ...tructured-session-background-tasks-view.ts | 6 +- .../agent-status-child-work-evidence.ts | 7 +- src/shared/protocol-version.ts | 2 +- 10 files changed, 275 insertions(+), 48 deletions(-) create mode 100644 src/main/claude/claude-background-wake-retention.test.ts 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 000000000000..907887479126 --- /dev/null +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -0,0 +1,142 @@ +// A finished child stays in the chat's strip 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: 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 { structuredStripChildWork } 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 strip = () => + structuredStripChildWork(host.getStructuredChildWorkViews(parent)).map( + (view) => `${view.description}:${view.membership}:${view.outcome ?? ''}` + ) + const replay = (frames: readonly CapturedFrame[]) => { + for (const captured of frames) { + run.replay([captured]) + feed.publish(parent.sessionId) + } + } + /** The user sends; 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: 1, + 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, strip, replay, send, accept, rootTurns } +} + +describe("a finished child 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.strip()).toContain('Run 45s sleep command:settled:succeeded') + + const next = session.send('next-send') + // Written, not yet taken: nothing retires on a send the provider may still refuse. + expect(session.strip()).toContain('Run 45s sleep command:settled:succeeded') + session.accept(next) + expect(session.strip()).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.strip()).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.strip()).toEqual(['Sleep then print marker:settled:succeeded']) + + // The user steers while the wake runs; Claude folds it into that turn and takes it. + session.accept(session.send('steer-uuid')) + expect(session.strip()).toEqual([]) + session.replay(wake.slice(wakeInit + 1)) + expect(session.strip()).toEqual([]) + }) +}) 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 e0642040d978..50ecb35ba75f 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' @@ -72,6 +74,8 @@ export function toolResult( 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 @@ -136,9 +140,18 @@ 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) } @@ -169,5 +182,17 @@ 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, + store, + send, + replay, + records, + byDescription, + evidenceLog, + stamps, + ingested, + journalItems + } } 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 index bd944d6a8c11..fd8ec4199eed 100644 --- 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 @@ -7,9 +7,7 @@ // open chat subscribes to, carries every tick. import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' -import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +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' @@ -54,14 +52,14 @@ export function structuredStatusChildrenEqual( return agentChildWorkViewsEqual(a, b, SUMMARY_CHILD_CLOCK_TOLERANCE_MS) } -/** The session's own newest turn, whatever state it is in; a subagent's rows never count. */ -export function newestRootTurnId(items: readonly AgentJournalRenderItem[]): string | null { - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] - const turn = isRootAgentJournalItem(item) ? readAgentJournalTurn(item?.body) : null - if (turn) { - return turn.turnId - } - } - return null +/** 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 index 08dd33ec116d..545c94231fd7 100644 --- 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 @@ -267,7 +267,7 @@ describe('structured status summary child records', () => { expect(lastSummary(events)?.children).toEqual([childView()]) }) - it("retires finished children when the session's own next turn begins", async () => { + 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( @@ -275,22 +275,51 @@ describe('structured status summary child records', () => { { 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') - // The session was first projected before it had a turn, so its first turn is a new one. - await turn('turn-1', 'running') 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) - // A subagent's own turn is not the session's. + // 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 turn('turn-2', 'running') - feed.publish(SESSION) + 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 () => { 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 40effa604d45..e2089ab09a68 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 @@ -97,8 +97,9 @@ export class StructuredAgentSessionStatusFeed { ) private readonly subscribers = new Map() private readonly published = new Map() - /** The newest root turn each session was last projected with; a new one retires settled children. */ - private readonly rootTurns = new Map() + /** 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) {} @@ -126,7 +127,7 @@ export class StructuredAgentSessionStatusFeed { /** 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.rootTurns.delete(sessionId) + this.acceptedSends.delete(sessionId) try { this.ownership.forget(sessionId) } catch (error) { @@ -195,7 +196,7 @@ export class StructuredAgentSessionStatusFeed { const source = journal ?? session.journal const record = this.deps.getRecord(sessionId) const projection = this.projections.read(source, record) - this.retireSettledChildrenOnNewTurn(sessionId, session, projection.rootTurnId) + this.retireSettledChildrenOnNewTurn(sessionId, session, projection.acceptedSendKey) const summary = this.summaryFor(sessionId, session, source, record, projection.state) const previous = this.published.get(sessionId) if (previous && structuredStatusSummariesEqual(previous, summary)) { @@ -218,8 +219,8 @@ export class StructuredAgentSessionStatusFeed { } } - /** A finished child's record stays, with its outcome, until the session's own next turn begins, - * unless it still owns live work. The provider ending the session removes nothing: children + /** 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. The provider ending the session removes nothing: children * still live settle `unknown`, and settled ones stay. The one earlier death is the host letting * go of the session, whose row takes every child record with it (see `forget`). Nothing caps how * many records a turn keeps, and a command never settles: its record goes when it stops. What @@ -228,12 +229,12 @@ export class StructuredAgentSessionStatusFeed { private retireSettledChildrenOnNewTurn( sessionId: string, session: StatusFeedSession, - rootTurnId: string | null + acceptedSendKey: string | null ): void { - const seen = this.rootTurns.has(sessionId) - const previous = this.rootTurns.get(sessionId) - this.rootTurns.set(sessionId, rootTurnId) - if (!seen || rootTurnId === null || rootTurnId === previous) { + const seen = this.acceptedSends.has(sessionId) + const previous = this.acceptedSends.get(sessionId) + this.acceptedSends.set(sessionId, acceptedSendKey) + if (!seen || acceptedSendKey === null || acceptedSendKey === previous) { return } this.admitChildWork(sessionId, session, [{ type: 'turn-started', observedAt: this.deps.now() }]) 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 index fbf8cd5ea418..cbb12e8833fe 100644 --- 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 @@ -1,10 +1,10 @@ // The status feed's per-journal projection, cached per commit: what a session's journal says its -// row is, and the newest root turn it holds. +// 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 { newestRootTurnId } from './structured-agent-session-status-child-work' +import { newestAcceptedSendKey } from './structured-agent-session-status-child-work' export type StructuredAgentSessionStatusState = ReturnType< typeof projectStructuredAgentSessionStatusState @@ -16,7 +16,8 @@ export type StructuredAgentSessionJournalProjection = { readOnly: boolean fence: number | undefined state: StructuredAgentSessionStatusState - rootTurnId: string | null + /** Null for an unreadable journal, which says nothing about the user's turns. */ + acceptedSendKey: string | null } export class StructuredAgentSessionJournalProjections { @@ -55,7 +56,7 @@ export class StructuredAgentSessionJournalProjections { snapshot?.submissions ?? [], fence ), - rootTurnId: newestRootTurnId(snapshot?.items ?? []) + acceptedSendKey: snapshot ? newestAcceptedSendKey(cursor.epoch, snapshot.submissions) : null } this.byJournal.set(journal, projection) } diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index ca209d6f966f..1dd55cff1f4e 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -23,9 +23,11 @@ import type { 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 + 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 { @@ -129,9 +131,9 @@ describe('the chat strip and the session list read the same host child records', }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` - expect(await host.attach({ callerKey: 'switch-test' }, attachParams)).toMatchObject({ - ok: true - }) + 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', @@ -307,9 +309,37 @@ describe('the chat strip and the session list read the same host child records', } ]) + // 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(stripRows()).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(strip.at(-1)).toBeNull()) expect(summaries.at(-1)).not.toHaveProperty('children') - expect(strip.at(-1)).toBeNull() // The reviewer's next run is still going when the provider exits: it settles with an outcome // nobody reported, and leaves the sidebar while the strip keeps listing it. 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 a42227f56cf9..3652d473b82a 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 @@ -6,7 +6,7 @@ // 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. // A host that publishes its child records also keeps a finished child listed, -// with how it ended, until the session's next turn. Only an idle session with +// with how it ended, until the user's next turn. Only an idle session with // live work lets the strip animate or speak for itself. import type { @@ -15,7 +15,6 @@ import type { } from '../../../../shared/agent-session-wire' import { agentChildWorkLiveness } from '../../../../shared/agent-status-child-work-liveness' import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view' -import { decodeAgentChildWorkViews } from '../../../../shared/agent-status-child-work-view-wire' export type StructuredSessionBackgroundTasksView = { /** The strip renders whenever the host reports rows — mid-turn included. */ @@ -36,7 +35,8 @@ export function structuredSessionBackgroundTasksView( turnId: string | null ): StructuredSessionBackgroundTasksView { const monitoring = backgroundTasks?.state === 'monitoring' - const children = monitoring ? decodeAgentChildWorkViews(backgroundTasks.children) : undefined + // Decoded once where the frame entered the client's state, so its identity holds between frames. + const children = monitoring ? backgroundTasks.children : undefined // A roster of finished children is shown, but nothing in it is running. const liveWork = children ? agentChildWorkLiveness(children) !== null : monitoring return { diff --git a/src/shared/agent-status-child-work-evidence.ts b/src/shared/agent-status-child-work-evidence.ts index f443c7751cea..1431f5187a0e 100644 --- a/src/shared/agent-status-child-work-evidence.ts +++ b/src/shared/agent-status-child-work-evidence.ts @@ -71,12 +71,13 @@ export type AgentChildWorkEndedEvidence = { totalTokens?: number } -/** The session's own next turn began. Settled children are kept, with their outcome, until then; - * a settled child that still owns live work stays so that work keeps an owner. */ +/** 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 until the session's next turn or the + * 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 } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 2f59194ccbb6..74705a79c0f3 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -245,7 +245,7 @@ export const AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY = export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY = 'agent-session.background-task-row-stop.v1' as const // Why: the strip's roster now carries the host's child records, and keeps a finished child listed -// until the parent's next turn. A reader that predates it reads any roster as live work (it +// until the user's next turn. A reader that predates it reads any roster as live work (it // animates the monitoring indicator and blocks conversation commands), so it never receives one // whose rows are all settled. export const AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY = From 4734efd7fa9ebd90b85b9d74ec66d6e33a7f1495 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:05:48 -0700 Subject: [PATCH 22/34] fix(native-chat): a background Stop reaches the tasks the child records show The strip draws a row's Stop, and /clear, /compact and rewind wait for background work, from the host's child records, but the Claude adapter still resolved which tasks a Stop reached from its own tracker's roster, and refused to stop at all once that roster was empty. A task the records kept live after the roster dropped it showed a Stop that sent nothing and blocked those commands until the chat tab closed. The host now resolves the provider ids a Stop sends from the records (the same per-row rule the strip and admission use; every such row for stop-all), and the adapter stops exactly those, with no tracker guard. An acknowledged stop ends the record: a running task sends its own stopped frame first, and the CLI answers success with no frame for a task it no longer knows. A refused stop leaves the record live. No production code reads the tracker's roster any more. --- .../claude-background-stop-records.test.ts | 109 ++++++++++++++++ src/main/claude/claude-child-work-decoder.ts | 9 ++ .../claude-structured-control-actions.test.ts | 116 +++++++++--------- .../claude-structured-control-actions.ts | 10 +- .../claude-structured-session-adapter.ts | 41 ++++--- .../structured-agent-session-adapter.ts | 3 +- ...structured-agent-session-host-mutations.ts | 2 +- ...structured-agent-session-mutation-plans.ts | 6 +- .../structured-agent-session-turns-cancel.ts | 6 +- .../structured-agent-session-turns.test.ts | 35 +++++- ...ructured-conversation-command-admission.ts | 2 +- ...ude-structured-session-integration.test.ts | 51 ++++++-- src/shared/agent-child-row-model.ts | 7 +- src/shared/agent-child-work-stop.ts | 26 ++++ 14 files changed, 316 insertions(+), 107 deletions(-) create mode 100644 src/main/claude/claude-background-stop-records.test.ts create mode 100644 src/shared/agent-child-work-stop.ts 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 000000000000..d3c2d05aa820 --- /dev/null +++ b/src/main/claude/claude-background-stop-records.test.ts @@ -0,0 +1,109 @@ +// 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' +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' }]) + }) +}) diff --git a/src/main/claude/claude-child-work-decoder.ts b/src/main/claude/claude-child-work-decoder.ts index 6de305194b0a..35567b563fac 100644 --- a/src/main/claude/claude-child-work-decoder.ts +++ b/src/main/claude/claude-child-work-decoder.ts @@ -113,6 +113,15 @@ export class ClaudeChildWorkDecoder { } } + /** The CLI acknowledged a stop of this task. A running task sends its own `stopped` frame first; + * one the CLI no longer knows is acknowledged with no frame at all, so the acknowledgement is the + * ending of a task still live here. */ + stopAcknowledged(id: string): void { + if (this.live.has(id)) { + this.end(id, 'stopped', {}) + } + } + /** The provider session is gone: the host settles what it still holds live. */ clear(): void { this.live.clear() diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 4ba08d3b642c..560b720a9de5 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -8,7 +8,7 @@ import { dispatchClaudeTurn } from './claude-structured-dispatch' import { ClaudeControlRequestError } from './claude-stream-json-connection' 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> @@ -288,82 +288,84 @@ 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' } - ] - }) + /** 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: 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 = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession + const session = stoppingSession(childWork, stopTask) - await expect(stopClaudeBackgroundTasks(session, 5_000)).resolves.toEqual({ cancelled: true }) + 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' + }, + { + type: 'ended', + observedAt: 2, + handle: { idKind: 'task_id', id: 'task-bash' }, + outcome: 'cancelled' + } + ]) }) it('stops issuing requests when ownership changes between tasks', async () => { - const backgroundTasks = new ClaudeBackgroundTaskTracker() - for (const taskId of ['task-1', 'task-2']) { - backgroundTasks.observe({ - type: 'system', - subtype: 'task_started', - task_id: taskId, - task_type: 'local_agent', - is_backgrounded: true - }) - } + 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' } - ] - }) - const stopTask = vi.fn(async (_taskId: string) => {}) - const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession - - 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 + 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, 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) }) }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 8ec1aa2e9c41..ca7bf5c88473 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -58,15 +58,14 @@ 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 for (const taskId of taskIds) { if (!isCurrent()) { @@ -74,6 +73,7 @@ export async function stopClaudeBackgroundTasks( } try { await session.connection.stopTask(taskId, { timeoutMs }) + session.childWork.stopAcknowledged(taskId) cancelled = true } catch (error) { if (!(error instanceof ClaudeControlRequestError)) { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 09d97ce191ae..9df3b83bd420 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -140,8 +140,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda }) private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void { - // The tracker's roster serves this provider's own stops; the host's child records, fed by the - // decoder's evidence drained below, are what every surface reads. + // 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() @@ -195,24 +195,31 @@ 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) + } + } } - /** 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. */ + /** 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 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 4ca9e7747392..bd7a7789edb0 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 @@ -332,10 +332,11 @@ 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 }> /** The stops this provider honours for a live session's background work; undefined when the * adapter holds no live session for it. */ 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 ae84046b73e6..5c531b30899e 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 @@ -169,7 +169,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-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 388fabd7bd45..dd531d855aef 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, @@ -171,6 +172,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', @@ -189,7 +192,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-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index eb3784b0a1d7..76b4a74fc9bf 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' 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 2a9ed84012b7..7c5ecf1ee15d 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-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index 65a213c01689..21419ffb32be 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,4 +1,4 @@ -import { agentChildWorkViewOffersStop } from '../../../shared/agent-child-row-model' +import { agentChildWorkViewOffersStop } from '../../../shared/agent-child-work-stop' 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' diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 82db5d6c5425..b8ca5c97769a 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/shared/agent-child-row-model.ts b/src/shared/agent-child-row-model.ts index 6d7bb95eb304..bbdc471f80dc 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' import type { AgentChildWorkView } from './agent-status-child-work-view' import { agentStatusAuthorityObservedAt, @@ -203,12 +204,6 @@ function viewName(view: AgentChildWorkView): string { ) } -/** 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 -} - function rowFromView( view: AgentChildWorkView, views: readonly AgentChildWorkView[], diff --git a/src/shared/agent-child-work-stop.ts b/src/shared/agent-child-work-stop.ts new file mode 100644 index 000000000000..fca60e45958f --- /dev/null +++ b/src/shared/agent-child-work-stop.ts @@ -0,0 +1,26 @@ +// 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' + +/** 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] + : [] + ) +} From 9f053384580cba46a9441a90f8bacce35de0399c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:05:58 -0700 Subject: [PATCH 23/34] fix(native-chat): one rule for a finished child that still owns live work, at any depth The listing kept a finished child whose work ran through any depth of ownership, but retention at the user's next turn protected only the direct owner, so a finished agent whose finished subagent still ran a shell was removed and that subagent jumped to the top level. Both now read settledOwnersOfLiveWork. --- src/shared/agent-child-work-listing.ts | 33 +++++------- .../agent-status-child-work-liveness.ts | 38 ++++++++++++- ...t-status-child-work-reconciliation.test.ts | 53 +++++++++++++++++++ .../agent-status-child-work-reconciliation.ts | 21 ++++---- 4 files changed, 114 insertions(+), 31 deletions(-) diff --git a/src/shared/agent-child-work-listing.ts b/src/shared/agent-child-work-listing.ts index f5b3834727c1..98878cc98b0a 100644 --- a/src/shared/agent-child-work-listing.ts +++ b/src/shared/agent-child-work-listing.ts @@ -5,6 +5,7 @@ // roster and a chat session's records alike); a finished child stays in the chat's strip, which // lists every running child and then the newest finished ones. +import { settledOwnersOfLiveWork } from './agent-status-child-work-liveness' import type { AgentChildWorkView } from './agent-status-child-work-view' /** The strip's row budget. Running children always show, even past it. */ @@ -19,25 +20,15 @@ export function worktreeSidebarListsChild(child: { return !child.settled || child.ownsLiveWork } -/** Settled views that still own live work, through any depth of ownership. */ -function settledOwnersOfLiveWork(views: readonly AgentChildWorkView[]): Set { - const byId = new Map(views.map((view) => [view.id, view])) - const owners = new Set() - for (const view of views) { - if (view.membership !== 'live') { - continue - } - const seen = new Set([view.id]) - let owner = view.parentChildWorkId ? byId.get(view.parentChildWorkId) : undefined - while (owner && !seen.has(owner.id)) { - seen.add(owner.id) - if (owner.membership === 'settled') { - owners.add(owner.id) - } - owner = owner.parentChildWorkId ? byId.get(owner.parentChildWorkId) : undefined - } - } - return owners +/** Settled views that still own live work, by the rule the host's retention also reads. */ +function settledViewOwnersOfLiveWork(views: readonly AgentChildWorkView[]): Set { + return settledOwnersOfLiveWork( + views.map((view) => ({ + id: view.id, + membership: view.membership, + ...(view.parentChildWorkId ? { ownerId: view.parentChildWorkId } : {}) + })) + ) } function viewRuns(view: AgentChildWorkView, owners: ReadonlySet): boolean { @@ -51,7 +42,7 @@ function viewRuns(view: AgentChildWorkView, owners: ReadonlySet): boolea export function structuredSidebarChildWork( views: readonly AgentChildWorkView[] ): AgentChildWorkView[] { - const owners = settledOwnersOfLiveWork(views) + const owners = settledViewOwnersOfLiveWork(views) return views.filter((view) => viewRuns(view, owners)) } @@ -63,7 +54,7 @@ export function structuredStripChildWork( if (views.length <= STRUCTURED_STRIP_CHILD_WORK_LIMIT) { return [...views] } - const owners = settledOwnersOfLiveWork(views) + const owners = settledViewOwnersOfLiveWork(views) const running = views.filter((view) => viewRuns(view, owners)) const room = Math.max(0, STRUCTURED_STRIP_CHILD_WORK_LIMIT - running.length) const newestFinished = new Set( diff --git a/src/shared/agent-status-child-work-liveness.ts b/src/shared/agent-status-child-work-liveness.ts index 693b4f295607..3a359e12978e 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 1be313826525..9d011204a772 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 { structuredSidebarChildWork, structuredStripChildWork } from './agent-child-work-listing' import { makeStructuredAgentStatusSubject } from './agent-status-subject' const parent = makeStructuredAgentStatusSubject( @@ -453,4 +455,55 @@ describe('structured child-work reconciliation', () => { '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 { + sidebar: structuredSidebarChildWork(views).map((view) => view.description), + strip: structuredStripChildWork(views).map((view) => view.description) + } + } + expect(surfaces().sidebar).toEqual(['Task top', 'Task nested', 'Task shell']) + + expect(apply({ type: 'turn-started', observedAt: 200 })).toMatchObject({ removed: 0 }) + expect(tree()).toEqual(before) + expect(surfaces()).toEqual({ + sidebar: ['Task top', 'Task nested', 'Task shell'], + strip: ['Task top', 'Task nested', 'Task shell'] + }) + }) }) diff --git a/src/shared/agent-status-child-work-reconciliation.ts b/src/shared/agent-status-child-work-reconciliation.ts index 88d5232b4cff..7cd08361e4e6 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 session's own next turn -// starts 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. +// 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, @@ -118,14 +119,16 @@ function applyRemoved(ctx: ReconcileContext, edge: AgentChildWorkRemovedEvidence removeChildren(ctx, [existing.childWorkId]) } -/** Settled children, less any that still owns live work: that one stays so its work keeps an - * owner. */ +/** 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 = new Set( - owned.flatMap((record) => - record.membership === 'live' && record.parentChildWorkId ? [record.parentChildWorkId] : [] - ) + 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)) From cb7d453dded80da182180f656558d9f7193eb4ec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:06:00 -0700 Subject: [PATCH 24/34] fix(native-chat): an older client sees a Codex child's shell as it did before views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clients that predate child views read a flat task roster derived from the views. It listed a Codex child agent's shell as an extra row beside the running agent, then as a bare command once the agent finished. The derivation now hides a running agent's commands and names a finished agent's as " — ", as the Codex tracker did; the label rule moves to a shared module both use. --- .../codex/codex-background-command-tracker.ts | 38 ++-------- src/shared/codex-child-command-description.ts | 38 ++++++++++ ...ed-agent-session-child-work-legacy.test.ts | 74 +++++++++++++++++++ ...uctured-agent-session-child-work-legacy.ts | 24 +++++- 4 files changed, 141 insertions(+), 33 deletions(-) create mode 100644 src/shared/codex-child-command-description.ts create mode 100644 src/shared/structured-agent-session-child-work-legacy.test.ts diff --git a/src/main/codex/codex-background-command-tracker.ts b/src/main/codex/codex-background-command-tracker.ts index 922743f1a831..e7d19e3bbccf 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/shared/codex-child-command-description.ts b/src/shared/codex-child-command-description.ts new file mode 100644 index 000000000000..212a420d7054 --- /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/structured-agent-session-child-work-legacy.test.ts b/src/shared/structured-agent-session-child-work-legacy.test.ts new file mode 100644 index 000000000000..cb65897cc18e --- /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 index 11441e458d4b..72c0d0db94ce 100644 --- a/src/shared/structured-agent-session-child-work-legacy.ts +++ b/src/shared/structured-agent-session-child-work-legacy.ts @@ -12,6 +12,7 @@ // 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, @@ -37,11 +38,32 @@ function withLegacyIds( ) } +/** 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(views, provider)) + return projectAgentChildWorkLegacyBackgroundTasks( + withLegacyIds(provider === 'codex' ? withCodexChildCommands(views) : views, provider) + ) } export function structuredChildWorkLegacySubagents( From 737c428376f141cb91f27dd9938ba23ee9117b06 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:06:08 -0700 Subject: [PATCH 25/34] fix(native-chat): the chat decodes a roster's child rows once, as the frame arrives The client reducer compared raw wire rows, so a row shaped by a newer host could throw there, and the strip decoded a new array on every render, which defeated its grouped-rows memo while a turn streamed. Rows are now decoded where the frame enters the reducer, an unchanged roster keeps its identity, and the strip's parent context is rebuilt only when one of its values changes. --- ...ured-session-background-tasks-view.test.ts | 6 +++ ...se-structured-session-child-row-context.ts | 22 +++++++++- ...session-background-task-state-admission.ts | 21 +++++++++ .../structured-agent-session-reducer.test.ts | 44 +++++++++++++++++++ .../structured-agent-session-reducer.ts | 14 ++++-- 5 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 src/shared/agent-session-background-task-state-admission.ts 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 index 72bfbe825fb8..8187409e9108 100644 --- 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 @@ -25,6 +25,12 @@ describe('structuredSessionBackgroundTasksView', () => { 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('reads live work beneath an idle session as monitoring, and a running turn as its own', () => { const roster = { state: 'monitoring' as const, 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 index c0703f5db1f7..b0a7f42001a1 100644 --- 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 @@ -30,8 +30,26 @@ export function useStructuredSessionChildRowContext( 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( - () => (parent ? structuredSessionChildRowContext(parent, now) : undefined), - [parent, now] + () => + 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/shared/agent-session-background-task-state-admission.ts b/src/shared/agent-session-background-task-state-admission.ts new file mode 100644 index 000000000000..839bdb595fea --- /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/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts index ca108710bcf8..391875f8a0f0 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 7c2f514c4bee..dde31b09418c 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. From 59e3bbd0b25637077157a30b74a75bda86d5f1f3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:06:10 -0700 Subject: [PATCH 26/34] fix(native-chat): the strip channel forgets a closed conversation's roster It kept the last roster fingerprint of every conversation for the host's lifetime. The conversation map now tells observers when one leaves it. Also corrects the summary's children comment: it carries running children only. --- ...nt-session-background-task-channel.test.ts | 70 +++++++++++++++++++ ...d-agent-session-background-task-channel.ts | 8 ++- .../structured-agent-session-conversations.ts | 14 +++- src/shared/agent-session-wire.ts | 8 ++- 4 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts 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 000000000000..a021f635a562 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-background-task-channel.test.ts @@ -0,0 +1,70 @@ +// 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 } +} + +function channelOver() { + 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: () => ({ supportsTaskStop: true, supportsStopAll: true }) } + } 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') + }, + () => [child] + ) + // 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) + }) +}) 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 55ac742ec15a..7b8e9d12bb65 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 @@ -13,6 +13,7 @@ import type { AgentSessionSubscribers, AgentSessionSubscribeInput } from './structured-agent-session-subscribers' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -27,14 +28,17 @@ export class StructuredAgentSessionBackgroundTaskChannel { 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, 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( 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 c312d6013fd5..70fe538ce202 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/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 62c4692cf509..c05501e5ff50 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -247,9 +247,11 @@ export type AgentSessionStatusSummary = { * subagent children without holding a journal reader open. Optional for * mixed-version hosts. Derived from `children` on hosts that publish it. */ backgroundTasks?: AgentSessionBackgroundTask[] - /** The host's child records for this session, live and recently settled, as views. 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. */ + /** 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 From 0788ed9a169ff97fb37ff7696f2c87fb84fc232c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:07:15 -0700 Subject: [PATCH 27/34] docs(native-chat): rewrap the retention comment --- .../structured-agent-session-status-feed.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 e2089ab09a68..e917b3a0f4e1 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 @@ -220,12 +220,13 @@ 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. The provider ending the session removes nothing: children - * still live settle `unknown`, and settled ones stay. The one earlier death is the host letting - * go of the session, whose row takes every child record with it (see `forget`). Nothing caps how - * many records a turn keeps, and a command never settles: its record goes when it stops. What - * each surface lists of them is picked on every read (`agent-child-work-listing`): the sidebar - * only running children, the strip running then newest finished. */ + * the provider accepts (see `newestAcceptedSendKey`), unless it still owns live work. The + * provider ending the session removes nothing: children still live settle `unknown`, and + * settled ones stay. The one earlier death is the host letting go of the session, whose row + * takes every child record with it (see `forget`). Nothing caps how many records a turn keeps, + * and a command never settles: its record goes when it stops. What each surface lists of them + * is picked on every read (`agent-child-work-listing`): the sidebar only running children, the + * strip running then newest finished. */ private retireSettledChildrenOnNewTurn( sessionId: string, session: StatusFeedSession, From 2c8575935f31c57bf39b8486dc926ece98d3a68b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:41:11 -0700 Subject: [PATCH 28/34] fix(native-chat): a task's own ending replaces a Stop's, and a child finished after the user wrote stays Two lifecycle gaps from the round-1 fixes. A Stop acknowledged ahead of the task's own ending relabelled it. The SDK hands Orca a control answer as soon as it reads it and queues other frames, so when a task finished just as the user pressed Stop, the acknowledgement arrived before the task's completion the CLI wrote first, and the task read "Stopped" with its result lost. An acknowledged Stop now ends a record provisionally (outcome basis `stop-acknowledged`); the task's own terminal frame replaces it, and nothing replaces an ending the task reported itself. A finished child still vanished with no new action from the user when the send the provider took was written before the child finished: a steer Claude takes at its next boundary, or a queued draft handed over at the end of the turn. The user's next turn now carries when they acted (the send's written time, or the draft's queued time, both on the host clock), and only children that finished at or before it retire; a later one stays until the user's next send. A rewind still retires every finished child. Also: a Stop-all keeps stopping the remaining tasks after one request fails, then reports the failure. --- .../claude-agent-sdk-scripted-cli.mjs | 10 +- .../claude-background-stop-records.test.ts | 52 +++++++ .../claude-background-wake-retention.test.ts | 30 +++- src/main/claude/claude-child-work-decoder.ts | 20 ++- ...hild-work-producer-harness.test-fixture.ts | 2 + src/main/claude/claude-stop-ack-race.test.ts | 144 ++++++++++++++++++ .../claude-structured-control-actions.test.ts | 25 ++- .../claude-structured-control-actions.ts | 7 +- ...ed-agent-session-status-child-work.test.ts | 56 +++++++ ...uctured-agent-session-status-child-work.ts | 40 ++++- ...agent-session-status-feed-children.test.ts | 11 +- .../structured-agent-session-status-feed.ts | 36 ++++- ...agent-session-status-journal-projection.ts | 28 +++- .../agent-status-child-work-admission-core.ts | 31 ++-- .../agent-status-child-work-admission.ts | 3 + src/shared/agent-status-child-work-codec.ts | 4 + src/shared/agent-status-child-work-ending.ts | 58 +++++++ ...nt-status-child-work-evidence-admission.ts | 8 +- .../agent-status-child-work-evidence.ts | 16 +- ...t-status-child-work-reconciliation.test.ts | 61 ++++++++ .../agent-status-child-work-reconciliation.ts | 24 ++- src/shared/agent-status-child-work.ts | 5 + 22 files changed, 603 insertions(+), 68 deletions(-) create mode 100644 src/main/claude/claude-stop-ack-race.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.test.ts create mode 100644 src/shared/agent-status-child-work-ending.ts 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 4f2a09425fd8..799b64546242 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 index d3c2d05aa820..092915dc1be6 100644 --- a/src/main/claude/claude-background-stop-records.test.ts +++ b/src/main/claude/claude-background-stop-records.test.ts @@ -106,4 +106,56 @@ describe('a background Stop acts on the host child records', () => { 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 index 907887479126..947eb89514fd 100644 --- a/src/main/claude/claude-background-wake-retention.test.ts +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -76,7 +76,7 @@ async function wiredSession() { feed.publish(parent.sessionId) } } - /** The user sends; the provider takes it once `accepted`. */ + /** 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, @@ -85,7 +85,7 @@ async function wiredSession() { dispatchState: 'pending', providerItemId: null, reason: null, - submittedAt: 1, + submittedAt: run.now(), resolvedAt: null } submissions.push(submission) @@ -139,4 +139,30 @@ describe("a finished child stays until the user's next turn", () => { session.replay(wake.slice(wakeInit + 1)) expect(session.strip()).toEqual([]) }) + + it('keeps a child that finished after the user wrote the message the provider takes later', 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.strip()).toEqual(['Sleep then print marker:live:']) + // Written while the shell runs: a steer Claude takes at its next boundary, or a queued draft. + const steer = session.send('steer-uuid') + const wakeInit = wake.findIndex((captured) => captured.frame.subtype === 'init') + session.replay(wake.slice(0, wakeInit + 1)) + expect(session.strip()).toEqual(['Sleep then print marker:settled:succeeded']) + + // The provider takes the message the user wrote before the shell finished: the user has + // not acted since it finished, so it stays. + session.accept(steer) + expect(session.strip()).toEqual(['Sleep then print marker:settled:succeeded']) + // Their next message retires it. + session.replay(wake.slice(wakeInit + 1)) + session.accept(session.send('next-uuid')) + expect(session.strip()).toEqual([]) + }) }) diff --git a/src/main/claude/claude-child-work-decoder.ts b/src/main/claude/claude-child-work-decoder.ts index 35567b563fac..c80c87710a6b 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,12 +114,13 @@ export class ClaudeChildWorkDecoder { } } - /** The CLI acknowledged a stop of this task. A running task sends its own `stopped` frame first; - * one the CLI no longer knows is acknowledged with no frame at all, so the acknowledgement is the - * ending of a task still live here. */ + /** 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', {}) + this.end(id, 'stopped', { basis: 'stop-acknowledged' }) } } @@ -238,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) @@ -260,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 50ecb35ba75f..0b460d91b4a7 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 @@ -185,6 +185,8 @@ export async function producer(host?: AgentHookServer) { return { adapter, claude, + /** The host clock the replay has reached. */ + now: () => clock, store, send, replay, 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 000000000000..5f2f60d3a692 --- /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-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 560b720a9de5..7cc154f27a55 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -329,13 +329,15 @@ describe('stopClaudeBackgroundTasks', () => { type: 'ended', observedAt: 2, handle: { idKind: 'task_id', id: 'task-agent' }, - outcome: 'cancelled' + outcome: 'cancelled', + basis: 'stop-acknowledged' }, { type: 'ended', observedAt: 2, handle: { idKind: 'task_id', id: 'task-bash' }, - outcome: 'cancelled' + outcome: 'cancelled', + basis: 'stop-acknowledged' } ]) }) @@ -368,4 +370,23 @@ describe('stopClaudeBackgroundTasks', () => { }) 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' } + ]) + }) }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index ca7bf5c88473..6aac249c232b 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -67,6 +67,8 @@ export async function stopClaudeBackgroundTasks( taskIds: readonly string[] ): Promise<{ cancelled: boolean }> { let cancelled = false + // A failed request for one task still leaves the others to stop; it is reported after them. + let failure: { error: unknown } | undefined for (const taskId of taskIds) { if (!isCurrent()) { break @@ -77,10 +79,13 @@ export async function stopClaudeBackgroundTasks( cancelled = true } catch (error) { if (!(error instanceof ClaudeControlRequestError)) { - throw error + failure ??= { error } } } } + if (failure) { + throw failure.error + } return { cancelled } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.test.ts new file mode 100644 index 000000000000..42c1925ea060 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.test.ts @@ -0,0 +1,56 @@ +// When the user acted for the send that begins their next turn: the time they wrote it, or queued +// the draft it was handed over from. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { newestAcceptedSend } from './structured-agent-session-status-child-work' + +function submission( + clientMessageId: string, + submittedAt: number, + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt, + resolvedAt: submittedAt, + ...overrides + } +} + +describe('newestAcceptedSend', () => { + it('dates a direct send by when it was written', () => { + const queuedAt = vi.fn(() => undefined) + expect( + newestAcceptedSend( + 'epoch-1', + [submission('first', 10), submission('pending', 30, { dispatchState: 'pending' })], + queuedAt + ) + ).toEqual({ epoch: 'epoch-1', clientMessageId: 'first', actedAt: 10 }) + expect(queuedAt).not.toHaveBeenCalled() + }) + + it('dates a send handed over from a queued draft by when the draft was queued', () => { + const queuedAt = vi.fn(() => 5) + const handedOver = [submission('handed-over', 40, { queuedMessageId: 'draft-1' })] + const read = newestAcceptedSend('epoch-1', handedOver, queuedAt) + expect(read).toEqual({ epoch: 'epoch-1', clientMessageId: 'handed-over', actedAt: 5 }) + expect(queuedAt).toHaveBeenCalledWith('draft-1') + // Still the newest: the draft table is not read again. + expect(newestAcceptedSend('epoch-1', handedOver, queuedAt, read)).toBe(read) + expect(queuedAt).toHaveBeenCalledTimes(1) + }) + + it('names no send, and no time, in a conversation with none accepted', () => { + expect(newestAcceptedSend('epoch-2', [], () => undefined)).toEqual({ + epoch: 'epoch-2', + clientMessageId: null + }) + }) +}) 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 index fd8ec4199eed..d5c68ca3f4e8 100644 --- 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 @@ -52,14 +52,38 @@ export function structuredStatusChildrenEqual( 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( +/** The user's newest send the provider accepted (a message, a steer or a command) in one journal + * epoch: a new one 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 type StructuredAcceptedSend = { + epoch: string + clientMessageId: string | null + /** When the user acted, on the host clock: they wrote the message, or queued the draft it was + * handed over from. A child that finished after it finished after what the user saw. */ + actedAt?: number +} + +/** `previous` is returned when it is still the newest, so the draft table is read once per send. */ +export function newestAcceptedSend( epoch: string, - submissions: readonly AgentJournalSubmission[] -): string { + submissions: readonly AgentJournalSubmission[], + draftQueuedAt: (queuedMessageId: string) => number | undefined, + previous?: StructuredAcceptedSend +): StructuredAcceptedSend { const accepted = submissions.findLast((submission) => submission.dispatchState === 'accepted') - return `${epoch}:${accepted?.clientMessageId ?? ''}` + const clientMessageId = accepted?.clientMessageId ?? null + if (previous?.epoch === epoch && previous.clientMessageId === clientMessageId) { + return previous + } + if (!accepted) { + return { epoch, clientMessageId: null } + } + const queuedAt = + accepted.queuedMessageId === undefined ? undefined : draftQueuedAt(accepted.queuedMessageId) + return { + epoch, + clientMessageId: accepted.clientMessageId, + actedAt: Math.min(accepted.submittedAt, queuedAt ?? accepted.submittedAt) + } } 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 index 545c94231fd7..ab0feac79edf 100644 --- 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 @@ -316,10 +316,19 @@ describe('structured status summary child records', () => { feed.publish(SESSION) expect(retirements()).toHaveLength(2) - // A rewind replaces the conversation, as the user's own action. + // Each carries when the user wrote the send, so a child that finished after it stays. + const writtenAt = (clientMessageId: string) => + journal.submissions().find((entry) => entry.clientMessageId === clientMessageId)?.submittedAt + expect(retirements().map((edge) => edge.type === 'turn-started' && edge.actedAt)).toEqual([ + writtenAt('send-1'), + writtenAt('send-2') + ]) + + // A rewind replaces the conversation, as the user's own action: every finished child goes. await journal.replaceEpochItems('handle_forked', 1, []) feed.publish(SESSION) expect(retirements()).toHaveLength(3) + expect(retirements().at(-1)).not.toHaveProperty('actedAt') }) it("forgets a closed session's children in the projection it keeps", async () => { 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 e917b3a0f4e1..638570299829 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 @@ -22,7 +22,10 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work 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 { + structuredStatusChildWork, + type StructuredAcceptedSend +} from './structured-agent-session-status-child-work' import { StructuredAgentSessionJournalProjections, type StructuredAgentSessionStatusState @@ -99,7 +102,7 @@ export class StructuredAgentSessionStatusFeed { private readonly published = new Map() /** 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 acceptedSends = new Map() private readonly projections = new StructuredAgentSessionJournalProjections() constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {} @@ -196,7 +199,7 @@ export class StructuredAgentSessionStatusFeed { const source = journal ?? session.journal const record = this.deps.getRecord(sessionId) const projection = this.projections.read(source, record) - this.retireSettledChildrenOnNewTurn(sessionId, session, projection.acceptedSendKey) + this.retireSettledChildrenOnNewTurn(sessionId, session, projection.acceptedSend) const summary = this.summaryFor(sessionId, session, source, record, projection.state) const previous = this.published.get(sessionId) if (previous && structuredStatusSummariesEqual(previous, summary)) { @@ -220,7 +223,8 @@ 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. The + * the provider accepts (see `newestAcceptedSend`), if it finished before the user wrote that + * send, and unless it still owns live work. The * provider ending the session removes nothing: children still live settle `unknown`, and * settled ones stay. The one earlier death is the host letting go of the session, whose row * takes every child record with it (see `forget`). Nothing caps how many records a turn keeps, @@ -230,15 +234,31 @@ export class StructuredAgentSessionStatusFeed { private retireSettledChildrenOnNewTurn( sessionId: string, session: StatusFeedSession, - acceptedSendKey: string | null + accepted: StructuredAcceptedSend | null ): void { + // An unreadable journal says nothing about the user's turns: the last send read stands. + if (accepted === null) { + return + } const seen = this.acceptedSends.has(sessionId) const previous = this.acceptedSends.get(sessionId) - this.acceptedSends.set(sessionId, acceptedSendKey) - if (!seen || acceptedSendKey === null || acceptedSendKey === previous) { + this.acceptedSends.set(sessionId, accepted) + if ( + !seen || + (previous?.epoch === accepted.epoch && previous.clientMessageId === accepted.clientMessageId) + ) { return } - this.admitChildWork(sessionId, session, [{ type: 'turn-started', observedAt: this.deps.now() }]) + // A child that finished after the user acted stays until their next send; a rewind (a new + // epoch) retires every finished child. + const actedAt = previous?.epoch === accepted.epoch ? accepted.actedAt : undefined + this.admitChildWork(sessionId, session, [ + { + type: 'turn-started', + observedAt: this.deps.now(), + ...(actedAt !== undefined ? { actedAt } : {}) + } + ]) } private summaryFor( 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 index cbb12e8833fe..44c861978f47 100644 --- 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 @@ -4,7 +4,10 @@ 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' +import { + newestAcceptedSend, + type StructuredAcceptedSend +} from './structured-agent-session-status-child-work' export type StructuredAgentSessionStatusState = ReturnType< typeof projectStructuredAgentSessionStatusState @@ -17,7 +20,7 @@ export type StructuredAgentSessionJournalProjection = { fence: number | undefined state: StructuredAgentSessionStatusState /** Null for an unreadable journal, which says nothing about the user's turns. */ - acceptedSendKey: string | null + acceptedSend: StructuredAcceptedSend | null } export class StructuredAgentSessionJournalProjections { @@ -36,7 +39,8 @@ export class StructuredAgentSessionJournalProjections { 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) + const previous = this.byJournal.get(journal) + let projection = previous if ( !projection || projection.epoch !== cursor.epoch || @@ -56,10 +60,26 @@ export class StructuredAgentSessionJournalProjections { snapshot?.submissions ?? [], fence ), - acceptedSendKey: snapshot ? newestAcceptedSendKey(cursor.epoch, snapshot.submissions) : null + acceptedSend: snapshot + ? newestAcceptedSend( + cursor.epoch, + snapshot.submissions, + (queuedMessageId) => draftQueuedAt(journal, queuedMessageId), + previous?.acceptedSend ?? undefined + ) + : null } this.byJournal.set(journal, projection) } return projection } } + +/** A draft table that cannot answer leaves the send's own time. */ +function draftQueuedAt(journal: AgentSessionJournal, queuedMessageId: string): number | undefined { + try { + return journal.queuedMessages.get(queuedMessageId)?.createdAt + } catch { + return undefined + } +} diff --git a/src/shared/agent-status-child-work-admission-core.ts b/src/shared/agent-status-child-work-admission-core.ts index 31c9db7cd42d..6ce390c42615 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 cf839485be8b..087f687c3136 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 f335bbae37ea..e5551514b9d9 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 000000000000..a9c784f0701f --- /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 b78859ff8406..24d06c0cdb30 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 1431f5187a0e..0fa3c1ba5737 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,20 +62,29 @@ 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 } + * child that still owns live work, at any depth, stays so that work keeps its owners, and one + * that settled after `actedAt` (when the user wrote that send, on the host clock) stays until + * the user's next send after it. No `actedAt` retires every settled child (a rewind). */ +export type AgentChildWorkTurnStartedEvidence = { + type: 'turn-started' + observedAt: number + actedAt?: 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 until the user's next turn or the diff --git a/src/shared/agent-status-child-work-reconciliation.test.ts b/src/shared/agent-status-child-work-reconciliation.test.ts index 9d011204a772..34bc4c2e6316 100644 --- a/src/shared/agent-status-child-work-reconciliation.test.ts +++ b/src/shared/agent-status-child-work-reconciliation.test.ts @@ -506,4 +506,65 @@ describe('structured child-work reconciliation', () => { strip: ['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 }) + }) + + it('at the next turn keeps a child that finished after the user wrote that send', () => { + const { store, apply } = harness() + const ended = (id: string, observedAt: number): AgentChildWorkEvidence => ({ + type: 'ended', + observedAt, + handle: { idKind: 'task_id', id }, + outcome: 'succeeded' + }) + apply(live(child('before')), live(child('after'))) + apply(ended('before', 110), ended('after', 130)) + expect(apply({ type: 'turn-started', observedAt: 140, actedAt: 120 })).toMatchObject({ + removed: 1 + }) + expect(records(store).map((record) => record.description)).toEqual(['Task after']) + // The user's next send, written after it finished, retires it. + expect(apply({ type: 'turn-started', observedAt: 160, actedAt: 150 })).toMatchObject({ + removed: 1 + }) + expect(records(store)).toEqual([]) + }) }) diff --git a/src/shared/agent-status-child-work-reconciliation.ts b/src/shared/agent-status-child-work-reconciliation.ts index 7cd08361e4e6..6ee72d6e9d53 100644 --- a/src/shared/agent-status-child-work-reconciliation.ts +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -3,8 +3,9 @@ // 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 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. +// next send the provider accepts, if they finished before the user wrote it) 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' @@ -56,7 +57,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 } : {}) }) } @@ -119,9 +121,10 @@ function applyRemoved(ctx: ReconcileContext, edge: AgentChildWorkRemovedEvidence 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[] { +/** Settled children the user has seen finish before `actedAt`, 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. Both clocks are the host's: admission stamps `settledAt`. */ +function removableSettled(ctx: ReconcileContext, actedAt: number | undefined): string[] { const owned = ownedStructuredChildWork(ctx) const owners = settledOwnersOfLiveWork( owned.map((record) => ({ @@ -131,7 +134,12 @@ function removableSettled(ctx: ReconcileContext): string[] { })) ) return owned - .filter((record) => record.membership === 'settled' && !owners.has(record.childWorkId)) + .filter( + (record) => + record.membership === 'settled' && + !owners.has(record.childWorkId) && + (actedAt === undefined || (record.settledAt ?? record.observedAt) <= actedAt) + ) .map((record) => record.childWorkId) } @@ -157,7 +165,7 @@ export function reconcileAgentChildWorkEvidence( } else if (edge.type === 'removed') { applyRemoved(ctx, edge) } else if (edge.type === 'turn-started') { - removeChildren(ctx, removableSettled(ctx)) + removeChildren(ctx, removableSettled(ctx, edge.actedAt)) } else { settleLive(ctx, edge.observedAt) } diff --git a/src/shared/agent-status-child-work.ts b/src/shared/agent-status-child-work.ts index c537bae4acca..1012d3e47a84 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 From 66afd163ba4a56e71c5315a055af890cc8a2dd3d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:41:14 -0700 Subject: [PATCH 29/34] fix(native-chat): the strip keeps one empty list for a roster that omits one A roster with only running or only finished rows made a new empty array on every render, so the strip regrouped its rows each time. One shared empty list keeps its memo. --- ...structured-session-background-tasks-view.test.ts | 13 +++++++++++++ .../structured-session-background-tasks-view.ts | 7 +++++-- 2 files changed, 18 insertions(+), 2 deletions(-) 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 index 8187409e9108..8b5f814c31e2 100644 --- 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 @@ -31,6 +31,19 @@ describe('structuredSessionBackgroundTasksView', () => { 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, 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 3652d473b82a..6b601d52c87c 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 @@ -30,6 +30,9 @@ export type StructuredSessionBackgroundTasksView = { 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 @@ -42,8 +45,8 @@ export function structuredSessionBackgroundTasksView( return { show: monitoring, isMonitoring: turnId === null && liveWork, - tasks: backgroundTasks?.tasks ?? [], - settledTasks: backgroundTasks?.settledTasks ?? [], + tasks: backgroundTasks?.tasks ?? NO_TASKS, + settledTasks: backgroundTasks?.settledTasks ?? NO_TASKS, ...(children ? { children } : {}), supportsStop: backgroundTasks?.supportsTaskStop === true, // Absent means the host predates the field and does accept an untargeted From bacc1babb3a3f40ab80b0bca186f3615603bc1cc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:41:15 -0700 Subject: [PATCH 30/34] fix(native-chat): a strip row whose owner the 100-row budget cut renders under the main agent The budget can keep a finished child and cut its finished owner. The child still named that owner, so it rendered nowhere. The selection now clears an owner it did not keep, as the view contract says for an owner outside the projection. --- ...agent-session-child-work-selection.test.ts | 23 +++++++++++++++++++ src/shared/agent-child-work-listing.ts | 12 +++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) 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 index 072fbf89a6ca..29158e2a4856 100644 --- 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 @@ -2,6 +2,7 @@ // chat's strip every running child and then the newest finished ones, up to 100 rows. import { describe, expect, it } from 'vitest' +import { buildAgentChildRowModels } from '../../../shared/agent-child-row-model' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { STRUCTURED_STRIP_CHILD_WORK_LIMIT as LIMIT, @@ -56,6 +57,28 @@ describe('the sidebar lists running children only', () => { }) describe('the strip lists running children, then the newest finished, up to 100 rows', () => { + it('renders every row it keeps: a child whose owner the budget cut belongs to the main agent', () => { + const views = [ + ...Array.from({ length: LIMIT - 2 }, (_, index) => running(`run-${index}`)), + finished('owner', 10), + { ...finished('nested', 200), parentChildWorkId: 'owner' }, + finished('newest', 300) + ] + const strip = structuredStripChildWork(views) + expect(strip).toHaveLength(LIMIT) + expect(ids(strip)).not.toContain('owner') + expect(strip.find((view) => view.id === 'nested')).not.toHaveProperty('parentChildWorkId') + const rows = buildAgentChildRowModels(strip, { + parentEvidenceFresh: true, + transportObservation: 'live', + parentObservedAt: 1, + hostClockOffsetMs: 0 + }) + const rendered = (models: typeof rows): string[] => + models.flatMap((model) => [model.id, ...rendered(model.owned)]) + expect(rendered(rows)).toHaveLength(LIMIT) + }) + it('lists every child while they fit', () => { const views = [running('a'), finished('b', 2), finished('c', 3)] expect(ids(structuredStripChildWork(views))).toEqual(['a', 'b', 'c']) diff --git a/src/shared/agent-child-work-listing.ts b/src/shared/agent-child-work-listing.ts index 98878cc98b0a..682df9f6e9e6 100644 --- a/src/shared/agent-child-work-listing.ts +++ b/src/shared/agent-child-work-listing.ts @@ -64,5 +64,15 @@ export function structuredStripChildWork( .slice(0, room) .map((view) => view.id) ) - return views.filter((view) => viewRuns(view, owners) || newestFinished.has(view.id)) + const selected = views.filter((view) => viewRuns(view, owners) || newestFinished.has(view.id)) + // An owner the budget cut leaves its child to the main agent, as a view whose owner is not in + // the projection reads; otherwise the child would render under a row that is not there. + const kept = new Set(selected.map((view) => view.id)) + return selected.map((view) => { + if (view.parentChildWorkId === undefined || kept.has(view.parentChildWorkId)) { + return view + } + const { parentChildWorkId: _cut, ...toMainAgent } = view + return toMainAgent + }) } From dbd2439cd4616e962bc2058c54cb0ed5ef0585ff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:57:50 -0700 Subject: [PATCH 31/34] fix(native-chat): a stop-all that times out stops asking, and "no children" is sent once A stop-all kept asking after a request timed out, so a Claude CLI that stopped answering control requests cost one full deadline per task, while the chat's sends, Stop and /compact waited behind it. A timeout now ends the loop; other request failures still let the remaining tasks be stopped. The chat strip channel never remembered that it had sent "no children", so every change in a chat with none re-sent that frame to each subscriber. It now remembers it, and forgets only when the conversation closes. Also renames agent-child-work-stop.ts to agent-child-work-stop-targets.ts, which says what it answers: the provider ids a background Stop reaches. --- .../claude-background-stop-records.test.ts | 2 +- .../claude-structured-control-actions.test.ts | 25 +++++++++++++++++++ .../claude-structured-control-actions.ts | 8 +++++- ...nt-session-background-task-channel.test.ts | 24 ++++++++++++++++-- ...d-agent-session-background-task-channel.ts | 10 +++----- .../structured-agent-session-turns-cancel.ts | 2 +- ...ructured-conversation-command-admission.ts | 2 +- src/shared/agent-child-row-model.ts | 2 +- ...op.ts => agent-child-work-stop-targets.ts} | 0 9 files changed, 62 insertions(+), 13 deletions(-) rename src/shared/{agent-child-work-stop.ts => agent-child-work-stop-targets.ts} (100%) diff --git a/src/main/claude/claude-background-stop-records.test.ts b/src/main/claude/claude-background-stop-records.test.ts index 092915dc1be6..0092d9e2f29d 100644 --- a/src/main/claude/claude-background-stop-records.test.ts +++ b/src/main/claude/claude-background-stop-records.test.ts @@ -3,7 +3,7 @@ // 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' +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' diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 7cc154f27a55..66913919a420 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -6,6 +6,10 @@ 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 { ClaudeChildWorkDecoder } from './claude-child-work-decoder' @@ -389,4 +393,25 @@ describe('stopClaudeBackgroundTasks', () => { { 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 6aac249c232b..8c9c9c7afebc 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' @@ -67,7 +68,9 @@ export async function stopClaudeBackgroundTasks( taskIds: readonly string[] ): Promise<{ cancelled: boolean }> { let cancelled = false - // A failed request for one task still leaves the others to stop; it is reported after them. + // 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()) { @@ -78,6 +81,9 @@ export async function stopClaudeBackgroundTasks( session.childWork.stopAcknowledged(taskId) cancelled = true } catch (error) { + if (error instanceof ClaudeControlRequestTimeoutError) { + throw error + } if (!(error instanceof ClaudeControlRequestError)) { failure ??= { error } } 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 index a021f635a562..129dbb641676 100644 --- 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 @@ -23,7 +23,7 @@ const child: AgentChildWorkView = { invocation: { invocationId: 'spawn-1', generation: 1 } } -function channelOver() { +function channelOver(children: () => AgentChildWorkView[] = () => [child]) { const sessions = new StructuredAgentSessionConversations({ deliver: () => {}, onDeliveryError: () => {}, @@ -42,7 +42,7 @@ function channelOver() { async () => { throw new Error('not opened here') }, - () => [child] + 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 = { @@ -67,4 +67,24 @@ describe('the background-task channel', () => { 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 + ]) + }) }) 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 7b8e9d12bb65..843aede4a3fa 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 @@ -85,7 +85,7 @@ export class StructuredAgentSessionBackgroundTaskChannel { /** Re-read after the session's child records changed; an unchanged roster sends nothing. */ publish(sessionId: string): void { const session = this.sessions.get(sessionId) - // Explicit null once rows were sent, not silence: a reader keeps its last roster on + // 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 @@ -96,11 +96,9 @@ export class StructuredAgentSessionBackgroundTaskChannel { if (this.published.get(sessionId) === fingerprint) { return } - if (state === null) { - this.published.delete(sessionId) - } else { - this.published.set(sessionId, fingerprint) - } + // "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, 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 76b4a74fc9bf..ee6e7bf1179d 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,4 @@ -import { agentChildWorkStopTargets } from '../../../shared/agent-child-work-stop' +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' 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 21419ffb32be..7aca4e23884d 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,4 +1,4 @@ -import { agentChildWorkViewOffersStop } from '../../../shared/agent-child-work-stop' +import { agentChildWorkViewOffersStop } 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' diff --git a/src/shared/agent-child-row-model.ts b/src/shared/agent-child-row-model.ts index bbdc471f80dc..c0dcfed2d045 100644 --- a/src/shared/agent-child-row-model.ts +++ b/src/shared/agent-child-row-model.ts @@ -19,7 +19,7 @@ import { deriveAgentChildDisplayState, type AgentChildDisplayState } from './agent-status-child-work-display' -import { agentChildWorkViewOffersStop } from './agent-child-work-stop' +import { agentChildWorkViewOffersStop } from './agent-child-work-stop-targets' import type { AgentChildWorkView } from './agent-status-child-work-view' import { agentStatusAuthorityObservedAt, diff --git a/src/shared/agent-child-work-stop.ts b/src/shared/agent-child-work-stop-targets.ts similarity index 100% rename from src/shared/agent-child-work-stop.ts rename to src/shared/agent-child-work-stop-targets.ts From bc7bc6ef685278b5732fc1c49b9f08429935559a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:41:51 -0700 Subject: [PATCH 32/34] fix(native-chat): the status feed reads a journal snapshot with no submissions, and e2e tests use the child-work reader CI on dbd2439cd46 was red in three places: - first-work-branch-rename and the agentSession.subscribeStatus RPC test feed the status feed a journal whose snapshot lists items only. The projection reads the user's newest accepted send from `snapshot.submissions`; it now tolerates their absence, as the status projection beside it already did. - the cross-version downgrade test still passed `backgroundTasks` to the teardown's working marker, which now takes `childWork`. - an e2e unit test still gave the status feed the removed `readBackgroundTasks` dependency (harmless at run time, a type error in the tests/ project). --- .../structured-agent-session-status-journal-projection.ts | 2 +- tests/e2e/codex-child-approval-activity-row.unit.test.ts | 3 +-- .../agent-session-resume-marker-downgrade.unit.test.ts | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) 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 index 44c861978f47..a13545697786 100644 --- 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 @@ -63,7 +63,7 @@ export class StructuredAgentSessionJournalProjections { acceptedSend: snapshot ? newestAcceptedSend( cursor.epoch, - snapshot.submissions, + snapshot?.submissions ?? [], (queuedMessageId) => draftQueuedAt(journal, queuedMessageId), previous?.acceptedSend ?? undefined ) 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 dbb2a5d8e9cb..c2df9b1f3e3f 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 f7f23d528aaf..9d1f1a601e07 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 From b9e1ef8eb09007ff5a4f80d788312c651a812ce1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:51:55 -0700 Subject: [PATCH 33/34] fix(native-chat): the chat strip lists running children only, by the sidebar's rule, and hides when none runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A finished subagent's result is already in the transcript ("Ran N subagents · completed"), so the strip is for work that runs. It now lists exactly what the sidebar lists, by one predicate (a running child, or a finished one whose own shell still runs, which reads monitoring), and the host sends no roster once none runs, so the strip hides. Gone with it: the 100-row budget and the running-then-newest-finished selection, the re-homing of a child whose owner the budget cut, and the RPC gate's rule for a roster of finished rows only (no such roster exists now). Older clients still get their derived task list, running work only. Finished records still stay in the host's store until the user's next accepted message: they refuse a late frame of their run, let a task's own ending replace an acknowledged Stop's, and keep a running shell's owner. Dating that retention by when the user wrote the message only kept finished rows visible longer, so it is removed. --- .../claude-background-wake-retention.test.ts | 68 +++++--------- ...d-agent-session-background-task-channel.ts | 10 +- ...agent-session-child-work-selection.test.ts | 92 +++++-------------- ...ed-agent-session-status-child-work.test.ts | 56 ----------- ...uctured-agent-session-status-child-work.ts | 44 ++------- ...agent-session-status-feed-children.test.ts | 11 +-- .../structured-agent-session-status-feed.ts | 46 +++------- ...agent-session-status-journal-projection.ts | 28 +----- ...ed-conversation-command-child-work.test.ts | 7 +- ...session-background-task-capability.test.ts | 17 ---- ...gent-session-background-task-capability.ts | 11 +-- .../structured-child-records-switch.test.ts | 26 +++--- .../agent-child-row-parity.test.tsx | 60 +++++------- .../structured-child-records-switch.test.tsx | 22 ++++- ...tructured-session-background-tasks-view.ts | 8 +- .../worktree-sidebar-running-children.test.ts | 20 ++-- .../sidebar/worktree-subagent-child-rows.ts | 6 +- src/shared/agent-child-work-listing.ts | 78 ++++------------ .../agent-status-child-work-evidence.ts | 10 +- ...t-status-child-work-reconciliation.test.ts | 33 +------ .../agent-status-child-work-reconciliation.ts | 21 ++--- src/shared/protocol-version.ts | 6 +- 22 files changed, 203 insertions(+), 477 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.test.ts diff --git a/src/main/claude/claude-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts index 947eb89514fd..313f9c5de590 100644 --- a/src/main/claude/claude-background-wake-retention.test.ts +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -1,10 +1,12 @@ -// A finished child stays in the chat's strip 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: replayed from captured -// frame orders through the real adapter, a real hook server and the production status feed. +// 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 { structuredStripChildWork } from '../../shared/agent-child-work-listing' +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' @@ -66,10 +68,13 @@ async function wiredSession() { readChildWork: (subject) => host.getStructuredChildWorkViews(subject) }) }) - const strip = () => - structuredStripChildWork(host.getStructuredChildWorkViews(parent)).map( - (view) => `${view.description}:${view.membership}:${view.outcome ?? ''}` - ) + 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]) @@ -98,22 +103,24 @@ async function wiredSession() { } const rootTurns = () => [...run.journalItems.values()].filter((item) => !item.agentId && item.body.kind === 'turn') - return { run, feed, strip, replay, send, accept, rootTurns } + return { run, feed, records, running, replay, send, accept, rootTurns } } -describe("a finished child stays until the user's next turn", () => { +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.strip()).toContain('Run 45s sleep command:settled:succeeded') + 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.strip()).toContain('Run 45s sleep command:settled:succeeded') + expect(session.records()).toContain('Run 45s sleep command:settled:succeeded') session.accept(next) - expect(session.strip()).toEqual([]) + expect(session.records()).toEqual([]) }) it('keeps a background shell through the wake its completion opens, and a steer into that wake retires it', async () => { @@ -125,44 +132,19 @@ describe("a finished child stays until the user's next turn", () => { }) session.accept(session.send('first-uuid')) session.replay(firstTurn) - expect(session.strip()).toEqual(['Sleep then print marker:live:']) + 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.strip()).toEqual(['Sleep then print marker:settled:succeeded']) + 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.strip()).toEqual([]) + expect(session.records()).toEqual([]) session.replay(wake.slice(wakeInit + 1)) - expect(session.strip()).toEqual([]) - }) - - it('keeps a child that finished after the user wrote the message the provider takes later', 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.strip()).toEqual(['Sleep then print marker:live:']) - // Written while the shell runs: a steer Claude takes at its next boundary, or a queued draft. - const steer = session.send('steer-uuid') - const wakeInit = wake.findIndex((captured) => captured.frame.subtype === 'init') - session.replay(wake.slice(0, wakeInit + 1)) - expect(session.strip()).toEqual(['Sleep then print marker:settled:succeeded']) - - // The provider takes the message the user wrote before the shell finished: the user has - // not acted since it finished, so it stays. - session.accept(steer) - expect(session.strip()).toEqual(['Sleep then print marker:settled:succeeded']) - // Their next message retires it. - session.replay(wake.slice(wakeInit + 1)) - session.accept(session.send('next-uuid')) - expect(session.strip()).toEqual([]) + expect(session.records()).toEqual([]) }) }) 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 843aede4a3fa..c5fd3f81b594 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 @@ -5,7 +5,7 @@ import type { } 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 { structuredStripChildWork } from '../../../shared/agent-child-work-listing' +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' @@ -20,9 +20,9 @@ import type { } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -/** The chat strip's roster: the host's child records for one session (every running child, then - * the newest finished ones, up to the strip's row budget), with the legacy task rows an older - * client reads derived from them. */ +/** 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() @@ -112,7 +112,7 @@ export class StructuredAgentSessionBackgroundTaskChannel { if (!session || stored === undefined) { return undefined } - const views = structuredStripChildWork(stored) + 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". 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 index 29158e2a4856..32b4abf5a215 100644 --- 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 @@ -1,14 +1,9 @@ -// What each surface lists of a session's child records: the sidebar its running children, the -// chat's strip every running child and then the newest finished ones, up to 100 rows. +// 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 { buildAgentChildRowModels } from '../../../shared/agent-child-row-model' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { - STRUCTURED_STRIP_CHILD_WORK_LIMIT as LIMIT, - structuredSidebarChildWork, - structuredStripChildWork -} from '../../../shared/agent-child-work-listing' +import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing' import { attach, hostTestState, @@ -42,7 +37,7 @@ function finished(id: string, settledAt: number): AgentChildWorkView { const ids = (views: readonly AgentChildWorkView[]) => views.map((view) => view.id) -describe('the sidebar lists running children only', () => { +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 = [ @@ -52,73 +47,26 @@ describe('the sidebar lists running children only', () => { finished('owner', 3), running('shell', { kind: 'command', parentChildWorkId: 'owner' }) ] - expect(ids(structuredSidebarChildWork(views))).toEqual(['working', 'owner', 'shell']) + expect(ids(structuredRunningChildWork(views))).toEqual(['working', 'owner', 'shell']) }) }) -describe('the strip lists running children, then the newest finished, up to 100 rows', () => { - it('renders every row it keeps: a child whose owner the budget cut belongs to the main agent', () => { - const views = [ - ...Array.from({ length: LIMIT - 2 }, (_, index) => running(`run-${index}`)), - finished('owner', 10), - { ...finished('nested', 200), parentChildWorkId: 'owner' }, - finished('newest', 300) - ] - const strip = structuredStripChildWork(views) - expect(strip).toHaveLength(LIMIT) - expect(ids(strip)).not.toContain('owner') - expect(strip.find((view) => view.id === 'nested')).not.toHaveProperty('parentChildWorkId') - const rows = buildAgentChildRowModels(strip, { - parentEvidenceFresh: true, - transportObservation: 'live', - parentObservedAt: 1, - hostClockOffsetMs: 0 - }) - const rendered = (models: typeof rows): string[] => - models.flatMap((model) => [model.id, ...rendered(model.owned)]) - expect(rendered(rows)).toHaveLength(LIMIT) - }) - - it('lists every child while they fit', () => { - const views = [running('a'), finished('b', 2), finished('c', 3)] - expect(ids(structuredStripChildWork(views))).toEqual(['a', 'b', 'c']) - }) - - it('keeps every running child and the newest finished ones, in the order the store holds them', () => { - const finishedViews = Array.from({ length: 150 }, (_, index) => - finished(`done-${index}`, 1_000 + index) - ) - const runningViews = Array.from({ length: 10 }, (_, index) => running(`run-${index}`)) - const listed = structuredStripChildWork([...finishedViews, ...runningViews]) - expect(listed).toHaveLength(LIMIT) - // The 90 newest finished children (settled latest), then the running ones, as the store orders. - expect(ids(listed)).toEqual([ - ...Array.from({ length: 90 }, (_, index) => `done-${index + 60}`), - ...ids(runningViews) - ]) - }) - - it('shows every running child when more than 100 run, and no finished one', () => { - const runningViews = Array.from({ length: 120 }, (_, index) => running(`run-${index}`)) - const listed = structuredStripChildWork([finished('done', 5), ...runningViews]) - expect(ids(listed)).toEqual(ids(runningViews)) - }) -}) - -describe("the host's strip channel and summary list what these pick", () => { - it('sends the strip the bounded roster and the sidebar the running children', async () => { - const records = [ - ...Array.from({ length: 150 }, (_, index) => finished(`done-${index}`, 1_000 + index)), - running('run') +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 page = await host.history({ sessionId: SESSION, direction: 'tail' }) - const children = page.ok ? (page.page.backgroundTasks?.children ?? []) : [] - expect(children).toHaveLength(LIMIT) - expect(children.at(-1)?.id).toBe('run') - expect(children[0]?.id).toBe('done-51') + 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', @@ -128,6 +76,10 @@ describe("the host's strip channel and summary list what these pick", () => { } } }) - expect(summaries.at(-1)).toEqual(['run']) + 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-status-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.test.ts deleted file mode 100644 index 42c1925ea060..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-child-work.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -// When the user acted for the send that begins their next turn: the time they wrote it, or queued -// the draft it was handed over from. - -import { describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { newestAcceptedSend } from './structured-agent-session-status-child-work' - -function submission( - clientMessageId: string, - submittedAt: number, - overrides: Partial = {} -): AgentJournalSubmission { - return { - clientMessageId, - fence: 1, - payloadFingerprint: clientMessageId, - dispatchState: 'accepted', - providerItemId: null, - reason: null, - submittedAt, - resolvedAt: submittedAt, - ...overrides - } -} - -describe('newestAcceptedSend', () => { - it('dates a direct send by when it was written', () => { - const queuedAt = vi.fn(() => undefined) - expect( - newestAcceptedSend( - 'epoch-1', - [submission('first', 10), submission('pending', 30, { dispatchState: 'pending' })], - queuedAt - ) - ).toEqual({ epoch: 'epoch-1', clientMessageId: 'first', actedAt: 10 }) - expect(queuedAt).not.toHaveBeenCalled() - }) - - it('dates a send handed over from a queued draft by when the draft was queued', () => { - const queuedAt = vi.fn(() => 5) - const handedOver = [submission('handed-over', 40, { queuedMessageId: 'draft-1' })] - const read = newestAcceptedSend('epoch-1', handedOver, queuedAt) - expect(read).toEqual({ epoch: 'epoch-1', clientMessageId: 'handed-over', actedAt: 5 }) - expect(queuedAt).toHaveBeenCalledWith('draft-1') - // Still the newest: the draft table is not read again. - expect(newestAcceptedSend('epoch-1', handedOver, queuedAt, read)).toBe(read) - expect(queuedAt).toHaveBeenCalledTimes(1) - }) - - it('names no send, and no time, in a conversation with none accepted', () => { - expect(newestAcceptedSend('epoch-2', [], () => undefined)).toEqual({ - epoch: 'epoch-2', - clientMessageId: null - }) - }) -}) 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 index d5c68ca3f4e8..10a447292627 100644 --- 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 @@ -12,7 +12,7 @@ import type { AgentSessionBackgroundTask } from '../../../shared/agent-session-w 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 { structuredSidebarChildWork } from '../../../shared/agent-child-work-listing' +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, @@ -33,7 +33,7 @@ export function structuredStatusChildWork( views: readonly AgentChildWorkView[] | undefined, provider: AgentSessionHandleProvider ): StructuredStatusChildWork { - const running = views ? structuredSidebarChildWork(views) : [] + const running = views ? structuredRunningChildWork(views) : [] if (running.length === 0) { return {} } @@ -52,38 +52,14 @@ export function structuredStatusChildrenEqual( return agentChildWorkViewsEqual(a, b, SUMMARY_CHILD_CLOCK_TOLERANCE_MS) } -/** The user's newest send the provider accepted (a message, a steer or a command) in one journal - * epoch: a new one 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 type StructuredAcceptedSend = { - epoch: string - clientMessageId: string | null - /** When the user acted, on the host clock: they wrote the message, or queued the draft it was - * handed over from. A child that finished after it finished after what the user saw. */ - actedAt?: number -} - -/** `previous` is returned when it is still the newest, so the draft table is read once per send. */ -export function newestAcceptedSend( +/** 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[], - draftQueuedAt: (queuedMessageId: string) => number | undefined, - previous?: StructuredAcceptedSend -): StructuredAcceptedSend { + submissions: readonly AgentJournalSubmission[] +): string { const accepted = submissions.findLast((submission) => submission.dispatchState === 'accepted') - const clientMessageId = accepted?.clientMessageId ?? null - if (previous?.epoch === epoch && previous.clientMessageId === clientMessageId) { - return previous - } - if (!accepted) { - return { epoch, clientMessageId: null } - } - const queuedAt = - accepted.queuedMessageId === undefined ? undefined : draftQueuedAt(accepted.queuedMessageId) - return { - epoch, - clientMessageId: accepted.clientMessageId, - actedAt: Math.min(accepted.submittedAt, queuedAt ?? accepted.submittedAt) - } + 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 index ab0feac79edf..545c94231fd7 100644 --- 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 @@ -316,19 +316,10 @@ describe('structured status summary child records', () => { feed.publish(SESSION) expect(retirements()).toHaveLength(2) - // Each carries when the user wrote the send, so a child that finished after it stays. - const writtenAt = (clientMessageId: string) => - journal.submissions().find((entry) => entry.clientMessageId === clientMessageId)?.submittedAt - expect(retirements().map((edge) => edge.type === 'turn-started' && edge.actedAt)).toEqual([ - writtenAt('send-1'), - writtenAt('send-2') - ]) - - // A rewind replaces the conversation, as the user's own action: every finished child goes. + // A rewind replaces the conversation, as the user's own action. await journal.replaceEpochItems('handle_forked', 1, []) feed.publish(SESSION) expect(retirements()).toHaveLength(3) - expect(retirements().at(-1)).not.toHaveProperty('actedAt') }) it("forgets a closed session's children in the projection it keeps", async () => { 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 638570299829..4f717d2c31be 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 @@ -22,10 +22,7 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work 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, - type StructuredAcceptedSend -} from './structured-agent-session-status-child-work' +import { structuredStatusChildWork } from './structured-agent-session-status-child-work' import { StructuredAgentSessionJournalProjections, type StructuredAgentSessionStatusState @@ -102,7 +99,7 @@ export class StructuredAgentSessionStatusFeed { private readonly published = new Map() /** 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 acceptedSends = new Map() private readonly projections = new StructuredAgentSessionJournalProjections() constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {} @@ -199,7 +196,7 @@ export class StructuredAgentSessionStatusFeed { const source = journal ?? session.journal const record = this.deps.getRecord(sessionId) const projection = this.projections.read(source, record) - this.retireSettledChildrenOnNewTurn(sessionId, session, projection.acceptedSend) + this.retireSettledChildrenOnNewTurn(sessionId, session, projection.acceptedSendKey) const summary = this.summaryFor(sessionId, session, source, record, projection.state) const previous = this.published.get(sessionId) if (previous && structuredStatusSummariesEqual(previous, summary)) { @@ -223,42 +220,29 @@ 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 `newestAcceptedSend`), if it finished before the user wrote that - * send, and unless it still owns live work. The - * provider ending the session removes nothing: children still live settle `unknown`, and - * settled ones stay. The one earlier death is the host letting go of the session, whose row - * takes every child record with it (see `forget`). Nothing caps how many records a turn keeps, - * and a command never settles: its record goes when it stops. What each surface lists of them - * is picked on every read (`agent-child-work-listing`): the sidebar only running children, the - * strip running then newest finished. */ + * 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, - accepted: StructuredAcceptedSend | null + acceptedSendKey: string | null ): void { // An unreadable journal says nothing about the user's turns: the last send read stands. - if (accepted === null) { + if (acceptedSendKey === null) { return } const seen = this.acceptedSends.has(sessionId) const previous = this.acceptedSends.get(sessionId) - this.acceptedSends.set(sessionId, accepted) - if ( - !seen || - (previous?.epoch === accepted.epoch && previous.clientMessageId === accepted.clientMessageId) - ) { + this.acceptedSends.set(sessionId, acceptedSendKey) + if (!seen || acceptedSendKey === previous) { return } - // A child that finished after the user acted stays until their next send; a rewind (a new - // epoch) retires every finished child. - const actedAt = previous?.epoch === accepted.epoch ? accepted.actedAt : undefined - this.admitChildWork(sessionId, session, [ - { - type: 'turn-started', - observedAt: this.deps.now(), - ...(actedAt !== undefined ? { actedAt } : {}) - } - ]) + this.admitChildWork(sessionId, session, [{ type: 'turn-started', observedAt: this.deps.now() }]) } private summaryFor( 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 index a13545697786..04ce86d36bca 100644 --- 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 @@ -4,10 +4,7 @@ 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 { - newestAcceptedSend, - type StructuredAcceptedSend -} from './structured-agent-session-status-child-work' +import { newestAcceptedSendKey } from './structured-agent-session-status-child-work' export type StructuredAgentSessionStatusState = ReturnType< typeof projectStructuredAgentSessionStatusState @@ -20,7 +17,7 @@ export type StructuredAgentSessionJournalProjection = { fence: number | undefined state: StructuredAgentSessionStatusState /** Null for an unreadable journal, which says nothing about the user's turns. */ - acceptedSend: StructuredAcceptedSend | null + acceptedSendKey: string | null } export class StructuredAgentSessionJournalProjections { @@ -39,8 +36,7 @@ export class StructuredAgentSessionJournalProjections { const readOnly = journal.isReadOnly // The conversation's fence, which a child's end moves: its unanswered sends stop counting. const fence = record?.lease.runtimeFence - const previous = this.byJournal.get(journal) - let projection = previous + let projection = this.byJournal.get(journal) if ( !projection || projection.epoch !== cursor.epoch || @@ -60,13 +56,8 @@ export class StructuredAgentSessionJournalProjections { snapshot?.submissions ?? [], fence ), - acceptedSend: snapshot - ? newestAcceptedSend( - cursor.epoch, - snapshot?.submissions ?? [], - (queuedMessageId) => draftQueuedAt(journal, queuedMessageId), - previous?.acceptedSend ?? undefined - ) + acceptedSendKey: snapshot + ? newestAcceptedSendKey(cursor.epoch, snapshot.submissions ?? []) : null } this.byJournal.set(journal, projection) @@ -74,12 +65,3 @@ export class StructuredAgentSessionJournalProjections { return projection } } - -/** A draft table that cannot answer leaves the send's own time. */ -function draftQueuedAt(journal: AgentSessionJournal, queuedMessageId: string): number | undefined { - try { - return journal.queuedMessages.get(queuedMessageId)?.createdAt - } catch { - return undefined - } -} 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 index fffe78c96637..c44236ce34ba 100644 --- 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 @@ -131,10 +131,9 @@ describe('conversation command admission reads the strip’s child records', () records = [devServer({ state: 'done', membership: 'settled', outcome: 'succeeded' })] state.host.publishChildWorkEvidence(HOST_TEST_SESSION, []) - // Still listed, as finished: a finished row blocks nothing. - expect(stripRows()).toEqual([ - { description: 'npm run dev', membership: 'settled', stoppable: true, providerId: 'task-dev' } - ]) + // 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 }) 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 a15d4cd8f72e..3f180e9487b4 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 @@ -211,23 +211,6 @@ describe('child views at the RPC boundary', () => { return call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client) } - it('hands a reader that predates views no strip for a roster of finished children', async () => { - const finishedOnly: AgentSessionBackgroundTaskState = { - state: 'monitoring', - supportsTaskStop: true, - settledTasks: [SETTLED_ROW], - children: [view('settled')] - } - expect(await historyFor(finishedOnly, PRE_VIEWS_CLIENT)).toMatchObject({ - ok: true, - result: { page: { backgroundTasks: null } } - }) - expect(await historyFor(finishedOnly, CURRENT_CLIENT)).toMatchObject({ - ok: true, - result: { page: { backgroundTasks: finishedOnly } } - }) - }) - it('hands a reader that predates views its live roster, without the views', async () => { const mixed: AgentSessionBackgroundTaskState = { state: 'monitoring', 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 be95818fa7f0..1c1b798953d4 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 @@ -33,15 +33,14 @@ function readsChildViews(ctx: BackgroundTaskReader): boolean { ) } -/** A reader that predates child views reads any roster as live work: it animates the monitoring - * indicator and refuses conversation commands on one. A roster of settled rows alone is new, so - * that reader gets its pre-feature view of it — no strip — and never the views it cannot read. - * Deleted with the legacy shapes: see `structured-agent-session-child-work-legacy`. */ +/** 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 | null { +): AgentSessionBackgroundTaskState { const { children: _children, ...legacy } = state - return legacy.tasks && legacy.tasks.length > 0 ? legacy : null + return legacy } /** A reader that predates `stoppable` draws a per-row stop on every row it is diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index 1dd55cff1f4e..4dd3cb07b0aa 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -1,7 +1,6 @@ // 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 carries the running children, the -// chat strip's channel carries those and the finished ones, and the parent row is folded from the -// same records. +// 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 @@ -94,7 +93,7 @@ describe('the chat strip and the session list read the same host child records', await rm(root, { recursive: true, force: true }) }) - it('carries running children to both and finished ones to the strip, and folds the parent row from them', async () => { + 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) @@ -296,10 +295,13 @@ describe('the chat strip and the session list read the same host child records', exitCode: 0 }) expect(parentRow()).toMatchObject({ state: 'done' }) - // The sidebar lists running children only; the finished child stays in the strip, with how it - // ended, until the session's next turn. The dev server's record went when its process exited. + // 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(stripRows()).toEqual([ + expect(strip.at(-1)).toBeNull() + const stored = () => rows(server.getStructuredChildWorkViews(parentSubject(summaries))) + expect(stored()).toEqual([ { kind: 'agent', description: 'review', @@ -312,7 +314,7 @@ describe('the chat strip and the session list read the same host child records', // 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(stripRows()).toHaveLength(1) + 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')}` @@ -338,17 +340,19 @@ describe('the chat strip and the session list read the same host child records', turn: { id: 'p2' }, item: { type: 'userMessage', id: 'user-2', clientId: clientOperationId } }) - await vi.waitFor(() => expect(strip.at(-1)).toBeNull()) + 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 the sidebar while the strip keeps listing it. + // 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(stripRows()).toEqual([ + expect(strip.at(-1)).toBeNull() + expect(stored()).toEqual([ { kind: 'agent', description: 'review', 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 e4cab6c23344..eb7721d2f2f1 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 worktree sidebar lists running children only; a finished child is read on the strip. A +// 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 [strip] = stripRows(children) - expect(strip).toEqual(expected) if (FINISHED.has(name)) { + expect(stripRows(children)).toEqual([]) expect(sidebarRows(parentWith(children))).toEqual([]) return } + const [strip] = stripRows(children) + expect(strip).toEqual(expected) const [sidebar] = sidebarRows(parentWith(children)) expect(sidebar).toEqual(expected) const full = fullRow(parentWith(children)) @@ -308,12 +316,11 @@ describe('a child reads the same in the sidebar and the chat strip', () => { } }) - it('names an unlabeled finished child by its state on the strip, and leaves it off the sidebar', () => { + it('lists a finished child on neither surface', () => { const children = [ settled('failed', { description: undefined, agentType: undefined, lastMessage: 'Exit 2' }) ] - const [strip] = stripRows(children) - expect(strip.lead).toBe('Failed') + expect(stripRows(children)).toEqual([]) expect(sidebarRows(parentWith(children))).toEqual([]) }) @@ -539,7 +546,7 @@ describe('sibling child rows keep their own clocks', () => { ]) }) - it("freezes a finished child's run in the strip, and leaves it off the sidebar", () => { + it('leaves a finished child off the sidebar and the strip', () => { expect( buildSubagentChildRows({ parentEntry: parentWith([settled('succeeded')]), @@ -547,26 +554,7 @@ describe('sibling child rows keep their own clocks', () => { parentIsFresh: true }) ).toEqual([]) - 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$/) + expect(stripRows([settled('succeeded')])).toEqual([]) }) }) @@ -618,13 +606,13 @@ describe('the chat strip from views', () => { }) }) -// A finished child is on the strip only (`null`: the sidebar lists no row for it). +// 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 | null, string]>([ - ['failed', [settled('failed')], null, 'blocked'], - ['cancelled', [settled('cancelled')], null, 'idle'], - ['ended, outcome unknown', [settled('unknown')], null, 'idle'], - ['finished', [settled('succeeded')], null, '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) => { @@ -633,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 ?? null).toBe(sidebarState) - expect(group.tasks[0].state).toBe(headerState) + expect(group?.tasks[0].state ?? null).toBe(headerState) }) }) 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 index 6a4dc2cf499a..ea325e9b155a 100644 --- 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 @@ -189,7 +189,7 @@ function stripRows(container: HTMLElement): RenderedRow[] { .map((item) => readRow(item, ' · ')) } -function Surfaces(props: { roster: AgentSessionBackgroundTaskState }): React.JSX.Element { +function Surfaces(props: { roster: AgentSessionBackgroundTaskState | null }): React.JSX.Element { return ( @@ -326,4 +326,24 @@ describe('the switch: both surfaces read the host child records', () => { 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.ts b/src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts index 6b601d52c87c..3563a386a043 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,9 +5,9 @@ // 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. -// A host that publishes its child records also keeps a finished child listed, -// with how it ended, until the user's next turn. Only an idle session with -// live work lets the strip 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, @@ -40,7 +40,7 @@ export function 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 - // A roster of finished children is shown, but nothing in it is running. + // Only running children arrive; a roster with none holds nothing open either way. const liveWork = children ? agentChildWorkLiveness(children) !== null : monitoring return { show: 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 index ae87bef15a3a..9eaba1066bdc 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-running-children.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-running-children.test.ts @@ -1,8 +1,11 @@ -// The worktree sidebar lists running children only, whichever source it reads them from; the chat's -// strip keeps the finished ones. +// 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 reaches the strip as it always did. import { describe, expect, it } from 'vitest' -import { worktreeSidebarListsChild } from '../../../../shared/agent-child-work-listing' +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' @@ -65,11 +68,12 @@ const sidebarNames = (entry: AgentStatusEntry) => ) describe('the worktree sidebar lists running children only', () => { - it("drops a chat session's finished child, which the strip keeps", () => { + 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(children) - expect(agents.tasks.map((entry) => entry.row.name)).toEqual(['child a', 'child b']) + 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', () => { @@ -104,7 +108,7 @@ describe('the worktree sidebar lists running children only', () => { expect(sidebarNames(parent({ subagents: [] }))).toEqual([]) }) - it("applies the one rule to a finished child from an older host's task roster, which the strip keeps", () => { + it("applies the one rule to a finished child from an older host's task roster, which that host's strip still lists", () => { const tasks = [ { id: 'live', kind: 'agent' as const, description: 'Live task', state: 'working' as const } ] @@ -112,7 +116,7 @@ describe('the worktree sidebar lists running children only', () => { const rows = buildLegacyTaskRowModels(tasks, settledTasks) expect( rows - .filter((row) => worktreeSidebarListsChild({ settled: row.settled, ownsLiveWork: false })) + .filter((row) => agentChildWorkIsRunning({ settled: row.settled, ownsLiveWork: false })) .map((row) => row.name) ).toEqual(['Live task']) const [agents] = buildBackgroundTaskGroups(tasks, settledTasks) 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 d18d0b817e14..1b3ad745648d 100644 --- a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts @@ -12,7 +12,7 @@ import { agentChildRunStateFor, type AgentChildDisplayState } from '../../../../shared/agent-status-child-work-display' -import { worktreeSidebarListsChild } from '../../../../shared/agent-child-work-listing' +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 @@ -108,12 +108,12 @@ export function buildSubagentChildRows(args: { ? buildAgentChildRowModels(parentEntry.children, context) : buildLegacyAgentChildRowModels(parentEntry.subagents ?? [], context) // Shells and monitors show through their owner's dot; the sidebar lists running agents only, from - // every source. A finished one stays in the chat's strip. + // every source, by the same rule as the chat's strip. return flattenAgentChildRowModels(rows) .filter( (row) => row.kind === 'agent' && - worktreeSidebarListsChild({ settled: row.settled, ownsLiveWork: ownsLiveWork(row) }) + agentChildWorkIsRunning({ settled: row.settled, ownsLiveWork: ownsLiveWork(row) }) ) .map((row) => childDashboardRow(row, parentEntry, args.tab)) } diff --git a/src/shared/agent-child-work-listing.ts b/src/shared/agent-child-work-listing.ts index 682df9f6e9e6..f13a3bb6ad71 100644 --- a/src/shared/agent-child-work-listing.ts +++ b/src/shared/agent-child-work-listing.ts @@ -1,78 +1,36 @@ -// Which child work each surface lists. The host keeps every record; these pick from them on each -// read, so nothing here is stored and nothing can disagree with the store. -// -// The worktree sidebar lists running children only, from every source it reads (a CLI pane's hook -// roster and a chat session's records alike); a finished child stays in the chat's strip, which -// lists every running child and then the newest finished ones. +// 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 strip's row budget. Running children always show, even past it. */ -export const STRUCTURED_STRIP_CHILD_WORK_LIMIT = 100 - -/** The one rule for what the worktree sidebar lists: 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 worktreeSidebarListsChild(child: { +/** 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 } -/** Settled views that still own live work, by the rule the host's retention also reads. */ -function settledViewOwnersOfLiveWork(views: readonly AgentChildWorkView[]): Set { - return settledOwnersOfLiveWork( +/** 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 } : {}) })) ) -} - -function viewRuns(view: AgentChildWorkView, owners: ReadonlySet): boolean { - return worktreeSidebarListsChild({ - settled: view.membership === 'settled', - ownsLiveWork: owners.has(view.id) - }) -} - -/** The sidebar's children from a session's records: the running ones, in store order. */ -export function structuredSidebarChildWork( - views: readonly AgentChildWorkView[] -): AgentChildWorkView[] { - const owners = settledViewOwnersOfLiveWork(views) - return views.filter((view) => viewRuns(view, owners)) -} - -/** The strip's children, in store order: every running child, then the newest finished ones, up to - * the row budget in all. */ -export function structuredStripChildWork( - views: readonly AgentChildWorkView[] -): AgentChildWorkView[] { - if (views.length <= STRUCTURED_STRIP_CHILD_WORK_LIMIT) { - return [...views] - } - const owners = settledViewOwnersOfLiveWork(views) - const running = views.filter((view) => viewRuns(view, owners)) - const room = Math.max(0, STRUCTURED_STRIP_CHILD_WORK_LIMIT - running.length) - const newestFinished = new Set( - views - .filter((view) => !viewRuns(view, owners)) - .sort((a, b) => (b.settledAt ?? b.observedAt) - (a.settledAt ?? a.observedAt)) - .slice(0, room) - .map((view) => view.id) + return views.filter((view) => + agentChildWorkIsRunning({ + settled: view.membership === 'settled', + ownsLiveWork: owners.has(view.id) + }) ) - const selected = views.filter((view) => viewRuns(view, owners) || newestFinished.has(view.id)) - // An owner the budget cut leaves its child to the main agent, as a view whose owner is not in - // the projection reads; otherwise the child would render under a row that is not there. - const kept = new Set(selected.map((view) => view.id)) - return selected.map((view) => { - if (view.parentChildWorkId === undefined || kept.has(view.parentChildWorkId)) { - return view - } - const { parentChildWorkId: _cut, ...toMainAgent } = view - return toMainAgent - }) } diff --git a/src/shared/agent-status-child-work-evidence.ts b/src/shared/agent-status-child-work-evidence.ts index 0fa3c1ba5737..f05be66c3d00 100644 --- a/src/shared/agent-status-child-work-evidence.ts +++ b/src/shared/agent-status-child-work-evidence.ts @@ -77,14 +77,8 @@ export type AgentChildWorkEndedEvidence = { /** 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, and one - * that settled after `actedAt` (when the user wrote that send, on the host clock) stays until - * the user's next send after it. No `actedAt` retires every settled child (a rewind). */ -export type AgentChildWorkTurnStartedEvidence = { - type: 'turn-started' - observedAt: number - actedAt?: number -} + * 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 until the user's next turn or the diff --git a/src/shared/agent-status-child-work-reconciliation.test.ts b/src/shared/agent-status-child-work-reconciliation.test.ts index 34bc4c2e6316..818f809638ad 100644 --- a/src/shared/agent-status-child-work-reconciliation.test.ts +++ b/src/shared/agent-status-child-work-reconciliation.test.ts @@ -9,7 +9,7 @@ import { reconcileAgentChildWorkEvidence } from './agent-status-child-work-recon 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 { structuredSidebarChildWork, structuredStripChildWork } from './agent-child-work-listing' +import { structuredRunningChildWork } from './agent-child-work-listing' import { makeStructuredAgentStatusSubject } from './agent-status-subject' const parent = makeStructuredAgentStatusSubject( @@ -493,18 +493,14 @@ describe('structured child-work reconciliation', () => { all.flatMap((record) => store.getAliasesForChild(record.childWorkId)) ) return { - sidebar: structuredSidebarChildWork(views).map((view) => view.description), - strip: structuredStripChildWork(views).map((view) => view.description) + running: structuredRunningChildWork(views).map((view) => view.description) } } - expect(surfaces().sidebar).toEqual(['Task top', 'Task nested', 'Task shell']) + 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({ - sidebar: ['Task top', 'Task nested', 'Task shell'], - strip: ['Task top', 'Task nested', 'Task shell'] - }) + 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", () => { @@ -546,25 +542,4 @@ describe('structured child-work reconciliation', () => { // Once the child reported its ending, a later acknowledged Stop changes nothing either. expect(apply(ended('same', 'failed', 103, stopped))).toMatchObject({ settled: 0 }) }) - - it('at the next turn keeps a child that finished after the user wrote that send', () => { - const { store, apply } = harness() - const ended = (id: string, observedAt: number): AgentChildWorkEvidence => ({ - type: 'ended', - observedAt, - handle: { idKind: 'task_id', id }, - outcome: 'succeeded' - }) - apply(live(child('before')), live(child('after'))) - apply(ended('before', 110), ended('after', 130)) - expect(apply({ type: 'turn-started', observedAt: 140, actedAt: 120 })).toMatchObject({ - removed: 1 - }) - expect(records(store).map((record) => record.description)).toEqual(['Task after']) - // The user's next send, written after it finished, retires it. - expect(apply({ type: 'turn-started', observedAt: 160, actedAt: 150 })).toMatchObject({ - removed: 1 - }) - expect(records(store)).toEqual([]) - }) }) diff --git a/src/shared/agent-status-child-work-reconciliation.ts b/src/shared/agent-status-child-work-reconciliation.ts index 6ee72d6e9d53..c37b5b7422ec 100644 --- a/src/shared/agent-status-child-work-reconciliation.ts +++ b/src/shared/agent-status-child-work-reconciliation.ts @@ -3,9 +3,8 @@ // 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 user's next turn (the -// next send the provider accepts, if they finished before the user wrote it) 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. +// 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' @@ -121,10 +120,9 @@ function applyRemoved(ctx: ReconcileContext, edge: AgentChildWorkRemovedEvidence removeChildren(ctx, [existing.childWorkId]) } -/** Settled children the user has seen finish before `actedAt`, 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. Both clocks are the host's: admission stamps `settledAt`. */ -function removableSettled(ctx: ReconcileContext, actedAt: number | undefined): string[] { +/** 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) => ({ @@ -134,12 +132,7 @@ function removableSettled(ctx: ReconcileContext, actedAt: number | undefined): s })) ) return owned - .filter( - (record) => - record.membership === 'settled' && - !owners.has(record.childWorkId) && - (actedAt === undefined || (record.settledAt ?? record.observedAt) <= actedAt) - ) + .filter((record) => record.membership === 'settled' && !owners.has(record.childWorkId)) .map((record) => record.childWorkId) } @@ -165,7 +158,7 @@ export function reconcileAgentChildWorkEvidence( } else if (edge.type === 'removed') { applyRemoved(ctx, edge) } else if (edge.type === 'turn-started') { - removeChildren(ctx, removableSettled(ctx, edge.actedAt)) + removeChildren(ctx, removableSettled(ctx)) } else { settleLive(ctx, edge.observedAt) } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 74705a79c0f3..4990a4530c36 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -244,10 +244,8 @@ export const AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY = // stop capability above, which a client can advertise while predating this. export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY = 'agent-session.background-task-row-stop.v1' as const -// Why: the strip's roster now carries the host's child records, and keeps a finished child listed -// until the user's next turn. A reader that predates it reads any roster as live work (it -// animates the monitoring indicator and blocks conversation commands), so it never receives one -// whose rows are all settled. +// 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 // Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an From 4430535e2dc3df2c53790c2bd83979cef75d8ae6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:07:19 -0700 Subject: [PATCH 34/34] fix(native-chat): the strip shows running work only from any host, and hides after a released session's last child - A new app paired with an older host no longer shows that host's finished task rows: the strip lists running work only, whatever host sent it, and hides when an older host's roster has only finished rows left. - A test for the path that hides the strip when a session's last running child settles after the provider let go of the session (Claude's release path): the channel sends `null` though no provider answers for the session any more. - A test comment still described the strip keeping finished children. --- ...nt-session-background-task-channel.test.ts | 28 +++++++++++++++++-- ...agent-session-status-feed-children.test.ts | 2 +- ...ured-session-background-tasks-view.test.ts | 13 +++++++++ ...tructured-session-background-tasks-view.ts | 11 ++++++-- .../worktree-sidebar-running-children.test.ts | 13 ++++++--- 5 files changed, 57 insertions(+), 10 deletions(-) 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 index 129dbb641676..11505b6c2045 100644 --- 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 @@ -23,7 +23,12 @@ const child: AgentChildWorkView = { invocation: { invocationId: 'spawn-1', generation: 1 } } -function channelOver(children: () => AgentChildWorkView[] = () => [child]) { +type Stops = { supportsTaskStop: boolean; supportsStopAll: boolean } | undefined + +function channelOver( + children: () => AgentChildWorkView[] = () => [child], + stops: () => Stops = () => ({ supportsTaskStop: true, supportsStopAll: true }) +) { const sessions = new StructuredAgentSessionConversations({ deliver: () => {}, onDeliveryError: () => {}, @@ -34,7 +39,7 @@ function channelOver(children: () => AgentChildWorkView[] = () => [child]) { // 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: () => ({ supportsTaskStop: true, supportsStopAll: true }) } + adapter: { backgroundTaskStops: stops } } as unknown as StructuredAgentSessionHostDeps, sessions, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: publish sends through `backgroundTasks` only. @@ -87,4 +92,23 @@ describe('the background-task channel', () => { 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-status-feed-children.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts index 545c94231fd7..c4e8100c30ae 100644 --- 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 @@ -187,7 +187,7 @@ describe('structured status summary child records', () => { }) }) - // The sidebar lists running children only; a finished one stays in the chat's strip. + // 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({ 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 index 8b5f814c31e2..f814f89e2fdd 100644 --- 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 @@ -53,6 +53,19 @@ describe('structuredSessionBackgroundTasksView', () => { 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({ 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 3563a386a043..f9782d9beb17 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 @@ -41,12 +41,17 @@ export function structuredSessionBackgroundTasksView( // 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. - const liveWork = children ? agentChildWorkLiveness(children) !== null : monitoring + // 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, + show, isMonitoring: turnId === null && liveWork, tasks: backgroundTasks?.tasks ?? NO_TASKS, - settledTasks: backgroundTasks?.settledTasks ?? 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 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 index 9eaba1066bdc..6bf9fa0a969a 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-running-children.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-running-children.test.ts @@ -1,5 +1,5 @@ // 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 reaches the strip as it always did. +// source they read them from, an older host's own task roster included. import { describe, expect, it } from 'vitest' import { @@ -14,6 +14,7 @@ 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 @@ -108,7 +109,7 @@ describe('the worktree sidebar lists running children only', () => { expect(sidebarNames(parent({ subagents: [] }))).toEqual([]) }) - it("applies the one rule to a finished child from an older host's task roster, which that host's strip still lists", () => { + 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 } ] @@ -119,7 +120,11 @@ describe('the worktree sidebar lists running children only', () => { .filter((row) => agentChildWorkIsRunning({ settled: row.settled, ownsLiveWork: false })) .map((row) => row.name) ).toEqual(['Live task']) - const [agents] = buildBackgroundTaskGroups(tasks, settledTasks) - expect(agents.tasks.map((entry) => entry.row.name).sort()).toEqual(['Done task', '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']) }) })