mirror of
https://github.com/stablyai/orca.git
synced 2026-10-05 16:02:22 +00:00
feat(native-chat): the chat strip and the sidebar read the host's child records (#22614)
* 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 "<agent> — <command>",
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 dbd2439cd4 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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AgentSessionStatusSummary> = {}): 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 })
|
||||
})
|
||||
|
||||
@@ -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> = {}): 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
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//
|
||||
// Env contract (set by the test):
|
||||
// ORCA_SDK_CONTRACT_SCENARIO_PATH — JSON file
|
||||
// { steps: Step[], controlResponses?: { [subtype]: <response> } } where a Step is
|
||||
// { steps: Step[], controlResponses?: { [subtype]: <response> },
|
||||
// controlResponseLeadingFrames?: { [subtype]: <frame>[] } } where a Step is
|
||||
// { emit: <frame> } | { awaitUserMessage: true } | { stderr: <text> } |
|
||||
// { awaitControlResponse: <request_id> } | { delayMs: <n> } | { exit: <code> }
|
||||
// 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') {
|
||||
|
||||
@@ -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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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<StructuredAgentSessionJournalProjections['read']>[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<typeof host.getStructuredChildWorkViews>) =>
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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 } : {})
|
||||
}))
|
||||
|
||||
@@ -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<AgentJournalRenderItem, 'itemId' | 'body' | 'sequence' | 'agentId'>
|
||||
|
||||
/** 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<string, JournaledItem>()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>[] {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -23,7 +23,6 @@ export async function releaseClaudeAcquisition(input: {
|
||||
onExitProven?: (sessionId: string, exit: ClaudeSessionExit) => Promise<void>
|
||||
persistHandle?: ClaudeStructuredSessionAdapterDeps['persistHandle']
|
||||
onEvent?: ClaudeStructuredSessionAdapterDeps['onEvent']
|
||||
onBackgroundTasksChanged?: ClaudeStructuredSessionAdapterDeps['onBackgroundTasksChanged']
|
||||
}): Promise<boolean> {
|
||||
const exit = input.exits.get(input.sessionId)
|
||||
if (!exit || input.sessions.has(input.sessionId) || input.acquisitions.get(input.sessionId)) {
|
||||
|
||||
@@ -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' })
|
||||
])
|
||||
|
||||
@@ -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<ReturnType<ClaudeSession['connection']['interrupt']>>
|
||||
@@ -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<void>
|
||||
): 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<void>(() => {}), 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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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<StructuredAgentSessionAdapter['stopBackgroundTasks']> = 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<StructuredAgentSessionAdapter['backgroundTaskState']> = (
|
||||
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<StructuredAgentSessionAdapter['backgroundTaskStops']> = (
|
||||
sessionId
|
||||
) =>
|
||||
this.sessions.has(sessionId) ? { supportsTaskStop: true, supportsStopAll: true } : undefined
|
||||
readCommands: NonNullable<StructuredAgentSessionAdapter['readCommands']> = (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 } : {})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<NonNullable<ClaudeStructuredSessionAdapterDeps['persistHandle']>>()
|
||||
.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<string, { translator: { dispose: () => 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)
|
||||
|
||||
@@ -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<void>
|
||||
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<void>
|
||||
onEvent?: (event: ClaudeStructuredSessionEvent) => void
|
||||
onBackgroundTasksChanged?: (
|
||||
sessionId: string,
|
||||
state: AgentSessionBackgroundTaskState | null
|
||||
) => void
|
||||
}
|
||||
): Promise<boolean> {
|
||||
return closeClaudePublishedSession({ sessions, sessionId, ...deps })
|
||||
@@ -256,10 +246,6 @@ export async function closeClaudeSession(input: {
|
||||
fence: number
|
||||
}) => Promise<void>
|
||||
onEvent?: (event: ClaudeStructuredSessionEvent) => void
|
||||
onBackgroundTasksChanged?: (
|
||||
sessionId: string,
|
||||
state: AgentSessionBackgroundTaskState | null
|
||||
) => void
|
||||
}): Promise<boolean> {
|
||||
const attempt = input.acquisitions.get(input.sessionId)
|
||||
if (!(await cancelClaudeAcquisitionAttempt(attempt))) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<string, Command>()
|
||||
private readonly settled = new Map<string, number>()
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<string, unknown> }
|
||||
type Delivery = { kind: 'journal' | 'legacy' | 'evidence'; detail: string }
|
||||
type Delivery = { kind: 'journal' | 'publish' | 'evidence'; detail: string }
|
||||
type Liveness = ReturnType<typeof agentChildWorkLiveness>
|
||||
/** 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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<StructuredAgentSessionAdapter['backgroundTaskState']> = (
|
||||
/** 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<StructuredAgentSessionAdapter['backgroundTaskStops']> = (
|
||||
sessionId
|
||||
) => this.sessions.get(sessionId)?.backgroundTasks.state
|
||||
) =>
|
||||
this.sessions.has(sessionId) ? { supportsTaskStop: false, supportsStopAll: false } : undefined
|
||||
|
||||
bindPromptItemId = (
|
||||
sessionId: string,
|
||||
|
||||
@@ -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<boolean>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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?.()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, CodexSession>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -111,9 +111,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
return stop ? stop(input) : Promise.resolve({ cancelled: false })
|
||||
}
|
||||
|
||||
backgroundTaskState: NonNullable<StructuredAgentSessionAdapter['backgroundTaskState']> = (
|
||||
backgroundTaskStops: NonNullable<StructuredAgentSessionAdapter['backgroundTaskStops']> = (
|
||||
sessionId
|
||||
) => this.liveOwnerOrNull(sessionId)?.backgroundTaskState?.(sessionId)
|
||||
) => this.liveOwnerOrNull(sessionId)?.backgroundTaskStops?.(sessionId)
|
||||
|
||||
holdsDispatch = (sessionId: string): boolean =>
|
||||
this.liveOwnerOrNull(sessionId)?.holdsDispatch?.(sessionId) ?? false
|
||||
|
||||
@@ -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
|
||||
|
||||
+114
@@ -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()
|
||||
})
|
||||
})
|
||||
+56
-15
@@ -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<string, string>()
|
||||
|
||||
constructor(
|
||||
private readonly deps: StructuredAgentSessionHostDeps,
|
||||
private readonly sessions: Map<string, StructuredAgentSessionHostSession>,
|
||||
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<StructuredAgentSessionHostSession>,
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+85
@@ -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> = {}): 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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
@@ -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<void>
|
||||
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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@ export class StructuredAgentSessionConversations extends Map<
|
||||
StructuredAgentSessionHostSession
|
||||
> {
|
||||
private readonly activity = new Map<string, number>()
|
||||
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 {
|
||||
|
||||
@@ -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<void>
|
||||
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
|
||||
},
|
||||
|
||||
@@ -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<StructuredAgentSessionResumeOutcome>
|
||||
reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void
|
||||
publishRestored: (sessionId: string) => void
|
||||
clientDelivery: Pick<StructuredAgentSessionClientDelivery, 'publishRestored' | 'readChildWork'>
|
||||
flushStreamedEvents: (sessionId: string) => Promise<void>
|
||||
}): 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)
|
||||
|
||||
@@ -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<void>
|
||||
/** The host's accessor, for a caller outside the session's serialize. */
|
||||
conversation: (sessionId: string) => Promise<StructuredAgentSessionHostSession>
|
||||
/** The session's child records, as the strip reads them; what command admission decides on. */
|
||||
readChildWork: (sessionId: string) => AgentChildWorkView[] | undefined
|
||||
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
|
||||
/** The session's conversation, opened when closed; inside the caller's serialize. */
|
||||
openConversation: (sessionId: string) => Promise<StructuredAgentSessionHostSession | null>
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<typeof releaseStructuredAgentSessionUnansweredDispatches>[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)
|
||||
|
||||
|
||||
+33
-38
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+27
-12
@@ -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<StructuredAgentSessionAdapter['dispatch']>
|
||||
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)
|
||||
|
||||
@@ -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<void>
|
||||
/** The session's child records, which name the tasks a background Stop reaches. */
|
||||
childWork?: () => readonly AgentChildWorkView[] | undefined
|
||||
}): MutationPlan<AgentSessionCancelResult> {
|
||||
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.
|
||||
|
||||
+17
-3
@@ -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<typeof fakeClaude>
|
||||
let landInit: () => void
|
||||
let lifecycle: Promise<void>[]
|
||||
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' })
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -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<StructuredAgentSessionAdapter['acknowledgeSessionRelease']>
|
||||
>
|
||||
backgroundTaskState: Mock<NonNullable<StructuredAgentSessionAdapter['backgroundTaskState']>>
|
||||
holdsDispatch: Mock<NonNullable<StructuredAgentSessionAdapter['holdsDispatch']>>
|
||||
readOptions: Mock<NonNullable<StructuredAgentSessionAdapter['readOptions']>>
|
||||
}
|
||||
@@ -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<StructuredAgentSessionHostDeps>) => Promise<StructuredAgentSessionHost>
|
||||
dispose: () => Promise<void>
|
||||
@@ -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' } }))
|
||||
}
|
||||
|
||||
+9
-5
@@ -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 })
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+16
@@ -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<AgentChildWorkView, 'id' | 'kind'> & Partial<AgentChildWorkView>
|
||||
): 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'
|
||||
|
||||
|
||||
+4
-1
@@ -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<StructuredAgentSessionRestartResumeSurfaces, 'readChildWork'> {
|
||||
return {
|
||||
revealSession: host.revealSession,
|
||||
send: (params) =>
|
||||
|
||||
+30
-28
@@ -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
|
||||
|
||||
+16
-9
@@ -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: [],
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@ export type StructuredAgentSessionRestartWitnesses = {
|
||||
export function createStructuredAgentSessionRestartWitnesses(deps: {
|
||||
sessions: ReadonlyMap<string, NonNullable<WorkingAtStopInput['session']>>
|
||||
getRecord: (sessionId: string) => AgentSessionRecord | null
|
||||
backgroundTasks: WorkingAtStopInput['backgroundTasks']
|
||||
childWork: WorkingAtStopInput['childWork']
|
||||
capsule?: Pick<AgentSessionRecoveryCapsule, 'record'>
|
||||
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()
|
||||
|
||||
+2
-1
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 ?? ''}`
|
||||
}
|
||||
+349
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
+23
-2
@@ -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<P extends AgentSessionHandleProvider = 'codex'>(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> = {}): 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
|
||||
}
|
||||
}
|
||||
|
||||
+14
-93
@@ -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<string, Parameters<typeof indexed>[0]>,
|
||||
record: Partial<AgentSessionRecord> | 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' })]
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, StructuredAgentSessionStatusSubscriber>()
|
||||
private readonly published = new Map<string, AgentSessionStatusSummary>()
|
||||
// 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<string, string>()
|
||||
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<AgentSessionStatusSummary, 'children' | 'backgroundTasks'> {
|
||||
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. */
|
||||
|
||||
+67
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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()
|
||||
|
||||
+57
@@ -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
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
+22
-10
@@ -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()
|
||||
|
||||
@@ -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<void>
|
||||
/** 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<TurnOutcome<AgentSessionCancelResult>> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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> = {}
|
||||
): 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<ReturnType<StructuredAgentSessionAdapter['cancelTurn']>>,
|
||||
|
||||
+11
-11
@@ -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,
|
||||
|
||||
+92
-37
@@ -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> = {}): 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<typeof ctx.journal.snapshot>
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
+40
-8
@@ -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<ReturnType<AgentSessionJournal['snapshot']>, 'items'>
|
||||
submissions: AgentSessionJournal['submissions']
|
||||
}
|
||||
adapter: Pick<StructuredAgentSessionAdapter, 'backgroundTaskStops'>
|
||||
}
|
||||
|
||||
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<AgentSessionTurnContext, 'journal' | 'adapter' | 'sessionId' | 'fence'>,
|
||||
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
|
||||
}
|
||||
|
||||
+180
@@ -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<typeof hostTestState>
|
||||
let compact: Mock<NonNullable<StructuredAgentSessionAdapter['compact']>>
|
||||
/** 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> = {}): 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<string | undefined> {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ function acceptStructuredConversationCommand(
|
||||
conversationCommandBlocked(
|
||||
ctx,
|
||||
record,
|
||||
context.readChildWork(ctx.sessionId),
|
||||
context.sessions.get(ctx.sessionId)?.child ? undefined : 'at-rest'
|
||||
)
|
||||
const blocked =
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<NonNullable<StructuredAgentSessionStatusSink['publishChildWork']>>[]
|
||||
let hookServer: AgentHookServer
|
||||
|
||||
async function call(method: string, params: unknown): Promise<RpcResponse> {
|
||||
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',
|
||||
|
||||
@@ -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)'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
+79
@@ -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<typeof call>[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 } }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+23
-3
@@ -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<RpcContext, 'clientKind' | 'clientCapabilities'>
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
'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<AgentChildWorkView, 'kind' | 'description' | 'state' | 'membership'> & {
|
||||
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<string, unknown>) => {
|
||||
connections[0]?.onNotification?.(method, params)
|
||||
await settle()
|
||||
}
|
||||
const item = (
|
||||
method: 'item/started' | 'item/completed',
|
||||
threadId: string,
|
||||
turnId: string,
|
||||
value: Record<string, unknown>
|
||||
) => 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
|
||||
)
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
<NativeChatBackgroundTasksStatus
|
||||
tasks={tasks}
|
||||
settledTasks={[]}
|
||||
childViews={children}
|
||||
childViews={sent}
|
||||
childRowContext={childRowContext}
|
||||
supportsTaskStop
|
||||
supportsStopAll
|
||||
@@ -283,12 +290,21 @@ const FULL_ROW_DETAIL: Record<string, { shows: string[]; hides?: string[] }> = {
|
||||
'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(
|
||||
<TooltipProvider>
|
||||
<CompactAgentRow agent={agent} now={NOW} onActivate={() => {}} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
).textContent
|
||||
expect(text?.endsWith('3m')).toBe(true)
|
||||
const strip = mount(
|
||||
renderToStaticMarkup(
|
||||
<NativeChatBackgroundTasksStatus
|
||||
tasks={[]}
|
||||
settledTasks={[]}
|
||||
childViews={[settled('succeeded')]}
|
||||
supportsTaskStop
|
||||
supportsStopAll
|
||||
stoppingTaskIds={new Set()}
|
||||
stoppingAll={false}
|
||||
indicatorActive
|
||||
isVisible
|
||||
expanded
|
||||
onExpandedChange={() => {}}
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -35,6 +35,7 @@ function statusElement(
|
||||
agentLabel="Claude"
|
||||
startupPhase={startupPhase}
|
||||
startupChildKey={startupChildKey}
|
||||
paneKey="pane-1"
|
||||
error={null}
|
||||
composerError={null}
|
||||
isVisible
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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<AppState> & { 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<typeof RuntimeRpcClientModule>()),
|
||||
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> = {}): 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> = {}): 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(
|
||||
<TooltipProvider>
|
||||
<CompactAgentRow agent={agent} now={Date.now()} onActivate={() => {}} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
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 (
|
||||
<TooltipProvider>
|
||||
<StructuredAgentSessionStatusBridge />
|
||||
<NativeChatStructuredSessionStatus
|
||||
sessionId={tab.entityId}
|
||||
agentLabel="Claude"
|
||||
startupPhase="ready"
|
||||
startupChildKey={null}
|
||||
paneKey={PANE_KEY}
|
||||
error={null}
|
||||
composerError={null}
|
||||
isVisible
|
||||
backgroundTasks={structuredSessionBackgroundTasksView(props.roster, null)}
|
||||
stopBackgroundTask={async () => undefined}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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(<Surfaces roster={roster} />)
|
||||
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(<Surfaces roster={null} />)
|
||||
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(
|
||||
<Surfaces roster={{ state: 'monitoring', tasks: [{ id: 'task-1', kind: 'command' }] }} />
|
||||
)
|
||||
expect(strip()).not.toBeNull()
|
||||
rerender(<Surfaces roster={null} />)
|
||||
expect(strip()).toBeNull()
|
||||
})
|
||||
})
|
||||
+84
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
+25
-6
@@ -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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user