From 72befaf360e7303b9948112d4e83167eec2cb34b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:44:10 -0700 Subject: [PATCH 01/14] feat(native-chat): show Claude subagent activity on the shared carrier (#18806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): show Claude subagent activity on the shared carrier Claude's `message:system:task_*` frames are classified `status-chrome` and reach the transcript as nothing at all, so a turn that spawns subagents renders as an idle turn. The journal translator now reads them into the shared subagent-group carrier — no new UI, and the frames stay `status-chrome` so nothing prints a raw opcode row. `local_agent`, `local_workflow` and `local_bash` tasks share that channel and all carry a `tool_use_id`, so `task_type` is the discriminator and a backgrounded `sleep 20` stays out of the roster; `subagent_type` covers releases that predate `task_type`. `skip_transcript` tasks never render, `is_backgrounded` children survive the turn-end sweep, and a resumed task re-announced under a fresh tool id is aliased onto its `task_id` rather than duplicated. A child still reported as working when the turn — or the session — ends becomes `unverifiable`: contact was lost, which is not evidence it exited. * fix(native-chat): stop the Claude subagent roster dropping its own rows The roster published under the same coalescing key it appends the row with, and the sink queue replaces any queued operation sharing a key regardless of kind: once a write was in flight, each new append evicted the pending publish and the next publish evicted that append, so the body never reached the journal and `lastSerialized` had already moved past it. Publish now takes the sink's own slot, as the Codex streams do. A tombstoned row could never come back: the non-batch item-row builder derived its revision from `items` alone, so a re-add was built at revision 1 against a tombstone at 2 and the reducer discarded it forever. It now takes the same `max(items, tombstones)` the batch builder already used — reachable here because an announcement that reveals a `local_bash` task empties and tombstones the group row that a genuine subagent later in the turn reuses. `settleTurn` swept whatever group the key named at the time it ran, so children rostered before any turn key existed were never swept, and a turn whose result never arrives was left working forever. The ending turn's key is now an argument, a superseding turn start settles the turn it replaces, and every turn end also sweeps the outside-turn group. Teardown without an `ended` event, and eviction past the group bound, both lose contact instead of stranding a row at `working`. Label ordinals are a high-water mark now: releasing one on a re-label handed the next child an ordinal that was already on screen. * fix(native-chat): bound subagent-group blocks on every wire that carries one Adding a fifth arm to `NativeChatBlock` made every consumer that assumed four wrong. Two of them ended in `return block`, so they compiled while handing a roster straight through: the mobile RPC sanitizer shipped it unclipped past both mobile char caps, and the legacy transcript import stored an untrusted roster unbounded. Both now clip each label and cap the entry count the way they bound their other blocks. The remaining three sites did not compile at all. The worker transcript payload and the live-session benchmark get real arms rather than casts — a cast would have turned the transcript one into a third silent passthrough inside the wire byte budget — and the CLI worker output renders a roster with its shared summary instead of `[image omitted]`. The mobile sanitizer moves to a sibling module beside the image-block one: the file sat exactly on the max-lines bound, and the block bounds are a self-contained concern with their own caps. Also caps the roster's `subagent_type` label fallback, which reached the journal uncapped, and covers the new block type in the schema audit. * test(native-chat): cover the capped subagent_type label The roster stores the frame's label verbatim, so the cap on the `subagent_type` fallback is the only thing bounding it. * fix(native-chat): type the roster fixture so the suite typechecks The mobile-cap test built its entries with an inferred `state: string`, which is not a `NativeChatSubagentState` — the only typecheck failure on the branch. * fix(native-chat): stop child traffic rostering an id Claude never announced `observeChildActivity` minted a provisional row for any `parent_tool_use_id` outside the excluded set. An id that was never announced is never excluded, so a nested Task, a workflow child, or a grandchild parented to a tool id inside the sidechain each produced a permanently unlabelled `subagent` row that could only ever end `unverifiable`. The bounded exclusion set cannot cover an id no frame ever declared, and in a long session it can forget a genuine exclusion. Track instead whether this CLI announces tasks at all — set by ANY `task_started`, including one the subagent filter rejects. Once it has, an undeclared child is provably not a new subagent, so no row is created. The provisional path now serves only releases that announce no task frames, which is what its comment already said it was for. The label-ordinal test moves to an announcement-driven removal, the scenario that path now actually reaches; it still fails if `remove` releases the ordinal. * fix(native-chat): outrank the tombstone when building one too `buildJournalTombstoneRow` still built its revision from `items` alone, leaving it asymmetric with the item builder. It is correct today only because `upsertItem` clears the tombstone whenever a re-add wins — an invariant that lives in the reducer and was not pinned. Apply the same `Math.max`, and pin the invariant so the reducer cannot drop it silently. * fix(native-chat): stop an unrelated turn end settling an outside-turn child `settleTurn` swept the `outside-turn` group on every turn end, so a child Claude announced while no turn was live — a frame trailing the previous turn's result, or one that arrives before the first turn starts — was marked `unverifiable` by the next, unrelated turn ending. That state is terminal and latches, so the `task_updated: completed` that followed was discarded: loss of contact was recorded as the child's outcome on evidence that was never about that child. A turn end now sweeps exactly the group its key names. `outside-turn` belongs to no turn, so only an end with no key of its own reaches it, and what no turn end reaches `settleSession` does — reliably, since teardown without an `ended` event also routes through it. The cost is a child outside every turn showing `working` a little longer; the alternative prints a wrong outcome that nothing can revise. Also pins that a subagent announced after a task the filter rejected still rosters: the announcement path was never what the child-traffic gate closes. * fix(native-chat): bound a subagent entry's id, not just its label Every site that bounds a `subagent-group` block clipped the label and handed the id through whole. From the Claude producer the id is bounded upstream, but the legacy transcript import reads an untrusted file, so an oversized id survived into the journal and then out to every wire that replays it — 64 entries of it, since only the entry count was capped. Each site now clips the id with the helper it already uses for its other bounded fields: the journal's inline-text bound on import, the transcript payload's metadata clip, and the mobile char cap (renamed, since it is no longer a label-only cap). * fix(native-chat): surface an adverse subagent outcome in the fallback sentence The roster row's plain-text stand-in counted only `working`, so a fan-out whose children all latched `unverifiable` (or `failed`, or `stopped`) rendered as "Ran 3 subagents" — a completion claim. Mobile and paired web have no roster renderer, so that write-time-frozen sentence is the entire row there, and collapsing `unverifiable` into something that reads like success is exactly what the SSH execution boundary forbids. It now appends the worst adverse count, worst-first across failed/stopped/ unverifiable, and shows it even while siblings still work — matching the Codex lane's shared `subagentGroupFallbackText` verbatim so collapsing the two copies later is a deletion, not a behaviour change. Also bounds the provisional entry id. `observeChildActivity` wrote the `parent_tool_use_id` straight into the entry's durable id with no length cap, while the announced path already rejects an over-long id via `claudeTaskId`. Both now share `isBoundedClaudeTaskId`, and the provisional path rejects rather than truncates, as the announced one does. * fix(native-chat): stop a subagent label ordinal and a clipped roster key colliding - claimLabel probes the labels the group actually rendered instead of a per-base counter, so a generated `Audit 2` cannot duplicate a provider's own `Audit 2`. - Bound `NativeChatSubagentEntry.id` with a head plus a digest of the whole id at every site that bounds it. The id is the roster key: a prefix clip merged two distinct children onto one entry. - Correct a stale journal-reducer test comment: tombstone cleanup is a map-state invariant, no longer load-bearing for revision ordering. * fix(claude): merge duplicate unhandled-provider-frame imports * fix(claude): preserve subagent lifecycle and bounded invocation identity --------- Co-authored-by: Merge Sim --- .../native-chat-live-session-benchmark.ts | 2 +- .../claude/claude-background-task-tracker.ts | 32 +- .../claude-structured-item-translation.ts | 12 + ...ured-journal-translation-subagents.test.ts | 259 ++++++++ .../claude-structured-journal-translation.ts | 56 +- .../claude-structured-provider-fallback.ts | 38 +- .../claude/claude-subagent-group-row.test.ts | 57 ++ src/main/claude/claude-subagent-group-row.ts | 32 + .../claude/claude-subagent-id-aliases.test.ts | 60 ++ src/main/claude/claude-subagent-id-aliases.ts | 63 ++ .../claude/claude-subagent-roster-state.ts | 75 +++ .../claude/claude-subagent-roster.test.ts | 602 ++++++++++++++++++ src/main/claude/claude-subagent-roster.ts | 388 +++++++++++ .../claude-subagent-task-frames.test.ts | 201 ++++++ .../claude/claude-subagent-task-frames.ts | 123 ++++ .../journal-legacy-import.test.ts | 80 +++ .../journal-legacy-import.ts | 15 + .../journal-reducer.test.ts | 52 +- .../journal-row-builders.ts | 18 +- .../subagent-entry-id-bounds.test.ts | 26 + .../native-chat/subagent-entry-id-bounds.ts | 27 + .../worker-transcript-payload.test.ts | 30 + .../worker-transcript-payload.ts | 15 +- .../native-chat-rpc-block-sanitize.test.ts | 35 + .../methods/native-chat-rpc-block-sanitize.ts | 5 +- .../runtime/rpc/methods/native-chat.test.ts | 35 +- .../agent-session-journal-schemas.test.ts | 45 +- 27 files changed, 2333 insertions(+), 50 deletions(-) create mode 100644 src/main/claude/claude-structured-journal-translation-subagents.test.ts create mode 100644 src/main/claude/claude-subagent-group-row.test.ts create mode 100644 src/main/claude/claude-subagent-group-row.ts create mode 100644 src/main/claude/claude-subagent-id-aliases.test.ts create mode 100644 src/main/claude/claude-subagent-id-aliases.ts create mode 100644 src/main/claude/claude-subagent-roster-state.ts create mode 100644 src/main/claude/claude-subagent-roster.test.ts create mode 100644 src/main/claude/claude-subagent-roster.ts create mode 100644 src/main/claude/claude-subagent-task-frames.test.ts create mode 100644 src/main/claude/claude-subagent-task-frames.ts create mode 100644 src/main/native-chat/subagent-entry-id-bounds.test.ts create mode 100644 src/main/native-chat/subagent-entry-id-bounds.ts create mode 100644 src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts diff --git a/config/scripts/native-chat-live-session-benchmark.ts b/config/scripts/native-chat-live-session-benchmark.ts index c21ae759a7f..5983dc2f4e7 100644 --- a/config/scripts/native-chat-live-session-benchmark.ts +++ b/config/scripts/native-chat-live-session-benchmark.ts @@ -117,7 +117,7 @@ function blockContent(message: NativeChatMessage): string { if (block.type === 'tool-result') { return block.output } - return block.path ?? block.url ?? block.alt ?? '' + return block.type === 'image-ref' ? (block.path ?? block.url ?? block.alt ?? '') : block.groupId } function messageWeight(message: NativeChatMessage, content: string): number { diff --git a/src/main/claude/claude-background-task-tracker.ts b/src/main/claude/claude-background-task-tracker.ts index a1504a65dec..de24b9a4fba 100644 --- a/src/main/claude/claude-background-task-tracker.ts +++ b/src/main/claude/claude-background-task-tracker.ts @@ -20,14 +20,22 @@ function record(value: unknown): Record | null { return typeof value === 'object' && value !== null ? (value as Record) : null } -function taskId(message: Record): string | null { - const value = message.task_id - return typeof value === 'string' && value.length > 0 && value.length <= MAX_TASK_ID_LENGTH - ? value - : null +/** The bound every task id shares, wherever it enters. An id the roster stores + * becomes a durable entry key, so a provisional one takes the same bound the + * announced path applies — an over-long id is rejected, never truncated. */ +export function isBoundedClaudeTaskId(value: string): boolean { + return value.length > 0 && value.length <= MAX_TASK_ID_LENGTH } -function taskDescription(value: unknown): string | undefined { +/** The task's canonical, resume-stable id. Shared with the subagent roster so + * both readers of this channel agree on what identifies a task. */ +export function claudeTaskId(message: Record): string | null { + const value = message.task_id + return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : null +} + +/** A task's human label, collapsed and bounded. */ +export function claudeTaskDescription(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined } @@ -107,7 +115,7 @@ export class ClaudeBackgroundTaskTracker { this.replaceAggregateRoster(message.tasks) return true } - const id = taskId(message) + const id = claudeTaskId(message) if (!id) { return false } @@ -126,13 +134,13 @@ export class ClaudeBackgroundTaskTracker { } const existing = this.tasks.get(id) if ( - (patch.is_backgrounded === true || taskDescription(patch.description)) && + (patch.is_backgrounded === true || claudeTaskDescription(patch.description)) && (!this.aggregateRosterObserved || existing) ) { this.upsert(id, { backgrounded: patch.is_backgrounded === true || existing?.backgrounded === true, kind: existing?.kind ?? 'unknown', - description: taskDescription(patch.description) ?? existing?.description + description: claudeTaskDescription(patch.description) ?? existing?.description }) return true } @@ -152,7 +160,7 @@ export class ClaudeBackgroundTaskTracker { this.upsert(id, { backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor', kind, - description: taskDescription(message.description) + description: claudeTaskDescription(message.description) }) return true } @@ -172,14 +180,14 @@ export class ClaudeBackgroundTaskTracker { if (!task || task.ambient === true) { continue } - const id = taskId(task) + const id = claudeTaskId(task) if (!id) { continue } this.tasks.set(id, { backgrounded: true, kind: classifyClaudeBackgroundTaskKind(task.task_type), - description: taskDescription(task.description) + description: claudeTaskDescription(task.description) }) } } diff --git a/src/main/claude/claude-structured-item-translation.ts b/src/main/claude/claude-structured-item-translation.ts index c0ce20908d8..1c1673b59cd 100644 --- a/src/main/claude/claude-structured-item-translation.ts +++ b/src/main/claude/claude-structured-item-translation.ts @@ -74,6 +74,18 @@ export function claudeMessageIdentity( return { provider: 'claude', sessionId: envelope.sessionId, uuid: envelope.uuid } } +/** User bubbles belong to the submitted message; SDK user frames carry echoes + * and tool results, so a user envelope keeps only its tool results. */ +export function claudeOutputEnvelope(envelope: ClaudeMessageEnvelope): ClaudeMessageEnvelope { + if (envelope.role !== 'user') { + return envelope + } + return { + ...envelope, + content: envelope.content.filter((part) => claudeRecord(part)?.type === 'tool_result') + } +} + function messageBlocks(envelope: ClaudeMessageEnvelope): NativeChatBlock[] { const blocks: NativeChatBlock[] = [] for (const value of envelope.content) { diff --git a/src/main/claude/claude-structured-journal-translation-subagents.test.ts b/src/main/claude/claude-structured-journal-translation-subagents.test.ts new file mode 100644 index 00000000000..3b280020c31 --- /dev/null +++ b/src/main/claude/claude-structured-journal-translation-subagents.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { + NativeChatSubagentEntry, + NativeChatSubagentGroupBlock +} from '../../shared/native-chat-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const GROUP_ITEM_ID = 'claude-subagents:claude-session:user-1' + +/** The union's other arms carry no client message id, so reading one narrows. */ +function orcaClientMessageId(identity: AgentJournalItemIdentity): string | null { + return identity.provider === 'orca' ? identity.clientMessageId : null +} + +function harness() { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: vi.fn(), + publish: vi.fn() + } + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) + const groupRows = () => + items.filter((item) => orcaClientMessageId(item.identity) === GROUP_ITEM_ID) + const agentsOf = (body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] => { + if (!body || body.kind !== 'message') { + return [] + } + const block = body.blocks.find( + (candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group' + ) + return block ? block.agents : [] + } + /** The last roster row written for one group, so a test can read a group that + * is no longer the live one. */ + const rosterIn = (groupId: string): NativeChatSubagentEntry[] => + agentsOf( + items.findLast((item) => orcaClientMessageId(item.identity) === `claude-subagents:${groupId}`) + ?.body + ) + const rosterOf = (turnUuid: string): NativeChatSubagentEntry[] => + rosterIn(`claude-session:${turnUuid}`) + const roster = (): NativeChatSubagentEntry[] => agentsOf(groupRows().at(-1)?.body) + const fallbackRows = (): AgentJournalItemBody[] => + items + .filter((item) => (orcaClientMessageId(item.identity) ?? '').startsWith('provider-frame:')) + .map((item) => item.body) + return { translator, groupRows, roster, rosterIn, rosterOf, fallbackRows } +} + +function userTurn(uuid: string) { + return { + type: 'message' as const, + sessionId: 'orca-session', + startsTurn: true as const, + message: { + type: 'user', + uuid, + session_id: 'claude-session', + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: 'go' }] } + } + } +} + +function systemFrame(subtype: string, fields: Record) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { type: 'system', subtype, session_id: 'claude-session', ...fields } + } +} + +function spawnResult(uuid: string, toolUseId: string) { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'user', + uuid, + session_id: 'claude-session', + parent_tool_use_id: null, + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'done' }] + } + } + } +} + +function resultFrame() { + return { + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'result', + subtype: 'success', + session_id: 'claude-session', + uuid: 'result-1', + result: 'ok' + } + } +} + +describe('claude journal translation — subagents', () => { + it('rosters a spawned subagent and settles it on the spawn call result', () => { + const { translator, roster, fallbackRows } = harness() + translator.handle(userTurn('user-1')) + translator.handle( + systemFrame('task_started', { + task_id: 'task-1', + tool_use_id: 'toolu_1', + task_type: 'local_agent', + subagent_type: 'explorer', + description: 'Map the lane' + }) + ) + expect(roster()).toEqual([ + expect.objectContaining({ id: 'task-1', label: 'Map the lane', state: 'working' }) + ]) + // The task frames stay status-chrome, so none of them prints an opcode row. + expect(fallbackRows()).toEqual([]) + translator.handle(spawnResult('user-2', 'toolu_1')) + expect(roster()).toEqual([expect.objectContaining({ state: 'completed' })]) + }) + + it('marks a child still working at turn end unverifiable', () => { + const { translator, roster } = harness() + translator.handle(userTurn('user-1')) + translator.handle( + systemFrame('task_started', { + task_id: 'task-1', + task_type: 'local_agent', + description: 'Map the lane' + }) + ) + translator.handle(resultFrame()) + expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it('leaves a backgrounded child running past the end of its turn', () => { + const { translator, roster } = harness() + translator.handle(userTurn('user-1')) + translator.handle( + systemFrame('task_started', { + task_id: 'task-1', + tool_use_id: 'toolu_1', + task_type: 'local_agent', + description: 'Watch the build', + is_backgrounded: true + }) + ) + // A backgrounded spawn returns its tool result immediately; the child runs on. + translator.handle(spawnResult('user-2', 'toolu_1')) + translator.handle(resultFrame()) + expect(roster()).toEqual([expect.objectContaining({ state: 'working' })]) + translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) + expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it('keeps a backgrounded shell task out of the roster entirely', () => { + const { translator, groupRows } = harness() + translator.handle(userTurn('user-1')) + translator.handle( + systemFrame('task_started', { + task_id: 'task-bash', + tool_use_id: 'toolu_bash', + task_type: 'local_bash', + description: 'sleep 20', + is_backgrounded: true + }) + ) + translator.handle(resultFrame()) + expect(groupRows()).toEqual([]) + }) + + it('shows a subagent whose release announces no task frames, from its child traffic', () => { + const { translator, roster } = harness() + translator.handle(userTurn('user-1')) + translator.handle({ + type: 'message' as const, + sessionId: 'orca-session', + message: { + type: 'assistant', + uuid: 'child-1', + session_id: 'claude-session', + parent_tool_use_id: 'toolu_1', + message: { role: 'assistant', content: [{ type: 'text', text: 'looking' }] } + } + }) + expect(roster()).toEqual([ + expect.objectContaining({ id: 'toolu_1', label: 'subagent', state: 'working' }) + ]) + }) + + it('settles the turn a new turn superseded, and leaves the new one running', () => { + const { translator, rosterOf } = harness() + translator.handle(userTurn('user-1')) + translator.handle( + systemFrame('task_started', { + task_id: 'task-1', + task_type: 'local_agent', + description: 'First turn' + }) + ) + // A second turn starts with no result frame for the first: the first turn + // ends here, and nothing else will ever name its group again. + translator.handle(userTurn('user-2')) + translator.handle( + systemFrame('task_started', { + task_id: 'task-2', + task_type: 'local_agent', + description: 'Second turn' + }) + ) + expect(rosterOf('user-1')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + expect(rosterOf('user-2')).toEqual([expect.objectContaining({ state: 'working' })]) + }) + + it('does not let an unrelated turn end settle a child announced outside a turn', () => { + const { translator, rosterIn } = harness() + // No turn is live yet, so this child has no turn key to belong to. + translator.handle( + systemFrame('task_started', { + task_id: 'task-early', + task_type: 'local_agent', + description: 'Before the turn' + }) + ) + translator.handle(userTurn('user-1')) + translator.handle(resultFrame()) + expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })]) + // The outcome still lands, which a latched `unverifiable` would have lost. + translator.handle( + systemFrame('task_updated', { task_id: 'task-early', patch: { status: 'completed' } }) + ) + expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })]) + }) + + it('settles a child left outside every turn when the session ends', () => { + const { translator, rosterIn } = harness() + translator.handle( + systemFrame('task_started', { + task_id: 'task-early', + task_type: 'local_agent', + description: 'Before the turn' + }) + ) + translator.handle(userTurn('user-1')) + translator.handle(resultFrame()) + translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' }) + expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) +}) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index df8e8e67f53..759c2926763 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -11,9 +11,8 @@ import { claudeMessageBody, claudeMessageIdentity, claudeHasReplayContent, - claudeRecord, + claudeOutputEnvelope, claudeStreamingMessageBody, - claudeText, claudeThinkingIdentity, claudeThinkingText, claudeToolBody, @@ -29,16 +28,15 @@ import { claudeQuestionItems } from './claude-structured-prompt-items' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import { readableProviderFrameText } from '../native-chat/agent-session-wire/unhandled-provider-frame' import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity' import { - CLAUDE_UNRENDERABLE_CONTENT_TEXT, + appendUnmodeledClaudeContent, claudeProviderFrameKind, claudeResultFailure, createClaudeProviderFrameFallback, - isModeledClaudeContent, isSettledClaudeResultKind } from './claude-structured-provider-fallback' +import { ClaudeSubagentRoster } from './claude-subagent-roster' import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity' import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints' @@ -89,10 +87,16 @@ export function createClaudeJournalTranslator( const promptItems = new Map() const streamedBlocks = createClaudeStreamedBlockRegistry() let currentTurn: { sessionId: string; turnId: string } | null = null + const groupKeyOf = (turn: { sessionId: string; turnId: string } | null): string | null => + turn ? `${turn.sessionId}:${turn.turnId}` : null const providerFallback = createClaudeProviderFrameFallback( deps.sink, deps.fallbackIdPrefix ?? 'acquisition' ) + const subagents = new ClaudeSubagentRoster({ + sink: deps.sink, + currentGroupKey: () => groupKeyOf(currentTurn) + }) const streamedText = createClaudeStreamedTextCheckpoints({ ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }), ...(deps.schedule ? { schedule: deps.schedule } : {}), @@ -144,14 +148,10 @@ export function createClaudeJournalTranslator( return false } let changed = false - // User bubbles belong to the submitted message; SDK user frames carry echoes and tool results. - const outputEnvelope = - envelope.role === 'user' - ? { - ...envelope, - content: envelope.content.filter((part) => claudeRecord(part)?.type === 'tool_result') - } - : envelope + if (envelope.parentToolUseId) { + subagents.observeChildActivity(envelope.parentToolUseId) + } + const outputEnvelope = claudeOutputEnvelope(envelope) const body = claudeMessageBody(outputEnvelope) // The final frame of a streamed block lands on the block's identity, not its own uuid. const identity = @@ -180,6 +180,8 @@ export function createClaudeJournalTranslator( claudeToolIdentity(envelope.sessionId, result.toolUseId), claudeToolBody({ tool, result }) ) + // A spawn call's result is the parent turn's evidence its child finished. + subagents.observeToolResult(result.toolUseId, result.failed) // Tool inputs are only needed until their matching result arrives. tools.delete(result.toolUseId) changed = true @@ -192,21 +194,7 @@ export function createClaudeJournalTranslator( }) changed = true } - const unhandledContent = outputEnvelope.content.filter((part) => !isModeledClaudeContent(part)) - for (const part of unhandledContent) { - const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown' - providerFallback.append( - `message:${envelope.role}:content:${partType}`, - part, - readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT - ) - changed = true - } - // An empty user frame is a replay with nothing to show, not an unknown kind. - if (envelope.content.length === 0 && envelope.role === 'assistant') { - providerFallback.append(`message:${envelope.role}:empty`, message) - changed = true - } + changed = appendUnmodeledClaudeContent(providerFallback, outputEnvelope, message) || changed if ( envelope.role === 'user' && startsTurn && @@ -214,6 +202,9 @@ export function createClaudeJournalTranslator( message.parent_tool_use_id === null ) { if (currentTurn) { + // A new turn starting is the only end the previous one gets when its + // result never arrives; settling it later would sweep THIS turn. + subagents.settleTurn(groupKeyOf(currentTurn)) publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) } currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid } @@ -254,6 +245,8 @@ export function createClaudeJournalTranslator( handle: (event) => { if (event.type === 'ended') { streamedText.flush() + // No event will ever settle a child once the provider is gone. + subagents.settleSession() if (currentTurn) { publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) currentTurn = null @@ -274,6 +267,9 @@ export function createClaudeJournalTranslator( promptItems.delete(event.promptKey) deps.sink.publish() } else if (event.type === 'message' && event.message.type === 'result') { + // The turn is over however it ended, so a foreground child still + // reported as working will never be settled by an event. + subagents.settleTurn(groupKeyOf(currentTurn)) if (currentTurn) { publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false) currentTurn = null @@ -291,6 +287,9 @@ export function createClaudeJournalTranslator( providerFallback.append(kind, event.message, failure?.text) } } else if (event.type === 'message') { + // These frames stay `status-chrome`: the roster reads them here, and the + // fallback below still drops the raw frame instead of printing an opcode. + subagents.observeSystemFrame(event.message) const kind = claudeProviderFrameKind(event.message) if (!handleMessage(event.message, event.startsTurn === true)) { providerFallback.append(kind, event.message) @@ -310,6 +309,7 @@ export function createClaudeJournalTranslator( tools.clear() promptItems.clear() streamedBlocks.clear() + subagents.dispose() } } } diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index 2528ac027df..68aec07976b 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -4,8 +4,15 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' import { CLAUDE_STREAM_JSON_FRAME_KINDS } from '../native-chat/agent-session-wire/claude-stream-json-frame-schema' -import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' -import { claudeRecord, claudeText } from './claude-structured-item-translation' +import { + readableProviderFrameText, + unhandledProviderFrameJournalItem +} from '../native-chat/agent-session-wire/unhandled-provider-frame' +import { + claudeRecord, + claudeText, + type ClaudeMessageEnvelope +} from './claude-structured-item-translation' export function claudeProviderFrameKind(message: Record): string { const type = claudeText(message.type) ?? 'unknown' @@ -123,3 +130,30 @@ export function createClaudeProviderFrameFallback( } } } + +export type ClaudeProviderFrameFallback = ReturnType + +/** Journal each content part this build does not model, plus the empty assistant + * frame a replay leaves behind (an empty USER frame is a replay with nothing to + * show, not an unknown kind). Returns whether anything was appended. */ +export function appendUnmodeledClaudeContent( + fallback: ClaudeProviderFrameFallback, + envelope: ClaudeMessageEnvelope, + message: Record +): boolean { + let changed = false + for (const part of envelope.content.filter((part) => !isModeledClaudeContent(part))) { + const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown' + fallback.append( + `message:${envelope.role}:content:${partType}`, + part, + readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT + ) + changed = true + } + if (envelope.content.length === 0 && envelope.role === 'assistant') { + fallback.append(`message:${envelope.role}:empty`, message) + changed = true + } + return changed +} diff --git a/src/main/claude/claude-subagent-group-row.test.ts b/src/main/claude/claude-subagent-group-row.test.ts new file mode 100644 index 00000000000..2d5ac56e22c --- /dev/null +++ b/src/main/claude/claude-subagent-group-row.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' +import { claudeSubagentGroupBody } from './claude-subagent-group-row' + +function entry(id: string, state: NativeChatSubagentEntry['state']): NativeChatSubagentEntry { + return { id, label: id, state, startedAt: 1 } +} + +/** The fallback sentence is the WHOLE row on mobile and paired web, which have + * no roster renderer, so these assertions are the entire contract there. */ +function sentence(agents: readonly NativeChatSubagentEntry[]): string { + const body = claudeSubagentGroupBody('turn-1', agents) + const block = body.kind === 'message' ? body.blocks[0] : undefined + return block && block.type === 'text' ? block.text : '' +} + +describe('claudeSubagentGroupBody fallback sentence', () => { + it('reads as a plain completion when every child completed', () => { + expect(sentence([entry('a', 'completed'), entry('b', 'completed')])).toBe('Ran 2 subagents') + }) + + it('keeps the singular noun for a lone child', () => { + expect(sentence([entry('a', 'completed')])).toBe('Ran 1 subagent') + expect(sentence([entry('a', 'working')])).toBe('Kicked off 1 subagent') + }) + + it('names an unverifiable child instead of claiming the group ran', () => { + expect(sentence([entry('a', 'completed'), entry('b', 'unverifiable')])).toBe( + 'Ran 2 subagents (1 unverifiable)' + ) + }) + + it('ranks the adverse outcome worst-first', () => { + expect( + sentence([entry('a', 'failed'), entry('b', 'unverifiable'), entry('c', 'completed')]) + ).toBe('Ran 3 subagents (1 failed)') + expect(sentence([entry('a', 'stopped'), entry('b', 'unverifiable')])).toBe( + 'Ran 2 subagents (1 stopped)' + ) + }) + + it('shows the adverse outcome while a sibling still works', () => { + expect( + sentence([entry('a', 'working'), entry('b', 'working'), entry('c', 'unverifiable')]) + ).toBe('Kicked off 3 subagents (1 unverifiable)') + }) + + it('leaves a benign settled state out of the sentence', () => { + expect(sentence([entry('a', 'idle'), entry('b', 'completed')])).toBe('Ran 2 subagents') + }) + + it('counts every child holding the worst adverse state', () => { + expect(sentence([entry('a', 'failed'), entry('b', 'failed'), entry('c', 'stopped')])).toBe( + 'Ran 3 subagents (2 failed)' + ) + }) +}) diff --git a/src/main/claude/claude-subagent-group-row.ts b/src/main/claude/claude-subagent-group-row.ts new file mode 100644 index 00000000000..58af6b6b346 --- /dev/null +++ b/src/main/claude/claude-subagent-group-row.ts @@ -0,0 +1,32 @@ +// The journal row one Claude spawn group writes: its durable identity and the +// body it revises in place. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { subagentGroupFallbackText } from '../../shared/native-chat-subagent-summary' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' + +/** Durable journal identity for the group's row — stable across revisions and + * across a restart, so replay finds the same row instead of appending a new one. */ +export function claudeSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `claude-subagents:${groupId}` } +} + +/** The roster row: the structured block plus the plain sentence an older client + * renders in its place. A message whose only block is the new variant would + * reach such a client with nothing it can draw. */ +export function claudeSubagentGroupBody( + groupId: string, + agents: readonly NativeChatSubagentEntry[] +): AgentJournalItemBody { + return { + kind: 'message', + role: 'system', + blocks: [ + { type: 'text', text: subagentGroupFallbackText(agents) }, + { type: 'subagent-group', groupId, agents: [...agents] } + ] + } +} diff --git a/src/main/claude/claude-subagent-id-aliases.test.ts b/src/main/claude/claude-subagent-id-aliases.test.ts new file mode 100644 index 00000000000..bffb549d173 --- /dev/null +++ b/src/main/claude/claude-subagent-id-aliases.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { ClaudeSubagentIds } from './claude-subagent-id-aliases' + +describe('ClaudeSubagentIds', () => { + it('resolves an aliased tool id to its task, and an unaliased id to itself', () => { + const ids = new ClaudeSubagentIds() + ids.alias('toolu_1', 'task-1') + expect(ids.canonical('toolu_1')).toBe('task-1') + expect(ids.canonical('toolu_unknown')).toBe('toolu_unknown') + }) + + it('remembers an exclusion under either of the ids that named it', () => { + const ids = new ClaudeSubagentIds() + ids.exclude('task-bash') + expect(ids.isExcluded('toolu_bash', 'task-bash')).toBe(true) + expect(ids.isExcluded(null, null)).toBe(false) + expect(ids.isExcluded('task-agent')).toBe(false) + }) + + it('drops the oldest alias past the bound and keeps the newest', () => { + const ids = new ClaudeSubagentIds() + for (let index = 0; index <= 512; index += 1) { + ids.alias(`toolu_${index}`, `task-${index}`) + } + // Evicted: the id now stands only for itself. + expect(ids.canonical('toolu_0')).toBe('toolu_0') + expect(ids.canonical('toolu_512')).toBe('task-512') + expect(ids.canonical('toolu_1')).toBe('task-1') + }) + + it('drops the oldest exclusion past the bound and keeps the newest', () => { + const ids = new ClaudeSubagentIds() + for (let index = 0; index <= 512; index += 1) { + ids.exclude(`task-${index}`) + } + expect(ids.isExcluded('task-0')).toBe(false) + expect(ids.isExcluded('task-512')).toBe(true) + expect(ids.isExcluded('task-1')).toBe(true) + }) + + it('does not retain oversized aliases or exclusions', () => { + const ids = new ClaudeSubagentIds() + const oversized = 'x'.repeat(513) + ids.alias(oversized, 'task-1') + ids.alias('tool-1', oversized) + ids.exclude(oversized) + expect(ids.canonical(oversized)).toBe(oversized) + expect(ids.canonical('tool-1')).toBe('tool-1') + expect(ids.isExcluded(oversized)).toBe(false) + }) + + it('forgets everything on clear', () => { + const ids = new ClaudeSubagentIds() + ids.alias('toolu_1', 'task-1') + ids.exclude('task-1') + ids.clear() + expect(ids.canonical('toolu_1')).toBe('toolu_1') + expect(ids.isExcluded('task-1')).toBe(false) + }) +}) diff --git a/src/main/claude/claude-subagent-id-aliases.ts b/src/main/claude/claude-subagent-id-aliases.ts new file mode 100644 index 00000000000..d06bc00bf8d --- /dev/null +++ b/src/main/claude/claude-subagent-id-aliases.ts @@ -0,0 +1,63 @@ +// Which Claude ids name the same subagent, and which name no subagent at all. +// +// Claude re-announces a resumed task under a NEW `tool_use_id` while `task_id` +// stays put, so tool ids are aliases of a canonical task id — a store keyed on +// the tool id would show the child twice after every resume. +// +// The exclusions matter just as much: `task_updated` carries no `task_type` and +// child traffic carries no task metadata at all, so the one announcement that +// said "this is a backgrounded shell, not an agent" has to be remembered or a +// later frame re-admits it. + +import { isBoundedClaudeTaskId } from './claude-background-task-tracker' + +/** Both maps are event-accumulated and nothing prunes them, so both are bounded. */ +const MAX_TOOL_USE_ALIASES = 512 +const MAX_EXCLUDED_IDS = 512 + +export class ClaudeSubagentIds { + private readonly canonicalByToolUse = new Map() + private readonly excluded = new Set() + + /** The task id a tool id stands for, or the id itself when nothing aliases it. */ + canonical(id: string): string { + return this.canonicalByToolUse.get(id) ?? id + } + + alias(toolUseId: string, taskId: string): void { + if (!isBoundedClaudeTaskId(toolUseId) || !isBoundedClaudeTaskId(taskId)) { + return + } + this.canonicalByToolUse.set(toolUseId, taskId) + while (this.canonicalByToolUse.size > MAX_TOOL_USE_ALIASES) { + const oldest = this.canonicalByToolUse.keys().next() + if (oldest.done || oldest.value === toolUseId) { + break + } + this.canonicalByToolUse.delete(oldest.value) + } + } + + exclude(id: string): void { + if (!isBoundedClaudeTaskId(id)) { + return + } + this.excluded.add(id) + while (this.excluded.size > MAX_EXCLUDED_IDS) { + const oldest = this.excluded.values().next() + if (oldest.done || oldest.value === id) { + break + } + this.excluded.delete(oldest.value) + } + } + + isExcluded(...ids: (string | null)[]): boolean { + return ids.some((id) => id !== null && this.excluded.has(id)) + } + + clear(): void { + this.canonicalByToolUse.clear() + this.excluded.clear() + } +} diff --git a/src/main/claude/claude-subagent-roster-state.ts b/src/main/claude/claude-subagent-roster-state.ts new file mode 100644 index 00000000000..2fa8971d856 --- /dev/null +++ b/src/main/claude/claude-subagent-roster-state.ts @@ -0,0 +1,75 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' +import type { ClaudeSubagentTaskFrame } from './claude-subagent-task-frames' + +const MAX_INVOCATIONS_PER_SUBAGENT = 16 + +export type TrackedEntry = { + entry: NativeChatSubagentEntry + /** The only signal separating a child that dies with its turn from one told to + * outlive it. A turn-end sweep must leave a backgrounded child alone. */ + backgrounded: boolean + toolUseId: string | null + invocationIds: Set | null + /** Label before its ordinal suffix, so a later announcement can tell a + * provisional row from one that already carries the provider's own name. */ + labelBase: string +} + +export type RosterGroup = { + groupId: string + identity: AgentJournalItemIdentity + /** Insertion order is the display order; the map holds the state. */ + entries: Map + /** Lifetime admissions bound retained labels even when entries are removed. */ + admittedEntries: number + /** Labels remain reserved after removal or provisional-name replacement. */ + claimedLabels: Set + /** Last body written, so an idempotent replay writes no new revision. */ + lastSerialized: string | null +} + +// Invocation history stays with the entry, independent of the evicting alias cache. +export function applyClaudeSubagentInvocation( + tracked: TrackedEntry, + frame: ClaudeSubagentTaskFrame, + now: () => number +): boolean { + if (tracked.invocationIds === null) { + return false + } + const newInvocation = + frame.announcement && frame.toolUseId !== null && !tracked.invocationIds.has(frame.toolUseId) + if (newInvocation && frame.toolUseId) { + if (tracked.invocationIds.size >= MAX_INVOCATIONS_PER_SUBAGENT) { + tracked.invocationIds = null + tracked.entry = { ...tracked.entry, state: 'unverifiable', settledAt: now() } + return true + } + tracked.invocationIds.add(frame.toolUseId) + if (tracked.toolUseId !== null && tracked.toolUseId !== frame.toolUseId) { + tracked.backgrounded = frame.backgrounded ?? false + tracked.entry = { ...tracked.entry, state: frame.state ?? 'working', settledAt: undefined } + } + tracked.toolUseId = frame.toolUseId + } else if (tracked.toolUseId && frame.toolUseId && tracked.toolUseId !== frame.toolUseId) { + return false + } + if (tracked.toolUseId === null) { + tracked.toolUseId = frame.toolUseId + } + return true +} + +/** Two children can share a description; the ordinal keeps their rows apart + * without inventing a name the provider never sent. The probe is over the + * labels actually rendered, not a per-base counter: a generated `Audit 2` + * must not collide with a provider that names its own child `Audit 2`. */ +export function claimClaudeSubagentLabel(group: RosterGroup, base: string): string { + let candidate = base + for (let ordinal = 2; group.claimedLabels.has(candidate); ordinal++) { + candidate = `${base} ${ordinal}` + } + group.claimedLabels.add(candidate) + return candidate +} diff --git a/src/main/claude/claude-subagent-roster.test.ts b/src/main/claude/claude-subagent-roster.test.ts new file mode 100644 index 00000000000..da1f16d0a0e --- /dev/null +++ b/src/main/claude/claude-subagent-roster.test.ts @@ -0,0 +1,602 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { + NativeChatSubagentEntry, + NativeChatSubagentGroupBlock +} from '../../shared/native-chat-types' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventSink +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { ClaudeSubagentRoster } from './claude-subagent-roster' + +const TURN_1 = 'claude-session:turn-1' + +function agentsOf(body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] { + if (!body || body.kind !== 'message') { + return [] + } + const block = body.blocks.find( + (candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group' + ) + return block ? block.agents : [] +} + +function isGroupRow(identity: AgentJournalItemIdentity, groupId: string): boolean { + return identity.provider === 'orca' && identity.clientMessageId === `claude-subagents:${groupId}` +} + +function harness(groupKey: string | null = TURN_1) { + const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] + const tombstones: AgentJournalItemIdentity[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body) => items.push({ identity, body }), + appendTombstone: (identity) => tombstones.push(identity), + publish: vi.fn() + } + let clock = 1_000 + let key = groupKey + const roster = new ClaudeSubagentRoster({ + sink, + currentGroupKey: () => key, + now: () => (clock += 1) + }) + const roles = (): NativeChatSubagentEntry[] => agentsOf(items.at(-1)?.body) + /** The last row written for one group, so a test can read a row that is no + * longer the newest one. */ + const rolesIn = (groupId: string): NativeChatSubagentEntry[] => + agentsOf(items.findLast((item) => isGroupRow(item.identity, groupId))?.body) + return { + roster, + items, + tombstones, + roles, + rolesIn, + setGroupKey: (next: string | null) => { + key = next + } + } +} + +function system(subtype: string, fields: Record): Record { + return { type: 'system', subtype, session_id: 'claude-session', ...fields } +} + +function started(fields: Record): Record { + return system('task_started', { task_type: 'local_agent', ...fields }) +} + +describe('ClaudeSubagentRoster', () => { + it('builds the row from task_started, with the fallback sentence beside the block', () => { + const { roster, items, roles } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Review the diff' }) + ) + expect(items).toHaveLength(1) + expect(items[0]?.identity).toEqual({ + provider: 'orca', + clientMessageId: 'claude-subagents:claude-session:turn-1' + }) + const body = items[0]?.body + expect(body?.kind === 'message' && body.blocks[0]).toEqual({ + type: 'text', + text: 'Kicked off 1 subagent' + }) + expect(roles()).toEqual([ + expect.objectContaining({ id: 'task-1', label: 'Review the diff', state: 'working' }) + ]) + }) + + it('keeps a backgrounded shell task out of the roster', () => { + const { roster, items } = harness() + roster.observeSystemFrame( + system('task_started', { + task_id: 'task-bash', + tool_use_id: 'toolu_bash', + task_type: 'local_bash', + description: 'sleep 20', + is_backgrounded: true + }) + ) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-bash', patch: { status: 'running' } }) + ) + // Its own frames carry a tool_use_id, so only the excluded-id memory stops it. + roster.observeChildActivity('toolu_bash') + expect(items).toHaveLength(0) + }) + + it('never renders a task marked skip_transcript', () => { + const { roster, items } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-a', tool_use_id: 'toolu_a', skip_transcript: true }) + ) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-a', patch: { status: 'completed' } }) + ) + roster.observeChildActivity('toolu_a') + expect(items).toHaveLength(0) + }) + + it('drops a provisional row once an announcement says the task is not a subagent', () => { + const { roster, items, tombstones, roles } = harness() + roster.observeChildActivity('toolu_bash') + expect(roles()).toHaveLength(1) + roster.observeSystemFrame( + system('task_started', { + task_id: 'task-bash', + tool_use_id: 'toolu_bash', + task_type: 'local_bash' + }) + ) + expect(tombstones).toEqual([ + { provider: 'orca', clientMessageId: 'claude-subagents:claude-session:turn-1' } + ]) + expect(items).toHaveLength(1) + }) + + it('does not duplicate a resumed task re-announced under a new tool_use_id', () => { + const { roster, roles } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'toolu_first', description: 'Audit' }) + ) + roster.observeChildActivity('toolu_first') + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'toolu_second', description: 'Audit' }) + ) + roster.observeChildActivity('toolu_second') + expect(roles()).toEqual([ + expect.objectContaining({ id: 'task-1', label: 'Audit', state: 'working' }) + ]) + }) + + it('adopts a row built from child traffic when the announcement finally names it', () => { + const { roster, roles } = harness() + roster.observeChildActivity('toolu_1') + expect(roles()).toEqual([expect.objectContaining({ id: 'toolu_1', label: 'subagent' })]) + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' }) + ) + expect(roles()).toEqual([ + expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' }) + ]) + }) + + it('is idempotent: a repeated frame writes no new revision', () => { + const { roster, items } = harness() + const frame = started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Audit' }) + roster.observeSystemFrame(frame) + roster.observeSystemFrame(frame) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-1', patch: { status: 'running' } }) + ) + expect(items).toHaveLength(1) + }) + + it('latches a terminal state against a later live report', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-1', patch: { status: 'failed' } }) + ) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-1', patch: { status: 'running' } }) + ) + expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })]) + }) + + it('ignores an update for a task it never rostered', () => { + const { roster, items } = harness() + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-unknown', patch: { status: 'running' } }) + ) + expect(items).toHaveLength(0) + }) + + it('disambiguates children that share a description', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Explore' })) + roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Explore' })) + expect(roles().map((agent) => agent.label)).toEqual(['Explore', 'Explore 2']) + }) + + describe('turn end', () => { + it('leaves a backgrounded child working and marks a foreground one unverifiable', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-fg', description: 'Foreground' })) + roster.observeSystemFrame( + started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true }) + ) + roster.settleTurn(TURN_1) + expect(roles()).toEqual([ + expect.objectContaining({ label: 'Foreground', state: 'unverifiable' }), + expect.objectContaining({ label: 'Background', state: 'working' }) + ]) + }) + + it('never re-settles a child that already reported an outcome', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } }) + ) + roster.settleTurn(TURN_1) + expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })]) + }) + + it('sweeps backgrounded children only when the provider itself is gone', () => { + const { roster, roles } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true }) + ) + roster.settleTurn(TURN_1) + roster.settleSession() + expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + }) + + describe('spawn tool result', () => { + it('settles a foreground child', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' })) + roster.observeToolResult('toolu_1', false) + expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })]) + }) + + it('reports a failed spawn as failed', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' })) + roster.observeToolResult('toolu_1', true) + expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })]) + }) + + it('ignores the immediate result a backgrounded spawn returns', () => { + const { roster, roles } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'toolu_1', is_backgrounded: true }) + ) + roster.observeToolResult('toolu_1', false) + expect(roles()).toEqual([expect.objectContaining({ state: 'working' })]) + }) + + it('ignores results for tools that are not spawn calls', () => { + const { roster, items } = harness() + roster.observeToolResult('toolu_read', false) + expect(items).toHaveLength(0) + }) + }) + + describe('label ordinals', () => { + it('never re-issues an ordinal a removed row gave up', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) + roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' })) + // task-1 is re-announced as a shell task, so its row goes; reclaiming the + // ordinal it held would print a second 'Audit 2' beside the one still shown. + roster.observeSystemFrame( + system('task_started', { task_id: 'task-1', task_type: 'local_bash' }) + ) + roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit' })) + expect(roles().map((agent) => agent.label)).toEqual(['Audit 2', 'Audit 3']) + }) + + it('never generates a label a provider-supplied one already took', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) + roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' })) + // The provider's own name for the third child is the label the ordinal just + // generated for the second; a per-base counter would print it twice. + roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit 2' })) + const labels = roles().map((agent) => agent.label) + expect(labels).toEqual(['Audit', 'Audit 2', 'Audit 2 2']) + expect(new Set(labels).size).toBe(labels.length) + }) + }) + + describe('child traffic for an id the CLI never declared', () => { + it('creates nothing once the CLI has announced any task at all', () => { + const { roster, items } = harness() + // A rejected announcement still proves this CLI declares what it spawns. + roster.observeSystemFrame( + system('task_started', { task_id: 'task-bash', task_type: 'local_bash' }) + ) + roster.observeChildActivity('toolu_never_announced') + expect(items).toHaveLength(0) + }) + + it('rejects an over-long provisional id instead of storing it as an entry id', () => { + const { roster, items } = harness() + // The announced path drops an id past `claudeTaskId`'s bound; the + // provisional one writes the same durable entry id, so it must too. + roster.observeChildActivity(`toolu_${'x'.repeat(512)}`) + expect(items).toHaveLength(0) + roster.observeChildActivity(`toolu_${'x'.repeat(500)}`) + expect(items).toHaveLength(1) + }) + + it('still rosters a subagent announced after a task the filter rejected', () => { + const { roster, roles } = harness() + roster.observeSystemFrame( + system('task_started', { task_id: 'task-bash', task_type: 'local_bash' }) + ) + // The gate closes the child-traffic fallback, never the announcement path. + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' }) + ) + roster.observeChildActivity('toolu_1') + expect(roles()).toEqual([ + expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' }) + ]) + }) + + it('leaves a grandchild parented inside the sidechain out of the roster', () => { + const { roster, roles } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' }) + ) + roster.observeChildActivity('toolu_1') + // A tool the subagent itself ran: never announced, so never excluded either. + roster.observeChildActivity('toolu_inner') + expect(roles()).toEqual([ + expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' }) + ]) + }) + + it('still mints the provisional row for a release that announces no task', () => { + const { roster, roles } = harness() + roster.observeChildActivity('toolu_1') + // Not an announcement: the fallback path stays open for this release. + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-x', patch: { status: 'running' } }) + ) + roster.observeChildActivity('toolu_2') + expect(roles().map((agent) => agent.label)).toEqual(['subagent', 'subagent 2']) + }) + }) + + describe('groups that no later event can reach', () => { + it('loses contact with a group evicted past the bound', () => { + const { roster, rolesIn, setGroupKey } = harness('turn-0') + for (let index = 0; index < 33; index += 1) { + setGroupKey(`turn-${index}`) + roster.observeSystemFrame(started({ task_id: `task-${index}`, description: 'Audit' })) + } + expect(rolesIn('turn-0')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + expect(rolesIn('turn-32')).toEqual([expect.objectContaining({ state: 'working' })]) + }) + + it('loses contact with a live child when the translator is disposed without an end', () => { + const { roster, roles } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true }) + ) + roster.dispose() + expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it('writes nothing on dispose when the session already settled', () => { + const { roster, items } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) + roster.settleSession() + const written = items.length + roster.dispose() + expect(items).toHaveLength(written) + }) + }) + + it('groups children outside any turn under their own row', () => { + const { roster, items } = harness(null) + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' })) + expect(items[0]?.identity).toEqual({ + provider: 'orca', + clientMessageId: 'claude-subagents:outside-turn' + }) + }) +}) + +describe('ClaudeSubagentRoster — the turn that is ending', () => { + it('leaves a child announced outside any turn alone when an unrelated turn ends', () => { + const { roster, rolesIn, setGroupKey } = harness(null) + roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' })) + setGroupKey(TURN_1) + roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' })) + roster.settleTurn(TURN_1) + expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })]) + expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + // `unverifiable` latches, so sweeping it above would have swallowed this. + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-early', patch: { status: 'completed' } }) + ) + expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })]) + }) + + it('sweeps the outside-turn group when a turn with no key of its own ends', () => { + const { roster, rolesIn } = harness(null) + roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' })) + roster.settleTurn(null) + expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it('still settles an outside-turn child once the session itself ends', () => { + const { roster, rolesIn, setGroupKey } = harness(null) + roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' })) + setGroupKey(TURN_1) + roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' })) + roster.settleTurn(TURN_1) + roster.settleSession() + expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it('sweeps the turn that ended, not whichever turn is live now', () => { + const { roster, rolesIn, setGroupKey } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'First turn' })) + setGroupKey('claude-session:turn-2') + roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Second turn' })) + // Turn 1's result lands after turn 2 has already begun. + roster.settleTurn(TURN_1) + expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + expect(rolesIn('claude-session:turn-2')).toEqual([ + expect.objectContaining({ state: 'working' }) + ]) + }) +}) + +describe('ClaudeSubagentRoster — through the real sink queue', () => { + it('lands every revision, not just the one that was already in flight', async () => { + const appended: AgentJournalItemBody[] = [] + let published = 0 + const journal = { + appendItem: async (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { + appended.push(body) + return { cursor: { epoch: 'e', sequence: appended.length } } + }, + appendTombstone: async () => ({ epoch: 'e', sequence: 0 }) + } as unknown as AgentSessionJournal + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind({ + journal, + fence: 1, + publish: () => { + published += 1 + } + }) + const roster = new ClaudeSubagentRoster({ sink: deferred.sink, currentGroupKey: () => TURN_1 }) + + // The first append is in flight while the rest are submitted, so a publish + // sharing the row's coalescing key would evict them. + roster.observeSystemFrame(started({ task_id: 'task-1', description: 'One' })) + roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Two' })) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } }) + ) + const drained = await deferred.drained() + + expect(drained).toEqual({ ok: true }) + expect(agentsOf(appended.at(-1))).toEqual([ + expect.objectContaining({ id: 'task-1', label: 'One', state: 'completed' }), + expect.objectContaining({ id: 'task-2', label: 'Two', state: 'working' }) + ]) + expect(published).toBeGreaterThan(0) + }) +}) + +describe('ClaudeSubagentRoster — authoritative outcomes and retained budgets', () => { + it('accepts a notification after the foreground turn lost contact', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1' })) + roster.settleTurn(TURN_1) + roster.observeSystemFrame( + system('task_notification', { task_id: 'task-1', status: 'completed' }) + ) + expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })]) + }) + + it('settles a background child from its notification without a task_updated', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', is_backgrounded: true })) + roster.settleTurn(TURN_1) + roster.observeSystemFrame(system('task_notification', { task_id: 'task-1', status: 'failed' })) + expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })]) + }) + + it('bounds lifetime admissions when reclassification repeatedly removes entries', () => { + const { roster, items } = harness() + for (let i = 0; i < 100; i++) { + roster.observeSystemFrame(started({ task_id: `task-${i}`, description: `Agent ${i}` })) + roster.observeSystemFrame( + system('task_started', { task_id: `task-${i}`, task_type: 'local_bash' }) + ) + } + expect(items).toHaveLength(64) + }) +}) + +describe('ClaudeSubagentRoster — resumed invocation', () => { + it('reopens one canonical child on a new announcement without replaying old results', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) + roster.observeToolResult('first', false) + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true }) + ) + expect(roles()).toEqual([expect.objectContaining({ id: 'task-1', state: 'working' })]) + expect(roles()[0].settledAt).toBeUndefined() + roster.observeSystemFrame( + system('task_notification', { task_id: 'task-1', tool_use_id: 'first', status: 'completed' }) + ) + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) + expect(roles()[0].state).toBe('working') + roster.observeSystemFrame( + system('task_notification', { + task_id: 'task-1', + tool_use_id: 'resumed', + status: 'completed' + }) + ) + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true }) + ) + expect(roles()[0].state).toBe('completed') + }) +}) + +describe('ClaudeSubagentRoster — invocation fences', () => { + it('ignores a previous invocation tool result even without a background flag', () => { + const { roster, roles } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) + roster.observeToolResult('first', false) + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'next' })) + roster.observeToolResult('first', true) + expect(roles()[0].state).toBe('working') + roster.observeToolResult('next', false) + expect(roles()[0].state).toBe('completed') + }) + + it('does not treat an evicted alias as a new invocation', () => { + const { roster, rolesIn, setGroupKey } = harness() + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) + roster.observeToolResult('first', false) + setGroupKey('churn') + for (let i = 0; i < 513; i++) { + roster.observeSystemFrame( + system('task_updated', { task_id: `other-${i}`, tool_use_id: `tool-${i}` }) + ) + } + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' })) + expect(rolesIn(TURN_1)[0].state).toBe('completed') + }) + + it('bounds invocation history and refuses to reopen beyond the retained budget', () => { + const { roster, roles } = harness() + for (let i = 0; i < 20; i++) { + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: `tool-${i}` })) + if (i >= 16) { + expect(roles()[0].state).toBe('unverifiable') + } + roster.observeToolResult(`tool-${i}`, false) + } + roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'tool-0' })) + expect(roles()[0].state).toBe('unverifiable') + }) +}) + +it('merges an explicit foreground patch without clearing on absent metadata', () => { + const { roster, roles } = harness() + roster.observeSystemFrame( + started({ task_id: 'task-1', tool_use_id: 'tool', is_backgrounded: true }) + ) + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-1', patch: { description: 'Audit' } }) + ) + roster.observeToolResult('tool', false) + expect(roles()[0].state).toBe('working') + roster.observeSystemFrame( + system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: false } }) + ) + roster.observeToolResult('tool', false) + expect(roles()[0].state).toBe('completed') +}) diff --git a/src/main/claude/claude-subagent-roster.ts b/src/main/claude/claude-subagent-roster.ts new file mode 100644 index 00000000000..34083f2ee8c --- /dev/null +++ b/src/main/claude/claude-subagent-roster.ts @@ -0,0 +1,388 @@ +// The Claude subagent roster: one journal row per turn that spawned children. +// +// Entries are built from `task_started`, never from child traffic: a +// BACKGROUNDED subagent emits no child frames at all, so a roster fed by +// `parent_tool_use_id` alone would leave every one of them an unlabelled row +// forever. Child traffic only creates an entry for CLI releases that announce +// no task frames. +// +// Claude re-announces a resumed task under a NEW `tool_use_id`, so `task_id` is +// the key and tool ids are aliases; keying on the tool id would duplicate the +// child on every resume. Outcomes latch within an invocation; a new spawn +// alias can reopen it, and authoritative evidence can correct lost contact. + +import { + canReplaceSubagentState, + isTerminalSubagentState +} from '../../shared/native-chat-subagent-summary' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { isBoundedClaudeTaskId } from './claude-background-task-tracker' +import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row' +import { ClaudeSubagentIds } from './claude-subagent-id-aliases' +import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames' +import { + applyClaudeSubagentInvocation, + claimClaudeSubagentLabel, + type RosterGroup, + type TrackedEntry +} from './claude-subagent-roster-state' + +/** Spawn-group rows kept live per session, and children per row. Both bound an + * event-accumulated map that no provider snapshot ever prunes. */ +const MAX_SUBAGENT_GROUPS = 32 +const MAX_SUBAGENTS_PER_GROUP = 64 + +/** The turn a group belongs to when Claude reports a task outside any turn. */ +const OUTSIDE_TURN = 'outside-turn' + +const UNLABELLED_AGENT = 'subagent' + +export type ClaudeSubagentRosterDeps = { + sink: StructuredAgentSessionEventSink + /** The turn that owns children spawned right now; null outside any turn. */ + currentGroupKey: () => string | null + now?: () => number +} + +export class ClaudeSubagentRoster { + private readonly groups = new Map() + /** Canonical id → the group holding its entry, so a late update for a child + * from an earlier turn revises that turn's row instead of the live one. */ + private readonly groupIdByEntry = new Map() + private readonly ids = new ClaudeSubagentIds() + /** Set by ANY `task_started`, including one the subagent filter rejects. Once + * this CLI has proven it declares its tasks, child traffic for an id it never + * announced is a nested tool or a grandchild, not a subagent. */ + private announcesTasks = false + private readonly now: () => number + + constructor(private readonly deps: ClaudeSubagentRosterDeps) { + this.now = deps.now ?? (() => Date.now()) + } + + /** Consume a `message:system:task_*` frame. Returns false when it is not one. */ + observeSystemFrame(message: Record): boolean { + const frame = readClaudeSubagentTaskFrame(message) + if (!frame) { + return false + } + this.announcesTasks ||= frame.announcement + if (frame.excluded) { + // Child traffic may already have built a provisional row under the tool id; + // the announcement is the first frame that says it is not a subagent. + for (const id of [frame.taskId, frame.toolUseId]) { + if (id !== null) { + this.ids.exclude(id) + this.remove(id) + } + } + return true + } + if (this.ids.isExcluded(frame.taskId, frame.toolUseId)) { + return true + } + if (frame.toolUseId) { + this.ids.alias(frame.toolUseId, frame.taskId) + } + const located = + this.locate(frame.taskId) ?? + (frame.toolUseId ? this.adopt(frame.toolUseId, frame.taskId) : null) + if (!located) { + if (frame.announcesSubagent) { + this.create( + frame.taskId, + frame.label, + frame.state ?? 'working', + frame.backgrounded ?? false, + frame.toolUseId + ) + } + return true + } + const tracked = located.group.entries.get(frame.taskId) + if (tracked && !applyClaudeSubagentInvocation(tracked, frame, this.now)) { + return true + } + this.revise(located.group, frame.taskId, { + label: frame.label, + state: frame.state, + backgrounded: frame.backgrounded + }) + return true + } + + /** + * A frame carrying `parent_tool_use_id` — the child's own traffic. It refreshes + * nothing on an announced child; it exists so a CLI release that sends no task + * frames still shows the subagent it is running. + */ + observeChildActivity(parentToolUseId: string): void { + const canonical = this.ids.canonical(parentToolUseId) + if (this.ids.isExcluded(parentToolUseId, canonical)) { + return + } + if (this.locate(canonical)) { + return + } + if (this.announcesTasks) { + // A nested Task, a workflow child, or a grandchild parented to a tool id + // inside the sidechain all reach here. This CLI announces what it spawns, + // so an id it never declared cannot be a subagent — and a row invented for + // one is unlabelled forever and can only ever end `unverifiable`. The + // bounded exclusion set cannot cover an id that was never announced. + return + } + if (!isBoundedClaudeTaskId(canonical)) { + // `claudeTaskId` rejects an over-long announced id rather than truncating + // it; a provisional id becomes the same durable entry key, so it cannot + // enter under a looser rule. + return + } + this.create(canonical, null, 'working', false, parentToolUseId) + } + + /** + * The parent turn's tool result for a spawn call. It settles a foreground + * child, whose result IS the turn's evidence the child finished. A backgrounded + * child's spawn call returns immediately while the child keeps running, so its + * result proves nothing and is ignored. + */ + observeToolResult(toolUseId: string, failed: boolean): void { + const canonical = this.ids.canonical(toolUseId) + const located = this.locate(canonical) + if ( + !located || + located.tracked.invocationIds === null || + located.tracked.backgrounded || + (located.tracked.toolUseId !== null && located.tracked.toolUseId !== toolUseId) + ) { + return + } + this.revise(located.group, canonical, { + label: null, + state: failed ? 'failed' : 'completed', + backgrounded: false + }) + } + + /** + * The parent turn ended. A foreground child still reported as working will + * never be settled by an event, so it becomes `unverifiable`: contact was + * lost, which is NOT evidence the child exited. A backgrounded child was + * explicitly told to outlive the turn and is left alone. + */ + settleTurn(groupKey: string | null): void { + // Only the group this key names. `OUTSIDE_TURN` belongs to no turn, so an + // unrelated turn ending is no evidence about a child announced outside it. + // `settleSession` reaches what no turn does. + this.sweep(this.groups.get(groupKey ?? OUTSIDE_TURN), false) + } + + /** The provider is gone. Nothing more will arrive for any child, backgrounded + * or not, so every one of them loses contact at once. */ + settleSession(): void { + for (const group of this.groups.values()) { + this.sweep(group, true) + } + } + + dispose(): void { + // Teardown paths reach here without an `ended` event, so a row still + // reporting `working` would have nothing left to revise it. A session that + // did settle first leaves every child terminal, so this writes nothing. + this.settleSession() + this.groups.clear() + this.groupIdByEntry.clear() + this.ids.clear() + this.announcesTasks = false + } + + private sweep(group: RosterGroup | undefined, includeBackgrounded: boolean): void { + if (!group) { + return + } + let changed = false + for (const [id, tracked] of group.entries) { + if (isTerminalSubagentState(tracked.entry.state)) { + continue + } + if (tracked.backgrounded && !includeBackgrounded) { + continue + } + group.entries.set(id, { + ...tracked, + entry: { ...tracked.entry, state: 'unverifiable', settledAt: this.now() } + }) + changed = true + } + if (changed) { + this.write(group) + } + } + + private create( + id: string, + label: string | null, + state: NativeChatSubagentEntry['state'], + backgrounded: boolean, + toolUseId: string | null + ): void { + const group = this.groupFor() + if (group.admittedEntries >= MAX_SUBAGENTS_PER_GROUP) { + return + } + group.admittedEntries += 1 + const now = this.now() + const labelBase = label ?? UNLABELLED_AGENT + group.entries.set(id, { + backgrounded, + toolUseId, + invocationIds: new Set(toolUseId ? [toolUseId] : []), + labelBase, + entry: { + id, + label: claimClaudeSubagentLabel(group, labelBase), + state, + startedAt: now, + ...(isTerminalSubagentState(state) ? { settledAt: now } : {}) + } + }) + this.groupIdByEntry.set(id, group.groupId) + this.write(group) + } + + private revise( + group: RosterGroup, + id: string, + change: { + label: string | null + state: NativeChatSubagentEntry['state'] | null + backgrounded: boolean | null + } + ): void { + const tracked = group.entries.get(id) + if (!tracked) { + return + } + const next: TrackedEntry = { + ...tracked, + backgrounded: change.backgrounded ?? tracked.backgrounded, + entry: { ...tracked.entry } + } + // A provisional row built from child traffic takes the real name the first + // announcement carries; an announced row keeps the name it was given. + if ( + change.label && + tracked.labelBase === UNLABELLED_AGENT && + change.label !== UNLABELLED_AGENT + ) { + next.labelBase = change.label + next.entry.label = claimClaudeSubagentLabel(group, change.label) + } + // Proven outcomes latch; lost contact can still receive a later verdict. + if (change.state && canReplaceSubagentState(tracked.entry.state, change.state)) { + next.entry.state = change.state + if (isTerminalSubagentState(change.state)) { + next.entry.settledAt = this.now() + } + } + group.entries.set(id, next) + this.write(group) + } + + /** Re-key a provisional entry from its tool id onto the canonical task id the + * announcement finally named, so the child does not appear twice. */ + private adopt(toolUseId: string, taskId: string): { group: RosterGroup } | null { + if (toolUseId === taskId) { + return null + } + const located = this.locate(toolUseId) + if (!located) { + return null + } + located.group.entries.delete(toolUseId) + located.group.entries.set(taskId, { + ...located.tracked, + entry: { ...located.tracked.entry, id: taskId } + }) + this.groupIdByEntry.delete(toolUseId) + this.groupIdByEntry.set(taskId, located.group.groupId) + return { group: located.group } + } + + private remove(id: string): void { + const located = this.locate(id) + if (!located) { + return + } + located.group.entries.delete(id) + this.groupIdByEntry.delete(id) + this.write(located.group) + } + + private locate(id: string): { group: RosterGroup; tracked: TrackedEntry } | null { + const groupId = this.groupIdByEntry.get(id) + const group = groupId === undefined ? undefined : this.groups.get(groupId) + const tracked = group?.entries.get(id) + return group && tracked ? { group, tracked } : null + } + + private groupFor(): RosterGroup { + const groupId = this.deps.currentGroupKey() ?? OUTSIDE_TURN + const existing = this.groups.get(groupId) + if (existing) { + return existing + } + const group: RosterGroup = { + groupId, + identity: claudeSubagentGroupIdentity(groupId), + entries: new Map(), + admittedEntries: 0, + claimedLabels: new Set(), + lastSerialized: null + } + this.groups.set(groupId, group) + while (this.groups.size > MAX_SUBAGENT_GROUPS) { + const oldest = this.groups.keys().next() + if (oldest.done || oldest.value === groupId) { + break + } + const evicted = this.groups.get(oldest.value) + // Once the group leaves the map nothing can reach its children again — + // not even a session sweep — so contact is lost here. + this.sweep(evicted, true) + for (const id of evicted?.entries.keys() ?? []) { + this.groupIdByEntry.delete(id) + } + this.groups.delete(oldest.value) + } + return group + } + + private write(group: RosterGroup): void { + const agents = [...group.entries.values()].map((tracked) => tracked.entry) + const options = { coalescingKey: `claude-subagents:${group.groupId}` } + if (agents.length === 0) { + // The row's last child turned out not to be a subagent. An empty roster is + // not a roster of nothing, so the row goes rather than reading "Ran 0". + if (group.lastSerialized !== null) { + group.lastSerialized = null + this.deps.sink.appendTombstone(group.identity, options) + this.deps.sink.publish() + } + return + } + const body = claudeSubagentGroupBody(group.groupId, agents) + const serialized = JSON.stringify(body) + if (serialized === group.lastSerialized) { + // Nothing changed — a duplicate delivery must not burn a revision. + return + } + group.lastSerialized = serialized + this.deps.sink.appendItem(group.identity, body, options) + // Publish keeps the sink's own coalescing slot: sharing the row's key makes + // each queued publish evict the append it was meant to flush. + this.deps.sink.publish() + } +} diff --git a/src/main/claude/claude-subagent-task-frames.test.ts b/src/main/claude/claude-subagent-task-frames.test.ts new file mode 100644 index 00000000000..230ef45e19c --- /dev/null +++ b/src/main/claude/claude-subagent-task-frames.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames' + +function system(subtype: string, fields: Record): Record { + return { type: 'system', subtype, session_id: 'claude-session', ...fields } +} + +describe('readClaudeSubagentTaskFrame', () => { + it('ignores frames that are not task frames', () => { + expect(readClaudeSubagentTaskFrame({ type: 'assistant', subtype: 'task_started' })).toBeNull() + expect(readClaudeSubagentTaskFrame(system('init', { task_id: 'task-1' }))).toBeNull() + expect(readClaudeSubagentTaskFrame(system('task_started', {}))).toBeNull() + expect(readClaudeSubagentTaskFrame(system('task_started', { task_id: '' }))).toBeNull() + }) + + describe('task_type triage', () => { + it('announces a local_agent task', () => { + const frame = readClaudeSubagentTaskFrame( + system('task_started', { + task_id: 'task-1', + tool_use_id: 'toolu_1', + task_type: 'local_agent', + subagent_type: 'code-reviewer', + description: 'Review the diff' + }) + ) + expect(frame).toMatchObject({ + taskId: 'task-1', + toolUseId: 'toolu_1', + label: 'Review the diff', + announcesSubagent: true, + excluded: false + }) + }) + + it('excludes a backgrounded shell command even though it carries a tool_use_id', () => { + const frame = readClaudeSubagentTaskFrame( + system('task_started', { + task_id: 'task-bash', + tool_use_id: 'toolu_bash', + task_type: 'local_bash', + description: 'sleep 20', + is_backgrounded: true + }) + ) + expect(frame).toMatchObject({ + taskId: 'task-bash', + toolUseId: 'toolu_bash', + announcesSubagent: false, + excluded: true + }) + }) + + it('excludes workflows and monitors', () => { + for (const taskType of ['local_workflow', 'monitor']) { + expect( + readClaudeSubagentTaskFrame( + system('task_started', { task_id: `task-${taskType}`, task_type: taskType }) + ) + ).toMatchObject({ announcesSubagent: false, excluded: true }) + } + }) + + it('caps a subagent_type label the way a description is capped', () => { + const frame = readClaudeSubagentTaskFrame( + system('task_started', { task_id: 'task-1', subagent_type: 'a'.repeat(900) }) + ) + // The roster stores this label verbatim, so nothing downstream bounds it. + expect(frame?.label).toHaveLength(512) + }) + + it('falls back to subagent_type only when the release sends no task_type', () => { + expect( + readClaudeSubagentTaskFrame( + system('task_started', { task_id: 'task-old', subagent_type: 'explorer' }) + ) + ).toMatchObject({ announcesSubagent: true, label: 'explorer' }) + expect( + readClaudeSubagentTaskFrame(system('task_started', { task_id: 'task-bare' })) + ).toMatchObject({ announcesSubagent: false, excluded: true }) + // A type this build does not recognise is not an agent on subagent_type's word. + expect( + readClaudeSubagentTaskFrame( + system('task_started', { + task_id: 'task-new', + task_type: 'local_something_new', + subagent_type: 'explorer' + }) + ) + ).toMatchObject({ announcesSubagent: false, excluded: true }) + }) + + it('excludes ambient housekeeping tasks', () => { + for (const suppression of [{ skip_transcript: true }, { ambient: true }]) { + expect( + readClaudeSubagentTaskFrame( + system('task_started', { + task_id: 'task-ambient', + task_type: 'local_agent', + subagent_type: 'watcher', + ...suppression + }) + ) + ).toMatchObject({ announcesSubagent: false, excluded: true }) + } + }) + }) + + describe('status', () => { + it('collapses every in-flight status to working', () => { + for (const status of ['pending', 'running', 'paused']) { + expect( + readClaudeSubagentTaskFrame( + system('task_updated', { task_id: 'task-1', patch: { status } }) + ) + ).toMatchObject({ state: 'working' }) + } + }) + + it('maps the settled statuses onto the carrier vocabulary', () => { + const mapped: [string, string][] = [ + ['completed', 'completed'], + ['failed', 'failed'], + ['killed', 'stopped'], + ['stopped', 'stopped'] + ] + for (const [status, state] of mapped) { + expect( + readClaudeSubagentTaskFrame( + system('task_updated', { task_id: 'task-1', patch: { status } }) + ) + ).toMatchObject({ state }) + } + }) + + it('reports no state for a status it cannot map', () => { + for (const status of ['__proto__', 'toString', 'invented', 7, null]) { + expect( + readClaudeSubagentTaskFrame( + system('task_updated', { task_id: 'task-1', patch: { status } }) + ) + ).toMatchObject({ state: null }) + } + }) + + it('treats progress as no lifecycle verdict', () => { + for (const subtype of ['task_progress']) { + expect( + readClaudeSubagentTaskFrame( + system(subtype, { task_id: 'task-1', status: 'completed', patch: { status: 'failed' } }) + ) + ).toMatchObject({ state: null }) + } + }) + }) + + it('reads the notification verdict from its top-level status', () => { + for (const state of ['completed', 'failed', 'stopped']) { + expect( + readClaudeSubagentTaskFrame( + system('task_notification', { + task_id: 'task-1', + status: state, + patch: { status: 'running' } + }) + ) + ).toMatchObject({ state }) + } + }) + + it('reads the backgrounded flag from the frame or its patch', () => { + expect( + readClaudeSubagentTaskFrame( + system('task_started', { + task_id: 'task-1', + task_type: 'local_agent', + is_backgrounded: true + }) + ) + ).toMatchObject({ backgrounded: true }) + expect( + readClaudeSubagentTaskFrame( + system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: true } }) + ) + ).toMatchObject({ backgrounded: true }) + expect( + readClaudeSubagentTaskFrame(system('task_updated', { task_id: 'task-1', patch: {} })) + ).toMatchObject({ backgrounded: null }) + }) + + it('collapses a multi-line description into one bounded label', () => { + expect( + readClaudeSubagentTaskFrame( + system('task_updated', { + task_id: 'task-1', + patch: { description: ' audit\n the lockfile ' } + }) + ) + ).toMatchObject({ label: 'audit the lockfile' }) + }) +}) diff --git a/src/main/claude/claude-subagent-task-frames.ts b/src/main/claude/claude-subagent-task-frames.ts new file mode 100644 index 00000000000..e5f361fa8c4 --- /dev/null +++ b/src/main/claude/claude-subagent-task-frames.ts @@ -0,0 +1,123 @@ +// Claude's declarative task protocol, read as subagent roster events. +// +// `local_agent`, `local_workflow` and `local_bash` tasks all arrive on the same +// `message:system:task_*` channel and ALL carry a `tool_use_id`, so id presence +// discriminates nothing: filtering on it alone puts a backgrounded `sleep 20` in +// the subagent roster. `task_type` is the discriminator, with `subagent_type` +// covering CLI releases that predate it. + +import type { NativeChatSubagentState } from '../../shared/native-chat-types' +import { + classifyClaudeBackgroundTaskKind, + claudeTaskDescription, + claudeTaskId, + isBoundedClaudeTaskId +} from './claude-background-task-tracker' +import { claudeRecord, claudeText } from './claude-structured-item-translation' + +const TASK_SUBTYPES: ReadonlySet = new Set([ + 'task_started', + 'task_updated', + 'task_progress', + 'task_notification' +]) + +/** Provider status → the carrier's vocabulary. `killed` and `stopped` both mean + * the task was deliberately ended, which the carrier calls `stopped`; every + * in-flight status collapses to `working`. A Map, not an object, so a payload + * carrying `__proto__` as its status cannot resolve to an inherited value. */ +const TASK_STATES: ReadonlyMap = new Map([ + ['pending', 'working'], + ['running', 'working'], + ['paused', 'working'], + ['completed', 'completed'], + ['failed', 'failed'], + ['killed', 'stopped'], + ['stopped', 'stopped'] +] satisfies [string, NativeChatSubagentState][]) + +export type ClaudeSubagentTaskFrame = { + /** Canonical, resume-stable id — the roster key. */ + taskId: string + /** Re-minted when Claude re-announces a resumed task, so it is only an alias. */ + toolUseId: string | null + label: string | null + /** null when the frame reported no lifecycle status. */ + state: NativeChatSubagentState | null + backgrounded: boolean | null + /** Any `task_started`, subagent or not. Proof this CLI declares its tasks. */ + announcement: boolean + /** `task_started` for a task the roster should show. Only an announcement + * creates an entry: an update carries no `task_type`, so honouring one for an + * unknown id would roster whatever else shares this channel. */ + announcesSubagent: boolean + /** Ambient housekeeping, or a task that is not a subagent at all. Its ids must + * never reach the roster, by this frame or by later child traffic. */ + excluded: boolean +} + +/** True when the task Claude announced is a subagent rather than a backgrounded + * shell command or a workflow. */ +export function isClaudeSubagentTask(message: Record): boolean { + if (classifyClaudeBackgroundTaskKind(message.task_type) === 'agent') { + return true + } + // Releases predating `task_type` still name the child in `subagent_type`. A + // task_type Orca does not recognise is NOT covered: it is a type this build + // has no reason to believe is an agent. + return ( + (message.task_type === undefined || message.task_type === null) && + claudeText(message.subagent_type) !== null + ) +} + +function taskState(value: unknown): NativeChatSubagentState | null { + return typeof value === 'string' ? (TASK_STATES.get(value) ?? null) : null +} + +export function readClaudeSubagentTaskFrame( + message: Record +): ClaudeSubagentTaskFrame | null { + if (message.type !== 'system') { + return null + } + const subtype = claudeText(message.subtype) + if (!subtype || !TASK_SUBTYPES.has(subtype)) { + return null + } + const taskId = claudeTaskId(message) + if (!taskId) { + return null + } + const patch = claudeRecord(message.patch) + const toolUseId = claudeText(message.tool_use_id) ?? claudeText(patch?.tool_use_id) + const announcement = subtype === 'task_started' + // Housekeeping Claude runs for itself; the user never asked for it. + const suppressed = message.ambient === true || message.skip_transcript === true + const subagent = announcement && !suppressed && isClaudeSubagentTask(message) + return { + taskId, + toolUseId: toolUseId && isBoundedClaudeTaskId(toolUseId) ? toolUseId : null, + label: + claudeTaskDescription(message.description) ?? + claudeTaskDescription(patch?.description) ?? + // Bounded like a description: the roster stores whatever this returns. + (announcement ? (claudeTaskDescription(message.subagent_type) ?? null) : null), + // Notifications carry terminal evidence; progress carries usage only. + state: + subtype === 'task_notification' + ? taskState(message.status) + : announcement || subtype === 'task_updated' + ? taskState(patch?.status ?? message.status) + : null, + backgrounded: + typeof patch?.is_backgrounded === 'boolean' + ? patch.is_backgrounded + : typeof message.is_backgrounded === 'boolean' + ? message.is_backgrounded + : null, + announcement, + announcesSubagent: subagent, + excluded: announcement && !subagent + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index ed86d81861e..13673626ebc 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -432,6 +432,86 @@ describe('payload bounds on import', () => { expect(body.output.byteLength).toBe(64 * 1024) expect(body.output.head).toHaveLength(1_024) }) + + it('bounds an imported subagent roster by entry count, label and id', async () => { + // The import reads an untrusted file: nothing upstream bounded either string. + const oversized = 'z'.repeat(20 * 1024) + const journal = await open('claude', CLAUDE_SESSION) + await appendLegacyTranscriptMessages({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + messages: [ + { + id: 'legacy-roster', + role: 'assistant', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'subagent-group', + groupId: 'group-1', + agents: Array.from({ length: 80 }, (_, index) => ({ + id: index === 0 ? oversized : `task-${index}`, + label: index === 0 ? oversized : `label-${index}`, + state: 'working' as const + })) + } + ] + } + ] + }) + + const body = journal.snapshot().items[0]?.body + const block = body?.kind === 'message' ? body.blocks[0] : undefined + if (block?.type !== 'subagent-group') { + throw new Error('expected a subagent-group block') + } + expect(block.agents).toHaveLength(64) + expect(block.agents[0]?.label.length).toBeLessThan(oversized.length) + expect(block.agents[0]?.id.length).toBeLessThan(oversized.length) + expect(block.agents[0]?.id.startsWith('z')).toBe(true) + }) + + it('bounds a roster id in the shared format, keeping a shared prefix distinct', async () => { + // The id is the roster key: it takes the same bounded-id format the wires + // use, so a later wire bound is a no-op instead of a second, merging clip. + const head = 'y'.repeat(512) + const journal = await open('claude', CLAUDE_SESSION) + await appendLegacyTranscriptMessages({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + messages: [ + { + id: 'legacy-roster-collision', + role: 'assistant', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'subagent-group', + groupId: 'group-1', + agents: [ + { id: `${head}-one`, label: 'Audit', state: 'working' as const }, + { id: `${head}-two`, label: 'Audit', state: 'working' as const } + ] + } + ] + } + ] + }) + + const body = journal.snapshot().items[0]?.body + const block = body?.kind === 'message' ? body.blocks[0] : undefined + if (block?.type !== 'subagent-group') { + throw new Error('expected a subagent-group block') + } + expect(block.agents[0]?.id).not.toBe(block.agents[1]?.id) + expect(block.agents[0]?.id).toHaveLength(512) + }) }) describe('import failures', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index 0907ebd28f2..801c91fb534 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -28,6 +28,7 @@ import { decodeOmpTranscriptLine } from '../transcript-line-decoders' import { decodeTranscriptStream } from '../transcript-stream-lines' +import { boundSubagentEntryId } from '../subagent-entry-id-bounds' import { createLegacyIdentityTracker } from './journal-legacy-identity' import type { JournalReplacementItem } from './journal-epoch-replacement' import { @@ -47,6 +48,8 @@ export type LegacyImportOptions = ResolveSessionFileOptions & { } const MAX_LEGACY_IMPORT_SOURCE_BYTES = 16 * 1024 * 1024 +/** A roster is a status list; an imported one is as untrusted as any other block. */ +const MAX_LEGACY_IMPORT_SUBAGENTS = 64 export type LegacyImportResult = | { @@ -271,5 +274,17 @@ function boundBlock(block: NativeChatBlock, limits: JournalPayloadLimits): Nativ if (block.type === 'tool-call') { return { ...block, input: boundToolInput(block.input, limits) } } + if (block.type === 'subagent-group') { + return { + ...block, + agents: block.agents.slice(0, MAX_LEGACY_IMPORT_SUBAGENTS).map((agent) => ({ + ...agent, + // The id is the roster key, so it is bounded with a digest rather than + // clipped to a prefix that two distinct children could share. + id: boundSubagentEntryId(agent.id), + label: boundInlineText(agent.label, limits).text + })) + } + } return block } diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index 2dc0ac1aeb2..255e4ed184c 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -5,7 +5,10 @@ import { boundJournalKeyComponent, MAX_JOURNAL_KEY_COMPONENT_CHARS } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { applyJournalRow, @@ -14,6 +17,7 @@ import { renderJournalState, type JournalReducerState } from './journal-reducer' +import { buildJournalItemRow, buildJournalTombstoneRow } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' const EPOCH = 'epoch-1' @@ -444,3 +448,49 @@ describe('bounded item-key collisions', () => { ]) }) }) + +describe('re-adding a tombstoned row', () => { + it('builds the rebuilt row above the tombstone that removed it', () => { + const identity: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'roster' } + const itemId = agentJournalItemKey(identity) + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + buildJournalItemRow({ state, identity, body: text('first'), seq: 1, fence: 1, ts: 1_001 }) + ) + applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 2, fence: 1, ts: 1_002 })) + expect(renderJournalState(state).items).toEqual([]) + + // Same identity, re-added later in the session: a revision built only from + // `items` would restart at 1 and lose to the tombstone forever. + applyJournalRow( + state, + buildJournalItemRow({ state, identity, body: text('second'), seq: 3, fence: 1, ts: 1_003 }) + ) + expect(renderJournalState(state).items.map((item) => item.body)).toEqual([text('second')]) + }) + + // `upsertItem` clearing the tombstone on a re-add is a map-state invariant: + // `items` and `tombstones` stay disjoint, so a re-added row is never both + // present and removed. Revision ordering is now independent of it — + // `buildJournalTombstoneRow` takes `max(itemRevision, tombstoneRevision) + 1` + // — so what this pins is the map state itself, not the ranking. + it('removes the row again after it was re-added', () => { + const identity: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'roster' } + const itemId = agentJournalItemKey(identity) + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + buildJournalItemRow({ state, identity, body: text('first'), seq: 1, fence: 1, ts: 1_001 }) + ) + applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 2, fence: 1, ts: 1_002 })) + applyJournalRow( + state, + buildJournalItemRow({ state, identity, body: text('second'), seq: 3, fence: 1, ts: 1_003 }) + ) + expect(state.tombstones.get(itemId)).toBeUndefined() + + applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 4, fence: 1, ts: 1_004 })) + expect(renderJournalState(state).items).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 5c77c180c68..db82e86318f 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -148,7 +148,13 @@ export function buildJournalItemRow(input: { }): JournalItemRow { const itemId = agentJournalItemKey(input.identity) const resolved = input.state.aliases.get(itemId) ?? itemId - const revision = (input.state.items.get(resolved)?.revision ?? 0) + 1 + // A tombstoned row keeps its revision in `tombstones`, and the reducer drops + // any item at or below it — so a re-add has to outrank the tombstone too. + const revision = + Math.max( + input.state.items.get(resolved)?.revision ?? 0, + input.state.tombstones.get(resolved) ?? 0 + ) + 1 return { kind: 'item', itemId, @@ -170,7 +176,15 @@ export function buildJournalTombstoneRow(input: { return { kind: 'tombstone', itemId: input.itemId, - revision: (input.state.items.get(resolved)?.revision ?? 0) + 1, + // Symmetric with the item builder: `upsertItem` clearing the tombstone on a + // re-add is what keeps the two maps disjoint, and that invariant lives in the + // reducer. Outranking both here means a repeat removal cannot be dropped as a + // stale revision if it ever stops holding. + revision: + Math.max( + input.state.items.get(resolved)?.revision ?? 0, + input.state.tombstones.get(resolved) ?? 0 + ) + 1, ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) } } diff --git a/src/main/native-chat/subagent-entry-id-bounds.test.ts b/src/main/native-chat/subagent-entry-id-bounds.test.ts new file mode 100644 index 00000000000..56d0edece66 --- /dev/null +++ b/src/main/native-chat/subagent-entry-id-bounds.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { boundSubagentEntryId, MAX_SUBAGENT_ENTRY_ID_CHARS } from './subagent-entry-id-bounds' + +const SHARED_HEAD = 'a'.repeat(MAX_SUBAGENT_ENTRY_ID_CHARS) + +describe('boundSubagentEntryId', () => { + it('leaves an id that already fits untouched', () => { + const id = 'task-1' + expect(boundSubagentEntryId(id)).toBe(id) + expect(boundSubagentEntryId(SHARED_HEAD)).toBe(SHARED_HEAD) + }) + + it('keeps two ids sharing the whole cap-length head distinct', () => { + const first = boundSubagentEntryId(`${SHARED_HEAD}-one`) + const second = boundSubagentEntryId(`${SHARED_HEAD}-two`) + expect(first).not.toBe(second) + expect(first).toHaveLength(MAX_SUBAGENT_ENTRY_ID_CHARS) + expect(second).toHaveLength(MAX_SUBAGENT_ENTRY_ID_CHARS) + }) + + it('is deterministic and a no-op on an already bounded id', () => { + const bounded = boundSubagentEntryId(`${SHARED_HEAD}-one`) + expect(boundSubagentEntryId(`${SHARED_HEAD}-one`)).toBe(bounded) + expect(boundSubagentEntryId(bounded)).toBe(bounded) + }) +}) diff --git a/src/main/native-chat/subagent-entry-id-bounds.ts b/src/main/native-chat/subagent-entry-id-bounds.ts new file mode 100644 index 00000000000..3abf19a4ff6 --- /dev/null +++ b/src/main/native-chat/subagent-entry-id-bounds.ts @@ -0,0 +1,27 @@ +// Bounding a subagent entry's id — a KEY, not display text. +// +// `NativeChatSubagentEntry.id` keys the roster, so clipping it to a fixed prefix +// merges two distinct children whose ids agree that far and associates one's +// state with the other. An id long enough to need bounding only reaches us from +// an imported legacy transcript, but a bound is still required, so keep a head +// for readability plus a digest of the WHOLE id to keep distinct ids distinct. + +import { createHash } from 'node:crypto' + +/** Shared by every site that puts a roster entry on a wire, so a journal-bound + * id survives the RPC and transcript bounds untouched instead of being clipped + * a second time into a different string. */ +export const MAX_SUBAGENT_ENTRY_ID_CHARS = 512 + +const DIGEST_CHARS = 16 + +/** Returns `id` unchanged when it fits, else a head plus a digest suffix whose + * total length is exactly the cap — so bounding a bounded id is a no-op. */ +export function boundSubagentEntryId(id: string): string { + if (id.length <= MAX_SUBAGENT_ENTRY_ID_CHARS) { + return id + } + const digest = createHash('sha256').update(id, 'utf8').digest('base64url').slice(0, DIGEST_CHARS) + const suffix = `…#${digest}` + return `${id.slice(0, MAX_SUBAGENT_ENTRY_ID_CHARS - suffix.length)}${suffix}` +} diff --git a/src/main/runtime/orchestration/worker-transcript-payload.test.ts b/src/main/runtime/orchestration/worker-transcript-payload.test.ts index f47a46605e1..aaa3fdcfcc1 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.test.ts @@ -146,6 +146,36 @@ describe('worker transcript wire bounds', () => { expect(result).toMatchObject({ limited: false, warnings: [] }) }) + it('keeps two roster ids sharing a 512-char prefix distinct', () => { + // The id is the roster key: a plain prefix clip would merge the two children. + const head = 'a'.repeat(512) + const result = boundWorkerTranscriptMessages([ + { + id: 'message-1', + role: 'assistant', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'subagent-group', + groupId: 'g', + agents: [ + { id: `${head}-one`, label: 'Audit', state: 'working' }, + { id: `${head}-two`, label: 'Audit', state: 'working' } + ] + } + ] + } + ]) + + const block = result.messages[0]?.blocks[0] + if (block?.type !== 'subagent-group') { + throw new Error('expected a subagent-group block') + } + expect(block.agents[0]?.id).not.toBe(block.agents[1]?.id) + expect(block.agents[0]?.id).toHaveLength(512) + }) + it('keeps fallback identifiers stable without exposing the transcript path', () => { const transcriptPath = 'C:\\Users\\worker\\.codex\\session.jsonl' const message = { diff --git a/src/main/runtime/orchestration/worker-transcript-payload.ts b/src/main/runtime/orchestration/worker-transcript-payload.ts index f9d83e20c62..0f50aa8309c 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.ts @@ -5,6 +5,7 @@ import type { NativeChatMessage, NativeChatSubagentState } from '../../../shared/native-chat-types' +import { boundSubagentEntryId } from '../../native-chat/subagent-entry-id-bounds' export const DEFAULT_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 40 export const MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 50 @@ -153,7 +154,7 @@ function boundBlock(block: NativeChatBlock, state: TranscriptBoundState): Native groupId: clipMetadata(block.groupId, state), agents: agents.map((agent) => ({ ...agent, - id: clipMetadata(agent.id, state), + id: boundEntryId(agent.id, state), label: clipMetadata(agent.label, state), state: clipSubagentState(agent.state, state) })) @@ -194,6 +195,18 @@ function isLocalFileLocator(value: string): boolean { ) } +/** A roster entry's id is the roster KEY, so it is redacted like other metadata + * but bounded with a digest rather than clipped: two ids sharing a 512-char + * head must not collapse onto one entry. */ +function boundEntryId(value: string, state: TranscriptBoundState): string { + const redacted = redactSensitiveText(value, state.warnings) + const bounded = boundSubagentEntryId(redacted) + if (bounded !== redacted) { + markClipped(state, 'Oversized transcript metadata was clipped.') + } + return bounded +} + function clipMetadata(value: string, state: TranscriptBoundState): string { const redacted = redactSensitiveText(value, state.warnings) if (redacted.length <= MAX_WORKER_TRANSCRIPT_METADATA_CHARS) { diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts new file mode 100644 index 00000000000..de25ea7e45d --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock } from '../../../../shared/native-chat-types' +import { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize' + +const SHARED_HEAD = 'a'.repeat(512) + +function rosterBlock(ids: readonly string[]): NativeChatBlock { + return { + type: 'subagent-group', + groupId: 'group-1', + agents: ids.map((id) => ({ id, label: 'l'.repeat(900), state: 'working' as const })) + } +} + +describe('mobile subagent roster bounds', () => { + it('keeps two ids sharing a 512-char prefix distinct', () => { + const block = sanitizeNativeChatRpcBlock( + rosterBlock([`${SHARED_HEAD}-one`, `${SHARED_HEAD}-two`]), + 'mobile' + ) + + if (block.type !== 'subagent-group') { + throw new Error('expected a subagent-group block') + } + expect(block.agents[0]?.id).not.toBe(block.agents[1]?.id) + expect(block.agents[0]?.id).toHaveLength(512) + // The label is display text and still clips. + expect(block.agents[0]?.label).toContain('… (truncated)') + }) + + it('leaves a short id alone', () => { + const block = sanitizeNativeChatRpcBlock(rosterBlock(['task-1']), 'mobile') + expect(block.type === 'subagent-group' && block.agents[0]?.id).toBe('task-1') + }) +}) diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts index fc19273d5ca..79f1da4fc53 100644 --- a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts +++ b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts @@ -3,6 +3,7 @@ import { normalizeSubagentState } from '../../../../shared/native-chat-subagent-summary' import type { NativeChatBlock, NativeChatSubagentState } from '../../../../shared/native-chat-types' +import { boundSubagentEntryId } from '../../../native-chat/subagent-entry-id-bounds' import type { RpcContext } from '../core' import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block' @@ -63,7 +64,9 @@ export function sanitizeNativeChatRpcBlock( groupId: clip(block.groupId, MAX_SUBAGENT_FIELD_CHARS), agents: block.agents.slice(0, MOBILE_SUBAGENT_CAP).map((agent) => ({ ...agent, - id: clip(agent.id, MAX_SUBAGENT_FIELD_CHARS), + // The id is the roster KEY: a prefix clip would merge two children, so + // it takes the shared digest bound the other wires use. + id: boundSubagentEntryId(agent.id), label: clip(agent.label, MAX_SUBAGENT_FIELD_CHARS), state: clipSubagentState(agent.state) })) diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 1417e716770..47a6d9e855e 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { + NativeChatMessage, + NativeChatSubagentEntry +} from '../../../../shared/native-chat-types' import type { RpcContext } from '../core' // Stub the bounded tail reader so the handler returns a deterministic transcript with @@ -88,6 +91,7 @@ vi.mock('../../../native-chat/transcript-watch', () => ({ } })) +import { boundSubagentEntryId } from '../../../native-chat/subagent-entry-id-bounds' import { NATIVE_CHAT_METHODS } from './native-chat' function makeMessage(text: string): NativeChatMessage { @@ -191,6 +195,35 @@ describe('nativeChat.readSession clientKind truncation gating', () => { expect(block.text).toBe(text) }) + it('bounds a subagent roster before it reaches mobile', async () => { + const agents: NativeChatSubagentEntry[] = Array.from({ length: 100 }, (_, index) => ({ + id: `task-${index}-${'i'.repeat(600)}`, + label: 'l'.repeat(600), + state: 'working' + })) + cachedResult.value = { + messages: [ + { + ...makeMessage(''), + blocks: [{ type: 'subagent-group', groupId: 'g', agents }] + } + ] + } + const result = await readSessionHandler()( + { agent: 'claude', sessionId: 's' }, + ctxWith('mobile') + ) + const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] as { + agents: { id: string; label: string }[] + } + expect(block.agents).toHaveLength(64) + expect(block.agents[0].label).toBe(`${'l'.repeat(512)}\n… (truncated)`) + // The id is as untrusted as the label on an imported roster, but it is the + // roster key: it is bounded with a digest, never clipped to a bare prefix. + expect(block.agents[0].id).toHaveLength(512) + expect(block.agents[0].id).toBe(boundSubagentEntryId(`task-0-${'i'.repeat(600)}`)) + }) + it('clips a pathological text block at the safety ceiling for mobile clients', async () => { const text = 'y'.repeat(70_000) cachedResult.value = { messages: [makeTextMessage(text)] } diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index ac7dafacfd2..93b2b460bb0 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -33,7 +33,15 @@ const CANONICAL_BODIES: AgentJournalItemBody[] = [ }, { type: 'tool-call', name: 'Read', input: { path: 'a' } }, { type: 'tool-result', output: 'ok', isError: false }, - { type: 'image-ref', path: '/tmp/a.png', alt: 'screenshot' } + { type: 'image-ref', path: '/tmp/a.png', alt: 'screenshot' }, + { + type: 'subagent-group', + groupId: 'claude-session:turn-1', + agents: [ + { id: 'task-1', label: 'Explore', state: 'working', startedAt: 1_000 }, + { id: 'task-2', label: 'Review', state: 'completed', tokens: 42, settledAt: 2_000 } + ] + } ] }, { kind: 'tool-call', name: 'Read', input: undefined, state: 'running' }, @@ -143,6 +151,41 @@ describe('nested corruption is rejected', () => { ).toBe(false) }) + it('rejects a subagent roster whose entries are malformed', () => { + // A KNOWN block type stays a known block: it must not fall through to the + // forward-tolerant arm just because its payload is wrong. + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'message', + role: 'system', + blocks: [{ type: 'subagent-group', groupId: 'g', agents: [{ id: 'a', label: 'x' }] }] + }) + ).toBe(false) + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'message', + role: 'system', + blocks: [{ type: 'subagent-group', groupId: 'g', agents: 'not-a-roster' }] + }) + ).toBe(false) + }) + + it('keeps a state string a newer build might write admissible', () => { + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'message', + role: 'system', + blocks: [ + { + type: 'subagent-group', + groupId: 'g', + agents: [{ id: 'a', label: 'x', state: 'some-future-state' }] + } + ] + }) + ).toBe(true) + }) + it('rejects shallow render items and submissions', () => { expect( isAdmissibleAgentJournalRenderItem({ From 1c1cb7115ac571b19fed27ade99eb40d1d33dbca Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:50:51 -0700 Subject: [PATCH 02/14] fix(orchestration): unify structured-session status across worktree ps and sidebar (#19217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(orchestration): stop worktree ps reporting a busy structured session as idle A worktree running a structured Claude or Codex chat read as idle to `orca worktree ps`, while the desktop sidebar showed the same session working. The sidebar was right: the host already projects a status summary for every structured session and publishes it, and the renderer maps it into an agent row. `worktree ps` simply never consumed it, so the agent-facing surface was the blind one. Structured sessions have no PTY, so they reach neither the hook snapshots nor the retained ones that every other row is built from. This reads the summaries the host has already published and applies the same projection the sidebar does — working, attention as blocked, otherwise done — so the CLI and the GUI cannot disagree about one session. Two things worth knowing: The connected-PTY evidence gate had to be skipped for these rows. It exists to drop a row whose PTY is gone, which is the wrong question for a session that never had one; a structured row's liveness evidence is the status feed that produced it. The exemption is keyed on the row being structured, so every PTY-backed row keeps today's behaviour. `RuntimeWorktreeAgentRow` needed no change. It was already a non-PTY shape — paneKey, state, agentType, and no ptyId, connected or writable — so a structured row fits without inventing a fake terminal coordinate. The pane key is the DERIVED one the renderer already publishes, never the orchestration bearer handle or the minted worker pane key: both are credentials, since `orchestration check` is identity-gated and accepts a caller-supplied pane key. `orca terminal list` is deliberately untouched, and a test pins that. Adding rows there breaks real consumers: mobile mounts a terminal WebView per row that can never receive a frame, a `connected`-keyed refresh check goes permanently true and pins shipped clients to a fast cadence with no exit, and the plugin projection has no field that can carry `writable: false`. Every safe consumer of a terminal summary checks `ptyId`; the breaking ones key off `connected` or mere row presence, which no added field can qualify. An honest partial-listing count there is a separate change. * fix(runtime): report only live structured sessions in worktree ps The status feed's `published` map is a broadcast cache, not a roster. It deliberately never retracts — an evicted idle session is still idle, and a reloading renderer must not lose every settled row — so enumerating it lists every session the host has ever opened, and eviction's `forget-session` step deletes the session from the live map while touching nothing else. Reading it as a roster made `worktree ps` report a closed chat forever. The sharp edge is a chat closed while an approval was pending: a deliberate close does not settle a pending prompt, so the retained summary stays `attention`, maps to a `blocked` row, and merges the worktree to `permission` for the whole 30-minute freshness window — on the CLI and on the mobile sidebar it backs. The poller now answers from the sessions the host still holds, intersecting the live map with the retained projections. `subscribe()` and its snapshot are untouched: retention there is the point. Gating on the live session set rather than the visible tabs keeps a headless orchestration worker listed, which is what the agent-facing surface is for. Also folds out two things the enumerator left behind: the working/attention/idle to working/blocked/done mapping now lives once in the shared projection module instead of once per process, which is what actually enforces "the CLI and the GUI cannot disagree"; and the structured row source no longer builds a write-only `payload` behind an `as` cast that compensated for nothing. The structured source construction moves to its own module to keep runtime-worktree-agent-rows.ts clear of the 300-line cap. * test(runtime): execute the structured-host call site in worktree ps Both structured-row suites called attachRuntimeWorktreeAgentRows directly with summaries they built themselves, so nothing ever ran getWorktreePs's own `getStructuredAgentSessionHost()?.liveSessionStatusSummaries()`. That file carries `@ts-nocheck`, so renaming the accessor was green in typecheck and in the suite, while `orca worktree ps` and mobile's 3s poll would throw a TypeError for every user — the `?.` optional-chains the host, not the method. Swapping the call back to a whole-cache read was equally invisible: the liveness suite injects feed.liveSessionSummaries() itself, and the string-match guard only needs the identifier to appear somewhere in the file. Drives the real runtime with a stub host over a real status feed that has published two sessions and forgotten one, asserting the live session's row reaches ps output, that the live accessor is the one called, and that the `?? []` fallback still returns a page with no host installed. The stub is typed against the real host, so a class-side rename reddens tc here. * refactor(runtime): admit agent sources before worktree row projection * fix(runtime): preserve host-authoritative structured status * Fix structured host session activity lifecycle --------- Co-authored-by: Merge Sim --- docs/review-evidence/pr-19217/README.md | 46 +++++ docs/review-evidence/pr-19217/closed.png | Bin 0 -> 105757 bytes docs/review-evidence/pr-19217/working.png | Bin 0 -> 111788 bytes ...structured-agent-session-attach-context.ts | 1 + ...structured-agent-session-event-recovery.ts | 1 + .../structured-agent-session-host-handoff.ts | 3 + .../structured-agent-session-host-lifetime.ts | 14 +- .../structured-agent-session-host.ts | 24 +-- ...ructured-agent-session-status-feed.test.ts | 82 ++++++++- .../structured-agent-session-status-feed.ts | 38 +++++ ...tructured-agent-session-unexpected-exit.ts | 3 + .../structured-rewind-claude-owner.ts | 1 + .../runtime/orca-runtime-get-worktree-ps.ts | 14 +- .../worktree-ps-structured-host.spec.ts | 146 ++++++++++++++++ src/main/runtime/orca-runtime.test.ts | 1 + ...ime-worktree-agent-rows-structured.test.ts | 120 +++++++++++++ .../runtime/runtime-worktree-agent-rows.ts | 142 ++-------------- .../runtime/runtime-worktree-agent-source.ts | 21 +++ .../runtime-worktree-agent-sources.test.ts | 70 ++++++++ .../runtime/runtime-worktree-agent-sources.ts | 22 +++ .../runtime-worktree-pty-agent-sources.ts | 121 ++++++++++++++ ...ree-structured-agent-rows-liveness.test.ts | 157 ++++++++++++++++++ .../runtime-worktree-structured-agent-rows.ts | 48 ++++++ ...tructuredAgentSessionStatusBridge.test.tsx | 22 +++ .../StructuredAgentSessionStatusBridge.tsx | 19 ++- src/renderer/src/lib/pane-agent-evidence.ts | 10 +- ...gent-session-status-feed-lifecycle.test.ts | 115 +++++++++++++ .../structured-agent-session-status-feed.ts | 44 ++++- .../src/store/slices/agent-status-contract.ts | 2 + .../slices/agent-status-live-entry-builder.ts | 1 + src/shared/agent-session-wire.ts | 2 + src/shared/agent-status-freshness.ts | 4 +- src/shared/agent-status-types.ts | 2 + .../structured-agent-session-projection.ts | 8 + 34 files changed, 1132 insertions(+), 172 deletions(-) create mode 100644 docs/review-evidence/pr-19217/README.md create mode 100644 docs/review-evidence/pr-19217/closed.png create mode 100644 docs/review-evidence/pr-19217/working.png create mode 100644 src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts create mode 100644 src/main/runtime/runtime-worktree-agent-rows-structured.test.ts create mode 100644 src/main/runtime/runtime-worktree-agent-source.ts create mode 100644 src/main/runtime/runtime-worktree-agent-sources.test.ts create mode 100644 src/main/runtime/runtime-worktree-agent-sources.ts create mode 100644 src/main/runtime/runtime-worktree-pty-agent-sources.ts create mode 100644 src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts create mode 100644 src/main/runtime/runtime-worktree-structured-agent-rows.ts create mode 100644 src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts diff --git a/docs/review-evidence/pr-19217/README.md b/docs/review-evidence/pr-19217/README.md new file mode 100644 index 00000000000..ca664ae0c9a --- /dev/null +++ b/docs/review-evidence/pr-19217/README.md @@ -0,0 +1,46 @@ +# Structured worktree status validation + +Validated on September 7, 2026 in a background Electron dev instance of +`pr19217-review-r2`, based on `ce1024096b` with the source-adapter refactor. +CDP app identity confirmed the checkout; screenshots show the full hidden renderer. +The command output is the real `orca worktree ps --json` response reduced to status, +agent state, provider, and pane key for readability. + +## Functional correctness + +A real Codex structured session appeared as `working` in `worktree.ps` while the +sidebar showed working. Closing its chat tab removed that exact session's row and +returned the worktree to `active`. A different completed chat remained present, +confirming that closure removed only the selected session. + +- [Working: CLI and sidebar](working.png) +- [Closed: CLI and sidebar](closed.png) + +The disappearing session is `codex_40677067_f492_4d7d_86dd_ec566ede04c3`. +The host's held-session roster controls eligibility; its retained broadcast cache +is history, not a roster. Failed eviction intentionally keeps an entry for retry. + +## Architecture + +PTY reconciliation and process admission belong to the PTY source adapter. +Structured input comes from the current host's held-session projections. One +admitted collection feeds row shaping and worktree aggregation, with no structured +boolean bypass. PTY hooks and retained reports still arrive independently, so their +precedence and conservative remote evidence rules remain necessary. No second +persistent status store or provider polling was introduced. + +## Validation and limits + +Independent final review found no proven issues. Runtime, host lifecycle, status +feed and source-admission suites passed: 1,344 tests, one skipped. Node typecheck, +targeted lint and diff checks passed. Ablating the runtime call to enumerate +retained history caused the executable call-site test to fail with two rows where +one was expected; restoring the live accessor passed both call-site tests. + +Live screenshots prove Codex working and closure on macOS. Claude provider turns, +approval/input states, live Windows/Linux/WSL/SSH/relay/mobile scenarios and +release-scale latency/heap measurements remain unverified. Existing tests cover +remote/WSL evidence, monitoring precedence and lifecycle cases. The existing +30-minute freshness rule and CLI activity timestamps are preserved; complete +CLI/sidebar timing parity is not claimed. The wire keeps its existing row shape +and status vocabulary; mobile receives the new rows without a new opcode. diff --git a/docs/review-evidence/pr-19217/closed.png b/docs/review-evidence/pr-19217/closed.png new file mode 100644 index 0000000000000000000000000000000000000000..7f1d816084489b4b21274caaa9933ef068c314ab GIT binary patch literal 105757 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

t- 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 literal 0 HcmV?d00001 diff --git a/docs/review-evidence/pr-19217/working.png b/docs/review-evidence/pr-19217/working.png new file mode 100644 index 0000000000000000000000000000000000000000..8ee570521348e613259e8f24b02c32a0e8378458 GIT binary patch literal 111788 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%0AciO~urB3i*wxg}JQcuH82rb5k-o|?+vk)vOD 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 literal 0 HcmV?d00001 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 412aa88025d..986fcefadbf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -38,4 +38,5 @@ export type StructuredAgentSessionAttachContext = { reconcileLeases: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise now: () => number + publishStatus?: (sessionId: string) => void } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 5f9894c3280..728942b6347 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -22,6 +22,7 @@ export class StructuredAgentSessionEventRecovery { sessions: Map flushLifecycle: (sessionId: string) => Promise publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void + publishStatus?: (sessionId: string) => void hasResumeCapableHolder: (sessionId: string) => boolean serialize: (sessionId: string, task: () => Promise) => Promise now: () => number diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts index 7c8c65a5292..3495409f127 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -25,6 +25,7 @@ type HostHandoffAccess = { flush: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise subscribers: AgentSessionSubscribers + publishStatus?: (sessionId: string) => void now: () => number } @@ -82,6 +83,7 @@ export function createStructuredAgentSessionHostHandoff( return { state: 'live' } } host.session(sessionId).hasProviderChild = false + host.publishStatus?.(sessionId) try { await host.flush(sessionId) host.eventSink(sessionId).unbind() @@ -243,6 +245,7 @@ export async function acquireNativeHandoffOwner( return rethrowAfterAgentSessionAcquisitionCleanup(deps.adapter, input.sessionId, error) } session.hasProviderChild = true + host.publishStatus?.(input.sessionId) session.fence = proved.lease.runtimeFence session.acquisitionGeneration = acquired.acquisitionGeneration ?? null eventSink.bind({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index ba62db1704e..d1664bc985b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -93,13 +93,19 @@ export async function resumeStructuredAgentSessionForHold( export function createStructuredAgentSessionHolds( context: StructuredAgentSessionLifetimeContext, input: { - resume: (sessionId: string) => Promise - evict: (sessionId: string) => Promise + reconcileLeases: (sessionId: string) => Promise + attach: Parameters[0]['attach'] + close: (sessionId: string) => Promise } ): StructuredAgentSessionHolds { return new StructuredAgentSessionHolds({ - resume: input.resume, - evict: input.evict, + resume: (sessionId) => + resumeStructuredAgentSessionForHold( + { ...context, reconcileLeases: input.reconcileLeases }, + sessionId, + input.attach + ), + evict: input.close, hasProviderChild: (sessionId) => hasProviderChild(context, sessionId), isTurnActive: (sessionId) => { const session = context.sessions.get(sessionId) 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 257c7a4e4f7..2df2fdf5412 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 @@ -27,7 +27,6 @@ import { attachStructuredAgentSession } from './structured-agent-session-attach- import { createStructuredAgentSessionHolds, evictHeldStructuredAgentSession, - resumeStructuredAgentSessionForHold, type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { @@ -118,16 +117,13 @@ export class StructuredAgentSessionHost { flush: (sessionId) => this.flushStreamedEvents(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), subscribers: this.subscribers, + publishStatus: (sessionId) => this.statusFeed.publish(sessionId), now: this.now }) this.holds = createStructuredAgentSessionHolds(this.lifetimeContext(), { - resume: (sessionId) => - resumeStructuredAgentSessionForHold( - { ...this.lifetimeContext(), reconcileLeases: this.reconcileLeases }, - sessionId, - (params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params) - ), - evict: (sessionId) => this.close(sessionId) + reconcileLeases: this.reconcileLeases, + attach: (params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params), + close: (sessionId) => this.close(sessionId) }) this.restore = createStructuredAgentSessionHostRestore(deps, this.sessions, () => this.now(), { reconcile: this.reconcileLeases, @@ -149,6 +145,7 @@ export class StructuredAgentSessionHost { flushLifecycle: (sessionId) => this.runtimeState.lifecycleBarrier(sessionId), publishFence: (sessionId, session) => this.subscribers.snapshot(sessionId, session.journal, session.fence), + publishStatus: (sessionId) => this.statusFeed.publish(sessionId), hasResumeCapableHolder: (sessionId) => this.holds.hasResumeCapableHolder(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), now: () => this.now(), @@ -193,7 +190,8 @@ export class StructuredAgentSessionHost { subscribers: this.subscribers, tasks: this.tasks, reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), - serialize: (sessionId, task) => this.serialize(sessionId, task) + serialize: (sessionId, task) => this.serialize(sessionId, task), + publishStatus: (sessionId) => this.statusFeed.publish(sessionId) } } /** Releases a session's resources without ending the conversation: the record and journal stay @@ -202,6 +200,7 @@ export class StructuredAgentSessionHost { return this.serialize(sessionId, async () => { await this.handoffs.closeRetainedTuiOwner(sessionId) await evictHeldStructuredAgentSession(this.lifetimeContext(), sessionId) + this.statusFeed.revokeLive(sessionId) // Whoever asked for the close, the surfaces that were holding this session are looking at a // session that no longer exists. A failed eviction throws above and keeps them. this.holds.forget(sessionId) @@ -213,8 +212,11 @@ export class StructuredAgentSessionHost { listSessionTabs = () => listStructuredAgentSessionTabs(this.sessions) - getPersistedVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => - this.deps.store.getVisibleSessionTabIndex() + /** Last projected status for every structured session this host still holds, for non-subscribing + * readers. The retained projections of forgotten sessions are deliberately not included. */ + readonly liveSessionStatusSummaries = () => this.statusFeed.liveSessionSummaries() + + getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() setSessionTabVisibility = (sessionId: string, visible: boolean): Promise => this.deps.store.setSessionTabVisibility(sessionId, visible) 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 c1b52f879d4..1e3b9bb25a3 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 @@ -53,15 +53,24 @@ async function openJournal(sessionId = SESSION, now?: () => number) { }) } -function indexed(session: { journal: Awaited> }) { +function indexed(session: { + journal: Awaited> + hasProviderChild?: boolean +}) { return { journal: session.journal, + ...(session.hasProviderChild !== undefined + ? { hasProviderChild: session.hasProviderChild } + : {}), params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const } } } function feedFor( - sessions: Map> }>, + sessions: Map< + string, + { journal: Awaited>; hasProviderChild?: boolean } + >, record: Partial | null = null, onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged'] ) { @@ -88,6 +97,41 @@ function feedFor( } describe('StructuredAgentSessionStatusFeed', () => { + it('publishes provider ownership transitions without changing journal time', async () => { + const journal = await openJournal() + const sessions = new Map([[SESSION, { journal, hasProviderChild: true }]]) + const { feed, events, dispose } = feedFor(sessions) + events.length = 0 + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + feed.publish(SESSION, journal) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ hostExecutionOwned: true, updatedAt: expect.any(Number) }) + }) + const firstStatus = events.at(-1) + expect(firstStatus?.type).toBe('status') + if (firstStatus?.type !== 'status') { + throw new Error('status publication missing') + } + const journalTime = firstStatus.session.updatedAt + sessions.get(SESSION)!.hasProviderChild = false + feed.publish(SESSION, journal) + expect(events.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ status: 'idle', updatedAt: journalTime }) + }) + const secondStatus = events.at(-1) + expect(secondStatus?.type).toBe('status') + if (secondStatus?.type === 'status') { + expect(secondStatus.session).not.toHaveProperty('hostExecutionOwned') + } + dispose() + }) + it('opens with every readable session and reports no status before a persisted turn', async () => { const journal = await openJournal() const { events } = feedFor(new Map([[SESSION, { journal }]])) @@ -523,3 +567,37 @@ describe('StructuredAgentSessionStatusFeed', () => { }) }) }) + +/** + * `published` is a broadcast cache, not a roster. It deliberately never retracts — an evicted idle + * session is still idle, and a reloading renderer must not lose every settled row — so enumerating + * it lists every session this host has ever opened. Eviction's `forget-session` step deletes the + * session from the live map and touches nothing else, so a poller has to intersect with that map. + */ +describe('the polling reader answers from the live sessions, not the retained cache', () => { + it('drops an evicted session from the poll while a late subscriber still sees it', async () => { + const journal = await openJournal() + const sessions = new Map([[SESSION, { journal }]]) + const { feed } = feedFor(sessions) + await journal.appendItem( + USER_IDENTITY, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + feed.publish(SESSION, journal) + expect(feed.liveSessionSummaries().map((summary) => summary.sessionId)).toEqual([SESSION]) + + // Exactly what eviction's `forget-session` step does; nothing else touches the feed. + sessions.delete(SESSION) + + expect(feed.liveSessionSummaries()).toEqual([]) + const late: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'list-2', emit: (event) => late.push(event) }) + expect(late).toEqual([ + { + type: 'snapshot', + sessions: [expect.objectContaining({ sessionId: SESSION, status: 'idle' })] + } + ]) + }) +}) 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 cfd85ff4648..61d9649587e 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 @@ -30,6 +30,7 @@ export type StructuredAgentSessionStatusSubscriber = { type StatusFeedSession = { journal: AgentSessionJournal params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } + hasProviderChild?: boolean } export type StructuredAgentSessionStatusFeedDeps = { @@ -46,6 +47,7 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma a.workspaceId === b.workspaceId && a.agent === b.agent && a.status === b.status && + a.hostExecutionOwned === b.hostExecutionOwned && a.rewindBlockedReason === b.rewindBlockedReason && // Settled activity changes ranking; streaming active turns must stay quiet. (a.status !== 'idle' || a.updatedAt === b.updatedAt) && @@ -76,6 +78,27 @@ export class StructuredAgentSessionStatusFeed { return () => this.unsubscribe(subscriber.id) } + /** + * Summaries for the sessions this host still holds, for readers that poll instead of subscribing. + * + * `published` never retracts, so it is a broadcast cache and not a roster: enumerating it lists + * every session ever opened here. A caller asking what is running gets the live intersection, + * while the retained view a subscriber opens on stays whole. + * + * Deliberately does NOT re-project: a subscriber's snapshot is the live read, and re-running the + * journal reduction per caller would make an enumerating command pay for every session it lists. + */ + liveSessionSummaries(): AgentSessionStatusSummary[] { + const summaries: AgentSessionStatusSummary[] = [] + for (const [sessionId] of this.deps.sessions) { + const summary = this.published.get(sessionId) + if (summary) { + summaries.push(summary) + } + } + return summaries + } + unsubscribe(id: string): void { const subscriber = this.subscribers.get(id) if (!subscriber) { @@ -89,6 +112,20 @@ export class StructuredAgentSessionStatusFeed { } } + /** Revoke live execution authority while retaining the last projection for reload history. */ + revokeLive(sessionId: string): void { + const previous = this.published.get(sessionId) + if (!previous) { + return + } + const { hostExecutionOwned: _hostExecutionOwned, ...retained } = previous + this.published.set(sessionId, retained) + this.broadcast({ + type: 'status', + session: retained + }) + } + /** Re-projects one session after its journal changed; equal projections are not re-sent. */ publish(sessionId: string, journal?: AgentSessionJournal, options?: { replay?: boolean }): void { const session = this.deps.sessions.get(sessionId) @@ -126,6 +163,7 @@ export class StructuredAgentSessionStatusFeed { sessionId, workspaceId: session.params.location.workspaceId, agent: session.params.provider, + ...(session.hasProviderChild ? { hostExecutionOwned: true as const } : {}), ...projectStructuredAgentSessionStatusSummary(items), ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' ? { rewindBlockedReason: 'outcome-unknown' as const } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 87fe0cbbeec..ddc4af9f2d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -32,6 +32,7 @@ export type StructuredAgentSessionUnexpectedExitContext = { sessions: Map flushLifecycle: (sessionId: string) => Promise publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void + publishStatus?: (sessionId: string) => void hasResumeCapableHolder: (sessionId: string) => boolean serialize: (sessionId: string, task: () => Promise) => Promise now: () => number @@ -59,6 +60,7 @@ export async function settleUnexpectedStructuredAgentSessionExit( if (!record || record.lease.handoffStage !== null) { // The handoff coordinator owns an already-started transition. session.hasProviderChild = false + context.publishStatus?.(unexpectedEvent.sessionId) return null } @@ -117,6 +119,7 @@ export async function settleUnexpectedStructuredAgentSessionExit( context.onBarrierError?.(unexpectedEvent.sessionId, error) } finally { session.hasProviderChild = false + context.publishStatus?.(unexpectedEvent.sessionId) if (released) { session.fence = released.lease.runtimeFence context.publishFence(unexpectedEvent.sessionId, session) diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts b/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts index f1108df181c..13d2b51f284 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts @@ -21,6 +21,7 @@ export async function replaceClaudeRewindOwner( return rewindRefusal('outcome-unknown') } session.hasProviderChild = false + context.publishStatus?.(sessionId) const head = agentSessionProviderHandleChainHead( context.deps.store.getRecord(sessionId)!.providerHandleChain )?.handle diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 240156d93c6..3dbd8809018 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' import { OrcaRuntimeWithStructuredAgentSessionRecoverTuiOwner } from './orca-runtime-structured-agent-session-recover-tui-owner' import { DEFAULT_WORKTREE_PS_LIMIT } from './orca-runtime-postlude' import type { RuntimeWorktreePsResult } from '../../shared/runtime-types' @@ -9,6 +10,7 @@ import { applyRuntimeWorktreePsTerminalActivity } from './runtime-worktree-ps-activity' import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { compareWorktreePs } from './runtime-worktree-status-projection' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { Repo } from '../../shared/repo-types' @@ -102,11 +104,15 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent summaries, pathIndex: runtimeWorktreeSummaryPathIndex, missingWorktreeIds: missingRuntimeWorktreeIds, - mirroredWorktreeIdByTabId, - connectedPtyEvidence, workingTerminalEvidenceByWorktreeId, - retainedSnapshots: this.agentRows.values(), - hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? [], + rowSources: collectRuntimeWorktreeAgentSources({ + mirroredWorktreeIdByTabId, + connectedPtyEvidence, + retainedSnapshots: this.agentRows.values(), + hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? [], + // Broadcast history outlives closed sessions; only the host roster is eligible. + structuredSummaries: getStructuredAgentSessionHost()?.liveSessionStatusSummaries() ?? [] + }), orchestrationByPaneKey: this.agentOrchestrationProjection.buildByPaneKey(), getSummary: (summaryMap, pathIndex, missingIds, worktreeId) => this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId) diff --git a/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts new file mode 100644 index 00000000000..58e7de1b674 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { TEST_WORKTREE_ID, store } from '../orca-runtime-test-fixtures.spec' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../../native-chat/agent-session-journal/journal-store' +import type { StructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-host' +import { + getStructuredAgentSessionHost, + setStructuredAgentSessionHost +} from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import { StructuredAgentSessionStatusFeed } from '../../native-chat/agent-session-wire/structured-agent-session-status-feed' + +/** + * The production wiring, not the projection. Both structured-row suites call + * `attachRuntimeWorktreeAgentRows` directly with summaries they built themselves, so nothing + * executed `getWorktreePs`'s own `getStructuredAgentSessionHost()?.liveSessionStatusSummaries()` + * — and that file carries `@ts-nocheck`, so renaming the accessor stayed green in typecheck AND + * in the suite while `orca worktree ps` and mobile's poll would throw for every user. + */ + +const HELD_SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const FORGOTTEN_SESSION = 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e' +const OBSERVED_AT = 1_757_030_400_000 + +function runningTurn(prompt: string): AgentJournalRenderItem[] { + return [ + { + itemId: 'user-1', + sequence: 1, + revision: 1, + observedAt: OBSERVED_AT, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: prompt }] } + }, + { + itemId: 'turn-1', + sequence: 2, + revision: 1, + observedAt: OBSERVED_AT, + body: { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + } + } + ] +} + +function journalWith(prompt: string): AgentSessionJournal { + return { + isReadOnly: false, + lastActivityAt: () => OBSERVED_AT, + snapshot: () => ({ items: runningTurn(prompt) }) + } as unknown as AgentSessionJournal +} + +/** A real feed the host still holds one session on, having forgotten the other. Its `published` + * cache never retracts, so the two views genuinely differ. */ +function statusFeed(): StructuredAgentSessionStatusFeed { + const session = (prompt: string) => ({ + journal: journalWith(prompt), + params: { location: { workspaceId: TEST_WORKTREE_ID }, provider: 'claude' as const } + }) + const sessions = new Map([ + [HELD_SESSION, session('ship the thing')], + [FORGOTTEN_SESSION, session('rm the branch')] + ]) + const feed = new StructuredAgentSessionStatusFeed({ + sessions, + getRecord: () => null, + now: () => OBSERVED_AT + }) + feed.publish(HELD_SESSION) + feed.publish(FORGOTTEN_SESSION) + // `forget-session`, the last eviction step, drops the session and leaves the feed alone. + sessions.delete(FORGOTTEN_SESSION) + return feed +} + +/** Everything the feed retained, read through the snapshot a subscriber opens on. No production + * code reads this; it is here so swapping the call site back to a whole-cache read is a one-line + * edit that this suite must catch. */ +function retainedSummaries(feed: StructuredAgentSessionStatusFeed): AgentSessionStatusSummary[] { + let retained: AgentSessionStatusSummary[] = [] + feed.subscribe({ + id: 'retained-probe', + emit: (event: AgentSessionStatusEvent) => { + if (event.type === 'snapshot') { + retained = event.sessions + } + } + })() + return retained +} + +function installHost(feed: StructuredAgentSessionStatusFeed) { + const liveSessionStatusSummaries = vi.fn(() => feed.liveSessionSummaries()) + const retainedSessionStatusSummaries = vi.fn(() => retainedSummaries(feed)) + // Typed against the real host, so renaming the accessor on the class reddens `tc` here — the + // caller cannot, because `orca-runtime-get-worktree-ps.ts` is `@ts-nocheck`. + const host: Pick & { + retainedSessionStatusSummaries: () => AgentSessionStatusSummary[] + } = { liveSessionStatusSummaries, retainedSessionStatusSummaries } + setStructuredAgentSessionHost(host as unknown as StructuredAgentSessionHost) + return { liveSessionStatusSummaries, retainedSessionStatusSummaries } +} + +describe('worktree ps reads the installed structured host', () => { + afterEach(() => { + setStructuredAgentSessionHost(null) + }) + + it('reports the held session and asks the host for its live summaries', async () => { + const feed = statusFeed() + const { liveSessionStatusSummaries } = installHost(feed) + + const { worktrees } = await new OrcaRuntimeService(store).getWorktreePs() + + const worktree = worktrees.find((entry) => entry.worktreeId === TEST_WORKTREE_ID) + expect(worktree).toBeDefined() + // Exactly one: the forgotten session is still in the feed's retained cache, so a call site + // that enumerated that cache instead would report two. + expect(worktree?.agents).toHaveLength(1) + expect(worktree?.agents[0]).toMatchObject({ + state: 'working', + agentType: 'claude', + prompt: 'ship the thing' + }) + // Pins the call site to the live-intersecting accessor, not merely to some accessor. + expect(liveSessionStatusSummaries).toHaveBeenCalledTimes(1) + }) + + it('succeeds with no structured rows when no host is installed', async () => { + // Guard the guard: these specs share one module registry, so state the premise. + expect(getStructuredAgentSessionHost()).toBeNull() + + const { worktrees } = await new OrcaRuntimeService(store).getWorktreePs() + + const worktree = worktrees.find((entry) => entry.worktreeId === TEST_WORKTREE_ID) + expect(worktree).toBeDefined() + expect(worktree?.agents).toEqual([]) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 829c2ca321e..74098040c27 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -85,6 +85,7 @@ await import('./orca-runtime-tests/mobile-summaries.spec') await import('./orca-runtime-tests/mobile-summaries-part-02.spec') await import('./orca-runtime-tests/mobile-summaries-part-03.spec') await import('./orca-runtime-tests/mobile-summaries-part-04.spec') +await import('./orca-runtime-tests/worktree-ps-structured-host.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec') await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-03.spec') diff --git a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts new file mode 100644 index 00000000000..84966846be7 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts @@ -0,0 +1,120 @@ +import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' +import { describe, expect, it } from 'vitest' +import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../shared/structured-agent-session-projection' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' + +/** + * A structured session has no PTY, so it reaches none of the hook or retained snapshots that every + * other row comes from. Before this, `worktree ps` reported a worktree running one as idle while + * the sidebar showed it working — the CLI, which is the agent-facing surface, was the blind one. + */ +const WORKTREE_ID = 'repo-1::/workspace/app' + +function summary(over: Partial = {}): AgentSessionStatusSummary { + return { + sessionId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', + workspaceId: WORKTREE_ID, + agent: 'claude', + status: 'working', + latestPrompt: 'ship the thing', + updatedAt: 1_757_030_400_000, + hostExecutionOwned: true, + ...over + } as AgentSessionStatusSummary +} + +function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummary { + const row = { + worktreeId: WORKTREE_ID, + status: 'inactive', + hasHostSidebarActivity: false, + agents: [] + } as unknown as RuntimeWorktreePsSummary + const summariesById = new Map([[WORKTREE_ID, row]]) + attachRuntimeWorktreeAgentRows({ + summaries: summariesById, + pathIndex: { byPath: new Map(), byRealPath: new Map() } as never, + missingWorktreeIds: new Set(), + workingTerminalEvidenceByWorktreeId: new Map(), + rowSources: collectRuntimeWorktreeAgentSources({ + mirroredWorktreeIdByTabId: new Map(), + connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() }, + retainedSnapshots: [], + hookSnapshots: [], + structuredSummaries: summaries + }), + orchestrationByPaneKey: null, + getSummary: (map, _p, _m, id) => map.get(id) ?? null + }) + return row +} + +describe('worktree ps reports structured sessions', () => { + it('a busy structured session is not reported idle', () => { + const row = attach([summary()]) + expect(row.agents).toHaveLength(1) + expect(row.agents[0]?.state).toBe('working') + expect(row.agents[0]?.agentType).toBe('claude') + expect(row.agents[0]?.prompt).toBe('ship the thing') + }) + + // The same projection the sidebar applies, so the two surfaces cannot disagree about one session. + it('maps attention to blocked and idle to done', () => { + expect(attach([summary({ status: 'attention' })]).agents[0]?.state).toBe('blocked') + expect(attach([summary({ status: 'idle' })]).agents[0]?.state).toBe('done') + }) + + it('does not turn a completed host-held session into permission', () => { + const row = attach([summary({ status: 'idle' })]) + expect(row.status).toBe('inactive') + expect(row.hasHostSidebarActivity).toBe(false) + }) + + it('reports the DERIVED pane key, never an orchestration credential', () => { + const row = attach([summary()]) + const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' + expect(row.agents[0]?.paneKey).toBe( + structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId) + ) + }) + + // Null status means no turn has been persisted; the chat itself shows nothing, so neither does this. + it('omits a session with no projected status', () => { + expect(attach([summary({ status: null })]).agents).toHaveLength(0) + }) +}) + +/** + * The deliberate non-goal. Adding structured rows to `terminal list` was investigated and rejected: + * mobile mounts a terminal WebView per row that can never receive a frame, a `connected`-keyed + * refresh check goes permanently true and pins shipped clients to a fast cadence with no exit, and + * the plugin projection has no field that can carry `writable: false`. Every SAFE consumer of a + * terminal summary checks `ptyId`; the breaking ones key off `connected` or mere row presence, + * which no added field can qualify. A separate change publishes an honest partial-listing count + * there instead. This pins that only `worktree ps` gained the enumerator. + */ +describe('terminal listing is deliberately left alone', () => { + it('only worktree ps consumes the structured status summaries', async () => { + const { readFile } = await import('node:fs/promises') + // orca-runtime-subscribe-to-terminal-resize.ts owns listTerminals. + const listing = await readFile( + new URL('./orca-runtime-subscribe-to-terminal-resize.ts', import.meta.url), + 'utf8' + ) + // Guard the guard: an empty read would make every assertion below vacuously true. + expect(listing).toContain('async listTerminals(') + expect(listing).not.toContain('liveSessionStatusSummaries') + expect(listing).not.toContain('structuredSummaries') + + const worktreePs = await readFile( + new URL('./orca-runtime-get-worktree-ps.ts', import.meta.url), + 'utf8' + ) + expect(worktreePs).toContain('liveSessionStatusSummaries') + }) +}) diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index 1853f704471..80802cf11a3 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -1,34 +1,10 @@ -import { - AGENT_STATUS_STALE_AFTER_MS, - isFreshNonDoneAgentStatus, - pickParsedAgentStatusPayload, - type AgentStatusIpcPayload, - type ParsedAgentStatusPayload -} from '../../shared/agent-status-types' -import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' +import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-types' import type { RuntimeWorktreeAgentRow, RuntimeWorktreePsSummary } from '../../shared/runtime-types' -import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' -import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import { mergeWorktreeSummaryStatus } from './runtime-worktree-status-projection' import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths' import type { RuntimeWorkingTerminalEvidence } from './runtime-worktree-ps-activity' - -export type RuntimeAgentRowSnapshot = { - paneKey: string - ptyId: string - worktreeId?: string - tabId?: string - connectionId: string | null - payload: ParsedAgentStatusPayload - stateStartedAt: number - updatedAt: number -} - -type ConnectedPtyEvidence = { - tabIds: ReadonlySet - paneKeys: ReadonlySet - ptyIds: ReadonlySet -} +import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' +export type { RuntimeAgentRowSnapshot } from './runtime-worktree-pty-agent-sources' type OrchestrationDisplay = { taskTitle?: string | null @@ -36,38 +12,15 @@ type OrchestrationDisplay = { parentPaneKey?: string | null } -type RuntimeWorktreeAgentSource = { - paneKey: string - ptyId?: string - tabId?: string - worktreeId?: string - connectionId: string | null - payload: ParsedAgentStatusPayload - state: ParsedAgentStatusPayload['state'] - workingMode?: ParsedAgentStatusPayload['workingMode'] - agentType: string | null - prompt: string - lastAssistantMessage: string | null - toolName: string | null - toolInput: string | null - interrupted: boolean - stateStartedAt: number - updatedAt: number - restoredUnconfirmed?: boolean -} - export function attachRuntimeWorktreeAgentRows(args: { summaries: Map pathIndex: RuntimeWorktreeSummaryPathIndex missingWorktreeIds: Set - mirroredWorktreeIdByTabId: ReadonlyMap - connectedPtyEvidence: ConnectedPtyEvidence + rowSources: ReadonlyMap workingTerminalEvidenceByWorktreeId: ReadonlyMap< string, readonly RuntimeWorkingTerminalEvidence[] > - retainedSnapshots: Iterable - hookSnapshots: readonly AgentStatusIpcPayload[] orchestrationByPaneKey: Record | null | undefined getSummary: ( summaries: Map, @@ -76,88 +29,11 @@ export function attachRuntimeWorktreeAgentRows(args: { worktreeId: string ) => RuntimeWorktreePsSummary | null }): void { - const rowSources = new Map() + const { rowSources } = args const now = Date.now() - for (const snapshot of args.retainedSnapshots) { - const { payload } = snapshot - rowSources.set(snapshot.paneKey, { - paneKey: snapshot.paneKey, - ptyId: snapshot.ptyId, - tabId: snapshot.tabId, - worktreeId: snapshot.worktreeId, - connectionId: snapshot.connectionId, - payload, - state: payload.state, - ...(payload.workingMode ? { workingMode: payload.workingMode } : {}), - agentType: payload.agentType ?? null, - prompt: payload.prompt, - lastAssistantMessage: payload.lastAssistantMessage ?? null, - toolName: payload.toolName ?? null, - toolInput: payload.toolInput ?? null, - interrupted: payload.interrupted ?? false, - stateStartedAt: snapshot.stateStartedAt, - updatedAt: snapshot.updatedAt - }) - } - for (const entry of args.hookSnapshots) { - if (entry.restoredUnconfirmed === true) { - continue - } - const existing = rowSources.get(entry.paneKey) - const hookPayload = pickParsedAgentStatusPayload(entry) - if (existing && existing.updatedAt > entry.receivedAt) { - if ( - entry.workingMode === 'monitoring' && - now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS && - terminalStatusPayloadMatchesHook(hookPayload, existing.payload) - ) { - existing.workingMode = 'monitoring' - if (existing.payload.workingMode === undefined) { - existing.payload = { ...existing.payload, workingMode: 'monitoring' } - } - } - continue - } - rowSources.set(entry.paneKey, { - paneKey: entry.paneKey, - ptyId: existing?.ptyId, - tabId: entry.tabId, - worktreeId: entry.worktreeId, - connectionId: entry.connectionId, - payload: hookPayload, - state: entry.state, - ...(entry.workingMode ? { workingMode: entry.workingMode } : {}), - agentType: entry.agentType ?? null, - prompt: entry.prompt, - lastAssistantMessage: entry.lastAssistantMessage ?? null, - toolName: entry.toolName ?? null, - toolInput: entry.toolInput ?? null, - interrupted: entry.interrupted ?? false, - stateStartedAt: entry.stateStartedAt, - updatedAt: entry.receivedAt - }) - } - if (rowSources.size === 0) { - return - } const rowsByWorktree = new Map() for (const source of rowSources.values()) { - const tabId = - source.tabId ?? - parsePaneKey(source.paneKey)?.tabId ?? - parseLegacyNumericPaneKey(source.paneKey)?.tabId - const mirroredWorktreeId = tabId ? args.mirroredWorktreeIdByTabId.get(tabId) : undefined - if ( - tabId !== undefined && - mirroredWorktreeId === undefined && - (source.connectionId === null || isWslHookRelayConnectionId(source.connectionId)) && - !args.connectedPtyEvidence.tabIds.has(tabId) && - !args.connectedPtyEvidence.paneKeys.has(source.paneKey) && - (source.ptyId === undefined || !args.connectedPtyEvidence.ptyIds.has(source.ptyId)) - ) { - continue - } - const worktreeId = mirroredWorktreeId ?? source.worktreeId + const { worktreeId } = source if (!worktreeId) { continue } @@ -204,13 +80,15 @@ export function attachRuntimeWorktreeAgentRows(args: { let hasForegroundWorkingAgent = false const monitoringSources: RuntimeWorktreeAgentSource[] = [] for (const row of rows) { - if (!isFreshNonDoneAgentStatus(row, now)) { + const source = rowSources.get(row.paneKey) + const hostHeldStructuredSession = + source?.authority === 'structured-host' && row.state !== 'done' + if (!hostHeldStructuredSession && !isFreshNonDoneAgentStatus(row, now)) { continue } summary.hasHostSidebarActivity = true if (row.state === 'working') { if (row.workingMode === 'monitoring') { - const source = rowSources.get(row.paneKey) if (source) { monitoringSources.push(source) } diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts new file mode 100644 index 00000000000..2e8bc833b23 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -0,0 +1,21 @@ +import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' + +export type RuntimeWorktreeAgentSource = { + paneKey: string + ptyId?: string + tabId?: string + worktreeId?: string + connectionId: string | null + state: ParsedAgentStatusPayload['state'] + workingMode?: ParsedAgentStatusPayload['workingMode'] + agentType: string | null + prompt: string + lastAssistantMessage: string | null + toolName: string | null + toolInput: string | null + interrupted: boolean + stateStartedAt: number + updatedAt: number + /** Structured host projections remain authoritative after PTY freshness expiry. */ + authority?: 'structured-host' +} diff --git a/src/main/runtime/runtime-worktree-agent-sources.test.ts b/src/main/runtime/runtime-worktree-agent-sources.test.ts new file mode 100644 index 00000000000..c0395cd6831 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-sources.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' +import type { RuntimeAgentRowSnapshot } from './runtime-worktree-pty-agent-sources' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' + +const paneKey = 'worktree:tab:0' +const now = Date.now() +const retained: RuntimeAgentRowSnapshot = { + paneKey, + ptyId: 'pty', + tabId: 'tab', + worktreeId: 'worktree', + connectionId: null, + payload: { state: 'working', prompt: 'implement', agentType: 'codex' }, + stateStartedAt: now, + updatedAt: now +} +const base = { + retainedSnapshots: [retained], + hookSnapshots: [] as AgentStatusIpcPayload[], + structuredSummaries: [], + mirroredWorktreeIdByTabId: new Map(), + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIds: new Set() + } +} + +describe('worktree agent source admission', () => { + it('rejects a disconnected local terminal before row assembly', () => { + expect(collectRuntimeWorktreeAgentSources(base).size).toBe(0) + const connected = { + ...base, + connectedPtyEvidence: { ...base.connectedPtyEvidence, ptyIds: new Set(['pty']) } + } + expect(collectRuntimeWorktreeAgentSources(connected).get(paneKey)?.state).toBe('working') + }) + + it('keeps remote evidence and resolves mirrored workspace ownership', () => { + const remote = { ...retained, connectionId: 'ssh-connection' } + expect(collectRuntimeWorktreeAgentSources({ ...base, retainedSnapshots: [remote] }).size).toBe( + 1 + ) + const sources = collectRuntimeWorktreeAgentSources({ + ...base, + mirroredWorktreeIdByTabId: new Map([['tab', 'remote-worktree']]) + }) + expect(sources.get(paneKey)?.worktreeId).toBe('remote-worktree') + }) + + it('preserves fresh monitoring enrichment on a newer retained report', () => { + const hook: AgentStatusIpcPayload = { + ...retained.payload, + paneKey, + tabId: 'tab', + worktreeId: 'worktree', + connectionId: null, + stateStartedAt: now - 1, + receivedAt: now - 1, + workingMode: 'monitoring' + } + const sources = collectRuntimeWorktreeAgentSources({ + ...base, + hookSnapshots: [hook], + connectedPtyEvidence: { ...base.connectedPtyEvidence, ptyIds: new Set(['pty']) } + }) + expect(sources.get(paneKey)).toMatchObject({ updatedAt: now, workingMode: 'monitoring' }) + }) +}) diff --git a/src/main/runtime/runtime-worktree-agent-sources.ts b/src/main/runtime/runtime-worktree-agent-sources.ts new file mode 100644 index 00000000000..44ce7848960 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-sources.ts @@ -0,0 +1,22 @@ +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import { collectRuntimeWorktreePtyAgentSources } from './runtime-worktree-pty-agent-sources' +import { structuredRuntimeWorktreeAgentSources } from './runtime-worktree-structured-agent-rows' +import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' + +/** One admitted roster for row and worktree-status projection. */ +export function collectRuntimeWorktreeAgentSources( + args: Parameters[0] & { + structuredSummaries: readonly AgentSessionStatusSummary[] + } +): ReadonlyMap { + const sources = new Map() + for (const source of collectRuntimeWorktreePtyAgentSources(args)) { + sources.set(source.paneKey, source) + } + for (const source of structuredRuntimeWorktreeAgentSources(args.structuredSummaries)) { + if (!sources.has(source.paneKey)) { + sources.set(source.paneKey, source) + } + } + return sources +} diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts new file mode 100644 index 00000000000..04e8dffc04e --- /dev/null +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -0,0 +1,121 @@ +import { + AGENT_STATUS_STALE_AFTER_MS, + pickParsedAgentStatusPayload, + type AgentStatusIpcPayload, + type ParsedAgentStatusPayload +} from '../../shared/agent-status-types' +import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' +import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' + +export type RuntimeAgentRowSnapshot = { + paneKey: string + ptyId: string + worktreeId?: string + tabId?: string + connectionId: string | null + payload: ParsedAgentStatusPayload + stateStartedAt: number + updatedAt: number +} + +export type ConnectedPtyEvidence = { + tabIds: ReadonlySet + paneKeys: ReadonlySet + ptyIds: ReadonlySet +} + +/** Reconcile terminal status, then admit rows using their execution-host evidence. */ +export function collectRuntimeWorktreePtyAgentSources(args: { + retainedSnapshots: Iterable + hookSnapshots: readonly AgentStatusIpcPayload[] + mirroredWorktreeIdByTabId: ReadonlyMap + connectedPtyEvidence: ConnectedPtyEvidence +}): RuntimeWorktreeAgentSource[] { + const rowSources = new Map< + string, + RuntimeWorktreeAgentSource & { payload: ParsedAgentStatusPayload } + >() + const now = Date.now() + for (const snapshot of args.retainedSnapshots) { + const { payload } = snapshot + rowSources.set(snapshot.paneKey, { + paneKey: snapshot.paneKey, + ptyId: snapshot.ptyId, + tabId: snapshot.tabId, + worktreeId: snapshot.worktreeId, + connectionId: snapshot.connectionId, + payload, + state: payload.state, + ...(payload.workingMode ? { workingMode: payload.workingMode } : {}), + agentType: payload.agentType ?? null, + prompt: payload.prompt, + lastAssistantMessage: payload.lastAssistantMessage ?? null, + toolName: payload.toolName ?? null, + toolInput: payload.toolInput ?? null, + interrupted: payload.interrupted ?? false, + stateStartedAt: snapshot.stateStartedAt, + updatedAt: snapshot.updatedAt + }) + } + for (const entry of args.hookSnapshots) { + if (entry.restoredUnconfirmed === true) { + continue + } + const existing = rowSources.get(entry.paneKey) + const hookPayload = pickParsedAgentStatusPayload(entry) + if (existing && existing.updatedAt > entry.receivedAt) { + if ( + entry.workingMode === 'monitoring' && + now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS && + terminalStatusPayloadMatchesHook(hookPayload, existing.payload) + ) { + existing.workingMode = 'monitoring' + if (existing.payload.workingMode === undefined) { + existing.payload = { ...existing.payload, workingMode: 'monitoring' } + } + } + continue + } + rowSources.set(entry.paneKey, { + paneKey: entry.paneKey, + ptyId: existing?.ptyId, + tabId: entry.tabId, + worktreeId: entry.worktreeId, + connectionId: entry.connectionId, + payload: hookPayload, + state: entry.state, + ...(entry.workingMode ? { workingMode: entry.workingMode } : {}), + agentType: entry.agentType ?? null, + prompt: entry.prompt, + lastAssistantMessage: entry.lastAssistantMessage ?? null, + toolName: entry.toolName ?? null, + toolInput: entry.toolInput ?? null, + interrupted: entry.interrupted ?? false, + stateStartedAt: entry.stateStartedAt, + updatedAt: entry.receivedAt + }) + } + const sources: RuntimeWorktreeAgentSource[] = [] + for (const source of rowSources.values()) { + const tabId = + source.tabId ?? + parsePaneKey(source.paneKey)?.tabId ?? + parseLegacyNumericPaneKey(source.paneKey)?.tabId + const mirroredWorktreeId = tabId ? args.mirroredWorktreeIdByTabId.get(tabId) : undefined + if ( + tabId !== undefined && + mirroredWorktreeId === undefined && + (source.connectionId === null || isWslHookRelayConnectionId(source.connectionId)) && + !args.connectedPtyEvidence.tabIds.has(tabId) && + !args.connectedPtyEvidence.paneKeys.has(source.paneKey) && + (source.ptyId === undefined || !args.connectedPtyEvidence.ptyIds.has(source.ptyId)) + ) { + continue + } + const worktreeId = mirroredWorktreeId ?? source.worktreeId + sources.push({ ...source, tabId, worktreeId }) + } + return sources +} diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts new file mode 100644 index 00000000000..063ee0b36cf --- /dev/null +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -0,0 +1,157 @@ +import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' +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 { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' +import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' + +/** + * The whole chain `worktree ps` walks: journal -> status feed -> agent rows -> worktree status. + * + * The feed's `published` map never retracts, so reading it as a roster reports every session the + * app has ever opened. A closed chat that was waiting on an approval is the sharp edge: deliberate + * close does not settle a pending prompt, so the retained summary stays `attention`, which maps to + * a `blocked` row and merges the worktree to `permission` for the 30-minute freshness window. + */ +const WORKTREE_ID = 'repo-1::/workspace/app' +const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const IDENTITY = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 0 +} as const + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-structured-ps-liveness-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +/** A session parked on an approval nobody answered — the state a deliberate close leaves behind. */ +async function awaitingApproval() { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: WORKTREE_ID, + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, SESSION) + }) + await journal.appendItem( + { ...IDENTITY, ordinal: 1 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'rm the branch' }] }, + { fence: 1 } + ) + await journal.appendItem( + { ...IDENTITY, ordinal: 2 }, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence: 1 } + ) + const sessions = new Map([ + [ + SESSION, + { journal, params: { location: { workspaceId: WORKTREE_ID }, provider: 'codex' as const } } + ] + ]) + const feed = new StructuredAgentSessionStatusFeed({ + sessions, + getRecord: () => null, + now: () => Date.now() + }) + feed.publish(SESSION, journal) + return { feed, sessions } +} + +function worktreeFor( + feed: StructuredAgentSessionStatusFeed, + summaries = feed.liveSessionSummaries() +): RuntimeWorktreePsSummary { + const row = { + worktreeId: WORKTREE_ID, + status: 'inactive', + agents: [] + } as unknown as RuntimeWorktreePsSummary + attachRuntimeWorktreeAgentRows({ + summaries: new Map([[WORKTREE_ID, row]]), + pathIndex: { byPath: new Map(), byRealPath: new Map() } as never, + missingWorktreeIds: new Set(), + workingTerminalEvidenceByWorktreeId: new Map(), + rowSources: collectRuntimeWorktreeAgentSources({ + mirroredWorktreeIdByTabId: new Map(), + connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() }, + retainedSnapshots: [], + hookSnapshots: [], + structuredSummaries: summaries + }), + orchestrationByPaneKey: null, + getSummary: (map, _paths, _missing, id) => map.get(id) ?? null + }) + return row +} + +describe('worktree ps and a closed structured chat', () => { + it('reports the blocked row while the session is still held', async () => { + const { feed } = await awaitingApproval() + const row = worktreeFor(feed) + expect(row.agents).toHaveLength(1) + expect(row.agents[0]?.state).toBe('blocked') + expect(row.status).toBe('permission') + }) + + it('stops reporting it once eviction forgets the session', async () => { + const { feed, sessions } = await awaitingApproval() + // `forget-session`, the last eviction step, does exactly this and nothing to the feed. + sessions.delete(SESSION) + + const row = worktreeFor(feed) + expect(row.agents).toHaveLength(0) + expect(row.status).toBe('inactive') + }) + + it('keeps an aged host-held working state authoritative', async () => { + const { feed } = await awaitingApproval() + const aged = feed.liveSessionSummaries().map((summary) => ({ + ...summary, + hostExecutionOwned: true as const, + updatedAt: Date.now() - 30 * 60 * 1000 - 1, + status: 'working' as const + })) + const row = worktreeFor(feed, aged) + expect(row.agents).toHaveLength(1) + expect(row.agents[0]?.state).toBe('working') + expect(row.status).toBe('working') + expect(row.agents[0]?.updatedAt).toBe(aged[0]?.updatedAt) + }) + + it('keeps an aged host-held approval state authoritative', async () => { + const { feed } = await awaitingApproval() + const aged = feed.liveSessionSummaries().map((summary) => ({ + ...summary, + hostExecutionOwned: true as const, + updatedAt: Date.now() - 30 * 60 * 1000 - 1 + })) + const row = worktreeFor(feed, aged) + expect(row.agents).toHaveLength(1) + expect(row.agents[0]?.state).toBe('blocked') + expect(row.status).toBe('permission') + expect(row.agents[0]?.updatedAt).toBe(aged[0]?.updatedAt) + }) +}) diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows.ts b/src/main/runtime/runtime-worktree-structured-agent-rows.ts new file mode 100644 index 00000000000..c4d075fbd9c --- /dev/null +++ b/src/main/runtime/runtime-worktree-structured-agent-rows.ts @@ -0,0 +1,48 @@ +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionStatusState, + structuredAgentSessionTabId +} from '../../shared/structured-agent-session-projection' +import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' + +/** + * Row sources for the structured (non-PTY) sessions a host still holds. + * + * A structured session reaches none of the hook or retained snapshots every other row comes from, + * so `worktree ps` projects it from the host's status feed instead. The feed's retained + * projections are not a roster — the caller passes only sessions the host still holds. + */ +export function structuredRuntimeWorktreeAgentSources( + summaries: readonly AgentSessionStatusSummary[] +): RuntimeWorktreeAgentSource[] { + const sources: RuntimeWorktreeAgentSource[] = [] + for (const summary of summaries) { + // No turn has been persisted yet, so there is nothing to report - the same read the chat shows. + if (!summary.status) { + continue + } + const tabId = structuredAgentSessionTabId(summary.sessionId) + // The DERIVED pane key the renderer already publishes, never the orchestration bearer handle + // or the minted worker pane key: both of those are credentials. + sources.push({ + paneKey: structuredAgentSessionPaneKey(tabId, summary.sessionId), + tabId, + worktreeId: summary.workspaceId, + connectionId: null, + // The shared mapping the sidebar applies, so the CLI and the GUI cannot disagree about one + // session. No hook payload: nothing reads one off a structured row. + state: structuredAgentSessionStatusState(summary.status), + agentType: summary.agent, + prompt: summary.latestPrompt, + lastAssistantMessage: summary.lastAssistantMessage ?? null, + toolName: summary.toolName ?? null, + toolInput: summary.toolInput ?? null, + interrupted: false, + stateStartedAt: summary.updatedAt, + updatedAt: summary.updatedAt, + ...(summary.hostExecutionOwned ? { authority: 'structured-host' as const } : {}) + }) + } + return sources +} diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index c4376dbf66e..1276287aca0 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -7,6 +7,7 @@ import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' import { resolveAttention } from '../sidebar/smart-attention' +import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { Tab } from '../../../../shared/tab-types' import type { AppState } from '@/store/types' @@ -88,6 +89,7 @@ function summary(overrides: Partial = {}): AgentSessi workspaceId: 'wt-1', agent: 'codex', status: 'working', + hostExecutionOwned: true, latestPrompt: 'hello', providerSession, updatedAt: 1, @@ -180,6 +182,26 @@ describe('StructuredAgentSessionStatusBridge', () => { // Hiddenness is the host's side of this: see structured-agent-session-subscribers.test.ts, // which drives an unsubscribed journal through the feed. Here the transport is a mock, so // only the summary-to-store mapping is under test. + it('keeps host-held working evidence active past the normal freshness window', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + const updatedAt = Date.now() - 30 * 60 * 1000 - 1 + act(() => feed().emit({ type: 'status', session: summary({ updatedAt }) })) + const entry = statuses()[0] + expect(entry).toEqual(expect.objectContaining({ state: 'working', structuredHostOwned: true })) + expect(isExplicitAgentStatusFresh(entry, Date.now(), 30 * 60 * 1000)).toBe(true) + }) + + it('clears host-held evidence when the status stream disconnects', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'status', session: summary() })) + expect(statuses()).toHaveLength(1) + act(() => feed().emit({ type: 'end' })) + expect(statuses()).toHaveLength(1) + expect(statuses()[0]).not.toHaveProperty('structuredHostOwned') + }) + it('maps each host status onto the sidebar agent state', async () => { render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index a72f28d7c08..47d6aec460f 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -2,7 +2,10 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' import { useShallow } from 'zustand/react/shallow' import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' -import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionStatusState +} from '../../../../shared/structured-agent-session-projection' import type { Tab } from '../../../../shared/tab-types' import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -67,12 +70,8 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | return } const desired = { - state: - summary.status === 'working' - ? 'working' - : summary.status === 'attention' - ? 'blocked' - : 'done', + // Shared with `worktree ps`, so the CLI and this row cannot disagree about one session. + state: structuredAgentSessionStatusState(summary.status), prompt: summary.latestPrompt, agentType: tab.agentSessionAgent, // The host projects these from the journal so the row reads like a hook-reported one: @@ -99,6 +98,7 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | current.tabId === tab.id && current.worktreeId === tab.worktreeId && current.terminalResumeEligible === false && + current.structuredHostOwned === summary.hostExecutionOwned && agentProviderSessionsEqual( tab.agentSessionAgent, current.providerSession, @@ -119,12 +119,13 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary | desired.state !== 'done' && current?.state === desired.state ? current.stateStartedAt : summary.updatedAt, - evidenceObservedAt: Date.now() + evidenceObservedAt: summary.updatedAt }, { tabId: tab.id, worktreeId: tab.worktreeId }, { ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), - terminalResumeEligible: false + terminalResumeEligible: false, + ...(summary.hostExecutionOwned ? { structuredHostOwned: true as const } : {}) } ) } diff --git a/src/renderer/src/lib/pane-agent-evidence.ts b/src/renderer/src/lib/pane-agent-evidence.ts index 51e306becca..67dbbf85c6f 100644 --- a/src/renderer/src/lib/pane-agent-evidence.ts +++ b/src/renderer/src/lib/pane-agent-evidence.ts @@ -18,14 +18,20 @@ import { export function isExplicitAgentStatusFresh( entry: Pick< AgentStatusEntry, - 'updatedAt' | 'evidenceObservedAt' | 'mirroredEvidenceReceivedAt' | 'restoredUnconfirmed' + | 'updatedAt' + | 'evidenceObservedAt' + | 'mirroredEvidenceReceivedAt' + | 'restoredUnconfirmed' + | 'structuredHostOwned' >, now: number, staleAfterMs: number ): boolean { // Why: an unconfirmed hydrated row may describe a turn that ended while no receiver was up; never fresh. return ( - entry.restoredUnconfirmed !== true && now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs + entry.restoredUnconfirmed !== true && + (entry.structuredHostOwned === true || + now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs) ) } diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts new file mode 100644 index 00000000000..4f98700d806 --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' + +const mocks = vi.hoisted(() => ({ subscribe: vi.fn() })) +vi.mock('./structured-agent-session-client', () => ({ + subscribeStructuredAgentSessionStatus: mocks.subscribe +})) +vi.mock('./runtime-rpc-client', () => ({ runtimeEnvironmentSupportsCapability: vi.fn() })) + +import { + getStructuredAgentSessionStatusFeed, + resetStructuredAgentSessionStatusFeedsForTests +} from './structured-agent-session-status-feed' + +type Subscription = { + emit: (event: AgentSessionStatusEvent) => void + unsubscribe: ReturnType +} +const subscriptions: Subscription[] = [] +const owned: AgentSessionStatusSummary = { + sessionId: 'running', + workspaceId: 'workspace', + agent: 'codex', + status: 'working', + latestPrompt: 'work', + updatedAt: 1, + hostExecutionOwned: true +} +const done: AgentSessionStatusSummary = { + ...owned, + sessionId: 'completed', + status: 'idle', + hostExecutionOwned: undefined +} + +function subscription(index = 0): Subscription { + const value = subscriptions[index] + if (!value) { + throw new Error('missing subscription') + } + return value +} + +describe('structured status feed execution authority lifecycle', () => { + beforeEach(() => { + vi.useFakeTimers() + resetStructuredAgentSessionStatusFeedsForTests() + subscriptions.length = 0 + mocks.subscribe.mockReset() + mocks.subscribe.mockImplementation((_target, emit: Subscription['emit']) => { + const unsubscribe = vi.fn(() => emit({ type: 'end' })) + subscriptions.push({ emit, unsubscribe }) + return Promise.resolve({ unsubscribe }) + }) + }) + + afterEach(() => { + resetStructuredAgentSessionStatusFeedsForTests() + vi.useRealTimers() + }) + + it('revokes on end before reentrant unsubscribe and ignores late frames', async () => { + const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) + feed.activate() + await vi.advanceTimersByTimeAsync(0) + subscription().emit({ type: 'snapshot', sessions: [owned, done] }) + subscription().emit({ type: 'end' }) + expect(subscription().unsubscribe).toHaveBeenCalledOnce() + expect(feed.getSnapshot().get('running')).toEqual({ ...owned, hostExecutionOwned: undefined }) + expect(feed.getSnapshot().get('completed')).toBe(done) + subscription().emit({ type: 'status', session: owned }) + expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(250) + subscription(1).emit({ type: 'snapshot', sessions: [done] }) + expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined() + subscription(1).emit({ type: 'status', session: owned }) + expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBe(true) + }) + + it('retains history without ownership while stopped and until remount receives fresh evidence', async () => { + const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) + const deactivate = feed.activate() + await vi.advanceTimersByTimeAsync(0) + subscription().emit({ type: 'snapshot', sessions: [owned, done] }) + deactivate() + expect(vi.getTimerCount()).toBe(0) + expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined() + expect(feed.getSnapshot().get('running')?.updatedAt).toBe(owned.updatedAt) + expect(feed.getSnapshot().get('completed')).toBe(done) + feed.activate() + expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined() + await vi.advanceTimersByTimeAsync(0) + subscription().emit({ type: 'status', session: owned }) + expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined() + subscription(1).emit({ type: 'snapshot', sessions: [owned] }) + expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBe(true) + }) + + it('does not notify or reallocate already unowned historical rows on teardown', async () => { + const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) + const deactivate = feed.activate() + await vi.advanceTimersByTimeAsync(0) + subscription().emit({ type: 'snapshot', sessions: [done] }) + const previous = feed.getSnapshot() + const listener = vi.fn() + feed.subscribe(listener) + deactivate() + expect(feed.getSnapshot()).toBe(previous) + expect(listener).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index 2b550679315..8308ac8729a 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -82,6 +82,32 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { handle?.unsubscribe() handle = null } + const revokeSnapshotOwnership = (): void => { + let next: Map | null = null + for (const [sessionId, summary] of snapshot) { + if (!summary.hostExecutionOwned) { + continue + } + if (!next) { + next = new Map(snapshot) + } + const { hostExecutionOwned: _owned, ...retained } = summary + next.set(sessionId, retained) + } + if (next) { + snapshot = next + emit() + } + } + const fenceCandidateAndReconnect = (candidate: number): void => { + if (candidate !== generation) { + return + } + generation += 1 + revokeSnapshotOwnership() + dropHandle() + scheduleReconnect(generation) + } let open = (): void => {} const scheduleReconnect = (candidate: number): void => { if (!active(candidate) || reconnectTimer) { @@ -104,22 +130,19 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { return } if (event.type === 'end') { - dropHandle() - scheduleReconnect(candidate) + fenceCandidateAndReconnect(candidate) return } applyEvent(event) }, () => { if (active(candidate)) { - dropHandle() - scheduleReconnect(candidate) + fenceCandidateAndReconnect(candidate) } }, () => { if (active(candidate)) { - dropHandle() - scheduleReconnect(candidate) + fenceCandidateAndReconnect(candidate) } } ) @@ -130,7 +153,13 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { opened.unsubscribe() } }) - .catch(() => scheduleReconnect(candidate)) + .catch(() => { + if (active(candidate)) { + fenceCandidateAndReconnect(candidate) + } else { + scheduleReconnect(candidate) + } + }) } open = (): void => { const candidate = ++generation @@ -163,6 +192,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { generation += 1 clearReconnect() dropHandle() + revokeSnapshotOwnership() reconnectAttempt = 0 } diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index 39bbde535d1..453fef02139 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -108,6 +108,8 @@ export type AgentStatusRouting = { } export type AgentStatusMetadata = { + /** Structured status rows remain fresh while the host owns the session; cleared on feed loss. */ + structuredHostOwned?: true providerSession?: AgentProviderSessionMetadata launchConfig?: SleepingAgentLaunchConfig launchToken?: string 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 5c3ef89bdaf..63ba0a5afc9 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 @@ -227,6 +227,7 @@ export function buildAgentStatusLiveEntry( ...(timing?.evidenceObservedAt !== undefined ? { evidenceObservedAt: timing.evidenceObservedAt } : {}), + ...(metadata?.structuredHostOwned === true ? { structuredHostOwned: true as const } : {}), stateStartedAt, agentType: identity.agentType, model: diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 70d464d5392..0f7c453752f 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -196,6 +196,8 @@ export type AgentSessionStatusSummary = { agent: AgentSessionRecord['provider'] /** Null until the journal holds a persisted user or assistant message. */ status: StructuredAgentSessionProjectedStatus | null + /** Present only while this host has the provider child executing the session. */ + hostExecutionOwned?: true latestPrompt: string /** Provider model in force for the next turn; absent until the host has read the options. */ model?: string diff --git a/src/shared/agent-status-freshness.ts b/src/shared/agent-status-freshness.ts index 84a3218e3dd..616879b93e9 100644 --- a/src/shared/agent-status-freshness.ts +++ b/src/shared/agent-status-freshness.ts @@ -37,6 +37,7 @@ export function isFreshNonDoneAgentStatus( | 'evidenceObservedAt' | 'mirroredEvidenceReceivedAt' | 'restoredUnconfirmed' + | 'structuredHostOwned' > | undefined, now = Date.now(), @@ -47,6 +48,7 @@ export function isFreshNonDoneAgentStatus( entry && entry.state !== 'done' && entry.restoredUnconfirmed !== true && - now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs + (entry.structuredHostOwned === true || + now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs) ) } diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 128e2f80f82..cc0ad487565 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -114,6 +114,8 @@ export type AgentStatusEntry = { * which is the delivery/ordering clock a relay reconnect must restamp to stay monotonic. * Absent for locally derived rows and old hosts; freshness falls back to `updatedAt`. */ evidenceObservedAt?: number + /** True only while a host-held structured session is represented by its live status feed. */ + structuredHostOwned?: true /** Timestamp (ms) when the current `state` was first reported. * Why: separate from updatedAt so tool/prompt pings (which reset updatedAt) don't move it. */ stateStartedAt: number diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 02ad872c211..1301f766e2b 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -299,6 +299,14 @@ export function projectStructuredAgentSessionStatusSummary( } } +/** The agent-status state one projected session status stands for. Shared across the process + * boundary so `worktree ps` and the sidebar cannot disagree about the same session. */ +export function structuredAgentSessionStatusState( + status: StructuredAgentSessionProjectedStatus +): 'working' | 'blocked' | 'done' { + return status === 'working' ? 'working' : status === 'attention' ? 'blocked' : 'done' +} + export function structuredAgentSessionPaneKey(tabId: string, sessionId: string): string { const bytes = sha256(new TextEncoder().encode(sessionId)) const hex = Array.from(bytes.slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join('') From 210ac1f77c1832c8ae20e9503110799cbbaf7e34 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:52:25 -0700 Subject: [PATCH 03/14] fix(native-chat): expand Claude slash commands sent with an attachment (#19654) * fix(native-chat): keep the text block last in Claude dispatch content Claude reads a streamed user message as a slash-command invocation only when the last content block is text. The composer builds the send body as text-then-images, and the Claude dispatch mapper preserved that order, so any slash command sent with an attachment arrived as literal prose instead of running -- /review, /init and the rest, not just one command. Emit images ahead of text in the mapper. Images-before-text is also the order Claude prefers for vision, so this is unconditional rather than slash-only. Fixed in the Claude mapper rather than the shared send body: that body also defines rendered journal block order for every provider. * fix(native-chat): derive Claude slash-command recognition from the sent prompt Claude encodes a user turn as attachment blocks followed by the typed text and recovers the prompt by reading only the trailing text block, so a body ending in an image has no recoverable prompt and its `/command` reaches the model as prose. The composer builds text-then-images, so that was every send with an attachment. Emit one trailing text block rather than appending each block: a partitioned `[...images, ...texts]` still strands a command ahead of trailing prose, because only the last block is read. Derive `acceptsResult` from that same sent content so the mapper and the dispatch waiter cannot disagree about whether a command ran; the previous `blocks.some(trimStart)` was strictly more permissive than Claude, which does not trim before matching `/`. * test(native-chat): pin the sent Claude content order and the joined prompt verdict The attachment scenario passed unchanged against main: it only exercised acceptsResult, which was already true there. Assert the send payload, and pin that joining narrows a command hiding behind leading prose. Reuse claudeRecord for the content-key narrowing the same file now imports. --------- Co-authored-by: Merge Sim --- ...claude-structured-dispatch-content.test.ts | 165 ++++++++++++++++++ .../claude-structured-dispatch-content.ts | 39 +++-- .../claude/claude-structured-dispatch.test.ts | 67 +++++++ src/main/claude/claude-structured-dispatch.ts | 7 +- 4 files changed, 264 insertions(+), 14 deletions(-) create mode 100644 src/main/claude/claude-structured-dispatch-content.test.ts diff --git a/src/main/claude/claude-structured-dispatch-content.test.ts b/src/main/claude/claude-structured-dispatch-content.test.ts new file mode 100644 index 00000000000..02ee6fa721d --- /dev/null +++ b/src/main/claude/claude-structured-dispatch-content.test.ts @@ -0,0 +1,165 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { + claudeDispatchInvokesSlashCommand, + claudeDispatchMessageContent +} from './claude-structured-dispatch-content' + +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' +) + +function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks } +} + +const REMOTE_IMAGE = { type: 'image-ref' as const, url: 'https://example.test/a.png' } + +describe('claudeDispatchMessageContent', () => { + it('puts the text block last so a slash command still expands with an attachment', async () => { + const content = await claudeDispatchMessageContent( + // The composer builds text-then-images; Claude only treats a leading `/` as a + // command when the LAST block is text. + userMessage([{ type: 'text', text: '/goal ship the parser' }, REMOTE_IMAGE]) + ) + + expect(content).toEqual([ + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }, + { type: 'text', text: '/goal ship the parser' } + ]) + }) + + it('keeps every image ahead of the text and preserves each side’s order', async () => { + const second = { type: 'image-ref' as const, url: 'https://example.test/b.png' } + + const content = await claudeDispatchMessageContent( + userMessage([{ type: 'text', text: 'look' }, REMOTE_IMAGE, second]) + ) + + expect(content.map((part) => (part as { type: string }).type)).toEqual([ + 'image', + 'image', + 'text' + ]) + expect(content[0]).toEqual({ + type: 'image', + source: { type: 'url', url: 'https://example.test/a.png' } + }) + expect(content[1]).toEqual({ + type: 'image', + source: { type: 'url', url: 'https://example.test/b.png' } + }) + }) + + it('sends text alone unchanged', async () => { + const content = await claudeDispatchMessageContent(userMessage([{ type: 'text', text: 'hi' }])) + + expect(content).toEqual([{ type: 'text', text: 'hi' }]) + }) + + it('sends an image with no text', async () => { + const content = await claudeDispatchMessageContent(userMessage([REMOTE_IMAGE])) + + expect(content).toEqual([ + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } } + ]) + }) + + it('rejects a message with no renderable block', async () => { + await expect( + claudeDispatchMessageContent(userMessage([{ type: 'text', text: '' }])) + ).rejects.toThrow('Claude dispatch requires text or an image') + }) + + it('rejects a non-user message', async () => { + await expect( + claudeDispatchMessageContent({ + ...userMessage([{ type: 'text', text: 'hi' }]), + role: 'assistant' + }) + ).rejects.toThrow('Claude dispatch accepts only user messages') + }) + + it('joins several text blocks so a command is not stranded ahead of trailing prose', async () => { + // Appending each block would leave `thanks` trailing, and Claude reads only that block. + const content = await claudeDispatchMessageContent( + userMessage([ + { type: 'text', text: '/goal ship' }, + REMOTE_IMAGE, + { type: 'text', text: 'thanks' } + ]) + ) + + expect(content).toEqual([ + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }, + { type: 'text', text: '/goal ship\nthanks' } + ]) + expect(claudeDispatchInvokesSlashCommand(content)).toBe(true) + }) + + it('puts a locally attached image ahead of the text, the shape the composer sends', async () => { + const dir = await mkdtemp(join(tmpdir(), 'claude-dispatch-content-')) + const path = join(dir, 'shot.png') + await writeFile(path, PNG) + + try { + const content = await claudeDispatchMessageContent( + userMessage([ + { type: 'text', text: '/goal ship' }, + { type: 'image-ref', path } + ]) + ) + + expect(content).toEqual([ + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: PNG.toString('base64') } + }, + { type: 'text', text: '/goal ship' } + ]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe('claudeDispatchInvokesSlashCommand', () => { + it('reads the trailing prompt Claude recovers, not any text block', () => { + expect( + claudeDispatchInvokesSlashCommand([ + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }, + { type: 'text', text: '/goal ship' } + ]) + ).toBe(true) + // The pre-fix order: Claude recovers no prompt at all, so no command runs. + expect( + claudeDispatchInvokesSlashCommand([ + { type: 'text', text: '/goal ship' }, + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } } + ]) + ).toBe(false) + }) + + it('reads the joined prompt, so a command behind leading prose is not one', async () => { + // Keeping the blocks separate would leave `/goal ship` trailing and falsely claim a command. + const content = await claudeDispatchMessageContent( + userMessage([ + { type: 'text', text: 'take a look' }, + { type: 'text', text: '/goal ship' } + ]) + ) + + expect(content).toEqual([{ type: 'text', text: 'take a look\n/goal ship' }]) + expect(claudeDispatchInvokesSlashCommand(content)).toBe(false) + }) + + it('matches untrimmed, as Claude does, and ignores a promptless turn', () => { + expect(claudeDispatchInvokesSlashCommand([{ type: 'text', text: ' /goal ship' }])).toBe(false) + expect(claudeDispatchInvokesSlashCommand([{ type: 'text', text: 'ship it' }])).toBe(false) + expect(claudeDispatchInvokesSlashCommand([])).toBe(false) + }) +}) diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index 71f180bc3ac..7df84091b7c 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -3,6 +3,7 @@ import { open } from 'node:fs/promises' import { extname } from 'node:path' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' +import { claudeRecord } from './claude-structured-item-translation' const MAX_IMAGE_BYTES = 5 * 1024 * 1024 const MAX_IMAGE_COUNT = 20 @@ -88,27 +89,49 @@ async function imageContent( } } +/** + * Claude encodes a user turn as attachment blocks followed by the typed text, and recovers the + * typed prompt by reading only the trailing text block. Verified against the real CLI over + * stream-json: a body ending in an image has no recoverable prompt, so its `/command` reaches + * the model as prose instead of being expanded. + */ export async function claudeDispatchMessageContent( body: AgentJournalMessageItem ): Promise { if (body.role !== 'user') { throw new Error('Claude dispatch accepts only user messages') } - const content: unknown[] = [] + const images: unknown[] = [] + const texts: string[] = [] const imageBudget: ImageBudget = { count: 0, localBytes: 0 } for (const block of body.blocks as NativeChatBlock[]) { if (block.type === 'text' && block.text.length > 0) { - content.push({ type: 'text', text: block.text }) + texts.push(block.text) } else if (block.type === 'image-ref') { - content.push(await imageContent(block, imageBudget)) + images.push(await imageContent(block, imageBudget)) } } + // Join rather than append each block: only the trailing text is read as the prompt, so several + // text blocks would silently discard every one but the last. + const content = texts.length > 0 ? [...images, { type: 'text', text: texts.join('\n') }] : images if (content.length === 0) { throw new Error('Claude dispatch requires text or an image') } return content } +/** The prompt Claude recovers from a dispatch, or null when the turn carries no prompt. */ +function claudeDispatchPrompt(content: readonly unknown[]): string | null { + const last = claudeRecord(content.at(-1)) + return last?.type === 'text' && typeof last.text === 'string' ? last.text : null +} + +/** Mirrors how Claude decides a turn is a command. Untrimmed on purpose: Claude does not trim + * here either, so leading whitespace really does mean no command runs. */ +export function claudeDispatchInvokesSlashCommand(content: readonly unknown[]): boolean { + return claudeDispatchPrompt(content)?.startsWith('/') === true +} + /** * Keep waiter metadata bounded even when a dispatch contains large base64 images. * The digest is only diagnostic: replay acknowledgement must use provider identity. @@ -117,10 +140,7 @@ export function claudeDispatchContentKey(content: readonly unknown[]): string { const digest = createHash('sha256') const summary = content .map((part) => { - const record = - typeof part === 'object' && part !== null && !Array.isArray(part) - ? (part as Record) - : null + const record = claudeRecord(part) const type = typeof record?.type === 'string' ? record.type : 'unknown' if (type === 'text') { return `text:${typeof record?.text === 'string' ? record.text.length : 0}` @@ -136,10 +156,7 @@ export function claudeDispatchContentKey(content: readonly unknown[]): string { }) .join(',') for (const [index, part] of content.entries()) { - const record = - typeof part === 'object' && part !== null && !Array.isArray(part) - ? (part as Record) - : null + const record = claudeRecord(part) const type = typeof record?.type === 'string' ? record.type : 'unknown' digest.update(`${index}:${type}:`) if (type === 'text' && typeof record?.text === 'string') { diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index ad09357df58..933bd77774b 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -423,6 +423,73 @@ describe('Claude structured dispatch image limits', () => { }) }) + it('accepts a slash command sent with an attachment from its result receipt', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { + clientMessageId: 'client-1', + body: userMessage([ + { type: 'text', text: '/permissions' }, + { type: 'image-ref', url: 'https://example.test/a.png' } + ]) + }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + // The mapper moves the image ahead of the prompt, so Claude runs the command and replies + // with a result receipt instead of a user replay. + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'command-result-uuid' + }) + ).toBe(false) + + await expect(dispatched).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: 'command-result-uuid' } + }) + // The sent order is the fix: the waiter's verdict alone was already what it is today. + expect(session.connection.send).toHaveBeenCalledWith( + expect.objectContaining({ + message: { + role: 'user', + content: [ + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }, + { type: 'text', text: '/permissions' } + ] + } + }) + ) + }) + + it('does not take a result receipt for leading whitespace Claude never reads as a command', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: ' /permissions' }]) + }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'unrelated-result-uuid' + }) + ).toBe(false) + + await expect(dispatched).resolves.toMatchObject({ state: 'unknown' }) + }) + it('correlates a later slash-command result by user_message_uuid despite a timed-out slash waiter', async () => { const session = sessionFor() const first = dispatchClaudeTurn( diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index b7619a1e94e..3080b5479e4 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -12,6 +12,7 @@ import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-se import { readClaudeFrameString } from './claude-structured-init-proof' import { claudeDispatchContentKey, + claudeDispatchInvokesSlashCommand, claudeDispatchMessageContent } from './claude-structured-dispatch-content' @@ -231,9 +232,9 @@ export async function dispatchClaudeTurn( return { state: 'rejected', reason: (error as Error).message } } const dispatchSequence = ++session.dispatchSequence - const acceptsResult = input.body.blocks.some( - (block) => block.type === 'text' && block.text.trimStart().startsWith('/') - ) + // Read the sent content, not the journal blocks: only the mapped trailing prompt decides + // whether Claude runs a command, so the two cannot disagree about which frame settles this. + const acceptsResult = claudeDispatchInvokesSlashCommand(content) const sentUuid = randomUUID() const replay = waitForReplay( session, From fe9d538b6f34f0634ced58ceae4449f538710e79 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:45:30 -0700 Subject: [PATCH 04/14] fix(native-chat): keep collapsible chevrons beside their header text (#19656) Co-authored-by: Merge Sim --- .../native-chat/NativeChatBackgroundTasksStatus.tsx | 2 +- .../src/components/native-chat/NativeChatSubagentRun.tsx | 4 +--- .../src/components/native-chat/NativeChatToolAnnotations.tsx | 2 +- src/renderer/src/components/native-chat/NativeChatToolRun.tsx | 2 +- .../src/components/native-chat/NativeChatTurnDiffRollup.tsx | 2 +- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx index 9f8f08e1efd..ce3718a967b 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx @@ -49,7 +49,7 @@ export function NativeChatBackgroundTasksStatus(props: { - + {translate( 'components.native-chat.backgroundTasks.monitoring', 'Monitoring background tasks' diff --git a/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx b/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx index af3bf468011..cbb25e624cd 100644 --- a/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx @@ -214,9 +214,7 @@ export function NativeChatSubagentRun({ > - - {headline} - + {headline} {verdict} {alert === null ? null : ` +${alert}`} diff --git a/src/renderer/src/components/native-chat/NativeChatToolAnnotations.tsx b/src/renderer/src/components/native-chat/NativeChatToolAnnotations.tsx index 7da3345fae4..25c0cd14d89 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolAnnotations.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolAnnotations.tsx @@ -41,7 +41,7 @@ export function NativeChatCommandMetadata({ return null } return ( - + {exitCode !== undefined ? ( {translate('components.native-chat.tool.exitCode', 'exit {{value0}}', { diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index 5f579a2778b..26ff8f40d6e 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -297,7 +297,7 @@ export function NativeChatToolRun({ rowWord={latestActiveCall.name} className="text-muted-foreground" /> - + {nativeChatToolActivityLabel(latestActiveCall)} {open ? : null} diff --git a/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.tsx b/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.tsx index 8050710a177..6a7359ce4a6 100644 --- a/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTurnDiffRollup.tsx @@ -29,7 +29,7 @@ export function NativeChatTurnDiffRollup({ ) : null} From 3a801d213d708e0942f5cc5575588dd3ea260562 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:53:48 -0700 Subject: [PATCH 05/14] fix(orchestration): worker-start settles readiness on observed turn start, not write acceptance (#19423) * fix(orchestration): worker-start settles readiness on observed turn start, not write acceptance A dispatched PTY worker whose agent wedged at startup (six codex workers on 2026-09-07) was reported 'ok: true, state: ready, stage: input_accepted': the preamble write was acknowledged with observationTimeoutMs: 0 and nothing ever verified a turn began. The corpse and the healthy worker produced identical receipts. worker-start now runs the existing second-stage prompt observer (observeTerminalAgentPrompt) after acceptance, inside the 30s window the client RPC grace already budgets for (orchestration-worker-start-prompt-budget): - turn observed (or provider ack for structured sessions) -> ready - permission prompt -> ready; positive liveness, surfaced in the receipt - provider without a turn-start signal -> ready; observation: unsupported - observation supported and nothing started -> worker state start_unknown, response state outcome_unknown with nextCommands. Honest 'unverifiable', never a death claim: the capability and terminal are kept, and worker-report settlement already reconnects a start_unknown worker that recovers and reports. Also fixes the effect-verb lie that misdirected the first diagnosis of this incident: agent-first worktree creation labeled its own brand-new agent terminal 'reused_agent_terminal' (a role test picking a lifecycle verb) on both the local and federation paths. It now says 'created'; readers keep accepting the retired verb for rows persisted before the rename. * fix: preserve worker authority through start observation --------- Co-authored-by: Merge Sim --- config/reliability-gates.jsonc | 5 +- .../handlers/orchestration-worker-cli.test.ts | 28 ++++ .../worker-report-settlement.ts | 14 +- .../worker-dispatch-authority.ts | 6 +- .../worker-dispatch-outcome.ts | 12 +- .../federation/federation-effects.ts | 4 +- .../worker/composed-workers.test.ts | 2 +- .../worker/local-worker-start.ts | 52 ++---- .../orchestration/worker/worker-setup-gate.ts | 2 + .../worker-start-prompt-contract.test.ts | 115 +++++++++++-- .../worker-start-readiness-settlement.ts | 156 ++++++++++++++++++ .../worker-start-turn-observation.test.ts | 120 ++++++++++++++ .../worker/worker-start-turn-observation.ts | 86 ++++++++++ .../worker/worker-worktree-creation.ts | 5 +- .../worker/workers-new-worktree.test.ts | 2 +- 15 files changed, 546 insertions(+), 63 deletions(-) create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0a7a6feadf8..c56dd9941e8 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -13289,7 +13289,7 @@ "https://github.com/stablyai/orca/issues/13821", "https://github.com/stablyai/orca/issues/14347" ], - "invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. A successful orchestration.workerStart must durably record exactly one accepted and started turn; a swallowed Enter must fail with agent_prompt_stalled and never trigger a blind rescue Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.", + "invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Local worker-start with supported observation must preserve an unobserved turn as start_unknown without revoking authority, closing questions, or triggering a rescue Enter; a worker report during observation must settle normally. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.", "oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, Claude/Codex marker-gated multi-frame renders, and the legacy platform delay for every other configured agent. The candidate resets settlement on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. The worker-start contract drives the production RPC through a delayed fake Codex composer and independently checks exact turn/Enter counts plus reopened SQLite Task, Dispatch, worker receipt, and mutation receipt state for accepted and swallowed outcomes. Other orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts", @@ -13339,7 +13339,8 @@ "file": "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts", "assertions": [ "delayed composer readiness produces exactly one submitted and started turn with no premature Enter and durable ready receipts", - "a swallowed Enter records agent_prompt_stalled across Task, Dispatch, worker, and mutation receipts without a rescue Enter" + "a swallowed Enter durably records start_unknown without a rescue Enter or capability revocation", + "early worker reports settle during observation, and outstanding questions survive observation uncertainty" ] }, { diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts index cddd36a4cf7..931c4477855 100644 --- a/src/cli/handlers/orchestration-worker-cli.test.ts +++ b/src/cli/handlers/orchestration-worker-cli.test.ts @@ -104,6 +104,34 @@ describe('orchestration worker-start CLI contract', () => { expect(process.exitCode).toBeUndefined() }) + it.each(['succeeded', 'failed'])( + 'accepts a successful start whose task already %s', + async (workerOutcome) => { + const receipt = { + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'ready', + stage: 'settled', + workerOutcome, + effects: [], + residualResources: [] + } + callMock.mockResolvedValue({ result: receipt }) + await invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['from', 'term_coord'] + ]) + ) + expect(process.exitCode).toBeUndefined() + expect(printResult).toHaveBeenCalledWith( + expect.objectContaining({ result: receipt }), + true, + expect.any(Function) + ) + } + ) + it('capability-gates and forwards per-invocation launch preferences', async () => { callMock .mockResolvedValueOnce({ diff --git a/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts b/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts index 3584a2e59a6..ddb0e383143 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts @@ -109,9 +109,13 @@ export function settleWorkerReportInTransaction( (dispatch.status === 'pending' || dispatch.status === 'dispatched') && task.status === 'blocked' && reportingWorker?.state === 'start_unknown' + const reportingStart = + dispatch.status === 'pending' && + task.status === 'dispatched' && + reportingWorker?.state === 'starting' const previousDispatchStatus = settledByUnobservedPrompt ? 'failed' - : reconnectingStart + : reconnectingStart || reportingStart ? dispatch.status : 'dispatched' const previousTaskStatus = settledByUnobservedPrompt @@ -198,7 +202,7 @@ export function settleWorkerReportInTransaction( const dispatchTransition = transitionLifecycleWithDb(this.db, { entity: 'dispatch', id: params.dispatchId, - from: reconnectingStart ? ['pending', 'dispatched'] : 'dispatched', + from: reconnectingStart || reportingStart ? ['pending', 'dispatched'] : 'dispatched', to: expectedDispatchStatus, projection: { completed_at: new Date().toISOString(), @@ -234,11 +238,11 @@ export function settleWorkerReportInTransaction( projection: { stage: 'settled', updated_at: new Date().toISOString() }, correction: 'unobserved_prompt_report' }) - } else if (reconnectingStart && params.outcome === 'succeeded') { + } else if ((reconnectingStart || reportingStart) && params.outcome === 'succeeded') { transitionLifecycleWithDb(this.db, { entity: 'worker', id: params.dispatchId, - from: 'start_unknown', + from: reportingStart ? 'starting' : 'start_unknown', to: 'ready' }) transitionLifecycleWithDb(this.db, { @@ -253,7 +257,7 @@ export function settleWorkerReportInTransaction( entity: 'worker', id: params.dispatchId, // A start_unknown success report reconnects through 'ready' above; only failure settles here. - from: params.outcome === 'succeeded' ? 'ready' : ['ready', 'start_unknown'], + from: params.outcome === 'succeeded' ? 'ready' : ['ready', 'start_unknown', 'starting'], to: params.outcome === 'succeeded' ? 'succeeded' : 'failed', projection: { stage: 'settled', updated_at: new Date().toISOString() } }) diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts index 5e0f5f5b142..57ec354a8cc 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts @@ -15,9 +15,9 @@ export function prepareStartingWorkerAuthority( effects: unknown[] setupState: string hostScope?: string | null - // 'created': this worker-start operation created the agent terminal (including agent-first - // worktree creation, whose effects receipt says 'reused_agent_terminal'). 'external': an - // explicit --terminal reuse; ownership transfers only from an exact owned settled resource. + // 'created': this worker-start operation created the agent terminal (agent-first worktree + // creation included; its pre-rename effects rows said 'reused_agent_terminal'). 'external': + // an explicit --terminal reuse; ownership transfers only from an exact owned settled resource. terminalOwnership?: 'created' | 'external' } ): string { diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts index 6544f519497..99873949f92 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-outcome.ts @@ -110,7 +110,8 @@ export function markWorkerStartUnknown( this: OrchestrationDb, dispatchId: string, stage: string, - reason: string + reason: string, + effects?: unknown[] ): WorkerDispatchRow { this.db.exec('BEGIN IMMEDIATE') try { @@ -124,7 +125,12 @@ export function markWorkerStartUnknown( id: dispatchId, from: 'starting', to: 'start_unknown', - projection: { stage, last_error: reason, updated_at: new Date().toISOString() } + projection: { + stage, + last_error: reason, + updated_at: new Date().toISOString(), + ...(effects ? { effects: JSON.stringify(effects) } : {}) + } }) transitionLifecycleWithDb(this.db, { entity: 'dispatch', @@ -138,7 +144,7 @@ export function markWorkerStartUnknown( from: 'dispatched', to: 'blocked' }) - this.closeQuestionsForDispatch(dispatchId) + // Authority survives uncertainty, so its outstanding questions must remain answerable. this.db.exec('COMMIT') return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow } catch (error) { diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-effects.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-effects.ts index da0bcf92894..36d25552d80 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-effects.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-effects.ts @@ -29,7 +29,9 @@ export function appendFederationTerminalEffects( : terminal.handle === setupHandle ? 'setup' : 'configured_tab', - action: terminal.handle === agentHandle ? 'reused_agent_terminal' : 'created', + // Remote agent-first worktree creation made every listed terminal, the agent one included; + // the verb is a lifecycle fact, not a role marker. + action: 'created', id: terminal.handle, tabId: terminal.tabId, leafId: terminal.leafId diff --git a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts index 32863c09371..aee45e25259 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts @@ -572,7 +572,7 @@ describe('orchestration RPC methods', () => { ) expect(result.effects).toEqual( expect.arrayContaining([ - expect.objectContaining({ role: 'agent', action: 'reused_agent_terminal' }), + expect.objectContaining({ role: 'agent', action: 'created' }), expect.objectContaining({ role: 'setup', action: 'created' }), expect.objectContaining({ role: 'configured_tab', action: 'created' }) ]) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 71370a4b3e9..ec188695f5c 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -19,11 +19,11 @@ import { import { failWorkerStartWithReceipt } from './worker-start-receipt' import { parseTaskDeps } from './task-deps-argument' import { assertExplicitWorkerTerminalUsable } from './explicit-worker-terminal-validation' -import { deliverWorkerDispatchPreamble } from './deliver-worker-dispatch-preamble' import { recordCreatedWorkerTerminalCustody } from './created-worker-terminal-custody' import { tearDownFailedWorkerStart } from './failed-worker-start-teardown' -import { monitorWorkerSetup, requireWorkerAuthority, type WorkerEffect } from './worker-topology' +import { requireWorkerAuthority, type WorkerEffect } from './worker-topology' import { prepareLocalWorkerStart } from './worker-start-validation' +import { deliverAndSettleWorkerStartReadiness } from './worker-start-readiness-settlement' type WorkerStartMutation = { callerFingerprint: string @@ -204,50 +204,30 @@ export async function startLocalWorker(args: { terminalOwnership: params.terminal ? 'external' : 'created' }) - failedStage = 'dispatch_input' - const promptDelivery = await deliverWorkerDispatchPreamble({ + return await deliverAndSettleWorkerStartReadiness({ runtime, - structuredSession, - terminalHandle, + db, + run, + task, dispatchId: started.dispatch.id, dispatchDepth: started.dispatch.depth, - taskId: task.id, - taskSpec: task.spec, + structuredSession, + terminalHandle, coordinatorHandle: params.from, dispatchCapability: capability, devMode: params.devMode, - requestId: orchestrationMutation?.requestId ?? started.dispatch.id - }) - effects.push({ - kind: 'dispatch_input', - role: 'agent', - id: terminalHandle, - state: 'accepted' - }) - const worker = db.markWorkerDispatchReady(started.dispatch.id, effects) - monitorWorkerSetup({ - runtime, - db, - runId: run.id, - dispatchId: started.dispatch.id, + requestId: orchestrationMutation?.requestId ?? started.dispatch.id, + agent: agent ?? null, setupReceipt, - effects - }) - return { - runId: run.id, - taskId: task.id, - dispatchId: started.dispatch.id, - state: worker.state, - stage: worker.stage, - setup: setupReceipt, - launch: launch.receipt, + launchReceipt: launch.receipt, mode, timeoutMs: params.timeoutMs ?? 60_000, effects, - ...(promptDelivery ? { prompt: promptDelivery } : {}), - residualResources: [], - ...(placed.warning ? { warning: placed.warning } : {}) - } + terminalRevealWarning: placed.warning, + onStage: (stage) => { + failedStage = stage + } + }) } catch (error) { await tearDownFailedWorkerStart({ runtime, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-setup-gate.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-setup-gate.ts index 98407cdb1b4..52a70433867 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-setup-gate.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-setup-gate.ts @@ -6,6 +6,8 @@ import { } from './worker-topology' function residualWorkerEffects(effects: WorkerEffect[]): WorkerEffect[] { + // 'reused_agent_terminal' is the retired verb agent-first creation used for its own agent + // terminal; rows persisted before the rename still carry it. return effects.filter( (effect) => effect.action?.startsWith('created') || effect.action === 'reused_agent_terminal' ) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts index dc645c97c88..593d097580f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts @@ -41,6 +41,8 @@ const openDatabases: OrchestrationDb[] = [] const temporaryRoots: string[] = [] type PromptContractHarness = { + runtime: Awaited>['runtime'] + handle: string db: OrchestrationDb dbPath: string dispatcher: RpcDispatcher @@ -131,6 +133,8 @@ async function createPromptContractHarness( vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') return { + runtime, + handle, db, dbPath, dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }), @@ -227,22 +231,100 @@ describe('orchestration worker-start prompt contract', () => { }) }) - it('keeps a swallowed Enter queued without revoking the worker or retrying input', async () => { + it.each([ + ['succeeded', false], + ['succeeded', true], + ['failed', false], + ['failed', true] + ] as const)('preserves an early %s report with turn evidence=%s', async (outcome, observed) => { + vi.useFakeTimers() + const harness = await createPromptContractHarness('swallowed') + vi.spyOn(harness.runtime, 'observeTerminalAgentPrompt').mockImplementation( + async (_handle, prompt) => { + const dispatch = harness.db.findActiveDispatchForAssignee(harness.handle) + expect(dispatch).toBeDefined() + expect( + harness.db.settleWorkerReport({ + taskId: harness.taskId, + dispatchId: dispatch!.id, + outcome, + result: 'Finished before the hook arrived' + }) + ).toMatchObject({ action: 'settled', outcome }) + return observed ? { ...prompt, stages: ['input_accepted', 'turn_started'] } : prompt + } + ) + const pending = harness.dispatcher.dispatch(harness.request) + await vi.runAllTimersAsync() + expect(await pending).toMatchObject({ + ok: true, + result: { state: 'ready', stage: 'settled', workerOutcome: outcome } + }) + expect(harness.db.getTask(harness.taskId)?.status).toBe( + outcome === 'succeeded' ? 'completed' : 'failed' + ) + }) + + it('retains accepted authority when the observation binding becomes stale', async () => { + vi.useFakeTimers() + const harness = await createPromptContractHarness('swallowed') + vi.spyOn(harness.runtime, 'observeTerminalAgentPrompt').mockRejectedValue( + new Error('terminal_handle_stale') + ) + const pending = harness.dispatcher.dispatch(harness.request) + await vi.runAllTimersAsync() + expect(await pending).toMatchObject({ ok: true, result: { state: 'outcome_unknown' } }) + expect(harness.db.findActiveDispatchForAssignee(harness.handle)).toMatchObject({ + status: 'pending', + capability_hash: expect.any(String), + capability_revoked_at: null + }) + expect(harness.submittedTurns()).toBe(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps a worker question answerable after turn observation times out', async () => { + vi.useFakeTimers() + const harness = await createPromptContractHarness('swallowed') + let questionId = '' + vi.spyOn(harness.runtime, 'observeTerminalAgentPrompt').mockImplementation( + async (_handle, prompt) => { + const dispatch = harness.db.findActiveDispatchForAssignee(harness.handle)! + questionId = harness.db.createQuestion({ + runId: dispatch.run_id!, + dispatchId: dispatch.id, + askerHandle: harness.handle, + question: 'Which target should I use?' + }).question.message_id + return prompt + } + ) + const pending = harness.dispatcher.dispatch(harness.request) + await vi.runAllTimersAsync() + expect(await pending).toMatchObject({ ok: true, result: { state: 'outcome_unknown' } }) + expect(harness.db.getQuestion(questionId)?.status).toBe('pending') + }) + + it('reports a swallowed Enter as start_unknown while keeping the worker and its capability', async () => { vi.useFakeTimers() const harness = await createPromptContractHarness('swallowed') const pending = harness.dispatcher.dispatch(harness.request) await vi.runAllTimersAsync() const response = await pending + // Codex supports turn-start observation and no turn started, so ready would be a lie: the + // paste can sit unsent in the composer while the receipt looks like a healthy dispatch. expect(response).toMatchObject({ ok: true, result: { - state: 'ready', - stage: 'input_accepted', + state: 'outcome_unknown', + stage: 'turn_start_unobserved', + turnStart: 'unobserved', prompt: { requestId: harness.requestId, stages: ['input_accepted'] }, + nextCommands: expect.arrayContaining([expect.stringContaining('worker-show')]), mutation: { requestId: harness.requestId, replayed: false } } }) @@ -251,29 +333,42 @@ describe('orchestration worker-start prompt contract', () => { } const dispatchId = (response.result as { dispatchId: string }).dispatchId await vi.advanceTimersByTimeAsync(20_000) + // Unverifiable is not failure: exactly one submit, no blind retry, nothing torn down. expect(harness.submittedTurns()).toBe(1) expect(harness.startedTurns()).toBe(0) expect(harness.prematureSubmits()).toBe(0) expect(harness.writes.filter((data) => data === '\r')).toHaveLength(1) const persisted = reopenPromptContractDb(harness) - expect(persisted.getTask(harness.taskId)?.status).toBe('dispatched') + expect(persisted.getTask(harness.taskId)?.status).toBe('blocked') expect(persisted.getDispatchContextById(dispatchId)).toMatchObject({ - status: 'dispatched', + status: 'pending', last_failure: null, + // The capability survives so a worker that recovers can still report; worker-report + // settlement reconnects a start_unknown worker through 'ready'. + capability_hash: expect.any(String), capability_revoked_at: null }) expect(persisted.getWorkerDispatch(dispatchId)).toMatchObject({ - state: 'ready', - stage: 'input_accepted', - last_error: null + state: 'start_unknown', + stage: 'turn_start_unobserved', + last_error: expect.stringContaining('turn start could not be verified') }) + const persistedEffects = JSON.parse( + persisted.getWorkerDispatch(dispatchId)?.effects ?? '[]' + ) as { kind?: string; state?: string }[] + expect(persistedEffects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' }), + expect.objectContaining({ kind: 'dispatch_input', state: 'turn_unobserved' }) + ]) + ) const callerFingerprint = persisted.getOrCreateLocalMutationCallerFingerprint() const receipt = persisted.getMutationReceipt(callerFingerprint, harness.requestId) expect(receipt).toMatchObject({ state: 'completed' }) expect(JSON.parse(receipt?.receipt ?? 'null')).toMatchObject({ dispatchId, - state: 'ready', - stage: 'input_accepted', + state: 'outcome_unknown', + stage: 'turn_start_unobserved', prompt: { requestId: harness.requestId, stages: ['input_accepted'] diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts new file mode 100644 index 00000000000..a3c57a357d2 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts @@ -0,0 +1,156 @@ +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrchestrationDb } from '../../../../orchestration/db' +import type { RunRow, TaskRow } from '../../../../orchestration/types' +import type { WorkerStartModeReceipt } from '../../orchestration-worker-start-mode' +import { deliverWorkerDispatchPreamble } from './deliver-worker-dispatch-preamble' +import type { OrchestrationWorkerLaunchReceipt } from './worker-launch-preferences' +import { + describeUnobservedWorkerTurnStart, + observeWorkerTurnStart, + type WorkerTurnStartObservation +} from './worker-start-turn-observation' +import { + monitorWorkerSetup, + type createStructuredWorkerSessionForWorktree, + type WorkerEffect, + type WorkerSetupReceipt +} from './worker-topology' + +/** + * Delivers the dispatch preamble and settles the worker's start state on the strongest + * evidence available: `ready` only with a positive turn-start (or a provider that cannot + * prove one), `start_unknown` when observation is supported and nothing started. + */ +export async function deliverAndSettleWorkerStartReadiness(args: { + runtime: OrcaRuntimeService + db: OrchestrationDb + run: RunRow + task: TaskRow + dispatchId: string + dispatchDepth: number + structuredSession: Awaited> | null + terminalHandle: string + coordinatorHandle: string + dispatchCapability: string + devMode: boolean | undefined + requestId: string + agent: string | null + setupReceipt: WorkerSetupReceipt + launchReceipt: OrchestrationWorkerLaunchReceipt + mode: WorkerStartModeReceipt + timeoutMs: number + effects: WorkerEffect[] + terminalRevealWarning: string | undefined + /** Keeps the caller's failure receipt naming the stage that actually failed. */ + onStage: (stage: 'dispatch_input' | 'turn_observation') => void +}): Promise { + const { runtime, db, run, task, structuredSession, terminalHandle, effects } = args + + args.onStage('dispatch_input') + const promptDelivery = await deliverWorkerDispatchPreamble({ + runtime, + structuredSession, + terminalHandle, + dispatchId: args.dispatchId, + dispatchDepth: args.dispatchDepth, + taskId: task.id, + taskSpec: task.spec, + coordinatorHandle: args.coordinatorHandle, + dispatchCapability: args.dispatchCapability, + devMode: args.devMode, + requestId: args.requestId + }) + effects.push({ + kind: 'dispatch_input', + role: 'agent', + id: terminalHandle, + state: 'accepted' + }) + + args.onStage('turn_observation') + // The write above was accepted without waiting on provider hooks; now demand the positive + // evidence the receipt claims is observable. A worker whose turn never starts must not be + // reported ready — a wedged agent and a working one looked identical before this gate. + // A structured preamble send is acknowledged by the provider or throws, so it is already + // positive evidence. + const turnStart: WorkerTurnStartObservation = structuredSession + ? { verdict: 'observed' } + : await observeWorkerTurnStart({ runtime, terminalHandle, prompt: promptDelivery }) + const deliveredPrompt = turnStart.prompt ?? promptDelivery + monitorWorkerSetup({ + runtime, + db, + runId: run.id, + dispatchId: args.dispatchId, + setupReceipt: args.setupReceipt, + effects + }) + // A worker report can settle the dispatch while turn observation is outstanding. + const currentWorker = db.getWorkerDispatch(args.dispatchId) + const alreadySettled = currentWorker && currentWorker.state !== 'starting' + if (turnStart.verdict === 'unobserved' && !alreadySettled) { + // Honest `unverifiable`: keep the dispatch capability and the terminal — the worker may + // still recover and report (worker-report settlement reconnects a start_unknown worker) — + // but never claim ready for a turn nobody observed. + effects.push({ + kind: 'dispatch_input', + role: 'agent', + id: terminalHandle, + state: 'turn_unobserved' + }) + const reason = describeUnobservedWorkerTurnStart(args.agent) + const worker = db.markWorkerStartUnknown( + args.dispatchId, + 'turn_start_unobserved', + reason, + effects + ) + return { + runId: run.id, + taskId: task.id, + dispatchId: args.dispatchId, + state: 'outcome_unknown', + stage: worker.stage, + turnStart: turnStart.verdict, + lastError: reason, + setup: args.setupReceipt, + launch: args.launchReceipt, + mode: args.mode, + timeoutMs: args.timeoutMs, + effects, + ...(deliveredPrompt ? { prompt: deliveredPrompt } : {}), + residualResources: JSON.parse(worker.residual_resources) as unknown[], + nextCommands: [ + `orca orchestration worker-show --dispatch ${args.dispatchId} --json`, + `orca terminal read --terminal ${terminalHandle} --screen`, + `orca orchestration worker-abandon --dispatch ${args.dispatchId} --json` + ], + ...(args.terminalRevealWarning ? { warning: args.terminalRevealWarning } : {}) + } + } + const worker = alreadySettled + ? currentWorker + : db.markWorkerDispatchReady(args.dispatchId, effects) + // A completed task proves start succeeded; older callers use only 'ready' as start success. + const reportedOutcome = + worker.stage === 'settled' && (worker.state === 'succeeded' || worker.state === 'failed') + ? worker.state + : undefined + return { + runId: run.id, + taskId: task.id, + dispatchId: args.dispatchId, + state: reportedOutcome ? 'ready' : worker.state, + stage: worker.stage, + ...(reportedOutcome ? { workerOutcome: reportedOutcome } : {}), + turnStart: turnStart.verdict, + setup: args.setupReceipt, + launch: args.launchReceipt, + mode: args.mode, + timeoutMs: args.timeoutMs, + effects, + ...(deliveredPrompt ? { prompt: deliveredPrompt } : {}), + residualResources: [], + ...(args.terminalRevealWarning ? { warning: args.terminalRevealWarning } : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts new file mode 100644 index 00000000000..f1992eb9b9c --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalPromptDelivery } from '../../../../../../shared/runtime-terminal-contracts' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { observeWorkerTurnStart } from './worker-start-turn-observation' + +function delivery( + overrides: Partial = {} +): RuntimeTerminalPromptDelivery { + return { + requestId: 'req-1', + stages: ['input_accepted'], + provider: 'codex', + observation: 'supported', + processIncarnation: 'inc-1', + generation: 1, + baselineWorkingSequence: 0, + ...overrides + } +} + +function runtimeObserving(result: RuntimeTerminalPromptDelivery): { + runtime: OrcaRuntimeService + observe: ReturnType +} { + const observe = vi.fn().mockResolvedValue(result) + return { + runtime: { observeTerminalAgentPrompt: observe } as unknown as OrcaRuntimeService, + observe + } +} + +describe('observeWorkerTurnStart', () => { + it('treats a missing receipt as unsupported observation, never as failure', async () => { + const { runtime, observe } = runtimeObserving(delivery()) + await expect( + observeWorkerTurnStart({ runtime, terminalHandle: 'term_w', prompt: undefined }) + ).resolves.toEqual({ verdict: 'unsupported' }) + expect(observe).not.toHaveBeenCalled() + }) + + it('accepts a first-stage turn_started without a second observation pass', async () => { + const prompt = delivery({ stages: ['input_accepted', 'turn_started'] }) + const { runtime, observe } = runtimeObserving(prompt) + await expect( + observeWorkerTurnStart({ runtime, terminalHandle: 'term_w', prompt }) + ).resolves.toEqual({ verdict: 'observed', prompt }) + expect(observe).not.toHaveBeenCalled() + }) + + it('reports observed when the second-stage observer sees the turn start', async () => { + const observed = delivery({ stages: ['input_accepted', 'turn_started'] }) + const { runtime, observe } = runtimeObserving(observed) + await expect( + observeWorkerTurnStart({ + runtime, + terminalHandle: 'term_w', + prompt: delivery(), + timeoutMs: 5 + }) + ).resolves.toEqual({ verdict: 'observed', prompt: observed }) + expect(observe).toHaveBeenCalledWith('term_w', delivery(), 5) + }) + + it('reports unobserved — not dead — when a supported observation stalls', async () => { + const { runtime } = runtimeObserving(delivery()) + await expect( + observeWorkerTurnStart({ + runtime, + terminalHandle: 'term_w', + prompt: delivery(), + timeoutMs: 5 + }) + ).resolves.toMatchObject({ verdict: 'unobserved' }) + }) + + it('reports permission as positive liveness', async () => { + const observed = delivery({ observation: 'permission' }) + const { runtime } = runtimeObserving(observed) + await expect( + observeWorkerTurnStart({ + runtime, + terminalHandle: 'term_w', + prompt: delivery(), + timeoutMs: 5 + }) + ).resolves.toEqual({ verdict: 'permission', prompt: observed }) + }) + + it('treats a replaced incarnation as unobserved rather than unsupported', async () => { + const observed = delivery({ observation: 'incarnation_replaced' }) + const { runtime } = runtimeObserving(observed) + await expect( + observeWorkerTurnStart({ + runtime, + terminalHandle: 'term_w', + prompt: delivery(), + timeoutMs: 5 + }) + ).resolves.toEqual({ verdict: 'unobserved', prompt: observed }) + }) + + it('leaves an unsupported provider on the accepted receipt', async () => { + const prompt = delivery({ provider: 'unsupported', observation: 'unsupported' }) + const { runtime, observe } = runtimeObserving(prompt) + await expect( + observeWorkerTurnStart({ runtime, terminalHandle: 'term_w', prompt }) + ).resolves.toEqual({ verdict: 'unsupported', prompt }) + expect(observe).not.toHaveBeenCalled() + }) + + it('preserves uncertainty when observation loses the terminal binding', async () => { + const prompt = delivery() + const { runtime, observe } = runtimeObserving(prompt) + observe.mockRejectedValue(new Error('terminal_handle_stale')) + await expect( + observeWorkerTurnStart({ runtime, terminalHandle: 'term_w', prompt }) + ).resolves.toEqual({ verdict: 'unobserved', prompt }) + expect(observe).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts new file mode 100644 index 00000000000..d1c9e59adfa --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-turn-observation.ts @@ -0,0 +1,86 @@ +import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../../../../../shared/orchestration-timing-budgets' +import type { RuntimeTerminalPromptDelivery } from '../../../../../../shared/runtime-terminal-contracts' +import type { OrcaRuntimeService } from '../../../../orca-runtime' + +/** + * Turn-start verdict for a dispatched worker prompt, in the execution-boundary vocabulary: + * + * - 'observed': the provider proved a turn started for this request. Positive liveness. + * - 'permission': the agent rendered an approval prompt after the write. Positive liveness, + * but the turn is blocked on a human. + * - 'unsupported': this provider exposes no turn-start signal; the accepted write is the + * strongest receipt that can exist. Never treated as failure. + * - 'unobserved': observation IS supported and no turn started within the window. This is + * `unverifiable`, never evidence of death — the bytes were written, but the agent may be + * wedged at startup or holding the spec unsent in its composer. + */ +export type WorkerTurnStartVerdict = 'observed' | 'permission' | 'unsupported' | 'unobserved' + +export type WorkerTurnStartObservation = { + verdict: WorkerTurnStartVerdict + prompt?: RuntimeTerminalPromptDelivery +} + +function classifyPromptDelivery(prompt: RuntimeTerminalPromptDelivery): WorkerTurnStartVerdict { + if (prompt.stages.includes('turn_started')) { + return 'observed' + } + if (prompt.observation === 'permission') { + return 'permission' + } + if (prompt.observation === 'supported') { + return 'unobserved' + } + // 'unsupported' (and an old host's missing observation) leaves acceptance as the best receipt. + return 'unsupported' +} + +/** + * Second-stage turn-start observation for a worker prompt that was accepted without waiting on + * provider hooks. Reuses the same observer that terminal.send receipts replay through, so the + * evidence rules (lifecycle edge or hook turn-start, never output bytes) stay in one place. + * + * The observation window is `AGENT_PROMPT_EFFECT_TIMEOUT_MS`, which worker-start's client RPC + * grace already budgets for (see orchestration-worker-start-prompt-budget.ts). + */ +export async function observeWorkerTurnStart(args: { + runtime: OrcaRuntimeService + terminalHandle: string + prompt: RuntimeTerminalPromptDelivery | undefined + timeoutMs?: number +}): Promise { + if (!args.prompt) { + return { verdict: 'unsupported' } + } + const verdict = classifyPromptDelivery(args.prompt) + if (verdict !== 'unobserved') { + return { verdict, prompt: args.prompt } + } + let observed: RuntimeTerminalPromptDelivery + try { + observed = await args.runtime.observeTerminalAgentPrompt( + args.terminalHandle, + args.prompt, + args.timeoutMs ?? AGENT_PROMPT_EFFECT_TIMEOUT_MS + ) + } catch { + // Observation failure cannot revoke authority for input that was already accepted. + return { verdict: 'unobserved', prompt: args.prompt } + } + if (observed.observation === 'incarnation_replaced') { + // The PTY under this handle changed mid-observation; the accepted write is unproven. + return { verdict: 'unobserved', prompt: observed } + } + return { verdict: classifyPromptDelivery(observed), prompt: observed } +} + +export function describeUnobservedWorkerTurnStart(agent: string | null): string { + const name = agent ?? 'the agent' + return ( + `Dispatch input was written and submitted, but ${name}'s turn start could not be verified ` + + `during observation (up to ${Math.round(AGENT_PROMPT_EFFECT_TIMEOUT_MS / 1000)}s). This is unverifiable, not proof the ` + + 'worker is dead: the agent may still be starting, may be wedged (for example waiting on ' + + 'network), or may be holding the task unsent in its composer. If the worker recovers and ' + + 'reports, this Dispatch settles normally.' + ) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts index 0f9435bb018..fe0c11e374d 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-worktree-creation.ts @@ -108,7 +108,10 @@ export async function createWorkerWorktree(args: { : terminal.handle === setupTerminalHandle ? 'setup' : 'configured_tab', - action: terminal.handle === terminalHandle ? 'reused_agent_terminal' : 'created', + // Every terminal listed here — the agent terminal included — was created by this call's + // agent-first worktree creation. The old 'reused_agent_terminal' verb on the agent row + // conflated the role test with a lifecycle claim and misdiagnosed at least one incident. + action: 'created', id: terminal.handle, tabId: terminal.tabId, leafId: terminal.leafId diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index a0d74031b72..5164ac0b22f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -157,7 +157,7 @@ describe('orchestration new-worktree workers', () => { expect.objectContaining({ kind: 'terminal', role: 'agent', - action: 'reused_agent_terminal', + action: 'created', id: 'term_worker' }) ]) From 750e6ffada777192fe67ef30e444d4ca64b4ab8f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:02:05 -0700 Subject: [PATCH 06/14] test(orchestration): pin the Run-required contract for unbound direct mail (#19684) * test(orchestration): pin the Run-required contract for unbound direct mail * test(orchestration): pin absent recovery keys and settle the push window for unbound mail --------- Co-authored-by: Merge Sim --- .../orchestration-idle-mail-delivery.spec.ts | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/tests/e2e/orchestration-idle-mail-delivery.spec.ts b/tests/e2e/orchestration-idle-mail-delivery.spec.ts index f679bdba608..8ca6657ab7c 100644 --- a/tests/e2e/orchestration-idle-mail-delivery.spec.ts +++ b/tests/e2e/orchestration-idle-mail-delivery.spec.ts @@ -35,6 +35,7 @@ import { waitForPaneIdentitySnapshot } from './helpers/terminal' import { RuntimeClient, type RuntimeRpcSuccess } from '../../src/cli/runtime-client' +import { RuntimeRpcFailureError } from '../../src/cli/runtime/types' import type { RuntimeTerminalListResult } from '../../src/shared/runtime-types' import { CODEX_IDLE_TITLE, @@ -350,7 +351,10 @@ test.describe('orchestration push-on-idle mail delivery', () => { await expectSubmitted(pane) }) - test('keeps unbound direct mail durable without pointing to an unsafe check', async ({ + // #19542 deleted the legacy-Run write fallback, so a sender in no Run has + // nowhere to file mail to a bare handle: the send is refused outright, which + // is what keeps an unsafe pointer out of the pane on the next idle frame. + test('refuses unbound direct mail from a sender in no Run instead of pushing it', async ({ orcaPage, electronApp }) => { @@ -360,17 +364,39 @@ test.describe('orchestration push-on-idle mail delivery', () => { await driveToLiveIdle(client, pane) const stdinBeforeScan = pane.agent.readStdin() - const messageId = await sendMail(client, pane.handle, { subject: 'Unbound direct mail' }) + const refusal = await sendMail(client, pane.handle, { subject: 'Unbound direct mail' }).then( + () => undefined, + (error: unknown) => error + ) + // Why runtime_error and not run_required: insertMessage throws a plain + // Error, which the dispatcher passes through with its message and no + // recovery data — the sibling no-recipient path is the one that adds it. + // The request-id suffix and stamp are the client's durable-mutation bookkeeping. + expect(refusal).toBeInstanceOf(RuntimeRpcFailureError) + expect(refusal).toMatchObject({ + code: 'runtime_error', + message: expect.stringContaining('Run is required') + }) + // Pins that the SERVER attached no orchestrationSkillRecoveryData, without + // freezing whatever else the client may stamp alongside its request id. + const refusalData = (refusal as RuntimeRpcFailureError).data + expect(refusalData).toMatchObject({ orchestrationRequestId: expect.any(String) }) + expect(refusalData).not.toHaveProperty('effectsApplied') + expect(refusalData).not.toHaveProperty('guide') + expect(refusalData).not.toHaveProperty('nextCommandArgs') + expect(refusalData).not.toHaveProperty('nextSteps') + expect(readMailbox(userDataDir, pane.handle)).toEqual([]) + + // A busy→idle edge is the push trigger. Walking one proves the refusal left + // nothing behind for the scan to point at, not merely that the push was slow. pane.agent.setTitle(CODEX_WORKING_TITLE) await waitForObservedTitle(client, pane.handle, CODEX_WORKING_TITLE) pane.agent.setTitle(CODEX_IDLE_TITLE) await waitForObservedTitle(client, pane.handle, CODEX_IDLE_TITLE) - expect(readMailRow(userDataDir, messageId)).toMatchObject({ - to_handle: pane.handle, - read: 0, - delivered_at: null - }) + // The ledger only proves anything once the push window has fully elapsed. + await orcaPage.waitForTimeout(NO_DELIVERY_SETTLE_MS) + expect(readMailbox(userDataDir, pane.handle)).toEqual([]) expect(pane.agent.readStdin()).toBe(stdinBeforeScan) }) From addd9f3da7de4ddeb72a4905405fce9bafdd369f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:41:46 -0700 Subject: [PATCH 07/14] fix(orchestration): accept a missing Run id from older federation coordinators (#19689) * fix(orchestration): accept v1.4.198 coordinators on federationAttachStart #19542 made runId required on the attach RPC and backfilled existing attachments with '', on the premise that federation was unreleased. It shipped in v1.4.198, so a v1.4.198 coordinator got 'Missing Run ID' from an upgraded worker host, and every pre-upgrade attachment lost its mailbox because home_run_id='' matches no Run. - runId is optional on the wire; an absent id mints a per-attachment stub Run (run_federated_) through the same INSERT OR IGNORE path. - migrate-v40 backfills existing attachments with the stub and inserts the stub Runs, so in-flight workers keep reporting back. - create SQL gives home_run_id a DEFAULT '' so a rolled-back v1.4.198 host can still insert into a v1.4.199-created table. Stub Runs never carry run_legacy_local, and #19542's attachment-mailbox exclusion in the skew probe is untouched, so no adoption replay path is reintroduced (probe test added). * fix(orchestration): repair empty federated home Run ids on every open A host on v1.4.199 that rolls back to v1.4.198, attaches workers (rows land with home_run_id='' via the DEFAULT), then upgrades again never re-runs the v40 backfill because user_version is already 40, so those attachments stay without a Run and their control mail is refused. Lift the two idempotent set-based statements out of migrate-v40 into backfillFederatedStubHomeRuns and run it from both the v40 migration and the OrchestrationDb constructor after migrate(), matching the existing on-open rememberCurrentRunCoordinatorHandles repair. Tests: reopen a v40 file DB holding a v1.4.198-shaped '' row through the constructor and assert the stub Run and mailbox; pin the wire schema accepting a v1.4.198 request with no runId ('' -> undefined, whitespace passes the schema and is refused at the DB layer). --------- Co-authored-by: Merge Sim --- .../orchestration/db/contract-constants.ts | 6 + .../federated-stub-home-run-backfill.ts | 16 +++ .../remote-dispatch-attachment-create.ts | 13 ++- .../orchestration/db/orchestration-db.ts | 2 + .../db/schema/create-graph-tables-sql.ts | 3 +- .../federated-home-run-migration.test.ts | 103 ++++++++++++++++-- .../orchestration/db/schema/migrate-v40.ts | 14 ++- ...chestration-federated-legacy-probe.test.ts | 46 ++++++-- .../federation-start-schema.test.ts | 41 +++++++ .../federation/federation-start-schema.ts | 3 +- 10 files changed, 214 insertions(+), 33 deletions(-) create mode 100644 src/main/runtime/orchestration/db/federation/federated-stub-home-run-backfill.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.test.ts diff --git a/src/main/runtime/orchestration/db/contract-constants.ts b/src/main/runtime/orchestration/db/contract-constants.ts index 47e0cd6165a..0a2e27f000f 100644 --- a/src/main/runtime/orchestration/db/contract-constants.ts +++ b/src/main/runtime/orchestration/db/contract-constants.ts @@ -3,6 +3,12 @@ import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-vers export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID +// Why: a v1.4.198 coordinator sends no Run id, so its remote workers file mail under a per-attachment stub Run. +export const FEDERATED_STUB_HOME_RUN_ID_PREFIX = 'run_federated_' +export function federatedStubHomeRunId(dispatchId: string): string { + return `${FEDERATED_STUB_HOME_RUN_ID_PREFIX}${dispatchId}` +} + export const LEGACY_CONTRACT_VERSION = 0 export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION diff --git a/src/main/runtime/orchestration/db/federation/federated-stub-home-run-backfill.ts b/src/main/runtime/orchestration/db/federation/federated-stub-home-run-backfill.ts new file mode 100644 index 00000000000..47c596e260d --- /dev/null +++ b/src/main/runtime/orchestration/db/federation/federated-stub-home-run-backfill.ts @@ -0,0 +1,16 @@ +import type Database from '../../../../sqlite/sync-database' +import { FEDERATED_STUB_HOME_RUN_ID_PREFIX } from '../contract-constants' + +// Why: a rolled-back v1.4.198 host inserts attachments with home_run_id='' after user_version is +// already 40, so this idempotent repair runs on every open, not only inside the v40 migration. +export function backfillFederatedStubHomeRuns(db: Database.Database): void { + db.exec(` + INSERT OR IGNORE INTO runs (id, objective, home_database, consumer_generation, legacy) + SELECT '${FEDERATED_STUB_HOME_RUN_ID_PREFIX}' || dispatch_id, + 'Coordinated from ' || home_peer_fingerprint, 'remote', 0, 0 + FROM remote_dispatch_attachments WHERE home_run_id = ''; + UPDATE remote_dispatch_attachments + SET home_run_id = '${FEDERATED_STUB_HOME_RUN_ID_PREFIX}' || dispatch_id + WHERE home_run_id = ''; + `) +} diff --git a/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-create.ts b/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-create.ts index d927cf96838..5a6e89b65ca 100644 --- a/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-create.ts +++ b/src/main/runtime/orchestration/db/federation/remote-dispatch-attachment-create.ts @@ -2,13 +2,15 @@ import type { WorkerDispatchState, RemoteDispatchAttachmentRow } from '../../typ import { OrchestrationError } from '../../orchestration-error' import { ensureMutationReceiptCapacity } from '../../mutation-receipt-capacity' import type { OrchestrationDb } from '../orchestration-db' +import { federatedStubHomeRunId } from '../contract-constants' import { insertRemoteDispatchAttachmentRow } from '../dispatch-row-writer' export function createRemoteDispatchAttachment( this: OrchestrationDb, params: { dispatchId: string - runId: string + /** Absent from a v1.4.198 coordinator; replaced by a per-attachment stub Run. */ + runId?: string taskId: string homePeerFingerprint: string protocolVersion: number @@ -44,7 +46,8 @@ export function createRemoteDispatchAttachment( `Remote attachment request ${params.mutationReceipt.requestId} already exists.` ) } - if (!params.runId?.trim()) { + const runId = params.runId ?? federatedStubHomeRunId(params.dispatchId) + if (!runId.trim()) { throw new OrchestrationError('invalid_argument', 'Missing Run ID') } this.db @@ -52,8 +55,8 @@ export function createRemoteDispatchAttachment( `INSERT OR IGNORE INTO runs (id, objective, home_database, consumer_generation, legacy) VALUES (?, ?, 'remote', 0, 0)` ) - .run(params.runId, `Coordinated from ${params.homePeerFingerprint}`) - this.requireRun(params.runId) + .run(runId, `Coordinated from ${params.homePeerFingerprint}`) + this.requireRun(runId) ensureMutationReceiptCapacity(this.db) this.db .prepare( @@ -70,7 +73,7 @@ export function createRemoteDispatchAttachment( ) insertRemoteDispatchAttachmentRow(this.db, { dispatchId: params.dispatchId, - runId: params.runId, + runId, taskId: params.taskId, homePeerFingerprint: params.homePeerFingerprint, protocolVersion: params.protocolVersion, diff --git a/src/main/runtime/orchestration/db/orchestration-db.ts b/src/main/runtime/orchestration/db/orchestration-db.ts index 2a970841a38..1ce52e96c4a 100644 --- a/src/main/runtime/orchestration/db/orchestration-db.ts +++ b/src/main/runtime/orchestration/db/orchestration-db.ts @@ -1,6 +1,7 @@ import Database from '../../../sqlite/sync-database' import { attachOrchestrationDbMethods } from './attach-orchestration-db-methods' import { hardenOrchestrationDatabaseFiles } from './database-file-permissions' +import { backfillFederatedStubHomeRuns } from './federation/federated-stub-home-run-backfill' import type { OrchestrationDbMethods } from './orchestration-db-methods' import { createCoordinatorMailRoutingTrigger, @@ -28,6 +29,7 @@ class OrchestrationDbCore { this.db.pragma('busy_timeout = 5000') createTables.call(this as unknown as OrchestrationDb) migrate.call(this as unknown as OrchestrationDb) + backfillFederatedStubHomeRuns(this.db) createCoordinatorMailRoutingTrigger.call(this as unknown as OrchestrationDb) rememberCurrentRunCoordinatorHandles.call(this as unknown as OrchestrationDb) hardenOrchestrationDatabaseFiles(dbPath) diff --git a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts index 0897cb852de..d674a5298e7 100644 --- a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts +++ b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts @@ -38,7 +38,8 @@ CREATE TABLE IF NOT EXISTS federated_dispatches ( ); CREATE TABLE IF NOT EXISTS remote_dispatch_attachments ( - home_run_id TEXT NOT NULL, + -- DEFAULT: a rolled-back v1.4.198 host still inserts here without a home Run. + home_run_id TEXT NOT NULL DEFAULT '', dispatch_id TEXT PRIMARY KEY, task_id TEXT NOT NULL, home_peer_fingerprint TEXT NOT NULL, diff --git a/src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts b/src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts index b970435223a..4fcd78634f3 100644 --- a/src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts +++ b/src/main/runtime/orchestration/db/schema/federated-home-run-migration.test.ts @@ -1,26 +1,105 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../../shared/protocol-version' import { OrchestrationDb } from '../orchestration-db' +import { SCHEMA_VERSION, federatedStubHomeRunId } from '../contract-constants' import { migrateV40 } from './migrate-v40' import { importFederatedControlMessage } from '../../federation-control-message' describe('federated home Run migration', () => { - const db = new OrchestrationDb(':memory:') + let db: OrchestrationDb + beforeEach(() => { + db = new OrchestrationDb(':memory:') + }) afterEach(() => db.close()) - it('adds the home Run column and refuses mail for a development placeholder', () => { + function importInstruction(target: OrchestrationDb, dispatchId: string, messageId: string): void { + expect( + importFederatedControlMessage(target, { + dispatchId, + messageId, + payload: JSON.stringify({ from: 'home', subject: 'Instruction', body: '', type: 'status' }) + }) + ).toEqual({ imported: true, type: 'status' }) + } + + function attachWithoutRunId(target: OrchestrationDb, dispatchId: string, runId?: string): void { + target.createRemoteDispatchAttachment({ + dispatchId, + runId, + taskId: `task_${dispatchId}`, + homePeerFingerprint: 'home', + protocolVersion: ORCHESTRATION_CONTRACT_VERSION, + runtimeEpoch: 'epoch', + mutationReceipt: { + callerFingerprint: 'home', + requestId: `request_${dispatchId}`, + method: 'orchestration.federationAttachStart', + payloadHash: `payload_${dispatchId}` + } + }) + } + + it('backfills a pre-upgrade attachment with a stub home Run that keeps its mailbox', () => { db.db.exec('ALTER TABLE remote_dispatch_attachments DROP COLUMN home_run_id') db.db.exec(`INSERT INTO remote_dispatch_attachments (dispatch_id, task_id, home_peer_fingerprint, runtime_epoch) VALUES ('ctx_old', 'task_old', 'home', 'epoch')`) migrateV40.call(db, 39) - expect(db.getRemoteDispatchAttachment('ctx_old')?.home_run_id).toBe('') - expect(() => - importFederatedControlMessage(db, { - dispatchId: 'ctx_old', - messageId: 'message_old', - payload: JSON.stringify({ from: 'home', subject: 'Instruction', body: '', type: 'message' }) - }) - ).toThrow('Run not found:') - expect(db.getMessageById('message_old')).toBeUndefined() + const stubRunId = federatedStubHomeRunId('ctx_old') + expect(db.getRemoteDispatchAttachment('ctx_old')?.home_run_id).toBe(stubRunId) + expect(db.getRunRaw(stubRunId)).toMatchObject({ home_database: 'remote', legacy: 0 }) + importInstruction(db, 'ctx_old', 'message_old') + expect(db.getMessageById('message_old')?.run_id).toBe(stubRunId) + }) + + it('mints a stub home Run when a v1.4.198 coordinator attaches without a Run id', () => { + attachWithoutRunId(db, 'ctx_legacy_home') + const stubRunId = federatedStubHomeRunId('ctx_legacy_home') + expect(db.getRemoteDispatchAttachment('ctx_legacy_home')?.home_run_id).toBe(stubRunId) + importInstruction(db, 'ctx_legacy_home', 'message_legacy_home') + expect(db.getMessageById('message_legacy_home')?.run_id).toBe(stubRunId) + }) + + it('rejects a whitespace-only Run id instead of minting a stub', () => { + expect(() => attachWithoutRunId(db, 'ctx_blank', ' ')).toThrow('Missing Run ID') + expect(db.getRemoteDispatchAttachment('ctx_blank')).toBeUndefined() + }) + + it('repairs rows a rolled-back v1.4.198 host inserted after user_version reached 40', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-federated-home-run-')) + const dbPath = join(dir, 'orchestration.db') + try { + const upgraded = new OrchestrationDb(dbPath) + expect(upgraded.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) + // v1.4.198's insert shape: no home_run_id column, so the DEFAULT '' lands. + upgraded.db.exec(`INSERT INTO remote_dispatch_attachments + (dispatch_id, task_id, home_peer_fingerprint, runtime_epoch) + VALUES ('ctx_rolled_back', 'task_rolled_back', 'home', 'epoch')`) + expect(upgraded.getRemoteDispatchAttachment('ctx_rolled_back')?.home_run_id).toBe('') + expect(() => + importFederatedControlMessage(upgraded, { + dispatchId: 'ctx_rolled_back', + messageId: 'message_refused', + payload: JSON.stringify({ from: 'home', subject: 'x', body: '', type: 'status' }) + }) + ).toThrow(/Run not found/) + upgraded.close() + + const reopened = new OrchestrationDb(dbPath) + try { + const stubRunId = federatedStubHomeRunId('ctx_rolled_back') + expect(reopened.getRemoteDispatchAttachment('ctx_rolled_back')?.home_run_id).toBe(stubRunId) + expect(reopened.getRunRaw(stubRunId)).toMatchObject({ home_database: 'remote', legacy: 0 }) + importInstruction(reopened, 'ctx_rolled_back', 'message_rolled_back') + expect(reopened.getMessageById('message_rolled_back')?.run_id).toBe(stubRunId) + } finally { + reopened.close() + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } }) }) diff --git a/src/main/runtime/orchestration/db/schema/migrate-v40.ts b/src/main/runtime/orchestration/db/schema/migrate-v40.ts index 50ef46f82cc..e3cd46ca30d 100644 --- a/src/main/runtime/orchestration/db/schema/migrate-v40.ts +++ b/src/main/runtime/orchestration/db/schema/migrate-v40.ts @@ -1,11 +1,15 @@ import type { OrchestrationDb } from '../orchestration-db' +import { backfillFederatedStubHomeRuns } from '../federation/federated-stub-home-run-backfill' export function migrateV40(this: OrchestrationDb, current: number): void { - if (current >= 40 || this.hasColumn('remote_dispatch_attachments', 'home_run_id')) { + if (current >= 40) { return } - // Federation is unreleased; any development-only rows fail Run validation until reattached. - this.db.exec( - "ALTER TABLE remote_dispatch_attachments ADD COLUMN home_run_id TEXT NOT NULL DEFAULT ''" - ) + if (!this.hasColumn('remote_dispatch_attachments', 'home_run_id')) { + this.db.exec( + "ALTER TABLE remote_dispatch_attachments ADD COLUMN home_run_id TEXT NOT NULL DEFAULT ''" + ) + } + // Why: workers attached by v1.4.198 keep a mailbox; without a Run their control mail is refused. + backfillFederatedStubHomeRuns(this.db) } diff --git a/src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts b/src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts index ad08a7383f0..24d18f0baef 100644 --- a/src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts +++ b/src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { LEGACY_RUN_ID, OrchestrationDb } from './db' -import { SCHEMA_VERSION } from './db/contract-constants' +import { federatedStubHomeRunId, SCHEMA_VERSION } from './db/contract-constants' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' describe('federated mailbox legacy-adoption probe', () => { @@ -17,15 +17,22 @@ describe('federated mailbox legacy-adoption probe', () => { } }) - function seedMailbox(handle: string, kind: 'message' | 'delivery'): string { + function seedMailbox( + handle: string, + kind: 'message' | 'delivery', + homeRunId = 'run_home', + mailRunId = LEGACY_RUN_ID + ): string { directory = mkdtempSync(join(tmpdir(), 'orca-federated-legacy-probe-')) const path = join(directory, 'orchestration.db') db = new OrchestrationDb(path) - db.db.exec(` - INSERT INTO remote_dispatch_attachments ( - dispatch_id, task_id, home_peer_fingerprint, home_run_id, runtime_epoch, state - ) VALUES ('ctx_remote', 'task_remote', 'peer_home', 'run_home', 'epoch', 'ready'); - `) + db.db + .prepare( + `INSERT INTO remote_dispatch_attachments ( + dispatch_id, task_id, home_peer_fingerprint, home_run_id, runtime_epoch, state + ) VALUES ('ctx_remote', 'task_remote', 'peer_home', ?, 'epoch', 'ready')` + ) + .run(homeRunId) if (kind === 'message') { db.db .prepare( @@ -33,14 +40,14 @@ describe('federated mailbox legacy-adoption probe', () => { id, run_id, delivery_contract, from_handle, to_handle, subject, type ) VALUES ('msg_probe', ?, 'current_delivery', 'term_home', ?, 'continue', 'dispatch')` ) - .run(LEGACY_RUN_ID, handle) + .run(mailRunId, handle) } else { db.db .prepare( `INSERT INTO deliveries (id, run_id, mailbox_handle, consumer_generation, message_ids) VALUES ('delivery_probe', ?, ?, 0, '[]')` ) - .run(LEGACY_RUN_ID, handle) + .run(mailRunId, handle) } return path } @@ -70,6 +77,27 @@ describe('federated mailbox legacy-adoption probe', () => { } ) + it.each(['message', 'delivery'] as const)( + 'does not treat a stub-home-Run attachment %s as pre-Runs evidence', + (kind) => { + const stubRunId = federatedStubHomeRunId('ctx_remote') + const path = seedMailbox('dispatch:ctx_remote', kind, stubRunId, stubRunId) + db!.db + .prepare( + `INSERT INTO runs (id, objective, home_database, consumer_generation, legacy) + VALUES (?, 'Coordinated from peer_home', 'remote', 0, 0)` + ) + .run(stubRunId) + expect( + resolveOrchestrationMigrationStartVersion(db!.db, SCHEMA_VERSION, SCHEMA_VERSION) + ).toBe(SCHEMA_VERSION) + db!.close() + db = new OrchestrationDb(path) + expect(db.getLegacyAdoption()).toBeUndefined() + expect(db.getRemoteDispatchAttachment('ctx_remote')?.home_run_id).toBe(stubRunId) + } + ) + it.each(['message', 'delivery'] as const)( 'still replays adoption for a genuine legacy %s', (kind) => { diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.test.ts new file mode 100644 index 00000000000..f00e5102d6d --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { FederationAttachStartParams } from './federation-start-schema' + +// The request shape a v1.4.198 coordinator sends: no runId field at all. +const legacyRequest = { + dispatchId: 'ctx_legacy', + taskId: 'task_legacy', + taskSpec: 'Do the thing', + protocolVersion: 3, + worktree: 'feature-branch' +} + +describe('FederationAttachStartParams', () => { + it('parses a v1.4.198 request that carries no runId', () => { + const result = FederationAttachStartParams.safeParse(legacyRequest) + expect(result.success, result.success ? undefined : JSON.stringify(result.error.issues)).toBe( + true + ) + expect(result.success && result.data.runId).toBeUndefined() + }) + + it('keeps a v1.4.199 runId verbatim', () => { + const result = FederationAttachStartParams.parse({ ...legacyRequest, runId: 'run_home' }) + expect(result.runId).toBe('run_home') + }) + + // Pins OptionalString: '' and non-strings drop to undefined (a stub Run is minted downstream); + // whitespace-only passes the schema and is refused by createRemoteDispatchAttachment. + it('maps an empty or non-string runId to undefined but passes whitespace through', () => { + expect(FederationAttachStartParams.parse({ ...legacyRequest, runId: '' }).runId).toBeUndefined() + expect(FederationAttachStartParams.parse({ ...legacyRequest, runId: 7 }).runId).toBeUndefined() + expect(FederationAttachStartParams.parse({ ...legacyRequest, runId: ' ' }).runId).toBe(' ') + }) + + it('still requires the dispatch, task, spec, and worktree fields', () => { + for (const field of ['dispatchId', 'taskId', 'taskSpec', 'worktree'] as const) { + const { [field]: _dropped, ...rest } = legacyRequest + expect(FederationAttachStartParams.safeParse(rest).success, field).toBe(false) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts index 1e7257df27d..84ed57d58cc 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts @@ -3,7 +3,8 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../../../s import { OptionalWorkerLaunchPreference } from '../worker/worker-start-schema' export const FederationAttachStartParams = z.object({ - runId: requiredString('Missing Run ID'), + /** Omitted by v1.4.198 coordinators; the worker host then mints a stub home Run. */ + runId: OptionalString, dispatchId: requiredString('Missing Dispatch ID'), taskId: requiredString('Missing Task ID'), taskSpec: requiredString('Missing Task spec'), From d15a6df22497ce7d444e669ea3cc015f77e73388 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:50:32 -0700 Subject: [PATCH 08/14] Render task checklists with update diffs and a composer progress panel (#19230) * Render native chat task lists with incremental checklist updates * Keep native chat task-list review plan out of repository root * Render live Codex plan notifications through task checklists * fix(native-chat): keep checklist test fixture within shared boundary * Keep agent task progress in one composer panel * Restore inline task checklists and historical update diffs --------- Co-authored-by: Merge Sim --- .../codex-structured-item-translation.test.ts | 10 + ...eChatMessageList.task-list-frames.test.tsx | 225 ++++++++++++++++ .../native-chat/NativeChatMessageList.tsx | 251 ++++++++++-------- .../native-chat/NativeChatMessageRow.tsx | 12 +- .../native-chat/NativeChatTaskList.test.tsx | 75 ++++++ .../native-chat/NativeChatTaskList.tsx | 183 +++++++++++++ .../native-chat/NativeChatToolRun.test.tsx | 55 ++++ .../native-chat/NativeChatToolRun.tsx | 28 +- .../native-chat-task-list-frames.ts | 36 +++ .../native-chat-task-list-history.test.ts | 114 ++++++++ .../native-chat-task-list-history.ts | 83 ++++++ .../native-chat-task-list-state.test.ts | 73 +++++ .../native-chat-task-list-state.ts | 43 +++ src/renderer/src/i18n/locales/en.json | 16 ++ src/shared/native-chat-task-list.test.ts | 138 ++++++++++ src/shared/native-chat-task-list.ts | 122 +++++++++ src/shared/native-chat-tool-icon.test.ts | 3 + src/shared/native-chat-tool-icon.ts | 1 + 18 files changed, 1352 insertions(+), 116 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatTaskList.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-task-list-frames.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-task-list-history.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-task-list-state.ts create mode 100644 src/shared/native-chat-task-list.test.ts create mode 100644 src/shared/native-chat-task-list.ts diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 45afb0c9fa5..201df58bc90 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -584,6 +584,16 @@ describe('codex item bodies', () => { }) }) + it('preserves plan prose documents byte-for-byte as status text', () => { + const text = + ' # Implementation plan\r\n\r\n- [ ] Preserve prose\r\n- [x] Keep café → 日本語\r\n\r\n```ts\r\nconst task = "pending"\r\n```\r\n ' + + expect(codexJournalItem({ type: 'plan', id: 'plan-document', text })).toEqual({ + body: { kind: 'status', text, presentation: 'plan-document' }, + handled: true + }) + }) + it('renders reasoning as status and exposes an unknown item as a provider frame', () => { expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({ kind: 'status', diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx new file mode 100644 index 00000000000..e34c1afd131 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx @@ -0,0 +1,225 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types' +import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { NativeChatMessageList } from './NativeChatMessageList' +import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' + +afterEach(cleanup) + +function frame(id: number, status: string, overrides: { kind?: string; truncated?: boolean } = {}) { + const kind = overrides.kind ?? 'notification:turn/plan/updated' + const head = JSON.stringify({ + threadId: 'thread', + turnId: 'turn', + explanation: 'Keep verification visible', + plan: [{ step: 'Verify', status }] + }) + const body: AgentJournalStatusItem = { + kind: 'status', + text: `codex · ${kind}`, + providerFrame: { + provider: 'codex', + kind, + payload: { + head, + byteLength: new TextEncoder().encode(head).byteLength, + digest: 'fixture-digest', + truncated: overrides.truncated ?? false + } + } + } + const message = projectStructuredItemToNativeChat({ + itemId: `frame-${id}`, + revision: 1, + sequence: id, + observedAt: id, + body + }) + if (!message) { + throw new Error('Expected a projected message') + } + return message +} + +function transcript(messages: NativeChatMessage[], sessionId = 'live-codex') { + return ( + + ) +} + +describe('live Codex checklist frames', () => { + it('updates one pinned checklist from journal notifications without rewinding on pagination', () => { + const first = frame(1, 'pending') + const active = frame(2, 'inProgress') + const last = frame(3, 'completed') + const { rerender, container } = render(transcript([first])) + const toggle = screen.getByRole('button', { name: 'Tasks 0 of 1 tasks completed' }) + const viewport = container.querySelector('.overflow-y-auto')! + expect(viewport.contains(toggle)).toBe(false) + fireEvent.click(toggle) + rerender(transcript([first, active])) + expect(within(toggle.parentElement!).getByText('Verify').closest('li')).toHaveClass( + 'text-foreground' + ) + expect(within(viewport as HTMLElement).getByText('Started Verify')).toBeInTheDocument() + rerender(transcript([last])) + expect( + within( + screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' }).parentElement! + ).getByText('Verify') + ).toHaveClass('line-through') + expect(screen.getAllByText('Keep verification visible')).toHaveLength(2) + rerender(transcript([first, active, last])) + expect(screen.getAllByText('Verify')).toHaveLength(2) + expect(screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' })).toBe(toggle) + expect(screen.getByText('Started Verify')).toBeInTheDocument() + expect(screen.queryByText('notification:turn/plan/updated')).toBeNull() + expect(projectNativeChatTaskListFrames([last])[0]).toBe( + projectNativeChatTaskListFrames([last])[0] + ) + }) + + it('uses the latest complete snapshot across Codex tool calls and notifications', () => { + const tool: NativeChatMessage = { + id: 'tool', + role: 'assistant', + timestamp: 1, + source: 'transcript', + blocks: [ + { + type: 'tool-call', + name: 'update_plan', + input: { plan: [{ step: 'Verify', status: 'pending' }] } + } + ] + } + render(transcript([tool, frame(3, 'completed')])) + fireEvent.click(screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' })) + expect(screen.getAllByText('Verify')).toHaveLength(2) + expect( + within( + screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' }).parentElement! + ).getByText('Verify') + ).toHaveClass('line-through') + }) + + it('keeps malformed, truncated, other-provider, and plan-document frames unchanged', () => { + const truncated = frame(1, 'pending', { truncated: true }) + const document = frame(2, 'pending', { kind: 'item:plan' }) + const malformed = frame(3, 'pending') + const otherProvider = frame(4, 'pending') + const malformedBlock = malformed.blocks[0] + const otherBlock = otherProvider.blocks[0] + if (malformedBlock.type === 'text' && malformedBlock.providerFrame) { + malformedBlock.providerFrame.payload.head = '{"plan":null}' + } + if (otherBlock.type === 'text' && otherBlock.providerFrame) { + otherBlock.providerFrame.provider = 'claude' + } + const messages = [truncated, document, malformed, otherProvider] + const projected = projectNativeChatTaskListFrames(messages) + projected.forEach((message, index) => expect(message).toBe(messages[index])) + render(transcript([truncated])) + expect(screen.getByText('notification:turn/plan/updated')).toBeInTheDocument() + expect(screen.queryByText('Tasks')).toBeNull() + }) + + it('does not consume a neighboring tool failure as a notification result', () => { + const command: NativeChatMessage = { + id: 'command', + role: 'assistant', + timestamp: 2, + source: 'transcript', + blocks: [ + { type: 'tool-call', name: 'shell', input: { command: 'verify' }, state: 'failed' }, + { type: 'tool-result', output: 'Verification failed', isError: true } + ] + } + render(transcript([frame(1, 'pending'), command])) + expect(screen.getByRole('button', { name: 'Tasks 0 of 1 tasks completed' })).toBeInTheDocument() + expect(screen.getByText('Verification failed', { selector: 'pre' })).toHaveClass( + 'text-destructive' + ) + }) +}) + +describe('NativeChatMessageList task list history', () => { + it('keeps the latest Claude state after pagination and resets disclosure between sessions', () => { + const first = { + id: 'first-list', + role: 'assistant' as const, + timestamp: 1, + source: 'transcript' as const, + blocks: [ + { + type: 'tool-call' as const, + name: 'TodoWrite', + input: { + todos: [ + { content: 'Read', status: 'pending' }, + { content: 'Test', status: 'pending' } + ] + } + } + ] + } + const last = { + ...first, + id: 'last-list', + timestamp: 3, + blocks: [ + { type: 'text' as const, text: 'Ready for verification' }, + { + type: 'tool-call' as const, + name: 'TodoWrite', + input: { + todos: [ + { content: 'Read', status: 'completed' }, + { content: 'Test', status: 'pending' } + ] + } + } + ] + } + const { rerender } = render(transcript([last])) + fireEvent.click(screen.getByRole('button', { name: 'Tasks 1 of 2 tasks completed' })) + rerender(transcript([first, last])) + expect(screen.getAllByText('Read')).toHaveLength(2) + expect(screen.getByText('Completed Read')).toBeInTheDocument() + expect( + within( + screen.getByRole('button', { name: 'Tasks 1 of 2 tasks completed' }).parentElement! + ).getByText('Read') + ).toHaveClass('line-through') + expect(screen.getByText('Ready for verification')).toBeInTheDocument() + rerender(transcript([first], 'two')) + expect(screen.getByRole('button', { name: 'Tasks 0 of 2 tasks completed' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(screen.getAllByText('Read')).toHaveLength(1) + rerender(transcript([], 'three')) + expect(screen.queryByText('Tasks')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index d8cee7f7e7c..c6da9030a2c 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -5,6 +5,10 @@ import { translate } from '@/i18n/i18n' import type { NativeChatLiveSession } from './use-native-chat-live-session' import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll' +import { nativeChatTaskListState } from './native-chat-task-list-state' +import { nativeChatTaskListPredecessors } from './native-chat-task-list-history' +import { NativeChatTaskList } from './NativeChatTaskList' +import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' import { MessageRow } from './NativeChatMessageRow' import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator' import { NativeChatWorkingStatus } from './NativeChatWorkingStatus' @@ -112,9 +116,11 @@ export function NativeChatMessageList({ [session.agent, session.sessionId] ) const messages = useMemo( - () => projectMessages(session.messages), + () => projectNativeChatTaskListFrames(projectMessages(session.messages)), [projectMessages, session.messages] ) + const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages]) + const taskListState = useMemo(() => nativeChatTaskListState(messages), [messages]) const showTypingIndicator = showTurnStatus ? isWorking : shouldShowNativeChatTypingIndicator({ messages, isWorking }) @@ -224,123 +230,140 @@ export function NativeChatMessageList({ }, [handleScroll, scrollToBottom]) return ( -

-
+
+
- {hasMore ? ( -
- -
- ) : null} - {messages.map((message, index) => { - const turnKey = turnKeys[index] - const isCurrentTurn = currentTurnKey - ? turnKey === currentTurnKey - : turnKey === undefined - const status = - index === latestUserIndex - ? turnStatuses.active - : message.role === 'user' && turnKey - ? turnStatuses.completedByTurn[turnKey] - : undefined - const receipt = receipts.get(message.id) - const turnDiff = - turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined - return ( - - {receipt ? ( - - ) : ( - - )} - {showTurnStatus && - status && - (index !== latestUserIndex || showTypingIndicator || !isWorking) ? ( - toggleExpandedTurn(turnKey) - : undefined - } - /> - ) : null} - {turnDiff ? ( - - ) : null} - - ) - })} - {showTurnStatus && - latestUserIndex === -1 && - turnStatuses.active && - showTypingIndicator ? ( - - ) : null} - {showTurnStatus && isWorking ? ( - - ) : null} - {!showTurnStatus && showTypingIndicator ? : null} +
+ {hasMore ? ( +
+ +
+ ) : null} + {messages.map((message, index) => { + const turnKey = turnKeys[index] + const isCurrentTurn = currentTurnKey + ? turnKey === currentTurnKey + : turnKey === undefined + const status = + index === latestUserIndex + ? turnStatuses.active + : message.role === 'user' && turnKey + ? turnStatuses.completedByTurn[turnKey] + : undefined + const receipt = receipts.get(message.id) + const turnDiff = + turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined + return ( + + {receipt ? ( + + ) : ( + + )} + {showTurnStatus && + status && + (index !== latestUserIndex || showTypingIndicator || !isWorking) ? ( + toggleExpandedTurn(turnKey) + : undefined + } + /> + ) : null} + {turnDiff ? ( + + ) : null} + + ) + })} + {showTurnStatus && + latestUserIndex === -1 && + turnStatuses.active && + showTypingIndicator ? ( + + ) : null} + {showTurnStatus && isWorking ? ( + + ) : null} + {!showTurnStatus && showTypingIndicator ? : null} +
+ {showJump ? ( + + ) : null}
- {showJump ? ( - + {taskListState.list && taskListState.list.tasks.length > 0 ? ( +
+
+ +
+
) : null}
) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index e3a4e78efb4..773e6f2c2ff 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -8,7 +8,11 @@ import { isSubagentGroupFallbackText, subagentGroupBlocks } from '../../../../shared/native-chat-subagent-summary' -import { isSubagentGroupBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' +import { + isSubagentGroupBlock, + type NativeChatMessage, + type NativeChatToolCallBlock +} from '../../../../shared/native-chat-types' import { splitNativeChatBlocks } from './native-chat-tool-fold' import { NativeChatToolRun } from './NativeChatToolRun' import { NativeChatNoticeRow } from './NativeChatNoticeRow' @@ -29,6 +33,8 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' * keep their block identity, so only the changed row re-renders. */ export const MessageRow = memo(function MessageRow({ message, + previousTodoWrite, + previousUpdatePlan, revealedDiff, expandSignal, activeTurnIsWorking, @@ -41,6 +47,8 @@ export const MessageRow = memo(function MessageRow({ runtimeContext }: { message: NativeChatMessage + previousTodoWrite?: NativeChatToolCallBlock + previousUpdatePlan?: NativeChatToolCallBlock revealedDiff?: NativeChatDiffReveal expandSignal: boolean activeTurnIsWorking?: boolean @@ -202,6 +210,8 @@ export const MessageRow = memo(function MessageRow({ {tools.length > 0 || subagentGroups.length > 0 ? ( { + it('shows tri-state glyphs, progress, and activeForm in the first checklist', () => { + const { container } = render() + expect(screen.getByText('Read')).toHaveClass('line-through') + expect(screen.getByText('Writing').closest('li')).toHaveClass('text-foreground') + expect(screen.getByText('Test')).toBeInTheDocument() + expect(screen.getByLabelText('1 of 3 tasks completed')).toHaveTextContent('1/3') + for (const glyph of ['circle', 'circle-dot', 'circle-check']) { + expect(container.querySelector(`.lucide-${glyph}`)).not.toBeNull() + } + expect(screen.getByText('In progress:')).toHaveClass('sr-only') + }) + + it('leads with the diff and expands the complete checklist on demand', () => { + render() + expect(screen.getByText('Completed Read')).toBeInTheDocument() + expect(screen.getByText('Started Write')).toBeInTheDocument() + expect(screen.queryByText('Test')).toBeNull() + const disclosure = screen.getByRole('button', { name: 'Full task list' }) + expect(disclosure).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(disclosure) + expect(disclosure).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('Writing')).toBeInTheDocument() + expect(screen.getByText('Test')).toBeInTheDocument() + }) + + it('shows unchanged feedback and the current explanation', () => { + render( + + ) + expect(screen.getByText('Tasks unchanged')).toBeInTheDocument() + expect(screen.getByText('Continuing verification')).toBeInTheDocument() + expect(screen.queryByText('Test')).toBeNull() + }) + + it('renders empty lists without claiming any task completed', () => { + render() + expect(screen.getByText('No tasks')).toBeInTheDocument() + expect(screen.getByLabelText('0 of 0 tasks completed')).toHaveTextContent('0/0') + }) + + it('switches from full list to diff when earlier history supplies a predecessor', () => { + const { rerender } = render() + expect(screen.getByText('Test')).toBeInTheDocument() + rerender() + expect(screen.queryByText('Test')).toBeNull() + expect(screen.getByText('Started Write')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatTaskList.tsx b/src/renderer/src/components/native-chat/NativeChatTaskList.tsx new file mode 100644 index 00000000000..3ddaa8959e8 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatTaskList.tsx @@ -0,0 +1,183 @@ +import { Circle, CircleCheck, CircleDot, ChevronRight, ListChecks } from 'lucide-react' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + diffNativeChatTaskLists, + nativeChatTaskLabel, + type NativeChatTask, + type NativeChatTaskChange, + type NativeChatTaskList as TaskList +} from '../../../../shared/native-chat-task-list' + +function statusLabel(task: NativeChatTask): string { + if (task.status === 'completed') { + return translate('components.native-chat.taskList.completed', 'Completed') + } + if (task.status === 'in_progress') { + return translate('components.native-chat.taskList.inProgress', 'In progress') + } + return translate('components.native-chat.taskList.pending', 'Pending') +} + +function changeLabel(change: NativeChatTaskChange): string { + const values = { task: change.task.content } + switch (change.kind) { + case 'added': + return translate('components.native-chat.taskList.added', 'Added {{task}}', values) + case 'removed': + return translate('components.native-chat.taskList.removed', 'Removed {{task}}', values) + case 'started': + return translate('components.native-chat.taskList.started', 'Started {{task}}', values) + case 'completed': + return translate('components.native-chat.taskList.finished', 'Completed {{task}}', values) + case 'pending': + return translate('components.native-chat.taskList.reset', 'Marked pending: {{task}}', values) + case 'updated': + return translate('components.native-chat.taskList.updated', 'Updated {{task}}', { + task: nativeChatTaskLabel(change.task) + }) + } +} + +function TaskRow({ task, label }: { task: NativeChatTask; label?: string }): React.JSX.Element { + const Icon = + task.status === 'completed' ? CircleCheck : task.status === 'in_progress' ? CircleDot : Circle + return ( +
  • + + {statusLabel(task)}: + + {label ?? nativeChatTaskLabel(task)} + +
  • + ) +} + +function Checklist({ list }: { list: TaskList }): React.JSX.Element { + return list.tasks.length === 0 ? ( +

    + {translate('components.native-chat.taskList.empty', 'No tasks')} +

    + ) : ( +
      + {list.tasks.map((task, index) => ( + + ))} +
    + ) +} + +export function NativeChatTaskList({ + list, + previous, + presentation = 'inline' +}: { + list: TaskList + previous?: TaskList + presentation?: 'inline' | 'composer' +}): React.JSX.Element { + const completed = list.tasks.filter((task) => task.status === 'completed').length + if (presentation === 'composer') { + return ( + + + + + {translate('components.native-chat.taskList.title', 'Tasks')} + + + {completed}/{list.tasks.length} + + + + +
    + + {list.explanation ? ( +

    + {list.explanation} +

    + ) : null} +
    +
    +
    + ) + } + const changes = previous ? diffNativeChatTaskLists(previous, list) : null + return ( +
    +
    + + + {translate('components.native-chat.taskList.title', 'Tasks')} + + + {completed}/{list.tasks.length} + +
    + {changes ? ( + <> + {changes.length > 0 ? ( +
      + {changes.map((change, index) => ( + + ))} +
    + ) : ( +

    + {translate('components.native-chat.taskList.unchanged', 'Tasks unchanged')} +

    + )} + + + + {translate('components.native-chat.taskList.showAll', 'Full task list')} + + + + + + + ) : ( + + )} + {list.explanation ? ( +

    + {list.explanation} +

    + ) : null} +
    + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx index cd819c5ced1..f38f63df4c6 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx @@ -730,3 +730,58 @@ describe('NativeChatToolRun', () => { expect(screen.getByTitle('ls')).toHaveTextContent('ls') }) }) + +describe('NativeChatToolRun task lists', () => { + it('renders task updates instead of JSON and consumes successful results', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'update_plan', + input: { + plan: [ + { step: 'Read', status: 'in_progress' }, + { step: 'Test', status: 'pending' } + ] + } + }, + { type: 'tool-result', output: 'Plan updated' }, + { + type: 'tool-call', + name: 'update_plan', + input: { + plan: [ + { step: 'Read', status: 'completed' }, + { step: 'Test', status: 'in_progress' } + ] + } + } + ] + const { container } = render() + expect(screen.getByText('Completed Read')).toBeInTheDocument() + expect(screen.getByText('Started Test')).toBeInTheDocument() + expect(screen.getByText('1/2')).toBeInTheDocument() + expect(screen.queryByText('Plan updated')).toBeNull() + expect(container.querySelector('pre')).toBeNull() + }) + + it('keeps malformed calls and failed results visible in the generic view', () => { + render( + + ) + expect(screen.getByText('Invalid arguments', { selector: 'pre' })).toBeInTheDocument() + expect(screen.getByText('Update rejected', { selector: 'pre' })).toBeInTheDocument() + expect(screen.queryByText('1/1')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index 26ff8f40d6e..69f79c4f907 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -31,6 +31,9 @@ import { selectActiveToolCall } from '../../../../shared/native-chat-tool-activity' import { nativeChatToolRunIconName } from '../../../../shared/native-chat-tool-icon' +import type { NativeChatToolCallBlock } from '../../../../shared/native-chat-types' +import { NativeChatTaskList } from './NativeChatTaskList' +import { buildNativeChatTaskListRows } from './native-chat-task-list-history' import { NativeChatDiffView } from './NativeChatDiffView' import { NativeChatSubagentRun } from './NativeChatSubagentRun' import { NativeChatToolIcon, NativeChatToolRunIcon } from './NativeChatToolIcon' @@ -158,6 +161,8 @@ function ToolLine({ * toolbar toggle drive every run at once while still allowing per-run override. */ export function NativeChatToolRun({ blocks, + previousTodoWrite, + previousUpdatePlan, revealedDiff, onRevealDiff, subagentGroups = NO_SUBAGENT_GROUPS, @@ -168,6 +173,8 @@ export function NativeChatToolRun({ onLinkClick }: { blocks: NativeChatBlock[] + previousTodoWrite?: NativeChatToolCallBlock + previousUpdatePlan?: NativeChatToolCallBlock revealedDiff?: NativeChatDiffReveal onRevealDiff?: (element: HTMLElement) => void /** Spawn-group rosters that belong with this run's activity, one row each. */ @@ -231,6 +238,18 @@ export function NativeChatToolRun({ // The turn caret opens the activity group, while each child tool remains // collapsed. The global expand toolbar still opens child details together. const expandToolLines = expandOverride === undefined ? open : false + // Diffing every edit is the run's most expensive work, so a collapsed run — + // which renders none of it — never pays for it. + const taskLists = useMemo( + () => + open + ? buildNativeChatTaskListRows(blocks, { + todowrite: previousTodoWrite, + update_plan: previousUpdatePlan + }) + : null, + [open, blocks, previousTodoWrite, previousUpdatePlan] + ) // Rollups cache counts only; detailed diff rows are built when the run opens. const { editCards, consumedResults } = useMemo( () => (open ? buildEditCards(blocks) : NO_EDIT_CARDS), @@ -381,7 +400,14 @@ export function NativeChatToolRun({
    {(() => { const seen = new Map() - return blocks.map((block) => { + return blocks.map((block, blockIndex) => { + const taskList = taskLists?.rows.get(block) + if (taskList) { + return + } + if (taskLists?.consumedResults.has(block)) { + return null + } const edit = editCards.get(block) if (edit) { return ( diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-frames.ts b/src/renderer/src/components/native-chat/native-chat-task-list-frames.ts new file mode 100644 index 00000000000..f58c4a2bbdd --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-frames.ts @@ -0,0 +1,36 @@ +import { normalizeNativeChatTaskList } from '../../../../shared/native-chat-task-list' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' + +const projectedFrames = new WeakMap() + +/** Project after tool folding so a notification never takes another call's result. */ +export function projectNativeChatTaskListFrames( + messages: readonly NativeChatMessage[] +): NativeChatMessage[] { + return messages.map((message) => { + const cached = projectedFrames.get(message) + if (cached) { + return cached + } + const block = message.blocks.length === 1 ? message.blocks[0] : undefined + const frame = block?.type === 'text' ? block.providerFrame : undefined + if ( + message.role !== 'system' || + frame?.provider !== 'codex' || + frame.kind !== 'notification:turn/plan/updated' || + frame.payload.truncated || + !normalizeNativeChatTaskList('update_plan', frame.payload.head) + ) { + return message + } + const projected: NativeChatMessage = { + ...message, + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'update_plan', input: frame.payload.head, state: 'completed' } + ] + } + projectedFrames.set(message, projected) + return projected + }) +} diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts b/src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts new file mode 100644 index 00000000000..ef8eae291ad --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import type { + NativeChatBlock, + NativeChatMessage, + NativeChatToolCallBlock +} from '../../../../shared/native-chat-types' +import { + buildNativeChatTaskListRows, + nativeChatTaskListPredecessors +} from './native-chat-task-list-history' + +function call(name = 'TodoWrite', status = 'pending'): NativeChatToolCallBlock { + return { + type: 'tool-call', + name, + input: + name === 'TodoWrite' + ? { todos: [{ content: 'Test', status }] } + : { plan: [{ step: 'Test', status }] } + } +} +function message( + id: string, + blocks: NativeChatBlock[], + role: NativeChatMessage['role'] = 'assistant' +): NativeChatMessage { + return { id, blocks, role, timestamp: 1, source: 'transcript' } +} + +describe('native chat task list history', () => { + it('carries predecessors across prose, ordinary tools, and user turns', () => { + const first = call() + const next = call('TodoWrite', 'completed') + const history = nativeChatTaskListPredecessors([ + message('a', [first]), + message('b', [{ type: 'text', text: 'Continue' }], 'user'), + message('c', [{ type: 'tool-call', name: 'Read', input: {} }]), + message('d', [next]) + ]) + expect(history.get('d')?.todowrite).toBe(first) + expect( + buildNativeChatTaskListRows([next], history.get('d')).rows.get(next)?.previous?.tasks[0] + .status + ).toBe('pending') + }) + + it('keeps interleaved tool families separate and ignores MCP lookalikes', () => { + const claude = call() + const codex = call('update_plan') + const next = call('TodoWrite', 'completed') + const model = buildNativeChatTaskListRows([claude, codex, call('mcp__x__TodoWrite'), next]) + expect(model.rows.get(codex)?.previous).toBeUndefined() + expect(model.rows.get(next)?.previous).toEqual(model.rows.get(claude)?.list) + const history = nativeChatTaskListPredecessors([ + message('a', [claude]), + message('b', [codex]), + message('c', [next]) + ]) + expect(history.get('c')).toEqual({ todowrite: claude, update_plan: codex }) + }) + + it('skips failed and malformed calls and keeps errors unconsumed', () => { + const first = call() + const failed = { ...call(), state: 'failed' as const } + const rejected = call('TodoWrite', 'completed') + const error: NativeChatBlock = { type: 'tool-result', output: 'Rejected', isError: true } + const next = call('TodoWrite', 'in_progress') + const blocks: NativeChatBlock[] = [ + first, + { type: 'tool-result', output: 'ok' }, + failed, + { type: 'tool-result', output: 'failed' }, + rejected, + error, + { ...call(), input: '{' }, + next + ] + const model = buildNativeChatTaskListRows(blocks) + expect(model.rows.has(failed)).toBe(false) + expect(model.rows.has(rejected)).toBe(false) + expect(model.consumedResults.has(error)).toBe(false) + expect(model.rows.get(next)?.previous).toEqual(model.rows.get(first)?.list) + const history = nativeChatTaskListPredecessors([ + message('a', blocks.slice(0, -1)), + message('b', [next]) + ]) + expect(history.get('b')?.todowrite).toBe(first) + }) + + it('updates predecessor identity after pagination and remains stable on rerender', () => { + const first = call() + const second = call('TodoWrite', 'in_progress') + const tail = message('b', [second]) + expect(nativeChatTaskListPredecessors([tail]).get('b')?.todowrite).toBeUndefined() + const history = nativeChatTaskListPredecessors([message('a', [first]), tail]) + expect(history.get('b')?.todowrite).toBe(first) + expect(nativeChatTaskListPredecessors([message('a', [first]), tail]).get('b')?.todowrite).toBe( + history.get('b')?.todowrite + ) + expect(nativeChatTaskListPredecessors([tail]).get('b')?.todowrite).toBeUndefined() + }) + + it('diffs a running call before its result arrives and consumes a successful result', () => { + const first = call() + const running = { ...call('TodoWrite', 'in_progress'), state: 'running' as const } + const result: NativeChatBlock = { type: 'tool-result', output: 'ok' } + const model = buildNativeChatTaskListRows([running, result], { + todowrite: first, + update_plan: undefined + }) + expect(model.rows.get(running)?.previous).toBeDefined() + expect(model.consumedResults.has(result)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-history.ts b/src/renderer/src/components/native-chat/native-chat-task-list-history.ts new file mode 100644 index 00000000000..62582e2a7cf --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-history.ts @@ -0,0 +1,83 @@ +import { + nativeChatTaskListTool, + normalizeNativeChatTaskList, + type NativeChatTaskList, + type NativeChatTaskListTool +} from '../../../../shared/native-chat-task-list' +import type { + NativeChatBlock, + NativeChatMessage, + NativeChatToolCallBlock +} from '../../../../shared/native-chat-types' +import { pairToolBlocks } from './native-chat-tool-fold' + +export type NativeChatTaskListPredecessors = Partial< + Record +> +export type NativeChatTaskListRow = { list: NativeChatTaskList; previous?: NativeChatTaskList } + +function taskListFromCall(call: NativeChatToolCallBlock): NativeChatTaskList | null { + return call.state === 'failed' ? null : normalizeNativeChatTaskList(call.name, call.input) +} + +/** Store call identities so unchanged rows stay memoized, while prepends replace their context. */ +export function nativeChatTaskListPredecessors( + messages: readonly NativeChatMessage[] +): Map { + const history = new Map() + const previous: NativeChatTaskListPredecessors = {} + for (const message of messages) { + history.set(message.id, { ...previous }) + if (message.role === 'user') { + continue + } + for (const { call, result } of pairToolBlocks(message.blocks)) { + if (!call || result?.isError) { + continue + } + const tool = nativeChatTaskListTool(call.name) + if (tool && taskListFromCall(call)) { + previous[tool] = call + } + } + } + return history +} + +export function buildNativeChatTaskListRows( + blocks: readonly NativeChatBlock[], + predecessors: NativeChatTaskListPredecessors = {} +): { + rows: Map + consumedResults: Set +} { + const rows = new Map() + const consumedResults = new Set() + const previous = new Map() + for (const call of Object.values(predecessors)) { + if (!call) { + continue + } + const tool = nativeChatTaskListTool(call.name) + const list = taskListFromCall(call) + if (tool && list) { + previous.set(tool, list) + } + } + for (const { call, result } of pairToolBlocks(blocks)) { + if (!call || result?.isError) { + continue + } + const tool = nativeChatTaskListTool(call.name) + const list = taskListFromCall(call) + if (!tool || !list) { + continue + } + rows.set(call, { list, previous: previous.get(tool) }) + previous.set(tool, list) + if (result) { + consumedResults.add(result) + } + } + return { rows, consumedResults } +} diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts b/src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts new file mode 100644 index 00000000000..c282c4d4532 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' +import { nativeChatTaskListState } from './native-chat-task-list-state' + +function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage { + return { id, role: 'assistant', timestamp: 1, source: 'transcript', blocks } +} +function call(content: string, status = 'pending'): NativeChatBlock { + return { type: 'tool-call', name: 'TodoWrite', input: { todos: [{ content, status }] } } +} + +describe('nativeChatTaskListState', () => { + it('projects one latest snapshot, preserves prose and leaves source messages unchanged', () => { + const first = message('first', [call('Read')]) + const last = message('last', [ + { type: 'text', text: 'Here is the result' }, + call('Read', 'completed'), + { type: 'tool-result', output: 'Updated todos' } + ]) + const result = nativeChatTaskListState([first, last]) + expect(result.list?.tasks).toEqual([{ content: 'Read', status: 'completed' }]) + expect(result.messages[0]).toBe(first) + expect(result.messages[1]).toBe(last) + expect(first.blocks).toHaveLength(1) + expect(last.blocks).toHaveLength(3) + expect(nativeChatTaskListState([first, last]).messages[1]).toBe(result.messages[1]) + }) + + it('preserves latest state across user follow-ups and clears it on an explicit empty list', () => { + const first = message('first', [call('Read')]) + const user = { ...message('user', [{ type: 'text', text: 'Continue' }]), role: 'user' as const } + const empty = message('empty', [{ type: 'tool-call', name: 'TodoWrite', input: { todos: [] } }]) + expect(nativeChatTaskListState([first, user]).list?.tasks).toHaveLength(1) + expect(nativeChatTaskListState([first, user, empty]).list?.tasks).toEqual([]) + expect(nativeChatTaskListState([]).list).toBeNull() + }) + + it('does not replace valid state with malformed or failed calls, and retains their diagnostics', () => { + const first = message('first', [call('Read')]) + const malformed = message('malformed', [ + { type: 'tool-call', name: 'TodoWrite', input: '{' }, + { type: 'tool-result', output: 'Invalid arguments', isError: true } + ]) + const failed = message('failed', [ + call('Wrong', 'completed'), + { type: 'tool-result', output: 'Update rejected', isError: true } + ]) + const failedCall = message('failed-call', [ + { type: 'tool-call', name: 'TodoWrite', state: 'failed', input: { todos: [] } } + ]) + const result = nativeChatTaskListState([first, malformed, failed, failedCall]) + expect(result.list?.tasks[0].content).toBe('Read') + expect(result.messages.slice(1)).toEqual([malformed, failed, failedCall]) + }) + + it('retains task history and unrelated errors while selecting the paired snapshot', () => { + const tasks: NativeChatBlock = call('Read') + const shell: NativeChatBlock = { + type: 'tool-call', + name: 'shell', + input: {} + } + const error: NativeChatBlock = { + type: 'tool-result', + output: 'Failed', + isError: true + } + const success: NativeChatBlock = { type: 'tool-result', output: 'Updated' } + const result = nativeChatTaskListState([message('mixed', [tasks, success, shell, error])]) + expect(result.list?.tasks[0].content).toBe('Read') + expect(result.messages[0].blocks).toEqual([tasks, success, shell, error]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-state.ts b/src/renderer/src/components/native-chat/native-chat-task-list-state.ts new file mode 100644 index 00000000000..f45d6531600 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-state.ts @@ -0,0 +1,43 @@ +import { + normalizeNativeChatTaskList, + type NativeChatTaskList +} from '../../../../shared/native-chat-task-list' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { pairToolBlocks } from './native-chat-tool-fold' + +const snapshots = new WeakMap() + +function latestSnapshot(message: NativeChatMessage): NativeChatTaskList | null { + if (snapshots.has(message)) { + return snapshots.get(message) ?? null + } + let list: NativeChatTaskList | null = null + if (message.role === 'assistant') { + for (const { call, result } of pairToolBlocks(message.blocks)) { + if (!call || call.state === 'failed' || result?.isError) { + continue + } + const snapshot = normalizeNativeChatTaskList(call.name, call.input) + if (snapshot) { + list = snapshot + } + } + } + snapshots.set(message, list) + return list +} + +/** Select composer progress without consuming historical transcript updates. */ +export function nativeChatTaskListState(messages: readonly NativeChatMessage[]): { + messages: readonly NativeChatMessage[] + list: NativeChatTaskList | null +} { + let list: NativeChatTaskList | null = null + for (const message of messages) { + const snapshot = latestSnapshot(message) + if (snapshot) { + list = snapshot + } + } + return { messages, list } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2f33a2d9745..4f283dc4f47 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16994,6 +16994,22 @@ "empty": "No users found" }, "native-chat": { + "taskList": { + "title": "Tasks", + "completed": "Completed", + "inProgress": "In progress", + "pending": "Pending", + "empty": "No tasks", + "progress": "{{completed}} of {{total}} tasks completed", + "added": "Added {{task}}", + "removed": "Removed {{task}}", + "started": "Started {{task}}", + "finished": "Completed {{task}}", + "reset": "Marked pending: {{task}}", + "updated": "Updated {{task}}", + "unchanged": "Tasks unchanged", + "showAll": "Full task list" + }, "turnDiff": { "one": "1 changed file", "many": "{{count}} changed files", diff --git a/src/shared/native-chat-task-list.test.ts b/src/shared/native-chat-task-list.test.ts new file mode 100644 index 00000000000..0bbb7077f3c --- /dev/null +++ b/src/shared/native-chat-task-list.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { + diffNativeChatTaskLists, + nativeChatTaskLabel, + normalizeNativeChatTaskList, + type NativeChatTask, + type NativeChatTaskList +} from './native-chat-task-list' + +const task = (content: string, status: NativeChatTask['status'] = 'pending'): NativeChatTask => ({ + content, + status +}) +const list = (...tasks: NativeChatTask[]): NativeChatTaskList => ({ tasks }) + +describe('normalizeNativeChatTaskList', () => { + it('normalizes Claude tasks and uses activeForm only while in progress', () => { + const result = normalizeNativeChatTaskList('TodoWrite', { + todos: [ + { content: 'Read', status: 'completed', activeForm: 'Reading' }, + { content: 'Write', status: 'in_progress', activeForm: 'Writing' }, + { content: 'Test', status: 'pending', activeForm: 'Testing' } + ] + })! + expect(result.tasks.map(nativeChatTaskLabel)).toEqual(['Read', 'Writing', 'Test']) + expect(result.tasks.map((entry) => entry.status)).toEqual([ + 'completed', + 'in_progress', + 'pending' + ]) + }) + + it('normalizes Codex JSON-string arguments and explanation', () => { + expect( + normalizeNativeChatTaskList( + 'update_plan', + JSON.stringify({ + explanation: 'Proceed with verification', + plan: [{ step: 'Test', status: 'in_progress' }] + }) + ) + ).toEqual({ explanation: 'Proceed with verification', tasks: [task('Test', 'in_progress')] }) + }) + + it('defaults unknown/missing statuses and ignores invalid entries', () => { + expect( + normalizeNativeChatTaskList(' TodoWrite ', { + todos: [ + null, + [], + 4, + {}, + { content: ' ' }, + { content: 7 }, + { content: ' One ', status: 'unknown', activeForm: 4 }, + { content: 'Two' } + ] + }) + ).toEqual(list(task('One'), task('Two'))) + }) + + it.each([undefined, null, 42, [], '{', '{}', { todos: null }, { todos: [{}] }])( + 'returns null for malformed input %j', + (input) => { + expect(normalizeNativeChatTaskList('TodoWrite', input)).toBeNull() + } + ) + + it('keeps empty lists valid and recognizes only exact tool families', () => { + expect(normalizeNativeChatTaskList('update_plan', { plan: [] })).toEqual(list()) + expect(normalizeNativeChatTaskList('TodoWrite', { todos: [] })).toEqual(list()) + expect(normalizeNativeChatTaskList('mcp__server__TodoWrite', { todos: [] })).toBeNull() + expect(normalizeNativeChatTaskList('ExitPlanMode', { plan: [] })).toBeNull() + expect(normalizeNativeChatTaskList('update_plan', { todos: [] })).toBeNull() + }) +}) + +describe('diffNativeChatTaskLists', () => { + it('reports completions and starts, omitting unchanged tasks', () => { + expect( + diffNativeChatTaskLists( + list(task('Read', 'in_progress'), task('Write'), task('Test')), + list(task('Read', 'completed'), task('Write', 'in_progress'), task('Test')) + ) + ).toEqual([ + { kind: 'completed', task: task('Read', 'completed') }, + { kind: 'started', task: task('Write', 'in_progress') } + ]) + }) + + it('ignores reorder-only updates and explanation changes', () => { + expect( + diffNativeChatTaskLists(list(task('A'), task('B')), { + tasks: [task('B'), task('A')], + explanation: 'Reordered' + }) + ).toEqual([]) + }) + + it('matches duplicate contents by occurrence', () => { + expect( + diffNativeChatTaskLists( + list(task('A'), task('A', 'in_progress')), + list(task('A', 'completed'), task('A', 'in_progress')) + ) + ).toEqual([{ kind: 'completed', task: task('A', 'completed') }]) + }) + + it('reports renamed content as an addition and removal', () => { + expect(diffNativeChatTaskLists(list(task('Old')), list(task('New')))).toEqual([ + { kind: 'added', task: task('New') }, + { kind: 'removed', task: task('Old') } + ]) + }) + + it('reports resets, reopening, and activeForm-only edits', () => { + const changed = { ...task('C', 'in_progress'), activeForm: 'Checking C' } + expect( + diffNativeChatTaskLists( + list(task('A', 'completed'), task('B', 'completed'), task('C', 'in_progress')), + list(task('A'), task('B', 'in_progress'), changed) + ) + ).toEqual([ + { kind: 'pending', task: task('A') }, + { kind: 'started', task: task('B', 'in_progress') }, + { kind: 'updated', task: changed } + ]) + }) + + it('reports clearing a list and removing a duplicate', () => { + expect(diffNativeChatTaskLists(list(task('A')), list())).toEqual([ + { kind: 'removed', task: task('A') } + ]) + expect(diffNativeChatTaskLists(list(task('A'), task('A')), list(task('A')))).toEqual([ + { kind: 'removed', task: task('A') } + ]) + }) +}) diff --git a/src/shared/native-chat-task-list.ts b/src/shared/native-chat-task-list.ts new file mode 100644 index 00000000000..4d4d470b246 --- /dev/null +++ b/src/shared/native-chat-task-list.ts @@ -0,0 +1,122 @@ +export type NativeChatTaskStatus = 'pending' | 'in_progress' | 'completed' +export type NativeChatTask = { + content: string + status: NativeChatTaskStatus + activeForm?: string +} +export type NativeChatTaskList = { tasks: NativeChatTask[]; explanation?: string } +export type NativeChatTaskChange = { + kind: 'added' | 'removed' | 'started' | 'completed' | 'pending' | 'updated' + task: NativeChatTask +} +export type NativeChatTaskListTool = 'todowrite' | 'update_plan' + +export function nativeChatTaskListTool(name: string): NativeChatTaskListTool | null { + const normalized = name.trim().toLowerCase() + return normalized === 'todowrite' || normalized === 'update_plan' ? normalized : null +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function nonemptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +export function normalizeNativeChatTaskList( + name: string, + input: unknown +): NativeChatTaskList | null { + const tool = nativeChatTaskListTool(name) + if (!tool) { + return null + } + if (typeof input === 'string') { + try { + input = JSON.parse(input) + } catch { + return null + } + } + const value = record(input) + const entries = tool === 'todowrite' ? value?.todos : value?.plan + if (!Array.isArray(entries)) { + return null + } + const tasks: NativeChatTask[] = [] + for (const entry of entries) { + const item = record(entry) + const content = nonemptyString(tool === 'todowrite' ? item?.content : item?.step) + if (!item || !content) { + continue + } + const status = + item.status === 'in_progress' || (tool === 'update_plan' && item.status === 'inProgress') + ? 'in_progress' + : item.status === 'completed' + ? 'completed' + : 'pending' + const activeForm = tool === 'todowrite' ? nonemptyString(item.activeForm) : undefined + tasks.push({ content, status, ...(activeForm ? { activeForm } : {}) }) + } + if (entries.length > 0 && tasks.length === 0) { + return null + } + const explanation = tool === 'update_plan' ? nonemptyString(value?.explanation) : undefined + return { tasks, ...(explanation ? { explanation } : {}) } +} + +export function nativeChatTaskLabel(task: NativeChatTask): string { + return task.status === 'in_progress' && task.activeForm ? task.activeForm : task.content +} + +/** Content plus occurrence is the only identity the providers give these entries. */ +export function diffNativeChatTaskLists( + previous: NativeChatTaskList, + current: NativeChatTaskList +): NativeChatTaskChange[] { + const byContent = new Map() + for (const task of previous.tasks) { + const matches = byContent.get(task.content) + if (matches) { + matches.push(task) + } else { + byContent.set(task.content, [task]) + } + } + const occurrences = new Map() + const consumed = new Set() + const changes: NativeChatTaskChange[] = [] + for (const task of current.tasks) { + const occurrence = occurrences.get(task.content) ?? 0 + occurrences.set(task.content, occurrence + 1) + const before = byContent.get(task.content)?.[occurrence] + if (!before) { + changes.push({ kind: 'added', task }) + continue + } + consumed.add(before) + if (before.status !== task.status) { + changes.push({ + kind: + task.status === 'completed' + ? 'completed' + : task.status === 'in_progress' + ? 'started' + : 'pending', + task + }) + } else if (before.activeForm !== task.activeForm) { + changes.push({ kind: 'updated', task }) + } + } + for (const task of previous.tasks) { + if (!consumed.has(task)) { + changes.push({ kind: 'removed', task }) + } + } + return changes +} diff --git a/src/shared/native-chat-tool-icon.test.ts b/src/shared/native-chat-tool-icon.test.ts index 695400c94ca..f8cabed950c 100644 --- a/src/shared/native-chat-tool-icon.test.ts +++ b/src/shared/native-chat-tool-icon.test.ts @@ -50,6 +50,9 @@ describe('native chat tool icons', () => { expect(nativeChatToolCategory('list')).toBe('listFiles') expect(nativeChatToolCategory('shell')).toBe('unknown') expect(nativeChatToolCategory('apply_patch')).toBe('fileChange') + expect(nativeChatToolCategory('update_plan')).toBe('todoList') + expect(nativeChatToolIconName('update_plan')).toBe('list-checks') + expect(nativeChatToolRunIconName([{ name: 'update_plan' }])).toBe('list-checks') expect(nativeChatToolCategory('web search')).toBe('webSearch') }) diff --git a/src/shared/native-chat-tool-icon.ts b/src/shared/native-chat-tool-icon.ts index d546a76e865..d52df9e52e9 100644 --- a/src/shared/native-chat-tool-icon.ts +++ b/src/shared/native-chat-tool-icon.ts @@ -81,6 +81,7 @@ const CATEGORY_BY_ROW_WORD = new Map([ ['task', 'subAgentActivity'], ['webfetch', 'webSearch'], ['todowrite', 'todoList'], + ['update_plan', 'todoList'], ['web search', 'webSearch'], ['websearch', 'webSearch'], ['web_search', 'webSearch'] From 7197593e31a2e44ca536b6d1a75da47e6bf19851 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:53:17 -0700 Subject: [PATCH 09/14] fix(lint): preserve deliberate collator benchmark baselines (#19686) Co-authored-by: Merge Sim --- config/scripts/locale-collator-sort-benchmark.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/scripts/locale-collator-sort-benchmark.mjs b/config/scripts/locale-collator-sort-benchmark.mjs index 3d34466532a..1a0b08e1643 100644 --- a/config/scripts/locale-collator-sort-benchmark.mjs +++ b/config/scripts/locale-collator-sort-benchmark.mjs @@ -129,6 +129,7 @@ for (const count of [36, 50, 250]) { const issues = makeJiraIssues(count) const before = () => [...issues] + // oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Baseline measures per-comparison setup against a reused collator. .sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true })) .map((issue) => issue.key) const after = () => sortJiraIssues(issues, 'key', 'asc').map((issue) => issue.key) @@ -142,6 +143,7 @@ for (const count of [36, 50, 250]) { for (const count of [10, 50, 250]) { const values = makeBaseSensitivityValues(count) const before = () => + // oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Baseline measures per-comparison setup against a reused collator. [...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) const after = () => [...values].sort(compareBaseSensitivityLocaleText) assertSameOrder(before, after, `base ${count}`) From 2ee4053ae6b00a1e6c5241d52ee931da86d096ad Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:05:10 -0400 Subject: [PATCH 10/14] fix(native-chat): merge duplicate native-chat-types import (#19698) #19230 added a second import of the same module, and the focused code-quality gate (import/no-duplicates, --deny-warnings) fails every PR opened on main since it merged. Claude-Session: 1fec75fd-224b-46ab-95fe-d88e0f3d9ff9 --- src/renderer/src/components/native-chat/NativeChatToolRun.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index 69f79c4f907..6feb44bd703 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -12,7 +12,8 @@ import { isToolCallBlock, isToolResultBlock, type NativeChatBlock, - type NativeChatSubagentGroupBlock + type NativeChatSubagentGroupBlock, + type NativeChatToolCallBlock } from '../../../../shared/native-chat-types' import { isRenderableSubagentGroup } from '../../../../shared/native-chat-subagent-summary' import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff' @@ -31,7 +32,6 @@ import { selectActiveToolCall } from '../../../../shared/native-chat-tool-activity' import { nativeChatToolRunIconName } from '../../../../shared/native-chat-tool-icon' -import type { NativeChatToolCallBlock } from '../../../../shared/native-chat-types' import { NativeChatTaskList } from './NativeChatTaskList' import { buildNativeChatTaskListRows } from './native-chat-task-list-history' import { NativeChatDiffView } from './NativeChatDiffView' From 0fe132ea29bc38bd6f0e66c1a9f27e04cb6f729b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:06:38 -0400 Subject: [PATCH 11/14] fix(orchestration): file mail from terminals in no Run under an unbound Run (#19696) * fix(orchestration): file mail from terminals in no Run under an unbound Run #19542 deleted the fallback that filed such mail under the legacy Run, because a live row there makes the schema-skew probe read the database as pre-Runs and replay adoption on the next open. That refusal also broke the first command in the guide: `orca orchestration send --to ` between two plain terminals, which worked in v1.4.198. Restore delivery by filing under `run_unbound`, a Run the probe never matches, created on first use so `run list` shows it only to a user who has such mail. Claude-Session: 1fec75fd-224b-46ab-95fe-d88e0f3d9ff9 * fix(orchestration): create the unbound Run only for a null Run id Claude-Session: 1fec75fd-224b-46ab-95fe-d88e0f3d9ff9 --- .../orchestration/db/contract-constants.ts | 6 ++- .../db/messages/message-insert.ts | 15 +++++-- .../db/writer-run-required.test.ts | 11 +++--- ...chestration-federated-legacy-probe.test.ts | 17 +++++++- .../messaging/send-unbound-terminals.test.ts | 31 +++++++++++++++ src/shared/orchestration-rpc-contract.ts | 3 ++ .../orchestration-idle-mail-delivery.spec.ts | 39 +++++-------------- 7 files changed, 83 insertions(+), 39 deletions(-) create mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts diff --git a/src/main/runtime/orchestration/db/contract-constants.ts b/src/main/runtime/orchestration/db/contract-constants.ts index 0a2e27f000f..56c2e2d542f 100644 --- a/src/main/runtime/orchestration/db/contract-constants.ts +++ b/src/main/runtime/orchestration/db/contract-constants.ts @@ -1,7 +1,11 @@ -import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../shared/orchestration-rpc-contract' +import { + ORCHESTRATION_LEGACY_RUN_ID, + ORCHESTRATION_UNBOUND_RUN_ID +} from '../../../../shared/orchestration-rpc-contract' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID +export const UNBOUND_RUN_ID = ORCHESTRATION_UNBOUND_RUN_ID // Why: a v1.4.198 coordinator sends no Run id, so its remote workers file mail under a per-attachment stub Run. export const FEDERATED_STUB_HOME_RUN_ID_PREFIX = 'run_federated_' diff --git a/src/main/runtime/orchestration/db/messages/message-insert.ts b/src/main/runtime/orchestration/db/messages/message-insert.ts index 82573305479..51ce7aaaa50 100644 --- a/src/main/runtime/orchestration/db/messages/message-insert.ts +++ b/src/main/runtime/orchestration/db/messages/message-insert.ts @@ -3,6 +3,7 @@ import { generateId } from '../generated-id' import { exposeMessageTimestamps } from '../utc-timestamp' import type { OrchestrationDb } from '../orchestration-db' import { runLifecycleWriteTransaction } from '../lifecycle-write-transaction-runner' +import { UNBOUND_RUN_ID } from '../contract-constants' // ── Messages ── @@ -25,9 +26,17 @@ export type MessageInsert = { } export function insertMessage(this: OrchestrationDb, msg: MessageInsert): MessageRow { - const runId = msg.runId - if (!runId) { - throw new Error('Run is required') + // A sender in no Run (two plain terminals, `send --to `) still gets durable mail. It is + // filed under the unbound Run, never the legacy one, which the schema-skew probe reads as pre-Runs. + // Created on first use so `run list` shows it only to a user who has such mail. + const runId = msg.runId ?? UNBOUND_RUN_ID + if (msg.runId == null) { + this.db + .prepare( + `INSERT OR IGNORE INTO runs (id, objective, home_database, consumer_generation, legacy) + VALUES (?, 'Mail from terminals in no Run', 'this_database', 0, 0)` + ) + .run(UNBOUND_RUN_ID) } const deliveryContract = msg.deliveryContract ?? 'current_delivery' this.requireRun(runId) diff --git a/src/main/runtime/orchestration/db/writer-run-required.test.ts b/src/main/runtime/orchestration/db/writer-run-required.test.ts index 21fcbeb28d6..682fabb1b60 100644 --- a/src/main/runtime/orchestration/db/writer-run-required.test.ts +++ b/src/main/runtime/orchestration/db/writer-run-required.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OrchestrationDb } from './orchestration-db' +import { UNBOUND_RUN_ID } from './contract-constants' describe('writers require a Run', () => { let db: OrchestrationDb @@ -8,11 +9,11 @@ describe('writers require a Run', () => { }) afterEach(() => db.close()) - it('rejects a message without a Run instead of using the legacy Run', () => { - expect(() => db.insertMessage({ from: 'sender', to: 'worker', subject: 'mail' })).toThrow( - 'Run is required' - ) - expect(db.db.prepare('SELECT id FROM messages').all()).toEqual([]) + it('files a message without a Run under the unbound Run, never the legacy one', () => { + const message = db.insertMessage({ from: 'sender', to: 'worker', subject: 'mail' }) + expect(message.run_id).toBe(UNBOUND_RUN_ID) + expect(db.getRun(UNBOUND_RUN_ID)).toMatchObject({ legacy: 0 }) + expect(db.getUnreadMessages('worker').map((row) => row.id)).toEqual([message.id]) }) it('rejects a Task without a Run instead of using the legacy Run', () => { diff --git a/src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts b/src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts index 24d18f0baef..5eff7569d27 100644 --- a/src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts +++ b/src/main/runtime/orchestration/orchestration-federated-legacy-probe.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { LEGACY_RUN_ID, OrchestrationDb } from './db' -import { federatedStubHomeRunId, SCHEMA_VERSION } from './db/contract-constants' +import { federatedStubHomeRunId, SCHEMA_VERSION, UNBOUND_RUN_ID } from './db/contract-constants' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' describe('federated mailbox legacy-adoption probe', () => { @@ -122,4 +122,19 @@ describe('federated mailbox legacy-adoption probe', () => { } } ) + + it('keeps mail from a terminal in no Run across a reopen without replaying adoption', () => { + directory = mkdtempSync(join(tmpdir(), 'orca-unbound-mail-probe-')) + const path = join(directory, 'orchestration.db') + db = new OrchestrationDb(path) + const sent = db.insertMessage({ from: 'term_a', to: 'term_b', subject: 'hi' }) + expect(sent.run_id).toBe(UNBOUND_RUN_ID) + expect(resolveOrchestrationMigrationStartVersion(db.db, SCHEMA_VERSION, SCHEMA_VERSION)).toBe( + SCHEMA_VERSION + ) + db.close() + db = new OrchestrationDb(path) + expect(db.getLegacyAdoption()).toBeUndefined() + expect(db.getUnreadMessages('term_b').map((row) => row.id)).toEqual([sent.id]) + }) }) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts new file mode 100644 index 00000000000..84cb250a19e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-unbound-terminals.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createOrchestrationRpcHarness } from '../rpc-test-harness' + +describe('orchestration.send between terminals in no Run', () => { + const h = createOrchestrationRpcHarness() + afterEach(() => h.cleanup()) + + it('delivers terminal-to-terminal mail when neither terminal is in a Run', async () => { + // Two plain panes and `send --to `: the first command the guide teaches. #19542 + // refused this with a bare "Run is required"; it files under the unbound Run instead. + const { db, runtime, ctx } = h.setup(false) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_a' ? 'tab_a:leaf_a' : handle === 'term_b' ? 'tab_b:leaf_b' : null + ) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + + const result = (await h.call( + 'orchestration.send', + { from: 'term_a', to: 'term_b', subject: 'hello from no Run' }, + ctx + )) as { message: { id: string; run_id: string; to_handle: string } } + + expect(result.message).toMatchObject({ run_id: 'run_unbound', to_handle: 'term_b' }) + expect(db.getRun('run_unbound')).toMatchObject({ legacy: 0 }) + expect(db.getUnreadMessages('term_b').map((row) => row.id)).toEqual([result.message.id]) + const checked = (await h.call('orchestration.check', { terminal: 'term_b' }, ctx)) as { + messages: { id: string }[] + } + expect(checked.messages.map((row) => row.id)).toEqual([result.message.id]) + }) +}) diff --git a/src/shared/orchestration-rpc-contract.ts b/src/shared/orchestration-rpc-contract.ts index 3067d5ad6b0..fec4d4ed4e7 100644 --- a/src/shared/orchestration-rpc-contract.ts +++ b/src/shared/orchestration-rpc-contract.ts @@ -14,6 +14,9 @@ export const ORCHESTRATION_SKILL_COMMAND_ARGS = [ ] as const export const ORCHESTRATION_LEGACY_RUN_ID = 'run_legacy_local' +/** Files mail sent from a terminal in no Run. Distinct from the legacy Run on purpose: rows under + * the legacy id read as a pre-Runs database and trigger adoption on the next open. */ +export const ORCHESTRATION_UNBOUND_RUN_ID = 'run_unbound' const ORCHESTRATION_MUTATION_METHODS = new Set([ 'orchestration.runCreate', diff --git a/tests/e2e/orchestration-idle-mail-delivery.spec.ts b/tests/e2e/orchestration-idle-mail-delivery.spec.ts index 8ca6657ab7c..cffa6415208 100644 --- a/tests/e2e/orchestration-idle-mail-delivery.spec.ts +++ b/tests/e2e/orchestration-idle-mail-delivery.spec.ts @@ -35,7 +35,6 @@ import { waitForPaneIdentitySnapshot } from './helpers/terminal' import { RuntimeClient, type RuntimeRpcSuccess } from '../../src/cli/runtime-client' -import { RuntimeRpcFailureError } from '../../src/cli/runtime/types' import type { RuntimeTerminalListResult } from '../../src/shared/runtime-types' import { CODEX_IDLE_TITLE, @@ -354,7 +353,7 @@ test.describe('orchestration push-on-idle mail delivery', () => { // #19542 deleted the legacy-Run write fallback, so a sender in no Run has // nowhere to file mail to a bare handle: the send is refused outright, which // is what keeps an unsafe pointer out of the pane on the next idle frame. - test('refuses unbound direct mail from a sender in no Run instead of pushing it', async ({ + test('keeps unbound direct mail durable without pointing to an unsafe check', async ({ orcaPage, electronApp }) => { @@ -363,40 +362,22 @@ test.describe('orchestration push-on-idle mail delivery', () => { const pane = await openAgentPane() await driveToLiveIdle(client, pane) + // Two plain terminals, neither in a Run: `send --to ` must still land durably. It + // files under the unbound Run, so a reopen never reads it as pre-Runs state (#19542 regression). const stdinBeforeScan = pane.agent.readStdin() - const refusal = await sendMail(client, pane.handle, { subject: 'Unbound direct mail' }).then( - () => undefined, - (error: unknown) => error - ) - // Why runtime_error and not run_required: insertMessage throws a plain - // Error, which the dispatcher passes through with its message and no - // recovery data — the sibling no-recipient path is the one that adds it. - // The request-id suffix and stamp are the client's durable-mutation bookkeeping. - expect(refusal).toBeInstanceOf(RuntimeRpcFailureError) - expect(refusal).toMatchObject({ - code: 'runtime_error', - message: expect.stringContaining('Run is required') - }) - // Pins that the SERVER attached no orchestrationSkillRecoveryData, without - // freezing whatever else the client may stamp alongside its request id. - const refusalData = (refusal as RuntimeRpcFailureError).data - expect(refusalData).toMatchObject({ orchestrationRequestId: expect.any(String) }) - expect(refusalData).not.toHaveProperty('effectsApplied') - expect(refusalData).not.toHaveProperty('guide') - expect(refusalData).not.toHaveProperty('nextCommandArgs') - expect(refusalData).not.toHaveProperty('nextSteps') - expect(readMailbox(userDataDir, pane.handle)).toEqual([]) - - // A busy→idle edge is the push trigger. Walking one proves the refusal left - // nothing behind for the scan to point at, not merely that the push was slow. + const messageId = await sendMail(client, pane.handle, { subject: 'Unbound direct mail' }) pane.agent.setTitle(CODEX_WORKING_TITLE) await waitForObservedTitle(client, pane.handle, CODEX_WORKING_TITLE) pane.agent.setTitle(CODEX_IDLE_TITLE) await waitForObservedTitle(client, pane.handle, CODEX_IDLE_TITLE) - // The ledger only proves anything once the push window has fully elapsed. await orcaPage.waitForTimeout(NO_DELIVERY_SETTLE_MS) - expect(readMailbox(userDataDir, pane.handle)).toEqual([]) + expect(readMailRow(userDataDir, messageId)).toMatchObject({ + to_handle: pane.handle, + run_id: 'run_unbound', + read: 0, + delivered_at: null + }) expect(pane.agent.readStdin()).toBe(stdinBeforeScan) }) From 5868fdc9e3ad9efd27a265b3af51f59b6815c6a8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:13:20 -0700 Subject: [PATCH 12/14] feat(native-chat): report Codex background tasks in the chat strip (#19346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): report Codex background tasks in the chat strip The background-tasks strip works for Claude only; a structured Codex session shows nothing in it. Feed it from the Codex app-server stream. The strip stands for work that OUTLIVED a turn, which is what the monitoring header, Claude's foreground suppression, and the conversation command gate all already assume. Codex has no `is_backgrounded` flag, so that fact is derived from the turn boundary: a `subAgentActivity` child or a primary-thread `commandExecution` becomes visible once the turn it belongs to completes and it is still unsettled. `turn/completed` only reveals a task here, never settles one — measured on `codex app-server` 0.153.4, a spawn_agent child reported `completed` 95.8s after its parent turn ended. Only a child's own activity kind settles it. Codex exposes no honest stop: `turn/interrupt` on a child ends its turn without emitting a terminal activity item and leaves its shell running. So the state carries a new optional `supportsStopAll: false`, the strip hides a control that could not act, and the blocked-command message asks the user to wait rather than to press a button that does not exist. * refactor(codex): move session teardown out of the structured adapter Merging main crossed the 300-line cap on `codex-structured-session-adapter.ts`: the rewind backend (#19235) and this branch's close-time strip clear both landed in it. The four close paths move verbatim into `codex-structured-session-teardown.ts`, where they funnel through one `settled` helper instead of repeating the notification-retry and background-task cleanup at each call site. No ratchet bump. Also normalize a background task's description once at receipt rather than on every projection; the roster is re-projected on each observed frame. * fix(codex): drop the shell row the journal already settles A `commandExecution` still `inProgress` when its turn ends was reported as a `command` task. But `settleCodexJournalTurn` writes exactly those items to the journal as `state: 'failed'` on `turn/completed` and forgets them, so the strip row would have claimed a shell was still running at the same instant Orca recorded that it was not — two surfaces contradicting each other about the same process. A subagent is the opposite case and stays: the roster pointedly does not sweep at a turn boundary, because children measurably outlive it. That leaves the producer making exactly one claim — these spawn_agent children are still live after their turn — which the durable roster row corroborates. * fix(native-chat): track Codex background execution lifetimes * fix(native-chat): keep running tool groups from claiming completion * Fix runtime catalog and capability expectation * fix(codex): keep a child's name on the command row that outlives it A child agent's commands stay hidden behind its agent row while the child works. Once the child's turn settles with a command still running, that command surfaces as its own row labelled from the raw command string, so 'long_probe' became "/bin/zsh -lc 'ping -c 300 127.0.0.1 > /dev/null'" at the moment that row was the only remaining signal for the work. Qualify a child's command row with the child's label. Resolved on read, so a label registered after the command still lands, and bounded by the existing description cap so admission accounting stays valid. Primary- thread commands are left unqualified: they have no child to name. --------- Co-authored-by: Merge Sim --- src/cli/runtime/websocket-transport.test.ts | 2 + .../codex-background-command-tracker.test.ts | 128 ++++++++ .../codex/codex-background-command-tracker.ts | 150 ++++++++++ .../codex/codex-background-task-frames.ts | 72 +++++ .../codex-background-task-tracker.test.ts | 280 ++++++++++++++++++ .../codex/codex-background-task-tracker.ts | 96 ++++++ src/main/codex/codex-command-lifecycle.ts | 6 + src/main/codex/codex-item-stream-retention.ts | 107 +++++++ ...codex-persistent-command-retention.test.ts | 219 ++++++++++++++ .../codex-structured-item-stream-bounds.ts | 1 + .../codex-structured-item-stream-contracts.ts | 5 +- .../codex/codex-structured-item-streams.ts | 68 +++-- .../codex-structured-journal-contracts.ts | 2 + .../codex/codex-structured-journal-items.ts | 25 +- .../codex-structured-journal-settlement.ts | 4 + ...x-structured-journal-translation-frames.ts | 22 ++ ...ured-journal-translation-subagents.test.ts | 16 + .../codex-structured-journal-translation.ts | 30 +- .../codex/codex-structured-session-acquire.ts | 6 + .../codex/codex-structured-session-adapter.ts | 87 ++---- ...tructured-session-background-tasks.test.ts | 258 ++++++++++++++++ .../codex-structured-session-close.test.ts | 2 + .../codex/codex-structured-session-close.ts | 4 + .../codex-structured-session-options.test.ts | 2 + .../codex/codex-structured-session-state.ts | 8 + .../codex-structured-session-teardown.ts | 94 ++++++ src/main/codex/codex-subagent-activity.ts | 21 +- ...odex-subagent-execution-projection.test.ts | 211 +++++++++++++ .../codex/codex-subagent-executions.test.ts | 51 ++++ src/main/codex/codex-subagent-executions.ts | 144 +++++++++ src/main/codex/codex-subagent-group-body.ts | 51 ++++ src/main/codex/codex-subagent-roster.test.ts | 23 +- src/main/codex/codex-subagent-roster.ts | 222 ++++++++------ src/main/ipc/runtime.ts | 3 + ...-session-delta-coalescer-protected.test.ts | 57 ++++ .../agent-session-delta-coalescer.ts | 26 +- ...red-conversation-command-admission.test.ts | 48 +++ ...ructured-conversation-command-admission.ts | 10 +- ...session-background-task-capability.test.ts | 105 +++++++ ...gent-session-background-task-capability.ts | 47 +++ .../rpc/methods/structured-agent-session.ts | 9 +- .../structured-agent-session-runtime.ts | 2 + .../NativeChatBackgroundTasksStatus.test.tsx | 55 ++++ .../NativeChatBackgroundTasksStatus.tsx | 5 +- .../NativeChatStructuredSession.test.tsx | 3 + .../NativeChatStructuredSession.tsx | 1 + .../native-chat/NativeChatToolRun.test.tsx | 16 +- .../native-chat/NativeChatToolRun.tsx | 5 +- ...tructured-session-background-tasks-view.ts | 31 ++ .../use-structured-agent-session-mutate.ts | 96 ++++++ .../use-structured-agent-session.ts | 99 ++----- src/shared/agent-session-wire.ts | 5 + src/shared/protocol-version.ts | 4 + .../remote-runtime-client-capabilities.ts | 2 + .../structured-agent-session-reducer.ts | 3 +- 55 files changed, 2716 insertions(+), 333 deletions(-) create mode 100644 src/main/codex/codex-background-command-tracker.test.ts create mode 100644 src/main/codex/codex-background-command-tracker.ts create mode 100644 src/main/codex/codex-background-task-frames.ts create mode 100644 src/main/codex/codex-background-task-tracker.test.ts create mode 100644 src/main/codex/codex-background-task-tracker.ts create mode 100644 src/main/codex/codex-command-lifecycle.ts create mode 100644 src/main/codex/codex-item-stream-retention.ts create mode 100644 src/main/codex/codex-persistent-command-retention.test.ts create mode 100644 src/main/codex/codex-structured-session-background-tasks.test.ts create mode 100644 src/main/codex/codex-structured-session-teardown.ts create mode 100644 src/main/codex/codex-subagent-execution-projection.test.ts create mode 100644 src/main/codex/codex-subagent-executions.test.ts create mode 100644 src/main/codex/codex-subagent-executions.ts create mode 100644 src/main/codex/codex-subagent-group-body.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-protected.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx create mode 100644 src/renderer/src/components/native-chat/structured-session-background-tasks-view.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 162b431f1bf..4c178b71fd8 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_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, @@ -70,6 +71,7 @@ describe('CLI remote WebSocket transport', () => { expect(runtime.authFrames).toContainEqual( expect.objectContaining({ clientCapabilities: [ + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, diff --git a/src/main/codex/codex-background-command-tracker.test.ts b/src/main/codex/codex-background-command-tracker.test.ts new file mode 100644 index 00000000000..abccb4e4a35 --- /dev/null +++ b/src/main/codex/codex-background-command-tracker.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { CodexBackgroundCommandTracker } from './codex-background-command-tracker' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexStructuredSessionEvent } from './codex-structured-session-state' + +function notification( + method: string, + params: Record +): Extract { + return { + type: 'notification', + sessionId: 'session', + threadId: 'root', + method, + params: { threadId: 'root', turnId: 'turn', ...params } + } +} + +function command(method: string, id = 'exec', threadId = 'root') { + return { + ...notification(method, { + item: { + type: 'commandExecution', + id, + command: 'sleep 30', + source: 'unifiedExecStartup', + status: method === 'item/completed' ? 'completed' : 'inProgress', + exitCode: method === 'item/completed' ? 0 : null + } + }), + threadId + } +} + +describe('persistent command ownership', () => { + it('preflights finite metadata capacity and admits work again after process completion', () => { + const tracker = new CodexBackgroundCommandTracker('root', 700) + const first = command('item/started', 'first') + const second = command('item/started', 'second') + expect(tracker.canObserve(first)).toBe(true) + tracker.observe(first) + expect(tracker.canObserve(second)).toBe(false) + expect(() => tracker.observe(second)).toThrow('not admitted') + expect(tracker.tasks()).toHaveLength(1) + expect(tracker.retainedMetadataBytes).toBeLessThanOrEqual(700) + tracker.observe(command('item/completed', 'first')) + expect(tracker.canObserve(second)).toBe(true) + tracker.observe(second) + expect(tracker.tasks()).toHaveLength(1) + expect(tracker.retainedMetadataBytes).toBeLessThanOrEqual(700) + tracker.clear() + expect(tracker.retainedMetadataBytes).toBe(0) + }) + + it('keeps the journal running across turn completion and accepts late output and exit', () => { + const rows: { key: string; body: AgentJournalItemBody }[] = [] + const translator = createCodexJournalTranslator({ + primaryThreadId: () => 'root', + sink: { + appendItem: (identity, body) => rows.push({ key: agentJournalItemKey(identity), body }), + appendTombstone: () => {}, + publish: () => {} + } + }) + const tracker = new CodexBackgroundCommandTracker('root') + const deliver = (event: Extract) => { + expect(translator.handle(event)).toEqual({ accepted: true }) + tracker.observe(event) + } + deliver(notification('turn/started', { turn: { id: 'turn' } })) + deliver(command('item/started')) + const originalKey = rows.find(({ body }) => body.kind === 'tool-call')?.key + deliver(notification('turn/completed', { turn: { id: 'turn' } })) + expect(rows.filter(({ body }) => body.kind === 'tool-call').map(({ body }) => body)).toEqual([ + expect.objectContaining({ state: 'running' }) + ]) + expect(tracker.tasks()).toHaveLength(1) + deliver( + notification('item/commandExecution/outputDelta', { itemId: 'exec', delta: 'late output' }) + ) + translator.flush() + expect(rows.at(-1)).toMatchObject({ key: originalKey, body: { state: 'running' } }) + deliver(command('item/completed')) + expect(rows.at(-1)).toMatchObject({ key: originalKey, body: { state: 'completed' } }) + expect(tracker.tasks()).toEqual([]) + translator.dispose() + }) + + it('counts child shells only after the child stops covering them, without resurrecting exits', () => { + const tracker = new CodexBackgroundCommandTracker('root') + tracker.observe(command('item/started', 'child-exec', 'child')) + tracker.observe( + notification('item/started', { + item: { + type: 'commandExecution', + id: 'poll', + source: 'unifiedExecInteraction', + status: 'inProgress' + } + }) + ) + expect(tracker.tasks(new Set(['child']))).toEqual([]) + expect(tracker.tasks()).toEqual([ + { id: 'codex-command:thread:child:child-exec', kind: 'command', description: 'sleep 30' } + ]) + tracker.observe(command('item/completed', 'child-exec', 'child')) + tracker.observe(command('item/started')) + tracker.observe(command('item/completed')) + tracker.observe(command('item/started')) + expect(tracker.tasks()).toEqual([]) + }) + + it('retains live commands while recycling bounded settled history', () => { + const tracker = new CodexBackgroundCommandTracker('root') + tracker.observe(command('item/started', 'long-lived')) + for (let index = 0; index < 300; index += 1) { + tracker.observe(command('item/started', `short-${index}`)) + tracker.observe(command('item/completed', `short-${index}`)) + } + expect(tracker.tasks()).toEqual([ + { id: 'codex-command:primary:long-lived', kind: 'command', description: 'sleep 30' } + ]) + tracker.clear() + expect(tracker.tasks()).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-background-command-tracker.ts b/src/main/codex/codex-background-command-tracker.ts new file mode 100644 index 00000000000..becbb18a67c --- /dev/null +++ b/src/main/codex/codex-background-command-tracker.ts @@ -0,0 +1,150 @@ +import type { AgentSessionBackgroundTask } from '../../shared/agent-session-wire' +import type { CodexBackgroundTaskEvent } from './codex-background-task-frames' +import { codexCommandOutlivesTurn } from './codex-command-lifecycle' +import { readRecord, readString } from './codex-item-field-readers' +import { readCodexThreadItem } from './codex-structured-item-translation' +import { MAX_CODEX_ITEM_STREAM_METADATA_BYTES } from './codex-item-stream-retention' + +const MAX_SETTLED_COMMANDS = 128 +const MAX_DESCRIPTION_CHARS = 512 + +type Command = { threadId: string; task: AgentSessionBackgroundTask; bytes: number } + +/** Stays within the retained bound, so read-time qualification cannot outgrow admission. */ +function qualifiedDescription(label: string, description: string | undefined): string { + return (description ? `${label} — ${description}` : label).slice(0, MAX_DESCRIPTION_CHARS) +} + +export class CodexBackgroundCommandTracker { + private readonly commands = new Map() + private readonly settled = new Map() + private liveBytes = 0 + private settledBytes = 0 + + constructor( + private readonly primaryThreadId: string, + private readonly maxMetadataBytes = MAX_CODEX_ITEM_STREAM_METADATA_BYTES + ) {} + + get retainedMetadataBytes(): number { + return this.liveBytes + this.settledBytes + } + + canObserve(event: CodexBackgroundTaskEvent): boolean { + const parsed = this.parse(event) + return ( + !parsed || + parsed.completed || + this.commands.has(parsed.key) || + this.settled.has(parsed.key) || + this.liveBytes + parsed.command.bytes <= this.maxMetadataBytes + ) + } + + observe(event: CodexBackgroundTaskEvent): void { + const parsed = this.parse(event) + if (!parsed || this.settled.has(parsed.key)) { + return + } + const { key, command, completed } = parsed + const existing = this.commands.get(key) + if (completed) { + if (existing) { + this.liveBytes -= existing.bytes + this.commands.delete(key) + } + const bytes = Buffer.byteLength(key, 'utf8') + 256 + if (this.liveBytes + bytes <= this.maxMetadataBytes) { + this.settled.set(key, bytes) + this.settledBytes += bytes + } + this.trimSettled() + return + } + if (existing) { + return + } + if (this.liveBytes + command.bytes > this.maxMetadataBytes) { + throw new Error('Codex command metadata was not admitted before observation') + } + this.commands.set(key, command) + this.liveBytes += command.bytes + this.trimSettled() + } + + tasks( + coveredThreads?: ReadonlySet, + childLabel?: (threadId: string) => string | null + ): AgentSessionBackgroundTask[] { + return [...this.commands.values()] + .filter((command) => !coveredThreads?.has(command.threadId)) + .map(({ threadId, task }) => { + // The agent row carrying the child's name is gone by the time this row shows; + // unqualified it reads as a bare shell string with no owner. Resolved on read so + // 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 + }) + } + + clear(): void { + this.commands.clear() + this.settled.clear() + this.liveBytes = 0 + this.settledBytes = 0 + } + + private trimSettled(): void { + while ( + this.settled.size > MAX_SETTLED_COMMANDS || + this.retainedMetadataBytes > this.maxMetadataBytes + ) { + const oldest = this.settled.entries().next().value + if (!oldest) { + break + } + this.settled.delete(oldest[0]) + this.settledBytes -= oldest[1] + } + } + + private parse( + event: CodexBackgroundTaskEvent + ): { key: string; command: Command; completed: boolean } | null { + if (event.method !== 'item/started' && event.method !== 'item/completed') { + return null + } + const item = readCodexThreadItem(readRecord(event.params).item) + if (!item || !codexCommandOutlivesTurn(item)) { + return null + } + const key = JSON.stringify([event.threadId, item.id]) + const completed = event.method === 'item/completed' || item.status !== 'inProgress' + const description = readString(item, 'command') + ?.slice(0, MAX_DESCRIPTION_CHARS) + .replace(/\s+/g, ' ') + .trim() + const value = { + threadId: event.threadId, + task: { + id: + event.threadId === this.primaryThreadId + ? `codex-command:primary:${encodeURIComponent(item.id)}` + : `codex-command:thread:${encodeURIComponent(event.threadId)}:${encodeURIComponent(item.id)}`, + kind: 'command' as const, + ...(description ? { description } : {}) + } + } + return { + key, + completed, + command: { + ...value, + bytes: + Buffer.byteLength(key, 'utf8') + Buffer.byteLength(JSON.stringify(value), 'utf8') + 256 + } + } + } +} diff --git a/src/main/codex/codex-background-task-frames.ts b/src/main/codex/codex-background-task-frames.ts new file mode 100644 index 00000000000..a2e7a7e2151 --- /dev/null +++ b/src/main/codex/codex-background-task-frames.ts @@ -0,0 +1,72 @@ +import type { NativeChatSubagentState } from '../../shared/native-chat-types' +import { + codexSubagentLabel, + isCodexRootAgentActivity, + readCodexSubagentActivity +} from './codex-subagent-activity' +import { codexChildTurnState } from './codex-subagent-executions' +import { readRecord } from './codex-item-field-readers' +import { readCodexThreadItem } from './codex-structured-item-translation' +import { readCodexTurnId } from './codex-structured-thread-facts' + +export type CodexBackgroundTaskFrame = + | { + kind: 'subagent' + agentThreadId: string + label: string | null + parentTurnId: string | null | undefined + } + | { + kind: 'turn' + threadId: string + turnId: string + state: NativeChatSubagentState + } + +export type CodexBackgroundTaskEvent = { + method: string + threadId: string + params: unknown +} + +export function readCodexBackgroundTaskFrame( + event: CodexBackgroundTaskEvent, + primaryThreadId: string +): CodexBackgroundTaskFrame | null { + if (event.method === 'turn/started' || event.method === 'turn/completed') { + const turnId = readCodexTurnId(event.params) + if (turnId === null) { + return null + } + return { + kind: 'turn', + threadId: event.threadId, + turnId, + state: + event.method === 'turn/started' + ? 'working' + : codexChildTurnState(readRecord(readRecord(event.params).turn).status) + } + } + if (event.method !== 'item/started' && event.method !== 'item/completed') { + return null + } + const item = readCodexThreadItem(readRecord(event.params).item) + const activity = item && readCodexSubagentActivity(item) + if ( + !activity || + activity.agentThreadId === primaryThreadId || + isCodexRootAgentActivity(activity) + ) { + return null + } + return { + kind: 'subagent', + agentThreadId: activity.agentThreadId, + label: codexSubagentLabel(activity), + parentTurnId: + activity.kind === 'started' || activity.kind === 'interacted' + ? readCodexTurnId(event.params) + : undefined + } +} diff --git a/src/main/codex/codex-background-task-tracker.test.ts b/src/main/codex/codex-background-task-tracker.test.ts new file mode 100644 index 00000000000..47987fe3fc0 --- /dev/null +++ b/src/main/codex/codex-background-task-tracker.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from 'vitest' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { + readCodexBackgroundTaskFrame, + type CodexBackgroundTaskEvent +} from './codex-background-task-frames' + +const PRIMARY = 'parent-thread' +const PARENT_TURN = 'parent-turn' +const CHILD = 'child-thread' +const CHILD_TURN = 'child-turn' + +function turn( + method: 'turn/started' | 'turn/completed', + threadId: string, + turnId: string, + status = 'completed' +): CodexBackgroundTaskEvent { + return { method, threadId, params: { threadId, turn: { id: turnId, status } } } +} + +function activity( + kind = 'started', + parentTurn = PARENT_TURN, + child = CHILD +): CodexBackgroundTaskEvent { + return { + method: 'item/started', + threadId: PRIMARY, + params: { + threadId: PRIMARY, + turnId: parentTurn, + item: { + type: 'subAgentActivity', + id: `activity-${kind}`, + kind, + agentThreadId: child, + agentPath: '/root/count_a' + } + } + } +} + +function runningChild(): CodexBackgroundTaskTracker { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN)) + tracker.observe(turn('turn/started', CHILD, CHILD_TURN)) + tracker.observe(activity()) + return tracker +} + +function command(threadId = PRIMARY, method = 'item/started'): CodexBackgroundTaskEvent { + return { + method, + threadId, + params: { + threadId, + turnId: PARENT_TURN, + item: { + type: 'commandExecution', + id: 'exec-1', + processId: '71831', + source: 'unifiedExecStartup', + command: 'sleep 90', + status: method === 'item/started' ? 'inProgress' : 'completed' + } + } + } +} + +describe('readCodexBackgroundTaskFrame', () => { + it('reads activity as child metadata without inferring execution state', () => { + expect(readCodexBackgroundTaskFrame(activity('interacted'), PRIMARY)).toEqual({ + kind: 'subagent', + agentThreadId: CHILD, + label: 'count_a', + parentTurnId: PARENT_TURN + }) + }) + + it('reads a child turn with its own execution identity', () => { + expect(readCodexBackgroundTaskFrame(turn('turn/started', CHILD, CHILD_TURN), PRIMARY)).toEqual({ + kind: 'turn', + threadId: CHILD, + turnId: CHILD_TURN, + state: 'working' + }) + }) + + it('does not register the primary thread even when its activity path is missing', () => { + const event = activity('interacted', PARENT_TURN, PRIMARY) + ;(event.params as { item: { agentPath?: string } }).item.agentPath = undefined + expect(readCodexBackgroundTaskFrame(event, PRIMARY)).toBeNull() + }) +}) + +describe('CodexBackgroundTaskTracker child execution ownership', () => { + it('does not claim work from an activity item without a child turn', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(activity()) + tracker.observe(activity('interacted')) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(tracker.state).toBeNull() + }) + + it('reports an executing child only after the foreground turn ends', () => { + const tracker = runningChild() + expect(tracker.state).toBeNull() + expect(tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))).toBe(true) + expect(tracker.state).toEqual({ + state: 'monitoring', + supportsStopAll: false, + tasks: [{ id: `codex-agent:${CHILD}`, kind: 'agent', description: 'count_a' }] + }) + }) + + it('never settles a child when a primary turn ends', () => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + for (let index = 0; index < 300; index++) { + expect(tracker.observe(turn('turn/completed', PRIMARY, `later-${index}`))).toBe(false) + } + expect(tracker.state?.tasks).toHaveLength(1) + }) + + it.each(['completed', 'interrupted', 'failed'])( + 'settles on the matching child turn %s', + (status) => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN, status))).toBe(true) + expect(tracker.state).toBeNull() + } + ) + + it('does not mistake late activity completion for the current child execution', () => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(activity('completed')) + expect(tracker.state?.tasks).toHaveLength(1) + }) + + it.each([PARENT_TURN, 'followup-parent'])( + 'reports follow-up work in %s using the new child turn', + (parentTurn) => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(turn('turn/started', PRIMARY, parentTurn)) + tracker.observe(activity('interacted', parentTurn)) + expect(tracker.state).toBeNull() + tracker.observe(turn('turn/started', CHILD, 'followup-child-turn')) + tracker.observe(turn('turn/completed', PRIMARY, parentTurn)) + expect(tracker.state?.tasks).toHaveLength(1) + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + tracker.observe(turn('turn/started', CHILD, CHILD_TURN)) + tracker.observe(activity('completed')) + expect(tracker.state?.tasks).toHaveLength(1) + tracker.observe(turn('turn/completed', CHILD, 'followup-child-turn')) + expect(tracker.state).toBeNull() + } + ) + + it('keeps idle send_message activity out of the strip', () => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(activity('interacted', 'message-parent')) + tracker.observe(turn('turn/completed', PRIMARY, 'message-parent')) + expect(tracker.state).toBeNull() + }) + + it('does not invent another execution for a message to a working child', () => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(activity('interacted', 'message-parent')) + tracker.observe(turn('turn/completed', PRIMARY, 'message-parent')) + expect(tracker.state?.tasks).toHaveLength(1) + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + expect(tracker.state).toBeNull() + }) + + it('retains completion delivered before child registration', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(turn('turn/started', CHILD, CHILD_TURN)) + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + tracker.observe(activity()) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(tracker.state).toBeNull() + }) + + it('publishes no extra state for duplicate owner or metadata events', () => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(tracker.observe(turn('turn/started', CHILD, CHILD_TURN))).toBe(false) + expect(tracker.observe({ ...activity(), method: 'item/completed' })).toBe(false) + expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))).toBe(true) + expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))).toBe(false) + }) + + it('bounds retained child history while allowing repeated completed runs', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(activity()) + for (let index = 0; index < 300; index++) { + const id = `child-turn-${index}` + tracker.observe(turn('turn/started', CHILD, id)) + expect(tracker.state?.tasks).toHaveLength(1) + tracker.observe(turn('turn/completed', CHILD, id)) + expect(tracker.state).toBeNull() + } + }) + + it('clears the roster at session teardown', () => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(tracker.clear()).toBe(true) + expect(tracker.state).toBeNull() + expect(tracker.clear()).toBe(false) + }) +}) + +describe('CodexBackgroundTaskTracker command integration', () => { + it('keeps a primary shell visible after the turn until its own completion', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN)) + tracker.observe(command()) + expect(tracker.state).toBeNull() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(tracker.state?.tasks).toEqual([ + { id: 'codex-command:primary:exec-1', kind: 'command', description: 'sleep 90' } + ]) + tracker.observe(command(PRIMARY, 'item/completed')) + expect(tracker.state).toBeNull() + }) + + it('reveals a child shell only after the child execution finishes', () => { + const tracker = runningChild() + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(command(CHILD)) + expect(tracker.state?.tasks).toHaveLength(1) + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN, 'interrupted')) + expect(tracker.state?.tasks).toEqual([ + { + id: `codex-command:thread:${CHILD}:exec-1`, + kind: 'command', + description: 'count_a — sleep 90' + } + ]) + tracker.observe(command(CHILD, 'item/completed')) + expect(tracker.state).toBeNull() + }) + + it('leaves a primary shell unqualified', () => { + const tracker = runningChild() + tracker.observe(command(PRIMARY)) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + expect(tracker.state?.tasks).toContainEqual({ + id: 'codex-command:primary:exec-1', + kind: 'command', + description: 'sleep 90' + }) + }) + + it('names a child shell whose label only arrives after the command', () => { + const tracker = new CodexBackgroundTaskTracker(PRIMARY) + tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN)) + tracker.observe(turn('turn/started', CHILD, CHILD_TURN)) + tracker.observe(command(CHILD)) + tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN)) + tracker.observe(activity()) + tracker.observe(turn('turn/completed', CHILD, CHILD_TURN)) + expect(tracker.state?.tasks).toEqual([ + { + id: `codex-command:thread:${CHILD}:exec-1`, + kind: 'command', + description: 'count_a — sleep 90' + } + ]) + }) +}) diff --git a/src/main/codex/codex-background-task-tracker.ts b/src/main/codex/codex-background-task-tracker.ts new file mode 100644 index 00000000000..2918f087809 --- /dev/null +++ b/src/main/codex/codex-background-task-tracker.ts @@ -0,0 +1,96 @@ +import type { + AgentSessionBackgroundTask, + AgentSessionBackgroundTaskState +} from '../../shared/agent-session-wire' +import { + readCodexBackgroundTaskFrame, + type CodexBackgroundTaskEvent +} from './codex-background-task-frames' +import { CodexSubagentExecutions } from './codex-subagent-executions' +import { CodexBackgroundCommandTracker } from './codex-background-command-tracker' +import { boundSubagentField } from './codex-subagent-group-body' + +/** Projects the same child execution facts the durable roster consumes. */ +export class CodexBackgroundTaskTracker { + private primaryTurnId: string | null = null + private publishedFingerprint = '[]' + private publishedState: AgentSessionBackgroundTaskState | null = null + private readonly commands: CodexBackgroundCommandTracker + + constructor( + private readonly primaryThreadId: string, + private readonly executions = new CodexSubagentExecutions() + ) { + this.commands = new CodexBackgroundCommandTracker(primaryThreadId) + } + + get state(): AgentSessionBackgroundTaskState | null { + // Journal admission precedes observe; readers must not see its pending facts. + return this.publishedState + } + + canObserve(event: CodexBackgroundTaskEvent): boolean { + return this.commands.canObserve(event) + } + + observe(event: CodexBackgroundTaskEvent): boolean { + const itemEvent = event.method === 'item/started' || event.method === 'item/completed' + if (itemEvent) { + this.commands.observe(event) + } + const frame = readCodexBackgroundTaskFrame(event, this.primaryThreadId) + if (!frame) { + return itemEvent ? this.refresh() : false + } + if (frame.kind === 'subagent') { + this.executions.register(frame.agentThreadId, frame.label, frame.parentTurnId) + } else if (frame.threadId === this.primaryThreadId) { + if (frame.state === 'working') { + this.primaryTurnId = frame.turnId + } else if (frame.turnId === this.primaryTurnId) { + this.primaryTurnId = null + } + } else { + this.executions.observeTurn(frame.threadId, frame.turnId, frame.state) + } + return this.refresh() + } + + clear(): boolean { + this.executions.clear() + this.commands.clear() + this.primaryTurnId = null + return this.refresh() + } + + private tasks(): AgentSessionBackgroundTask[] { + if (this.primaryTurnId !== null) { + return [] + } + const children = this.executions.workingChildren() + const agents: AgentSessionBackgroundTask[] = children.map((child, index) => ({ + id: `codex-agent:${child.agentThreadId}`, + kind: 'agent', + ...(child.label ? { description: boundSubagentField(child.label, index) } : {}) + })) + return [ + ...agents, + ...this.commands.tasks(new Set(children.map((child) => child.agentThreadId)), (threadId) => + this.executions.label(threadId) + ) + ] + } + + private refresh(): boolean { + const tasks = this.tasks() + const fingerprint = JSON.stringify(tasks) + if (fingerprint === this.publishedFingerprint) { + return false + } + this.publishedFingerprint = fingerprint + this.publishedState = tasks.length + ? { state: 'monitoring', tasks, supportsStopAll: false } + : null + return true + } +} diff --git a/src/main/codex/codex-command-lifecycle.ts b/src/main/codex/codex-command-lifecycle.ts new file mode 100644 index 00000000000..dac8d7ddf97 --- /dev/null +++ b/src/main/codex/codex-command-lifecycle.ts @@ -0,0 +1,6 @@ +import type { CodexThreadItem } from './codex-structured-item-translation' + +/** Persistent exec has its own process-exit notification, independent of a turn. */ +export function codexCommandOutlivesTurn(item: CodexThreadItem): boolean { + return item.type === 'commandExecution' && item.source === 'unifiedExecStartup' +} diff --git a/src/main/codex/codex-item-stream-retention.ts b/src/main/codex/codex-item-stream-retention.ts new file mode 100644 index 00000000000..ace12f34667 --- /dev/null +++ b/src/main/codex/codex-item-stream-retention.ts @@ -0,0 +1,107 @@ +import { codexCommandOutlivesTurn } from './codex-command-lifecycle' +import { + MAX_CODEX_ITEM_STREAM_ITEM_BYTES, + MAX_CODEX_ITEM_STREAM_STATES +} from './codex-structured-item-stream-bounds' +import type { CodexItemStreamState } from './codex-structured-item-stream-contracts' + +// Preserve the previous metadata ceiling while letting small live commands share it. +export const MAX_CODEX_ITEM_STREAM_METADATA_BYTES = + MAX_CODEX_ITEM_STREAM_STATES * MAX_CODEX_ITEM_STREAM_ITEM_BYTES + +type RetainedState = { state: CodexItemStreamState; bytes: number; persistent: boolean } + +export class CodexItemStreamRetention { + private readonly states = new Map() + private bytes = 0 + private persistentBytes = 0 + private persistentCount = 0 + + constructor(private readonly maxBytes = MAX_CODEX_ITEM_STREAM_METADATA_BYTES) {} + + get retainedBytes(): number { + return this.bytes + } + + get size(): number { + return this.states.size + } + + get persistentSize(): number { + return this.persistentCount + } + + get overCapacity(): boolean { + return ( + this.bytes > this.maxBytes || + this.states.size - this.persistentCount > MAX_CODEX_ITEM_STREAM_STATES + ) + } + + get(key: string): CodexItemStreamState | undefined { + return this.states.get(key)?.state + } + + isPersistent(key: string): boolean { + return this.states.get(key)?.persistent === true + } + + canRetain(key: string, state: CodexItemStreamState): boolean { + const previous = this.states.get(key) + return ( + this.persistentBytes - + (previous?.persistent ? previous.bytes : 0) + + this.stateBytes(key, state) <= + this.maxBytes + ) + } + + retain(key: string, state: CodexItemStreamState): boolean { + if (!this.canRetain(key, state)) { + return false + } + this.forget(key) + const bytes = this.stateBytes(key, state) + const persistent = codexCommandOutlivesTurn(state.item) + this.states.set(key, { state, bytes, persistent }) + this.bytes += bytes + if (persistent) { + this.persistentBytes += bytes + this.persistentCount += 1 + } + return true + } + + oldestEvictable(): string | undefined { + for (const [key, entry] of this.states) { + if (!entry.persistent) { + return key + } + } + return undefined + } + + forget(key: string): void { + const entry = this.states.get(key) + if (!entry) { + return + } + this.bytes -= entry.bytes + if (entry.persistent) { + this.persistentBytes -= entry.bytes + this.persistentCount -= 1 + } + this.states.delete(key) + } + + clear(): void { + this.states.clear() + this.bytes = 0 + this.persistentBytes = 0 + this.persistentCount = 0 + } + + private stateBytes(key: string, state: CodexItemStreamState): number { + return Buffer.byteLength(key, 'utf8') + Buffer.byteLength(JSON.stringify(state), 'utf8') + 256 + } +} diff --git a/src/main/codex/codex-persistent-command-retention.test.ts b/src/main/codex/codex-persistent-command-retention.test.ts new file mode 100644 index 00000000000..2acb357cd71 --- /dev/null +++ b/src/main/codex/codex-persistent-command-retention.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { CodexBackgroundCommandTracker } from './codex-background-command-tracker' +import { + CodexItemStreamRetention, + MAX_CODEX_ITEM_STREAM_METADATA_BYTES +} from './codex-item-stream-retention' +import { CodexJournalItems } from './codex-structured-journal-items' +import { settleCodexJournalTurn } from './codex-structured-journal-settlement' + +function command(threadId: string, id: string, method = 'item/started') { + return { + threadId, + method, + params: { + turnId: 'turn', + item: { + type: 'commandExecution', + id, + source: 'unifiedExecStartup', + command: `sleep 30 # ${threadId}/${id}`, + cwd: '/workspace', + status: method === 'item/completed' ? 'completed' : 'inProgress', + ...(method === 'item/completed' ? { exitCode: 0, aggregatedOutput: 'BEFORE\nAFTER\n' } : {}) + } + } + } +} + +function fixture(maxMetadataBytes?: number) { + const rows = new Map() + const scheduled = new Set<() => void>() + const sink = { + appendItem: ( + identity: Parameters[0], + body: AgentJournalItemBody + ) => { + rows.set(agentJournalItemKey(identity), body) + }, + appendTombstone() {}, + publish() {} + } + const items = new CodexJournalItems( + { + sink, + maxMetadataBytes, + schedule: (run) => { + scheduled.add(run) + return () => { + scheduled.delete(run) + } + } + }, + () => 'turn', + () => {} + ) + return { items, sink, rows, scheduled } +} + +describe('persistent command retention', () => { + it('does not rebuild unchanged persistent output on every later lifecycle flush', () => { + const { items } = fixture() + const event = command('root', 'quiet') + items.handle(event) + items.streams.handle('root', 'item/commandExecution/outputDelta', { + itemId: 'quiet', + delta: 'retained-prefix' + }) + items.streams.flush() + const originalJoin = Array.prototype.join + let retainedJoins = 0 + const spy = vi + .spyOn(Array.prototype, 'join') + .mockImplementation(function (this: unknown[], separator) { + if (this[0] === 'retained-prefix') { + retainedJoins += 1 + } + return originalJoin.call(this, separator) + }) + try { + for (let index = 0; index < 100; index += 1) { + items.streams.flush() + } + } finally { + spy.mockRestore() + items.dispose() + } + expect(retainedJoins).toBe(0) + }) + + it('retains 448 live commands through completed turns, late output, and process completion', () => { + const { items, sink, rows, scheduled } = fixture() + const tracker = new CodexBackgroundCommandTracker('thread-0') + const events = Array.from({ length: 7 }, (_, thread) => + Array.from({ length: 64 }, (_, index) => command(`thread-${thread}`, `exec-${index}`)) + ).flat() + for (const event of events) { + expect(tracker.canObserve(event)).toBe(true) + expect(items.handle(event)).toMatchObject({ admission: { accepted: true } }) + tracker.observe(event) + expect( + items.streams.handle(event.threadId, 'item/commandExecution/outputDelta', { + turnId: 'turn', + itemId: event.params.item.id, + delta: 'BEFORE\n' + }).admission + ).toEqual({ accepted: true }) + } + for (let thread = 0; thread < 7; thread += 1) { + expect( + settleCodexJournalTurn({ + sessionId: 'session', + threadId: `thread-${thread}`, + turnId: 'turn', + sink, + streams: items.streams, + activeItems: items.activeItems + }) + ).toEqual({ accepted: true }) + } + expect(items.activeItems.size).toBe(448) + expect(items.streams.persistentCount).toBe(448) + expect(tracker.tasks()).toHaveLength(448) + expect(tracker.retainedMetadataBytes).toBeLessThan(256 * 1024) + for (const event of events) { + items.streams.handle(event.threadId, 'item/commandExecution/outputDelta', { + turnId: 'turn', + itemId: event.params.item.id, + delta: 'AFTER\n' + }) + } + expect(items.streams.flush()).toBe(true) + for (const event of events) { + const key = agentJournalItemKey({ + provider: 'orca', + clientMessageId: `codex-item:${event.threadId}:${event.params.item.id}` + }) + expect(rows.get(key)).toMatchObject({ + state: 'running', + input: { command: event.params.item.command, cwd: '/workspace' }, + output: { head: 'BEFORE\nAFTER\n' } + }) + const completed = command(event.threadId, event.params.item.id, 'item/completed') + expect(items.handle(completed)).toMatchObject({ admission: { accepted: true } }) + tracker.observe(completed) + expect(rows.get(key)).toMatchObject({ + state: 'completed', + output: { head: 'BEFORE\nAFTER\n' } + }) + expect(items.streams.snapshot(event.threadId, event.params.item.id)).toBeNull() + } + expect(items.activeItems.size).toBe(0) + expect(items.streams.persistentCount).toBe(0) + expect(tracker.tasks()).toEqual([]) + expect(tracker.retainedMetadataBytes).toBeLessThan(64 * 1024) + items.dispose() + tracker.clear() + expect(tracker.retainedMetadataBytes).toBe(0) + expect(scheduled.size).toBe(0) + }) + + it('rejects command metadata exhaustion before appending or evicting live state and frees it on completion', () => { + const { items, rows } = fixture(800) + const first = command('root', 'first') + const second = command('root', 'second') + expect(items.handle(first)).toMatchObject({ admission: { accepted: true } }) + const prior = [...rows] + expect(items.handle(second)).toMatchObject({ admission: { accepted: false, reason: 'failed' } }) + expect([...rows]).toEqual(prior) + expect(items.activeItems.size).toBe(1) + expect(items.handle(command('root', 'first', 'item/completed'))).toMatchObject({ + admission: { accepted: true } + }) + expect(items.handle(second)).toMatchObject({ admission: { accepted: true } }) + items.dispose() + }) + + it('accounts metadata bytes instead of interpreting the item count as liveness', () => { + const retention = new CodexItemStreamRetention() + for (let index = 0; index < 448; index += 1) { + const item = command('root', `exec-${index}`).params.item + expect( + retention.retain(item.id, { + item, + identity: { provider: 'orca', clientMessageId: item.id } + }) + ).toBe(true) + } + expect(retention.size).toBe(448) + expect(retention.retainedBytes).toBeLessThan(256 * 1024) + expect(retention.retainedBytes).toBeLessThan(MAX_CODEX_ITEM_STREAM_METADATA_BYTES) + expect(retention.overCapacity).toBe(false) + expect(retention.oldestEvictable()).toBeUndefined() + retention.clear() + expect(retention.retainedBytes).toBe(0) + expect(retention.persistentSize).toBe(0) + }) + + it('retains startup provenance when large command metadata is bounded', () => { + const { items, sink } = fixture() + const event = command('root', 'large') + event.params.item.command = 'x'.repeat(128 * 1024) + expect(items.handle(event)).toMatchObject({ admission: { accepted: true } }) + expect( + settleCodexJournalTurn({ + sessionId: 'session', + threadId: 'root', + turnId: 'turn', + sink, + streams: items.streams, + activeItems: items.activeItems + }) + ).toEqual({ accepted: true }) + expect(items.activeItems.size).toBe(1) + expect(items.streams.persistentCount).toBe(1) + items.dispose() + }) +}) diff --git a/src/main/codex/codex-structured-item-stream-bounds.ts b/src/main/codex/codex-structured-item-stream-bounds.ts index 117b9b3a911..46104c54360 100644 --- a/src/main/codex/codex-structured-item-stream-bounds.ts +++ b/src/main/codex/codex-structured-item-stream-bounds.ts @@ -30,6 +30,7 @@ export function boundStreamItem(item: Record): Record void + readonly persistentCount: number + canTrack: (threadId: string, item: CodexThreadItem, identity: AgentJournalItemIdentity) => boolean + track: (threadId: string, item: CodexThreadItem, identity: AgentJournalItemIdentity) => boolean handle: ( threadId: string, method: string, diff --git a/src/main/codex/codex-structured-item-streams.ts b/src/main/codex/codex-structured-item-streams.ts index a765f8339da..e67263f133d 100644 --- a/src/main/codex/codex-structured-item-streams.ts +++ b/src/main/codex/codex-structured-item-streams.ts @@ -1,5 +1,6 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { createAgentSessionDeltaCoalescer } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' +import { CodexItemStreamRetention } from './codex-item-stream-retention' import { codexJournalItem, codexStreamingJournalItem, @@ -10,7 +11,6 @@ import { MAX_CODEX_ITEM_STREAM_PENDING_PATCHES, MAX_CODEX_ITEM_STREAM_PENDING_PATCH_BYTES, MAX_CODEX_ITEM_STREAM_RETAINED_BYTES, - MAX_CODEX_ITEM_STREAM_STATES, boundStreamItem, pendingPatchBytes } from './codex-structured-item-stream-bounds' @@ -47,8 +47,9 @@ export { export function createCodexStructuredItemStreams( deps: CodexItemStreamDeps ): CodexStructuredItemStreams { - const states = new Map() + const states = new CodexItemStreamRetention(deps.maxMetadataBytes) const checkpointLengths = new Map() + const pendingCheckpoints = new Set() // Patch updates are authoritative item snapshots. Keep the latest rejected // snapshot until the journal admits it; unlike streamed deltas, there is no // coalescer timer to retry these events for us. @@ -57,8 +58,9 @@ export function createCodexStructuredItemStreams( const forgetState = (key: string): void => { coalescer.forget(key) - states.delete(key) + states.forget(key) checkpointLengths.delete(key) + pendingCheckpoints.delete(key) const pending = pendingPatches.get(key) if (pending) { retainedPatchBytes = Math.max(0, retainedPatchBytes - pendingPatchBytes(pending)) @@ -67,8 +69,8 @@ export function createCodexStructuredItemStreams( } const trimStates = (): void => { - while (states.size > MAX_CODEX_ITEM_STREAM_STATES) { - const oldest = states.keys().next().value + while (states.overCapacity) { + const oldest = states.oldestEvictable() if (typeof oldest !== 'string') { break } @@ -124,6 +126,7 @@ export function createCodexStructuredItemStreams( const state = states.get(key) if (state && append(state, text)) { checkpointLengths.set(key, text.length) + pendingCheckpoints.delete(key) return true } return false @@ -133,6 +136,7 @@ export function createCodexStructuredItemStreams( windowMs: deps.coalesceMs, maxRetainedBytes: deps.maxRetainedBytes, maxTotalRetainedBytes: deps.maxTotalRetainedBytes, + isProtected: (key) => states.isPersistent(key), schedule: deps.schedule, emit: (key, text) => { return persist(key, text, false) @@ -144,7 +148,7 @@ export function createCodexStructuredItemStreams( itemId: string, type: string, params: unknown - ): CodexItemStreamState => { + ): CodexItemStreamState | null => { const key = codexStructuredItemKey(threadId, itemId) const existing = states.get(key) if (existing) { @@ -152,36 +156,25 @@ export function createCodexStructuredItemStreams( } const item = { type, id: itemId } const state = { item, identity: deps.identityFor(threadId, params, item) } - states.set(key, state) + if (!states.retain(key, state)) { + return null + } trimStates() return state } const flush = (): boolean => { let flushed = coalescer.flushAll() - for (const key of states.keys()) { + for (const key of pendingCheckpoints) { const snapshot = coalescer.snapshot(key) if (snapshot && checkpointLengths.get(key) !== snapshot.text.length) { flushed = persist(key, snapshot.text, true) && flushed + } else { + pendingCheckpoints.delete(key) } } - for (const [key, pending] of pendingPatches) { - const admission = deps.sink.tryAppendItem - ? deps.sink.tryAppendItem(pending.identity, pending.body) - : (deps.sink.appendItem(pending.identity, pending.body), { accepted: true as const }) - if (!admission.accepted) { - flushed = false - continue - } - const published = deps.sink.tryPublish - ? deps.sink.tryPublish() - : (deps.sink.publish(), { accepted: true as const }) - if (!published.accepted) { - flushed = false - continue - } - retainedPatchBytes = Math.max(0, retainedPatchBytes - pendingPatchBytes(pending)) - pendingPatches.delete(key) + for (const key of pendingPatches.keys()) { + flushed = flushPatch(key).accepted && flushed } return flushed } @@ -209,11 +202,21 @@ export function createCodexStructuredItemStreams( } return { + get persistentCount() { + return states.persistentSize + }, + canTrack: (threadId, item, identity) => + states.canRetain(codexStructuredItemKey(threadId, item.id), { + item: boundStreamItem(item) as CodexThreadItem, + identity + }), track: (threadId, item, identity) => { const key = codexStructuredItemKey(threadId, item.id) - states.delete(key) - states.set(key, { item: boundStreamItem(item) as CodexThreadItem, identity }) + if (!states.retain(key, { item: boundStreamItem(item) as CodexThreadItem, identity })) { + return false + } trimStates() + return true }, handle: (threadId, method, params) => { const paramsRecord = readCodexItemStreamRecord(params) @@ -230,6 +233,9 @@ export function createCodexStructuredItemStreams( const key = codexStructuredItemKey(threadId, itemId) const streamFlushed = coalescer.flush(key) const state = ensureState(threadId, itemId, 'fileChange', params) + if (!state) { + return { handled: true, admission: { accepted: false, reason: 'failed' } } + } state.item = { ...state.item, changes: paramsRecord.changes } const translated = codexJournalItem(state.item) if (translated.body) { @@ -269,9 +275,14 @@ export function createCodexStructuredItemStreams( return { handled: true, admission: { accepted: true } } } const state = ensureState(threadId, itemId, type ?? 'reasoning', params) + if (!state) { + return { handled: true, admission: { accepted: false, reason: 'failed' } } + } const delta = method === REASONING_PART_METHOD ? '\n' : paramsRecord.delta if (typeof delta === 'string') { - const accepted = coalescer.append(codexStructuredItemKey(threadId, state.item.id), delta) + const key = codexStructuredItemKey(threadId, state.item.id) + pendingCheckpoints.add(key) + const accepted = coalescer.append(key, delta) if (!accepted) { return { handled: true, admission: { accepted: false, reason: 'backpressure' } } } @@ -287,6 +298,7 @@ export function createCodexStructuredItemStreams( coalescer.dispose() states.clear() checkpointLengths.clear() + pendingCheckpoints.clear() pendingPatches.clear() retainedPatchBytes = 0 }, diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index acd04a4cf30..56a543fe001 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -1,11 +1,13 @@ import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import type { CodexSubagentExecutions } from './codex-subagent-executions' export type CodexJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink bindPromptItemId?: (journalItemId: string, threadId: string, promptKey: string) => void primaryThreadId?: () => string | null + subagentExecutions?: CodexSubagentExecutions coalesceMs?: number maxRetainedBytes?: number schedule?: AgentSessionDeltaCoalescerDeps['schedule'] diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index f984bc1d9bf..62091580da3 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -11,7 +11,8 @@ import { type CodexThreadItem } from './codex-structured-item-translation' import { createCodexStructuredItemStreams } from './codex-structured-item-streams' -import { codexStructuredItemKey } from './codex-structured-item-stream-bounds' +import { boundStreamItem, codexStructuredItemKey } from './codex-structured-item-stream-bounds' +import { codexCommandOutlivesTurn } from './codex-command-lifecycle' import type { CodexItemTranslation, CodexJournalTranslationAdmission, @@ -40,7 +41,7 @@ export class CodexJournalItems { private readonly deps: Pick< CodexJournalTranslatorDeps, 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' - >, + > & { maxMetadataBytes?: number }, private readonly activeTurn: (threadId: string) => string | null, private readonly suppress: (threadId: string, turnId: string) => void ) { @@ -49,6 +50,7 @@ export class CodexJournalItems { coalesceMs: deps.coalesceMs, maxRetainedBytes: deps.maxRetainedBytes, schedule: deps.schedule, + maxMetadataBytes: deps.maxMetadataBytes, identityFor: (threadId, params, item) => { const turnId = readCodexTurnId(params) ?? this.activeTurn(threadId) return this.identityFor(threadId, turnId, item) @@ -81,6 +83,12 @@ export class CodexJournalItems { if (item.type === 'contextCompaction' && event.method === 'item/started') { return { handled: true, admission: CODEX_JOURNAL_ADMITTED } } + if ( + event.method !== 'item/completed' && + !this.streams.canTrack(event.threadId, item, identity) + ) { + return { handled: true, admission: { accepted: false, reason: 'failed' } } + } const translated = codexJournalItem(item) const command = readCodexJournalString(item, 'command') if (command) { @@ -157,12 +165,15 @@ export class CodexJournalItems { item: CodexThreadItem, identity: AgentJournalItemIdentity ): void { - this.streams.track(threadId, item, identity) + const retainedItem = codexCommandOutlivesTurn(item) + ? (boundStreamItem(item) as CodexThreadItem) + : item + this.streams.track(threadId, retainedItem, identity) this.activeItems.set(codexStructuredItemKey(threadId, item.id), { threadId, turnId, identity, - item + item: retainedItem }) } @@ -194,8 +205,10 @@ export class CodexJournalItems { } private trimActiveState(): CodexJournalTranslationAdmission { - while (this.activeItems.size > MAX_CODEX_ACTIVE_ITEMS) { - const oldest = this.activeItems.keys().next().value + while (this.activeItems.size - this.streams.persistentCount > MAX_CODEX_ACTIVE_ITEMS) { + const oldest = [...this.activeItems].find( + ([, active]) => !codexCommandOutlivesTurn(active.item) + )?.[0] if (typeof oldest !== 'string') { break } diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index 2b8eb627d4f..8c8bf091839 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -20,6 +20,7 @@ import { } from './codex-structured-item-translation' import type { CodexStructuredItemStreams } from './codex-structured-item-streams' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { codexCommandOutlivesTurn } from './codex-command-lifecycle' export type CodexActiveJournalItem = { threadId: string @@ -118,6 +119,9 @@ export function settleCodexJournalTurn(input: { if (active.threadId !== input.threadId || active.turnId !== input.turnId) { continue } + if (codexCommandOutlivesTurn(active.item)) { + continue + } const streamed = input.streams.snapshot(active.threadId, active.item.id) const translated = streamed ? codexStreamingJournalItem(active.item, streamed.text) diff --git a/src/main/codex/codex-structured-journal-translation-frames.ts b/src/main/codex/codex-structured-journal-translation-frames.ts index 22dc516b210..d4cb2c825d9 100644 --- a/src/main/codex/codex-structured-journal-translation-frames.ts +++ b/src/main/codex/codex-structured-journal-translation-frames.ts @@ -6,6 +6,8 @@ * than as the shape checks each arm performs. */ +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import type { CodexJournalItems } from './codex-structured-journal-items' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts' import { settleCodexOversizedNotification } from './codex-structured-journal-settlement' import { @@ -41,3 +43,23 @@ export function settleCodexOversizedNotificationFrame(input: { }) : null } + +export function createCodexOversizedNotificationSettler( + deps: { sink: OversizedInput['sink'] }, + items: Pick +) { + return settleOversizedNotification + + /** Settles the item a notification the transport refused to carry left + * mid-flight; null when the frame is not one. */ + function settleOversizedNotification( + event: Extract + ): CodexJournalTranslationAdmission | null { + return settleCodexOversizedNotificationFrame({ + ...event, + sink: deps.sink, + streams: items.streams, + activeItems: items.activeItems + }) + } +} diff --git a/src/main/codex/codex-structured-journal-translation-subagents.test.ts b/src/main/codex/codex-structured-journal-translation-subagents.test.ts index bf5cdffa5a9..8db30d31b21 100644 --- a/src/main/codex/codex-structured-journal-translation-subagents.test.ts +++ b/src/main/codex/codex-structured-journal-translation-subagents.test.ts @@ -59,6 +59,22 @@ function deliverActivity( translator: ReturnType, params: unknown ): void { + const item = (params as { item: { kind: string; agentThreadId: string } }).item + if (item.kind === 'started' || item.kind === 'completed') { + translator.handle({ + type: 'notification', + sessionId: SESSION_ID, + threadId: item.agentThreadId, + method: item.kind === 'started' ? 'turn/started' : 'turn/completed', + params: { + threadId: item.agentThreadId, + turn: { + id: `execution:${item.agentThreadId}`, + status: item.kind === 'started' ? 'inProgress' : 'completed' + } + } + }) + } translator.handle(notification('item/started', params)) translator.handle(notification('item/completed', params)) } diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 00b90d7ffc8..cea129e5cdf 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -19,7 +19,7 @@ import { settleCodexJournalSession, settleCodexJournalTurn } from './codex-structured-journal-settlement' -import { settleCodexOversizedNotificationFrame } from './codex-structured-journal-translation-frames' +import { createCodexOversizedNotificationSettler } from './codex-structured-journal-translation-frames' import { restoreCodexJournalThread } from './codex-structured-journal-translation-restore' import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state' import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns' @@ -58,13 +58,15 @@ export function createCodexJournalTranslator( (threadId) => activeTurns.current(threadId), (threadId, turnId) => genericFrames.suppress(threadId, turnId) ) + const settleOversizedNotification = createCodexOversizedNotificationSettler(deps, items) const prompts = new CodexJournalPrompts(deps, (threadId, itemId) => items.detailFor(threadId, itemId) ) const subagents = new CodexSubagentRoster({ sink: deps.sink, primaryThreadId: () => deps.primaryThreadId?.() ?? null, - activeTurn: (threadId) => activeTurns.current(threadId) + activeTurn: (threadId) => activeTurns.current(threadId), + ...(deps.subagentExecutions ? { executions: deps.subagentExecutions } : {}) }) const flushStreams = (): CodexJournalTranslationAdmission => items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' } @@ -174,16 +176,17 @@ export function createCodexJournalTranslator( } return genericFrames.appendUnhandled(event.kind, event.payload, event.threadId) } - if (event.method === 'turn/started') { - return startTurn(event) + if (event.method === 'turn/started' || event.method === 'turn/completed') { + const childAdmission = subagents.handleTurnEvent(event) + if (!childAdmission.accepted) { + return childAdmission + } + return event.method === 'turn/started' ? startTurn(event) : completeTurn(event) } const compaction = compactions.handle(event) if (compaction) { return publishActivity(event, compaction) } - if (event.method === 'turn/completed') { - return completeTurn(event) - } if (event.method === CODEX_TOKEN_USAGE_METHOD) { // Classified `status-chrome`, so the generic-frame path swallows it // before the journal. The roster consumes it as a typed notification. @@ -240,19 +243,6 @@ export function createCodexJournalTranslator( } } - /** Settles the item a notification the transport refused to carry left - * mid-flight; null when the frame is not one. */ - function settleOversizedNotification( - event: Extract - ): CodexJournalTranslationAdmission | null { - return settleCodexOversizedNotificationFrame({ - ...event, - sink: deps.sink, - streams: items.streams, - activeItems: items.activeItems - }) - } - function startTurn( event: Extract ): CodexJournalTranslationAdmission { diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 8c8b39ca48b..3f7f130e173 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -8,6 +8,8 @@ import { closeFailedCodexAcquisition, stopSupersededCodexAcquisition } from './codex-structured-acquisition-lifecycle' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { CodexSubagentExecutions } from './codex-subagent-executions' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { openCodexAppServerConnection } from './codex-app-server-connection' import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity' @@ -74,10 +76,12 @@ export async function acquireCodexStructuredSession(input: { acquireInput.identity.providerHandle.kind === 'codex' ? acquireInput.identity.providerHandle.threadId : null + const subagentExecutions = new CodexSubagentExecutions() const translator = acquireInput.events ? createCodexJournalTranslator({ sink: acquireInput.events, primaryThreadId: () => primaryThreadId, + subagentExecutions, bindPromptItemId: (journalItemId, threadId, promptKey) => acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey) }) @@ -138,6 +142,7 @@ export async function acquireCodexStructuredSession(input: { connection: acquisition.connection, error, prompts: acquisition.prompts, + onBackgroundTasksChanged: deps.onBackgroundTasksChanged, ...(deps.onEvent ? { onEvent: deps.onEvent } : {}) }) } finally { @@ -199,6 +204,7 @@ export async function acquireCodexStructuredSession(input: { reportedOptions: reportedCodexThreadOptions(opened), turnIdWaiters: [], translator, + backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions), forceCloseUnexpected: (reason) => input.forceCloseUnexpected( sessionId, diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index ebd7c3331bf..8b0e649b177 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -16,11 +16,7 @@ import type { CodexJournalTranslationAdmission } from './codex-structured-journa import { answerCodexPrompt } from './codex-structured-prompt-replies' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' import { supportsCodexStructuredLocation } from './codex-structured-location-support' -import { - closeAllCodexSessions, - closeCodexPublishedSession, - closeCodexSession -} from './codex-structured-session-close' +import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' import { applyCodexStructuredSessionOption, readLiveCodexSessionOptions @@ -54,6 +50,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap private readonly acquisitions = new CodexAcquisitionRegistry() private readonly turnCancellation: CodexStructuredTurnCancellation private readonly notificationRetries: ReturnType + private readonly teardown: CodexStructuredSessionTeardown constructor(private readonly deps: CodexStructuredSessionAdapterDeps) { this.notificationRetries = createCodexStructuredNotificationRetry({ @@ -61,6 +58,15 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap translate: (sessionId, session, method, params) => this.translateNotification(sessionId, session, method, params) }) + this.teardown = new CodexStructuredSessionTeardown({ + 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({ captureTurnProcesses: deps.captureTurnProcesses, terminateTurnProcesses: deps.terminateTurnProcesses, @@ -92,7 +98,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap handleUnhandledFrame: (sessionId, kind, payload) => this.handleUnhandledFrame(sessionId, kind, payload), forceCloseUnexpected: (sessionId, fence, acquisitionGeneration, reason) => - this.forceCloseUnexpected(sessionId, fence, acquisitionGeneration, reason) + this.teardown.forceCloseUnexpected(sessionId, fence, acquisitionGeneration, reason) }) /** Buffers pre-publication events and drops events from superseded children. */ @@ -134,12 +140,20 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap session: CodexSession, event: CodexStructuredSessionEvent ): CodexJournalTranslationAdmission { + if (event.type === 'notification' && !session.backgroundTasks.canObserve(event)) { + return { accepted: false, reason: 'failed' } + } const admission = session.translator?.handle(event) ?? { accepted: true } if (!admission.accepted) { return admission } if (event.type === 'notification') { this.compactions.codex(event.sessionId, event.method, event.params) + // After the admission check, so a refused frame is observed by the strip + // only on the retry that also reaches the journal. + if (session.backgroundTasks.observe(event)) { + this.deps.onBackgroundTasksChanged?.(event.sessionId, session.backgroundTasks.state) + } } if (event.type === 'ended') { this.compactions.ended(event.sessionId) @@ -167,6 +181,10 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap ) } + backgroundTaskState: NonNullable = ( + sessionId + ) => this.sessions.get(sessionId)?.backgroundTasks.state + bindPromptItemId = (sessionId: string, journalItemId: string, promptKey: string): void => this.sessions .get(sessionId) @@ -267,59 +285,12 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap identity: AgentSessionJournalIdentity }): Promise => this.sessions.get(input.identity.sessionId)?.historyPath ?? null - closeSession = async (sessionId: string): Promise => { - const closed = await closeCodexSession( - sessionId, - this.sessions, - this.acquisitions, - this.deps.onEvent - ) - if (closed) { - this.notificationRetries.clear(sessionId, null) - } - return closed - } - forceCloseSession = async (sessionId: string): Promise => { - const closed = await closeCodexPublishedSession(this.sessions, sessionId, this.deps.onEvent, { - allowFailedSettlement: true, - requestedClose: false - }) - if (closed) { - this.notificationRetries.clear(sessionId, null) - } - return closed - } - - private forceCloseUnexpected( - sessionId: string, - fence: number, - acquisitionGeneration: string, - reason: Error - ): Promise { - const session = this.sessions.get(sessionId) - if ( - !session || - session.ended || - session.fence !== fence || - session.acquisitionGeneration !== acquisitionGeneration - ) { - return Promise.resolve(false) - } - return closeCodexPublishedSession(this.sessions, sessionId, this.deps.onEvent, { - allowFailedSettlement: true, - requestedClose: false, - expectedFence: fence, - expectedAcquisitionGeneration: acquisitionGeneration, - unexpectedReason: reason - }) - } - disposeSession = (sessionId: string): Promise => this.closeSession(sessionId) - closeAll = (): Promise => - closeAllCodexSessions(this.sessions, this.acquisitions, (sessionId) => - this.disposeSession(sessionId) - ) + closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) + forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) + disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) + closeAll = (): Promise => this.teardown.closeAll() releaseAcquisition = (input: { sessionId: string }): Promise => - this.closeSession(input.sessionId) + this.teardown.close(input.sessionId) private session(sessionId: string): CodexSession { return requireLiveCodexSession(this.sessions, sessionId) diff --git a/src/main/codex/codex-structured-session-background-tasks.test.ts b/src/main/codex/codex-structured-session-background-tasks.test.ts new file mode 100644 index 00000000000..aa6156027e8 --- /dev/null +++ b/src/main/codex/codex-structured-session-background-tasks.test.ts @@ -0,0 +1,258 @@ +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 { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from './codex-app-server-connection' +import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' +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. + +const THREAD_ID = '01a07d54-3785-71d0-b065-82c8ebbc572a' +const PARENT_TURN = '01a07d54-37be-72e1-8206-8f0c23dd2cef' +const CHILD_ID = '01a07d54-5523-78a3-91f5-e0acb1dab065' + +/** A three-route stand-in, deliberately smaller than the full adapter harness: + * this suite only needs a thread and a notification pipe. */ +function fakeCodex(close: () => Promise = async () => true): { + handlers: () => CodexAppServerConnectionHandlers + openConnection: typeof openCodexAppServerConnection +} { + let live: CodexAppServerConnectionHandlers = {} + const openConnection = (async (_launch, handlers = {}) => { + live = handlers + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method) => + method === 'thread/start' ? { thread: { id: THREAD_ID, path: null } } : {}, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close + } as unknown as CodexAppServerConnection + return connection + }) as typeof openCodexAppServerConnection + return { handlers: () => live, openConnection } +} + +function identity(sessionId: string): AgentSessionJournalIdentity { + return { + sessionId, + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD_ID } + } +} + +function subagentNotification(kind: string): { method: string; params: unknown } { + return { + method: 'item/started', + params: { + item: { + type: 'subAgentActivity', + id: 'call_1', + kind, + agentThreadId: CHILD_ID, + agentPath: '/root/count_a' + }, + threadId: THREAD_ID, + turnId: PARENT_TURN + } + } +} + +const TURN_COMPLETED = { + method: 'turn/completed', + params: { threadId: THREAD_ID, turn: { id: PARENT_TURN, status: 'completed' } } +} + +async function adapterWithSession( + published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[], + events?: StructuredAgentSessionEventSink, + onEvent?: (event: CodexStructuredSessionEvent) => void, + close?: () => Promise +): Promise<{ adapter: CodexStructuredSessionAdapter; codex: ReturnType }> { + const codex = fakeCodex(close) + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + onEvent, + onBackgroundTasksChanged: (sessionId, state) => published.push({ sessionId, state }) + }) + await adapter.acquire({ + identity: identity('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events + }) + codex.handlers().onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: PARENT_TURN, status: 'inProgress' } + }) + codex.handlers().onNotification?.('turn/started', { + threadId: CHILD_ID, + turn: { id: 'child-turn', status: 'inProgress' } + }) + return { adapter, codex } +} + +describe('codex background tasks reach the strip', () => { + it('clears natural-exit state before lifecycle observers can read it', async () => { + const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const onEvent = vi.fn() + const { adapter, codex } = await adapterWithSession(published, undefined, onEvent) + const spawn = subagentNotification('started') + codex.handlers().onNotification?.(spawn.method, spawn.params) + codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params) + expect(adapter.backgroundTaskState('session-1')?.tasks).toHaveLength(1) + published.length = 0 + onEvent.mockImplementation((event: CodexStructuredSessionEvent) => { + if (event.type === 'ended') { + expect(adapter.backgroundTaskState('session-1')).toBeNull() + } + }) + codex.handlers().onExit?.(new Error('provider exited')) + expect(adapter.backgroundTaskState('session-1')).toBeNull() + expect(published).toEqual([{ sessionId: 'session-1', state: null }]) + await adapter.closeSession('session-1') + }) + + it('keeps live tasks when close is refused', async () => { + const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const close = vi.fn(async () => false) + const { adapter, codex } = await adapterWithSession(published, undefined, undefined, close) + const spawn = subagentNotification('started') + codex.handlers().onNotification?.(spawn.method, spawn.params) + codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params) + const before = adapter.backgroundTaskState('session-1') + published.length = 0 + expect(await adapter.closeSession('session-1')).toBe(false) + expect(adapter.backgroundTaskState('session-1')).toEqual(before) + expect(published).toEqual([]) + close.mockResolvedValue(true) + await adapter.closeSession('session-1') + }) + + it('does not let an old exit callback clear a replacement roster', async () => { + const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const { adapter, codex } = await adapterWithSession(published) + const oldExit = codex.handlers().onExit + await adapter.acquire({ identity: identity('session-1'), fence: 8, spawnToken: 'spawn-10' }) + codex.handlers().onNotification?.('turn/started', { + threadId: CHILD_ID, + turn: { id: 'replacement-child-turn' } + }) + const spawn = subagentNotification('started') + codex.handlers().onNotification?.(spawn.method, spawn.params) + const before = adapter.backgroundTaskState('session-1') + expect(before?.tasks).toHaveLength(1) + published.length = 0 + oldExit?.(new Error('old provider exited late')) + expect(adapter.backgroundTaskState('session-1')).toEqual(before) + expect(published).toEqual([]) + await adapter.closeSession('session-1') + }) + + it('recovers the exact provider generation when command metadata cannot be admitted', async () => { + const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const observed: CodexStructuredSessionEvent[] = [] + const appendItem = vi.fn() + const { adapter, codex } = await adapterWithSession( + published, + { appendItem, appendTombstone: () => {}, publish: () => {} }, + (event) => observed.push(event) + ) + appendItem.mockClear() + observed.length = 0 + const admission = vi + .spyOn(CodexBackgroundTaskTracker.prototype, 'canObserve') + .mockReturnValue(false) + try { + codex.handlers().onNotification?.('item/started', { + threadId: THREAD_ID, + turnId: PARENT_TURN, + item: { + type: 'commandExecution', + id: 'over-budget', + command: 'sleep 1', + source: 'unifiedExecStartup', + status: 'inProgress' + } + }) + await vi.waitFor(() => expect(adapter.backgroundTaskState('session-1')).toBeUndefined()) + expect(appendItem.mock.calls.map((call) => call[1])).toEqual([ + { kind: 'status', text: 'Provider exited: notification admission failed (failed)' } + ]) + expect(observed).toEqual([ + expect.objectContaining({ + type: 'ended', + cause: 'unexpected-exit', + fence: 7, + acquisitionGeneration: expect.any(String), + reason: 'notification admission failed (failed)' + }) + ]) + expect(published).toEqual([{ sessionId: 'session-1', state: null }]) + } finally { + admission.mockRestore() + await adapter.closeSession('session-1') + } + }) + + it('publishes the orphaned fan-out once the spawning turn completes', async () => { + const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const { adapter, codex } = await adapterWithSession(published) + + const spawn = subagentNotification('started') + codex.handlers().onNotification?.(spawn.method, spawn.params) + // The child is still inside the turn, so the strip stays silent. + expect(published).toEqual([]) + expect(adapter.backgroundTaskState('session-1')).toBeNull() + + codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params) + + expect(published).toEqual([ + { + sessionId: 'session-1', + state: { + state: 'monitoring', + supportsStopAll: false, + tasks: [{ id: `codex-agent:${CHILD_ID}`, kind: 'agent', description: 'count_a' }] + } + } + ]) + expect(adapter.backgroundTaskState('session-1')).toEqual(published[0].state) + }) + + it('clears the strip when the session closes', async () => { + const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = [] + const { adapter, codex } = await adapterWithSession(published) + const spawn = subagentNotification('started') + codex.handlers().onNotification?.(spawn.method, spawn.params) + codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params) + published.length = 0 + + 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 }]) + expect(adapter.backgroundTaskState('session-1')).toBeUndefined() + }) +}) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index b04e7bc2540..45bfbbf45a1 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -10,6 +10,7 @@ import { type CodexStructuredSessionEvent } from './codex-structured-session-adapter' import { handleCodexSessionExit } from './codex-structured-session-close' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexSession } from './codex-structured-session-state' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' @@ -90,6 +91,7 @@ describe('Codex structured session close lifecycle', () => { } as unknown as NonNullable const session = { connection, + backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, requestedClose: false, fence: 7, diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index db723096177..5b29dc6c056 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -4,6 +4,7 @@ import { cancelCodexAcquisitionAttempt, type CodexAcquisitionRegistry, type CodexSession, + type CodexStructuredSessionAdapterDeps, type CodexStructuredSessionEvent } from './codex-structured-session-state' import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -16,6 +17,7 @@ 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) { @@ -43,6 +45,8 @@ export function handleCodexSessionExit(input: { event.settlementRetryRequired = true } session.ended = true + session.backgroundTasks.clear() + input.onBackgroundTasksChanged?.(input.sessionId, null) session.unbindReadingControl?.() input.onEvent?.(event) session.prompts.clear() diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 1f28ff5e197..b081e52dd6a 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -7,6 +7,7 @@ import { reportedCodexThreadOptions, restoredCodexSessionOptions } from './codex-structured-session-options' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexSession } from './codex-structured-session-state' function optionSession(request: CodexAppServerConnection['request']): CodexSession { @@ -20,6 +21,7 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi respondWithError: () => {}, close: async () => true }, + backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, requestedClose: false, fence: 1, diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 12ba28d712f..d004b0006e6 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -6,6 +6,8 @@ import type { openCodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' import type { CodexTurnProcessSnapshot } from './codex-structured-turn-processes' import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -44,6 +46,10 @@ 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 openConnection?: typeof openCodexAppServerConnection readProcessStartTime?: (pid: number) => Promise mintLinkId?: () => string @@ -73,6 +79,8 @@ export type CodexSession = { reportedOptions: { model?: string; effort?: string } turnIdWaiters: ((turnId: string) => void)[] translator: CodexJournalTranslator | null + /** Ephemeral roster behind the background-tasks strip; never durable state. */ + backgroundTasks: CodexBackgroundTaskTracker unbindReadingControl?: () => void /** Terminates this exact child as an unexpected death and enters host recovery. */ forceCloseUnexpected?: (reason: Error) => Promise diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts new file mode 100644 index 00000000000..dcd38eb82fe --- /dev/null +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -0,0 +1,94 @@ +// Stopping one Codex app-server child, in the four ways the host asks for it. +// +// Every path funnels through `settled` so the ephemeral surfaces a closed +// 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, + closeCodexSession +} from './codex-structured-session-close' +import type { + CodexAcquisitionRegistry, + CodexSession, + CodexStructuredSessionEvent +} from './codex-structured-session-state' + +export type CodexStructuredSessionTeardownDeps = { + sessions: Map + acquisitions: CodexAcquisitionRegistry + onEvent?: (event: CodexStructuredSessionEvent) => void + onBackgroundTasksChanged?: ( + sessionId: string, + state: AgentSessionBackgroundTaskState | null + ) => void + forgetNotificationRetries: (sessionId: string) => void +} + +export class CodexStructuredSessionTeardown { + constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {} + + close = async (sessionId: string): Promise => { + const closed = await closeCodexSession( + sessionId, + this.deps.sessions, + this.deps.acquisitions, + this.deps.onEvent + ) + return this.settled(sessionId, closed) + } + + forceClose = async (sessionId: string): Promise => { + const closed = await closeCodexPublishedSession( + this.deps.sessions, + sessionId, + this.deps.onEvent, + { allowFailedSettlement: true, requestedClose: false } + ) + return this.settled(sessionId, closed) + } + + /** Terminates this exact child as an unexpected death. Every ownership check + * stays here so a stale caller cannot close a replacement child. */ + forceCloseUnexpected = ( + sessionId: string, + fence: number, + acquisitionGeneration: string, + reason: Error + ): Promise => { + const session = this.deps.sessions.get(sessionId) + if ( + !session || + session.ended || + session.fence !== fence || + session.acquisitionGeneration !== acquisitionGeneration + ) { + return Promise.resolve(false) + } + return closeCodexPublishedSession(this.deps.sessions, sessionId, this.deps.onEvent, { + allowFailedSettlement: true, + requestedClose: false, + expectedFence: fence, + expectedAcquisitionGeneration: acquisitionGeneration, + unexpectedReason: reason + }).then((closed) => this.settled(sessionId, closed)) + } + + closeAll = (): Promise => + closeAllCodexSessions(this.deps.sessions, this.deps.acquisitions, (sessionId) => + this.close(sessionId) + ) + + 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/codex/codex-subagent-activity.ts b/src/main/codex/codex-subagent-activity.ts index f12e9dfb1b3..9761f8634f8 100644 --- a/src/main/codex/codex-subagent-activity.ts +++ b/src/main/codex/codex-subagent-activity.ts @@ -7,11 +7,10 @@ // segment is a semantic task name and the only label available. There is no // `thread/started` for a child, so nickname/role/depth do not exist. // * `agentsStates` on `collabAgentToolCall` arrived empty (`{}`) throughout the -// probe, so nothing here reads it — state comes from `kind` alone. +// probe, so nothing here reads it; child turn events own execution state. // * `thread/tokenUsage/updated` reports a per-thread RUNNING TOTAL, so the // latest frame replaces the previous one — it is never accumulated. -import type { NativeChatSubagentState } from '../../shared/native-chat-types' import type { CodexThreadItem } from './codex-structured-item-translation' export const CODEX_SUBAGENT_ITEM_TYPE = 'subAgentActivity' @@ -48,24 +47,6 @@ export function readCodexSubagentActivity(item: CodexThreadItem): CodexSubagentA } } -/** - * The state a `kind` implies for the child it names. - * - * An unrecognized kind means "this child exists and reported something we - * cannot classify" — `working`, which the session sweep will later settle to - * `unverifiable` if nothing better ever arrives. Claiming a terminal state from - * an unknown kind would assert an outcome the wire never gave us. - */ -export function codexSubagentStateForKind(kind: string): NativeChatSubagentState { - if (kind === 'completed') { - return 'completed' - } - if (kind === 'interrupted') { - return 'stopped' - } - return 'working' -} - /** Path segments, empty ones dropped: `/root/list_directory` → 2 segments. */ export function codexSubagentPathSegments(agentPath: string | null): string[] { return agentPath === null ? [] : agentPath.split('/').filter((part) => part.length > 0) diff --git a/src/main/codex/codex-subagent-execution-projection.test.ts b/src/main/codex/codex-subagent-execution-projection.test.ts new file mode 100644 index 00000000000..21d39f4f195 --- /dev/null +++ b/src/main/codex/codex-subagent-execution-projection.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { CodexSubagentExecutions } from './codex-subagent-executions' + +const PRIMARY = 'primary' +const CHILD = 'child' + +function turn( + method: 'turn/started' | 'turn/completed', + threadId: string, + turnId: string, + status = 'completed' +): CodexStructuredSessionEvent { + return { + type: 'notification', + sessionId: 'session', + method, + threadId, + params: { threadId, turn: { id: turnId, status } } + } +} + +function activity(parentTurn: string, kind = 'started'): CodexStructuredSessionEvent { + return { + type: 'notification', + sessionId: 'session', + threadId: PRIMARY, + method: 'item/started', + params: { + threadId: PRIMARY, + turnId: parentTurn, + item: { + type: 'subAgentActivity', + id: `activity-${parentTurn}-${kind}`, + kind, + agentThreadId: CHILD, + agentPath: '/root/task' + } + } + } +} + +function harness() { + const executions = new CodexSubagentExecutions() + const tracker = new CodexBackgroundTaskTracker(PRIMARY, executions) + const rows = new Map() + let refused = false + const translator = createCodexJournalTranslator({ + primaryThreadId: () => PRIMARY, + subagentExecutions: executions, + sink: { + appendItem: (identity, body) => rows.set(JSON.stringify(identity), body), + appendTombstone: () => {}, + publish: () => {}, + tryAppendItem: (identity, body) => { + if (refused) { + return { accepted: false, reason: 'backpressure' } + } + rows.set(JSON.stringify(identity), body) + return { accepted: true } + } + }, + schedule: (run) => { + run() + return () => {} + } + }) + function send(event: CodexStructuredSessionEvent) { + const admission = translator.handle(event) + if (admission.accepted && event.type === 'notification') { + tracker.observe(event) + } + return admission + } + function state(parentTurn: string): string | undefined { + for (const body of rows.values()) { + if (body.kind !== 'message') { + continue + } + for (const block of body.blocks) { + if (block.type === 'subagent-group' && block.groupId === `${PRIMARY}:${parentTurn}`) { + return block.agents[0]?.state + } + } + } + return undefined + } + return { + send, + tracker, + state, + refuse: (value: boolean) => { + refused = value + }, + dispose: () => translator.dispose() + } +} + +function firstRun(h: ReturnType) { + h.send(turn('turn/started', PRIMARY, 'parent-1')) + h.send(turn('turn/started', CHILD, 'child-1')) + h.send(activity('parent-1')) + h.send(turn('turn/completed', CHILD, 'child-1')) +} + +describe('shared child execution projection', () => { + it('creates no execution record from activity without an owner turn', () => { + const h = harness() + h.send(activity('parent-1')) + h.send(activity('parent-2', 'interacted')) + expect(h.state('parent-1')).toBeUndefined() + expect(h.state('parent-2')).toBeUndefined() + expect(h.tracker.state).toBeNull() + h.dispose() + }) + + it.each(['parent-1', 'parent-2'])( + 'reopens a child for real follow-up in %s and fences old execution events', + (parent) => { + const h = harness() + firstRun(h) + if (parent === 'parent-2') { + h.send(turn('turn/completed', PRIMARY, 'parent-1')) + h.send(turn('turn/started', PRIMARY, parent)) + } + h.send(activity(parent, 'interacted')) + expect(h.tracker.state).toBeNull() + h.send(turn('turn/started', CHILD, 'child-2')) + h.send(turn('turn/completed', PRIMARY, parent)) + expect(h.state(parent)).toBe('working') + expect(h.tracker.state?.tasks).toHaveLength(1) + h.send(turn('turn/completed', CHILD, 'child-1')) + h.send(turn('turn/started', CHILD, 'child-1')) + h.send(activity('parent-1', 'completed')) + expect(h.state(parent)).toBe('working') + expect(h.tracker.state?.tasks).toHaveLength(1) + if (parent === 'parent-2') { + expect(h.state('parent-1')).toBe('completed') + } + h.send(turn('turn/completed', CHILD, 'child-2')) + expect(h.state(parent)).toBe('completed') + expect(h.tracker.state).toBeNull() + h.dispose() + } + ) + + it('keeps an idle message from creating execution on either surface', () => { + const h = harness() + firstRun(h) + h.send(turn('turn/completed', PRIMARY, 'parent-1')) + h.send(activity('parent-2', 'interacted')) + h.send(turn('turn/completed', PRIMARY, 'parent-2')) + expect(h.state('parent-1')).toBe('completed') + expect(h.state('parent-2')).toBeUndefined() + expect(h.tracker.state).toBeNull() + h.dispose() + }) + + it('keeps a late completion activity from retargeting a queued follow-up', () => { + const h = harness() + firstRun(h) + h.send(turn('turn/completed', PRIMARY, 'parent-1')) + h.send(activity('parent-2', 'interacted')) + h.send(activity('parent-1', 'completed')) + h.send(turn('turn/started', CHILD, 'child-2')) + expect(h.state('parent-1')).toBe('completed') + expect(h.state('parent-2')).toBe('working') + expect(h.tracker.state?.tasks).toHaveLength(1) + h.dispose() + }) + + it('keeps a message to a running child in the original execution group', () => { + const h = harness() + h.send(turn('turn/started', PRIMARY, 'parent-1')) + h.send(turn('turn/started', CHILD, 'child-1')) + h.send(activity('parent-1')) + h.send(turn('turn/completed', PRIMARY, 'parent-1')) + h.send(turn('turn/started', PRIMARY, 'parent-2')) + h.send(activity('parent-2', 'interacted')) + h.send(turn('turn/started', CHILD, 'child-1')) + h.send(turn('turn/completed', PRIMARY, 'parent-2')) + expect(h.tracker.state?.tasks).toHaveLength(1) + expect(h.state('parent-2')).toBeUndefined() + h.send(turn('turn/completed', CHILD, 'child-1')) + expect(h.state('parent-1')).toBe('completed') + expect(h.state('parent-2')).toBeUndefined() + expect(h.tracker.state).toBeNull() + h.dispose() + }) + + it('does not expose pending child settlement before journal admission and retries the same fact', () => { + const h = harness() + h.send(turn('turn/started', PRIMARY, 'parent-1')) + h.send(activity('parent-1')) + h.send(turn('turn/started', CHILD, 'child-1')) + h.send(turn('turn/completed', PRIMARY, 'parent-1')) + const complete = turn('turn/completed', CHILD, 'child-1') + h.refuse(true) + expect(h.send(complete)).toEqual({ accepted: false, reason: 'backpressure' }) + expect(h.tracker.state?.tasks).toHaveLength(1) + expect(h.state('parent-1')).toBe('working') + h.refuse(false) + expect(h.send(complete)).toEqual({ accepted: true }) + expect(h.state('parent-1')).toBe('completed') + expect(h.tracker.state).toBeNull() + h.dispose() + }) +}) diff --git a/src/main/codex/codex-subagent-executions.test.ts b/src/main/codex/codex-subagent-executions.test.ts new file mode 100644 index 00000000000..aceff7ab465 --- /dev/null +++ b/src/main/codex/codex-subagent-executions.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { CodexSubagentExecutions } from './codex-subagent-executions' + +describe('CodexSubagentExecutions retention and identity', () => { + it('bounds settled history through repeated execution without evicting live children', () => { + const executions = new CodexSubagentExecutions() + executions.register('long-lived', 'long-lived', 'parent') + executions.observeTurn('long-lived', 'long-lived-turn', 'working') + for (let index = 0; index < 1_000; index++) { + const id = `child-${index}` + executions.observeTurn(id, id, 'working') + executions.register(id, id, 'parent') + executions.observeTurn(id, id, 'completed') + } + expect(executions.workingChildren().map((child) => child.agentThreadId)).toEqual(['long-lived']) + expect(Reflect.get(executions, 'children').size).toBeLessThanOrEqual(128) + expect(Reflect.get(executions, 'settledTurns').size).toBeLessThanOrEqual(256) + }) + + it('retains early live owner events at capacity and makes room only after settlement', () => { + const executions = new CodexSubagentExecutions() + for (let index = 0; index < 128; index++) { + executions.observeTurn(`child-${index}`, `turn-${index}`, 'working') + } + expect(executions.observeTurn('overflow', 'overflow', 'working')).toBeNull() + for (let index = 0; index < 128; index++) { + executions.register(`child-${index}`, `child-${index}`, 'parent') + } + expect(executions.workingChildren()).toHaveLength(128) + executions.observeTurn('child-0', 'turn-0', 'completed') + expect(executions.observeTurn('overflow', 'overflow', 'working')).not.toBeNull() + executions.register('overflow', 'overflow', 'parent') + expect(executions.workingChildren()).toHaveLength(128) + }) + + it('corrects an unverifiable execution with its own terminal event and ignores stale starts', () => { + const executions = new CodexSubagentExecutions() + executions.register('child', 'child', 'parent') + executions.observeTurn('child', 'turn', 'working') + executions.settleSession() + expect(executions.workingChildren()).toEqual([]) + expect(executions.observeTurn('child', 'turn', 'working')).toBeNull() + expect(executions.observeTurn('child', 'turn', 'completed')?.execution.state).toBe('completed') + executions.observeTurn('child', 'new-turn', 'working') + executions.observeTurn('child', 'turn', 'failed') + expect(executions.workingChildren()[0]?.execution?.turnId).toBe('new-turn') + executions.clear() + expect(Reflect.get(executions, 'children').size).toBe(0) + expect(Reflect.get(executions, 'settledTurns').size).toBe(0) + }) +}) diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts new file mode 100644 index 00000000000..cd33b4eb1d5 --- /dev/null +++ b/src/main/codex/codex-subagent-executions.ts @@ -0,0 +1,144 @@ +import type { NativeChatSubagentState } from '../../shared/native-chat-types' +import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary' + +const MAX_CHILDREN = 128 +const MAX_SETTLED_TURNS = 256 + +export type CodexChildExecution = { + turnId: string + state: NativeChatSubagentState +} + +export type CodexExecutionChild = { + agentThreadId: string + registered: boolean + label: string | null + parentTurnId: string | null + execution: CodexChildExecution | null +} + +/** Child turn events own execution; activity items only identify the child. */ +export class CodexSubagentExecutions { + private readonly children = new Map() + private readonly settledTurns = new Map() + + register( + agentThreadId: string, + label: string | null, + parentTurnId: string | null | undefined + ): CodexExecutionChild | undefined { + const child = this.child(agentThreadId) + if (!child) { + return undefined + } + if (!child.registered || parentTurnId !== undefined) { + child.parentTurnId = parentTurnId ?? null + } + child.registered = true + // Retain one overflow unit so the journal can append its per-row truncation marker. + child.label ??= + label + ?.trim() + .replace(/\s+/g, ' ') + .slice(0, MAX_SUBAGENT_FIELD_CHARS + 1) || null + return child + } + + observeTurn( + agentThreadId: string, + turnId: string, + state: NativeChatSubagentState + ): { child: CodexExecutionChild; execution: CodexChildExecution } | null { + const key = JSON.stringify([agentThreadId, turnId]) + const settled = this.settledTurns.get(key) + if (state === 'working' && settled !== undefined) { + return null + } + const child = this.child(agentThreadId) + if (!child) { + return null + } + if ( + state === 'working' && + child.execution?.turnId === turnId && + child.execution.state !== 'working' + ) { + return null + } + const execution = { turnId, state: settled ?? state } + if (state !== 'working') { + this.settledTurns.set(key, execution.state) + while (this.settledTurns.size > MAX_SETTLED_TURNS) { + const oldest = this.settledTurns.keys().next().value + if (oldest === undefined) { + break + } + this.settledTurns.delete(oldest) + } + } + if (state === 'working' || !child.execution || child.execution.turnId === turnId) { + child.execution = execution + } + return { child, execution } + } + + /** Survives the child's turn, so a row outliving that turn can still name it. */ + label(agentThreadId: string): string | null { + return this.children.get(agentThreadId)?.label ?? null + } + + workingChildren(): CodexExecutionChild[] { + return [...this.children.values()].filter( + (child) => child.registered && child.execution?.state === 'working' + ) + } + + settleSession(): void { + for (const child of this.children.values()) { + if (child.execution?.state === 'working') { + child.execution = { ...child.execution, state: 'unverifiable' } + } + } + } + + clear(): void { + this.children.clear() + this.settledTurns.clear() + } + + private child(agentThreadId: string): CodexExecutionChild | undefined { + const existing = this.children.get(agentThreadId) + if (existing) { + return existing + } + if (this.children.size >= MAX_CHILDREN) { + const settled = [...this.children].find(([, child]) => child.execution?.state !== 'working') + if (!settled) { + return undefined + } + this.children.delete(settled[0]) + } + const child: CodexExecutionChild = { + agentThreadId, + registered: false, + label: null, + parentTurnId: null, + execution: null + } + this.children.set(agentThreadId, child) + return child + } +} + +export function codexChildTurnState(status: unknown): NativeChatSubagentState { + if (status === 'completed') { + return 'completed' + } + if (status === 'interrupted') { + return 'stopped' + } + if (status === 'failed') { + return 'failed' + } + return 'unverifiable' +} diff --git a/src/main/codex/codex-subagent-group-body.ts b/src/main/codex/codex-subagent-group-body.ts new file mode 100644 index 00000000000..c2157b0e7eb --- /dev/null +++ b/src/main/codex/codex-subagent-group-body.ts @@ -0,0 +1,51 @@ +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' +import { + MAX_SUBAGENT_FIELD_CHARS, + subagentGroupFallbackText +} from '../../shared/native-chat-subagent-summary' + +/** The roster row: the structured block plus the plain sentence an older client + * renders in its place. A message whose only block is the new variant would + * reach such a client with nothing it can draw. */ +export function codexSubagentGroupBody( + groupId: string, + agents: readonly NativeChatSubagentEntry[] +): AgentJournalItemBody { + const bounded = agents.map((agent, index) => ({ + ...agent, + id: boundSubagentField(agent.id, index), + label: boundSubagentField(agent.label, index) + })) + return { + kind: 'message', + role: 'system', + blocks: [ + { type: 'text', text: subagentGroupFallbackText(bounded) }, + { type: 'subagent-group', groupId, agents: bounded } + ] + } +} + +/** `id` and `label` are provider strings, so they take the bound both readers of + * this row already clip them to. A plain length check, not the tool-output + * bound: that one digests the whole value before it checks the length, and this + * runs twice per child on every streamed token-usage frame. + * + * A clip is not identity-preserving, so a clipped value carries the child's + * index: two ids sharing a long prefix collapse to one React key, and + * `claimLabel` writes its ordinal at the very tail the clip removes. The index + * is reserved out of the bound, not appended to it, because both readers + * re-clip to the same cap and would cut a suffix that overflowed it. */ +export function boundSubagentField(value: string, index: number): string { + if (value.length <= MAX_SUBAGENT_FIELD_CHARS) { + return value + } + const suffix = `…~${index}` + const keep = MAX_SUBAGENT_FIELD_CHARS - suffix.length + // Slicing UTF-16 units can split a surrogate pair; a lone surrogate is + // malformed in a durable row and lossy through any non-JSON UTF-8 hop. + const last = value.charCodeAt(keep - 1) + const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep + return `${value.slice(0, end)}${suffix}` +} diff --git a/src/main/codex/codex-subagent-roster.test.ts b/src/main/codex/codex-subagent-roster.test.ts index 2f20c9df9bf..9d3e2c690c4 100644 --- a/src/main/codex/codex-subagent-roster.test.ts +++ b/src/main/codex/codex-subagent-roster.test.ts @@ -83,6 +83,22 @@ function deliver( item: CodexThreadItem, turnId: string | null = TURN ): void { + // The fixture includes the child's owner event separately from its activity metadata. + const state = + item.kind === 'started' + ? 'working' + : item.kind === 'completed' + ? 'completed' + : item.kind === 'interrupted' + ? 'stopped' + : null + if (state && typeof item.agentThreadId === 'string') { + roster.handleTurn({ + threadId: item.agentThreadId, + turnId: `execution:${item.agentThreadId}`, + state + }) + } // Every activity item reaches the wire twice: item/started, then item/completed. roster.handleItem({ threadId: THREAD, turnId, item }) roster.handleItem({ threadId: THREAD, turnId, item }) @@ -270,7 +286,7 @@ describe('CodexSubagentRoster', () => { expect(appended).toHaveLength(1) }) - it('rule 2 — a first event of any kind creates the entry in the state it implies', () => { + it('registers a child after its owner already reported completion', () => { const { roster, agents } = createHarness() deliver( @@ -374,7 +390,7 @@ describe('CodexSubagentRoster', () => { deliver( roster, - activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' }) + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) ) roster.settleSession() const afterFirstSweep = appended.length @@ -676,6 +692,7 @@ describe('CodexSubagentRoster', () => { now: () => 1_000 }) const item = activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' }) expect(roster.handleItem({ threadId: THREAD, turnId: TURN, item })).toEqual(refusal) @@ -724,6 +741,7 @@ describe('CodexSubagentRoster', () => { activeTurn: () => TURN, now: () => 1_000 }) + roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' }) roster.handleItem({ threadId: THREAD, turnId: TURN, @@ -758,6 +776,7 @@ describe('CodexSubagentRoster', () => { activeTurn: () => TURN }) + roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' }) expect( roster.handleItem({ threadId: THREAD, diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index 257fe705764..a2b58e1a6ce 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -1,11 +1,6 @@ // The Codex subagent roster: one journal row per spawn group, revised in place. // -// There is no snapshot to read. `agentsStates` arrived empty in the live probe -// and children get no `thread/started`, so the roster is -// accumulated purely from `subAgentActivity` items — each of which arrives TWICE -// (`item/started` and `item/completed`). Every transition here is therefore -// idempotent, and a terminal state latches: duplicate and out-of-order delivery -// must not resurrect a settled child. +// Activity supplies membership; child turn events supply execution state. // // KNOWN LIMITATION: `groups` is process-local and is never seeded from the // journal, while the row's identity is keyed on the group id alone. So once a @@ -18,28 +13,32 @@ // thread, and a real turn id is assumed freshly minted per turn. Seeding from // the journal is the fix. +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary' import type { - AgentJournalItemBody, - AgentJournalItemIdentity -} from '../../shared/agent-session-journal-types' -import { - canReplaceSubagentState, - isTerminalSubagentState, - MAX_SUBAGENT_FIELD_CHARS, - subagentGroupFallbackText -} from '../../shared/native-chat-subagent-summary' -import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' + NativeChatSubagentEntry, + NativeChatSubagentState +} from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { codexSubagentLabel, - codexSubagentStateForKind, isCodexRootAgentActivity, readCodexSubagentActivity, readCodexThreadTokenTotal } from './codex-subagent-activity' +import { + CodexSubagentExecutions, + codexChildTurnState, + type CodexChildExecution, + type CodexExecutionChild +} from './codex-subagent-executions' +import { readRecord } from './codex-item-field-readers' +import { readCodexTurnId } from './codex-structured-thread-facts' +import { codexSubagentGroupBody } from './codex-subagent-group-body' +export { codexSubagentGroupBody } from './codex-subagent-group-body' import type { CodexThreadItem } from './codex-structured-item-translation' import { MAX_CODEX_SUBAGENT_GROUPS, @@ -51,15 +50,12 @@ const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } /** The turn a group belongs to when Codex reports activity outside any turn. * Mirrors the generic-frame bucket name so the two read alike in the journal. */ -const OUTSIDE_TURN = 'outside-turn' - -const UNLABELLED_AGENT = 'subagent' - type RosterGroup = { groupId: string identity: AgentJournalItemIdentity /** Insertion order is the display order; the map holds the state. */ entries: Map + executionTurns: Map /** Times each label has been claimed, so a repeat gets an ordinal suffix. */ labelCounts: Map /** Last body written, so an idempotent replay writes no new revision. */ @@ -70,7 +66,7 @@ type RosterGroup = { * tree rooted at the parent thread, so every child of one turn shares a row * no matter which thread's stream carried its activity item. */ export function codexSubagentGroupId(threadId: string, turnId: string | null): string { - return `${threadId}:${turnId ?? OUTSIDE_TURN}` + return `${threadId}:${turnId ?? 'outside-turn'}` } /** Durable journal identity for the group's row — stable across revisions and @@ -85,6 +81,7 @@ export type CodexSubagentRosterDeps = { primaryThreadId: () => string | null activeTurn: (threadId: string) => string | null now?: () => number + executions?: CodexSubagentExecutions } export class CodexSubagentRoster { @@ -95,9 +92,11 @@ export class CodexSubagentRoster { * the map itself is LRU-capped in `handleTokenUsage`. */ private readonly tokensByThread = new Map() private readonly now: () => number + private readonly executions: CodexSubagentExecutions constructor(private readonly deps: CodexSubagentRosterDeps) { this.now = deps.now ?? (() => Date.now()) + this.executions = deps.executions ?? new CodexSubagentExecutions() } /** Consume a `subAgentActivity` item. Returns null when the item is not one. */ @@ -111,42 +110,81 @@ export class CodexSubagentRoster { return null } // The root node is the parent turn itself, not a child it spawned. - if (isCodexRootAgentActivity(activity)) { + if ( + activity.agentThreadId === this.deps.primaryThreadId() || + isCodexRootAgentActivity(activity) + ) { return ADMITTED } - const group = this.groupFor(input.threadId, input.turnId) - const existing = group.entries.get(activity.agentThreadId) - const state = codexSubagentStateForKind(activity.kind) - if (!existing) { - // Rule: the first event for a child may be ANY kind. An `interacted` or - // `completed` with no prior `started` creates the entry in the state its - // kind implies rather than being dropped for lacking a roster row. - if (group.entries.size >= MAX_CODEX_SUBAGENTS_PER_GROUP) { - return ADMITTED - } - const now = this.now() - group.entries.set(activity.agentThreadId, { - id: activity.agentThreadId, - label: this.claimLabel(group, codexSubagentLabel(activity)), - state, - startedAt: now, - ...(isTerminalSubagentState(state) ? { settledAt: now } : {}) - }) - } else if (canReplaceSubagentState(existing.state, state)) { - // A child's own verdict latches. Re-applying the same non-terminal state - // is a no-op, which is what makes the duplicate `item/started` + - // `item/completed` delivery idempotent. `unverifiable` does not latch: a - // child swept when contact was lost can still report what it actually did - // if contact returns. - group.entries.set(activity.agentThreadId, { - ...existing, - state, - ...(isTerminalSubagentState(state) ? { settledAt: this.now() } : {}) - }) + const child = this.executions.register( + activity.agentThreadId, + codexSubagentLabel(activity), + activity.kind === 'started' || activity.kind === 'interacted' ? input.turnId : undefined + ) + if (!child?.execution) { + return ADMITTED + } + const group = + this.executionGroup(child.agentThreadId, child.execution.turnId) ?? + this.groupFor(input.threadId, input.turnId) + if (!group.entries.has(child.agentThreadId)) { + this.recordExecution(group, child, child.execution) } return this.write(group) } + handleTurnEvent(event: { + method: string + threadId: string + params: unknown + }): StructuredAgentSessionSinkAdmission { + const turnId = readCodexTurnId(event.params) + return turnId + ? this.handleTurn({ + threadId: event.threadId, + turnId, + state: + event.method === 'turn/started' + ? 'working' + : codexChildTurnState(readRecord(readRecord(event.params).turn).status) + }) + : ADMITTED + } + + handleTurn(input: { + threadId: string + turnId: string + state: NativeChatSubagentState + }): StructuredAgentSessionSinkAdmission { + if (input.threadId === this.deps.primaryThreadId()) { + return ADMITTED + } + const observed = this.executions.observeTurn(input.threadId, input.turnId, input.state) + if (!observed || !observed.child.registered) { + return ADMITTED + } + const { child, execution } = observed + if (input.state === 'working') { + const parent = this.deps.primaryThreadId() ?? input.threadId + const group = + this.executionGroup(child.agentThreadId, execution.turnId) ?? + this.groupFor(parent, this.deps.activeTurn(parent) ?? child.parentTurnId) + this.recordExecution(group, child, execution) + return this.write(group) + } + for (const group of this.groups.values()) { + if (group.executionTurns.get(input.threadId) !== input.turnId) { + continue + } + this.recordExecution(group, child, execution) + const admission = this.write(group) + if (!admission.accepted) { + return admission + } + } + return ADMITTED + } + /** Consume `thread/tokenUsage/updated`. Returns null when the params are not one. */ handleTokenUsage(params: unknown): StructuredAgentSessionSinkAdmission | null { const usage = readCodexThreadTokenTotal(params) @@ -187,6 +225,7 @@ export class CodexSubagentRoster { * routinely outlive their turn and keep reporting into the same group. */ settleSession(): StructuredAgentSessionSinkAdmission { + this.executions.settleSession() for (const group of this.groups.values()) { const admission = this.sweep(group) if (!admission.accepted) { @@ -233,6 +272,7 @@ export class CodexSubagentRoster { groupId, identity: codexSubagentGroupIdentity(groupId), entries: new Map(), + executionTurns: new Map(), labelCounts: new Map(), lastSerialized: null } @@ -247,15 +287,46 @@ export class CodexSubagentRoster { return group } + private executionGroup(threadId: string, turnId: string): RosterGroup | undefined { + return [...this.groups.values()].find((group) => group.executionTurns.get(threadId) === turnId) + } + /** Two children can share a trailing path segment; the ordinal keeps their * rows apart without inventing a name the provider never sent. */ private claimLabel(group: RosterGroup, label: string | null): string { - const base = label ?? UNLABELLED_AGENT + const base = label ?? 'subagent' const seen = group.labelCounts.get(base) ?? 0 group.labelCounts.set(base, seen + 1) return seen === 0 ? base : `${base} ${seen + 1}` } + private recordExecution( + group: RosterGroup, + child: CodexExecutionChild, + execution: CodexChildExecution | null + ): void { + const existing = group.entries.get(child.agentThreadId) + if (!existing && group.entries.size >= MAX_CODEX_SUBAGENTS_PER_GROUP) { + return + } + const turnId = execution?.turnId ?? null + const state = execution?.state ?? 'unverifiable' + const sameTurn = existing && group.executionTurns.get(child.agentThreadId) === turnId + if (sameTurn && existing.state === state) { + return + } + const now = this.now() + group.executionTurns.set(child.agentThreadId, turnId) + group.entries.set(child.agentThreadId, { + id: child.agentThreadId, + label: existing?.label ?? this.claimLabel(group, child.label), + state, + startedAt: sameTurn ? existing.startedAt : now, + ...(isTerminalSubagentState(state) ? { settledAt: now } : {}), + ...(existing?.tokens !== undefined ? { tokens: existing.tokens } : {}) + }) + } + private write(group: RosterGroup): StructuredAgentSessionSinkAdmission { const agents = [...group.entries].map(([id, entry]) => { const tokens = this.tokensByThread.get(id) @@ -300,48 +371,3 @@ export class CodexSubagentRoster { return published } } - -/** The roster row: the structured block plus the plain sentence an older client - * renders in its place. A message whose only block is the new variant would - * reach such a client with nothing it can draw. */ -export function codexSubagentGroupBody( - groupId: string, - agents: readonly NativeChatSubagentEntry[] -): AgentJournalItemBody { - const bounded = agents.map((agent, index) => ({ - ...agent, - id: boundSubagentField(agent.id, index), - label: boundSubagentField(agent.label, index) - })) - return { - kind: 'message', - role: 'system', - blocks: [ - { type: 'text', text: subagentGroupFallbackText(bounded) }, - { type: 'subagent-group', groupId, agents: bounded } - ] - } -} - -/** `id` and `label` are provider strings, so they take the bound both readers of - * this row already clip them to. A plain length check, not the tool-output - * bound: that one digests the whole value before it checks the length, and this - * runs twice per child on every streamed token-usage frame. - * - * A clip is not identity-preserving, so a clipped value carries the child's - * index: two ids sharing a long prefix collapse to one React key, and - * `claimLabel` writes its ordinal at the very tail the clip removes. The index - * is reserved out of the bound, not appended to it, because both readers - * re-clip to the same cap and would cut a suffix that overflowed it. */ -function boundSubagentField(value: string, index: number): string { - if (value.length <= MAX_SUBAGENT_FIELD_CHARS) { - return value - } - const suffix = `…~${index}` - const keep = MAX_SUBAGENT_FIELD_CHARS - suffix.length - // Slicing UTF-16 units can split a surrogate pair; a lone surrogate is - // malformed in a durable row and lossy through any non-JSON UTF-8 hop. - const last = value.charCodeAt(keep - 1) - const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep - return `${value.slice(0, end)}${suffix}` -} diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 3901d8b1ffa..6237b8d040d 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -11,6 +11,7 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' import { + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' @@ -80,6 +81,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { clientKind: 'runtime', connectionId: desktopSenders.connectionIdFor(event.sender), clientCapabilities: [ + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ] @@ -128,6 +130,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { clientKind: 'runtime', connectionId, clientCapabilities: [ + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ] diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-protected.test.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-protected.test.ts new file mode 100644 index 00000000000..ff82a8f63ed --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer-protected.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' + +describe('protected provider streams', () => { + it('preserves protected prefixes beyond the ordinary count cap and evicts only ordinary streams', () => { + const protectedKeys = new Set() + const coalescer = createAgentSessionDeltaCoalescer({ + maxStreams: 1, + isProtected: (key) => protectedKeys.has(key), + emit: () => true, + schedule: () => () => {} + }) + // A typed start can arrive after the first delta, before the next output. + coalescer.append('command-0', 'before') + protectedKeys.add('command-0') + for (let index = 1; index < 448; index += 1) { + const key = `command-${index}` + protectedKeys.add(key) + expect(coalescer.append(key, 'before')).toBe(true) + } + coalescer.append('ordinary-1', 'temporary') + coalescer.append('ordinary-2', 'latest') + expect(coalescer.snapshot('ordinary-1')).toBeNull() + expect(coalescer.snapshot('ordinary-2')?.text).toBe('latest') + for (const key of protectedKeys) { + coalescer.append(key, 'after') + expect(coalescer.snapshot(key)?.text).toBe('beforeafter') + coalescer.forget(key) + expect(coalescer.snapshot(key)).toBeNull() + } + coalescer.dispose() + }) + + it('keeps aggregate and per-stream output byte ceilings under protected-key pressure', () => { + const coalescer = createAgentSessionDeltaCoalescer({ + maxStreams: 1, + maxRetainedBytes: 80, + maxTotalRetainedBytes: 120, + isProtected: () => true, + emit: () => true, + schedule: () => () => {} + }) + coalescer.append('first', 'a'.repeat(80)) + coalescer.append('second', 'b'.repeat(80)) + coalescer.append('third', 'c'.repeat(80)) + const snapshots = ['first', 'second', 'third'].map((key) => coalescer.snapshot(key)!) + expect(snapshots[0].text).toBe('a'.repeat(80)) + expect(snapshots.map((snapshot) => snapshot.truncated)).toEqual([false, true, true]) + expect( + snapshots.reduce((total, snapshot) => total + Buffer.byteLength(snapshot.text), 0) + ).toBeLessThanOrEqual(120) + coalescer.forget('first') + coalescer.append('new', 'd'.repeat(80)) + expect(coalescer.snapshot('new')?.text).toBe('d'.repeat(80)) + coalescer.dispose() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts index dbc63154b72..73dc08cc05c 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts @@ -37,6 +37,8 @@ export type AgentSessionDeltaCoalescerDeps = { maxTotalRetainedBytes?: number /** Maximum distinct item streams retained at once. */ maxStreams?: number + /** The caller byte-bounds protected metadata; only ordinary streams use the count cap. */ + isProtected?: (key: string) => boolean /** Injected by tests so a window can be driven without real time. */ schedule?: (run: () => void, ms: number) => () => void } @@ -83,8 +85,7 @@ export function createAgentSessionDeltaCoalescer( >() let totalRetainedBytes = 0 let cancelTimer: (() => void) | null = null - const streamOrder = new Map() - let nextOrder = 0 + const evictable = new Set() const flushKey = (key: string): boolean => { const stream = streams.get(key) @@ -130,9 +131,13 @@ export function createAgentSessionDeltaCoalescer( if (!stream) { // Evict the oldest stream before admitting a new attacker-controlled // id. Flush first so the retained prefix is durably visible. - if (streams.size >= maxStreams) { - const oldest = [...streamOrder.entries()].sort((a, b) => a[1] - b[1])[0]?.[0] + while (!deps.isProtected?.(key) && evictable.size >= maxStreams) { + const oldest = evictable.values().next().value if (oldest) { + if (deps.isProtected?.(oldest)) { + evictable.delete(oldest) + continue + } // Under sink backpressure the oldest stream must remain available // for a later retry; dropping it would lose already-observed output. if (!flushKey(oldest)) { @@ -143,8 +148,9 @@ export function createAgentSessionDeltaCoalescer( totalRetainedBytes -= evicted.retainedBytes } streams.delete(oldest) - streamOrder.delete(oldest) + evictable.delete(oldest) } + break } stream = { chunks: [], @@ -153,7 +159,11 @@ export function createAgentSessionDeltaCoalescer( truncated: false, dirty: false } - streamOrder.set(key, nextOrder++) + if (!deps.isProtected?.(key)) { + evictable.add(key) + } + } else if (deps.isProtected?.(key)) { + evictable.delete(key) } stream.observedBytes += Buffer.byteLength(delta, 'utf8') if (!stream.truncated) { @@ -184,14 +194,14 @@ export function createAgentSessionDeltaCoalescer( if (stream) { totalRetainedBytes -= stream.retainedBytes streams.delete(key) - streamOrder.delete(key) + evictable.delete(key) } }, dispose: () => { cancelTimer?.() cancelTimer = null streams.clear() - streamOrder.clear() + evictable.clear() totalRetainedBytes = 0 }, snapshot: (key) => { 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 new file mode 100644 index 00000000000..42d03f46b5b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-wire' +import { conversationCommandBlocked } from './structured-conversation-command-admission' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +function contextWith( + backgroundTasks: AgentSessionBackgroundTaskState | null +): AgentSessionTurnContext { + return { + sessionId: 'session-1', + journal: { + snapshot: () => ({ items: [] }), + submissions: () => [] + }, + adapter: { backgroundTaskState: () => backgroundTasks } + } as unknown as AgentSessionTurnContext +} + +const RECORD = { lease: {} } as unknown as AgentSessionRecord + +describe('conversationCommandBlocked background tasks', () => { + it('admits the command when nothing is being monitored', () => { + expect(conversationCommandBlocked(contextWith(null), RECORD)).toBeNull() + }) + + it('asks for a stop when the host accepts targeted stops', () => { + const blocked = conversationCommandBlocked( + contextWith({ state: 'monitoring', supportsTaskStop: true }), + RECORD + ) + expect(blocked).toBe('Stop background tasks before using this command.') + }) + + it('asks for a stop on a host that predates the stop-capability field', () => { + const blocked = conversationCommandBlocked(contextWith({ state: 'monitoring' }), RECORD) + expect(blocked).toBe('Stop background tasks before using this command.') + }) + + 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(blocked).toBe('Wait for background tasks 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 a69a5163b67..78b9f4a59d0 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 @@ -38,8 +38,14 @@ export function conversationCommandBlocked( ) { return 'Resolve the pending question or approval before using this command.' } - if (ctx.adapter.backgroundTaskState?.(ctx.sessionId)?.state === 'monitoring') { - return 'Stop background tasks 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. + return backgroundTasks.supportsTaskStop || backgroundTasks.supportsStopAll !== false + ? 'Stop background tasks before using this command.' + : 'Wait for background tasks to finish before using this command.' } if ( ctx.journal 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 new file mode 100644 index 00000000000..cc5a7282080 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionBackgroundTaskState } from '../../../../shared/agent-session-wire' +import { AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version' +import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities' +import type { AgentSessionSubscribeInput } from '../../../native-chat/agent-session-wire/structured-agent-session-subscribers' +import { + call, + clearStructuredHostStub, + hostCalls, + installStructuredHostStub, + SESSION, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +beforeEach(installStructuredHostStub) +afterEach(clearStructuredHostStub) + +const TASKS: AgentSessionBackgroundTaskState = { + state: 'monitoring', + supportsStopAll: false, + tasks: [{ id: 'child', kind: 'agent' }] +} +const CURRENT_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: remoteRuntimeClientCapabilities(STRUCTURED_CLIENT.clientCapabilities) +} + +describe('background-task stop capability at the RPC boundary', () => { + it('advertises reader support on remote requests and subscriptions', () => { + expect(CURRENT_CLIENT.clientCapabilities).toContain( + AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY + ) + }) + + it.each([ + ['legacy reader', STRUCTURED_CLIENT, null], + ['current reader', CURRENT_CLIENT, TASKS], + ['in-process reader', undefined, TASKS] + ] as const)('projects history for a %s', async (_label, client, expected) => { + hostCalls.history.mockReturnValue({ ok: true, page: { items: [], backgroundTasks: TASKS } }) + expect( + await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client) + ).toMatchObject({ ok: true, result: { page: { backgroundTasks: expected } } }) + }) + + it.each(['snapshot', 'batch', 'reset'] as const)( + 'gates the %s stream without changing the provider state', + async (type) => { + hostCalls.hold = vi.fn(async () => undefined) + hostCalls.subscribe.mockImplementation((input: AgentSessionSubscribeInput) => { + const base = { sessionId: SESSION, fence: 1, backgroundTasks: TASKS } + if (type === 'batch') { + input.emit({ + ...base, + type, + batch: { + cursor: { epoch: 'a', sequence: 0 }, + items: [], + removedItemIds: [], + submissions: [] + } + }) + } else { + const page = { + sessionId: SESSION, + epoch: 'a', + direction: 'tail' as const, + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: { epoch: 'a', sequence: 0 } }, + hasOlder: false, + hasNewer: false + } + input.emit( + type === 'snapshot' + ? { ...base, type, page } + : { ...base, type, page, reset: 'epoch_changed' } + ) + } + return () => {} + }) + for (const [client, expected] of [ + [STRUCTURED_CLIENT, null], + [CURRENT_CLIENT, TASKS] + ] as const) { + expect(await call('agentSession.subscribe', { sessionId: SESSION }, client)).toMatchObject({ + ok: true, + result: { type, backgroundTasks: expected } + }) + } + expect(TASKS.supportsStopAll).toBe(false) + } + ) + + it('preserves legacy stoppable state for both readers', async () => { + const stoppable = { state: 'monitoring', tasks: TASKS.tasks } + hostCalls.history.mockReturnValue({ ok: true, page: { items: [], backgroundTasks: stoppable } }) + for (const client of [STRUCTURED_CLIENT, CURRENT_CLIENT]) { + expect( + await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, client) + ).toMatchObject({ ok: true, result: { page: { backgroundTasks: stoppable } } }) + } + }) +}) 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 new file mode 100644 index 00000000000..02afc5569bf --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-background-task-capability.ts @@ -0,0 +1,47 @@ +import type { + AgentSessionBackgroundTaskState, + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../../../shared/agent-session-wire' +import { AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY } from '../../../../shared/protocol-version' +import type { RpcContext } from '../core' + +type BackgroundTaskReader = Pick + +function supportsReadOnlyTasks(ctx: BackgroundTaskReader): boolean { + return ( + ctx.clientKind === undefined || + ctx.clientCapabilities?.includes(AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY) === true + ) +} + +function projectState( + state: AgentSessionBackgroundTaskState | null | undefined, + ctx: BackgroundTaskReader +): AgentSessionBackgroundTaskState | null | undefined { + // Legacy readers always offer a stop; retain their pre-producer empty strip. + return state?.supportsStopAll === false && !state.supportsTaskStop && !supportsReadOnlyTasks(ctx) + ? null + : state +} + +export function projectBackgroundTaskHistory( + result: AgentSessionHistoryResult, + ctx: BackgroundTaskReader +): AgentSessionHistoryResult { + const state = projectState(result.page.backgroundTasks, ctx) + return state === result.page.backgroundTasks + ? result + : { ...result, page: { ...result.page, backgroundTasks: state } } +} + +export function projectBackgroundTaskEvent( + event: AgentSessionSubscribeEvent, + ctx: BackgroundTaskReader +): AgentSessionSubscribeEvent { + if (!('backgroundTasks' in event)) { + return event + } + const state = projectState(event.backgroundTasks, ctx) + return state === event.backgroundTasks ? event : { ...event, backgroundTasks: state } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 3078c9fff61..d629b1c29a1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -10,6 +10,10 @@ import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import type { z } from 'zod' +import { + projectBackgroundTaskEvent, + projectBackgroundTaskHistory +} from './structured-agent-session-background-task-capability' import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' import { ensureStructuredHostInstalled as ensureHostInstalled, @@ -251,7 +255,8 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'agentSession.history', params: HistoryParams, - handler: async (params, ctx) => requireHost(ctx).history(params) + handler: async (params, ctx) => + projectBackgroundTaskHistory(requireHost(ctx).history(params), ctx) }), defineStreamingMethod({ name: 'agentSession.subscribe', @@ -278,7 +283,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ dispose = host.subscribe({ id: subscriptionId, sessionId: params.sessionId, - emit, + emit: (event) => emit(projectBackgroundTaskEvent(event, ctx)), ...(params.cursor ? { cursor: params.cursor } : {}) }) if (stream.isClosed()) { diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index d9b3e59186a..0245f15054b 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -225,6 +225,8 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise + host?.publishBackgroundTaskState(sessionId, state), onEvent: (event) => { if (event.type !== 'ended' || !('cause' in event) || event.cause !== 'unexpected-exit') { return diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx new file mode 100644 index 00000000000..e137220dd66 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' +import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus' + +afterEach(cleanup) + +const TASKS: AgentSessionBackgroundTask[] = [ + { id: 'codex-agent:child-1', kind: 'agent', description: 'count_a' }, + { id: 'codex-command:exec-1', kind: 'command', description: 'sleep 90' } +] + +function renderStrip(props: { supportsTaskStop: boolean; supportsStopAll: boolean }): { + onStop: ReturnType +} { + const onStop = vi.fn() + render( + + ) + fireEvent.click(screen.getByRole('button', { expanded: false })) + return { onStop } +} + +describe('NativeChatBackgroundTasksStatus stop affordances', () => { + it('offers a per-task stop on a host that accepts targeted stops', () => { + renderStrip({ supportsTaskStop: true, supportsStopAll: true }) + expect(screen.getByLabelText('Stop count_a')).toBeInTheDocument() + expect(screen.queryByLabelText('Stop background tasks')).not.toBeInTheDocument() + }) + + it('falls back to a stop-all on a host that only accepts an untargeted stop', () => { + renderStrip({ supportsTaskStop: false, supportsStopAll: true }) + expect(screen.getByLabelText('Stop background tasks')).toBeInTheDocument() + }) + + it('offers no stop at all when the provider exposes none', () => { + // Codex: a Stop button here would be a control that cannot act. + renderStrip({ supportsTaskStop: false, supportsStopAll: false }) + expect(screen.queryByLabelText('Stop background tasks')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Stop count_a')).not.toBeInTheDocument() + expect(screen.getByText('count_a')).toBeInTheDocument() + expect(screen.getByText('sleep 90')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx index ce3718a967b..c52b73842a9 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx @@ -26,6 +26,9 @@ function backgroundTaskLabel(task: AgentSessionBackgroundTask): string { export function NativeChatBackgroundTasksStatus(props: { tasks: readonly AgentSessionBackgroundTask[] supportsTaskStop: boolean + /** False when the provider exposes no honest stop at all; the fallback + * control is hidden rather than offering a button that cannot act. */ + supportsStopAll: boolean stoppingTaskIds: ReadonlySet stoppingAll: boolean onStop: (taskId?: string) => void @@ -115,7 +118,7 @@ export function NativeChatBackgroundTasksStatus(props: { )}

    )} - {!props.supportsTaskStop ? ( + {!props.supportsTaskStop && props.supportsStopAll ? (
    0 ? 'mt-2 border-t border-border pt-2' : 'mt-2'}>