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

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