feat(chat): host writes one saved row per subagent (#27947)

* feat(chat): read one saved row per subagent

Adds the singular `subagent` journal block (one row per child, revised in
place, with a plain-text twin for older clients) and teaches every reader
to draw it: desktop and phone fold a turn's sibling rows into the existing
"Ran N subagents" row, the client and off-page rosters name children from
their own rows, the reopen pass settles a row a dead host left working,
and the phone and worker-transcript wires bound it. No producer writes the
block yet; that switch lands in the follow-up.

* feat(chat): host writes one saved row per subagent

Claude, Codex and Grok now only report child-work evidence. The host writes each
subagent's row from its child record, so the chat row, strip and sidebar read one
lifecycle. Deletes the shared subagent tracker, the per-agent rosters and their
caps. An accepted Stop settles only the run it was sent to, provisionally, until
that run reports its own ending. Work the host store has no room to record keeps
the running-tasks strip and its Stop instead of reading as idle.

* fix(chat): open subagent background flag, separate nested group ids, page window skips subagent rows

- background is an open boolean so a later build's value never fails a chat load
- a helper's own helpers fold under a groupId of their own, so the phone opens them apart
- each subagent's own row rides along with the page window instead of counting as conversation

* fix(chat): subagent row writer reads the live turn once, stop acks survive a failed batch

- the row writer reads the journal's live turn at most once per change and only for a
  subagent with no saved, queued or in-flight row; a revision keeps its first scope
- a background stop names each accepted task as its answer lands, so a stop-all batch that
  times out or fails part way still ends the runs it was answered for (Claude and Grok)
- the strip's untargeted Stop rule lives in the shared view (offersStopAll); both strips
  inline it again; incomplete tracking no longer holds commands or rewind
- clients admit a frame whose only change is incomplete tracking
- drop the Claude stop's dead publish; the real-CLI stop test asserts the run's own ending
- list the writer test with the real SQLite tests

* fix(chat): a late Grok report never revives an ended helper; Codex keeps announced helpers

- Grok: once a helper's run ended, a later progress or repeated spawn sends no live evidence,
  so its record and saved row stay ended after the next send retires the record; the test rig
  now retires finished records on each turn open, as the host does
- Codex: past the bound, only a child that ran and ended is evicted, so an announced helper
  that has not started keeps its record, row, Stop and parent
- cut: Codex parentTurnId (written, never read), the Claude identities' turn-end forwarding
  (call the corrections directly), the duplicate Grok outcome map
- update stale comments that described the deleted producers

* test(chat): Claude frames to saved row, kept Codex path and usage rules, agent ingestion exemption

- a captured background Claude helper keeps its saved row working past its spawn result and
  the parent turn's end, through the real adapter, host and child records, until its own end
- restore focused tests for the Codex root path, label trim and placeholder, usage before
  announcement and parent usage
- the per-session live limit test uses tasks: subagents are exempt from it

* fix(chat): incomplete tracking follows the refused children, and the strip's Stop reaches them

- the host remembers which children the store refused for room, by handle; each leaves when a
  later report of it is admitted or it reports its own end, so incomplete clears on a truthful
  signal; the provider session's end still clears all, and past 512 a flag holds until then
- a parent row the store refused counts every live child it reports as refused
- with work no record lists, an untargeted strip Stop while no turn runs ends the agent through
  the idle sweep's stop; under a running turn it leaves the agent alone
- the background Stop moves into its own module

* fix(chat): the incomplete Stop is offered for every agent and decides inside its step

- the strip offers the untargeted Stop for work no row lists whenever no turn runs, whatever
  untargeted stop the provider has (ACP agents, Codex before it proves terminal stops), since the
  host reaches it by ending the agent; never under a running turn
- the Stop reads whether work is unlisted and whether a turn runs inside its own step, after the
  listed stops land, so a wake-up turn opened meanwhile is never ended
- Codex executions past the bound evict the oldest ended child, else the oldest never started
- test the writer's unnamed-subagent label; qualify the writer's retry comment

* refactor(chat): the subagent row writer joins the views with their records

Drops the shared host-only row projection and its hook-server read: the writer's wiring reads
the views every surface reads and the records they come from, and takes residency and the
ending's basis from the record.

* refactor(chat): Grok subagent evidence reads its ending through the one outcome map

* refactor(chat): a subagent's group entry as one object

* fix(chat): a background Stop with nothing listed asks the agent for no task stops

So an agent with no stop route (an ACP agent the probe found none for) never refuses before the
Stop ends the work no row lists. Tests: the unlisted case is untargeted and reads unlisted work
inside the step; the repeat-press rig lists the task its Stops reach; reflow the writer header.

* test(chat): a nested helper's saved parent; describe how incomplete tracking clears

* fix(chat): a closed session claims no Stop; a helper's row follows its own running work

- a session close Orca proves is loss of contact, not a Stop the CLI accepted: Claude no longer
  ends what still ran as stopped, so the host settles it unknown and its row reads unverifiable,
  as before this stack; stop-acknowledged comes only from a Stop the CLI accepted
- a helper that finished while work it started still runs is written working, by the strip's
  own rule; once that work ends, or the session ends, it reads its own ending again
- the CLI starting a completed helper again under the same spawn call is the helper waking when
  its work ends: the decoder reports it as a restart, so its one row revises in place and ends
  with its real duration; after any other ending such a start is still read as late
- tests from a captured 2.1.280 frame order; item 4 Stops pin their stop-acknowledged basis

* refactor(chat): drop the unused Stop basis on a child's own ending

Only the acknowledged-Stop edge writes that basis now; reflow the decoder header.

* fix(chat): an older history page keeps a turn's subagent ordinals and row

A page now reaches back to the first sibling of each subagent group it holds part of, at most one more page of conversation, so loading the older page no longer renumbers same-named helpers or moves (and closes) their folded row.
This commit is contained in:
Brennan Benson
2026-10-12 22:53:42 -07:00
committed by GitHub
parent 1182168681
commit 8be17ddf3a
145 changed files with 3238 additions and 10487 deletions
@@ -45,7 +45,6 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/claude/claude-subagent-permission-request.test.ts',
'src/main/claude/claude-subagent-resume-after-restart.test.ts',
'src/main/codex/codex-provider-retry-idle-sweep.test.ts',
'src/main/codex/codex-subagent-roster-retention.test.ts',
'src/main/codex/codex-structured-journal-exit-convergence.test.ts',
'src/main/codex/codex-structured-journal-goal-revision.test.ts',
'src/main/codex/codex-structured-journal-repeated-questions.test.ts',
@@ -81,6 +80,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-subagent-row.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts',
@@ -180,6 +180,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-stopping-journal.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-subagent-rows.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-background-tasks.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts',
@@ -236,7 +236,10 @@ function MobileNativeChatBackgroundTasksImpl({
) : (
<Text style={styles.unavailable}>{say('detailsUnavailable')}</Text>
)}
{!view.supportsStop && view.supportsStopAll ? (
{view.incomplete && groups.length > 0 ? (
<Text style={styles.unavailable}>{say('detailsUnavailable')}</Text>
) : null}
{view.offersStopAll ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={say('stopAll')}
@@ -1,538 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ACP subagent timeline rows (characterization) > a group past 64 children drops the rest 1`] = `
{
"events": 1,
"has": [
true,
false,
],
"lastLabels": "l":"X 60","state":"working","startedAt":1010},{"id":"c60","label":"X 61","state":"working","startedAt":1010},{"id":"c61","label":"X 62","state":"working","startedAt":1010},{"id":"c62","label":"X 63","state":"working","startedAt":1010},{"id":"c63","label":"X 64","state":"working","startedAt":1010}]}]",
}
`;
exports[`ACP subagent timeline rows (characterization) > children sharing a label, a provider-named ordinal, and outcomes in one batch 1`] = `
[
[
{
"body": {
"blocks": [
{
"text": "Kicked off 4 subagents",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Audit",
"startedAt": 1010,
"state": "working",
},
{
"id": "a2",
"label": "Audit 2",
"startedAt": 1010,
"state": "working",
},
{
"id": "a3",
"label": "Audit 2 2",
"startedAt": 1010,
"state": "working",
},
{
"id": "a4",
"label": "subagent",
"startedAt": 1010,
"state": "working",
},
],
"groupId": "turn-1",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"subagentIdentities": [
"a1",
"a2",
"a3",
"a4",
],
"type": "item.update",
},
],
[
{
"body": {
"blocks": [
{
"text": "Kicked off 4 subagents (1 failed)",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Audit",
"settledAt": 1020,
"startedAt": 1010,
"state": "stopped",
},
{
"id": "a2",
"label": "Audit 2",
"settledAt": 1020,
"startedAt": 1010,
"state": "completed",
},
{
"id": "a3",
"label": "Audit 2 2",
"settledAt": 1020,
"startedAt": 1010,
"state": "failed",
},
{
"id": "a4",
"label": "subagent",
"startedAt": 1010,
"state": "working",
},
],
"groupId": "turn-1",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"subagentIdentities": [
"a1",
"a2",
"a3",
"a4",
],
"type": "item.update",
},
{
"body": {
"blocks": [
{
"text": "late",
"type": "text",
},
],
"kind": "message",
"role": "assistant",
},
"item": "subagent-result:a2",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"producer": {
"agentId": "a2",
"producerKind": "agent",
},
"type": "item.update",
},
],
]
`;
exports[`ACP subagent timeline rows (characterization) > knownOnly updates, turn from the update, and the no-turn group 1`] = `
{
"has": [
false,
true,
true,
],
"log": [
[],
[
{
"body": {
"blocks": [
{
"text": "Kicked off 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Spawned",
"startedAt": 1020,
"state": "working",
},
],
"groupId": "turn-0",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-0",
"join": {
"thread": "parent",
"turn": "turn-0",
},
"subagentIdentities": [
"a1",
],
"type": "item.update",
},
],
[
{
"body": {
"blocks": [
{
"text": "Kicked off 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "a2",
"label": "Loose",
"startedAt": 1030,
"state": "working",
},
],
"groupId": "thread",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:thread",
"join": {
"thread": "parent",
},
"subagentIdentities": [
"a2",
],
"type": "item.update",
},
],
[
{
"body": {
"blocks": [
{
"text": "Ran 1 subagent (1 stopped)",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Spawned",
"settledAt": 1040,
"startedAt": 1020,
"state": "stopped",
},
],
"groupId": "turn-0",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-0",
"join": {
"thread": "parent",
"turn": "turn-0",
},
"subagentIdentities": [
"a1",
],
"type": "item.update",
},
],
],
}
`;
exports[`ACP subagent timeline rows (characterization) > more than 32 settled groups, then a late update for an evicted child 1`] = `
{
"has": [
true,
true,
true,
],
"tail": [
[
{
"body": {
"blocks": [
{
"text": "Kicked off 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "live",
"label": "Live",
"startedAt": 1350,
"state": "working",
},
],
"groupId": "turn-live",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-live",
"join": {
"thread": "parent",
"turn": "turn-live",
},
"subagentIdentities": [
"live",
],
"type": "item.update",
},
],
[],
[],
],
}
`;
exports[`ACP subagent timeline rows (characterization) > one child: working, tokens, completed with its reply, replayed 1`] = `
[
[
{
"body": {
"blocks": [
{
"text": "Kicked off 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Explore",
"startedAt": 1010,
"state": "working",
},
],
"groupId": "turn-1",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"subagentIdentities": [
"a1",
],
"type": "item.update",
},
],
[
{
"body": {
"blocks": [
{
"text": "Kicked off 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Explore",
"startedAt": 1010,
"state": "working",
"tokens": 0,
},
],
"groupId": "turn-1",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"subagentIdentities": [
"a1",
],
"type": "item.update",
},
],
[
{
"body": {
"blocks": [
{
"text": "Kicked off 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Explore",
"startedAt": 1010,
"state": "working",
"tokens": 200,
},
],
"groupId": "turn-1",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"subagentIdentities": [
"a1",
],
"type": "item.update",
},
],
[
{
"body": {
"blocks": [
{
"text": "Ran 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "Explore",
"settledAt": 1040,
"startedAt": 1010,
"state": "completed",
"tokens": 200,
},
],
"groupId": "turn-1",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"subagentIdentities": [
"a1",
],
"type": "item.update",
},
{
"body": {
"blocks": [
{
"text": "Found it",
"type": "text",
},
],
"kind": "message",
"role": "assistant",
},
"item": "subagent-result:a1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"producer": {
"agentId": "a1",
"producerKind": "agent",
},
"type": "item.update",
},
],
[],
[],
]
`;
exports[`ACP subagent timeline rows (characterization) > writes nothing after dispose 1`] = `
{
"has": false,
"log": [
[
{
"body": {
"blocks": [
{
"text": "Kicked off 1 subagent",
"type": "text",
},
{
"agents": [
{
"id": "a1",
"label": "A",
"startedAt": 1010,
"state": "working",
},
],
"groupId": "turn-1",
"type": "subagent-group",
},
],
"kind": "message",
"role": "system",
},
"item": "subagents:turn-1",
"join": {
"thread": "parent",
"turn": "turn-1",
},
"subagentIdentities": [
"a1",
],
"type": "item.update",
},
],
[],
],
}
`;
+2 -2
View File
@@ -6,7 +6,7 @@ import {
import { acpWindowUsage } from './acp-context-usage'
import type { AcpDialect } from './acp-dialects/acp-dialect'
import type { AcpBackgroundTaskTimeline } from './acp-background-task-timeline'
import type { AcpSubagentTimeline } from './acp-subagent-timeline'
import type { AcpSubagents } from './acp-subagents'
import type { AcpToolTimeline } from './acp-tool-timeline'
import type { SessionNotification } from './generated/acp-protocol.generated'
import { acpNamedTextKey } from './acp-turn-messages'
@@ -29,7 +29,7 @@ export function acpSessionUpdate(
tools: AcpToolTimeline
dialect: AcpDialect
backgroundTasks: AcpBackgroundTaskTimeline
subagents: AcpSubagentTimeline
subagents: AcpSubagents
messageKey?: string
}
): ProviderTimelineEvent[] {
@@ -1,16 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { isSubagentGroupBlock } from '../../shared/native-chat-types'
import { isSubagentBlock } from '../../shared/native-chat-types'
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect'
import { GENERIC_ACP_DIALECT, type AcpDialect } from './acp-dialects/acp-dialect'
import { acpChildWorkStatusSink } from './acp-structured-child-work.test-support'
import {
acpChildWorkStatusSink,
acpSubagentRecords
} from './acp-structured-child-work.test-support'
import { PROVIDER_SESSION, replyChunk } from './acp-structured-adapter.test-support'
import { openAttachedHostRig, promptIdOf, send, stop } from './acp-structured-host.test-support'
import { readAcpFixture } from './acp-timeline-fixture.test-support'
import { AcpTimelineTranslator } from './acp-timeline-translator'
import type { AcpTimelineEvent } from './acp-timeline-event'
const cleanup: (() => Promise<unknown>)[] = []
afterEach(async () => {
@@ -21,14 +23,6 @@ afterEach(async () => {
await closeProviderTimelineRigs()
})
function agents(events: AcpTimelineEvent[]) {
return events.flatMap((event) =>
event.type === 'item.update' && event.body.kind === 'message'
? event.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents)
: []
)
}
function translator(dialect: AcpDialect = GROK_ACP_DIALECT) {
const lane = new AcpTimelineTranslator({ sessionId: PROVIDER_SESSION, dialect })
const { promptId } = lane.openPrompt('parent', 100)
@@ -46,6 +40,9 @@ function translator(dialect: AcpDialect = GROK_ACP_DIALECT) {
},
102
)
const records = acpSubagentRecords()
const host = () => records(lane.subagents.drainEvidence())
host()
const end = (
reason: string,
sessionId = 'child',
@@ -67,7 +64,7 @@ function translator(dialect: AcpDialect = GROK_ACP_DIALECT) {
},
120
)
return { lane, end, promptId }
return { lane, end, host, promptId }
}
describe('owned Grok child-session completion', () => {
@@ -78,19 +75,17 @@ describe('owned Grok child-session completion', () => {
'_x.ai/session/update'
])('settles a known child through %s without ending or charging the parent turn', (method) => {
const f = translator()
const events = f.end('end_turn', 'child', {}, method)
expect(agents(events)).toEqual([
{ id: 'child', label: 'Original label', state: 'completed', startedAt: 102, settledAt: 120 }
])
expect(events).toMatchObject([
expect(f.end('end_turn', 'child', {}, method)).toEqual([])
expect(f.host()).toMatchObject([
{
type: 'item.update',
item: 'subagents:prompt:parent',
join: { turn: f.promptId },
subagentIdentities: ['child']
providerId: 'child',
description: 'Original label',
membership: 'settled',
outcome: 'succeeded'
}
])
expect(f.end('cancelled')).toEqual([])
expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'succeeded' }])
const parent = f.lane.notification(
'session/update',
replyChunk(f.promptId, 'Parent continues'),
@@ -106,9 +101,8 @@ describe('owned Grok child-session completion', () => {
it('latches cancellation against conflicting child ends, parent reports and later working progress', () => {
const f = translator()
expect(agents(f.end('cancelled'))).toMatchObject([
{ id: 'child', state: 'stopped', settledAt: 120 }
])
expect(f.end('cancelled')).toEqual([])
expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'cancelled' }])
expect(f.end('end_turn')).toEqual([])
expect(
f.lane.notification(
@@ -128,8 +122,9 @@ describe('owned Grok child-session completion', () => {
},
122
)
expect(agents(progress)).toMatchObject([
{ id: 'child', state: 'stopped', settledAt: 120, tokens: 7 }
expect(progress).toEqual([])
expect(f.host()).toMatchObject([
{ providerId: 'child', membership: 'settled', outcome: 'cancelled' }
])
})
@@ -138,6 +133,7 @@ describe('owned Grok child-session completion', () => {
expect(f.end('future_stop_reason')).toEqual([])
expect(f.end('cancelled', 'unowned-child')).toEqual([])
expect(f.end('cancelled', 'child', {}, '_x.ai/other_notification')).toEqual([])
expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }])
expect(f.lane.promptResult('parent', { stopReason: 'end_turn' }, 122)).toMatchObject([
{ type: 'turn.end', turn: f.promptId, outcome: 'success' }
])
@@ -154,7 +150,9 @@ describe('owned Grok child-session completion', () => {
f.lane.notification('_x.ai/session_notification', { sessionId: 'child', update }, 120)
).toEqual([])
}
expect(agents(f.end('end_turn'))).toMatchObject([{ id: 'child', state: 'completed' }])
expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }])
expect(f.end('end_turn')).toEqual([])
expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'succeeded' }])
})
it('ignores malformed completions and child-scoped roster reports without learning a new child', () => {
@@ -170,7 +168,9 @@ describe('owned Grok child-session completion', () => {
).toEqual([])
}
expect(f.end('cancelled', 'nested-child')).toEqual([])
expect(agents(f.end('end_turn'))).toMatchObject([{ id: 'child', state: 'completed' }])
expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }])
expect(f.end('end_turn')).toEqual([])
expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'succeeded' }])
})
it('ignores replay and loading even when a loading notice explicitly says it is live', () => {
@@ -181,7 +181,9 @@ describe('owned Grok child-session completion', () => {
expect(f.end('cancelled')).toEqual([])
expect(f.end('cancelled', 'child', { isReplay: false })).toEqual([])
f.lane.finishLoad()
expect(agents(f.end('end_turn'))).toMatchObject([{ id: 'child', state: 'completed' }])
expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }])
expect(f.end('end_turn')).toEqual([])
expect(f.host()).toMatchObject([{ providerId: 'child', outcome: 'succeeded' }])
f.lane.dispose()
expect(f.end('cancelled')).toEqual([])
})
@@ -192,6 +194,7 @@ describe('owned Grok child-session completion', () => {
notification: GROK_ACP_DIALECT.notification
})
expect(f.end('cancelled')).toEqual([])
expect(f.host()).toMatchObject([{ providerId: 'child', membership: 'live' }])
})
})
@@ -241,14 +244,15 @@ describe('recorded child-session outcome during a full parent Stop', () => {
await hosted.rows()
vi.setSystemTime(1791496678726)
agent.notify(selectedFinished.method, selectedFinished.params)
const rosters = async () =>
// One saved row per subagent, which the host writes from its records.
const subagents = async () =>
(await hosted.rows()).flatMap((row) =>
row.body.kind === 'message'
? row.body.blocks.filter(isSubagentGroupBlock).map((group) => ({ row, group }))
? row.body.blocks.filter(isSubagentBlock).map((block) => ({ row, block }))
: []
)
const [original] = await rosters()
expect(original.group.agents).toMatchObject([
const original = await subagents()
expect(original.map(({ block }) => block)).toMatchObject([
{ id: 'subagent-1', state: 'stopped' },
{ id: 'subagent-2', state: 'working' }
])
@@ -272,16 +276,18 @@ describe('recorded child-session outcome during a full parent Stop', () => {
})
expect(await stop(hosted.host)).toMatchObject({ ok: true, value: { cancelled: true } })
await vi.advanceTimersByTimeAsync(17)
const [beforeParentResult] = await rosters()
expect.soft(beforeParentResult.row.itemId).toBe(original.row.itemId)
expect.soft(beforeParentResult.row.turnScope).toEqual(original.row.turnScope)
const beforeParentResult = await subagents()
expect
.soft(beforeParentResult.group.agents)
.toEqual([
original.group.agents[0],
reason === 'cancelled'
? { ...original.group.agents[1], state: 'stopped', settledAt: 1791496686302 }
: original.group.agents[1]
.soft(beforeParentResult.map(({ row }) => row.itemId))
.toEqual(original.map(({ row }) => row.itemId))
expect
.soft(beforeParentResult.map(({ row }) => row.turnScope))
.toEqual(original.map(({ row }) => row.turnScope))
expect
.soft(beforeParentResult.map(({ block }) => block))
.toMatchObject([
original[0].block,
reason === 'cancelled' ? { id: 'subagent-2', state: 'stopped' } : original[1].block
])
expect.soft((await hosted.turns()).at(-1)).toMatchObject({ state: 'running' })
expect.soft(child.closes).toBe(0)
@@ -297,8 +303,8 @@ describe('recorded child-session outcome during a full parent Stop', () => {
state: 'interrupted',
outcome: 'cancellation'
})
const [settled] = await rosters()
expect(settled.group.agents).toMatchObject([
const settled = await subagents()
expect(settled.map(({ block }) => block)).toMatchObject([
{ id: 'subagent-1', state: 'stopped' },
{ id: 'subagent-2', state }
])
+28 -11
View File
@@ -2,10 +2,18 @@ import type { AcpStructuredSession } from './acp-structured-session'
import type { AcpStructuredConnection } from './acp-structured-connection'
import type { AcpChildStops, AcpDialect } from './acp-dialects/acp-dialect'
import { AcpAgentError } from './acp-errors'
import type { AgentSessionBackgroundTaskStops } from '../../shared/agent-child-work-stop-targets'
import type {
AgentChildWorkStopAcknowledgement,
AgentSessionBackgroundTaskStops
} from '../../shared/agent-child-work-stop-targets'
import { acpBackgroundTaskStopId } from './acp-background-task-child-work'
import { acpSubagentOutcome } from './acp-subagents'
type ChildStopResult = { cancelled: boolean; stillRunning?: true }
type ChildStopResult = {
cancelled: boolean
stillRunning?: true
acknowledged?: AgentChildWorkStopAcknowledgement
}
type SubagentStop = NonNullable<AcpDialect['subagentStop']>
type BackgroundTaskStop = NonNullable<AcpDialect['backgroundTaskStop']>
@@ -72,8 +80,7 @@ export async function probeAcpChildStops(
async function stopAcpSubagent(
session: AcpStructuredSession,
control: SubagentStop,
id: string,
at: () => number
id: string
): Promise<ChildStopResult> {
const request = control.request(session.lane.translator.providerSessionId, id)
let value: unknown
@@ -87,10 +94,17 @@ async function stopAcpSubagent(
throw error
}
const result = control.response(value, id)
if (result.state && result.state !== 'working') {
session.lane.apply(session.lane.translator.reconcileSubagent(id, result.state, at()))
if (result.state === 'working') {
return { cancelled: result.cancelled }
}
return { cancelled: result.cancelled }
// Not found is no acceptance; an answer that names how the subagent finished is its own ending.
const outcome = result.state === undefined ? undefined : acpSubagentOutcome(result.state)
return result.cancelled || outcome !== undefined
? {
cancelled: result.cancelled,
acknowledged: { taskId: id, ...(outcome !== undefined ? { outcome } : {}) }
}
: { cancelled: false }
}
async function stopAcpBackgroundTask(
@@ -126,8 +140,8 @@ export async function stopAcpChildren(
session: AcpStructuredSession,
fence: number,
taskIds: readonly string[],
at: () => number
): Promise<ChildStopResult> {
acknowledge: (acknowledgement: AgentChildWorkStopAcknowledgement) => void = () => {}
): Promise<{ cancelled: boolean; stillRunning?: true }> {
const subagentStop = session.childStops?.subagents ? session.spec.dialect.subagentStop : undefined
const taskStop = session.childStops?.backgroundTasks
? session.spec.dialect.backgroundTaskStop
@@ -141,14 +155,17 @@ export async function stopAcpChildren(
let stillRunning = false
for (const id of taskIds) {
const taskId = acpBackgroundTaskStopId(id)
const result =
const result: ChildStopResult =
taskId !== undefined && taskStop
? await stopAcpBackgroundTask(session, taskStop, taskId)
: subagentStop
? await stopAcpSubagent(session, subagentStop, id, at)
? await stopAcpSubagent(session, subagentStop, id)
: { cancelled: false }
cancelled ||= result.cancelled
stillRunning ||= result.stillRunning === true
if (result.acknowledged) {
acknowledge(result.acknowledged)
}
}
return { cancelled, ...(stillRunning ? { stillRunning: true as const } : {}) }
}
@@ -1,14 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body'
import {
closeProviderTimelineRigs,
openProviderTimelineRig,
providerTurnItemId,
refusingSink
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event'
import { AcpStructuredLane } from './acp-structured-lane'
import type { AcpTimelineEvent } from './acp-timeline-event'
import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect'
import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan'
@@ -16,10 +14,6 @@ import {
recordingStructuredAgentSessionLogger,
testEventSinkLogging
} from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { agentJournalTurnBody } from '../../shared/agent-session-turn-record'
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
import { AgentHookServer } from '../agent-hooks/server'
const lanes: AcpStructuredLane[] = []
afterEach(async () => {
@@ -27,14 +21,17 @@ afterEach(async () => {
await closeProviderTimelineRigs()
})
function roster(state: 'working' | 'completed'): ProviderTimelineEvent {
return {
type: 'item.update',
item: 'subagents:turn',
body: subagentGroupJournalBody('turn', [
{ id: 'child', label: 'Exact label', state, startedAt: 1 }
])
}
/** What Grok says about one subagent, translated as the adapter would. */
function subagent(lane: AcpStructuredLane, state: 'working' | 'completed'): AcpTimelineEvent[] {
const update =
state === 'working'
? { sessionUpdate: 'subagent_spawned', subagent_id: 'child', description: 'Exact label' }
: { sessionUpdate: 'subagent_finished', subagent_id: 'child', status: 'completed' }
return lane.translator.notification(
'_x.ai/session/update',
{ sessionId: 'provider-session-1', update },
1
)
}
async function fixture(reason: 'backpressure' | 'failed' = 'backpressure') {
@@ -60,101 +57,7 @@ async function fixture(reason: 'backpressure' | 'failed' = 'backpressure') {
return { rig, lane, refused, delivery, failed }
}
describe('ACP roster evidence at the ordered journal boundary', () => {
it('does not settle host child work from a preliminary parent settlement while its roster write is refused', async () => {
const rig = await openProviderTimelineRig()
const server = new AgentHookServer()
const subject = makeStructuredAgentStatusSubject(
{
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'folder'
},
'session-timeline'
)
server.ingestStructuredStatus(
{
sessionId: subject.sessionId,
workspaceId: subject.workspaceId,
agent: 'grok',
status: 'working',
hostExecutionOwned: true,
latestPrompt: 'Delegate',
updatedAt: 1
},
subject
)
let refuseRoster = false
const internalSettlements = vi.fn()
const delivery = vi.fn((evidence: AgentChildWorkEvidence[]) =>
server.ingestStructuredChildWork(subject, evidence, 'grok')
)
const lane = new AcpStructuredLane({
sink: {
...rig.sink,
tryAppendTransition: (transition) => {
if (refuseRoster && !transition.lifecycle) {
return { accepted: false, reason: 'backpressure' }
}
if (refuseRoster && transition.lifecycle) {
internalSettlements()
}
return rig.sink.tryAppendTransition(transition)
}
},
sessionId: 'session-timeline',
providerSessionId: 'provider-session-1',
agent: 'grok',
agentName: 'Grok',
generation: 'gen-1',
dialect: GROK_ACP_DIALECT,
logger: recordingStructuredAgentSessionLogger().logger,
onInputAccepted: () => {},
onFailed: () => {},
onChildWorkEvidence: delivery,
now: () => 10
})
lanes.push(lane)
lane.apply([{ type: 'turn.open', turn: 'turn', at: 1 }, roster('working')])
await rig.rows()
const running = await rig.turn('turn')
const identity = parseAgentJournalItemKey(providerTurnItemId('turn'))
if (!running || !identity) {
throw new Error('Missing running parent turn')
}
await rig.journal.appendItem(
identity,
agentJournalTurnBody({ ...running, state: 'interrupted', completedAt: 2 }),
{ fence: 1, turnScope: { kind: 'thread' } }
)
expect(lane.openTurnId).not.toBeNull()
refuseRoster = true
lane.apply([roster('completed')])
const heldRows = await rig.rows()
expect(internalSettlements).toHaveBeenCalledTimes(1)
expect(lane.openTurnId).toBeNull()
expect(
heldRows.some(
(row) =>
row.body.kind === 'message' &&
row.body.blocks.some(
(block) => block.type === 'subagent-group' && block.agents[0]?.state === 'working'
)
)
).toBe(true)
expect(server.getStructuredChildWorkViews(subject)).toMatchObject([
{ membership: 'live', state: 'working' }
])
expect(delivery).toHaveBeenCalledTimes(1)
refuseRoster = false
lane.retry()
await rig.rows()
expect(server.getStructuredChildWorkViews(subject)).toMatchObject([
{ membership: 'settled', outcome: 'succeeded' }
])
expect(delivery).toHaveBeenCalledTimes(2)
})
describe('ACP subagent evidence at the ordered journal boundary', () => {
it('delivers acquisition-time snapshots only after the journal binds and publishes the parent', async () => {
const rig = await openProviderTimelineRig()
const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging())
@@ -217,7 +120,8 @@ describe('ACP roster evidence at the ordered journal boundary', () => {
it('holds working and finished snapshots in order across backpressure and publishes each once', async () => {
const { rig, lane, refused, delivery } = await fixture()
refused.value = true
lane.apply([roster('working'), roster('completed')])
lane.apply(subagent(lane, 'working'))
lane.apply(subagent(lane, 'completed'))
await rig.rows()
expect(delivery).toEqual([])
refused.value = false
@@ -231,16 +135,16 @@ describe('ACP roster evidence at the ordered journal boundary', () => {
expect(delivery).toHaveLength(2)
})
it('publishes no roster facts for fatal refusal, disposed held events or events dropped after session end', async () => {
it('publishes no subagent facts for fatal refusal, disposed held events or events dropped after session end', async () => {
const fatal = await fixture('failed')
fatal.refused.value = true
fatal.lane.apply([roster('working')])
fatal.lane.apply(subagent(fatal.lane, 'working'))
await fatal.rig.rows()
expect(fatal.failed).toHaveBeenCalledWith('failed')
expect(fatal.delivery).toEqual([])
const held = await fixture()
held.refused.value = true
held.lane.apply([roster('working')])
held.lane.apply(subagent(held.lane, 'working'))
held.lane.dispose()
held.refused.value = false
held.lane.retry()
@@ -251,7 +155,7 @@ describe('ACP roster evidence at the ordered journal boundary', () => {
{ type: 'session.ended', verdict: { state: 'interrupted', completedAt: 10 } }
])
await ended.rig.rows()
ended.lane.apply([roster('working')])
ended.lane.apply(subagent(ended.lane, 'working'))
await ended.rig.rows()
expect(ended.delivery.flat().map((edge) => edge.type)).toEqual(['session-ended'])
})
@@ -277,21 +181,23 @@ describe('ACP roster evidence at the ordered journal boundary', () => {
})
lane.dispose()
lanes.push(throwing)
throwing.apply([roster('working'), roster('completed')])
throwing.apply(subagent(throwing, 'working'))
throwing.apply(subagent(throwing, 'completed'))
throwing.apply([{ type: 'turn.open', turn: 'turn', at: 2 }])
expect(await rig.rows()).toHaveLength(1)
expect(failure).toHaveBeenCalledTimes(2)
})
it('releases live ownership when session-end admission is held and disposal ends observation', async () => {
const { rig, lane, refused, delivery } = await fixture()
lane.apply([roster('working')])
lane.apply(subagent(lane, 'working'))
await rig.rows()
refused.value = true
lane.apply([{ type: 'session.ended', verdict: { state: 'interrupted', completedAt: 20 } }])
lane.dispose()
await rig.rows()
expect(delivery.flat().map((edge) => edge.type)).toEqual(['live', 'session-ended'])
lane.apply([roster('working')])
lane.apply(subagent(lane, 'working'))
lane.retry()
await rig.rows()
expect(delivery.flat().map((edge) => edge.type)).toEqual(['live', 'session-ended'])
@@ -1,5 +1,9 @@
import { AgentHookServer } from '../agent-hooks/server'
import type { AgentStatusStructuredSessionSubject } from '../../shared/agent-status-subject'
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import {
makeStructuredAgentStatusSubject,
type AgentStatusStructuredSessionSubject
} from '../../shared/agent-status-subject'
import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-ownership'
export function acpChildWorkStatusSink() {
@@ -13,7 +17,39 @@ export function acpChildWorkStatusSink() {
forget: (subject) => server.dropStructuredStatus(subject),
publishChildWork: (subject, evidence, provider) =>
server.ingestStructuredChildWork(subject, evidence, provider),
readChildWork: (subject) => server.getStructuredChildWorkViews(subject)
readChildWork: (subject) => server.getStructuredChildWorkViews(subject),
readChildWorkRecords: (subject) => server.getStructuredChildWork(subject)
}
return { sink, views: () => (parent ? server.getStructuredChildWorkViews(parent) : []) }
}
/** A host holding one working Grok parent: what a translator reported becomes its child records,
* the one authority on each subagent. */
export function acpSubagentRecords() {
const { sink, views } = acpChildWorkStatusSink()
const subject = makeStructuredAgentStatusSubject(
{
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'folder'
},
'session-timeline'
)
sink.publish(
{
sessionId: subject.sessionId,
workspaceId: subject.workspaceId,
agent: 'grok',
status: 'working',
hostExecutionOwned: true,
latestPrompt: 'Delegate',
updatedAt: 1
},
subject
)
return (evidence: AgentChildWorkEvidence[]) => {
sink.publishChildWork?.(subject, evidence, 'grok')
return views()
}
}
+15 -12
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { _internals } from '../agent-hooks/server'
import { isSubagentGroupBlock } from '../../shared/native-chat-types'
import { isSubagentBlock } from '../../shared/native-chat-types'
import type { AgentChildWorkView } from '../../shared/agent-status-child-work-view'
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import {
@@ -89,9 +89,7 @@ async function fixture() {
(await hosted.host.history({ sessionId: SESSION, direction: 'tail' })).page.backgroundTasks
const roster = async () =>
(await hosted.rows()).flatMap((row) =>
row.body.kind === 'message'
? row.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents)
: []
row.body.kind === 'message' ? row.body.blocks.filter(isSubagentBlock) : []
)
const targetedStop = (taskId: string) => {
const fields = { turnId: 'background-tasks', scope: 'background-tasks' as const, taskId }
@@ -154,14 +152,15 @@ describe('Grok roster evidence through the existing host child store', () => {
).toBeNull()
})
it('shares exact ordinal labels between roster, agent strip and sidebar after parent completion', async () => {
it('shares one label and one id between saved rows, agent strip and sidebar after parent completion', async () => {
const f = await fixture()
await f.spawn('first')
await f.spawn('sibling')
expect(await f.roster()).toMatchObject([{ label: 'Exact label' }, { label: 'Exact label 2' }])
// Each saved row keeps the raw label; the shared presentation derives the ordinal, as the strip does.
expect(await f.roster()).toMatchObject([{ label: 'Exact label' }, { label: 'Exact label' }])
expect((await f.strip())?.tasks).toMatchObject([
{ id: 'first', kind: 'agent', description: 'Exact label' },
{ id: 'sibling', kind: 'agent', description: 'Exact label 2' }
{ id: 'sibling', kind: 'agent', description: 'Exact label' }
])
const stripIds = (await f.strip())?.children?.map((view) => view.id)
expect(f.sidebar.at(-1)?.map((view) => view.id)).toEqual(stripIds)
@@ -189,7 +188,11 @@ describe('Grok roster evidence through the existing host child store', () => {
})
})
expect(await f.targetedStop('first')).toMatchObject({ ok: true, value: { cancelled: true } })
expect(f.childWork.views().every((view) => view.membership === 'live')).toBe(true)
// The accepted Stop settles that run until Grok's own ending says how it ended.
expect(f.childWork.views()).toMatchObject([
{ providerId: 'first', membership: 'settled', outcome: 'cancelled' },
{ providerId: 'sibling', membership: 'live' }
])
expect(f.agent.frames.filter((frame) => frame.method === 'session/cancel')).toEqual([])
await f.finish('first', 'cancelled')
expect((await f.roster())[0].state).toBe('stopped')
@@ -277,7 +280,7 @@ describe('Grok roster evidence through the existing host child store', () => {
await f.spawn(first, 'First', 'turn-A')
await f.spawn(second, 'Second', 'turn-B')
const entries = await f.roster()
expect(entries[0].id).toBe(entries[1].id)
expect(entries[0].id).not.toBe(entries[1].id)
const bothStrip = (await f.strip())?.tasks
const bothSidebar = f.sidebar.at(-1)
await f.finish(first, 'completed')
@@ -301,7 +304,7 @@ describe('Grok roster evidence through the existing host child store', () => {
{ description: entry.label, kind: 'agent', stoppable: false }
])
expect((await f.strip())?.tasks?.[0]?.id).toMatch(/^acp-child:[0-9a-f]{64}$/)
expect((await f.strip())?.tasks?.[0]?.id).not.toBe(entry.id)
expect((await f.strip())?.tasks?.[0]?.id).toBe(entry.id)
})
it('keeps 34 live groups in both host surfaces and never recreates the first settled eviction', async () => {
@@ -403,10 +406,10 @@ describe('Grok roster evidence through the existing host child store', () => {
}
)
it('records a terminal first snapshot without leaving either running surface occupied', async () => {
it('records nothing for a subagent first reported finished, and occupies no running surface', async () => {
const f = await fixture()
await f.finish('finished-before-spawn', 'completed')
expect(await f.roster()).toMatchObject([{ id: 'finished-before-spawn', state: 'completed' }])
expect(await f.roster()).toEqual([])
expect(f.childWork.views()).toEqual([])
expect(await f.strip()).toBeNull()
})
@@ -1,10 +1,11 @@
// The adapter against Grok's recorded subagents (Grok 1.0.46): each recording plays back as the
// agent, and the assertions read the roster and reply rows a client would see.
// agent, and the assertions read what it reports to the host about each subagent and the reply
// rows a client would see.
import { afterEach, describe, expect, it } from 'vitest'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
import { isBackgroundTaskBlock, isSubagentGroupBlock } from '../../shared/native-chat-types'
import { isBackgroundTaskBlock } from '../../shared/native-chat-types'
import {
closeProviderTimelineRigs,
SESSION
@@ -43,12 +44,32 @@ function send(rig: AcpAdapterRig, clientMessageId: string) {
return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: ask, fence: 1 })
}
async function subagents(rig: AcpAdapterRig) {
return (await rig.rig.rows()).flatMap((row) =>
row.body.kind === 'message'
? row.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents)
: []
)
type Reported = { id: string; label?: string; outcome?: string; tokens?: number }
/** Each subagent as the adapter last reported it to the host. */
function subagents(evidence: readonly AgentChildWorkEvidence[]): Reported[] {
const reported = new Map<string, Reported>()
for (const edge of evidence) {
const handle =
edge.type === 'live' ? edge.child.handle : edge.type === 'ended' ? edge.handle : null
if (!handle) {
continue
}
const prior = reported.get(handle.id) ?? { id: handle.id }
const tokens =
edge.type === 'live'
? edge.child.totalTokens
: edge.type === 'ended'
? edge.totalTokens
: undefined
reported.set(handle.id, {
...prior,
...(edge.type === 'live' && edge.child.description ? { label: edge.child.description } : {}),
...(edge.type === 'ended' ? { outcome: edge.outcome } : {}),
...(tokens !== undefined ? { tokens } : {})
})
}
return [...reported.values()]
}
async function replies(rig: AcpAdapterRig) {
@@ -77,15 +98,15 @@ async function stop(rig: AcpAdapterRig, replay: GrokFixtureReplay) {
}
describe('Grok subagent recordings through the adapter', () => {
it('a foreground subagent completes in the roster and its reply opens under it', async () => {
it('a foreground subagent completes and its reply opens under it', async () => {
const { rig, replay, evidence } = await replaying('s7-subagent-foreground')
await send(rig, 'send-1')
await waitFor(() => expect(replay.awaiting).toBeNull())
await waitFor(async () =>
expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }])
)
expect(await subagents(rig)).toMatchObject([
{ id: 'subagent-1', label: 'Count note lines', state: 'completed', tokens: 13778 }
expect(subagents(evidence)).toMatchObject([
{ id: 'subagent-1', label: 'Count note lines', outcome: 'succeeded', tokens: 13778 }
])
expect(await replies(rig)).toEqual([
{ agentId: 'subagent-1', blocks: [{ type: 'text', text: '4' }] }
@@ -101,13 +122,13 @@ describe('Grok subagent recordings through the adapter', () => {
)
})
it('a background subagent completes in the roster and stays out of the background tasks', async () => {
it('a background subagent completes and stays out of the background tasks', async () => {
const { rig, replay, evidence } = await replaying('s7-subagent-background')
await send(rig, 'send-1')
await waitFor(() => expect(replay.awaiting).toBeNull())
await waitFor(async () =>
expect(await subagents(rig)).toMatchObject([
{ id: 'subagent-1', label: 'List folder files', state: 'completed' }
await waitFor(() =>
expect(subagents(evidence)).toMatchObject([
{ id: 'subagent-1', label: 'List folder files', outcome: 'succeeded' }
])
)
expect(await replies(rig)).toEqual([
@@ -126,12 +147,12 @@ describe('Grok subagent recordings through the adapter', () => {
})
it('Stop while a foreground and then a background subagent runs ends each stopped', async () => {
const { rig, replay } = await replaying('s7-subagent-stop')
const { rig, replay, evidence } = await replaying('s7-subagent-stop')
await send(rig, 'send-1')
await stop(rig, replay)
await waitFor(() => expect(replay.awaiting).toBe('session/prompt'))
await waitFor(async () =>
expect(await subagents(rig)).toMatchObject([{ label: 'Slow sleeper', state: 'stopped' }])
await waitFor(() =>
expect(subagents(evidence)).toMatchObject([{ label: 'Slow sleeper', outcome: 'cancelled' }])
)
await send(rig, 'send-2')
await stop(rig, replay)
@@ -145,9 +166,9 @@ describe('Grok subagent recordings through the adapter', () => {
'success'
])
)
expect(await subagents(rig)).toMatchObject([
{ id: 'subagent-1', label: 'Slow sleeper', state: 'stopped' },
{ id: 'subagent-2', label: 'Background sleeper', state: 'stopped' }
expect(subagents(evidence)).toMatchObject([
{ id: 'subagent-1', label: 'Slow sleeper', outcome: 'cancelled' },
{ id: 'subagent-2', label: 'Background sleeper', outcome: 'cancelled' }
])
expect(await replies(rig)).toEqual([])
expect(await backgroundTasks(rig)).toEqual([])
@@ -3,7 +3,7 @@ import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import type { PipedChildProcess, PipedProcessSpawner } from '@orca/process-host/process-spec'
import { isSubagentGroupBlock } from '../../shared/native-chat-types'
import { isSubagentBlock } from '../../shared/native-chat-types'
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import type { terminateProviderProcessTree } from '../provider-process/provider-process-teardown'
@@ -100,7 +100,7 @@ async function stoppingChild(reportsOutcome: boolean) {
const rosters = async () =>
(await hosted.rows()).flatMap((row) =>
row.body.kind === 'message'
? row.body.blocks.filter(isSubagentGroupBlock).map((group) => ({ row, group }))
? row.body.blocks.filter(isSubagentBlock).map((block) => ({ row, block }))
: []
)
const original = (await rosters())[0]
@@ -125,7 +125,7 @@ describe('Grok child outcome during a full host Stop', () => {
await vi.advanceTimersByTimeAsync(TURN_ANSWER_AFTER_STOP_MS)
expect(fixture.closeAt).toEqual([STOP_AT + TURN_ANSWER_AFTER_STOP_MS])
expect((await fixture.turns()).at(-1)).toMatchObject({ state: 'interrupted' })
expect((await fixture.rosters())[0].group.agents[0].state).toBe('working')
expect((await fixture.rosters())[0].block.state).toBe('working')
expect(fixture.childWork.views()).toMatchObject([
{ membership: 'live', providerId: 'subagent-1' }
])
@@ -133,18 +133,16 @@ describe('Grok child outcome during a full host Stop', () => {
const [cancelled] = await fixture.rosters()
expect(cancelled.row.itemId).toBe(fixture.original.row.itemId)
expect(cancelled.row.turnScope).toEqual(fixture.original.row.turnScope)
expect(cancelled.group.agents).toEqual([
{
...fixture.original.group.agents[0],
state: 'stopped',
settledAt: STOP_AT + 233
}
])
expect(cancelled.block).toEqual({
...fixture.original.block,
state: 'stopped',
settledAt: STOP_AT + 233
})
await vi.advanceTimersByTimeAsync(EXIT_AFTER_STOP_MS - 233)
expect(fixture.childWork.views()).toMatchObject([
{ membership: 'settled', outcome: 'cancelled' }
])
expect((await fixture.rosters())[0].group).toEqual(cancelled.group)
expect((await fixture.rosters())[0].block).toEqual(cancelled.block)
const settled = await fixture.rows()
fixture.agent.notify(fixture.finished.method, fixture.finished.params)
expect(await fixture.rows()).toEqual(settled)
@@ -158,11 +156,13 @@ describe('Grok child outcome during a full host Stop', () => {
expect(fixture.childWork.views()).toMatchObject([{ membership: 'settled', outcome: 'unknown' }])
expect(unavailable.row.itemId).toBe(fixture.original.row.itemId)
// Contact was lost when the session ended, a moment the host observed.
expect(unavailable.group.agents).toEqual([
{ ...fixture.original.group.agents[0], state: 'unverifiable', settledAt: expect.any(Number) }
])
expect(unavailable.block).toEqual({
...fixture.original.block,
state: 'unverifiable',
settledAt: expect.any(Number)
})
fixture.agent.notify(fixture.finished.method, fixture.finished.params)
expect((await fixture.rosters())[0].group).toEqual(unavailable.group)
expect((await fixture.rosters())[0].block).toEqual(unavailable.block)
await fixture.host.close(SESSION, 'user-close')
})
})
+67 -19
View File
@@ -13,7 +13,6 @@ import type { ProviderTimelineSink } from '../native-chat/agent-session-timeline
import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { AcpChildStops, AcpDialect } from './acp-dialects/acp-dialect'
import { AcpTimelineTranslator } from './acp-timeline-translator'
import { acpSubagentChildWork, isAcpSubagentChildWorkEvent } from './acp-subagent-child-work'
import {
acpBackgroundTaskChildWork,
isAcpBackgroundTaskChildWorkEvent
@@ -29,6 +28,10 @@ import type { AcpTextDrop } from './acp-turn-messages'
/** How long a held event waits for the sink to say it drained before trying again on its own. */
const BACKPRESSURE_RETRY_MS = 250
/** Subagent evidence holds its place among the events: it reaches the host only after the journal
* wrote what came before it. */
type SubagentWork = { type: 'subagent.work'; evidence: AgentChildWorkEvidence[] }
export type AcpStructuredLaneDeps = {
sink: ProviderTimelineSink
sessionId: string
@@ -52,11 +55,12 @@ export type AcpStructuredLaneDeps = {
export class AcpStructuredLane {
readonly translator: AcpTimelineTranslator
private readonly assembler: ProviderTimelineAssembler
private readonly backlog: AcpTimelineEvent[] = []
private readonly backlog: (AcpTimelineEvent | SubagentWork)[] = []
private readonly turnWatchers = new Set<() => void>()
private readonly requestIdentity: ReturnType<typeof createLegacyProviderTimelineIdentityScheme>
private retryTimer: ReturnType<typeof setTimeout> | null = null
private failed = false
private ended = false
private disposed = false
private readonly logger: StructuredAgentSessionLogger
@@ -102,6 +106,10 @@ export class AcpStructuredLane {
return
}
this.backlog.push(...events)
const evidence = this.translator.subagents.drainEvidence()
if (evidence.length > 0) {
this.backlog.push({ type: 'subagent.work', evidence })
}
this.drain()
}
@@ -165,11 +173,20 @@ export class AcpStructuredLane {
this.clearRetry()
while (this.backlog.length > 0 && !this.failed && !this.disposed) {
const event = this.backlog[0]
if (event.type === 'subagent.work') {
if (!this.queueSubagentWork(event.evidence)) {
return
}
this.backlog.shift()
continue
}
const { admission, dropped } = this.assembler.apply(
event,
isAcpSubagentChildWorkEvent(event) || isAcpBackgroundTaskChildWorkEvent(event)
? () => this.publishChildWork(event)
: undefined
event.type === 'session.ended'
? () => this.publishSessionEnd()
: isAcpBackgroundTaskChildWorkEvent(event)
? () => this.publishChildWork(event)
: undefined
)
if (dropped === 'stream-mismatch' && event.type === 'text.delta') {
this.reportTextDrop({
@@ -182,17 +199,12 @@ export class AcpStructuredLane {
})
}
if (!admission.accepted) {
if (admission.reason === 'backpressure') {
this.retryTimer = setTimeout(() => this.drain(), BACKPRESSURE_RETRY_MS)
return
}
this.failed = true
this.backlog.length = 0
this.notifyTurnWatchers()
this.deps.onFailed(admission.reason)
this.refused(admission.reason)
return
}
this.backlog.shift()
// A subagent the provider reports after its session ended is no live child of it.
this.ended ||= event.type === 'session.ended' && dropped === undefined
if (event.type === 'input.accepted') {
this.deps.onInputAccepted(event.clientMessageId)
}
@@ -203,14 +215,50 @@ export class AcpStructuredLane {
private publishChildWork(event: AcpTimelineEvent): void {
const observedAt = this.deps.now?.() ?? Date.now()
const stops = this.deps.childStops?.()
const evidence = [
...acpSubagentChildWork(event, observedAt, stops?.subagents === true),
...acpBackgroundTaskChildWork(event, observedAt, stops?.backgroundTasks === true)
]
if (!evidence.length) {
const evidence = acpBackgroundTaskChildWork(event, observedAt, stops?.backgroundTasks === true)
if (evidence.length > 0) {
this.publishEvidence(evidence)
}
}
private refused(reason: string): void {
if (reason === 'backpressure') {
this.retryTimer = setTimeout(() => this.drain(), BACKPRESSURE_RETRY_MS)
return
}
this.publishEvidence(evidence)
this.failed = true
this.backlog.length = 0
this.notifyTurnWatchers()
this.deps.onFailed(reason)
}
/** False when the sink held it back; it stays first in the backlog. */
private queueSubagentWork(evidence: AgentChildWorkEvidence[]): boolean {
if (this.ended) {
return true
}
const admission = this.deps.sink.tryAppendTransition({
steps: [],
lifecycle: false,
publish: false,
onPublished: () => this.publishSubagentWork(evidence)
})
if (!admission.accepted) {
this.refused(admission.reason)
}
return admission.accepted
}
/** A subagent's stop is offered only once the agent proved it honours one. */
private publishSubagentWork(evidence: AgentChildWorkEvidence[]): void {
const stoppable = this.deps.childStops?.().subagents === true
this.publishEvidence(
evidence.map((edge) =>
edge.type === 'live'
? { ...edge, child: { ...edge.child, stoppable: stoppable && edge.child.stoppable } }
: edge
)
)
}
private publishEvidence(evidence: AgentChildWorkEvidence[]): void {
@@ -235,7 +235,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
stopBackgroundTasks: NonNullable<StructuredAgentSessionAdapter['stopBackgroundTasks']> = (
input
) => stopAcpChildren(this.live(input.sessionId), input.fence, input.taskIds, () => this.now())
) => stopAcpChildren(this.live(input.sessionId), input.fence, input.taskIds, input.acknowledge)
backgroundTaskStops: NonNullable<StructuredAgentSessionAdapter['backgroundTaskStops']> = (
sessionId
-78
View File
@@ -1,78 +0,0 @@
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import type { AgentChildWorkOutcome } from '../../shared/agent-status-child-work'
import { isSubagentGroupBlock } from '../../shared/native-chat-types'
import type { AcpTimelineEvent } from './acp-timeline-event'
import { createHash } from 'node:crypto'
import { AGENT_CHILD_WORK_ID_MAX_LENGTH } from '../../shared/agent-status-child-work-value-guards'
export function isAcpSubagentChildWorkEvent(event: AcpTimelineEvent): boolean {
return (
event.type === 'session.ended' ||
(['item.open', 'item.update', 'item.close'].includes(event.type) &&
'body' in event &&
event.body.kind === 'message' &&
event.body.blocks.some(isSubagentGroupBlock))
)
}
/** Project the admitted snapshot, never the translator's potentially newer roster. */
export function acpSubagentChildWork(
event: AcpTimelineEvent,
observedAt: number,
stoppable: boolean
): AgentChildWorkEvidence[] {
if (event.type === 'session.ended') {
return [{ type: 'session-ended', observedAt }]
}
if (
!['item.open', 'item.update', 'item.close'].includes(event.type) ||
!('body' in event) ||
event.body.kind !== 'message'
) {
return []
}
const evidence: AgentChildWorkEvidence[] = []
for (const block of event.body.blocks) {
if (!isSubagentGroupBlock(block)) {
continue
}
for (const [index, child] of block.agents.entries()) {
const identity = event.subagentIdentities?.[index] ?? child.id
const handle = {
idKind: 'task_id' as const,
id: identity,
// Invocation fences are smaller than aliases; keep the roster's full stable handle.
...(identity.length > AGENT_CHILD_WORK_ID_MAX_LENGTH
? { runId: `acp-child:${createHash('sha256').update(identity).digest('hex')}` }
: {})
}
if (child.state === 'working') {
evidence.push({
type: 'live',
observedAt,
child: {
handle,
kind: 'agent',
residency: 'background',
state: 'working',
description: child.label,
totalTokens: child.tokens,
// Display-clipped handles cannot safely address the provider's cancellation API.
stoppable: stoppable && identity === child.id && !/…~\d+$/.test(child.id)
}
})
} else {
const outcome: AgentChildWorkOutcome =
child.state === 'failed'
? 'failed'
: child.state === 'stopped'
? 'cancelled'
: child.state === 'unverifiable'
? 'unknown'
: 'succeeded'
evidence.push({ type: 'ended', observedAt, handle, outcome, totalTokens: child.tokens })
}
}
}
return evidence
}
@@ -1,115 +0,0 @@
// Golden timeline events from the ACP subagent roster for scripted update sequences, so moving it
// onto shared code cannot change a saved byte.
import { describe, expect, it } from 'vitest'
import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect'
import { AcpSubagentTimeline } from './acp-subagent-timeline'
import type { AcpTimelineEvent } from './acp-timeline-event'
function harness() {
const timeline = new AcpSubagentTimeline()
const log: AcpTimelineEvent[][] = []
let at = 1_000
return {
timeline,
log,
send: (updates: AcpSubagentUpdate[], join: { thread?: string; turn?: string }) => {
log.push(timeline.translate(updates, join, (at += 10)))
}
}
}
const JOIN = { thread: 'parent', turn: 'turn-1' }
describe('ACP subagent timeline rows (characterization)', () => {
it('one child: working, tokens, completed with its reply, replayed', () => {
const h = harness()
h.send([{ id: 'a1', label: 'Explore', state: 'working' }], JOIN)
h.send([{ id: 'a1', tokens: 0 }], JOIN)
h.send([{ id: 'a1', tokens: 200 }], JOIN)
h.send([{ id: 'a1', state: 'completed', result: 'Found it' }], JOIN)
h.send([{ id: 'a1', state: 'completed', result: 'Found it' }], JOIN)
h.send([{ id: 'a1', state: 'failed' }], JOIN)
expect(h.log).toMatchSnapshot()
})
it('children sharing a label, a provider-named ordinal, and outcomes in one batch', () => {
const h = harness()
h.send(
[
{ id: 'a1', label: 'Audit' },
{ id: 'a2', label: 'Audit' },
{ id: 'a3', label: 'Audit 2' },
{ id: 'a4' }
],
JOIN
)
h.send(
[
{ id: 'a1', state: 'stopped' },
{ id: 'a2', state: 'unverifiable' },
{ id: 'a2', state: 'completed', result: 'late' },
{ id: 'a3', state: 'failed' }
],
JOIN
)
expect(h.log).toMatchSnapshot()
})
it('knownOnly updates, turn from the update, and the no-turn group', () => {
const h = harness()
h.send([{ id: 'unknown', state: 'completed', knownOnly: true }], JOIN)
h.send([{ id: 'a1', label: 'Spawned', turn: 'turn-0' }], JOIN)
h.send([{ id: 'a2', label: 'Loose' }], { thread: 'parent' })
h.send([{ id: 'a1', state: 'stopped', knownOnly: true }], JOIN)
expect({
log: h.log,
has: ['unknown', 'a1', 'a2'].map((id) => h.timeline.has(id))
}).toMatchSnapshot()
})
it('a group past 64 children drops the rest', () => {
const h = harness()
h.send(
Array.from({ length: 66 }, (_, index): AcpSubagentUpdate => ({
id: `c${index}`,
label: 'X'
})),
JOIN
)
const last = h.log.at(-1)?.at(-1)
expect({
events: h.log.at(-1)?.length,
lastLabels:
last && 'body' in last && last.body.kind === 'message'
? JSON.stringify(last.body.blocks).slice(-300)
: null,
has: [h.timeline.has('c63'), h.timeline.has('c64')]
}).toMatchSnapshot()
})
it('more than 32 settled groups, then a late update for an evicted child', () => {
const h = harness()
for (let index = 0; index < 34; index++) {
h.send([{ id: `c${index}`, label: `Job ${index}`, state: 'completed' }], {
thread: 'parent',
turn: `turn-${index}`
})
}
h.send([{ id: 'live', label: 'Live' }], { thread: 'parent', turn: 'turn-live' })
h.send([{ id: 'c0', state: 'failed' }], JOIN)
h.send([{ id: 'c0', label: 'Job 0' }], JOIN)
expect({
tail: h.log.slice(-3),
has: [h.timeline.has('c0'), h.timeline.has('c1'), h.timeline.has('c33')]
}).toMatchSnapshot()
})
it('writes nothing after dispose', () => {
const h = harness()
h.send([{ id: 'a1', label: 'A' }], JOIN)
h.timeline.dispose()
h.send([{ id: 'a1', state: 'completed' }], JOIN)
expect({ log: h.log, has: h.timeline.has('a1') }).toMatchSnapshot()
})
})
-128
View File
@@ -1,128 +0,0 @@
import { describe, expect, it } from 'vitest'
import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary'
import { isSubagentGroupBlock, type NativeChatSubagentState } from '../../shared/native-chat-types'
import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect'
import { grokSubagentNotification } from './acp-dialects/grok-subagents'
import { AcpSubagentTimeline } from './acp-subagent-timeline'
function spawnGroups(
timeline: AcpSubagentTimeline,
count: number,
state: NativeChatSubagentState = 'working'
) {
for (let index = 0; index < count; index++) {
timeline.translate(
[{ id: `child-${index}`, turn: `turn-${index}`, state }],
{ thread: 'parent', turn: `turn-${index}` },
index
)
}
}
describe('ACP subagent retention', () => {
it('keeps every live owner beyond both former cache limits and releases newly settled groups', () => {
const timeline = new AcpSubagentTimeline()
for (let group = 0; group < 33; group++) {
timeline.translate(
Array.from({ length: 64 }, (_, child): AcpSubagentUpdate => ({
id: `${group}:${child}`,
turn: `turn-${group}`,
state: 'working'
})),
{ thread: 'parent' },
group
)
}
expect(timeline.has('0:0')).toBe(true)
expect(timeline.has('32:63')).toBe(true)
expect(timeline.retentionSizes().groups).toBe(33)
timeline.translate(
Array.from({ length: 63 }, (_, child): AcpSubagentUpdate => ({
id: `0:${child}`,
state: 'completed',
result: `Answer ${child}`
})),
{ thread: 'parent' },
100
)
expect(timeline.retentionSizes()).toMatchObject({ groups: 33, replies: 63 })
const events = timeline.translate(
[{ id: '0:63', state: 'completed', result: 'Last sibling' }],
{ thread: 'parent', turn: 'later' },
101
)
expect(events.map((event) => event.type === 'item.update' && event.join?.turn)).toEqual([
'turn-0',
'turn-0'
])
// The settled group is released with its replies; its children stay known as settled.
expect(timeline.retentionSizes()).toMatchObject({ groups: 32, replies: 0 })
expect(timeline.has('0:0')).toBe(true)
expect(timeline.translate([{ id: '0:0', state: 'working' }], {}, 102)).toEqual([])
})
it('bounds settled history and evicted child ids while keeping live groups', () => {
const timeline = new AcpSubagentTimeline()
timeline.translate([{ id: 'live', turn: 'original' }], {}, 0)
for (let group = 0; group < 65; group++) {
timeline.translate(
Array.from({ length: 64 }, (_, child): AcpSubagentUpdate => ({
id: `${group}:${child}`,
turn: `turn-${group}`,
state: 'completed',
result: 'Answer'
})),
{},
group + 1
)
}
expect(timeline.retentionSizes()).toEqual({
groups: 32,
replies: 31 * 64,
settledIdentities: 32 * 64
})
expect(timeline.has('live')).toBe(true)
expect(timeline.has('0:0')).toBe(false)
expect(timeline.has('33:0')).toBe(true)
expect(timeline.translate([{ id: '33:0', state: 'working' }], {}, 2200)).toEqual([])
})
it('bounds retained descriptions, preserving ordinary duplicate labels and clipped distinctions', () => {
const timeline = new AcpSubagentTimeline()
const description = 'A'.repeat(1024 * 1024)
const updates = ['large-1', 'large-2'].flatMap((id) => {
const update = grokSubagentNotification({
sessionUpdate: 'subagent_spawned',
subagent_id: id,
description
})
return update ? [update] : []
})
const events = timeline.translate(
[...updates, { id: 'normal-1', label: 'Review' }, { id: 'normal-2', label: 'Review' }],
{ turn: 'turn' },
0
)
const labels = events
.flatMap((event) =>
event.type === 'item.update' && event.body.kind === 'message'
? event.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents)
: []
)
.map((entry) => entry.label)
expect(labels.every((label) => label.length <= MAX_SUBAGENT_FIELD_CHARS)).toBe(true)
expect(new Set(labels).size).toBe(4)
expect(labels.slice(2)).toEqual(['Review', 'Review 2'])
})
it('drops ownership and cached replies on disposal even if the producer continues', () => {
const timeline = new AcpSubagentTimeline()
spawnGroups(timeline, 33)
timeline.translate([{ id: 'child-0', state: 'completed', result: 'Answer' }], {}, 100)
timeline.dispose()
const empty = { groups: 0, settledIdentities: 0, replies: 0 }
expect(timeline.retentionSizes()).toEqual(empty)
expect(timeline.translate([{ id: 'child-1', tokens: 9 }, { id: 'new' }], {}, 101)).toEqual([])
expect(timeline.retentionSizes()).toEqual(empty)
})
})
-133
View File
@@ -1,133 +0,0 @@
import {
boundInlineText,
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event'
import { SubagentTracker } from '../native-chat/subagent-tracker/subagent-tracker'
import type { SubagentGroup } from '../native-chat/subagent-tracker/subagent-tracker-types'
import { acpSubagentIdentity, type AcpTimelineEvent } from './acp-timeline-event'
import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect'
/** The group a subagent reported outside any turn belongs to. */
const OUTSIDE_TURN = 'thread'
/** One roster row per spawning turn, revised in place, plus each completed subagent's reply as its
* own row, filed under its id so it opens beneath its roster entry. The rows follow the shared
* subagent tracker's rules; this module only reads the dialect's updates. Lives as long as the
* provider child: a subagent an earlier run spawned is not known here, and its row is that run's. */
export class AcpSubagentTimeline {
/** Row events the tracker wrote during the current `translate`. */
private written: AcpTimelineEvent[] = []
private readonly results = new Map<string, string>()
/** The time of the updates being translated: when they were observed. */
private at = 0
private readonly tracker = new SubagentTracker<ProviderTimelineJoin>({
// The host admits the events later and retries a refused one itself.
outsideTurn: (groupId) => groupId === OUTSIDE_TURN,
port: {
write: (group, { body }) => {
if (body) {
this.written.push({
type: 'item.update',
item: `subagents:${group.groupId}`,
body,
subagentIdentities: [...group.entries.keys()].map(acpSubagentIdentity),
join: group.placement
})
}
return { accepted: true }
}
},
now: () => this.at,
onEvict: (group) => {
for (const id of group.entries.keys()) {
this.results.delete(id)
}
}
})
private disposed = false
/** Known children include recently evicted outcomes, so they cannot become background tasks. */
has(id: string): boolean {
return this.tracker.has(id)
}
/** Retention is not observable through the update API, so expose what it holds. */
retentionSizes(): { groups: number; settledIdentities: number; replies: number } {
return { ...this.tracker.sizes(), replies: this.results.size }
}
/** The journal owns session-end settlement; no later frame may reacquire working ownership. */
dispose(): void {
this.disposed = true
this.tracker.dispose()
this.results.clear()
this.written = []
}
translate(
updates: AcpSubagentUpdate[],
join: ProviderTimelineJoin,
at: number
): AcpTimelineEvent[] {
if (this.disposed) {
return []
}
const replies: AcpTimelineEvent[] = []
this.written = []
// One revision per changed row; replies are cached before the batch releases settled rows, so a
// released row's replies go with it.
this.tracker.batch(() => {
for (const update of updates) {
this.apply(update, join, at)
const located = this.tracker.locate(update.id)
if (update.result && located?.tracked.entry.state === 'completed') {
replies.push(...this.reply(update.id, located.group, update.result))
}
}
})
const events = [...this.written, ...replies]
this.written = []
return events
}
private reply(
id: string,
group: SubagentGroup<ProviderTimelineJoin>,
text: string
): AcpTimelineEvent[] {
const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
if (this.results.get(id) === bounded) {
return []
}
this.results.set(id, bounded)
return [
{
type: 'item.update',
item: `subagent-result:${id}`,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: bounded }] },
producer: { agentId: id, producerKind: 'agent' },
join: group.placement
}
]
}
private apply(update: AcpSubagentUpdate, join: ProviderTimelineJoin, at: number): void {
const turn = update.turn ?? join.turn
const placement: ProviderTimelineJoin = {
...(join.thread === undefined ? {} : { thread: join.thread }),
...(turn === undefined ? {} : { turn })
}
this.at = at
this.tracker.report({
id: update.id,
group: { id: turn ?? OUTSIDE_TURN, placement: () => placement },
announces: update.knownOnly !== true,
...(update.label !== undefined ? { label: update.label } : {}),
...(update.state !== undefined ? { state: update.state } : {}),
...(update.tokens !== undefined ? { tokens: update.tokens } : {}),
// A subagent the provider runs outlives the prompt turn that spawned it.
backgrounded: true
})
}
}
+173
View File
@@ -0,0 +1,173 @@
// An ACP agent's subagents, read as evidence for the host's child records, plus each completed
// subagent's reply as its own row.
//
// The host decides what every subagent is doing and writes its saved row; this only reads the
// dialect's updates. It lives as long as the provider child: a subagent an earlier run spawned is
// not known here.
import { createHash } from 'node:crypto'
import { BoundedMap } from '../../shared/bounded-map'
import type { AgentChildWorkOutcome } from '../../shared/agent-status-child-work'
import type {
AgentChildWorkEvidence,
AgentChildWorkEvidenceHandle
} from '../../shared/agent-status-child-work-evidence'
import { AGENT_CHILD_WORK_ID_MAX_LENGTH } from '../../shared/agent-status-child-work-value-guards'
import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary'
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import {
boundInlineText,
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import { boundSubagentLabel } from '../native-chat/agent-session-journal/journal-subagent-item'
import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event'
import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect'
import type { AcpTimelineEvent } from './acp-timeline-event'
/** Subagents remembered, so a later report about one is still known as a subagent's. */
const MAX_KNOWN_SUBAGENTS = 2048
type KnownSubagent = {
/** Where its reply row goes: the turn that spawned it. */
placement: ProviderTimelineJoin
/** The reply already written, so a repeated result writes nothing. */
reply?: string
/** How it first ended: a reply belongs only to a run that completed. */
ended?: NativeChatSubagentState
}
const OUTCOMES: Record<
Exclude<NativeChatSubagentState, 'working' | 'idle'>,
AgentChildWorkOutcome
> = {
completed: 'succeeded',
failed: 'failed',
stopped: 'cancelled',
unverifiable: 'unknown'
}
/** How a subagent's run ended, by the state the dialect read; undefined while it runs. */
export function acpSubagentOutcome(
state: NativeChatSubagentState
): AgentChildWorkOutcome | undefined {
return state === 'working' || state === 'idle' ? undefined : OUTCOMES[state]
}
export class AcpSubagents {
private readonly known = new BoundedMap<string, KnownSubagent>({
maxEntries: MAX_KNOWN_SUBAGENTS
})
private readonly pending: AgentChildWorkEvidence[] = []
/** Known children include settled ones, so they cannot become background tasks. */
has(id: string): boolean {
return this.known.has(id)
}
dispose(): void {
this.known.clear()
this.pending.length = 0
}
/** What the updates translated since the last drain said, for the host's records. */
drainEvidence(): AgentChildWorkEvidence[] {
return this.pending.splice(0)
}
translate(
updates: readonly AcpSubagentUpdate[],
join: ProviderTimelineJoin,
at: number
): AcpTimelineEvent[] {
const replies: AcpTimelineEvent[] = []
for (const update of updates) {
const known = this.known.get(update.id)
if (!known && update.knownOnly === true) {
continue
}
const turn = update.turn ?? join.turn
const subagent = known ?? {
placement: {
...(join.thread === undefined ? {} : { thread: join.thread }),
...(turn === undefined ? {} : { turn })
}
}
this.known.set(update.id, subagent)
const ending = update.state !== undefined && acpSubagentOutcome(update.state) !== undefined
// A late report about an ended run is not a new run: the host drops the run's record at the
// next send, so a live edge then would bring it back as working.
if (ending || subagent.ended === undefined) {
this.pending.push(subagentEvidence(update, at))
}
if (ending) {
subagent.ended ??= update.state
}
if (update.result && subagent.ended === 'completed') {
replies.push(...this.reply(update.id, subagent, update.result))
}
}
return replies
}
private reply(id: string, subagent: KnownSubagent, text: string): AcpTimelineEvent[] {
const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
if (subagent.reply === bounded) {
return []
}
subagent.reply = bounded
return [
{
type: 'item.update',
item: `subagent-result:${id}`,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: bounded }] },
producer: { agentId: id, producerKind: 'agent' },
join: subagent.placement
}
]
}
}
/** A subagent runs on past the prompt turn that spawned it, and the provider names no run. */
function subagentEvidence(update: AcpSubagentUpdate, observedAt: number): AgentChildWorkEvidence {
const handle = subagentHandle(update.id)
const outcome = update.state === undefined ? undefined : acpSubagentOutcome(update.state)
if (outcome === undefined) {
return {
type: 'live',
observedAt,
child: {
handle,
kind: 'agent',
residency: 'background',
state: update.state === 'idle' ? 'idle' : 'working',
// The strip and the saved row show the same label.
...(update.label !== undefined ? { description: boundSubagentLabel(update.label) } : {}),
...(update.tokens !== undefined ? { totalTokens: update.tokens } : {}),
// An id the handle had to digest cannot address the provider's cancellation.
stoppable: handle.id === update.id
}
}
}
return {
type: 'ended',
observedAt,
handle,
outcome,
...(update.tokens !== undefined ? { totalTokens: update.tokens } : {})
}
}
function subagentHandle(id: string): AgentChildWorkEvidenceHandle {
const bounded =
id.length <= MAX_SUBAGENT_FIELD_CHARS
? id
: `acp-child:${createHash('sha256').update(id).digest('hex')}`
return {
idKind: 'task_id',
id: bounded,
// Invocation fences are smaller than handles; keep the full stable handle.
...(bounded.length > AGENT_CHILD_WORK_ID_MAX_LENGTH
? { runId: `acp-child:${createHash('sha256').update(bounded).digest('hex')}` }
: {})
}
}
+1 -12
View File
@@ -1,14 +1,3 @@
import { createHash } from 'node:crypto'
import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary'
import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event'
export type AcpTimelineEvent = ProviderTimelineEvent & {
/** Event-local identities in roster order, before display clipping; never journaled. */
subagentIdentities?: readonly string[]
}
export function acpSubagentIdentity(providerId: string): string {
return providerId.length <= MAX_SUBAGENT_FIELD_CHARS
? providerId
: `acp-child:${createHash('sha256').update(providerId).digest('hex')}`
}
export type AcpTimelineEvent = ProviderTimelineEvent
@@ -11,6 +11,9 @@ import { openProviderTimelineRig } from '../native-chat/agent-session-timeline/p
import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect'
import type { AcpRequestPresentation } from './acp-dialects/acp-dialect'
import { AcpTimelineTranslator } from './acp-timeline-translator'
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import type { AgentChildWorkView } from '../../shared/agent-status-child-work-view'
import { acpSubagentRecords } from './acp-structured-child-work.test-support'
import { PromptResponseSchema } from './generated/acp-protocol.generated'
const frameSchema = z.object({
@@ -73,6 +76,15 @@ export async function openAcpFixtureRig() {
const translator = () =>
new AcpTimelineTranslator({ sessionId: providerSessionId, dialect: GROK_ACP_DIALECT })
let lane = translator()
const records = acpSubagentRecords()
// What each subagent's saved row last said: its host record, kept past the record's retirement.
const latest = new Map<string, AgentChildWorkView>()
const report = (evidence: AgentChildWorkEvidence[]) => {
for (const view of records(evidence)) {
latest.set(view.id, view)
}
}
const drain = () => report(lane.subagents.drainEvidence())
const requests = new Map<
string | number,
{ request: string; presentation: AcpRequestPresentation }
@@ -80,6 +92,16 @@ export async function openAcpFixtureRig() {
const apply = (events: ProviderTimelineEvent[]) => {
for (const event of events) {
expect(rig.assembler.apply(event).admission.accepted).toBe(true)
if (event.type === 'turn.open') {
// As the host retires finished records when the user's next send is accepted.
drain()
report([{ type: 'turn-started', observedAt: event.at }])
}
if (event.type === 'session.ended') {
// As the lane reports a session end once the journal admitted it.
drain()
report([{ type: 'session-ended', observedAt: 9_000 }])
}
}
}
const feed = async (frames: AcpFixtureFrame[]) => {
@@ -92,6 +114,7 @@ export async function openAcpFixtureRig() {
const session = z.object({ sessionId: z.string() }).parse(message.params)
if (providerSessionId !== session.sessionId) {
providerSessionId = session.sessionId
drain()
lane = translator()
}
apply(lane.openPrompt(`${frame.process}:${message.id}`, at).events)
@@ -162,8 +185,14 @@ export async function openAcpFixtureRig() {
/** A new provider child: the sweep, a new generation's assembler and a new translator. */
restart: async () => {
await rig.restart()
drain()
lane = translator()
},
/** Every subagent reported so far, as its host record last read. */
subagents: () => {
drain()
return [...latest.values()]
},
finishLoad: () => lane.finishLoad()
}
}
@@ -1,9 +1,8 @@
import { afterEach, describe, expect, it } from 'vitest'
import { isBackgroundTaskBlock, isSubagentGroupBlock } from '../../shared/native-chat-types'
import {
closeProviderTimelineRigs,
providerItemId
} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import { isBackgroundTaskBlock } from '../../shared/native-chat-types'
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { AcpStructuredLane } from './acp-structured-lane'
import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect'
import { openAcpFixtureRig } from './acp-timeline-fixture.test-support'
@@ -67,16 +66,14 @@ function laterGroups(fixture: Fixture, settled: boolean) {
}
}
async function roster(fixture: Fixture) {
return (await fixture.rig.rows()).flatMap((row) =>
row.body.kind === 'message'
? row.body.blocks.filter(isSubagentGroupBlock).map((group) => ({ row, group }))
: []
)
function record(fixture: Fixture, id: string) {
return fixture.subagents().filter((view) => view.providerId === id)
}
async function original(fixture: Fixture) {
return (await roster(fixture)).find(({ group }) => group.groupId === 'prompt:original')
/** The scope of the rows the latest turn writes. */
async function openTurnScope(fixture: Fixture) {
const turn = (await fixture.rig.rows()).findLast((row) => readAgentJournalTurn(row.body))
return turn ? { kind: 'turn', turnItemId: turn.itemId } : undefined
}
function toolResult(fixture: Fixture, kind: 'output' | 'kill', at: number) {
@@ -117,12 +114,13 @@ function toolResult(fixture: Fixture, kind: 'output' | 'kill', at: number) {
)
}
describe('Grok roster ownership after more than 32 spawning groups', () => {
describe('a Grok subagent reported on long after the turn that spawned it', () => {
it.each([false, true])(
'finishes the original durable row with siblings and reply when later groups settled=%s',
'finishes the original child and files its reply under its spawn turn when 33 later turns settled=%s',
async (settled) => {
const fixture = await openAcpFixtureRig()
openTurn(fixture, 'original', 1)
const spawnScope = await openTurnScope(fixture)
spawn(fixture, 'old', 'original', 2)
spawn(fixture, 'sibling', 'original', 3)
notify(
@@ -131,8 +129,6 @@ describe('Grok roster ownership after more than 32 spawning groups', () => {
4
)
fixture.apply(fixture.lane().promptResult('original', { stopReason: 'end_turn' }, 5))
const before = await original(fixture)
expect(before).toBeDefined()
laterGroups(fixture, settled)
openTurn(fixture, 'current', 300)
notify(
@@ -146,27 +142,17 @@ describe('Grok roster ownership after more than 32 spawning groups', () => {
},
301
)
const after = await original(fixture)
expect(after?.row.itemId).toBe(before?.row.itemId)
expect(after?.row.turnScope).toEqual(before?.row.turnScope)
expect(after?.group.agents).toEqual([
expect(record(fixture, 'old')).toMatchObject([
{
id: 'old',
label: 'Original label',
startedAt: 2,
settledAt: 301,
state: 'completed',
tokens: 12
},
{ id: 'sibling', label: 'Original label 2', startedAt: 3, settledAt: 4, state: 'completed' }
description: 'Original label',
membership: 'settled',
outcome: 'succeeded',
totalTokens: 12
}
])
expect(
(await roster(fixture)).flatMap(({ group }) =>
group.agents.filter((entry) => entry.id === 'old')
)
).toHaveLength(1)
expect(record(fixture, 'sibling')).toMatchObject([{ outcome: 'succeeded' }])
const reply = (await fixture.rig.rows()).find((row) => row.agentId === 'old')
expect(reply?.turnScope).toEqual(before?.row.turnScope)
expect(reply?.turnScope).toEqual(spawnScope)
expect(reply?.body).toMatchObject({
kind: 'message',
role: 'assistant',
@@ -182,20 +168,11 @@ describe('Grok roster ownership after more than 32 spawning groups', () => {
openTurn(fixture, 'original', 1)
spawn(fixture, 'old', 'original', 2)
fixture.apply(fixture.lane().promptResult('original', { stopReason: 'end_turn' }, 3))
const before = await original(fixture)
laterGroups(fixture, false)
openTurn(fixture, 'current', 300)
toolResult(fixture, kind, 301)
const after = await original(fixture)
expect(after?.row.itemId).toBe(before?.row.itemId)
expect(after?.group.agents).toEqual([
{
id: 'old',
label: 'Original label',
startedAt: 2,
settledAt: 301,
state: kind === 'output' ? 'completed' : 'stopped'
}
expect(record(fixture, 'old')).toMatchObject([
{ membership: 'settled', outcome: kind === 'output' ? 'succeeded' : 'cancelled' }
])
const rows = await fixture.rig.rows()
expect(
@@ -207,13 +184,12 @@ describe('Grok roster ownership after more than 32 spawning groups', () => {
}
)
it('drops late progress, repeated spawn and finish for a stopped child after its settled group is evicted', async () => {
it('keeps a stopped child stopped through late progress, a repeated spawn and a finish', async () => {
const fixture = await openAcpFixtureRig()
openTurn(fixture, 'original', 1)
spawn(fixture, 'old', 'original', 2)
toolResult(fixture, 'kill', 3)
fixture.apply(fixture.lane().promptResult('original', { stopReason: 'end_turn' }, 4))
const before = await original(fixture)
laterGroups(fixture, true)
openTurn(fixture, 'current', 300)
notify(fixture, { sessionUpdate: 'subagent_progress', subagent_id: 'old', tokens_used: 9 }, 301)
@@ -228,17 +204,13 @@ describe('Grok roster ownership after more than 32 spawning groups', () => {
},
303
)
expect(await original(fixture)).toEqual(before)
expect(
(await roster(fixture)).flatMap(({ group }) =>
group.agents.filter((entry) => entry.id === 'old')
)
).toHaveLength(1)
expect(record(fixture, 'old')).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }])
expect((await fixture.rig.rows()).filter((row) => row.agentId === 'old')).toEqual([])
})
it('lane disposal ends in-memory ownership while the journal settles a disconnected producer as unverifiable', async () => {
it('lane disposal ends observation: the host hears the session end, and nothing after it', async () => {
const fixture = await openAcpFixtureRig()
const evidence: AgentChildWorkEvidence[] = []
const lane = new AcpStructuredLane({
sink: fixture.rig.sink,
sessionId: 'session-timeline',
@@ -251,7 +223,8 @@ describe('Grok roster ownership after more than 32 spawning groups', () => {
onInputAccepted: () => {},
onFailed: (reason) => {
throw new Error(reason)
}
},
onChildWorkEvidence: (batch) => evidence.push(...batch)
})
lane.apply(
lane.translator.notification(
@@ -279,12 +252,9 @@ describe('Grok roster ownership after more than 32 spawning groups', () => {
2
)
expect(events).toEqual([])
const row = await fixture.rig.row(
providerItemId('item', 'subagents:thread', { namespace: 'session-1', thread: 'session-1' })
)
expect(
row?.body.kind === 'message' &&
row.body.blocks.filter(isSubagentGroupBlock).flatMap((group) => group.agents)
).toMatchObject([{ id: 'old', state: 'unverifiable' }])
await fixture.rig.rows()
// Disposal says the session ended once more; nothing reports the child live again.
expect(evidence.map((edge) => edge.type).slice(0, 2)).toEqual(['live', 'session-ended'])
expect(evidence.filter((edge) => edge.type === 'live')).toHaveLength(1)
})
})
@@ -1,13 +1,10 @@
// Grok's recorded subagents (Grok 1.0.46, s7 fixtures) through translation, assembly and the
// journal: each subagent is an entry in the shared roster row, and its reply is its own row.
// journal: each subagent is one host record, which the host saves as its row, and its reply is its
// own row.
import { afterEach, describe, expect, it } from 'vitest'
import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types'
import {
isBackgroundTaskBlock,
isSubagentGroupBlock,
type NativeChatSubagentEntry
} from '../../shared/native-chat-types'
import { isBackgroundTaskBlock } from '../../shared/native-chat-types'
import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support'
import { openAcpFixtureRig, readAcpFixture } from './acp-timeline-fixture.test-support'
@@ -15,15 +12,14 @@ afterEach(closeProviderTimelineRigs)
type Fixture = Awaited<ReturnType<typeof openAcpFixtureRig>>
async function rosters(fixture: Fixture) {
return (await fixture.rig.rows()).flatMap((row) => {
const group = row.body.kind === 'message' ? row.body.blocks.find(isSubagentGroupBlock) : null
return group ? [{ row, group }] : []
})
}
async function entries(fixture: Fixture): Promise<NativeChatSubagentEntry[]> {
return (await rosters(fixture)).flatMap(({ group }) => group.agents)
function subagents(fixture: Fixture) {
return fixture.subagents().map((view) => ({
id: view.providerId,
label: view.description,
membership: view.membership,
outcome: view.outcome,
tokens: view.totalTokens
}))
}
function replies(rows: AgentJournalRenderItem[]) {
@@ -54,25 +50,18 @@ async function untilSpawned() {
}
describe('Grok subagents through the shared timeline', () => {
it('a foreground subagent is one roster entry that completes, with its reply filed under it', async () => {
it('a foreground subagent is one record that completes, with its reply filed under it', async () => {
const fixture = await openAcpFixtureRig()
const rows = await fixture.feed(await readAcpFixture('s7-subagent-foreground'))
const [roster] = await rosters(fixture)
expect(roster?.group.agents).toEqual([
expect(subagents(fixture)).toEqual([
{
id: 'subagent-1',
label: 'Count note lines',
state: 'completed',
tokens: 13778,
startedAt: expect.any(Number),
settledAt: expect.any(Number)
membership: 'settled',
outcome: 'succeeded',
tokens: 13778
}
])
expect(roster?.row.body).toMatchObject({
role: 'system',
blocks: [{ type: 'text', text: 'Ran 1 subagent' }, { type: 'subagent-group' }]
})
expect(roster?.row.turnScope?.kind).toBe('turn')
expect(replies(rows)).toEqual([
{ agentId: 'subagent-1', role: 'assistant', blocks: [{ type: 'text', text: '4' }] }
])
@@ -89,8 +78,8 @@ describe('Grok subagents through the shared timeline', () => {
it('a background subagent completes from Grok, and reading its output adds no background task', async () => {
const fixture = await openAcpFixtureRig()
const rows = await fixture.feed(await readAcpFixture('s7-subagent-background'))
expect(await entries(fixture)).toMatchObject([
{ id: 'subagent-1', label: 'List folder files', state: 'completed', tokens: 13829 }
expect(subagents(fixture)).toMatchObject([
{ id: 'subagent-1', label: 'List folder files', outcome: 'succeeded', tokens: 13829 }
])
expect(replies(rows)).toEqual([
{
@@ -105,13 +94,9 @@ describe('Grok subagents through the shared timeline', () => {
it('Stop ends a foreground and a background subagent as stopped, and a later read keeps them so', async () => {
const fixture = await openAcpFixtureRig()
const rows = await fixture.feed(await readAcpFixture('s7-subagent-stop'))
expect(await entries(fixture)).toMatchObject([
{ id: 'subagent-1', label: 'Slow sleeper', state: 'stopped', tokens: 4094 },
{ id: 'subagent-2', label: 'Background sleeper', state: 'stopped', tokens: 4094 }
])
expect((await rosters(fixture)).map(({ group }) => group.groupId)).toEqual([
'prompt:p1:5',
'prompt:p1:6'
expect(subagents(fixture)).toMatchObject([
{ id: 'subagent-1', label: 'Slow sleeper', outcome: 'cancelled', tokens: 4094 },
{ id: 'subagent-2', label: 'Background sleeper', outcome: 'cancelled', tokens: 4094 }
])
expect(replies(rows)).toEqual([])
expect(backgroundTasks(rows)).toEqual([])
@@ -125,7 +110,7 @@ describe('Grok subagents through the shared timeline', () => {
it('a kill stops a running subagent without a background-task row, and nothing relights it', async () => {
const fixture = await openAcpFixtureRig()
await fixture.feed(await untilSpawned())
expect((await entries(fixture)).map((entry) => entry.state)).toEqual(['working'])
expect(subagents(fixture).map((entry) => entry.membership)).toEqual(['live'])
const lane = fixture.lane()
fixture.apply(
lane.notification(
@@ -162,8 +147,8 @@ describe('Grok subagents through the shared timeline', () => {
2002
)
)
expect(await entries(fixture)).toMatchObject([
{ id: 'subagent-1', state: 'stopped', settledAt: 2000, tokens: 7 }
expect(subagents(fixture)).toMatchObject([
{ id: 'subagent-1', membership: 'settled', outcome: 'cancelled' }
])
expect(backgroundTasks(await fixture.rig.rows())).toEqual([])
})
@@ -194,14 +179,14 @@ describe('Grok subagents through the shared timeline', () => {
2000
)
)
expect((await entries(fixture)).map((entry) => entry.id)).toEqual(['subagent-1'])
expect(subagents(fixture).map((entry) => entry.id)).toEqual(['subagent-1'])
})
it('a subagent still running when the session ends reads unverifiable', async () => {
const fixture = await openAcpFixtureRig()
await fixture.feed(await untilSpawned())
expect((await entries(fixture)).map((entry) => entry.state)).toEqual(['working'])
expect(subagents(fixture).map((entry) => entry.membership)).toEqual(['live'])
fixture.apply([{ type: 'session.ended', verdict: { state: 'unverifiable' } }])
expect((await entries(fixture)).map((entry) => entry.state)).toEqual(['unverifiable'])
expect(subagents(fixture).map((entry) => entry.outcome)).toEqual(['unknown'])
})
})
+3 -2
View File
@@ -9,7 +9,7 @@ import { acpTurnEnd, AcpPromptTurns } from './acp-prompt-turns'
import { readAcpSessionEvent, type AcpSessionEvent } from './acp-session-events'
import { translateAcpRequest } from './acp-timeline-requests'
import { ACP_SUBSTANTIVE_UPDATES, acpSessionUpdate } from './acp-session-update'
import { AcpSubagentTimeline } from './acp-subagent-timeline'
import { AcpSubagents } from './acp-subagents'
import { AcpToolTimeline } from './acp-tool-timeline'
import {
AcpTurnFailures,
@@ -39,7 +39,8 @@ export class AcpTimelineTranslator {
private readonly prompts: AcpPromptTurns
private readonly tools = new AcpToolTimeline()
private readonly backgroundTasks: AcpBackgroundTaskTimeline
private readonly subagents = new AcpSubagentTimeline()
/** Its evidence is drained by the lane, in the journal's order. */
readonly subagents = new AcpSubagents()
private readonly messages = new AcpTurnMessages()
private readonly started = new BoundedMap<string, true>({ maxEntries: 128 })
private readonly failures: AcpTurnFailures
@@ -33,7 +33,8 @@ export function agentHookRuntimeDeps(
forget: (subject) => server.dropStructuredStatus(subject),
publishChildWork: (subject, evidence, provider) =>
server.ingestStructuredChildWork(subject, evidence, provider),
readChildWork: (subject) => server.getStructuredChildWorkViews(subject)
readChildWork: (subject) => server.getStructuredChildWorkViews(subject),
readChildWorkRecords: (subject) => server.getStructuredChildWork(subject)
},
checkHookAgentPresence: (paneKey) => server.checkAgentPresence(paneKey),
reconcileAgentStatusForEndedProcess: (paneKeys) =>
@@ -3,7 +3,11 @@
// 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 {
agentChildWorkStopEvidence,
agentChildWorkStopTargets,
type AgentChildWorkStopAcknowledgement
} 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'
@@ -46,43 +50,55 @@ async function droppedFromRoster() {
])
expect(run.adapter.backgroundTaskState('session-1')).toBeNull()
expect(blocked()?.message).toBe('Stop background tasks before using this command.')
return { run, records, blocked, stopCalls }
/** A Stop as the host sends it: targets captured first, what the CLI accepted settled after. */
const stop = async (taskId?: string) => {
const targets = agentChildWorkStopTargets(records(), taskId)
const acknowledged: AgentChildWorkStopAcknowledgement[] = []
const outcome = await run.adapter.stopBackgroundTasks({
sessionId: 'session-1',
fence: 7,
taskIds: targets.map((target) => target.providerId),
acknowledge: (acknowledgement) => void acknowledged.push(acknowledgement)
})
host.ingestStructuredChildWork(
parent,
agentChildWorkStopEvidence(targets, acknowledged, 6_500),
'claude'
)
return outcome
}
const basis = () => host.getStructuredChildWork(parent).map((record) => record.outcomeBasis)
return { run, records, blocked, stopCalls, stop, basis }
}
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 { records, blocked, stopCalls, stop, basis } = await droppedFromRoster()
const outcome = await run.adapter.stopBackgroundTasks({
sessionId: 'session-1',
fence: 7,
taskIds: agentChildWorkStopTargets(records(), 'agent-1')
})
const outcome = await stop('agent-1')
expect(outcome).toEqual({ cancelled: true })
expect(outcome).toMatchObject({ 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(basis()).toEqual(['stop-acknowledged'])
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 { records, blocked, stopCalls, stop, basis } = await droppedFromRoster()
const outcome = await run.adapter.stopBackgroundTasks({
sessionId: 'session-1',
fence: 7,
taskIds: agentChildWorkStopTargets(records())
})
const outcome = await stop()
expect(outcome).toEqual({ cancelled: true })
expect(outcome).toMatchObject({ cancelled: true })
expect(stopCalls()).toEqual([{ subtype: 'stop_task', params: { taskId: 'agent-1' } }])
expect(records()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }])
expect(basis()).toEqual(['stop-acknowledged'])
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()
const { run, records, stopCalls, stop } = await droppedFromRoster()
run.replay([
{
at: 7_000,
@@ -95,25 +111,17 @@ describe('a background Stop acts on the host child records', () => {
}
])
const outcome = await run.adapter.stopBackgroundTasks({
sessionId: 'session-1',
fence: 7,
taskIds: agentChildWorkStopTargets(records(), 'agent-1')
})
const outcome = await stop('agent-1')
// The finished row offers no stop, so nothing reaches the CLI and the outcome stands.
expect(outcome).toEqual({ cancelled: false })
expect(outcome).toMatchObject({ 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')
})
const { run, records, stop } = await droppedFromRoster()
await stop('agent-1')
expect(records()).toMatchObject([{ membership: 'settled', outcome: 'cancelled' }])
// The completion the CLI wrote before its answer, which the SDK delivered after it.
@@ -0,0 +1,125 @@
// Claude CLI 2.1.280 stream-json capture, cut to the frames and fields the child-work path reads.
// Ids and prompts are replaced; the frame order and the relative clock (`at`, ms after the spawn)
// are the captured ones.
import {
result,
says,
spawn,
system,
toolResult,
type CapturedFrame
} from './claude-captured-frame-builders.test-fixture'
/** A background agent backgrounds its own 40 s shell and ends its turn; the CLI reports it
* completed, then starts it again under the same spawn call when the shell ends, and it ends again
* with its answer. CLI 2.1.280. */
export const WAKES_AFTER_ITS_SHELL: CapturedFrame[] = [
{
at: 0,
frame: spawn(
'toolu_agent',
'Agent',
{ description: 'Sleep 40 seconds then reply', run_in_background: true },
null
)
},
{
at: 15,
frame: system('task_started', {
task_id: 'agent-1',
tool_use_id: 'toolu_agent',
description: 'Sleep 40 seconds then reply',
subagent_type: 'general-purpose',
is_backgrounded: true,
task_type: 'local_agent'
})
},
{ at: 30, frame: toolResult('toolu_agent', 'Async agent launched successfully.', null) },
{ at: 1_944, frame: result('success') },
{
at: 4_343,
frame: spawn(
'toolu_shell',
'Bash',
{
command: 'sleep 40',
description: 'Wait 40 seconds in background',
run_in_background: true
},
'toolu_agent'
)
},
{
at: 4_431,
frame: system('task_started', {
task_id: 'shell-1',
owned_by_subagent: true,
tool_use_id: 'toolu_shell',
description: 'Wait 40 seconds in background',
is_backgrounded: true,
task_type: 'local_bash'
})
},
{
at: 4_441,
frame: toolResult(
'toolu_shell',
'Command running in background with ID: shell-1.',
'toolu_agent'
)
},
{
at: 11_913,
frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'completed' } })
},
{
at: 11_913,
frame: system('task_notification', {
task_id: 'agent-1',
tool_use_id: 'toolu_agent',
status: 'completed',
summary: '`sleep 40` is still running in the background.',
usage: { total_tokens: 17_928, tool_uses: 2, duration_ms: 11_901 }
})
},
{
at: 44_454,
frame: system('task_updated', { task_id: 'shell-1', patch: { status: 'completed' } })
},
{
at: 44_454,
frame: system('task_notification', {
task_id: 'shell-1',
tool_use_id: 'toolu_shell',
status: 'completed',
summary: 'Background command "Wait 40 seconds in background" completed (exit code 0)'
})
},
{
at: 44_460,
frame: system('task_started', {
task_id: 'agent-1',
tool_use_id: 'toolu_agent',
description: 'Sleep 40 seconds then reply',
subagent_type: 'general-purpose',
is_backgrounded: true,
task_type: 'local_agent'
})
},
{ at: 46_198, frame: says('agent-1-final', 'DONE', 'toolu_agent') },
{
at: 46_198,
frame: system('task_updated', { task_id: 'agent-1', patch: { status: 'completed' } })
},
{
at: 46_199,
frame: system('task_notification', {
task_id: 'agent-1',
tool_use_id: 'toolu_agent',
status: 'completed',
summary: 'DONE',
usage: { total_tokens: 18_181, tool_uses: 2, duration_ms: 46_184 }
})
}
]
@@ -145,7 +145,7 @@ describe('Claude child-work decoder', () => {
])
})
it('reports a start for an ended child as a restart, and a late start of its run as nothing', () => {
it('reports the run a completion ended waking under the same spawn call as that run again', () => {
const decoder = decoderWith(
backgroundAgent,
system('task_notification', {
@@ -155,6 +155,29 @@ describe('Claude child-work decoder', () => {
})
)
decoder.observe(backgroundAgent)
decoder.observe(system('task_progress', { task_id: 'agent-bg', last_tool_name: 'Read' }))
expect(decoder.drain(500)).toEqual([
expect.objectContaining({
type: 'live',
restart: true,
child: expect.objectContaining({
handle: { idKind: 'task_id', id: 'agent-bg', runId: 'toolu_bg' }
})
}),
expect.objectContaining({ type: 'live' })
])
})
it('reports a start for an ended child as a restart, and a late start of a stopped run as nothing', () => {
const decoder = decoderWith(
backgroundAgent,
system('task_notification', {
task_id: 'agent-bg',
tool_use_id: 'toolu_bg',
status: 'stopped'
})
)
decoder.observe(backgroundAgent)
decoder.observe(system('task_updated', { task_id: 'agent-bg', patch: { description: 'Late' } }))
decoder.observe(system('task_progress', { task_id: 'agent-bg', last_tool_name: 'Read' }))
expect(decoder.drain(500)).toEqual([])
@@ -185,22 +208,12 @@ describe('Claude child-work decoder', () => {
expect(decoder.drain(500)).toEqual([])
})
it('bounds the live children it tracks', () => {
it('admits every live child: only the host store bounds them', () => {
const decoder = new ClaudeChildWorkDecoder()
for (let index = 0; index < 257; index += 1) {
for (let index = 0; index < 300; index += 1) {
decoder.observe({ ...backgroundAgent, task_id: `agent-${index}` })
}
expect(decoder.drain(500)).toHaveLength(256)
})
it('ends what still runs as stopped when Orca ends the session, and only then', () => {
const decoder = decoderWith(backgroundAgent)
decoder.stopLive()
decoder.clear()
expect(decoder.drain(500)).toEqual([
expect.objectContaining({ type: 'ended', outcome: 'cancelled' }),
{ type: 'session-ended', observedAt: 500 }
])
expect(decoder.drain(500)).toHaveLength(300)
})
it('marks the end of the provider session', () => {
+29 -39
View File
@@ -3,16 +3,15 @@
// A child is live from its `task_started` until its own terminal `task_updated` or
// `task_notification`; nothing else ends it. A roster (`background_tasks_changed`), a turn ending
// or a spawn call returning is the parent's view of the child, not the child's, and the CLI sends
// every child its own terminal frame, so none of them settles one. When Orca ends the session and
// proves the close, what is still live is stopped (`stopLive`); any other end leaves it for the
// host to settle as unknown. A live child blocked on a permission request reads waiting; no task
// frame says so, so the caller hands over which children a request blocks. Edges wait here until
// the frame is journaled, then take the host clock.
// every child its own terminal frame, so none of them settles one. When the session ends, however
// it ends, the host settles what is still live as unknown. A live child blocked on a permission
// request reads waiting; no task frame says so, so the caller hands over which children a request
// blocks. Edges wait here until the frame is journaled, then take the host clock.
import type {
AgentChildWorkKind,
AgentChildWorkOperation,
AgentChildWorkOutcomeBasis
AgentChildWorkOutcome
} from '../../shared/agent-status-child-work'
import type {
AgentChildWorkEvidence,
@@ -30,14 +29,15 @@ import {
taskUsageTotalTokens,
terminalClaudeTaskRunState
} from './claude-background-task-frames'
import { isClaudeSubagentTask } from './claude-subagent-task-frames'
import {
claudeChildWorkOutcome,
claudeTaskProgressFacts,
type ClaudeTaskFacts
} from './claude-child-work-evidence'
/** Live children one session tracks, and the ended ids it remembers to recognise a restart. */
const MAX_LIVE_TASKS = 256
/** Ended ids remembered to recognise a restart. Live children take no bound here: refusing one
* would hide a running child; the host's global limits bound them. */
const MAX_ENDED_TASKS = 256
type DecodedClaudeTask = {
@@ -85,8 +85,11 @@ function observation(
export class ClaudeChildWorkDecoder {
private readonly live = new Map<string, DecodedClaudeTask>()
/** Ended task ids, with the spawn call each ended under. */
private readonly ended = new Map<string, string | undefined>()
/** Ended task ids, with the spawn call each ended under and how. */
private readonly ended = new Map<
string,
{ run: string | undefined; outcome: AgentChildWorkOutcome }
>()
/** The children a pending permission request blocks, as the caller last derived them. */
private waiting: ReadonlySet<string> = new Set()
private pending: PendingEdge[] = []
@@ -119,16 +122,6 @@ 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' })
}
}
/** Which children a pending request blocks, re-derived by the caller before every drain. A
* live child that starts or stops waiting is a live edge of its own. */
observeWaiting(waiting: ReadonlySet<string>): void {
@@ -142,14 +135,6 @@ export class ClaudeChildWorkDecoder {
}
}
/** Orca ended the session and proved the close: what still ran is stopped. The
* ending is Orca's, not the child's, so a frame of the child's own still replaces it. */
stopLive(): void {
for (const id of this.live.keys()) {
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()
@@ -171,12 +156,15 @@ export class ClaudeChildWorkDecoder {
}
const toolUseId = taskAliasId(message.tool_use_id)
const existing = this.live.get(id)
const restart = !existing && this.ended.has(id)
// A start under the spawn call a run ended with is that run's late start, not a new run.
if (restart && toolUseId !== undefined && this.ended.get(id) === toolUseId) {
const ended = existing ? undefined : this.ended.get(id)
const restart = ended !== undefined
// Under the spawn call a run ended with, a start after it completed is that run waking (the
// CLI ends a helper's turn while work it started runs, then resumes it when that work ends);
// after any other ending it is the ended run's late start.
if (ended?.run !== undefined && ended.run === toolUseId && ended.outcome !== 'succeeded') {
return
}
const kind = classifyClaudeBackgroundTaskKind(message.task_type)
const kind = startedTaskKind(message)
this.upsert(
id,
{
@@ -251,9 +239,6 @@ export class ClaudeChildWorkDecoder {
facts: ClaudeTaskFacts,
restart = false
): void {
if (!this.live.has(id) && this.live.size >= MAX_LIVE_TASKS) {
return
}
this.ended.delete(id)
this.live.set(id, task)
this.report(id, task, facts, restart)
@@ -276,20 +261,19 @@ export class ClaudeChildWorkDecoder {
runId?: string
lastMessage?: string
totalTokens?: number
basis?: AgentChildWorkOutcomeBasis
}
): void {
const task = this.live.get(id)
this.live.delete(id)
const outcome = claudeChildWorkOutcome(status)
this.ended.delete(id)
this.ended.set(id, reported.runId ?? task?.toolUseId)
this.ended.set(id, { run: reported.runId ?? task?.toolUseId, outcome })
if (this.ended.size > MAX_ENDED_TASKS) {
const [oldest] = this.ended.keys()
if (oldest !== undefined) {
this.ended.delete(oldest)
}
}
const outcome = claudeChildWorkOutcome(status)
this.pending.push((observedAt) => ({
type: 'ended',
observedAt,
@@ -299,9 +283,15 @@ 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 } : {})
}))
}
}
/** Releases predating `task_type` still name an agent in `subagent_type`. */
function startedTaskKind(message: Record<string, unknown>): AgentChildWorkKind {
return isClaudeSubagentTask(message)
? 'agent'
: classifyClaudeBackgroundTaskKind(message.task_type)
}
@@ -246,32 +246,6 @@ describe('the command turn at each point the ordinary result path threads throug
expect(finished.translator.currentTurnId).not.toBeNull()
})
it("settles a child still working in the command's turn when the command ends", () => {
const { begin, frame, rows } = harness()
begin('compact-input')
frame({
type: 'system',
subtype: 'task_started',
task_id: 'task-1',
task_type: 'local_agent',
description: 'Map the lane'
})
frame({ type: 'result', subtype: 'success', is_error: false })
const group = rows.get(
agentJournalItemKey({
provider: 'orca',
clientMessageId: `claude-subagents:${CAPTURED_COMPACT_SESSION_ID}:${RUNNING.turnId}`
})
)
const agents =
group?.body.kind === 'message'
? group.body.blocks.flatMap((block) =>
block.type === 'subagent-group' ? block.agents : []
)
: []
expect(agents).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it("records the context window the command's result reports on the command's turn", () => {
const { begin, frame, commandTurn } = harness()
begin('compact-input')
+2 -6
View File
@@ -30,7 +30,7 @@ import type { createClaudeStreamedBlockRegistry } from './claude-streamed-block-
import type { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints'
import type { ClaudeStreamedThinking } from './claude-streamed-thinking'
import type { ClaudeProvisionalRowCorrections } from './claude-provisional-row-corrections'
import type { ClaudeSubagentRoster } from './claude-subagent-roster'
import type { ClaudeSubagentIdentities } from './claude-subagent-identities'
import { claudeTurnOpenedBySendEcho, type ClaudeTurnSource } from './claude-turn-opening'
import type { ClaudeOpenTurn } from './claude-open-turn'
import type { ClaudeAuthenticationFailures } from './claude-authentication-failures'
@@ -41,7 +41,7 @@ export type ClaudeMessageJournalContext = {
streamedBlocks: ReturnType<typeof createClaudeStreamedBlockRegistry>
streamedText: ReturnType<typeof createClaudeStreamedTextCheckpoints>
streamedThinking: ClaudeStreamedThinking
subagents: ClaudeSubagentRoster
subagents: ClaudeSubagentIdentities
toolOrigins: ClaudeToolOriginRegistry
backgroundTasks: ClaudeBackgroundTaskRows
providerFallback: ClaudeProviderFrameFallback
@@ -67,9 +67,6 @@ export function journalClaudeMessage(
return false
}
let changed = false
if (envelope.parentToolUseId) {
ctx.subagents.observeChildActivity(envelope.parentToolUseId)
}
const results = claudeToolResults(envelope)
// Everything this envelope journals belongs to whoever produced the envelope.
// A child's rows live in the parent's journal, so without this the parent's
@@ -139,7 +136,6 @@ export function journalClaudeMessage(
const resultIdentity = claudeToolIdentity(envelope.sessionId, result.toolUseId)
const resultBody = claudeToolBody({ tool, result })
ctx.sink.appendItem(resultIdentity, resultBody, stamp(resultIdentity, resultBody))
ctx.subagents.observeToolResult(result.toolUseId, result.failed)
if (
results.length === 1 &&
envelope.parentToolUseId === null &&
+4 -8
View File
@@ -24,8 +24,8 @@ import { createClaudeTurnOpener, type ClaudeTurnSource } from './claude-turn-ope
export type ClaudeOpenTurnDeps = {
sink: StructuredAgentSessionEventSink
/** Settles the superseded turn's children; they get no later event of their own. */
settleChildren: (groupKey: string | null) => void
/** The superseded turn's spawn calls get no announcement now. */
turnSuperseded: () => void
/** Ends what the ending turn left open, at the instant it ended; no later frame will. */
endOpenWork: (completedAt: number) => void
/** A turn opening moves the conversation on. */
@@ -80,10 +80,6 @@ export class ClaudeOpenTurn {
: AGENT_JOURNAL_THREAD_SCOPE
}
get groupKey(): string | null {
return this.current ? `${this.current.sessionId}:${this.current.turnId}` : null
}
get isOpen(): boolean {
return this.current !== null
}
@@ -112,7 +108,7 @@ export class ClaudeOpenTurn {
open(turn: ClaudeCurrentTurn, observedAt: number): void {
this.deps.onOpen?.()
if (this.current) {
this.deps.settleChildren(this.groupKey)
this.deps.turnSuperseded()
this.deps.endOpenWork(observedAt)
this.publish(this.current, {
state: 'interrupted',
@@ -130,7 +126,7 @@ export class ClaudeOpenTurn {
beginCommand(turn: ClaudeCurrentTurn): void {
this.deps.onOpen?.()
if (this.current) {
this.deps.settleChildren(this.groupKey)
this.deps.turnSuperseded()
this.deps.endOpenWork(turn.startedAt)
this.publish(this.current, {
state: 'interrupted',
+1 -5
View File
@@ -21,7 +21,6 @@ export type ClaudeResultJournalContext = Pick<
| 'sink'
| 'streamedBlocks'
| 'streamedText'
| 'subagents'
| 'providerFallback'
| 'corrections'
| 'turn'
@@ -36,7 +35,6 @@ export function journalClaudeResult(
sink,
streamedBlocks,
streamedText,
subagents,
providerFallback,
corrections,
turn,
@@ -65,9 +63,7 @@ export function journalClaudeResult(
if (settlesTurn) {
prompts.retryPendingCancellations()
turn.suppressReopenOnFailure(message.is_error === true)
// The turn is over however it ended, so a foreground child still
// reported as working will never be settled by an event.
subagents.settleTurn(turn.groupKey)
corrections.abandon()
context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt, leftToStop))
// The turn is over. A block still awaiting its final keeps the text the
// flush above journaled, but its live state goes: an interrupted turn
@@ -87,7 +87,8 @@ describe('Claude child work from captured frame orders', () => {
})
})
it('stops what still runs when Orca ends the session, and keeps every record', async () => {
// A close Orca proves is still loss of contact: no Stop was accepted, so no outcome is claimed.
it('leaves how they ended unknown when Orca ends the session, and keeps every record', async () => {
const run = await producer(hostWithParent())
const [running] = until(MOVED_TO_BACKGROUND, 51_275)
run.replay(running)
@@ -99,8 +100,8 @@ describe('Claude child work from captured frame orders', () => {
outcome
}))
).toEqual([
{ description: 'Run 45s sleep command', membership: 'settled', outcome: 'cancelled' },
{ description: 'Sleep 45 seconds then print 1', membership: 'settled', outcome: 'cancelled' }
{ description: 'Run 45s sleep command', membership: 'settled', outcome: 'unknown' },
{ description: 'Sleep 45 seconds then print 1', membership: 'settled', outcome: 'unknown' }
])
})
@@ -18,8 +18,8 @@ afterEach(() => vi.restoreAllMocks())
describe('Claude structured child-work producer', () => {
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(
const spawnCall = send(toolUse('toolu_bg', 'Agent', { description: 'Audit the build' }))
const announced = send(
system('task_started', {
task_id: 'agent-bg',
tool_use_id: 'toolu_bg',
@@ -29,8 +29,8 @@ describe('Claude structured child-work producer', () => {
is_backgrounded: true
})
)
const kinds = deliveries.map((delivery) => delivery.kind)
// The frame's own rows, then the parent's republished row, and only then its children.
const kinds = [...spawnCall, ...announced].map((delivery) => delivery.kind)
// The spawn call's own row, then the parent's republished row, and only then its child.
expect(kinds.filter((kind) => kind === 'journal').length).toBeGreaterThan(0)
expect(kinds.lastIndexOf('publish')).toBeGreaterThan(kinds.lastIndexOf('journal'))
expect(kinds.at(-1)).toBe('evidence')
@@ -195,7 +195,8 @@ describe('Claude structured child-work producer', () => {
step.check?.()
}
await adapter.closeSession('session-1')
// Orca ended the session: what still ran is stopped with it, and every record stays.
// Orca ended the session: what still ran ends unknown, as any lost contact does, and every
// record stays.
const summary = records().map((record) => ({
description: record.description,
membership: record.membership,
@@ -210,7 +211,7 @@ describe('Claude structured child-work producer', () => {
generation: 1
},
{ description: 'npm test', membership: 'settled', outcome: 'cancelled', generation: 1 },
{ description: 'Audit the build', membership: 'settled', outcome: 'cancelled', generation: 2 }
{ description: 'Audit the build', membership: 'settled', outcome: 'unknown', generation: 2 }
])
expect(adapter.backgroundTaskState('session-1')).toBeUndefined()
})
@@ -308,34 +308,28 @@ describe('stopClaudeBackgroundTasks', () => {
return { childWork, connection: { stopTask } } as unknown as ClaudeSession
}
it('stops exactly the ids the host named, and ends each acknowledged one', async () => {
it('stops exactly the ids the host named, and reports 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)
const acknowledge = vi.fn()
await expect(
stopClaudeBackgroundTasks(session, 5_000, () => true, ['task-agent', 'task-bash'])
stopClaudeBackgroundTasks(
session,
5_000,
() => true,
['task-agent', 'task-bash'],
acknowledge
)
).resolves.toEqual({ cancelled: true })
expect(acknowledge.mock.calls).toEqual([[{ taskId: 'task-agent' }], [{ taskId: 'task-bash' }]])
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'
}
])
// The host settles what was acknowledged; the decoder records nothing of its own.
expect(childWork.drain(2)).toEqual([])
})
it('stops issuing requests when ownership changes between tasks', async () => {
@@ -356,15 +350,16 @@ describe('stopClaudeBackgroundTasks', () => {
throw new ClaudeControlRequestError('stop_task', 'Task task-live is owned by another agent')
})
const session = stoppingSession(childWork, stopTask)
const acknowledge = vi.fn()
await expect(
stopClaudeBackgroundTasks(session, undefined, () => true, ['task-live'])
stopClaudeBackgroundTasks(session, undefined, () => true, ['task-live'], acknowledge)
).resolves.toEqual({ cancelled: false })
await expect(
stopClaudeBackgroundTasks(session, undefined, () => true, [], acknowledge)
).resolves.toEqual({ cancelled: false })
expect(childWork.drain(2)).toEqual([])
await expect(stopClaudeBackgroundTasks(session, undefined, () => true, [])).resolves.toEqual({
cancelled: false
})
expect(stopTask).toHaveBeenCalledTimes(1)
expect(acknowledge).not.toHaveBeenCalled()
})
it('stops the remaining tasks after one request fails, then reports the failure', async () => {
@@ -376,34 +371,39 @@ describe('stopClaudeBackgroundTasks', () => {
}
})
const session = stoppingSession(childWork, stopTask)
const acknowledge = vi.fn()
await expect(
stopClaudeBackgroundTasks(session, undefined, () => true, ['task-1', 'task-2'])
stopClaudeBackgroundTasks(session, undefined, () => true, ['task-1', 'task-2'], acknowledge)
).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' }
])
// The failure is reported, and the stop it did not touch is still named.
expect(acknowledge.mock.calls).toEqual([[{ taskId: 'task-2' }]])
})
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 stopTask = vi.fn((taskId: string, options?: { timeoutMs?: number }) =>
taskId === 'task-0'
? Promise.resolve()
: runClaudeControl('stop_task', () => new Promise<void>(() => {}), options?.timeoutMs)
)
const session = stoppingSession(childWork, stopTask)
const acknowledge = vi.fn()
await expect(
stopClaudeBackgroundTasks(session, 50, () => true, [
'task-0',
'task-1',
'task-2',
'task-3',
'task-4'
])
stopClaudeBackgroundTasks(
session,
50,
() => true,
['task-0', 'task-1', 'task-2', 'task-3', 'task-4'],
acknowledge
)
).rejects.toBeInstanceOf(ClaudeControlRequestTimeoutError)
expect(stopTask).toHaveBeenCalledTimes(1)
expect(stopTask).toHaveBeenCalledTimes(2)
// What the CLI accepted before it went quiet is still named.
expect(acknowledge.mock.calls).toEqual([[{ taskId: 'task-0' }]])
expect(childWork.drain(2)).toEqual([])
})
})
@@ -1,4 +1,5 @@
import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk'
import type { AgentChildWorkStopAcknowledgement } from '../../shared/agent-child-work-stop-targets'
import type { ClaudePromptClaim } from './claude-structured-prompt-replies'
import { ClaudeControlRequestError } from './claude-stream-json-connection'
import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-requests'
@@ -61,13 +62,15 @@ 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. */
/** Stops each task the host named, and names each the CLI accepted the stop of as it does: the host
* ends that run provisionally, since 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,
taskIds: readonly string[]
taskIds: readonly string[],
acknowledge: (acknowledgement: AgentChildWorkStopAcknowledgement) => void = () => {}
): Promise<{ cancelled: boolean }> {
let cancelled = false
// A failed request for one task still leaves the others to stop; it is reported after them. A
@@ -80,8 +83,8 @@ export async function stopClaudeBackgroundTasks(
}
try {
await session.connection.stopTask(taskId, { timeoutMs })
session.childWork.stopAcknowledged(taskId)
cancelled = true
acknowledge({ taskId })
} catch (error) {
if (error instanceof ClaudeControlRequestTimeoutError) {
throw error
@@ -97,8 +100,7 @@ export async function stopClaudeBackgroundTasks(
return { cancelled }
}
/** Stops the tasks while `session` is still the one the host asked about, then publishes its child
* work so the host's records follow every acknowledged stop. */
/** Stops the tasks while `session` is still the one the host asked about. */
export async function stopCurrentClaudeBackgroundTasks(input: {
sessions: ReadonlyMap<string, ClaudeSession>
session: ClaudeSession
@@ -106,7 +108,7 @@ export async function stopCurrentClaudeBackgroundTasks(input: {
fence: number
taskIds: readonly string[]
timeoutMs: number | undefined
publishChildWork: (session: ClaudeSession) => void
acknowledge?: (acknowledgement: AgentChildWorkStopAcknowledgement) => void
}): Promise<{ cancelled: boolean }> {
const { sessions, session, sessionId, fence } = input
const acquisitionGeneration = session.acquisitionGeneration
@@ -114,13 +116,13 @@ export async function stopCurrentClaudeBackgroundTasks(input: {
sessions.get(sessionId) === session &&
session.fence === fence &&
session.acquisitionGeneration === acquisitionGeneration
try {
return await stopClaudeBackgroundTasks(session, input.timeoutMs, isCurrent, input.taskIds)
} finally {
if (isCurrent()) {
input.publishChildWork(session)
}
}
return stopClaudeBackgroundTasks(
session,
input.timeoutMs,
isCurrent,
input.taskIds,
input.acknowledge
)
}
export async function answerClaudePrompt(
@@ -339,7 +339,7 @@ describe('context usage on journal rows', () => {
const onOpen = vi.fn()
const turn = new ClaudeOpenTurn({
sink: journal().sink,
settleChildren: () => {},
turnSuperseded: () => {},
endOpenWork: () => {},
onOpen
})
@@ -3,18 +3,12 @@ import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import type {
NativeChatSubagentEntry,
NativeChatSubagentGroupBlock
} from '../../shared/native-chat-types'
import type {
StructuredAgentSessionAppendOptions,
StructuredAgentSessionEventSink
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
const GROUP_ITEM_ID = 'claude-subagents:claude-session:user-1'
/** The union's other arms carry no client message id, so reading one narrows. */
function orcaClientMessageId(identity: AgentJournalItemIdentity): string | null {
return identity.provider === 'orca' ? identity.clientMessageId : null
@@ -51,27 +45,6 @@ function harness() {
const run = scheduled as (() => void) | null
run?.()
}
const groupRows = () =>
items.filter((item) => orcaClientMessageId(item.identity) === GROUP_ITEM_ID)
const agentsOf = (body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] => {
if (!body || body.kind !== 'message') {
return []
}
const block = body.blocks.find(
(candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group'
)
return block ? block.agents : []
}
/** The last roster row written for one group, so a test can read a group that
* is no longer the live one. */
const rosterIn = (groupId: string): NativeChatSubagentEntry[] =>
agentsOf(
items.findLast((item) => orcaClientMessageId(item.identity) === `claude-subagents:${groupId}`)
?.body
)
const rosterOf = (turnUuid: string): NativeChatSubagentEntry[] =>
rosterIn(`claude-session:${turnUuid}`)
const roster = (): NativeChatSubagentEntry[] => agentsOf(groupRows().at(-1)?.body)
const fallbackRows = (): AgentJournalItemBody[] =>
items
.filter((item) => (orcaClientMessageId(item.identity) ?? '').startsWith('provider-frame:'))
@@ -92,10 +65,6 @@ function harness() {
return {
translator,
items,
groupRows,
roster,
rosterIn,
rosterOf,
fallbackRows,
linkageOfProse,
writesOfProse,
@@ -158,8 +127,8 @@ function resultFrame() {
}
describe('claude journal translation — subagents', () => {
it('rosters a spawned subagent and settles it on the spawn call result', () => {
const { translator, roster, fallbackRows } = harness()
it('writes no subagent rows of its own: the host saves each one from its records', () => {
const { translator, items, fallbackRows } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
@@ -170,141 +139,20 @@ describe('claude journal translation — subagents', () => {
description: 'Map the lane'
})
)
expect(roster()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Map the lane', state: 'working' })
])
// The task frames stay status-chrome, so none of them prints an opcode row.
translator.handle(spawnResult('user-2', 'toolu_1'))
translator.handle(resultFrame())
translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' })
expect(
items.filter(
(item) =>
item.body.kind === 'message' &&
item.body.blocks.some(
(block) => block.type === 'subagent' || block.type === 'subagent-group'
)
)
).toEqual([])
// The task frames stay status chrome, so none of them prints an opcode row.
expect(fallbackRows()).toEqual([])
translator.handle(spawnResult('user-2', 'toolu_1'))
expect(roster()).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('marks a child still working at turn end unverifiable', () => {
const { translator, roster } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-1',
task_type: 'local_agent',
description: 'Map the lane'
})
)
translator.handle(resultFrame())
expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('leaves a backgrounded child running past the end of its turn', () => {
const { translator, roster } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-1',
tool_use_id: 'toolu_1',
task_type: 'local_agent',
description: 'Watch the build',
is_backgrounded: true
})
)
// A backgrounded spawn returns its tool result immediately; the child runs on.
translator.handle(spawnResult('user-2', 'toolu_1'))
translator.handle(resultFrame())
expect(roster()).toEqual([expect.objectContaining({ state: 'working' })])
translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' })
expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('keeps a backgrounded shell task out of the roster entirely', () => {
const { translator, groupRows } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-bash',
tool_use_id: 'toolu_bash',
task_type: 'local_bash',
description: 'sleep 20',
is_backgrounded: true
})
)
translator.handle(resultFrame())
expect(groupRows()).toEqual([])
})
it('shows a subagent whose release announces no task frames, from its child traffic', () => {
const { translator, roster } = harness()
translator.handle(userTurn('user-1'))
translator.handle({
type: 'message' as const,
sessionId: 'orca-session',
message: {
type: 'assistant',
uuid: 'child-1',
session_id: 'claude-session',
parent_tool_use_id: 'toolu_1',
message: { role: 'assistant', content: [{ type: 'text', text: 'looking' }] }
}
})
expect(roster()).toEqual([
expect.objectContaining({ id: 'toolu_1', label: 'subagent', state: 'working' })
])
})
it('settles the turn a new turn superseded, and leaves the new one running', () => {
const { translator, rosterOf } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-1',
task_type: 'local_agent',
description: 'First turn'
})
)
// A second turn starts with no result frame for the first: the first turn
// ends here, and nothing else will ever name its group again.
translator.handle(userTurn('user-2'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-2',
task_type: 'local_agent',
description: 'Second turn'
})
)
expect(rosterOf('user-1')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
expect(rosterOf('user-2')).toEqual([expect.objectContaining({ state: 'working' })])
})
it('does not let an unrelated turn end settle a child announced outside a turn', () => {
const { translator, rosterIn } = harness()
// No turn is live yet, so this child has no turn key to belong to.
translator.handle(
systemFrame('task_started', {
task_id: 'task-early',
task_type: 'local_agent',
description: 'Before the turn'
})
)
translator.handle(userTurn('user-1'))
translator.handle(resultFrame())
expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })])
// The outcome still lands, which a latched `unverifiable` would have lost.
translator.handle(
systemFrame('task_updated', { task_id: 'task-early', patch: { status: 'completed' } })
)
expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('settles a child left outside every turn when the session ends', () => {
const { translator, rosterIn } = harness()
translator.handle(
systemFrame('task_started', {
task_id: 'task-early',
task_type: 'local_agent',
description: 'Before the turn'
})
)
translator.handle(userTurn('user-1'))
translator.handle(resultFrame())
translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' })
expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
})
@@ -829,19 +677,4 @@ describe('claude journal translation — which agent produced a row', () => {
expect(linkageOfProse('shell output')).toMatchObject({ producerKind: 'background' })
})
it("leaves the spawn-group row the parent's, though a child frame triggered it", () => {
// Hazard: the group row is written from a child's frame but describes the
// PARENT's children. Stamping it as a child's would hide the roster from
// the very row that owns it. Asserted on the written row, not on a field.
const { translator, groupRows } = harness()
translator.handle(userTurn('user-1'))
translator.handle(spawnCall('assistant-1', 'toolu_1'))
translator.handle(announce('task-1', 'toolu_1'))
translator.handle(childProse('child-1', 'toolu_1', 'looking'))
const groupRow = groupRows().at(-1)
expect(groupRow).toBeDefined()
expect(groupRow?.options?.agentId).toBeUndefined()
})
})
@@ -16,8 +16,8 @@ import { ClaudeBackgroundTaskRows } from './claude-background-task-rows'
import { ClaudeToolOriginRegistry } from './claude-tool-origin-registry'
import { ClaudeProvisionalRowCorrections } from './claude-provisional-row-corrections'
import { createClaudeStreamedThinking } from './claude-streamed-thinking'
import { ClaudeSubagentRoster } from './claude-subagent-roster'
import { ClaudeJournaledRoster } from './claude-subagent-journaled-roster'
import { ClaudeSubagentIdentities } from './claude-subagent-identities'
import { ClaudeJournaledSubagents } from './claude-subagent-journaled-identities'
import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity'
import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints'
import {
@@ -63,7 +63,9 @@ export function createClaudeJournalTranslator(
const streamedBlocks = createClaudeStreamedBlockRegistry()
const turn = new ClaudeOpenTurn({
sink: deps.sink,
settleChildren: (groupKey) => subagents.settleTurn(groupKey),
// No announcement can come after its turn, so a correction still owed is never coming; the
// rows keep the stamp they already have.
turnSuperseded: () => corrections.abandon(),
endOpenWork: (completedAt) => streamedThinking.finishOpen(completedAt),
onOpen: () => context.markActivity()
})
@@ -71,18 +73,12 @@ export function createClaudeJournalTranslator(
const fallbackId = deps.fallbackIdPrefix ?? 'acquisition'
const providerFallback = createClaudeProviderFrameFallback(deps.sink, fallbackId, turnScope)
const toolOrigins = new ClaudeToolOriginRegistry()
const journaled = new ClaudeJournaledRoster(() => deps.sink.journalLinkage?.() ?? null)
const subagents = new ClaudeSubagentRoster({
sink: deps.sink,
currentGroupKey: () => turn.groupKey,
currentTurnScope: turnScope,
const journaled = new ClaudeJournaledSubagents(() => deps.sink.journalLinkage?.() ?? null)
const subagents = new ClaudeSubagentIdentities({
isForwardedParentTool: (toolUseId) => toolOrigins.has(toolUseId),
childOwnerRefOf: (toolUseId) => toolOrigins.childOwnerRef(toolUseId),
// A restarted provider continues the roster earlier runs journaled.
journaled,
// A settled group can receive no further announcement, so a correction
// still owed is never coming; the rows keep the stamp they already have.
onIdentitiesFinal: () => corrections.abandon()
// A restarted provider continues what earlier runs journaled.
journaled
})
const childQueries = claudeChildToolQueries({ tools, toolOrigins, linkage: subagents.linkage })
const corrections = new ClaudeProvisionalRowCorrections({
@@ -104,7 +100,7 @@ export function createClaudeJournalTranslator(
const backgroundTasks = new ClaudeBackgroundTaskRows({
sink: deps.sink,
isForwardedParentTool: (toolUseId) => toolOrigins.has(toolUseId),
rosteredByEarlierRun: (taskId) => journaled.groupOf(taskId) !== null,
rosteredByEarlierRun: journaled.isSubagent,
// A typed task row is provider output: journaling one must open a resumed
// turn, or the session shows the row while reading idle.
openOutputTurn: (frame, observedAt) =>
@@ -188,7 +184,7 @@ export function createClaudeJournalTranslator(
if (event.type === 'ended') {
prompts.retryPendingCancellations()
flush()
subagents.settleSession()
corrections.abandon()
backgroundTasks.settleSession()
// The host saw the child end, so the turn's end is observed, not lost. Whether it was a
// person's Stop is the journal's Stop event to say (`turnEndAfterStop`), else it is news.
@@ -272,7 +268,7 @@ export function createClaudeJournalTranslator(
// after an idle belongs to a turn, and suppressing it would read as
// idle while the agent works.
if (claudeSessionStateEndsTurn(event.message)) {
subagents.settleTurn(turn.groupKey)
corrections.abandon()
// No verdict: the CLI said the turn is over, not how it ended.
turn.settle({ state: 'completed', completedAt: event.observedAt ?? Date.now() })
}
@@ -317,6 +313,7 @@ export function createClaudeJournalTranslator(
tools.clear()
prompts.clear()
streamedBlocks.clear()
corrections.abandon()
subagents.dispose()
backgroundTasks.dispose()
toolOrigins.clear()
@@ -0,0 +1,183 @@
// A targeted Stop against the real CLI: `stop_task` on one live background agent. The CLI's
// acknowledgement is what the host settles that run on until the agent's own ending arrives.
// Runs only where a signed-in Claude CLI exists, like the rest of the real-CLI suite.
import { randomUUID } from 'node:crypto'
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution'
import {
ClaudeStructuredSessionAdapter,
type ClaudeStructuredSessionEvent
} from './claude-structured-session-adapter'
import {
realClaudeAuthenticated,
realClaudeAvailable,
realClaudeCommand,
realClaudeLaunchHome
} from './claude-real-cli-availability-test-support'
import { claudeStartupSettled } from './claude-structured-session-test-support'
import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
const SESSION_ID = 'real-cli-stop-task'
const SETTINGS = JSON.stringify({ permissions: { allow: ['Bash(sleep:*)'] } })
function identity(providerSessionId: string): AgentSessionJournalIdentity {
return {
sessionId: SESSION_ID,
workspaceId: 'real-cli-stop-task-workspace',
hostId: 'local',
agent: 'claude',
providerHandle: claudeProviderHandle(providerSessionId, null)
}
}
async function until(check: () => boolean, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (check()) {
return true
}
await new Promise((resolve) => setTimeout(resolve, 200))
}
return check()
}
/** The task frames the CLI sent about one task, in order. */
function taskFrames(events: readonly ClaudeStructuredSessionEvent[], taskId: string) {
return events.flatMap((event) =>
event.type === 'message' &&
event.message.type === 'system' &&
typeof event.message.subtype === 'string' &&
event.message.subtype.startsWith('task_') &&
event.message.task_id === taskId
? [event.message]
: []
)
}
describe.skipIf(!realClaudeAvailable)('Claude structured real CLI targeted stop', () => {
it.skipIf(!realClaudeAuthenticated)(
'acknowledges stop_task on one live background agent',
async () => {
const providerSessionId = randomUUID()
const { claudeConfigDir, env } = realClaudeLaunchHome()
const cwd = await mkdtemp(join(tmpdir(), 'orca-real-stop-task-'))
const events: ClaudeStructuredSessionEvent[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: () => {},
appendTombstone: () => {},
publish: () => {}
}
const adapter = new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
pathToClaudeCodeExecutable: realClaudeCommand,
options: {
...CLAUDE_STRUCTURED_BASE_OPTIONS,
extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, settings: SETTINGS },
sessionId: providerSessionId
},
cwd,
env,
claudeConfigDir,
providerSessionId,
resumeLeafUuid: null,
resumesTranscript: false,
continuesChain: false
}),
onEvent: (event) => events.push(event),
readProcessStartTime: async () => 1
})
try {
await adapter.acquire({
identity: identity(providerSessionId),
fence: 1,
spawnToken: 'real-cli-stop-task',
events: sink
})
await claudeStartupSettled(adapter, SESSION_ID)
await expect(
adapter.dispatch({
sessionId: SESSION_ID,
clientMessageId: 'client-A',
body: {
kind: 'message',
role: 'user',
blocks: [
{
type: 'text',
text: 'Use the Agent tool once, with run_in_background set to true and description "Sleeper", asking the subagent to run `sleep 120` with the Bash tool. Do not wait for it. Then reply exactly: LAUNCHED'
}
]
},
requestedAt: Date.now(),
fence: 1
})
).resolves.toEqual({ state: 'admitted' })
const started = () =>
events.flatMap((event) =>
event.type === 'message' &&
event.message.type === 'system' &&
event.message.subtype === 'task_started' &&
event.message.task_type === 'local_agent' &&
typeof event.message.task_id === 'string'
? [event.message.task_id]
: []
)
expect(await until(() => started().length > 0, 90_000)).toBe(true)
const [taskId] = started()
// Give the subagent time to reach its sleep, so the stop lands on a live run.
await new Promise((resolve) => setTimeout(resolve, 5_000))
const acknowledged: unknown[] = []
const outcome = await adapter.stopBackgroundTasks({
sessionId: SESSION_ID,
fence: 1,
taskIds: [taskId],
acknowledge: (acknowledgement) => void acknowledged.push(acknowledgement)
})
expect(outcome).toMatchObject({ cancelled: true })
expect(acknowledged).toEqual([{ taskId }])
// The CLI sends the run its own ending, and it says the run stopped.
const ended = await until(
() =>
taskFrames(events, taskId).some(
(frame) =>
frame.subtype === 'task_notification' ||
(frame.subtype === 'task_updated' &&
typeof frame.patch === 'object' &&
frame.patch !== null &&
'status' in frame.patch)
),
30_000
)
expect(ended).toBe(true)
const frames = taskFrames(events, taskId).map((frame) => ({
subtype: frame.subtype,
status:
frame.status ??
(typeof frame.patch === 'object' && frame.patch !== null && 'status' in frame.patch
? frame.patch.status
: undefined)
}))
console.info('[real-cli stop_task] task frames', JSON.stringify(frames))
const endings = frames.filter((frame) => frame.status !== undefined)
expect(endings.length).toBeGreaterThan(0)
expect(
endings.every((frame) =>
['stopped', 'killed', 'cancelled'].includes(String(frame.status))
)
).toBe(true)
} finally {
await adapter.closeAll()
}
},
180_000
)
})
@@ -197,8 +197,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
...input,
sessions: this.sessions,
session: this.session(input.sessionId),
timeoutMs: this.deps.requestTimeoutMs,
publishChildWork: (session) => this.publishChildWork(input.sessionId, session)
timeoutMs: this.deps.requestTimeoutMs
})
/** 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
@@ -144,8 +144,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()
// A close Orca asked for stops the child still running, then the host hears the session end.
expect(childWork).toEqual(['live', 'ended', 'session-ended'])
// A close Orca asked for claims no outcome for the child still running: only the session ends.
expect(childWork).toEqual(['live', 'session-ended'])
// Nothing of the dead child stays indexed for a retry.
await expect(adapter.closeSession('session-1')).resolves.toBe(true)
@@ -119,12 +119,8 @@ async function finalizeClaudePublishedSession(
rootExitVerdict = cleanupError
}
// Queues the session's ending for the host's child records; the adapter delivers it after close.
// A proven close stopped what still ran: on POSIX the whole tree was seen gone; on Windows Claude
// left after its stdin ended, or taskkill reported its tree terminated. Any other end, like an
// exit of the session's own, leaves how it ended unknown.
if (connectionClosed === true) {
session.childWork.stopLive()
}
// Even a proven close is Orca losing contact, not a Stop the CLI accepted: what still ran ends
// with no outcome anyone reported.
session.childWork.clear()
session.backgroundTasks.clear()
// The exit is proven, so the session ends now. Saving its resume point is bookkeeping that
@@ -0,0 +1,101 @@
// Which Claude subagent a frame belongs to, read from Claude's task frames.
//
// The host's child records decide what every subagent is doing and what its saved row says; the
// task-frame decoder is what feeds them. This only names children, so each row a child writes is
// filed under it: `task_id` is the key, and each `tool_use_id` is one run of it, since Claude
// re-announces a resumed task under a NEW tool id. An announcement that a task is not a subagent
// is remembered too, so later frames for it are never read as a subagent's.
import { BoundedMap } from '../../shared/bounded-map'
import { ClaudeSubagentIds } from './claude-subagent-id-aliases'
import type { ClaudeJournaledSubagents } from './claude-subagent-journaled-identities'
import {
ClaudeSubagentLinkage,
type ClaudeAgentLinkageSource,
type ClaudeSubagentLinkageSource
} from './claude-subagent-linkage'
import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames'
/** Children whose current run attribution remembers; an older one's rows read as its first run. */
const MAX_KNOWN_SUBAGENTS = 1024
type KnownSubagent = { run: string | null; attempt: number }
export type ClaudeSubagentIdentitiesDeps = {
/** Whether a tool id was forwarded at the TOP level. A child parented to one
* was spawned by a call the transcript shows, so its announcement is still
* expected; a child parented to anything else names an id that only ever
* existed inside a sidechain, which this CLI will never announce. */
isForwardedParentTool?: (toolUseId: string) => boolean
/** The reference naming the child that journaled a tool call, when a child
* did. It is how a grandchild's row reaches the agent that spawned it. */
childOwnerRefOf?: (toolUseId: string) => string | null
/** What earlier provider runs of this session journaled. */
journaled?: ClaudeJournaledSubagents
}
export class ClaudeSubagentIdentities {
private readonly ids: ClaudeSubagentIds
private readonly known = new BoundedMap<string, KnownSubagent>({
maxEntries: MAX_KNOWN_SUBAGENTS
})
/** Who produced a row, for every write site journaling this session. */
readonly linkage: ClaudeSubagentLinkageSource & ClaudeAgentLinkageSource
constructor(private readonly deps: ClaudeSubagentIdentitiesDeps) {
this.ids = new ClaudeSubagentIds(deps.journaled?.canonical)
this.linkage = new ClaudeSubagentLinkage({
ids: this.ids,
trackedFor: (canonicalId) => {
const attempt =
this.known.get(canonicalId)?.attempt ?? deps.journaled?.attempt(canonicalId) ?? 0
return attempt > 0 ? { attempt } : null
},
isForwardedParentTool: deps.isForwardedParentTool,
childOwnerRefOf: deps.childOwnerRefOf,
spawnRefOf: (canonicalId) => this.known.get(canonicalId)?.run ?? null
})
}
/** Consume a `message:system:task_*` frame. Returns false when it is not one. */
observeSystemFrame(message: Record<string, unknown>): boolean {
const frame = readClaudeSubagentTaskFrame(message)
if (!frame) {
return false
}
if (frame.excluded) {
for (const id of [frame.taskId, frame.toolUseId]) {
if (id !== null) {
this.ids.exclude(id)
}
}
return true
}
if (this.ids.isExcluded(frame.taskId, frame.toolUseId)) {
return true
}
if (frame.toolUseId) {
this.ids.alias(frame.toolUseId, frame.taskId)
}
if (frame.announcesSubagent) {
this.announce(frame.taskId, frame.toolUseId)
}
return true
}
dispose(): void {
this.ids.clear()
this.known.clear()
}
/** A new spawn call is the child's next run; one an earlier provider process ran it under was
* that process's, so this run follows the last one its rows record. */
private announce(taskId: string, run: string | null): void {
const known = this.known.get(taskId)
if (known && (run === null || run === known.run)) {
return
}
const previous = known?.attempt ?? this.deps.journaled?.attempt(taskId) ?? 0
this.known.set(taskId, { run, attempt: previous + 1 })
}
}
@@ -0,0 +1,86 @@
// What earlier runs of this session recorded about its Claude subagents, re-derived from the rows
// they journaled: the task id each spawn call resolved to (a resumed child's frames still carry the
// old call), the latest run of each child, and which task ids were subagents at all.
import type { AgentJournalProducerLinkage } from '../../shared/agent-session-journal-types'
import { namedSubagents } from '../../shared/native-chat-subagent-items'
import type { StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { isBoundedClaudeTaskId } from './claude-background-task-tracker'
type Reading = {
canonicalByToolUse: Map<string, string>
attemptByAgent: Map<string, number>
subagents: Set<string>
}
/** Read once per bound journal epoch. Before the journal is bound nothing is read and nothing is
* kept, so the first read after bind still sees every row. */
export class ClaudeJournaledSubagents {
private journal: StructuredAgentSessionLinkageJournal | null = null
private epoch: string | null = null
private reading: Reading | null = null
constructor(private readonly bound: () => StructuredAgentSessionLinkageJournal | null) {}
/** The task id an earlier run resolved this spawn call to. */
canonical = (toolUseId: string): string | null =>
this.current()?.canonicalByToolUse.get(toolUseId) ?? null
/** The latest run any row records for this child; 0 when no row names it. */
attempt = (agentId: string): number => this.current()?.attemptByAgent.get(agentId) ?? 0
/** An earlier run saved this task as a subagent, so it is not a background task. */
isSubagent = (taskId: string): boolean => this.current()?.subagents.has(taskId) === true
private current(): Reading | null {
const journal = this.bound()
if (!journal) {
return null
}
if (!this.reading || journal !== this.journal || journal.epoch !== this.epoch) {
this.journal = journal
this.epoch = journal.epoch
this.reading = read(journal)
}
return this.reading
}
}
function read(journal: StructuredAgentSessionLinkageJournal): Reading {
const reading: Reading = {
canonicalByToolUse: new Map(),
attemptByAgent: new Map(),
subagents: new Set()
}
journal.visitItemsWithLinkage((_itemId, _sequence, body, linkage) => {
readAgentRow(reading, linkage)
if (body.kind === 'message' && linkage.agentId === undefined) {
for (const entry of body.blocks.flatMap(namedSubagents)) {
reading.subagents.add(entry.id)
}
}
})
return reading
}
function readAgentRow(
reading: Reading,
{ agentId, providerParentRef, producerKind, attempt }: AgentJournalProducerLinkage
): void {
if (producerKind !== 'agent' || agentId === undefined) {
return
}
reading.attemptByAgent.set(
agentId,
Math.max(attempt ?? 1, reading.attemptByAgent.get(agentId) ?? 1)
)
// A row stamped with its own reference was never resolved, so it names no alias.
if (
providerParentRef !== undefined &&
agentId !== providerParentRef &&
isBoundedClaudeTaskId(agentId) &&
isBoundedClaudeTaskId(providerParentRef)
) {
reading.canonicalByToolUse.set(providerParentRef, agentId)
}
}
@@ -1,170 +0,0 @@
import { describe, expect, it } from 'vitest'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import type {
AgentJournalItemBody,
AgentJournalProducerLinkage,
AgentJournalTurnScope
} from '../../shared/agent-session-journal-types'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type { StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body'
import {
ClaudeJournaledRoster,
claudeSubagentGroupIdentity
} from './claude-subagent-journaled-roster'
type Row = {
itemId: string
sequence: number
body: AgentJournalItemBody
linkage: AgentJournalProducerLinkage & { turnScope?: AgentJournalTurnScope }
}
const prose: AgentJournalItemBody = {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'working on it' }]
}
const agentRow = (sequence: number, linkage: AgentJournalProducerLinkage): Row => ({
itemId: `claude:claude-session:row-${sequence}`,
sequence,
body: prose,
linkage
})
const groupRow = (
sequence: number,
groupId: string,
agents: NativeChatSubagentEntry[],
itemId = agentJournalItemKey(claudeSubagentGroupIdentity(groupId)),
turnScope?: AgentJournalTurnScope
): Row => ({
itemId,
sequence,
body: subagentGroupJournalBody(groupId, agents),
linkage: turnScope ? { turnScope } : {}
})
const entry = (id: string, state: NativeChatSubagentEntry['state']): NativeChatSubagentEntry => ({
id,
label: `Child ${id}`,
state,
startedAt: 1
})
function linkageJournal(
epoch: string,
rows: () => Row[]
): {
epoch: string
visitItemsWithLinkage: StructuredAgentSessionLinkageJournal['visitItemsWithLinkage']
} {
return {
epoch,
visitItemsWithLinkage: (visit) => {
for (const row of rows()) {
visit(row.itemId, row.sequence, row.body, row.linkage)
}
}
}
}
describe('ClaudeJournaledRoster', () => {
it('recalls only an agent row that resolved its reference to another id', () => {
const rows = [
agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }),
// Stamped with its own reference: never resolved, so it names no alias.
agentRow(2, { agentId: 'toolu_raw', providerParentRef: 'toolu_raw', producerKind: 'agent' }),
// A backgrounded shell is not a subagent and never outlives its process.
agentRow(3, {
agentId: 'shell-1',
providerParentRef: 'toolu_shell',
producerKind: 'background'
})
]
const journal = linkageJournal('epoch-1', () => rows)
const journaled = new ClaudeJournaledRoster(() => journal)
expect(journaled.canonical('toolu_a')).toBe('task-a')
expect(journaled.canonical('toolu_raw')).toBeNull()
expect(journaled.canonical('toolu_shell')).toBeNull()
})
it('reads the latest run any row of a child records', () => {
const rows = [
agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }),
agentRow(2, {
agentId: 'task-a',
providerParentRef: 'toolu_a',
producerKind: 'agent',
attempt: 3
}),
agentRow(3, {
agentId: 'task-a',
providerParentRef: 'toolu_a',
producerKind: 'agent',
attempt: 2
})
]
const journal = linkageJournal('epoch-1', () => rows)
const journaled = new ClaudeJournaledRoster(() => journal)
expect(journaled.attempt('task-a')).toBe(3)
expect(journaled.attempt('task-unknown')).toBe(1)
})
it('hands an earlier run’s group over once, and places a child in the newest row listing it', () => {
const turnA: AgentJournalTurnScope = {
kind: 'turn',
turnItemId: 'claude:claude-session:turn-a'
}
const rows = [
groupRow(
10,
'turn-a',
[entry('task-a', 'failed'), entry('task-b', 'failed')],
undefined,
turnA
),
groupRow(20, 'turn-b', [entry('task-a', 'working')]),
// Another lane's group block under a key this roster never writes.
groupRow(30, 'turn-c', [entry('task-c', 'working')], 'orca:elsewhere')
]
const journal = linkageJournal('epoch-1', () => rows)
const journaled = new ClaudeJournaledRoster(() => journal)
expect(journaled.groupOf('task-a')).toBe('turn-b')
expect(journaled.groupOf('task-b')).toBe('turn-a')
expect(journaled.groupOf('task-c')).toBeNull()
const claimed = journaled.claimGroup('turn-a')
expect(claimed?.entries.map((agent) => agent.id)).toEqual(['task-a', 'task-b'])
// The row keeps the turn it was created beside.
expect(claimed?.placement).toEqual(turnA)
// After that the roster's own copy is the newer one.
expect(journaled.claimGroup('turn-a')).toBeNull()
})
it('reads nothing before the journal is bound, and does not keep that answer', () => {
let bound: StructuredAgentSessionLinkageJournal | null = null
const journaled = new ClaudeJournaledRoster(() => bound)
expect(journaled.canonical('toolu_a')).toBeNull()
expect(journaled.claimGroup('turn-a')).toBeNull()
bound = linkageJournal('epoch-1', () => [
agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' }),
groupRow(2, 'turn-a', [entry('task-a', 'failed')])
])
expect(journaled.canonical('toolu_a')).toBe('task-a')
expect(journaled.claimGroup('turn-a')?.entries).toHaveLength(1)
})
it('re-reads the same journal once its epoch is replaced', () => {
let rows = [
agentRow(1, { agentId: 'task-a', providerParentRef: 'toolu_a', producerKind: 'agent' })
]
const journal = linkageJournal('epoch-1', () => rows)
const journaled = new ClaudeJournaledRoster(() => journal)
expect(journaled.canonical('toolu_a')).toBe('task-a')
rows = [agentRow(1, { agentId: 'task-b', providerParentRef: 'toolu_b', producerKind: 'agent' })]
journal.epoch = 'epoch-2'
expect(journaled.canonical('toolu_a')).toBeNull()
expect(journaled.canonical('toolu_b')).toBe('task-b')
})
})
@@ -1,75 +0,0 @@
// What earlier runs of this session left of the Claude subagent roster, re-derived
// from the rows they journaled.
//
// Beyond the group rows every agent's tracker inherits, a Claude run needs two more
// facts earlier runs recorded on their child rows: the canonical task id each spawn
// call resolved to (a resumed child's frames still carry the old call), and the
// latest run of each child. Both come from the same read of the journal.
import type {
AgentJournalItemIdentity,
AgentJournalProducerLinkage,
AgentJournalTurnScope
} from '../../shared/agent-session-journal-types'
import type { StructuredAgentSessionLinkageJournal } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { JournaledSubagentGroups } from '../native-chat/subagent-tracker/journaled-subagent-groups'
import type { JournaledSubagentSource } from '../native-chat/subagent-tracker/subagent-tracker-types'
import { isBoundedClaudeTaskId } from './claude-background-task-tracker'
/** Durable journal identity for a group's row — stable across revisions and
* across a restart, so replay finds the same row instead of appending a new one. */
export function claudeSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity {
return { provider: 'orca', clientMessageId: `claude-subagents:${groupId}` }
}
/** What the roster asks of earlier runs. */
export type ClaudeJournaledRosterSource = JournaledSubagentSource<AgentJournalTurnScope> & {
/** The task id an earlier run resolved this spawn call to. */
canonical: (toolUseId: string) => string | null
}
type ClaudeAgentRows = {
canonicalByToolUse: Map<string, string>
attemptByAgent: Map<string, number>
}
export class ClaudeJournaledRoster implements ClaudeJournaledRosterSource {
private readonly rows: JournaledSubagentGroups<ClaudeAgentRows>
constructor(bound: () => StructuredAgentSessionLinkageJournal | null) {
this.rows = new JournaledSubagentGroups(bound, claudeSubagentGroupIdentity, {
create: () => ({ canonicalByToolUse: new Map(), attemptByAgent: new Map() }),
visit: readAgentRow
})
}
canonical = (toolUseId: string): string | null =>
this.rows.extra()?.canonicalByToolUse.get(toolUseId) ?? null
groupOf = (entryId: string): string | null => this.rows.groupOf(entryId)
claimGroup = (groupId: string) => this.rows.claimGroup(groupId)
attempt = (agentId: string): number => this.rows.extra()?.attemptByAgent.get(agentId) ?? 1
}
function readAgentRow(
reading: ClaudeAgentRows,
{ agentId, providerParentRef, producerKind, attempt }: AgentJournalProducerLinkage
): void {
if (producerKind !== 'agent' || agentId === undefined) {
return
}
if (attempt !== undefined && attempt > (reading.attemptByAgent.get(agentId) ?? 1)) {
reading.attemptByAgent.set(agentId, attempt)
}
// A row stamped with its own reference was never resolved, so it names no alias.
if (
providerParentRef !== undefined &&
agentId !== providerParentRef &&
isBoundedClaudeTaskId(agentId) &&
isBoundedClaudeTaskId(providerParentRef)
) {
reading.canonicalByToolUse.set(providerParentRef, agentId)
}
}
@@ -2,21 +2,11 @@ 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 {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentJournalRenderItem
} from '../../shared/agent-session-journal-types'
import {
isBackgroundTaskBlock,
isSubagentGroupBlock,
type NativeChatSubagentEntry
} from '../../shared/native-chat-types'
import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types'
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support'
import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body'
import { claudeSubagentGroupIdentity } from './claude-subagent-journaled-roster'
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
@@ -89,30 +79,6 @@ const taskFailed = (taskId: string, toolUseId: string) =>
status: 'failed'
})
const taskCompleted = (taskId: string, toolUseId: string) =>
frame({
type: 'system',
subtype: 'task_notification',
uuid: `completed-${taskId}`,
task_id: taskId,
tool_use_id: toolUseId,
status: 'completed'
})
/** A call a CHILD makes: its frame names the child's own spawn call as parent. */
const childToolCall = (
uuid: string,
parentToolUseId: string,
id: string,
input: Record<string, unknown>
) =>
frame({
type: 'assistant',
uuid,
parent_tool_use_id: parentToolUseId,
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Agent', input }] }
})
const childSays = (uuid: string, parentToolUseId: string, text: string) =>
frame({
type: 'assistant',
@@ -164,70 +130,6 @@ const proseRow = (journal: AgentSessionJournal, text: string): AgentJournalRende
item.body.blocks.some((block) => block.type === 'text' && block.text === text)
)
type GroupRow = { groupId: string; revision: number; agents: NativeChatSubagentEntry[] }
/** Every spawn-group row, as a reader sees it now. */
const groupRows = (journal: AgentSessionJournal): GroupRow[] =>
journal.snapshot().items.flatMap((item) => {
const block = item.body.kind === 'message' ? item.body.blocks.find(isSubagentGroupBlock) : null
return block ? [{ groupId: block.groupId, revision: item.revision, agents: block.agents }] : []
})
const rowsListing = (journal: AgentSessionJournal, agentId: string): GroupRow[] =>
groupRows(journal).filter((row) => row.agents.some((agent) => agent.id === agentId))
/** Every task a background-task row names. */
const backgroundTaskIds = (journal: AgentSessionJournal): string[] =>
journal
.snapshot()
.items.flatMap((item) =>
item.body.kind === 'message'
? item.body.blocks.flatMap((block) => (isBackgroundTaskBlock(block) ? [block.taskId] : []))
: []
)
/** Every id any spawn-group row names: what a transcript can put a name on. */
const rosterIds = (journal: AgentSessionJournal): Set<string> =>
new Set(groupRows(journal).flatMap((row) => row.agents.map((agent) => agent.id)))
/** An older build re-rostered a resumed child in the later turn's row, so two rows list it. */
async function journalAnOlderBuildListedTwice(): Promise<AgentSessionJournal> {
const journal = await openJournal()
const older = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging())
older.bind({ journal, fence: 1, publish: () => {} })
const listed = (id: string, state: NativeChatSubagentEntry['state']) => ({
id,
label: `Child ${id}`,
state,
startedAt: 1,
settledAt: 2
})
for (const [groupId, agents] of [
[
'claude-session:turn-a',
[listed('agent-a', 'unverifiable'), listed('agent-b', 'unverifiable')]
],
['claude-session:turn-b', [listed('agent-a', 'completed')]]
] as const) {
older.sink.appendItem(
claudeSubagentGroupIdentity(groupId),
subagentGroupJournalBody(groupId, agents),
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
}
await expect(older.drained()).resolves.toEqual({ ok: true })
older.close()
return journal
}
const agentAStateByRow = (journal: AgentSessionJournal): Record<string, string | undefined> =>
Object.fromEntries(
rowsListing(journal, 'agent-a').map((row) => [
row.groupId,
row.agents.find((agent) => agent.id === 'agent-a')?.state
])
)
describe('a Claude subagent resumed after its provider restarted', () => {
it('keeps the canonical id its first run was given, so one child stays one child', async () => {
const journal = await openJournal()
@@ -284,234 +186,13 @@ describe('a Claude subagent resumed after its provider restarted', () => {
agentId: 'agent-b',
providerParentRef: 'toolu_spawn_b'
})
// The resume revised the child's own entry, as it would have without a restart: one row names
// it, and its rows say which run of it they are.
expect(rowsListing(journal, 'agent-a')).toEqual([
expect.objectContaining({
groupId: 'claude-session:turn-a',
agents: [
expect.objectContaining({ id: 'agent-a', state: 'working' }),
expect.objectContaining({ id: 'agent-b', state: 'working' })
]
})
])
// Its rows say which run of it they are.
expect(proseRow(journal, 'A reads the rig recipes')?.attempt).toBeUndefined()
expect(proseRow(journal, 'A restarts the receiver')?.attempt).toBe(2)
expect(proseRow(journal, 'B checks the rig')?.attempt).toBe(2)
// What a reader sees: every child row names an agent a roster can name.
const named = rosterIds(journal)
const childRows = journal.snapshot().items.filter((item) => item.agentId !== undefined)
expect(childRows.length).toBeGreaterThan(0)
expect(childRows.filter((item) => !named.has(item.agentId ?? ''))).toEqual([])
await second.exit()
})
it('keeps the earlier run’s children in the roster row no turn owns', async () => {
// A backgrounded child spawns its own child after the parent's turn has ended, so the
// grandchild is rostered outside any turn; the next provider run does the same again.
const journal = await openJournal()
const first = acquire(journal)
first.translator.handle(userTurn('turn-a'))
first.translator.handle(
toolCall('spawn-c', 'toolu_spawn_c', 'Agent', {
description: 'Readiness',
run_in_background: true
})
)
first.translator.handle(taskStarted('agent-c', 'toolu_spawn_c', 'Readiness'))
first.translator.handle(toolResult('spawned-c', 'toolu_spawn_c', 'Async agent launched.'))
first.translator.handle(result('result-a'))
first.translator.handle(
childToolCall('c-spawn', 'toolu_spawn_c', 'toolu_spawn_x', { description: 'Sweep leases' })
)
first.translator.handle(taskStarted('agent-x', 'toolu_spawn_x', 'Sweep leases'))
first.translator.handle(childSays('x-1', 'toolu_spawn_x', 'X reads the hydration'))
first.translator.handle(taskCompleted('agent-x', 'toolu_spawn_x'))
first.translator.handle(taskCompleted('agent-c', 'toolu_spawn_c'))
await first.exit()
expect(rowsListing(journal, 'agent-x')).toEqual([
expect.objectContaining({ groupId: 'outside-turn' })
])
const second = acquire(journal)
second.translator.handle(userTurn('turn-b'))
second.translator.handle(
toolCall('spawn-d', 'toolu_spawn_d', 'Agent', {
description: 'Checklist',
run_in_background: true
})
)
second.translator.handle(taskStarted('agent-d', 'toolu_spawn_d', 'Checklist'))
second.translator.handle(toolResult('spawned-d', 'toolu_spawn_d', 'Async agent launched.'))
second.translator.handle(result('result-b'))
second.translator.handle(
childToolCall('d-spawn', 'toolu_spawn_d', 'toolu_spawn_y', { description: 'Run checklist' })
)
second.translator.handle(taskStarted('agent-y', 'toolu_spawn_y', 'Run checklist'))
await second.settle()
const outside = groupRows(journal).filter((row) => row.groupId === 'outside-turn')
expect(outside).toEqual([
expect.objectContaining({
agents: [
expect.objectContaining({ id: 'agent-x', label: 'Sweep leases', state: 'completed' }),
expect.objectContaining({ id: 'agent-y', label: 'Run checklist', state: 'working' })
]
})
])
expect(proseRow(journal, 'X reads the hydration')?.agentId).toBe('agent-x')
expect(rosterIds(journal).has('agent-x')).toBe(true)
await second.exit()
})
it('takes Claude’s verdict on a child the earlier run lost contact with, and reopens it on an announcement', async () => {
// The provider exited while its backgrounded child still ran, so the roster said contact was
// lost. The next run is told the child did not finish, works on its own, then messages it.
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(1_000_000)
const journal = await openJournal()
const first = acquire(journal)
first.translator.handle(userTurn('turn-a'))
first.translator.handle(
toolCall('spawn-a', 'toolu_spawn_a', 'Agent', {
description: 'Grok PR',
run_in_background: true
})
)
first.translator.handle(taskStarted('agent-a', 'toolu_spawn_a', 'Grok PR'))
first.translator.handle(toolResult('spawned-a', 'toolu_spawn_a', 'Async agent launched.'))
first.translator.handle(childSays('a-1', 'toolu_spawn_a', 'A reads the docs'))
first.translator.handle(result('result-a'))
await first.exit()
const [lost] = rowsListing(journal, 'agent-a')
expect(lost?.agents).toEqual([
expect.objectContaining({ id: 'agent-a', state: 'unverifiable' })
])
const lostAt = lost?.agents[0]?.settledAt
expect(lostAt).toBe(1_000_000)
// The restart comes much later: that is not when the child stopped.
vi.setSystemTime(5_000_000)
const second = acquire(journal)
second.translator.handle(userTurn('turn-b'))
second.translator.handle(
frame({
type: 'system',
subtype: 'task_notification',
uuid: 'unfinished-a',
task_id: 'agent-a',
status: 'stopped',
summary: 'Background agent "Grok PR" didn\'t finish before the previous session ended'
})
)
second.translator.handle(toolCall('bash-1', 'toolu_bash_1', 'Bash', { command: 'git status' }))
second.translator.handle(toolResult('bashed-1', 'toolu_bash_1', 'clean'))
await second.settle()
// The child's own row takes the verdict, keeping when that run lost it; no second row names it.
expect(rowsListing(journal, 'agent-a')).toEqual([
expect.objectContaining({
groupId: 'claude-session:turn-a',
agents: [expect.objectContaining({ id: 'agent-a', state: 'stopped', settledAt: lostAt })]
})
])
expect(backgroundTaskIds(journal)).toEqual([])
// The resume is a new run: its clock starts now, not at the first run's start.
vi.setSystemTime(5_100_000)
second.translator.handle(
toolCall('resume-a', 'toolu_message_a', 'SendMessage', { to: 'agent-a', message: 'resume' })
)
second.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Grok PR'))
second.translator.handle(childSays('a-2', 'toolu_spawn_a', 'A resumes the capture'))
await second.settle()
expect(rowsListing(journal, 'agent-a')).toEqual([
expect.objectContaining({
groupId: 'claude-session:turn-a',
agents: [expect.objectContaining({ id: 'agent-a', state: 'working', startedAt: 5_100_000 })]
})
])
expect(rowsListing(journal, 'agent-a')[0]?.agents[0]?.settledAt).toBeUndefined()
expect(proseRow(journal, 'A resumes the capture')).toMatchObject({
agentId: 'agent-a',
attempt: 2
})
await second.exit()
})
it('invents no stop time for a child whose host died without sweeping it', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(1_000_000)
const crashed = await openJournal()
const first = acquire(crashed)
first.translator.handle(userTurn('turn-a'))
first.translator.handle(
toolCall('spawn-a', 'toolu_spawn_a', 'Agent', {
description: 'Grok PR',
run_in_background: true
})
)
first.translator.handle(taskStarted('agent-a', 'toolu_spawn_a', 'Grok PR'))
await first.settle()
// The host dies: nothing sweeps the child, and reopening the journal can say only that
// contact was lost, not when.
await crashed.close()
const journal = await openJournal()
const [lost] = rowsListing(journal, 'agent-a')[0]?.agents ?? []
expect(lost).toMatchObject({ id: 'agent-a', state: 'unverifiable' })
expect(lost?.settledAt).toBeUndefined()
vi.setSystemTime(5_000_000)
const second = acquire(journal)
second.translator.handle(userTurn('turn-b'))
second.translator.handle(
frame({
type: 'system',
subtype: 'task_notification',
uuid: 'unfinished-a',
task_id: 'agent-a',
status: 'stopped'
})
)
await second.settle()
const [stopped] = rowsListing(journal, 'agent-a')[0]?.agents ?? []
expect(stopped).toMatchObject({ id: 'agent-a', state: 'stopped' })
expect(stopped?.settledAt).toBeUndefined()
await second.exit()
})
it('keeps one live entry for a child an older build listed in two rows', async () => {
const journal = await journalAnOlderBuildListedTwice()
const run = acquire(journal)
run.translator.handle(userTurn('turn-c'))
run.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Child agent-a'))
// A frame for a sibling only the other row lists brings that row in too.
run.translator.handle(taskCompleted('agent-b', 'toolu_spawn_b'))
run.translator.handle(taskCompleted('agent-a', 'toolu_message_a'))
await run.settle()
// The copy the resume reopened is the one its outcome lands on; nothing is left running.
expect(agentAStateByRow(journal)).toEqual({
'claude-session:turn-a': 'unverifiable',
'claude-session:turn-b': 'completed'
})
await run.exit()
})
it('resumes a twice-listed child in its later row even when a sibling reaches the older row first', async () => {
const journal = await journalAnOlderBuildListedTwice()
const run = acquire(journal)
run.translator.handle(userTurn('turn-c'))
run.translator.handle(taskCompleted('agent-b', 'toolu_spawn_b'))
run.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Child agent-a'))
await run.settle()
expect(agentAStateByRow(journal)).toEqual({
'claude-session:turn-a': 'unverifiable',
'claude-session:turn-b': 'working'
})
await run.exit()
})
it('still files a child no journaled row resolved under its own reference, never the parent', async () => {
const journal = await openJournal()
const first = acquire(journal)
@@ -1,196 +0,0 @@
// Golden row writes for the Claude subagent roster: every append, tombstone and publish it makes
// for scripted frame sequences, so moving the roster onto shared code cannot change a saved byte.
import { describe, expect, it } from 'vitest'
import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import { recordingSubagentRowSink } from '../native-chat/subagent-row-sink-recorder-test-support'
import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster'
import { ClaudeSubagentRoster } from './claude-subagent-roster'
/** The clock moves once per delivered frame, never per read, so a row's times do not depend on how
* often the roster happens to read it. */
function harness(journaled?: ClaudeJournaledRosterSource) {
const recorder = recordingSubagentRowSink()
let clock = 1_000
let key: string | null = 's:turn-1'
const scope = (): AgentJournalTurnScope =>
key === null ? { kind: 'thread' } : { kind: 'turn', turnItemId: `turn-row:${key}` }
const inner = new ClaudeSubagentRoster({
sink: recorder.sink,
currentGroupKey: () => key,
currentTurnScope: scope,
...(journaled ? { journaled } : {}),
now: () => clock
})
const tick = <T>(deliver: () => T): T => {
clock += 10
return deliver()
}
const roster = {
observeSystemFrame: (message: Record<string, unknown>) =>
tick(() => inner.observeSystemFrame(message)),
observeChildActivity: (toolUseId: string) => tick(() => inner.observeChildActivity(toolUseId)),
observeToolResult: (toolUseId: string, failed: boolean) =>
tick(() => inner.observeToolResult(toolUseId, failed)),
settleTurn: (groupKey: string | null) => tick(() => inner.settleTurn(groupKey)),
settleSession: () => tick(() => inner.settleSession()),
dispose: () => tick(() => inner.dispose())
}
return {
...recorder,
roster,
turn: (next: string | null) => {
key = next
}
}
}
function frame(subtype: string, fields: Record<string, unknown>): Record<string, unknown> {
return { type: 'system', subtype, session_id: 's', ...fields }
}
const started = (fields: Record<string, unknown>) =>
frame('task_started', { task_type: 'local_agent', ...fields })
const notified = (fields: Record<string, unknown>) => frame('task_notification', fields)
describe('Claude subagent roster rows (characterization)', () => {
it('one foreground child settled by its spawn call result', () => {
const { roster, log } = harness()
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Audit' }))
roster.observeSystemFrame(frame('task_progress', { task_id: 't1', tool_use_id: 'u1' }))
roster.observeToolResult('u1', false)
roster.observeToolResult('u1', false)
roster.settleTurn('s:turn-1')
expect(log).toMatchSnapshot()
})
it('two children sharing a label, one backgrounded, through turn end and session end', () => {
const { roster, log } = harness()
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Audit' }))
roster.observeSystemFrame(
started({ task_id: 't2', tool_use_id: 'u2', description: 'Audit', is_backgrounded: true })
)
roster.observeSystemFrame(started({ task_id: 't3', tool_use_id: 'u3', description: 'Audit 2' }))
roster.settleTurn('s:turn-1')
roster.observeSystemFrame(notified({ task_id: 't3', tool_use_id: 'u3', status: 'completed' }))
roster.settleSession()
roster.dispose()
expect(log).toMatchSnapshot()
})
it('verdicts after lost contact, failures and stops', () => {
const { roster, log } = harness()
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'A' }))
roster.observeSystemFrame(started({ task_id: 't2', tool_use_id: 'u2', description: 'B' }))
roster.observeSystemFrame(started({ task_id: 't3', tool_use_id: 'u3', description: 'C' }))
roster.observeSystemFrame(notified({ task_id: 't2', tool_use_id: 'u2', status: 'killed' }))
roster.observeToolResult('u3', true)
roster.settleTurn('s:turn-1')
roster.observeSystemFrame(notified({ task_id: 't1', tool_use_id: 'u1', status: 'completed' }))
roster.observeSystemFrame(notified({ task_id: 't2', tool_use_id: 'u2', status: 'completed' }))
expect(log).toMatchSnapshot()
})
it('more than 32 settled groups, then a late frame for an evicted child', () => {
const { roster, log, turn } = harness()
for (let index = 0; index < 34; index++) {
turn(`s:turn-${index}`)
roster.observeSystemFrame(
started({ task_id: `t${index}`, tool_use_id: `u${index}`, description: `Job ${index}` })
)
roster.observeSystemFrame(
notified({ task_id: `t${index}`, tool_use_id: `u${index}`, status: 'completed' })
)
}
turn('s:turn-live')
roster.observeSystemFrame(started({ task_id: 'live', tool_use_id: 'ul', description: 'Live' }))
roster.observeSystemFrame(notified({ task_id: 't0', tool_use_id: 'u0', status: 'failed' }))
roster.observeSystemFrame(started({ task_id: 't0', tool_use_id: 'u0', description: 'Job 0' }))
expect(log.length).toMatchSnapshot()
expect(log.slice(-6)).toMatchSnapshot()
})
it('a provisional row from child traffic takes the announced name and canonical id', () => {
const { roster, log } = harness()
roster.observeChildActivity('u1')
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Named' }))
roster.observeChildActivity('u1')
roster.observeChildActivity('u-unknown')
roster.observeSystemFrame(notified({ task_id: 't1', tool_use_id: 'u1', status: 'completed' }))
expect(log).toMatchSnapshot()
})
it('an announcement that is not a subagent tombstones the provisional row', () => {
const { roster, log } = harness()
roster.observeChildActivity('u1')
roster.observeSystemFrame(
frame('task_started', { task_id: 't1', tool_use_id: 'u1', task_type: 'local_bash' })
)
roster.observeChildActivity('u1')
expect(log).toMatchSnapshot()
})
it('a resume under a new tool id in a later turn reopens the original row', () => {
const { roster, log, turn } = harness()
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Audit' }))
roster.observeToolResult('u1', false)
roster.settleTurn('s:turn-1')
turn('s:turn-2')
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1b', description: 'Audit' }))
roster.observeSystemFrame(notified({ task_id: 't1', tool_use_id: 'u1', status: 'failed' }))
roster.observeToolResult('u1b', false)
roster.settleTurn('s:turn-2')
expect(log).toMatchSnapshot()
})
it('a child announced outside any turn is not swept by a turn end', () => {
const { roster, log, turn } = harness()
turn(null)
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'Loose' }))
turn('s:turn-9')
roster.settleTurn('s:turn-9')
roster.settleTurn(null)
expect(log).toMatchSnapshot()
})
it('a refused append or publish is retried by the next write of that row', () => {
const { roster, log, refuseNextAppend, refuseNextPublish } = harness()
refuseNextAppend()
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u1', description: 'A' }))
roster.observeSystemFrame(frame('task_progress', { task_id: 't1', tool_use_id: 'u1' }))
roster.observeSystemFrame(started({ task_id: 't2', tool_use_id: 'u2', description: 'B' }))
refuseNextPublish()
roster.observeToolResult('u2', false)
roster.observeToolResult('u2', false)
roster.settleTurn('s:turn-1')
expect(log).toMatchSnapshot()
})
it('a later run inherits the row an earlier run journaled and revises it', () => {
const earlier: NativeChatSubagentEntry[] = [
{ id: 't1', label: 'Audit', state: 'working', startedAt: 5 },
{ id: 't2', label: 'Audit 2', state: 'completed', startedAt: 6, settledAt: 9 }
]
let claimed = false
const journaled: ClaudeJournaledRosterSource = {
canonical: (toolUseId) => (toolUseId === 'u1' ? 't1' : null),
groupOf: (id) => (id === 't1' || id === 't2' ? 's:turn-0' : null),
claimGroup: (groupId) => {
if (groupId !== 's:turn-0' || claimed) {
return null
}
claimed = true
return { entries: earlier, placement: { kind: 'turn', turnItemId: 'turn-row:s:turn-0' } }
},
attempt: (id) => (id === 't1' ? 2 : 1)
}
const { roster, log, turn } = harness(journaled)
turn('s:turn-3')
roster.observeSystemFrame(notified({ task_id: 't1', status: 'completed' }))
roster.observeSystemFrame(started({ task_id: 't1', tool_use_id: 'u9', description: 'Audit' }))
roster.observeSystemFrame(started({ task_id: 't4', tool_use_id: 'u4', description: 'Audit' }))
roster.settleSession()
expect(log).toMatchSnapshot()
})
})
@@ -1,196 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentJournalItemBody,
type AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { isSubagentGroupBlock } from '../../shared/native-chat-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { ClaudeSubagentRoster } from './claude-subagent-roster'
import type { ClaudeJournaledRosterSource } from './claude-subagent-journaled-roster'
function harness(journaled?: ClaudeJournaledRosterSource) {
const rows = new Map<string, AgentJournalItemBody>()
let group = 'oldest'
let writes = 0
let refuseAppend = false
let refusePublish = false
const append = (identity: AgentJournalItemIdentity, body: AgentJournalItemBody): void => {
if (identity.provider !== 'orca') {
throw new Error('unexpected roster identity')
}
rows.set(identity.clientMessageId, body)
writes++
}
const sink: StructuredAgentSessionEventSink = {
appendItem: append,
appendTombstone: () => {},
publish: () => {},
tryAppendItem: (identity, body) => {
if (refuseAppend) {
return { accepted: false, reason: 'backpressure' }
}
append(identity, body)
return { accepted: true }
},
tryPublish: () =>
refusePublish ? { accepted: false, reason: 'backpressure' } : { accepted: true }
}
const roster = new ClaudeSubagentRoster({
sink,
journaled,
currentGroupKey: () => group,
currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE
})
const start = (id: string, tool = id, backgrounded = false): void => {
roster.observeSystemFrame({
type: 'system',
subtype: 'task_started',
task_type: 'local_agent',
task_id: id,
tool_use_id: tool,
description: id,
is_backgrounded: backgrounded
})
}
const finish = (id: string, tool = id): void => {
roster.observeSystemFrame({
type: 'system',
subtype: 'task_notification',
task_id: id,
tool_use_id: tool,
status: 'completed'
})
}
const agents = (key: string) => {
const body = rows.get(`claude-subagents:${key}`)
return body?.kind === 'message' ? (body.blocks.find(isSubagentGroupBlock)?.agents ?? []) : []
}
const churn = (count: number, settled = true): void => {
for (let index = 0; index < count; index++) {
group = `later-${index}`
start(`later-child-${index}`)
if (settled) {
finish(`later-child-${index}`)
}
}
}
return {
roster,
start,
finish,
agents,
churn,
writes: () => writes,
setGroup: (key: string) => {
group = key
},
refuse: (append: boolean, publish: boolean) => {
refuseAppend = append
refusePublish = publish
}
}
}
describe('Claude shared settled-only roster retention', () => {
it('keeps every child of the oldest live group past 33 later spawn groups', () => {
const h = harness()
h.start('a', 'tool-a', true)
h.start('b', 'tool-b', true)
h.roster.settleTurn('oldest')
h.churn(34)
expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['working', 'working'])
h.finish('a', 'tool-a')
expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['completed', 'working'])
h.finish('b', 'tool-b')
expect(h.agents('oldest').map((entry) => entry.id)).toEqual(['a', 'b'])
expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['completed', 'completed'])
})
it('reaches every still-running group in the session-end sweep', () => {
const h = harness()
h.start('a', 'tool-a', true)
h.start('b')
h.churn(34, false)
h.roster.settleSession()
expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['unverifiable', 'unverifiable'])
expect(h.agents('later-33')[0].state).toBe('unverifiable')
})
it('ignores old announcements after settled eviction and alias-cache churn, but admits a new invocation', () => {
const h = harness()
h.start('a', 'first')
h.finish('a', 'first')
h.start('a', 'second')
h.finish('a', 'second')
h.churn(600)
const before = h.writes()
h.start('a', 'first')
h.start('a', 'second')
h.roster.observeSystemFrame({
type: 'system',
subtype: 'task_started',
task_type: 'local_agent',
task_id: 'a'
})
h.roster.observeChildActivity('first')
expect(h.writes()).toBe(before)
expect(h.roster.linkage.settledLinkageFor('first').linkage.agentId).toBe('a')
h.setGroup('resumed')
h.start('a', 'third')
expect(h.agents('resumed')).toEqual([expect.objectContaining({ id: 'a', state: 'working' })])
})
it('keeps an inherited row reachable when child traffic precedes its resume announcement', () => {
let claimed = false
const h = harness({
canonical: (tool) => (tool === 'original-spawn' ? 'a' : null),
groupOf: (id) => (['a', 'b'].includes(id) ? 'inherited' : null),
claimGroup: (group) => {
if (group !== 'inherited' || claimed) {
return null
}
claimed = true
return {
entries: ['a', 'b'].map((id) => ({
id,
label: id,
state: 'completed',
startedAt: 1,
settledAt: 2
})),
placement: AGENT_JOURNAL_THREAD_SCOPE
}
},
attempt: () => 4
})
h.churn(32, false)
h.roster.observeChildActivity('original-spawn')
h.setGroup('resumed')
h.start('a', 'resume-tool', true)
expect(h.agents('inherited').map((entry) => entry.state)).toEqual(['working', 'completed'])
expect(h.agents('resumed')).toHaveLength(0)
expect(h.roster.linkage.settledLinkageFor('original-spawn').linkage).toMatchObject({
agentId: 'a',
attempt: 5
})
})
for (const failure of ['append', 'publish']) {
it(`retains a terminal group whose final ${failure} was refused and retries it`, () => {
const h = harness()
h.start('a')
h.start('b')
h.refuse(failure === 'append', failure === 'publish')
h.finish('a')
h.finish('b')
h.refuse(false, false)
h.churn(34)
h.finish('b')
expect(h.agents('oldest').map((entry) => entry.state)).toEqual(['completed', 'completed'])
const before = h.writes()
h.start('a')
expect(h.writes()).toBe(before)
})
}
})
@@ -1,624 +0,0 @@
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types'
import { describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import type {
NativeChatSubagentEntry,
NativeChatSubagentGroupBlock
} from '../../shared/native-chat-types'
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
import {
createDeferredStructuredAgentSessionEventSink,
type StructuredAgentSessionEventSink
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { ClaudeSubagentRoster } from './claude-subagent-roster'
import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support'
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
const TURN_1 = 'claude-session:turn-1'
function agentsOf(body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] {
if (!body || body.kind !== 'message') {
return []
}
const block = body.blocks.find(
(candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group'
)
return block ? block.agents : []
}
function isGroupRow(identity: AgentJournalItemIdentity, groupId: string): boolean {
return identity.provider === 'orca' && identity.clientMessageId === `claude-subagents:${groupId}`
}
function harness(groupKey: string | null = TURN_1) {
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
const tombstones: AgentJournalItemIdentity[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity, body) => items.push({ identity, body }),
appendTombstone: (identity) => tombstones.push(identity),
publish: vi.fn()
}
let clock = 1_000
let pinnedClock: number | null = null
let key = groupKey
const roster = new ClaudeSubagentRoster({
sink,
currentGroupKey: () => key,
currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE,
now: () => pinnedClock ?? (clock += 1)
})
const roles = (): NativeChatSubagentEntry[] => agentsOf(items.at(-1)?.body)
/** The last row written for one group, so a test can read a row that is no
* longer the newest one. */
const rolesIn = (groupId: string): NativeChatSubagentEntry[] =>
agentsOf(items.findLast((item) => isGroupRow(item.identity, groupId))?.body)
return {
roster,
items,
tombstones,
roles,
rolesIn,
setGroupKey: (next: string | null) => {
key = next
},
pinClock: (at: number) => {
pinnedClock = at
}
}
}
function system(subtype: string, fields: Record<string, unknown>): Record<string, unknown> {
return { type: 'system', subtype, session_id: 'claude-session', ...fields }
}
function started(fields: Record<string, unknown>): Record<string, unknown> {
return system('task_started', { task_type: 'local_agent', ...fields })
}
describe('ClaudeSubagentRoster', () => {
it('builds the row from task_started, with the fallback sentence beside the block', () => {
const { roster, items, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Review the diff' })
)
expect(items).toHaveLength(1)
expect(items[0]?.identity).toEqual({
provider: 'orca',
clientMessageId: 'claude-subagents:claude-session:turn-1'
})
const body = items[0]?.body
expect(body?.kind === 'message' && body.blocks[0]).toEqual({
type: 'text',
text: 'Kicked off 1 subagent'
})
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Review the diff', state: 'working' })
])
})
it('keeps a backgrounded shell task out of the roster', () => {
const { roster, items } = harness()
roster.observeSystemFrame(
system('task_started', {
task_id: 'task-bash',
tool_use_id: 'toolu_bash',
task_type: 'local_bash',
description: 'sleep 20',
is_backgrounded: true
})
)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-bash', patch: { status: 'running' } })
)
// Its own frames carry a tool_use_id, so only the excluded-id memory stops it.
roster.observeChildActivity('toolu_bash')
expect(items).toHaveLength(0)
})
it('never renders a task marked skip_transcript', () => {
const { roster, items } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-a', tool_use_id: 'toolu_a', skip_transcript: true })
)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-a', patch: { status: 'completed' } })
)
roster.observeChildActivity('toolu_a')
expect(items).toHaveLength(0)
})
it('drops a provisional row once an announcement says the task is not a subagent', () => {
const { roster, items, tombstones, roles } = harness()
roster.observeChildActivity('toolu_bash')
expect(roles()).toHaveLength(1)
roster.observeSystemFrame(
system('task_started', {
task_id: 'task-bash',
tool_use_id: 'toolu_bash',
task_type: 'local_bash'
})
)
expect(tombstones).toEqual([
{ provider: 'orca', clientMessageId: 'claude-subagents:claude-session:turn-1' }
])
expect(items).toHaveLength(1)
})
it('does not duplicate a resumed task re-announced under a new tool_use_id', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_first', description: 'Audit' })
)
roster.observeChildActivity('toolu_first')
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_second', description: 'Audit' })
)
roster.observeChildActivity('toolu_second')
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Audit', state: 'working' })
])
})
it('adopts a row built from child traffic when the announcement finally names it', () => {
const { roster, roles } = harness()
roster.observeChildActivity('toolu_1')
expect(roles()).toEqual([expect.objectContaining({ id: 'toolu_1', label: 'subagent' })])
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' })
)
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' })
])
})
it('is idempotent: a repeated frame writes no new revision', () => {
const { roster, items } = harness()
const frame = started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Audit' })
roster.observeSystemFrame(frame)
roster.observeSystemFrame(frame)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'running' } })
)
expect(items).toHaveLength(1)
})
it('latches a terminal state against a later live report', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'failed' } })
)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'running' } })
)
expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })])
})
it('ignores an update for a task it never rostered', () => {
const { roster, items } = harness()
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-unknown', patch: { status: 'running' } })
)
expect(items).toHaveLength(0)
})
it('disambiguates children that share a description', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Explore' }))
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Explore' }))
expect(roles().map((agent) => agent.label)).toEqual(['Explore', 'Explore 2'])
})
describe('turn end', () => {
it('leaves a backgrounded child working and marks a foreground one unverifiable', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-fg', description: 'Foreground' }))
roster.observeSystemFrame(
started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true })
)
roster.settleTurn(TURN_1)
expect(roles()).toEqual([
expect.objectContaining({ label: 'Foreground', state: 'unverifiable' }),
expect.objectContaining({ label: 'Background', state: 'working' })
])
})
it('never re-settles a child that already reported an outcome', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } })
)
roster.settleTurn(TURN_1)
expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('sweeps backgrounded children only when the provider itself is gone', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true })
)
roster.settleTurn(TURN_1)
roster.settleSession()
expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
})
describe('spawn tool result', () => {
it('settles a foreground child', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' }))
roster.observeToolResult('toolu_1', false)
expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('reports a failed spawn as failed', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' }))
roster.observeToolResult('toolu_1', true)
expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })])
})
it('ignores the immediate result a backgrounded spawn returns', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', is_backgrounded: true })
)
roster.observeToolResult('toolu_1', false)
expect(roles()).toEqual([expect.objectContaining({ state: 'working' })])
})
it('ignores results for tools that are not spawn calls', () => {
const { roster, items } = harness()
roster.observeToolResult('toolu_read', false)
expect(items).toHaveLength(0)
})
})
describe('label ordinals', () => {
it('never re-issues an ordinal a removed row gave up', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' }))
// task-1 is re-announced as a shell task, so its row goes; reclaiming the
// ordinal it held would print a second 'Audit 2' beside the one still shown.
roster.observeSystemFrame(
system('task_started', { task_id: 'task-1', task_type: 'local_bash' })
)
roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit' }))
expect(roles().map((agent) => agent.label)).toEqual(['Audit 2', 'Audit 3'])
})
it('never generates a label a provider-supplied one already took', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' }))
// The provider's own name for the third child is the label the ordinal just
// generated for the second; a per-base counter would print it twice.
roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit 2' }))
const labels = roles().map((agent) => agent.label)
expect(labels).toEqual(['Audit', 'Audit 2', 'Audit 2 2'])
expect(new Set(labels).size).toBe(labels.length)
})
})
describe('child traffic for an id the CLI never declared', () => {
it('creates nothing once the CLI has announced any task at all', () => {
const { roster, items } = harness()
// A rejected announcement still proves this CLI declares what it spawns.
roster.observeSystemFrame(
system('task_started', { task_id: 'task-bash', task_type: 'local_bash' })
)
roster.observeChildActivity('toolu_never_announced')
expect(items).toHaveLength(0)
})
it('rejects an over-long provisional id instead of storing it as an entry id', () => {
const { roster, items } = harness()
// The announced path drops an id past `claudeTaskId`'s bound; the
// provisional one writes the same durable entry id, so it must too.
roster.observeChildActivity(`toolu_${'x'.repeat(512)}`)
expect(items).toHaveLength(0)
roster.observeChildActivity(`toolu_${'x'.repeat(500)}`)
expect(items).toHaveLength(1)
})
it('still rosters a subagent announced after a task the filter rejected', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
system('task_started', { task_id: 'task-bash', task_type: 'local_bash' })
)
// The gate closes the child-traffic fallback, never the announcement path.
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' })
)
roster.observeChildActivity('toolu_1')
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' })
])
})
it('leaves a grandchild parented inside the sidechain out of the roster', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' })
)
roster.observeChildActivity('toolu_1')
// A tool the subagent itself ran: never announced, so never excluded either.
roster.observeChildActivity('toolu_inner')
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' })
])
})
it('still mints the provisional row for a release that announces no task', () => {
const { roster, roles } = harness()
roster.observeChildActivity('toolu_1')
// Not an announcement: the fallback path stays open for this release.
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-x', patch: { status: 'running' } })
)
roster.observeChildActivity('toolu_2')
expect(roles().map((agent) => agent.label)).toEqual(['subagent', 'subagent 2'])
})
})
describe('groups that no later event can reach', () => {
it('retains live groups past the settled-history bound', () => {
const { roster, rolesIn, setGroupKey } = harness('turn-0')
for (let index = 0; index < 33; index += 1) {
setGroupKey(`turn-${index}`)
roster.observeSystemFrame(started({ task_id: `task-${index}`, description: 'Audit' }))
}
expect(rolesIn('turn-0')).toEqual([expect.objectContaining({ state: 'working' })])
expect(rolesIn('turn-32')).toEqual([expect.objectContaining({ state: 'working' })])
})
it('loses contact with a live child when the translator is disposed without an end', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true })
)
roster.dispose()
expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('writes nothing on dispose when the session already settled', () => {
const { roster, items } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.settleSession()
const written = items.length
roster.dispose()
expect(items).toHaveLength(written)
})
})
it('groups children outside any turn under their own row', () => {
const { roster, items } = harness(null)
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
expect(items[0]?.identity).toEqual({
provider: 'orca',
clientMessageId: 'claude-subagents:outside-turn'
})
})
})
describe('ClaudeSubagentRoster — the turn that is ending', () => {
it('leaves a child announced outside any turn alone when an unrelated turn ends', () => {
const { roster, rolesIn, setGroupKey } = harness(null)
roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' }))
setGroupKey(TURN_1)
roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' }))
roster.settleTurn(TURN_1)
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })])
expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })])
// `unverifiable` latches, so sweeping it above would have swallowed this.
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-early', patch: { status: 'completed' } })
)
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('sweeps the outside-turn group when a turn with no key of its own ends', () => {
const { roster, rolesIn } = harness(null)
roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' }))
roster.settleTurn(null)
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('still settles an outside-turn child once the session itself ends', () => {
const { roster, rolesIn, setGroupKey } = harness(null)
roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' }))
setGroupKey(TURN_1)
roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' }))
roster.settleTurn(TURN_1)
roster.settleSession()
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('sweeps the turn that ended, not whichever turn is live now', () => {
const { roster, rolesIn, setGroupKey } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'First turn' }))
setGroupKey('claude-session:turn-2')
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Second turn' }))
// Turn 1's result lands after turn 2 has already begun.
roster.settleTurn(TURN_1)
expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })])
expect(rolesIn('claude-session:turn-2')).toEqual([
expect.objectContaining({ state: 'working' })
])
})
})
describe('ClaudeSubagentRoster — through the real sink queue', () => {
it('lands every revision, not just the one that was already in flight', async () => {
const appended: AgentJournalItemBody[] = []
let published = 0
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a double for the journal members this path calls; the helper adds the in-order ones.
const journal = withJournalQueueMembers({
appendItem: async (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => {
appended.push(body)
return { cursor: { epoch: 'e', sequence: appended.length } }
},
appendTombstone: async () => ({ epoch: 'e', sequence: 0 })
}) as unknown as AgentSessionJournal
const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging())
deferred.bind({
journal,
fence: 1,
publish: () => {
published += 1
}
})
const roster = new ClaudeSubagentRoster({
sink: deferred.sink,
currentGroupKey: () => TURN_1,
currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE
})
// The first append is in flight while the rest are submitted; each must still land.
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'One' }))
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Two' }))
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } })
)
const drained = await deferred.drained()
expect(drained).toEqual({ ok: true })
expect(agentsOf(appended.at(-1))).toEqual([
expect.objectContaining({ id: 'task-1', label: 'One', state: 'completed' }),
expect.objectContaining({ id: 'task-2', label: 'Two', state: 'working' })
])
expect(published).toBeGreaterThan(0)
})
})
describe('ClaudeSubagentRoster — authoritative outcomes and retained budgets', () => {
it('accepts a notification after the foreground turn lost contact', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1' }))
roster.settleTurn(TURN_1)
roster.observeSystemFrame(
system('task_notification', { task_id: 'task-1', status: 'completed' })
)
expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('settles a background child from its notification without a task_updated', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', is_backgrounded: true }))
roster.settleTurn(TURN_1)
roster.observeSystemFrame(system('task_notification', { task_id: 'task-1', status: 'failed' }))
expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })])
})
it('bounds lifetime admissions when reclassification repeatedly removes entries', () => {
const { roster, items } = harness()
for (let i = 0; i < 100; i++) {
roster.observeSystemFrame(started({ task_id: `task-${i}`, description: `Agent ${i}` }))
roster.observeSystemFrame(
system('task_started', { task_id: `task-${i}`, task_type: 'local_bash' })
)
}
expect(items).toHaveLength(64)
})
})
describe('ClaudeSubagentRoster — resumed invocation', () => {
it('reopens one canonical child on a new announcement without replaying old results', () => {
const { roster, roles, pinClock } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
roster.observeToolResult('first', false)
// Resumed long after its first run: the new run starts its own clock.
pinClock(900_000)
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true })
)
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', state: 'working', startedAt: 900_000 })
])
expect(roles()[0].settledAt).toBeUndefined()
expect(roster.linkage.settledLinkageFor('resumed')).toMatchObject({
linkage: { agentId: 'task-1', attempt: 2 }
})
roster.observeSystemFrame(
system('task_notification', { task_id: 'task-1', tool_use_id: 'first', status: 'completed' })
)
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
expect(roles()[0].state).toBe('working')
roster.observeSystemFrame(
system('task_notification', {
task_id: 'task-1',
tool_use_id: 'resumed',
status: 'completed'
})
)
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true })
)
expect(roles()[0].state).toBe('completed')
})
})
describe('ClaudeSubagentRoster — invocation fences', () => {
it('ignores a previous invocation tool result even without a background flag', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
roster.observeToolResult('first', false)
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'next' }))
roster.observeToolResult('first', true)
expect(roles()[0].state).toBe('working')
roster.observeToolResult('next', false)
expect(roles()[0].state).toBe('completed')
})
it('does not treat an evicted alias as a new invocation', () => {
const { roster, rolesIn, setGroupKey } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
roster.observeToolResult('first', false)
setGroupKey('churn')
for (let i = 0; i < 513; i++) {
roster.observeSystemFrame(
system('task_updated', { task_id: `other-${i}`, tool_use_id: `tool-${i}` })
)
}
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
expect(rolesIn(TURN_1)[0].state).toBe('completed')
})
it('keeps reopening a child resumed many times, and never revives a run it has forgotten', () => {
const { roster, roles } = harness()
for (let i = 0; i < 20; i++) {
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: `tool-${i}` }))
expect(roles()[0].state).toBe('working')
roster.observeToolResult(`tool-${i}`, false)
expect(roles()[0].state).toBe('completed')
}
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'tool-0' }))
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'tool-19' }))
expect(roles()[0].state).toBe('completed')
expect(roster.linkage.settledLinkageFor('tool-19')).toMatchObject({
linkage: { agentId: 'task-1', attempt: 20 }
})
})
})
it('merges an explicit foreground patch without clearing on absent metadata', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'tool', is_backgrounded: true })
)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { description: 'Audit' } })
)
roster.observeToolResult('tool', false)
expect(roles()[0].state).toBe('working')
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: false } })
)
roster.observeToolResult('tool', false)
expect(roles()[0].state).toBe('completed')
})
-223
View File
@@ -1,223 +0,0 @@
// The Claude subagent roster: one journal row per turn that spawned children.
//
// Entries are built from `task_started`, never from child traffic: a
// BACKGROUNDED subagent emits no child frames at all, so a roster fed by
// `parent_tool_use_id` alone would leave every one of them an unlabelled row
// forever. Child traffic only creates an entry for CLI releases that announce
// no task frames.
//
// Claude re-announces a resumed task under a NEW `tool_use_id`, so `task_id` is
// the key and each tool id is one run of it; keying on the tool id would
// duplicate the child on every resume. Everything the row says follows the
// shared subagent tracker's rules; this module only reads Claude's frames.
import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { subagentRowSinkPort } from '../native-chat/subagent-tracker/subagent-row-sink-port'
import { SubagentTracker } from '../native-chat/subagent-tracker/subagent-tracker'
import type { SubagentReport } from '../native-chat/subagent-tracker/subagent-tracker-types'
import { isBoundedClaudeTaskId } from './claude-background-task-tracker'
import { ClaudeSubagentIds } from './claude-subagent-id-aliases'
import {
claudeSubagentGroupIdentity,
type ClaudeJournaledRosterSource
} from './claude-subagent-journaled-roster'
import {
ClaudeSubagentLinkage,
type ClaudeAgentLinkageSource,
type ClaudeSubagentLinkageSource
} from './claude-subagent-linkage'
import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames'
/** The turn a group belongs to when Claude reports a task outside any turn. */
const OUTSIDE_TURN = 'outside-turn'
export type ClaudeSubagentRosterDeps = {
sink: StructuredAgentSessionEventSink
/** The turn that owns children spawned right now; null outside any turn. */
currentGroupKey: () => string | null
/** That turn's scope, which the roster row it spawns belongs to. */
currentTurnScope: () => AgentJournalTurnScope
/** Whether a tool id was forwarded at the TOP level. A child parented to one
* was spawned by a call the transcript shows, so its announcement is still
* expected; a child parented to anything else names an id that only ever
* existed inside a sidechain, which this CLI will never announce. */
isForwardedParentTool?: (toolUseId: string) => boolean
/** The reference naming the child that journaled a tool call, when a child
* did. It is how a grandchild's row reaches the agent that spawned it. */
childOwnerRefOf?: (toolUseId: string) => string | null
/** What earlier provider runs of this session journaled of this roster. */
journaled?: ClaudeJournaledRosterSource
/** A settled group can receive no further announcement, so an identity still
* provisional will stay that way. Fires on EVERY settle path, so a caller
* holding rows against a pending identity cannot miss one. */
onIdentitiesFinal?: () => void
now?: () => number
}
export class ClaudeSubagentRoster {
private readonly tracker: SubagentTracker<AgentJournalTurnScope>
private readonly ids: ClaudeSubagentIds
/** Who produced a row, for every write site journaling this session. */
readonly linkage: ClaudeSubagentLinkageSource & ClaudeAgentLinkageSource
/** Set by ANY `task_started`, including one the subagent filter rejects. Once
* this CLI has proven it declares its tasks, child traffic for an id it never
* announced is a nested tool or a grandchild, not a subagent. */
private announcesTasks = false
constructor(private readonly deps: ClaudeSubagentRosterDeps) {
this.tracker = new SubagentTracker({
port: subagentRowSinkPort(deps.sink, claudeSubagentGroupIdentity),
outsideTurn: (groupId) => groupId === OUTSIDE_TURN,
...(deps.journaled ? { journaled: deps.journaled } : {}),
...(deps.now ? { now: deps.now } : {})
})
this.ids = new ClaudeSubagentIds(deps.journaled?.canonical)
this.linkage = new ClaudeSubagentLinkage({
ids: this.ids,
trackedFor: (canonicalId) =>
this.tracker.locate(canonicalId) ? { attempt: this.tracker.attempt(canonicalId) } : null,
isForwardedParentTool: deps.isForwardedParentTool,
childOwnerRefOf: deps.childOwnerRefOf,
spawnRefOf: (canonicalId) => this.tracker.locate(canonicalId)?.tracked.run ?? null
})
}
/** Consume a `message:system:task_*` frame. Returns false when it is not one. */
observeSystemFrame(message: Record<string, unknown>): boolean {
const frame = readClaudeSubagentTaskFrame(message)
if (!frame) {
return false
}
this.announcesTasks ||= frame.announcement
if (frame.excluded) {
// Child traffic may already have built a provisional row under the tool id;
// the announcement is the first frame that says it is not a subagent.
for (const id of [frame.taskId, frame.toolUseId]) {
if (id !== null) {
this.ids.exclude(id)
this.tracker.remove(id)
}
}
return true
}
if (this.ids.isExcluded(frame.taskId, frame.toolUseId)) {
return true
}
if (frame.toolUseId) {
this.ids.alias(frame.toolUseId, frame.taskId)
if (!this.tracker.locate(frame.taskId)) {
// A provisional row built under the tool id takes the canonical id the announcement names.
this.tracker.rekey(frame.toolUseId, frame.taskId)
}
}
this.tracker.report({
id: frame.taskId,
...(frame.toolUseId ? { run: frame.toolUseId } : {}),
group: this.currentGroup(),
announces: frame.announcesSubagent,
label: frame.label,
state: frame.state,
...(frame.backgrounded !== null ? { backgrounded: frame.backgrounded } : {})
})
return true
}
/**
* A frame carrying `parent_tool_use_id` — the child's own traffic. It refreshes
* nothing on an announced child; it exists so a CLI release that sends no task
* frames still shows the subagent it is running.
*/
observeChildActivity(parentToolUseId: string): void {
const canonical = this.ids.canonical(parentToolUseId)
if (
this.ids.isExcluded(parentToolUseId, canonical) ||
this.tracker.hasSettled(canonical, parentToolUseId)
) {
return
}
const located = this.tracker.locate(canonical)
if (located) {
// Child traffic can precede the announcement that reopens an inherited row.
if (located.tracked.inherited) {
this.tracker.retain(located.group)
}
return
}
if (
this.tracker.hasSettled(canonical, null) ||
// This CLI announces what it spawns, so an id it never declared is a nested
// Task, a workflow child, or a grandchild — not a subagent. A row invented
// for one is unlabelled forever and can only ever end `unverifiable`.
this.announcesTasks ||
// A provisional id becomes the same durable entry key, so it takes the
// bound an announced id does.
!isBoundedClaudeTaskId(canonical)
) {
return
}
this.tracker.report({
id: canonical,
run: parentToolUseId,
group: this.currentGroup(),
announces: true,
state: 'working'
})
}
/**
* The parent turn's tool result for a spawn call. It settles a foreground
* child, whose result IS the turn's evidence the child finished. A backgrounded
* child's spawn call returns immediately while the child keeps running, so its
* result proves nothing and is ignored.
*/
observeToolResult(toolUseId: string, failed: boolean): void {
const canonical = this.ids.canonical(toolUseId)
const located = this.tracker.locate(canonical)
if (
!located ||
located.tracked.backgrounded ||
(located.tracked.run !== null && located.tracked.run !== toolUseId)
) {
return
}
this.tracker.report({
id: canonical,
run: toolUseId,
group: this.currentGroup(),
announces: false,
state: failed ? 'failed' : 'completed'
})
}
/** The parent turn ended. A foreground child still working loses contact with
* it; a backgrounded one was told to outlive the turn. Only the group this key
* names: `OUTSIDE_TURN` belongs to no turn, so no turn's end says anything about it. */
settleTurn(groupKey: string | null): void {
this.tracker.settleTurn(groupKey ?? OUTSIDE_TURN)
this.deps.onIdentitiesFinal?.()
}
/** The provider is gone. Nothing more will arrive for any child, backgrounded
* or not, so every one of them loses contact at once. */
settleSession(): void {
this.tracker.settleSession()
this.deps.onIdentitiesFinal?.()
}
dispose(): void {
// Teardown paths reach here without an `ended` event; a session that did
// settle first leaves every child terminal, so this writes nothing.
this.settleSession()
this.tracker.dispose()
this.ids.clear()
this.announcesTasks = false
}
private currentGroup(): SubagentReport<AgentJournalTurnScope>['group'] {
return {
id: this.deps.currentGroupKey() ?? OUTSIDE_TURN,
placement: this.deps.currentTurnScope
}
}
}
@@ -27,7 +27,6 @@ describe('readClaudeSubagentTaskFrame', () => {
expect(frame).toMatchObject({
taskId: 'task-1',
toolUseId: 'toolu_1',
label: 'Review the diff',
announcesSubagent: true,
excluded: false
})
@@ -75,20 +74,12 @@ describe('readClaudeSubagentTaskFrame', () => {
}
})
it('caps a subagent_type label the way a description is capped', () => {
const frame = readClaudeSubagentTaskFrame(
system('task_started', { task_id: 'task-1', subagent_type: 'a'.repeat(900) })
)
// The roster stores this label verbatim, so nothing downstream bounds it.
expect(frame?.label).toHaveLength(512)
})
it('falls back to subagent_type only when the release sends no task_type', () => {
expect(
readClaudeSubagentTaskFrame(
system('task_started', { task_id: 'task-old', subagent_type: 'explorer' })
)
).toMatchObject({ announcesSubagent: true, label: 'explorer' })
).toMatchObject({ announcesSubagent: true })
expect(
readClaudeSubagentTaskFrame(system('task_started', { task_id: 'task-bare' }))
).toMatchObject({ announcesSubagent: false, excluded: true })
@@ -120,96 +111,11 @@ describe('readClaudeSubagentTaskFrame', () => {
})
})
describe('status', () => {
it('collapses every in-flight status to working', () => {
for (const status of ['pending', 'running', 'paused']) {
expect(
readClaudeSubagentTaskFrame(
system('task_updated', { task_id: 'task-1', patch: { status } })
)
).toMatchObject({ state: 'working' })
}
})
it('maps the settled statuses onto the carrier vocabulary', () => {
const mapped: [string, string][] = [
['completed', 'completed'],
['failed', 'failed'],
['killed', 'stopped'],
['stopped', 'stopped']
]
for (const [status, state] of mapped) {
expect(
readClaudeSubagentTaskFrame(
system('task_updated', { task_id: 'task-1', patch: { status } })
)
).toMatchObject({ state })
}
})
it('reports no state for a status it cannot map', () => {
for (const status of ['__proto__', 'toString', 'invented', 7, null]) {
expect(
readClaudeSubagentTaskFrame(
system('task_updated', { task_id: 'task-1', patch: { status } })
)
).toMatchObject({ state: null })
}
})
it('treats progress as no lifecycle verdict', () => {
for (const subtype of ['task_progress']) {
expect(
readClaudeSubagentTaskFrame(
system(subtype, { task_id: 'task-1', status: 'completed', patch: { status: 'failed' } })
)
).toMatchObject({ state: null })
}
})
})
it('reads the notification verdict from its top-level status', () => {
for (const state of ['completed', 'failed', 'stopped']) {
expect(
readClaudeSubagentTaskFrame(
system('task_notification', {
task_id: 'task-1',
status: state,
patch: { status: 'running' }
})
)
).toMatchObject({ state })
}
})
it('reads the backgrounded flag from the frame or its patch', () => {
it('reads the spawn call id from an update patch', () => {
expect(
readClaudeSubagentTaskFrame(
system('task_started', {
task_id: 'task-1',
task_type: 'local_agent',
is_backgrounded: true
})
system('task_updated', { task_id: 'task-1', patch: { tool_use_id: 'toolu_1' } })
)
).toMatchObject({ backgrounded: true })
expect(
readClaudeSubagentTaskFrame(
system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: true } })
)
).toMatchObject({ backgrounded: true })
expect(
readClaudeSubagentTaskFrame(system('task_updated', { task_id: 'task-1', patch: {} }))
).toMatchObject({ backgrounded: null })
})
it('collapses a multi-line description into one bounded label', () => {
expect(
readClaudeSubagentTaskFrame(
system('task_updated', {
task_id: 'task-1',
patch: { description: ' audit\n the lockfile ' }
})
)
).toMatchObject({ label: 'audit the lockfile' })
).toMatchObject({ taskId: 'task-1', toolUseId: 'toolu_1', excluded: false })
})
})
+8 -54
View File
@@ -1,4 +1,4 @@
// Claude's declarative task protocol, read as subagent roster events.
// Claude's declarative task protocol, read for which tasks are subagents and which ids name them.
//
// `local_agent`, `local_workflow` and `local_bash` tasks all arrive on the same
// `message:system:task_*` channel and ALL carry a `tool_use_id`, so id presence
@@ -6,10 +6,8 @@
// the subagent roster. `task_type` is the discriminator, with `subagent_type`
// covering CLI releases that predate it.
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import {
classifyClaudeBackgroundTaskKind,
claudeTaskDescription,
claudeTaskId,
isBoundedClaudeTaskId
} from './claude-background-task-tracker'
@@ -23,37 +21,16 @@ const TASK_SUBTYPES: ReadonlySet<string> = new Set([
'task_notification'
])
/** Provider status → the carrier's vocabulary. `killed` and `stopped` both mean
* the task was deliberately ended, which the carrier calls `stopped`; every
* in-flight status collapses to `working`. A Map, not an object, so a payload
* carrying `__proto__` as its status cannot resolve to an inherited value. */
const TASK_STATES: ReadonlyMap<string, NativeChatSubagentState> = new Map([
['pending', 'working'],
['running', 'working'],
['paused', 'working'],
['completed', 'completed'],
['failed', 'failed'],
['killed', 'stopped'],
['stopped', 'stopped']
] satisfies [string, NativeChatSubagentState][])
export type ClaudeSubagentTaskFrame = {
/** Canonical, resume-stable id — the roster key. */
/** Canonical, resume-stable id. */
taskId: string
/** Re-minted when Claude re-announces a resumed task, so it is only an alias. */
toolUseId: string | null
label: string | null
/** null when the frame reported no lifecycle status. */
state: NativeChatSubagentState | null
backgrounded: boolean | null
/** Any `task_started`, subagent or not. Proof this CLI declares its tasks. */
announcement: boolean
/** `task_started` for a task the roster should show. Only an announcement
* creates an entry: an update carries no `task_type`, so honouring one for an
* unknown id would roster whatever else shares this channel. */
/** `task_started` for a subagent. Only an announcement says what a task is: an update carries
* no `task_type`. */
announcesSubagent: boolean
/** Ambient housekeeping, or a task that is not a subagent at all. Its ids must
* never reach the roster, by this frame or by later child traffic. */
/** Ambient housekeeping, or a task that is not a subagent at all. Its ids must never be read as
* a subagent's, by this frame or by later child traffic. */
excluded: boolean
}
@@ -72,10 +49,6 @@ export function isClaudeSubagentTask(message: Record<string, unknown>): boolean
)
}
function taskState(value: unknown): NativeChatSubagentState | null {
return typeof value === 'string' ? (TASK_STATES.get(value) ?? null) : null
}
export function readClaudeSubagentTaskFrame(
message: Record<string, unknown>
): ClaudeSubagentTaskFrame | null {
@@ -90,8 +63,8 @@ export function readClaudeSubagentTaskFrame(
if (!taskId) {
return null
}
const patch = claudeRecord(message.patch)
const toolUseId = claudeText(message.tool_use_id) ?? claudeText(patch?.tool_use_id)
const toolUseId =
claudeText(message.tool_use_id) ?? claudeText(claudeRecord(message.patch)?.tool_use_id)
const announcement = subtype === 'task_started'
// Housekeeping Claude runs for itself; the user never asked for it.
const suppressed = message.ambient === true || message.skip_transcript === true
@@ -99,25 +72,6 @@ export function readClaudeSubagentTaskFrame(
return {
taskId,
toolUseId: toolUseId && isBoundedClaudeTaskId(toolUseId) ? toolUseId : null,
label:
claudeTaskDescription(message.description) ??
claudeTaskDescription(patch?.description) ??
// Bounded like a description: the roster stores whatever this returns.
(announcement ? (claudeTaskDescription(message.subagent_type) ?? null) : null),
// Notifications carry terminal evidence; progress carries usage only.
state:
subtype === 'task_notification'
? taskState(message.status)
: announcement || subtype === 'task_updated'
? taskState(patch?.status ?? message.status)
: null,
backgrounded:
typeof patch?.is_backgrounded === 'boolean'
? patch.is_backgrounded
: typeof message.is_backgrounded === 'boolean'
? message.is_backgrounded
: null,
announcement,
announcesSubagent: subagent,
excluded: announcement && !subagent
}
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -1,6 +1,6 @@
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import { readCodexSubagentAnnouncements } from './codex-subagent-activity'
import { codexChildTurnState } from './codex-subagent-executions'
import { codexChildTurnState, type CodexSubagentExecutions } from './codex-subagent-executions'
import { readRecord } from './codex-item-field-readers'
import { readCodexThreadItem } from './codex-structured-item-translation'
import { readCodexTurnId } from './codex-structured-thread-facts'
@@ -8,7 +8,6 @@ import { readCodexTurnId } from './codex-structured-thread-facts'
export type CodexAnnouncedChild = {
agentThreadId: string
label: string | null
parentTurnId: string | null | undefined
/** The reporting thread, for a spawn: the agent that spawned the child. */
spawnerThreadId: string | undefined
}
@@ -83,10 +82,27 @@ export function readCodexBackgroundTaskFrame(
(announcement) => ({
agentThreadId: announcement.agentThreadId,
label: announcement.label,
parentTurnId: announcement.namesParentTurn ? readCodexTurnId(event.params) : undefined,
// Only a spawn names the spawner: other announcements ride whichever agent acted.
spawnerThreadId: announcement.spawned ? event.threadId : undefined
})
)
return children.length > 0 ? { kind: 'subagents', children } : null
}
/** What one frame says about the child executions. Repeating it changes nothing, so the journal
* reads it before it writes the frame's rows and the background tasks read it again after. */
export function observeCodexExecutionFrame(
executions: CodexSubagentExecutions,
frame: CodexBackgroundTaskFrame | null,
primaryThreadId: string
): void {
if (frame?.kind === 'subagents') {
for (const child of frame.children) {
executions.register(child.agentThreadId, child.label, child.spawnerThreadId)
}
} else if (frame?.kind === 'thread-closed') {
executions.closeThread(frame.threadId)
} else if (frame && frame.threadId !== primaryThreadId) {
executions.observeTurn(frame.threadId, frame.turnId, frame.state)
}
}
@@ -77,7 +77,7 @@ describe('readCodexBackgroundTaskFrame', () => {
it('reads activity as child metadata without inferring execution state', () => {
expect(readCodexBackgroundTaskFrame(activity('interacted'), PRIMARY)).toEqual({
kind: 'subagents',
children: [{ agentThreadId: CHILD, label: 'count_a', parentTurnId: PARENT_TURN }]
children: [{ agentThreadId: CHILD, label: 'count_a' }]
})
})
@@ -4,6 +4,7 @@ import type {
} from '../../shared/agent-session-wire'
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import {
observeCodexExecutionFrame,
readCodexBackgroundTaskFrame,
type CodexBackgroundTaskEvent
} from './codex-background-task-frames'
@@ -12,7 +13,7 @@ import { CodexBackgroundCommandTracker } from './codex-background-command-tracke
import { CodexChildWorkEvidence } from './codex-child-work-evidence'
import type { CodexAbandonedCommand } from './codex-prompt-registry'
import type { CodexStructuredSessionAdapterDeps } from './codex-structured-session-state'
import { boundSubagentField } from '../native-chat/agent-session-journal/journal-subagent-group-body'
import { boundSubagentLabel } from '../native-chat/agent-session-journal/journal-subagent-item'
/** Where a session's child-work evidence goes, and the host clock that stamps it. */
export type CodexChildWorkSink = {
@@ -153,20 +154,7 @@ export class CodexBackgroundTaskTracker {
? [command]
: [])
]
if (frame?.kind === 'subagents') {
for (const child of frame.children) {
this.executions.register(
child.agentThreadId,
child.label,
child.parentTurnId,
child.spawnerThreadId
)
}
} else if (frame?.kind === 'thread-closed') {
this.executions.closeThread(frame.threadId)
} else if (frame && frame.threadId !== this.primaryThreadId) {
this.executions.observeTurn(frame.threadId, frame.turnId, frame.state)
}
observeCodexExecutionFrame(this.executions, frame, this.primaryThreadId)
this.childWork.observe(event, frame, commands)
if (!frame) {
return itemEvent || commands.length > 0 ? this.refresh() : false
@@ -200,10 +188,10 @@ export class CodexBackgroundTaskTracker {
private tasks(): AgentSessionBackgroundTask[] {
const children = this.executions.workingChildren()
const agents: AgentSessionBackgroundTask[] = children.map((child, index) => ({
const agents: AgentSessionBackgroundTask[] = children.map((child) => ({
id: `codex-agent:${child.agentThreadId}`,
kind: 'agent',
...(child.label ? { description: boundSubagentField(child.label, index) } : {})
...(child.label ? { description: boundSubagentLabel(child.label) } : {})
}))
return [
...agents,
@@ -136,6 +136,19 @@ describe('Codex child-work evidence', () => {
expect(log.flat().filter((edge) => edge.type === 'live')).toHaveLength(1)
})
it('keeps usage that arrives before the child is announced, and never credits the parent its own', () => {
const { send, byKind } = harness()
const usage = (threadId: string, totalTokens: number): CodexBackgroundTaskEvent => ({
method: 'thread/tokenUsage/updated',
threadId,
params: { threadId, tokenUsage: { total: { totalTokens } } }
})
send(turn('turn/started', PRIMARY, PARENT_TURN), usage(PRIMARY, 9_000), usage(CHILD, 1_500))
expect(byKind('agent')).toEqual([])
send(spawned(), turn('turn/started', CHILD, 'c1'))
expect(byKind('agent')).toEqual([expect.objectContaining({ totalTokens: 1_500 })])
})
it('makes no record for a child whose turn began before its announcement, until it lands', () => {
const { send, records } = harness()
send(turn('turn/started', PRIMARY, PARENT_TURN), turn('turn/started', CHILD, 'c1'))
+6 -6
View File
@@ -34,7 +34,7 @@ import { codexThreadWaitsOnUser, readCodexTurnId } from './codex-structured-thre
import { CODEX_TOKEN_USAGE_METHOD, readCodexThreadTokenTotal } from './codex-subagent-activity'
import type { CodexExecutionChild, CodexSubagentExecutions } from './codex-subagent-executions'
/** The executions' own child bound. */
/** Facts kept for settled children; a working child always has its own. */
const MAX_CHILD_FACTS = 128
const MAX_OPEN_CALLS_PER_CHILD = 16
const CHILD_FRAME_METHODS: ReadonlySet<string> = new Set([
@@ -113,7 +113,7 @@ export class CodexChildWorkEvidence {
this.queueCommands(commands)
for (const threadId of this.childThreads(event, frame)) {
const facts = this.factsFor(threadId)
if (facts && event.threadId === threadId) {
if (event.threadId === threadId) {
this.record(facts, event)
}
this.queueChild(threadId)
@@ -299,19 +299,19 @@ export class CodexChildWorkEvidence {
}
}
private factsFor(threadId: string): ChildFacts | undefined {
private factsFor(threadId: string): ChildFacts {
const existing = this.facts.get(threadId)
if (existing) {
return existing
}
if (this.facts.size >= MAX_CHILD_FACTS) {
// Refusing a working child its facts would drop its every edge; the host's limits bound them.
const idle = [...this.facts.keys()].find(
(id) => this.executions.find(id)?.execution?.state !== 'working'
)
if (idle === undefined) {
return undefined
if (idle !== undefined) {
this.facts.delete(idle)
}
this.facts.delete(idle)
}
const facts: ChildFacts = { openCalls: new Map(), waiting: false }
this.facts.set(threadId, facts)
@@ -1,7 +1,7 @@
// Codex's default multi-agent mode announces a helper only by the `collabAgentToolCall` items that
// spawn, message, wait on or close it. These frames, through the real adapter, must register that
// helper the same way a `subAgentActivity` does: one child in the strip, the host's records and
// the roster row.
// helper the same way a `subAgentActivity` does: one child in the strip and the host's records,
// which the host saves as the helper's row.
import { describe, expect, it } from 'vitest'
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
@@ -179,21 +179,15 @@ async function session() {
const strip = () => adapter.backgroundTaskState('session-1')?.tasks ?? []
const toolRow = (name: string) =>
[...rows.values()].find((body) => body.kind === 'tool-call' && body.name === name)
const rosterRows = () =>
[...rows.values()].flatMap((body) =>
body.kind === 'message'
? body.blocks.flatMap((block) => (block.type === 'subagent-group' ? [block] : []))
: []
)
const rawCollabRows = () =>
[...rows.values()].filter(
(body) => body.kind === 'status' && body.providerFrame?.kind === 'item:collabAgentToolCall'
)
return { send, agents, commands, strip, toolRow, rosterRows, rawCollabRows }
return { send, agents, commands, strip, toolRow, rawCollabRows }
}
describe('Codex default-mode helpers', () => {
it('registers a helper announced only by its spawn call, in the strip, the records and the roster row', async () => {
it('registers a helper announced only by its spawn call, in the strip and the records', async () => {
const run = await session()
run.send(turn('turn/started', THREAD_ID, PARENT_TURN), spawnStarted)
// The call has not said which thread the helper is yet.
@@ -212,11 +206,6 @@ describe('Codex default-mode helpers', () => {
expect(run.strip()).toEqual([
{ id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL }
])
expect(run.rosterRows()).toEqual([
expect.objectContaining({
agents: [expect.objectContaining({ id: HELPER, label: LABEL, state: 'working' })]
})
])
// While the helper runs, its command is the helper's work, not the session's own.
run.send(helperShell('item/started'))
@@ -245,9 +234,6 @@ describe('Codex default-mode helpers', () => {
})
])
expect(run.strip()).toEqual([])
expect(run.rosterRows().at(-1)?.agents).toEqual([
expect.objectContaining({ id: HELPER, state: 'completed' })
])
})
it('labels a command the helper leaves running with the helper, once its turn is over', async () => {
@@ -316,12 +302,7 @@ describe('Codex default-mode helpers', () => {
expect.objectContaining({ membership: 'live', state: 'working', description: LABEL })
])
expect(run.strip()).toHaveLength(1)
expect(run.rosterRows()).toEqual([
expect.objectContaining({
agents: [expect.objectContaining({ id: HELPER, label: LABEL, state: 'working' })]
})
])
// The roster row does not stand in for the call's own row.
// The call keeps its own row.
expect(run.toolRow('spawn_agent')).toMatchObject({
state: 'completed',
input: { description: LABEL }
@@ -355,11 +336,6 @@ describe('Codex default-mode helpers', () => {
expect(run.strip()).toEqual([
{ id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL }
])
expect(run.rosterRows()).toEqual([
expect.objectContaining({
agents: [expect.objectContaining({ id: HELPER, state: 'working' })]
})
])
})
it('registers nothing for a spawn that created no thread', async () => {
@@ -376,7 +352,6 @@ describe('Codex default-mode helpers', () => {
)
expect(run.agents()).toEqual([])
expect(run.strip()).toEqual([])
expect(run.rosterRows()).toEqual([])
})
it('leaves a helper running when its caller failed to close it', async () => {
@@ -415,9 +390,6 @@ describe('Codex default-mode helpers', () => {
)
expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })])
expect(run.strip()).toHaveLength(1)
expect(run.rosterRows().at(-1)?.agents).toEqual([
expect.objectContaining({ id: HELPER, state: 'working' })
])
// A close that works aborts the helper's turn, which Codex reports on the helper's thread.
run.send(closeCompleted, turn('turn/completed', HELPER, HELPER_TURN, 'interrupted'))
@@ -425,9 +397,6 @@ describe('Codex default-mode helpers', () => {
expect.objectContaining({ membership: 'settled', outcome: 'cancelled' })
])
expect(run.strip()).toEqual([])
expect(run.rosterRows().at(-1)?.agents).toEqual([
expect.objectContaining({ id: HELPER, state: 'stopped' })
])
expect(run.toolRow('close_agent')).toMatchObject({
state: 'completed',
input: { description: LABEL }
@@ -480,9 +449,6 @@ describe('Codex default-mode helpers', () => {
expect(run.strip()).toEqual([
{ id: `codex-agent:${HELPER}`, kind: 'agent', description: 'sleeper' }
])
expect(run.rosterRows().at(-1)?.agents).toEqual([
expect.objectContaining({ id: HELPER, label: 'sleeper' })
])
})
})
@@ -520,9 +486,6 @@ describe('a helper whose spawn was never seen', () => {
parentChildWorkId: run.agents()[0]?.childWorkId
})
])
expect(run.rosterRows().at(-1)?.agents).toEqual([
expect.objectContaining({ id: HELPER, label: 'subagent', state: 'working' })
])
expect(run.toolRow('send_input')).toMatchObject({ input: { description: HELPER } })
})
@@ -548,10 +511,6 @@ describe('a helper whose spawn was never seen', () => {
expect.objectContaining({ membership: 'live' }),
expect.objectContaining({ membership: 'live' })
])
expect(run.rosterRows().at(-1)?.agents).toEqual([
expect.objectContaining({ id: HELPER, label: 'subagent' }),
expect.objectContaining({ id: second, label: 'subagent 2' })
])
})
it("reopens a helper's one entry when a later turn sends it work, even after that turn ended", async () => {
@@ -567,11 +526,9 @@ describe('a helper whose spawn was never seen', () => {
turn('turn/completed', THREAD_ID, 'parent-turn-2'),
turn('turn/started', HELPER, 'helper-turn-2')
)
// One entry per helper: a second row naming it would show it without its own section.
const groups = new Set(run.rosterRows().map((row) => row.groupId))
expect(groups.size).toBe(1)
expect(run.rosterRows().at(-1)?.agents).toEqual([
expect.objectContaining({ id: HELPER, state: 'working' })
// One record per helper, back at work.
expect(run.agents()).toEqual([
expect.objectContaining({ membership: 'live', state: 'working' })
])
})
@@ -590,14 +547,13 @@ describe('a helper whose spawn was never seen', () => {
}
}
expect(readCodexSubagentAnnouncements(waited, THREAD_ID)).toEqual([
{ agentThreadId: HELPER, label: null, namesParentTurn: false, spawned: false },
{ agentThreadId: second, label: null, namesParentTurn: false, spawned: false }
{ agentThreadId: HELPER, label: null, spawned: false },
{ agentThreadId: second, label: null, spawned: false }
])
})
})
describe('the roster row follows a helper whose turn ends', () => {
const lastRow = (run: Awaited<ReturnType<typeof session>>) => run.rosterRows().at(-1)?.agents
describe('the record follows a helper whose turn ends', () => {
const running = async () => {
const run = await session()
run.send(
@@ -605,11 +561,10 @@ describe('the roster row follows a helper whose turn ends', () => {
activityStarted,
turn('turn/started', HELPER, HELPER_TURN)
)
expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'working' })])
return run
}
it('settles the row with the strip and the record on the failed completion that follows a fatal error', async () => {
it('settles the strip and the record on the failed completion that follows a fatal error', async () => {
const run = await running()
// Codex sends the fatal error, then the helper's failed `turn/completed` 0-32 ms later.
run.send({
@@ -623,24 +578,21 @@ describe('the roster row follows a helper whose turn ends', () => {
})
expect(run.strip()).toEqual([expect.objectContaining({ id: `codex-agent:${HELPER}` })])
expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })])
expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'working' })])
run.send(turn('turn/completed', HELPER, HELPER_TURN, 'failed'))
expect(run.strip()).toEqual([])
expect(run.agents()).toEqual([
expect.objectContaining({ membership: 'settled', outcome: 'failed' })
])
expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'failed' })])
})
it('settles the row with the strip and the record when its thread closes with no turn/completed', async () => {
it('settles the strip and the record when its thread closes with no turn/completed', async () => {
const run = await running()
run.send({ method: 'thread/closed', params: { threadId: HELPER } })
expect(run.strip()).toEqual([])
expect(run.agents()).toEqual([
expect.objectContaining({ membership: 'settled', outcome: 'unknown' })
])
expect(lastRow(run)).toEqual([expect.objectContaining({ id: HELPER, state: 'unverifiable' })])
})
})
@@ -203,11 +203,13 @@ const shellFrame = (
describe('Codex structured child-work producer', () => {
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'))
const deliveries = send(spawned(REVIEWER, 'review', 'p1'))
const deliveries = [
...send(turn('turn/started', THREAD_ID, 'p1')),
...send(turn('turn/started', REVIEWER, 'r1')),
...send(spawned(REVIEWER, 'review', 'p1'))
]
const kinds = deliveries.map((delivery) => delivery.kind)
// The frame's own rows, then the parent's republished row, and only then its children.
// The turn's own rows, then the parent's republished row, and only then its child.
expect(kinds.filter((kind) => kind === 'journal').length).toBeGreaterThan(0)
expect(kinds.lastIndexOf('publish')).toBeGreaterThan(kinds.lastIndexOf('journal'))
expect(kinds.at(-1)).toBe('evidence')
@@ -474,7 +476,8 @@ describe('Codex structured child-work producer', () => {
before = step.parent
step.check?.()
}
expect(journalMoments).toBeGreaterThan(steps.length)
// The host writes the children's rows, so not every step writes one here.
expect(journalMoments).toBeGreaterThan(0)
const settled = records()
await adapter.closeSession('session-1')
// Every child had already ended; closing the session changes none of what they said.
@@ -11,9 +11,3 @@ export const MAX_CODEX_PENDING_PROMPTS = 128
export const MAX_CODEX_IDENTITY_ENTRIES = 512
export const MAX_CODEX_DETAIL_ENTRIES = 512
export const MAX_CODEX_DETAIL_BYTES = 64 * 1024
/** The shared subagent tracker's bounds, which Codex's spawn-group rows follow. */
export { MAX_SUBAGENT_GROUPS as MAX_CODEX_SUBAGENT_GROUPS } from '../native-chat/subagent-tracker/subagent-tracker-groups'
export { MAX_SUBAGENTS_PER_GROUP as MAX_CODEX_SUBAGENTS_PER_GROUP } from '../native-chat/subagent-tracker/subagent-tracker'
/** Threads whose latest token total is retained. Usage frames arrive for
* threads that are not yet (or never become) roster children. */
export const MAX_CODEX_TOKEN_USAGE_THREADS = 256
@@ -1,6 +1,7 @@
// Where one `item/started` or `item/completed` notification goes: the subagent
// roster, the typed item translator, or the generic fallback — and, when the item
// is Codex echoing a send back, the turn attribution that echo unlocks.
// Where one `item/started` or `item/completed` notification goes: nowhere for a subagent's
// activity item, whose child draws its own row from the host's records; else the typed item
// translator or the generic fallback — and, when the item is Codex echoing a send back, the turn
// attribution that echo unlocks.
import type { CodexJournalGenericFrames } from './codex-structured-journal-generic-frames'
import type { CodexJournalItems } from './codex-structured-journal-items'
@@ -8,12 +9,13 @@ import type {
CodexJournalTranslationAdmission,
CodexJournalTranslatorDeps
} from './codex-structured-journal-contracts'
import type { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state'
import type { CodexJournalTurnBoundaries } from './codex-structured-journal-translation-turn-boundaries'
import { readCodexThreadItem } from './codex-structured-item-translation'
import { readCodexNotificationThreadItem } from './codex-subagent-activity'
import type { CodexSubagentRoster } from './codex-subagent-roster'
import { readCodexTurnId } from './codex-structured-thread-facts'
import {
readCodexNotificationThreadItem,
readCodexSubagentActivity
} from './codex-subagent-activity'
import { CODEX_JOURNAL_ADMITTED } from './codex-structured-journal-contracts'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
type ThreadItemEvent = Extract<CodexStructuredSessionEvent, { type: 'notification' }>
@@ -23,25 +25,14 @@ export type CodexThreadItemRouting = CodexJournalTranslationAdmission | null
export function createCodexThreadItemRouter(input: {
deps: Pick<CodexJournalTranslatorDeps, 'dispatchRequestOrigin' | 'onUserMessageEcho'>
subagents: Pick<CodexSubagentRoster, 'handleItem'>
items: Pick<CodexJournalItems, 'handle'>
activeTurns: Pick<CodexJournalActiveTurns, 'current'>
turnBoundaries: Pick<CodexJournalTurnBoundaries, 'attributeRequest'>
genericFrames: Pick<CodexJournalGenericFrames, 'appendUnhandled'>
}): (event: ThreadItemEvent) => CodexThreadItemRouting {
return (event) => {
const subagentItem = readCodexNotificationThreadItem(event.params, readCodexThreadItem)
// Null means the roster did not claim it; fall through to normal item
// handling. Returning here unconditionally swallows every other item.
const subagentAdmission = subagentItem
? input.subagents.handleItem({
threadId: event.threadId,
turnId: readCodexTurnId(event.params) ?? input.activeTurns.current(event.threadId),
item: subagentItem
})
: null
if (subagentAdmission) {
return subagentAdmission
if (subagentItem && readCodexSubagentActivity(subagentItem) !== null) {
return CODEX_JOURNAL_ADMITTED
}
const translated = input.items.handle(event)
if (translated.handled && translated.dispatchEcho) {
@@ -36,7 +36,7 @@ export function restoreCodexHistoryItem(
const item = readCodexNotificationThreadItem(event.params, readCodexThreadItem)
const announcements = item ? readCodexSubagentAnnouncements(item, input.primaryThreadId) : []
for (const announcement of announcements) {
input.executions.register(announcement.agentThreadId, announcement.label, undefined)
input.executions.register(announcement.agentThreadId, announcement.label)
}
const compaction = input.compactions.handle(event)
if (compaction) {
@@ -109,14 +109,8 @@ describe('codex journal translation — subagent producer linkage', () => {
expect(commandRows.map((write) => write.linkage)).toEqual(commandRows.map(() => CHILD_LINKAGE))
expect(linkageOf('child prose')).toEqual(CHILD_LINKAGE)
expect(linkageOf('own prose')).toEqual({})
// The spawn-group row is the parent's list of its children, though a child's
// frames also write it.
expect(
writes.filter((write) => write.key.includes('codex-subagents')).map((w) => w.linkage)
).toEqual(expect.arrayContaining([{}]))
expect(
writes.filter((write) => write.key.includes('codex-subagents') && write.linkage.agentId)
).toEqual([])
// The host writes each child's own row; the translator writes none.
expect(writes.filter((write) => write.key.includes('subagent'))).toEqual([])
})
it('stamps a child row that arrives before the spawn announcement names it', () => {
@@ -5,7 +5,6 @@ import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { isSubagentGroupBlock } from '../../shared/native-chat-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
@@ -79,38 +78,27 @@ function deliverActivity(
translator.handle(notification('item/completed', params))
}
function rosterAgents(rows: Row[]): { id: string; state: string; tokens?: number }[] {
const body = rows.findLast((row) => row.key.startsWith('orca:codex-subagents'))?.body
if (!body || body.kind !== 'message') {
return []
}
return body.blocks.find(isSubagentGroupBlock)?.agents ?? []
}
describe('codex journal translation — subagents', () => {
it('renders a spawn group as one roster row and no opcode-shaped duplicate', () => {
it('writes neither a subagent row nor an opcode-shaped one: the host saves each child', () => {
const { translator, rows } = harness()
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
deliverActivity(translator, subagentItem('started', 'child-1', '/root/list_directory'))
deliverActivity(translator, subagentItem('interacted', 'child-1', '/root/list_directory'))
expect(rosterAgents(rows)).toMatchObject([
{ id: 'child-1', label: 'list_directory', state: 'working' }
])
// Four wire deliveries (two items, each sent twice) collapse to ONE roster
// row, and none of the gray `codex · item:subAgentActivity` rows survive.
// Four wire deliveries (two items, each sent twice), and none of the gray
// `codex · item:subAgentActivity` rows survive.
const providerFrameKinds = rows.flatMap((row) =>
row.body.kind === 'status' && row.body.providerFrame ? [row.body.providerFrame.kind] : []
)
expect(providerFrameKinds).toEqual([])
expect(rows.filter((row) => row.key.startsWith('orca:codex-subagents'))).toHaveLength(1)
expect(rows.filter((row) => row.key.includes('subagent'))).toEqual([])
})
// The roster claims the item, but claiming it must not take the turn tail with
// The translator claims the item, but claiming it must not take the turn tail with
// it: the activity table is reached only through the publish arm, so a bare
// return leaves the tail stuck on whatever the previous frame said.
it('still publishes the turn tail for an item the roster claims', () => {
it('still publishes the turn tail for an item it claims', () => {
const { translator, activities } = harness()
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
@@ -122,63 +110,4 @@ describe('codex journal translation — subagents', () => {
text: 'Coordinating with another agent'
})
})
it('consumes thread/tokenUsage/updated instead of swallowing it as chrome', () => {
const { translator, rows } = harness()
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
deliverActivity(translator, subagentItem('started', 'child-1', '/root/read'))
translator.handle(
notification('thread/tokenUsage/updated', {
threadId: 'child-1',
tokenUsage: { total: { totalTokens: 40661 } }
})
)
expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', tokens: 40661 }])
})
// The QA scenario this row got wrong: three `spawn_agent` children were still
// running when a mid-turn correction ended their turn and opened a new one.
// They reported `completed` 57-87s later, so a turn boundary is a fact about
// the turn and never evidence that contact with a child was lost.
it('leaves children working when their turn ends and a newer turn opens', () => {
const { translator, rows } = harness()
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
deliverActivity(translator, subagentItem('started', 'child-1', '/root/read_readme'))
deliverActivity(translator, subagentItem('started', 'child-2', '/root/read_package'))
translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))
translator.handle(notification('turn/started', { turn: { id: 'turn-2' } }))
expect(rosterAgents(rows)).toMatchObject([
{ id: 'child-1', state: 'working' },
{ id: 'child-2', state: 'working' }
])
// And the verdict a child reports after its turn ended still lands on the row.
deliverActivity(translator, subagentItem('completed', 'child-1', '/root/read_readme'))
expect(rosterAgents(rows)).toMatchObject([
{ id: 'child-1', state: 'completed' },
{ id: 'child-2', state: 'working' }
])
})
it('sweeps every group when the provider ends', () => {
const { translator, rows } = harness()
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
deliverActivity(translator, subagentItem('started', 'child-1', '/root/read'))
translator.handle({
type: 'ended',
sessionId: SESSION_ID,
reason: 'provider exited',
cause: 'unexpected-exit',
fence: 1,
acquisitionGeneration: 'gen-1'
} as CodexStructuredSessionEvent)
expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', state: 'unverifiable' }])
})
})
@@ -1,6 +1,6 @@
// The translator's writers, built once. Split out so the translator reads as
// routing, and so one place shows that every writer is handed the same
// row attribution: the roster's producer, which knows each child thread, and the turn scope.
// row attribution: the producer each child thread is, and the turn scope.
import { CodexJournalCompactions } from './codex-structured-journal-compactions'
import type { CodexJournalTranslatorDeps } from './codex-structured-journal-contracts'
@@ -11,8 +11,8 @@ import { CodexJournalPrompts } from './codex-structured-journal-prompts'
import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state'
import { CodexJournalTurnScopes } from './codex-journal-turn-scopes'
import { CodexJournalCommandTurn } from './codex-journal-command-turn'
import { CodexSubagentRoster } from './codex-subagent-roster'
import type { CodexRowAttribution } from './codex-subagent-linkage'
import { CodexSubagentExecutions } from './codex-subagent-executions'
import { CodexSubagentLinkage, type CodexRowAttribution } from './codex-subagent-linkage'
export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslatorDeps) {
const activeTurns = new CodexJournalActiveTurns()
@@ -25,14 +25,10 @@ export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslator
activeTurn,
commandScope: (turnId) => commands.scopeFor(turnId)
})
const subagents = new CodexSubagentRoster({
sink: deps.sink,
primaryThreadId,
activeTurn,
turnScopeFor: (threadId, turnId) => turnScopes.scopeFor(threadId, turnId),
...(deps.subagentExecutions ? { executions: deps.subagentExecutions } : {})
})
const { linkageFor } = subagents.linkage
// The translator and the background-task tracker both feed these from the same frames, which
// repeat harmlessly; the rows only read them.
const executions = deps.subagentExecutions ?? new CodexSubagentExecutions()
const { linkageFor } = new CodexSubagentLinkage({ primaryThreadId, executions })
const attributionFor: CodexRowAttribution = (threadId, turnId) => ({
...linkageFor(threadId, turnId),
turnScope: turnScopes.scopeFor(threadId, turnId)
@@ -43,13 +39,13 @@ export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslator
producerDeps,
activeTurn,
(threadId, turnId) => genericFrames.suppress(threadId, turnId),
// A collab call's row names its helpers the way the roster does.
(threadId) => subagents.executions.label(threadId)
// A collab call's row names its helpers the way their own rows do.
(threadId) => executions.label(threadId)
)
return {
activeTurns,
commands,
subagents,
executions,
attributionFor,
genericFrames,
items,
@@ -1,5 +1,4 @@
import { createCodexProviderActivityReader } from '../native-chat/agent-session-wire/provider-frame-activity'
import { CODEX_TOKEN_USAGE_METHOD } from './codex-subagent-activity'
import {
CODEX_JOURNAL_ADMITTED,
type CodexJournalTranslationAdmission,
@@ -17,6 +16,10 @@ import { publishCodexTurnLifecycle } from './codex-structured-journal-translatio
import { codexProviderRetryRowBody, isCodexProviderRetryFrame } from './codex-provider-retry-row'
import { createCodexThreadItemRouter } from './codex-structured-journal-thread-item-routing'
import { codexThreadStoppedRunning, readCodexTurnId } from './codex-structured-thread-facts'
import {
observeCodexExecutionFrame,
readCodexBackgroundTaskFrame
} from './codex-background-task-frames'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
export type {
@@ -42,7 +45,7 @@ export function createCodexJournalTranslator(
const {
activeTurns,
commands,
subagents,
executions,
attributionFor,
genericFrames,
items,
@@ -83,9 +86,7 @@ export function createCodexJournalTranslator(
}
const routeThreadItem = createCodexThreadItemRouter({
deps,
subagents,
items,
activeTurns,
turnBoundaries,
genericFrames
})
@@ -122,7 +123,7 @@ export function createCodexJournalTranslator(
primaryThreadId: deps.primaryThreadId?.() ?? null,
compactions,
items,
executions: subagents.executions
executions
}),
...(deps.sessionId !== undefined
? {
@@ -140,6 +141,15 @@ export function createCodexJournalTranslator(
})
},
handle: (event) => {
const primaryThreadId = deps.primaryThreadId?.() ?? null
if (event.type === 'notification' && primaryThreadId !== null) {
// Before any row: the rows this frame writes name the child run it starts.
observeCodexExecutionFrame(
executions,
readCodexBackgroundTaskFrame(event, primaryThreadId),
primaryThreadId
)
}
if (event.type === 'ended') {
const streamAdmission = flushStreams()
if (!streamAdmission.accepted) {
@@ -173,11 +183,6 @@ export function createCodexJournalTranslator(
if (!admission.accepted) {
return admission
}
// No event will ever settle a child once the provider is gone.
const sweep = subagents.settleSession()
if (!sweep.accepted) {
return sweep
}
readActivity = createCodexProviderActivityReader()
deps.sink.setActivity?.(null)
items.activeItems.clear()
@@ -212,10 +217,6 @@ export function createCodexJournalTranslator(
return genericFrames.appendUnhandled(event.kind, event.payload, event.threadId)
}
if (event.method === 'turn/started' || event.method === 'turn/completed') {
const childAdmission = subagents.handleTurnEvent(event)
if (!childAdmission.accepted) {
return childAdmission
}
const admission =
event.method === 'turn/started'
? turnBoundaries.start(event)
@@ -233,14 +234,6 @@ export function createCodexJournalTranslator(
if (goal) {
return publishActivity(event, goal)
}
if (event.method === CODEX_TOKEN_USAGE_METHOD) {
// Classified `status-chrome`, so the generic-frame path swallows it
// before the journal. The roster consumes it as a typed notification.
const admission = subagents.handleTokenUsage(event.params)
if (admission) {
return admission
}
}
if (event.method === 'item/started' || event.method === 'item/completed') {
const routed = routeThreadItem(event)
// Not a bare return: a claimed item must not skip the turn-tail arm,
@@ -298,7 +291,6 @@ export function createCodexJournalTranslator(
items.dispose()
prompts.dispose()
genericFrames.dispose()
subagents.dispose()
turnBoundaries.clear()
compactions.clear()
goals.dispose()
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import {
codexSubagentLabel,
isCodexRootAgentActivity,
readCodexSubagentAnnouncements
} from './codex-subagent-activity'
const activity = (agentPath: string | null) => ({
kind: 'started',
agentThreadId: 'child',
agentPath
})
describe('a Codex subagent read from its agent path', () => {
it.each(['/root', '/root/', '/root//'])('reads %s as the turn itself, never a child', (path) => {
expect(isCodexRootAgentActivity(activity(path))).toBe(true)
})
it('counts a single-segment path other than root, and a path-less one, as a child', () => {
expect(isCodexRootAgentActivity(activity('/morpheus'))).toBe(false)
expect(isCodexRootAgentActivity(activity(null))).toBe(false)
})
it('labels a child by its trimmed last segment, and by nothing when that is blank', () => {
expect(codexSubagentLabel(activity('/root//list_directory'))).toBe('list_directory')
expect(codexSubagentLabel(activity('/root/ read '))).toBe('read')
expect(codexSubagentLabel(activity('/root/ '))).toBeNull()
})
it('announces no child for the tree root or the session’s own thread', () => {
const item = (agentThreadId: string, agentPath: string) => ({
type: 'subAgentActivity',
id: `activity-${agentThreadId}`,
kind: 'started',
agentThreadId,
agentPath
})
expect(readCodexSubagentAnnouncements(item('root-thread', '/root'), 'primary')).toEqual([])
expect(readCodexSubagentAnnouncements(item('primary', '/root/x'), 'primary')).toEqual([])
expect(readCodexSubagentAnnouncements(item('child', '/root/x'), 'primary')).toEqual([
{ agentThreadId: 'child', label: 'x', spawned: true }
])
})
})
+1 -16
View File
@@ -96,18 +96,10 @@ export function codexSubagentLabel(activity: CodexSubagentActivity): string | nu
export type CodexSubagentAnnouncement = {
agentThreadId: string
label: string | null
/** The item's turn is the one the child was spawned or messaged from. */
namesParentTurn: boolean
/** The item is the spawn itself, so the thread that carried it spawned the child. */
spawned: boolean
}
/** Collab calls that hand a helper work from the caller's turn, as an `interacted` activity does. */
const CODEX_COLLAB_TOOLS_THAT_TASK_A_HELPER: ReadonlySet<string> = new Set([
'spawnAgent',
'sendInput'
])
/** The children an item announces: a `subAgentActivity`'s, or every helper a collab call names,
* keyed by thread id so repeats announce one child. Neither the tree root nor the session's own
* thread is a child. Any call counts, since a helper whose spawn was never seen (compacted out of
@@ -130,7 +122,6 @@ function readAnnouncements(item: CodexThreadItem): CodexSubagentAnnouncement[] {
{
agentThreadId: activity.agentThreadId,
label: codexSubagentLabel(activity),
namesParentTurn: activity.kind === 'started' || activity.kind === 'interacted',
spawned: activity.kind === 'started'
}
]
@@ -143,13 +134,7 @@ function readAnnouncements(item: CodexThreadItem): CodexSubagentAnnouncement[] {
// Only a spawn's prompt describes its helper; another call's prompt is a later message to it,
// so a helper no spawn named reads as any unnamed subagent does.
const label = spawned ? codexCollabHelperLabel(call.prompt) : null
const namesParentTurn = CODEX_COLLAB_TOOLS_THAT_TASK_A_HELPER.has(call.tool)
return call.helperThreadIds.map((agentThreadId) => ({
agentThreadId,
label,
namesParentTurn,
spawned
}))
return call.helperThreadIds.map((agentThreadId) => ({ agentThreadId, label, spawned }))
}
export type CodexThreadTokenTotal = { threadId: string; totalTokens: number }
@@ -75,23 +75,9 @@ function harness() {
}
return admission
}
function state(parentTurn: string): string | undefined {
for (const body of rows.values()) {
if (body.kind !== 'message') {
continue
}
for (const block of body.blocks) {
if (block.type === 'subagent-group' && block.groupId === `${PRIMARY}:${parentTurn}`) {
return block.agents[0]?.state
}
}
}
return undefined
}
return {
send,
tracker,
state,
refuse: (value: boolean) => {
refused = value
},
@@ -111,14 +97,12 @@ describe('shared child execution projection', () => {
const h = harness()
h.send(activity('parent-1'))
h.send(activity('parent-2', 'interacted'))
expect(h.state('parent-1')).toBeUndefined()
expect(h.state('parent-2')).toBeUndefined()
expect(h.tracker.state).toBeNull()
h.dispose()
})
it.each(['parent-1', 'parent-2'])(
'reopens a child for real follow-up in %s in its own row and fences old execution events',
'reopens a child for real follow-up in %s and fences old execution events',
(parent) => {
const h = harness()
firstRun(h)
@@ -130,19 +114,13 @@ describe('shared child execution projection', () => {
expect(h.tracker.state).toBeNull()
h.send(turn('turn/started', CHILD, 'child-2'))
h.send(turn('turn/completed', PRIMARY, parent))
// One entry per child: the follow-up reopens the row that spawned it.
expect(h.state('parent-1')).toBe('working')
// One entry per child: the follow-up reopens the child that was spawned.
expect(h.tracker.state?.tasks).toHaveLength(1)
h.send(turn('turn/completed', CHILD, 'child-1'))
h.send(turn('turn/started', CHILD, 'child-1'))
h.send(activity('parent-1', 'completed'))
expect(h.state('parent-1')).toBe('working')
expect(h.tracker.state?.tasks).toHaveLength(1)
if (parent === 'parent-2') {
expect(h.state('parent-2')).toBeUndefined()
}
h.send(turn('turn/completed', CHILD, 'child-2'))
expect(h.state('parent-1')).toBe('completed')
expect(h.tracker.state).toBeNull()
h.dispose()
}
@@ -154,8 +132,6 @@ describe('shared child execution projection', () => {
h.send(turn('turn/completed', PRIMARY, 'parent-1'))
h.send(activity('parent-2', 'interacted'))
h.send(turn('turn/completed', PRIMARY, 'parent-2'))
expect(h.state('parent-1')).toBe('completed')
expect(h.state('parent-2')).toBeUndefined()
expect(h.tracker.state).toBeNull()
h.dispose()
})
@@ -167,8 +143,6 @@ describe('shared child execution projection', () => {
h.send(activity('parent-2', 'interacted'))
h.send(activity('parent-1', 'completed'))
h.send(turn('turn/started', CHILD, 'child-2'))
expect(h.state('parent-1')).toBe('working')
expect(h.state('parent-2')).toBeUndefined()
expect(h.tracker.state?.tasks).toHaveLength(1)
h.dispose()
})
@@ -184,28 +158,20 @@ describe('shared child execution projection', () => {
h.send(turn('turn/started', CHILD, 'child-1'))
h.send(turn('turn/completed', PRIMARY, 'parent-2'))
expect(h.tracker.state?.tasks).toHaveLength(1)
expect(h.state('parent-2')).toBeUndefined()
h.send(turn('turn/completed', CHILD, 'child-1'))
expect(h.state('parent-1')).toBe('completed')
expect(h.state('parent-2')).toBeUndefined()
expect(h.tracker.state).toBeNull()
h.dispose()
})
it('does not expose pending child settlement before journal admission and retries the same fact', () => {
it('settles a child on its own completion, which writes no row the journal could hold back', () => {
const h = harness()
h.send(turn('turn/started', PRIMARY, 'parent-1'))
h.send(activity('parent-1'))
h.send(turn('turn/started', CHILD, 'child-1'))
h.send(turn('turn/completed', PRIMARY, 'parent-1'))
const complete = turn('turn/completed', CHILD, 'child-1')
h.refuse(true)
expect(h.send(complete)).toEqual({ accepted: false, reason: 'backpressure' })
expect(h.tracker.state?.tasks).toHaveLength(1)
expect(h.state('parent-1')).toBe('working')
h.refuse(false)
expect(h.send(complete)).toEqual({ accepted: true })
expect(h.state('parent-1')).toBe('completed')
h.refuse(true)
expect(h.send(turn('turn/completed', CHILD, 'child-1'))).toEqual({ accepted: true })
expect(h.tracker.state).toBeNull()
h.dispose()
})
@@ -4,12 +4,12 @@ import { CodexSubagentExecutions } from './codex-subagent-executions'
describe('CodexSubagentExecutions retention and identity', () => {
it('bounds settled history through repeated execution without evicting live children', () => {
const executions = new CodexSubagentExecutions()
executions.register('long-lived', 'long-lived', 'parent')
executions.register('long-lived', 'long-lived')
executions.observeTurn('long-lived', 'long-lived-turn', 'working')
for (let index = 0; index < 1_000; index++) {
const id = `child-${index}`
executions.observeTurn(id, id, 'working')
executions.register(id, id, 'parent')
executions.register(id, id)
executions.observeTurn(id, id, 'completed')
}
expect(executions.workingChildren().map((child) => child.agentThreadId)).toEqual(['long-lived'])
@@ -18,27 +18,60 @@ describe('CodexSubagentExecutions retention and identity', () => {
expect(settledTurns).toBeLessThanOrEqual(256)
})
it('retains early live owner events at capacity and makes room only after settlement', () => {
it('keeps every running child past the settled-history bound', () => {
const executions = new CodexSubagentExecutions()
for (let index = 0; index < 128; index++) {
for (let index = 0; index < 200; index++) {
expect(executions.observeTurn(`child-${index}`, `turn-${index}`, 'working')).not.toBeNull()
executions.register(`child-${index}`, `child-${index}`)
}
expect(executions.workingChildren()).toHaveLength(200)
executions.observeTurn('child-0', 'turn-0', 'completed')
executions.register('next', 'next')
expect(executions.find('child-0')).toBeUndefined()
expect(executions.workingChildren()).toHaveLength(199)
})
it('evicts a child that ended before one announced but not yet started, which keeps its spawner', () => {
const executions = new CodexSubagentExecutions()
executions.register('announced', 'announced', 'spawner')
for (let index = 0; index < 127; index++) {
executions.observeTurn(`child-${index}`, `turn-${index}`, 'working')
}
expect(executions.observeTurn('overflow', 'overflow', 'working')).toBeNull()
for (let index = 0; index < 128; index++) {
executions.register(`child-${index}`, `child-${index}`, 'parent')
executions.observeTurn('child-5', 'turn-5', 'completed')
executions.register('next', 'next')
expect(executions.find('child-5')).toBeUndefined()
expect(executions.find('announced')).toMatchObject({
registered: true,
spawnerThreadId: 'spawner'
})
executions.observeTurn('announced', 'turn-a', 'working')
expect(executions.workingChildren().map((child) => child.agentThreadId)).toContain('announced')
})
it('past the bound, evicts the oldest never-started child once no child has ended', () => {
const executions = new CodexSubagentExecutions()
for (let index = 0; index < 200; index++) {
executions.register(`restored-${index}`, `restored-${index}`)
}
expect(executions.workingChildren()).toHaveLength(128)
executions.observeTurn('child-0', 'turn-0', 'completed')
expect(executions.observeTurn('overflow', 'overflow', 'working')).not.toBeNull()
executions.register('overflow', 'overflow', 'parent')
expect(executions.workingChildren()).toHaveLength(128)
expect(executions.retentionSizes().children).toBe(128)
expect(executions.find('restored-0')).toBeUndefined()
expect(executions.find('restored-199')).toBeDefined()
})
it('collapses a label’s whitespace, and keeps the first label a child was given', () => {
const executions = new CodexSubagentExecutions()
executions.register('child', ' audit \n the build ')
executions.register('child', 'later name')
executions.register('blank', ' ')
expect(executions.label('child')).toBe('audit the build')
expect(executions.label('blank')).toBeNull()
})
it('corrects an unverifiable execution with its own terminal event and ignores stale starts', () => {
const executions = new CodexSubagentExecutions()
executions.register('child', 'child', 'parent')
executions.register('child', 'child')
executions.observeTurn('child', 'turn', 'working')
executions.settleSession()
executions.closeThread('child')
expect(executions.workingChildren()).toEqual([])
expect(executions.observeTurn('child', 'turn', 'working')).toBeNull()
expect(executions.observeTurn('child', 'turn', 'completed')?.execution.state).toBe('completed')
+12 -44
View File
@@ -4,6 +4,8 @@ import {
canReplaceSubagentState
} from '../../shared/native-chat-subagent-summary'
/** Children kept past this evict the oldest that ran and ended, else the oldest never started; a
* running child is always kept, however many run. */
const MAX_CHILDREN = 128
const MAX_SETTLED_TURNS = 256
/** Turn ordinals remembered per child; a row from an older run reads as its first. */
@@ -18,7 +20,6 @@ export type CodexExecutionChild = {
agentThreadId: string
registered: boolean
label: string | null
parentTurnId: string | null
/** The thread whose stream carried this child's `started` activity. Codex
* emits that item on the spawning agent's own session, so it names the parent. */
spawnerThreadId: string | null
@@ -28,34 +29,17 @@ export type CodexExecutionChild = {
turnCount: number
}
/** Told when a child's current execution changes, whichever frame changed it. */
export type CodexExecutionListener = (child: Readonly<CodexExecutionChild>) => void
/** Child turn events own execution; activity items only identify the child. */
export class CodexSubagentExecutions {
private readonly children = new Map<string, CodexExecutionChild>()
private readonly settledTurns = new Map<string, NativeChatSubagentState>()
private readonly listeners = new Set<CodexExecutionListener>()
/** Every reader of a child's execution follows it here, so none needs the frames that end it. */
onExecutionChanged(listener: CodexExecutionListener): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
register(
agentThreadId: string,
label: string | null,
parentTurnId: string | null | undefined,
spawnerThreadId?: string
): CodexExecutionChild | undefined {
): CodexExecutionChild {
const child = this.child(agentThreadId)
if (!child) {
return undefined
}
if (!child.registered || parentTurnId !== undefined) {
child.parentTurnId = parentTurnId ?? null
}
// A child is spawned once; its announcement is delivered twice, never by another thread.
child.spawnerThreadId ??= spawnerThreadId ?? null
child.registered = true
@@ -79,9 +63,6 @@ export class CodexSubagentExecutions {
return null
}
const child = this.child(agentThreadId)
if (!child) {
return null
}
this.numberTurn(child, turnId)
if (
state === 'working' &&
@@ -107,7 +88,6 @@ export class CodexSubagentExecutions {
}
if (state === 'working' || !child.execution || child.execution.turnId === turnId) {
child.execution = execution
this.changed(child)
}
return { child, execution }
}
@@ -168,15 +148,6 @@ export class CodexSubagentExecutions {
)
}
settleSession(): void {
for (const child of this.children.values()) {
if (child.execution?.state === 'working') {
child.execution = { ...child.execution, state: 'unverifiable' }
this.changed(child)
}
}
}
clear(): void {
this.children.clear()
this.settledTurns.clear()
@@ -187,29 +158,26 @@ export class CodexSubagentExecutions {
return { children: this.children.size, settledTurns: this.settledTurns.size }
}
private changed(child: CodexExecutionChild): void {
for (const listener of this.listeners) {
listener(child)
}
}
private child(agentThreadId: string): CodexExecutionChild | undefined {
private child(agentThreadId: string): CodexExecutionChild {
const existing = this.children.get(agentThreadId)
if (existing) {
return existing
}
if (this.children.size >= MAX_CHILDREN) {
const settled = [...this.children].find(([, child]) => child.execution?.state !== 'working')
if (!settled) {
return undefined
// Refusing a running child would hide it while it runs. One announced but not started is
// about to run, so it goes only when no child has ended; a restored history announces many.
const children = [...this.children.values()]
const evicted =
children.find((child) => child.execution !== null && child.execution.state !== 'working') ??
children.find((child) => child.execution === null)
if (evicted) {
this.children.delete(evicted.agentThreadId)
}
this.children.delete(settled[0])
}
const child: CodexExecutionChild = {
agentThreadId,
registered: false,
label: null,
parentTurnId: null,
spawnerThreadId: null,
execution: null,
turnOrdinals: new Map(),
@@ -1,189 +0,0 @@
// Golden row writes for the Codex subagent roster: every append and publish it makes, and every
// admission it hands back, for scripted event sequences, so moving the roster onto shared code
// cannot change a saved byte.
import { describe, expect, it } from 'vitest'
import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types'
import { recordingSubagentRowSink } from '../native-chat/subagent-row-sink-recorder-test-support'
import type { CodexThreadItem } from './codex-structured-item-translation'
import { CodexSubagentRoster } from './codex-subagent-roster'
const PARENT = 'thread-parent'
/** The clock moves once per delivered event, never per read, so a row's times do not depend on how
* often the roster happens to read it. */
function harness() {
const recorder = recordingSubagentRowSink()
let clock = 1_000
let parentTurn: string | null = 'turn-1'
const roster = new CodexSubagentRoster({
sink: recorder.sink,
primaryThreadId: () => PARENT,
activeTurn: (threadId) => (threadId === PARENT ? parentTurn : null),
turnScopeFor: (threadId, turnId): AgentJournalTurnScope =>
turnId === null ? { kind: 'thread' } : { kind: 'turn', turnItemId: `${threadId}/${turnId}` },
now: () => clock
})
const admissions: boolean[] = []
const note = (admission: { accepted: boolean } | null) => {
admissions.push(admission?.accepted ?? true)
}
const tick = <T>(deliver: () => T): T => {
clock += 10
return deliver()
}
const activity = (kind: string, child: string, path: string | null): CodexThreadItem => ({
type: 'subAgentActivity',
id: `item-${child}-${kind}`,
kind,
agentThreadId: child,
agentPath: path
})
return {
...recorder,
roster,
admissions,
parentTurn: (next: string | null) => {
parentTurn = next
},
tick,
announce: (kind: string, child: string, path: string | null) => {
clock += 10
note(
roster.handleItem({
threadId: PARENT,
turnId: parentTurn,
item: activity(kind, child, path)
})
)
},
turnStarted: (child: string, turnId: string) => {
clock += 10
note(roster.handleTurnEvent({ method: 'turn/started', threadId: child, params: { turnId } }))
},
turnCompleted: (child: string, turnId: string, status: string) => {
clock += 10
note(
roster.handleTurnEvent({
method: 'turn/completed',
threadId: child,
params: { turn: { id: turnId, status } }
})
)
},
tokens: (child: string, totalTokens: number) => {
clock += 10
note(roster.handleTokenUsage({ threadId: child, tokenUsage: { total: { totalTokens } } }))
}
}
}
describe('Codex subagent roster rows (characterization)', () => {
it('one child: announced, runs a turn, reports tokens, completes', () => {
const h = harness()
h.announce('started', 'c1', '/root/reader')
h.turnStarted('c1', 'ct1')
h.announce('started', 'c1', '/root/reader')
h.tokens('c1', 120)
h.tokens('c1', 120)
h.tokens('c1', 340)
h.turnCompleted('c1', 'ct1', 'completed')
h.tokens('c1', 400)
expect({ log: h.log, admissions: h.admissions }).toMatchSnapshot()
})
it('two children sharing a path label, one failing and one interrupted', () => {
const h = harness()
h.turnStarted('c1', 'ct1')
h.announce('started', 'c1', '/root/worker')
h.turnStarted('c2', 'ct2')
h.announce('started', 'c2', '/root/team/worker')
h.turnStarted('c3', 'ct3')
h.announce('started', 'c3', '/root/worker 2')
h.turnCompleted('c1', 'ct1', 'failed')
h.turnCompleted('c2', 'ct2', 'interrupted')
h.turnCompleted('c3', 'ct3', 'mystery')
expect(h.log).toMatchSnapshot()
})
it('a child given new work in a later parent turn, and a late end of its earlier run', () => {
const h = harness()
h.turnStarted('c1', 'ct1')
h.announce('started', 'c1', '/root/reader')
h.turnCompleted('c1', 'ct1', 'completed')
h.parentTurn('turn-2')
h.announce('interacted', 'c1', '/root/reader')
h.turnStarted('c1', 'ct2')
h.turnCompleted('c1', 'ct1', 'failed')
h.turnStarted('c1', 'ct1')
h.turnCompleted('c1', 'ct2', 'completed')
expect(h.log).toMatchSnapshot()
})
it('a child running when the session ends, then a late verdict', () => {
const h = harness()
h.turnStarted('c1', 'ct1')
h.announce('started', 'c1', '/root/a')
h.turnStarted('c2', 'ct2')
h.announce('started', 'c2', '/root/b')
h.turnCompleted('c2', 'ct2', 'completed')
note(h.tick(() => h.roster.settleSession()))
h.turnCompleted('c1', 'ct1', 'completed')
expect(h.log).toMatchSnapshot()
function note(admission: { accepted: boolean }) {
h.admissions.push(admission.accepted)
}
})
it('a child thread closing mid-turn, then its turn completing', () => {
const h = harness()
h.turnStarted('c1', 'ct1')
h.announce('started', 'c1', '/root/a')
h.tick(() => h.roster.executions.closeThread('c1'))
h.turnCompleted('c1', 'ct1', 'completed')
expect(h.log).toMatchSnapshot()
})
it('a child announced outside any parent turn', () => {
const h = harness()
h.parentTurn(null)
h.turnStarted('c1', 'ct1')
h.announce('started', 'c1', '/root/loose')
h.parentTurn('turn-5')
h.turnCompleted('c1', 'ct1', 'completed')
expect(h.log).toMatchSnapshot()
})
it('more than 32 settled groups, then a late frame for an evicted run', () => {
const h = harness()
for (let index = 0; index < 34; index++) {
h.parentTurn(`turn-${index}`)
h.turnStarted(`c${index}`, `ct${index}`)
h.announce('started', `c${index}`, `/root/job${index}`)
h.turnCompleted(`c${index}`, `ct${index}`, 'completed')
}
h.parentTurn('turn-live')
h.turnStarted('live', 'ctl')
h.announce('started', 'live', '/root/live')
h.turnCompleted('c0', 'ct0', 'failed')
h.turnStarted('c0', 'ct0')
h.announce('started', 'c0', '/root/job0')
expect(h.log.length).toMatchSnapshot()
expect(h.log.slice(-6)).toMatchSnapshot()
expect(h.roster.retentionSizes()).toMatchSnapshot()
})
it('a refused append or publish is handed back, and the redelivered frame writes it', () => {
const h = harness()
h.turnStarted('c1', 'ct1')
h.refuseNextAppend()
h.announce('started', 'c1', '/root/a')
h.announce('started', 'c1', '/root/a')
h.refuseNextPublish()
h.turnCompleted('c1', 'ct1', 'completed')
h.turnCompleted('c1', 'ct1', 'completed')
h.tokens('c1', 50)
expect({ log: h.log, admissions: h.admissions }).toMatchSnapshot()
})
})
@@ -1,62 +0,0 @@
import { describe, expect, it } from 'vitest'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { isSubagentGroupBlock } from '../../shared/native-chat-types'
import { subagentGroupJournalBody } from '../native-chat/agent-session-journal/journal-subagent-group-body'
import { recordingSubagentRowSink } from '../native-chat/subagent-row-sink-recorder-test-support'
import {
CodexSubagentRoster,
codexSubagentGroupId,
codexSubagentGroupIdentity
} from './codex-subagent-roster'
const PARENT = 'thread-parent'
const OUTSIDE = codexSubagentGroupId(PARENT, null)
describe('Codex subagent roster after a provider restart', () => {
it('continues the row no turn owns instead of rewriting it from one child', () => {
const recorder = recordingSubagentRowSink()
const earlier = subagentGroupJournalBody(OUTSIDE, [
{ id: 'c1', label: 'one', state: 'completed', startedAt: 1, settledAt: 2 },
{ id: 'c2', label: 'two', state: 'unverifiable', startedAt: 1 }
])
const roster = new CodexSubagentRoster({
sink: {
...recorder.sink,
journalLinkage: () => ({
epoch: 'e1',
visitItemsWithLinkage: (visit) =>
visit(agentJournalItemKey(codexSubagentGroupIdentity(OUTSIDE)), 1, earlier, {
turnScope: { kind: 'thread' }
})
})
},
primaryThreadId: () => PARENT,
activeTurn: () => null,
turnScopeFor: () => ({ kind: 'thread' })
})
roster.handleTurn({ threadId: 'c3', turnId: 't3', state: 'working' })
roster.handleItem({
threadId: PARENT,
turnId: null,
item: {
type: 'subAgentActivity',
id: 'i3',
kind: 'started',
agentThreadId: 'c3',
agentPath: '/root/three'
}
})
const last = recorder.log.findLast((write) => write.op === 'append')
const agents =
last?.op === 'append' && last.body.kind === 'message'
? last.body.blocks.find(isSubagentGroupBlock)?.agents
: undefined
expect(last?.op === 'append' && last.identity).toEqual(codexSubagentGroupIdentity(OUTSIDE))
expect(agents?.map((agent) => [agent.id, agent.label, agent.state])).toEqual([
['c1', 'one', 'completed'],
['c2', 'two', 'unverifiable'],
['c3', 'three', 'working']
])
})
})
@@ -1,322 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
import { isSubagentGroupBlock, type NativeChatSubagentState } from '../../shared/native-chat-types'
import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support'
import {
createDeferredStructuredAgentSessionEventSink,
type StructuredAgentSessionEventSink
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
import { CodexSubagentRoster } from './codex-subagent-roster'
import { MAX_CODEX_SUBAGENT_GROUPS } from './codex-structured-journal-limits'
const THREAD = 'parent'
const ORIGINAL = 'original'
const journals = createTrackedJournalOpener()
const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) {
await cleanup()
}
await journals.closeAll()
})
async function session(refuse?: 'append' | 'publish') {
const root = await mkdtemp(join(tmpdir(), 'orca-codex-roster-retention-'))
const journal = await journals.open({
identity: {
sessionId: 'session-codex-roster-retention',
workspaceId: 'workspace-1',
hostId: 'local',
agent: 'codex',
providerHandle: codexProviderHandle(THREAD)
},
stateDirectory: root,
now: () => 1_000
})
const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging())
deferred.bind({ journal, fence: 1, publish: () => {} })
const append = deferred.sink.tryAppendItem
const publish = deferred.sink.tryPublish
if (!append || !publish) {
throw new Error('the deferred sink must expose append and publish admission')
}
let refusing = false
let writingOriginal = false
const refusal = { accepted: false, reason: 'backpressure' } as const
const sink: StructuredAgentSessionEventSink = {
...deferred.sink,
tryAppendItem: (identity, body, options) => {
writingOriginal =
identity.provider === 'orca' &&
identity.clientMessageId === `codex-subagents:${THREAD}:${ORIGINAL}`
return refusing && writingOriginal && refuse === 'append'
? refusal
: append(identity, body, options)
},
tryPublish: () => (refusing && writingOriginal && refuse === 'publish' ? refusal : publish())
}
let parentTurn = ORIGINAL
let clock = 1_000
const roster = new CodexSubagentRoster({
sink,
primaryThreadId: () => THREAD,
activeTurn: () => parentTurn,
turnScopeFor: (_thread, turnId) => ({ kind: 'turn', turnItemId: turnId ?? 'outside-turn' }),
now: () => ++clock
})
cleanups.push(async () => {
roster.dispose()
deferred.close()
await journal.close()
await rm(root, { recursive: true, force: true })
})
const turn = (id: string, state: NativeChatSubagentState, turnId = `execution:${id}`) =>
roster.handleTurn({ threadId: id, turnId, state })
const announce = (id: string, kind = 'started') =>
roster.handleItem({
threadId: THREAD,
turnId: parentTurn,
item: {
type: 'subAgentActivity',
id: `activity:${id}:${kind}`,
agentThreadId: id,
agentPath: '/root/task',
kind
}
})
function spawn(id: string, parent = ORIGINAL) {
parentTurn = parent
expect(turn(id, 'working')).toEqual({ accepted: true })
expect(announce(id)).toEqual({ accepted: true })
}
async function rows() {
await deferred.drained()
return journal
.snapshot()
.items.flatMap((row) =>
row.body.kind === 'message'
? row.body.blocks.filter(isSubagentGroupBlock).map((group) => ({ row, group }))
: []
)
}
return {
roster,
turn,
announce,
spawn,
rows,
original: async () =>
(await rows()).find(({ group }) => group.groupId === `${THREAD}:${ORIGINAL}`),
parent: (turnId: string) => {
parentTurn = turnId
},
refuse: (value: boolean) => {
refusing = value
}
}
}
type Session = Awaited<ReturnType<typeof session>>
async function laterGroups(s: Session, settled: boolean, count = MAX_CODEX_SUBAGENT_GROUPS + 2) {
for (let index = 0; index < count; index++) {
const id = `later-child-${index}`
s.spawn(id, `later-parent-${index}`)
if (settled) {
expect(s.turn(id, 'completed')).toEqual({ accepted: true })
}
if (index % 32 === 31) {
await s.rows()
}
}
await s.rows()
}
function states(s: Awaited<ReturnType<Session['original']>>) {
return s?.group.agents.map(({ id, state }) => ({ id, state }))
}
describe('Codex roster retention through the real sink and journal', () => {
it.each([
[false, 'completed'],
[true, 'completed'],
[false, 'sweep'],
[true, 'sweep']
] as const)(
'preserves all oldest live children when later groups settled=%s and final event=%s',
async (settled, final) => {
const s = await session()
for (const id of ['old-a', 'old-b', 'old-c']) {
s.spawn(id)
}
const before = await s.original()
expect(before?.group.agents).toHaveLength(3)
await laterGroups(s, settled)
s.parent('current')
s.announce('old-a', 'interacted')
expect(states(await s.original())).toEqual(
['old-a', 'old-b', 'old-c'].map((id) => ({ id, state: 'working' }))
)
if (final === 'sweep') {
expect(s.roster.settleSession()).toEqual({ accepted: true })
} else {
for (const id of ['old-a', 'old-b', 'old-c']) {
s.turn(id, 'completed')
}
}
const after = await s.original()
expect(after?.row.itemId).toBe(before?.row.itemId)
expect(after?.row.turnScope).toEqual(before?.row.turnScope)
expect(states(after)).toEqual(
['old-a', 'old-b', 'old-c'].map((id) => ({
id,
state: final === 'sweep' ? 'unverifiable' : 'completed'
}))
)
expect(after?.group.agents.every((entry) => entry.settledAt !== undefined)).toBe(true)
expect(
(await s.rows()).flatMap(({ group }) =>
group.agents.filter((entry) => entry.id === 'old-a')
)
).toHaveLength(1)
}
)
it('ignores late reports for evicted settled executions even after the execution cache has pruned them', async () => {
const s = await session()
s.spawn('old-a')
s.spawn('old-b')
s.turn('old-a', 'stopped')
s.turn('old-b', 'completed')
const before = await s.original()
await laterGroups(s, true, 270)
expect(s.roster.executions.find('old-a')).toBeUndefined()
expect(s.roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS)
s.parent('current')
s.turn('old-a', 'working')
s.announce('old-a')
s.announce('old-a', 'interacted')
s.turn('old-a', 'completed')
s.announce('old-a', 'completed')
s.roster.handleTokenUsage({ threadId: 'old-a', tokenUsage: { total: { totalTokens: 123 } } })
expect(await s.original()).toEqual(before)
expect(s.roster.executions.find('old-a')?.execution).toBeNull()
expect(
(await s.rows()).flatMap(({ group }) => group.agents.filter((entry) => entry.id === 'old-a'))
).toHaveLength(1)
})
it.each([
['before', false],
['after', false],
['before', true],
['after', true]
] as const)(
'admits a genuine new child turn when the announcement is %s its start and execution cache pruned=%s',
async (order, pruned) => {
const s = await session()
s.spawn('old-a')
s.turn('old-a', 'completed')
const before = await s.original()
await laterGroups(s, true, pruned ? 270 : MAX_CODEX_SUBAGENT_GROUPS + 2)
s.parent('follow-up')
if (order === 'before') {
s.announce('old-a', 'interacted')
}
s.turn('old-a', 'working', 'new-execution')
if (order === 'after') {
s.announce('old-a', 'interacted')
}
const followup = () =>
s.rows().then((rows) => rows.find(({ group }) => group.groupId === `${THREAD}:follow-up`))
expect(states(await followup())).toEqual([{ id: 'old-a', state: 'working' }])
s.turn('old-a', 'working')
s.turn('old-a', 'completed')
expect(states(await followup())).toEqual([{ id: 'old-a', state: 'working' }])
s.turn('old-a', 'completed', 'new-execution')
expect(states(await followup())).toEqual([{ id: 'old-a', state: 'completed' }])
expect(await s.original()).toEqual(before)
}
)
it('suppresses both settled executions after same-group reuse, eviction and execution-cache churn', async () => {
const s = await session()
s.spawn('old-a')
s.spawn('old-b')
s.turn('old-a', 'completed')
s.announce('old-a', 'interacted')
s.turn('old-a', 'working', 'second-execution')
s.turn('old-a', 'completed', 'second-execution')
s.turn('old-b', 'completed')
const original = await s.original()
await laterGroups(s, true, 270)
expect(s.roster.executions.find('old-a')).toBeUndefined()
expect(s.roster.retentionSizes().settledIdentities).toBeLessThan(2048)
const before = await s.rows()
s.parent('current')
for (const turnId of ['execution:old-a', 'second-execution']) {
s.turn('old-a', 'working', turnId)
s.announce('old-a')
s.turn('old-a', 'completed', turnId)
s.announce('old-a', 'completed')
}
expect(await s.rows()).toEqual(before)
s.announce('old-a', 'interacted')
s.turn('old-a', 'working', 'third-execution')
const current = (await s.rows()).find(({ group }) => group.groupId === `${THREAD}:current`)
expect(states(current)).toEqual([{ id: 'old-a', state: 'working' }])
s.turn('old-a', 'completed', 'third-execution')
expect(await s.original()).toEqual(original)
})
it.each(['append', 'publish'] as const)(
'retains final retry ownership after %s refuses settlement under group pressure',
async (refuse) => {
const s = await session(refuse)
s.spawn('old-a')
s.spawn('old-b')
const before = await s.original()
await laterGroups(s, false)
s.turn('old-b', 'completed')
s.refuse(true)
expect(s.turn('old-a', 'completed')).toEqual({ accepted: false, reason: 'backpressure' })
s.spawn('more-live', 'current')
expect(s.roster.retentionSizes().groups).toBeGreaterThan(MAX_CODEX_SUBAGENT_GROUPS)
s.refuse(false)
expect(s.roster.settleSession()).toEqual({ accepted: true })
const after = await s.original()
expect(after?.row.itemId).toBe(before?.row.itemId)
expect(states(after)).toEqual([
{ id: 'old-a', state: 'completed' },
{ id: 'old-b', state: 'completed' }
])
expect(s.roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS)
}
)
it.each(['append', 'publish'] as const)(
'retries a refused final sweep %s after more live groups arrive',
async (refuse) => {
const s = await session(refuse)
s.spawn('old-a')
s.spawn('old-b')
const before = await s.original()
await laterGroups(s, false)
s.refuse(true)
expect(s.roster.settleSession()).toEqual({ accepted: false, reason: 'backpressure' })
s.spawn('more-live', 'current')
s.refuse(false)
expect(s.roster.settleSession()).toEqual({ accepted: true })
const after = await s.original()
expect(after?.row.itemId).toBe(before?.row.itemId)
expect(states(after)).toEqual([
{ id: 'old-a', state: 'unverifiable' },
{ id: 'old-b', state: 'unverifiable' }
])
expect(s.roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS)
}
)
})
@@ -1,23 +0,0 @@
// The ids a Codex spawn group's journal row is keyed on.
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
const OUTSIDE_TURN = 'outside-turn'
/** Group identity: the parent turn that spawned the children. `agentPath` is a
* tree rooted at the parent thread, so every child of one turn shares a row
* no matter which thread's stream carried its activity item. */
export function codexSubagentGroupId(threadId: string, turnId: string | null): string {
return `${threadId}:${turnId ?? OUTSIDE_TURN}`
}
/** The group of children reported outside any of the parent's turns. */
export function isCodexOutsideTurnGroup(groupId: string): boolean {
return groupId.endsWith(`:${OUTSIDE_TURN}`)
}
/** Durable journal identity for the group's row — stable across revisions and
* across a restart, so replay finds the same row instead of appending a new one. */
export function codexSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity {
return { provider: 'orca', clientMessageId: `codex-subagents:${groupId}` }
}
@@ -1,779 +0,0 @@
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types'
import { describe, expect, it } from 'vitest'
import { isAdmissibleAgentJournalItemBody } from '../../shared/agent-session-journal-schemas'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary'
import { isSubagentGroupBlock, type NativeChatSubagentEntry } from '../../shared/native-chat-types'
import {
createDeferredStructuredAgentSessionEventSink,
type StructuredAgentSessionEventSink,
type StructuredAgentSessionEventTarget
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { withJournalQueueMembers } from '../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support'
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
import {
CodexSubagentRoster,
codexSubagentGroupIdentity,
codexSubagentGroupId
} from './codex-subagent-roster'
import type { CodexThreadItem } from './codex-structured-item-translation'
import {
MAX_CODEX_SUBAGENT_GROUPS,
MAX_CODEX_SUBAGENTS_PER_GROUP,
MAX_CODEX_TOKEN_USAGE_THREADS
} from './codex-structured-journal-limits'
const THREAD = 'thread-parent'
const TURN = 'turn-1'
type Appended = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }
function createHarness(options: { threadId?: string | null } = {}): {
roster: CodexSubagentRoster
appended: Appended[]
agents: () => NativeChatSubagentEntry[]
latest: () => Appended | undefined
} {
const appended: Appended[] = []
let clock = 1_000
const sink: StructuredAgentSessionEventSink = {
appendItem: () => {},
appendTombstone: () => {},
publish: () => {},
tryAppendItem: (identity, body) => {
appended.push({ identity, body })
return { accepted: true }
},
tryPublish: () => ({ accepted: true })
}
const roster = new CodexSubagentRoster({
turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE,
sink,
primaryThreadId: () => (options.threadId === undefined ? THREAD : options.threadId),
activeTurn: () => TURN,
now: () => (clock += 1)
})
const agents = (): NativeChatSubagentEntry[] => {
const body = appended.at(-1)?.body
if (!body || body.kind !== 'message') {
return []
}
const block = body.blocks.find(isSubagentGroupBlock)
return block ? block.agents : []
}
return { roster, appended, agents, latest: () => appended.at(-1) }
}
function latestIdentity(appended: Appended[]): AgentJournalItemIdentity | undefined {
return appended.at(-1)?.identity
}
function activity(input: {
id?: string
kind: string
agentThreadId: string
agentPath: string | null
}): CodexThreadItem {
return {
type: 'subAgentActivity',
id: input.id ?? `item-${input.agentThreadId}-${input.kind}`,
kind: input.kind,
agentThreadId: input.agentThreadId,
agentPath: input.agentPath
}
}
function deliver(
roster: CodexSubagentRoster,
item: CodexThreadItem,
turnId: string | null = TURN
): void {
// The fixture includes the child's owner event separately from its activity metadata.
const state =
item.kind === 'started'
? 'working'
: item.kind === 'completed'
? 'completed'
: item.kind === 'interrupted'
? 'stopped'
: null
if (state && typeof item.agentThreadId === 'string') {
roster.handleTurn({
threadId: item.agentThreadId,
turnId: `execution:${item.agentThreadId}`,
state
})
}
// Every activity item reaches the wire twice: item/started, then item/completed.
roster.handleItem({ threadId: THREAD, turnId, item })
roster.handleItem({ threadId: THREAD, turnId, item })
}
/** The real event sink, unbound until `drain`, over a journal double that records appends. */
function createQueuedHarness(): {
roster: CodexSubagentRoster
appended: Appended[]
drain: () => void
} {
const appended: Appended[] = []
let clock = 1_000
const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging())
const journal = withJournalQueueMembers({
appendItem: async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => {
appended.push({ identity, body })
return { cursor: { epoch: 'e', sequence: appended.length } }
}
})
const roster = new CodexSubagentRoster({
turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE,
sink: deferred.sink,
primaryThreadId: () => THREAD,
activeTurn: () => TURN,
now: () => (clock += 1)
})
return {
roster,
appended,
drain: () =>
deferred.bind({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the roster's rows reach only appendItem and the in-order members the helper adds.
journal: journal as unknown as StructuredAgentSessionEventTarget['journal'],
fence: 1,
publish: () => {}
})
}
}
describe('CodexSubagentRoster', () => {
it('lands the roster append its own publish was buffered beside', () => {
const { roster, appended, drain } = createQueuedHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
drain()
// A publish once shared the append's coalescing key and spliced it out of the queue, and
// `lastSerialized` then suppressed every retry.
expect(appended).toHaveLength(1)
})
it('counts a /morpheus agent as a child — only /root is the turn itself', () => {
const { roster, agents } = createHarness()
deliver(roster, activity({ kind: 'started', agentThreadId: 'child-m', agentPath: '/morpheus' }))
expect(agents()).toMatchObject([{ id: 'child-m', label: 'morpheus', state: 'working' }])
})
// `codexSubagentPathSegments` already defines what a path means for the label,
// and the root check has to agree with it: a path that normalizes to the same
// node must classify the same way, or one string is both the turn itself and a
// child of it — a phantom row labelled `root` inflating the group by one.
it('reads a root path with a trailing or doubled separator as the turn itself', () => {
for (const agentPath of ['/root/', '/root//', '//root']) {
const { roster, appended } = createHarness()
deliver(roster, activity({ kind: 'started', agentThreadId: THREAD, agentPath }))
expect(appended).toEqual([])
}
})
it('keeps a doubled separator inside a child path off the label', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root//read/' })
)
expect(agents()).toMatchObject([{ id: 'child-1', label: 'read' }])
})
// An all-whitespace trailing segment survives the empty-segment filter and
// would draw a row with no visible name at all.
it('falls back to the placeholder when the trailing segment has nothing to show', () => {
const { roster, agents } = createHarness()
deliver(roster, activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/ ' }))
expect(agents()).toMatchObject([{ id: 'child-1', label: 'subagent' }])
})
// The collision ordinal keys on the label, so two segments that render
// identically must collide rather than both draw as `read`.
it('collides labels that differ only in surrounding whitespace', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/ read ' })
)
expect(agents().map((agent) => agent.label)).toEqual(['read', 'read 2'])
})
it('ignores the root node so a turn is not its own subagent', () => {
const { roster, appended } = createHarness()
deliver(roster, activity({ kind: 'started', agentThreadId: THREAD, agentPath: '/root' }))
expect(appended).toEqual([])
})
it('writes an admissible journal body carrying a plain-text fallback block', () => {
const { roster, latest } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/list_directory' })
)
const body = latest()?.body
expect(body?.kind).toBe('message')
expect(isAdmissibleAgentJournalItemBody(body)).toBe(true)
expect(body?.kind === 'message' ? body.blocks.map((block) => block.type) : []).toEqual([
'text',
'subagent-group'
])
expect(
body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : ''
).toBe('Kicked off 1 subagent')
})
it('keys the durable identity by the parent turn so a revision lands on one row', () => {
const { roster, appended } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
)
const expected = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, TURN))
expect(new Set(appended.map((entry) => JSON.stringify(entry.identity)))).toEqual(
new Set([JSON.stringify(expected)])
)
})
it('rule 1 — a duplicate delivery writes no second revision', () => {
const { roster, appended } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(appended).toHaveLength(1)
})
it('registers a child after its owner already reported completion', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-late', agentPath: '/root/search' })
)
expect(agents()).toMatchObject([{ id: 'child-late', label: 'search', state: 'completed' }])
})
it('rule 3 — a terminal state latches against a late or duplicate start', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()).toMatchObject([{ state: 'completed' }])
})
it('rule 4 — the session sweep settles a lost child as unverifiable, not exited', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-2', agentPath: '/root/search' })
)
roster.settleSession()
expect(agents()).toMatchObject([
{ id: 'child-1', state: 'unverifiable' },
{ id: 'child-2', state: 'completed' }
])
})
it('lets a swept child still report what it actually did', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
roster.settleSession()
expect(agents()[0]?.state).toBe('unverifiable')
// Contact can return — a reconnected provider replays the child's own
// verdict. Latching the sweep would report a child that finished as one we
// never saw finish.
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()[0]?.state).toBe('completed')
})
it('refuses to put a swept child back to working', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
roster.settleSession()
// A straggler progress tick after we gave up must not re-light the row.
deliver(
roster,
activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()[0]?.state).toBe('unverifiable')
})
it('keeps a real verdict when a later frame disagrees', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'interrupted', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()[0]?.state).toBe('completed')
})
it('rule 4 — the session sweep settles every group and never un-terminals one', () => {
const { roster, agents, appended } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
roster.settleSession()
const afterFirstSweep = appended.length
roster.settleSession()
expect(agents()).toMatchObject([{ state: 'unverifiable' }])
expect(appended).toHaveLength(afterFirstSweep)
})
it('rule 5 — the whole roster is persisted in the carrier, not just a count', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 40661 } } })
expect(agents()).toMatchObject([
{ id: 'child-1', label: 'read', state: 'working', tokens: 40661 }
])
})
it('rule 6 — the group id names the parent turn, or says there was none', () => {
const { roster, appended } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/search' }),
null
)
expect(appended.map((entry) => entry.identity)).toEqual([
{ provider: 'orca', clientMessageId: `codex-subagents:${THREAD}:${TURN}` },
{ provider: 'orca', clientMessageId: `codex-subagents:${THREAD}:outside-turn` }
])
})
it('disambiguates two children that share a trailing path segment', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/read' })
)
expect(agents().map((agent) => agent.label)).toEqual(['read', 'read 2'])
})
it('takes the latest token snapshot per child and never accumulates updates', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 100 } } })
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 250 } } })
expect(agents()).toMatchObject([{ tokens: 250 }])
})
it('retains a usage frame that arrives before the child is known', () => {
const { roster, agents } = createHarness()
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 900 } } })
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()).toMatchObject([{ tokens: 900 }])
})
it('never attributes the parent thread its own usage', () => {
const { roster, agents, appended } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
const beforeParentUsage = appended.length
roster.handleTokenUsage({ threadId: THREAD, tokenUsage: { total: { totalTokens: 26099 } } })
expect(appended).toHaveLength(beforeParentUsage)
expect(agents()).toHaveLength(1)
expect(agents()[0]).not.toHaveProperty('tokens')
})
// The row is durable and both readers clip these fields to the same cap, so
// writing more than that is bytes replayed on every reconnect and then thrown
// away. The marker is an ellipsis, not the tool-output truncation sentence:
// `id` is the roster key and the renderer's React key.
it('bounds the provider strings the roster row carries into the journal', () => {
const { roster, agents, latest } = createHarness()
const oversized = 'a'.repeat(20 * 1024)
deliver(
roster,
activity({ kind: 'started', agentThreadId: oversized, agentPath: `/root/${oversized}` })
)
const entry = agents()[0]
expect(entry?.label.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
expect(entry?.label).toMatch(/…~0$/)
expect(entry?.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
expect(entry?.id).toMatch(/…~0$/)
expect(JSON.stringify(latest()?.body)).not.toContain('output truncated')
expect(isAdmissibleAgentJournalItemBody(latest()?.body)).toBe(true)
})
// The clip cuts UTF-16 code units, so a boundary landing inside a surrogate
// pair left a LONE high surrogate in a durable row — malformed, and replaced
// with U+FFFD through any non-JSON UTF-8 hop.
it('never clips a provider string mid surrogate pair', () => {
const { roster, agents } = createHarness()
const astral = '😀'.repeat(400)
deliver(
roster,
activity({ kind: 'started', agentThreadId: astral, agentPath: `/root/${astral}` })
)
const entry = agents()[0]
expect(entry?.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
expect(Buffer.from(entry?.id ?? '', 'utf8').toString('utf8')).toBe(entry?.id)
expect(Buffer.from(entry?.label ?? '', 'utf8').toString('utf8')).toBe(entry?.label)
})
// The clip removes exactly the tail that told two children apart: `id` is the
// renderer's React key, and `claimLabel` writes its repeat ordinal at the end.
// Two clipped children collapsing to one key drew two rows under one identity.
it('keeps clipped ids and labels distinct between children', () => {
const { roster, agents } = createHarness()
const prefix = 'p'.repeat(MAX_SUBAGENT_FIELD_CHARS)
const sharedPath = `/root/${'q'.repeat(640)}`
deliver(
roster,
activity({ kind: 'started', agentThreadId: `${prefix}AAAA`, agentPath: sharedPath })
)
deliver(
roster,
activity({ kind: 'started', agentThreadId: `${prefix}BBBB`, agentPath: sharedPath })
)
const entries = agents()
expect(entries).toHaveLength(2)
expect(new Set(entries.map((agent) => agent.id)).size).toBe(2)
expect(new Set(entries.map((agent) => agent.label)).size).toBe(2)
for (const agent of entries) {
expect(agent.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
expect(agent.label.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
}
})
it('caps the children one spawn group admits', () => {
const { roster, agents, appended } = createHarness()
for (let index = 0; index < MAX_CODEX_SUBAGENTS_PER_GROUP; index++) {
deliver(
roster,
activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' })
)
}
const atCap = appended.length
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-over-cap', agentPath: '/root/read' })
)
expect(agents()).toHaveLength(MAX_CODEX_SUBAGENTS_PER_GROUP)
expect(agents().map((agent) => agent.id)).not.toContain('child-over-cap')
// Refusing the child must not burn a revision either.
expect(appended).toHaveLength(atCap)
})
it('retains live spawn groups beyond the settled history budget', () => {
const { roster, appended, agents } = createHarness()
for (let index = 0; index <= MAX_CODEX_SUBAGENT_GROUPS; index++) {
deliver(
roster,
activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' }),
`turn-${index}`
)
}
const original = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, 'turn-0'))
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-late', agentPath: '/root/search' }),
'turn-0'
)
expect(latestIdentity(appended)).toEqual(original)
expect(agents().map((agent) => agent.id)).toEqual(['child-0', 'child-late'])
expect(roster.retentionSizes().groups).toBe(MAX_CODEX_SUBAGENT_GROUPS + 1)
})
it('keeps a token count a later thread-map eviction would otherwise retract', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 4242 } } })
expect(agents()).toMatchObject([{ tokens: 4242 }])
for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) {
roster.handleTokenUsage({
threadId: `other-${index}`,
tokenUsage: { total: { totalTokens: index } }
})
}
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()).toMatchObject([{ state: 'completed', tokens: 4242 }])
})
it('caps retained usage threads, so a frame evicted before its child is dropped', () => {
const { roster, agents } = createHarness()
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 900 } } })
for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) {
roster.handleTokenUsage({
threadId: `other-${index}`,
tokenUsage: { total: { totalTokens: index } }
})
}
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()[0]).not.toHaveProperty('tokens')
})
it('declines a payload that is not a subagent item or a usage frame', () => {
const { roster } = createHarness()
expect(
roster.handleItem({
threadId: THREAD,
turnId: TURN,
item: { type: 'commandExecution', id: 'item-9' }
})
).toBeNull()
expect(roster.handleTokenUsage({ threadId: 'child-1' })).toBeNull()
})
// A refusal must never advance the duplicate-suppression state: an identical
// replay would short-circuit and the revision would never be retried. The
// append and the publish are the two ways to be refused, so both are covered.
it.each([{ refuse: 'append' as const }, { refuse: 'publish' as const }])(
'retries the same revision after the $refuse is refused',
({ refuse }) => {
let refusing = true
const appended: Appended[] = []
const published: number[] = []
const refusal = { accepted: false, reason: 'backpressure' } as const
const roster = new CodexSubagentRoster({
turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE,
sink: {
appendItem: () => {},
appendTombstone: () => {},
publish: () => {},
tryAppendItem: (identity, body) => {
if (refusing && refuse === 'append') {
return refusal
}
appended.push({ identity, body })
return { accepted: true }
},
tryPublish: () => {
if (refusing && refuse === 'publish') {
return refusal
}
published.push(1)
return { accepted: true }
}
},
primaryThreadId: () => THREAD,
activeTurn: () => TURN,
now: () => 1_000
})
const item = activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' })
expect(roster.handleItem({ threadId: THREAD, turnId: TURN, item })).toEqual(refusal)
// The wire redelivers the very same item; nothing about the roster changed,
// so only a cleared suppression state can get the revision out.
refusing = false
expect(roster.handleItem({ threadId: THREAD, turnId: TURN, item })).toEqual({
accepted: true
})
// The retry re-appends when the publish was the half that failed; the real
// sink writes both, as revisions of the one group row. What must not happen
// is the revision never being published at all.
expect(published).toHaveLength(1)
const body = appended.at(-1)?.body
expect(
body?.kind === 'message' ? body.blocks.filter(isSubagentGroupBlock) : []
).toMatchObject([{ agents: [{ id: 'child-1', state: 'working' }] }])
}
)
// The sweep is the last event a group ever gets. A refusal there, left
// unretried, strands the settled roster's final revision — the exact "row
// stays stale forever" this row exists to prevent.
it('republishes the settled roster when the sweep publish was refused', () => {
let refusing = false
const appended: Appended[] = []
const published: number[] = []
const roster = new CodexSubagentRoster({
turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE,
sink: {
appendItem: () => {},
appendTombstone: () => {},
publish: () => {},
tryAppendItem: (identity, body) => {
appended.push({ identity, body })
return { accepted: true }
},
tryPublish: () => {
if (refusing) {
return { accepted: false, reason: 'backpressure' }
}
published.push(1)
return { accepted: true }
}
},
primaryThreadId: () => THREAD,
activeTurn: () => TURN,
now: () => 1_000
})
roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' })
roster.handleItem({
threadId: THREAD,
turnId: TURN,
item: activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
})
const publishedBeforeSweep = published.length
refusing = true
expect(roster.settleSession()).toEqual({ accepted: false, reason: 'backpressure' })
// The retry sweep flips no state — every child already latched — so only a
// cleared suppression state can carry the unverifiable roster out.
refusing = false
expect(roster.settleSession()).toEqual({ accepted: true })
expect(published.length).toBe(publishedBeforeSweep + 1)
const body = appended.at(-1)?.body
expect(body?.kind === 'message' ? body.blocks.filter(isSubagentGroupBlock) : []).toMatchObject([
{ agents: [{ id: 'child-1', state: 'unverifiable' }] }
])
})
it('propagates sink backpressure instead of reporting the row as written', () => {
const roster = new CodexSubagentRoster({
turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE,
sink: {
appendItem: () => {},
appendTombstone: () => {},
publish: () => {},
tryAppendItem: () => ({ accepted: false, reason: 'backpressure' }),
tryPublish: () => ({ accepted: true })
},
primaryThreadId: () => THREAD,
activeTurn: () => TURN
})
roster.handleTurn({ threadId: 'child-1', turnId: 'child-turn', state: 'working' })
expect(
roster.handleItem({
threadId: THREAD,
turnId: TURN,
item: activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
})
).toEqual({ accepted: false, reason: 'backpressure' })
})
})
-241
View File
@@ -1,241 +0,0 @@
// The Codex subagent roster: one journal row per spawn group, revised in place.
//
// An announcement supplies membership — a `subAgentActivity` item, or in
// Codex's default multi-agent mode any collab call naming the helper — and
// child turn events supply execution state: each child turn is one run of the
// child. Everything the row says follows the shared subagent tracker's rules;
// this module only reads Codex's events. Rows earlier provider runs journaled
// are inherited, so a restart neither splits a child nor rewrites the row no
// turn owns from one child.
import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types'
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import type {
StructuredAgentSessionEventSink,
StructuredAgentSessionSinkAdmission
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { JournaledSubagentGroups } from '../native-chat/subagent-tracker/journaled-subagent-groups'
import { subagentRowSinkPort } from '../native-chat/subagent-tracker/subagent-row-sink-port'
import { SubagentTracker } from '../native-chat/subagent-tracker/subagent-tracker'
import type { SubagentReport } from '../native-chat/subagent-tracker/subagent-tracker-types'
import {
readCodexSubagentActivity,
readCodexSubagentAnnouncements,
type CodexSubagentAnnouncement,
readCodexThreadTokenTotal
} from './codex-subagent-activity'
import {
CodexSubagentExecutions,
codexChildTurnState,
type CodexChildExecution,
type CodexExecutionChild
} from './codex-subagent-executions'
import { readRecord } from './codex-item-field-readers'
import { readCodexTurnId } from './codex-structured-thread-facts'
import { CodexSubagentLinkage } from './codex-subagent-linkage'
import {
codexSubagentGroupId,
codexSubagentGroupIdentity,
isCodexOutsideTurnGroup
} from './codex-subagent-roster-state'
import type { CodexThreadItem } from './codex-structured-item-translation'
import { CodexThreadTokenTotals } from './codex-thread-token-totals'
export { subagentGroupJournalBody as codexSubagentGroupBody } from '../native-chat/agent-session-journal/journal-subagent-group-body'
export { codexSubagentGroupId, codexSubagentGroupIdentity } from './codex-subagent-roster-state'
const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true }
export type CodexSubagentRosterDeps = {
sink: StructuredAgentSessionEventSink
/** The thread that owns the agent tree; falls back to the event's thread. */
primaryThreadId: () => string | null
activeTurn: (threadId: string) => string | null
turnScopeFor: (threadId: string, turnId: string | null) => AgentJournalTurnScope
now?: () => number
executions?: CodexSubagentExecutions
}
export class CodexSubagentRoster {
private readonly tracker: SubagentTracker<AgentJournalTurnScope>
/** Every thread's total, members or not; a child shows its total once it is listed. */
private readonly tokensByThread = new CodexThreadTokenTotals()
/** The one owner of child membership and turn state; the rows of calls on a helper read it too. */
readonly executions: CodexSubagentExecutions
private readonly unfollow: () => void
/** Who produced a row, from what this roster learned about each child thread. */
readonly linkage: CodexSubagentLinkage
constructor(private readonly deps: CodexSubagentRosterDeps) {
this.tracker = new SubagentTracker({
port: subagentRowSinkPort(deps.sink, codexSubagentGroupIdentity),
outsideTurn: isCodexOutsideTurnGroup,
journaled: new JournaledSubagentGroups(
() => deps.sink.journalLinkage?.() ?? null,
codexSubagentGroupIdentity
),
...(deps.now ? { now: deps.now } : {})
})
this.executions = deps.executions ?? new CodexSubagentExecutions()
// The row follows the executions, so every frame that ends a child's turn — its own
// `turn/completed` (a failed one included) or its thread closing — settles it.
this.unfollow = this.executions.onExecutionChanged(
(child) => child.execution && this.follow(child, child.execution)
)
this.linkage = new CodexSubagentLinkage({
primaryThreadId: deps.primaryThreadId,
executions: this.executions
})
}
/** Consume an item that announces children. Null means the item is not this roster's to render:
* a `subAgentActivity` item renders as the roster row alone, while a collab call keeps its own
* row, so it is claimed only to hand back a refused write. */
handleItem(input: {
threadId: string
turnId: string | null
item: CodexThreadItem
}): StructuredAgentSessionSinkAdmission | null {
const refused = readCodexSubagentAnnouncements(input.item, this.deps.primaryThreadId())
.map((announcement) => this.announce(input, announcement))
.find((admission) => !admission.accepted)
return refused ?? (readCodexSubagentActivity(input.item) !== null ? ADMITTED : null)
}
private announce(
input: { threadId: string; turnId: string | null },
announcement: CodexSubagentAnnouncement
): StructuredAgentSessionSinkAdmission {
const child = this.executions.register(
announcement.agentThreadId,
announcement.label,
announcement.namesParentTurn ? input.turnId : undefined,
// Only a spawn names the spawner: other announcements ride whichever agent acted.
announcement.spawned ? input.threadId : undefined
)
return child?.execution
? this.report(child, child.execution, true, this.groupFor(input.threadId, input.turnId))
: ADMITTED
}
handleTurnEvent(event: {
method: string
threadId: string
params: unknown
}): StructuredAgentSessionSinkAdmission {
const turnId = readCodexTurnId(event.params)
return turnId
? this.handleTurn({
threadId: event.threadId,
turnId,
state:
event.method === 'turn/started'
? 'working'
: codexChildTurnState(readRecord(readRecord(event.params).turn).status)
})
: ADMITTED
}
handleTurn(input: {
threadId: string
turnId: string
state: NativeChatSubagentState
}): StructuredAgentSessionSinkAdmission {
if (
input.threadId === this.deps.primaryThreadId() ||
this.tracker.hasSettled(input.threadId, input.turnId)
) {
return ADMITTED
}
const observed = this.executions.observeTurn(input.threadId, input.turnId, input.state)
// Followed already if the execution changed; re-derived (idempotently) for its admission.
return observed ? this.follow(observed.child, observed.execution) : ADMITTED
}
/** A child turn starting is a run starting; one ending only revises the row listing it. */
private follow(
child: Readonly<CodexExecutionChild>,
execution: CodexChildExecution
): StructuredAgentSessionSinkAdmission {
if (!child.registered) {
return ADMITTED
}
const parent = this.deps.primaryThreadId() ?? child.agentThreadId
const group = this.groupFor(parent, this.deps.activeTurn(parent) ?? child.parentTurnId)
return this.report(child, execution, execution.state === 'working', group)
}
/** Consume `thread/tokenUsage/updated`. Returns null when the params are not one. */
handleTokenUsage(params: unknown): StructuredAgentSessionSinkAdmission | null {
const usage = readCodexThreadTokenTotal(params)
if (!usage) {
return null
}
this.tokensByThread.record(usage.threadId, usage.totalTokens)
const located = this.tracker.locate(usage.threadId)
return located
? this.tracker.report({
id: usage.threadId,
group: { id: located.group.groupId, placement: () => located.group.placement },
announces: false,
tokens: usage.totalTokens
})
: ADMITTED
}
/**
* The provider is gone, so any child still reported as working will never be
* settled by an event: it becomes `unverifiable` — contact was lost, which is
* NOT evidence the child exited.
*
* This is the ONLY sweep. A turn ending is not one: `spawn_agent` children
* routinely outlive their turn and keep reporting into the same group.
*/
settleSession(): StructuredAgentSessionSinkAdmission {
this.executions.settleSession()
return this.tracker.settleSession()
}
dispose(): void {
this.unfollow()
this.tracker.dispose()
this.tokensByThread.clear()
}
retentionSizes(): { groups: number; settledIdentities: number } {
return this.tracker.sizes()
}
private report(
child: Readonly<CodexExecutionChild>,
execution: CodexChildExecution,
announces: boolean,
group: SubagentReport<AgentJournalTurnScope>['group']
): StructuredAgentSessionSinkAdmission {
const tokens = this.tokensByThread.get(child.agentThreadId)
return this.tracker.report({
id: child.agentThreadId,
run: execution.turnId,
group,
announces,
label: child.label,
state: execution.state,
...(typeof tokens === 'number' ? { tokens } : {}),
// A spawned child may outlive the turn that spawned it.
backgrounded: true
})
}
/** The group a child spawned now belongs to: the session's own agent's current turn. */
private groupFor(
threadId: string,
turnId: string | null
): SubagentReport<AgentJournalTurnScope>['group'] {
const ownerThreadId = this.deps.primaryThreadId() ?? threadId
const ownerTurnId =
ownerThreadId === threadId ? turnId : (this.deps.activeTurn(ownerThreadId) ?? turnId)
return {
id: codexSubagentGroupId(ownerThreadId, ownerTurnId),
placement: () => this.deps.turnScopeFor(ownerThreadId, ownerTurnId)
}
}
}
@@ -1,24 +0,0 @@
import { describe, expect, it } from 'vitest'
import { MAX_CODEX_TOKEN_USAGE_THREADS } from './codex-structured-journal-limits'
import { CodexThreadTokenTotals } from './codex-thread-token-totals'
describe('CodexThreadTokenTotals', () => {
it("replaces a thread's total with its newest report instead of summing reports", () => {
const totals = new CodexThreadTokenTotals()
totals.record('child-1', 100)
totals.record('child-1', 250)
expect(totals.get('child-1')).toBe(250)
})
it('evicts by recency of report, so a thread still reporting outlives quieter ones', () => {
const totals = new CodexThreadTokenTotals()
totals.record('child-1', 1)
for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS - 1; index++) {
totals.record(`other-${index}`, index)
}
totals.record('child-1', 2)
totals.record('newest', 3)
expect(totals.get('child-1')).toBe(2)
expect(totals.get('other-0')).toBeUndefined()
})
})
@@ -1,32 +0,0 @@
import { MAX_CODEX_TOKEN_USAGE_THREADS } from './codex-structured-journal-limits'
/** Latest reported token total per thread, kept regardless of roster membership: a
* usage frame can arrive before the child's first activity item, and filtering at
* receipt would lose it permanently. LRU-capped at MAX_CODEX_TOKEN_USAGE_THREADS. */
export class CodexThreadTokenTotals {
private readonly totals = new Map<string, number>()
/** A running total: the newest frame REPLACES the previous one. Summing updates
* would multiply a single child's usage by its frame count. */
record(threadId: string, totalTokens: number): void {
// Re-insert so eviction sees recency: `set` on an existing key keeps its
// original position, which would age out an active thread.
this.totals.delete(threadId)
this.totals.set(threadId, totalTokens)
while (this.totals.size > MAX_CODEX_TOKEN_USAGE_THREADS) {
const oldest = this.totals.keys().next().value
if (typeof oldest !== 'string') {
break
}
this.totals.delete(oldest)
}
}
get(threadId: string): number | undefined {
return this.totals.get(threadId)
}
clear(): void {
this.totals.clear()
}
}
@@ -1,57 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types'
import { subagentGroupJournalBody } from './journal-subagent-group-body'
function entry(id: string, state: NativeChatSubagentEntry['state']): NativeChatSubagentEntry {
return { id, label: id, state, startedAt: 1 }
}
/** The fallback sentence is the WHOLE row on mobile and paired web, which have
* no roster renderer, so these assertions are the entire contract there. */
function sentence(agents: readonly NativeChatSubagentEntry[]): string {
const body = subagentGroupJournalBody('turn-1', agents)
const block = body.kind === 'message' ? body.blocks[0] : undefined
return block && block.type === 'text' ? block.text : ''
}
describe('subagentGroupJournalBody fallback sentence', () => {
it('reads as a plain completion when every child completed', () => {
expect(sentence([entry('a', 'completed'), entry('b', 'completed')])).toBe('Ran 2 subagents')
})
it('keeps the singular noun for a lone child', () => {
expect(sentence([entry('a', 'completed')])).toBe('Ran 1 subagent')
expect(sentence([entry('a', 'working')])).toBe('Kicked off 1 subagent')
})
it('names an unverifiable child instead of claiming the group ran', () => {
expect(sentence([entry('a', 'completed'), entry('b', 'unverifiable')])).toBe(
'Ran 2 subagents (1 unverifiable)'
)
})
it('ranks the adverse outcome worst-first', () => {
expect(
sentence([entry('a', 'failed'), entry('b', 'unverifiable'), entry('c', 'completed')])
).toBe('Ran 3 subagents (1 failed)')
expect(sentence([entry('a', 'stopped'), entry('b', 'unverifiable')])).toBe(
'Ran 2 subagents (1 stopped)'
)
})
it('shows the adverse outcome while a sibling still works', () => {
expect(
sentence([entry('a', 'working'), entry('b', 'working'), entry('c', 'unverifiable')])
).toBe('Kicked off 3 subagents (1 unverifiable)')
})
it('leaves a benign settled state out of the sentence', () => {
expect(sentence([entry('a', 'idle'), entry('b', 'completed')])).toBe('Ran 2 subagents')
})
it('counts every child holding the worst adverse state', () => {
expect(sentence([entry('a', 'failed'), entry('b', 'failed'), entry('c', 'stopped')])).toBe(
'Ran 3 subagents (2 failed)'
)
})
})
@@ -1,51 +0,0 @@
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types'
import {
MAX_SUBAGENT_FIELD_CHARS,
subagentGroupFallbackText
} from '../../../shared/native-chat-subagent-summary'
/** The roster row: the structured block plus the plain sentence an older client
* renders in its place. A message whose only block is the new variant would
* reach such a client with nothing it can draw. */
export function subagentGroupJournalBody(
groupId: string,
agents: readonly NativeChatSubagentEntry[]
): AgentJournalMessageItem {
const bounded = agents.map((agent, index) => ({
...agent,
id: boundSubagentField(agent.id, index),
label: boundSubagentField(agent.label, index)
}))
return {
kind: 'message',
role: 'system',
blocks: [
{ type: 'text', text: subagentGroupFallbackText(bounded) },
{ type: 'subagent-group', groupId, agents: bounded }
]
}
}
/** `id` and `label` are provider strings, so they take the bound both readers of
* this row already clip them to. A plain length check, not the tool-output
* bound: that one digests the whole value before it checks the length, and this
* runs twice per child on every streamed token-usage frame.
*
* A clip is not identity-preserving, so a clipped value carries the child's
* index: two ids sharing a long prefix collapse to one React key, and
* `claimLabel` writes its ordinal at the very tail the clip removes. The index
* is reserved out of the bound, not appended to it, because both readers
* re-clip to the same cap and would cut a suffix that overflowed it. */
export function boundSubagentField(value: string, index: number): string {
if (value.length <= MAX_SUBAGENT_FIELD_CHARS) {
return value
}
const suffix = `…~${index}`
const keep = MAX_SUBAGENT_FIELD_CHARS - suffix.length
// Slicing UTF-16 units can split a surrogate pair; a lone surrogate is
// malformed in a durable row and lossy through any non-JSON UTF-8 hop.
const last = value.charCodeAt(keep - 1)
const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep
return `${value.slice(0, end)}${suffix}`
}
@@ -25,7 +25,7 @@ export function subagentJournalBody(block: NativeChatSubagentBlock): AgentJourna
const bounded: NativeChatSubagentBlock = {
...block,
id: boundSubagentEntryId(block.id),
label: boundLabel(block.label),
label: boundSubagentLabel(block.label),
groupId: boundSubagentEntryId(block.groupId),
...(block.parentId !== undefined ? { parentId: boundSubagentEntryId(block.parentId) } : {}),
...(block.run !== undefined ? { run: boundSubagentEntryId(block.run) } : {})
@@ -48,7 +48,7 @@ export function journaledSubagent(
/** The bound every reader clips a label to. A clip never splits a surrogate pair: a lone one is
* malformed in a durable row. */
function boundLabel(label: string): string {
export function boundSubagentLabel(label: string): string {
if (label.length <= MAX_SUBAGENT_FIELD_CHARS) {
return label
}
@@ -15,10 +15,7 @@ import type {
NativeChatBackgroundTaskBlock,
NativeChatSubagentEntry
} from '../../../shared/native-chat-types'
import {
codexSubagentGroupBody,
codexSubagentGroupIdentity
} from '../../codex/codex-subagent-roster'
import { subagentGroupFallbackText } from '../../../shared/native-chat-subagent-summary'
import type { openAgentSessionJournal } from './journal-store-factory'
import { createTrackedJournalOpener } from './journal-host-database-test-support'
import { lostLiveWorkJournalBody, staleSubagentRosterRevisions } from './journal-subagent-liveness'
@@ -55,11 +52,18 @@ async function open(overrides: Partial<Parameters<typeof openAgentSessionJournal
})
}
/** The row as the producer writes it: the structured block plus its twin. */
/** A grouped row as an earlier build wrote it: the structured block plus its twin. */
function rosterRow(agents: NativeChatSubagentEntry[]) {
return {
identity: codexSubagentGroupIdentity(GROUP_ID),
body: codexSubagentGroupBody(GROUP_ID, agents)
identity: { provider: 'orca' as const, clientMessageId: `codex-subagents:${GROUP_ID}` },
body: {
kind: 'message' as const,
role: 'system' as const,
blocks: [
{ type: 'text' as const, text: subagentGroupFallbackText(agents) },
{ type: 'subagent-group' as const, groupId: GROUP_ID, agents }
]
}
}
}
@@ -9,9 +9,9 @@
// `unverifiable`, never a synthesized exit — see
// `docs/reference/ssh-execution-boundary.md`.
//
// Reconciles JOURNAL ROWS, not roster state. A producer that continues a roster
// from the journal reads these rows after this revision, so it inherits the
// verdict rather than the dead host's `working`.
// Reconciles JOURNAL ROWS, not live records. The host's subagent row writer revises a row it
// finds by key after this revision, so a later run starts from the verdict, not the dead host's
// `working`.
import {
agentJournalItemKey,
@@ -20,6 +20,7 @@ import {
readAgentSessionHydrationPage
} from './agent-session-history-page'
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
import { foldSubagentItems } from '../../../shared/native-chat-subagent-items'
import {
subagentJournalBody,
subagentJournalItemIdentity
@@ -172,6 +173,81 @@ describe('a history page over a session with a subagent', () => {
expect(tail.hasOlder).toBe(true)
})
it("loads an older page without renumbering a turn's subagents or moving their row", async () => {
const audit = async (id: string): Promise<void> => {
await journal.appendItem(
subagentJournalItemIdentity(id),
subagentJournalBody({
type: 'subagent',
id,
label: 'Audit',
state: 'working',
groupId: 't'
}),
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
}
await append(named('own-', 1, 3))
await audit('a')
await append(named('own-', 4, 5))
await audit('b')
await append(named('own-', 9, 2))
await audit('c')
await append(['own-11'])
const folded = (pages: AgentSessionHistoryPage[]) =>
foldSubagentItems(
pages
.flatMap((page) => page.items)
.map((entry) => ({
id: entry.itemId,
role: 'system' as const,
timestamp: null,
source: 'transcript' as const,
blocks: entry.body.kind === 'message' ? entry.body.blocks : []
}))
).flatMap((row) =>
row.blocks.flatMap((block) =>
block.type === 'subagent-group'
? [{ row: row.id, labels: block.agents.map((agent) => `${agent.id}=${agent.label}`) }]
: []
)
)
const tail = read({ direction: 'tail', limit: 5 })
const older = read({ direction: 'before', cursor: tail.window.nextCursor, limit: 5 })
expect(textsOf(tail)[0]).toBe('Kicked off subagent Audit')
expect(textsOf(older)).toEqual(named('own-', 1, 3))
expect(folded([tail])).toEqual([
{ row: 'orca:subagent%3Aa', labels: ['a=Audit', 'b=Audit 2', 'c=Audit 3'] }
])
expect(folded([older, tail])).toEqual(folded([tail]))
})
it('reaches back for a first sibling no further than one more page of conversation', async () => {
for (const [id, own] of [
['a', named('own-', 1, 10)],
['b', ['own-11']]
] as const) {
await journal.appendItem(
subagentJournalItemIdentity(id),
subagentJournalBody({
type: 'subagent',
id,
label: id,
state: 'working',
groupId: 'thread'
}),
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
await append([...own])
}
const tail = read({ direction: 'tail', limit: 2 })
expect(textsOf(tail)).toEqual(['own-8', 'own-9', 'own-10', 'Kicked off subagent b', 'own-11'])
})
it('still bounds a page by bytes, and paging back still reaches every row', async () => {
const large = 'x'.repeat(250 * 1024)
await append(['own-1'])
@@ -8,6 +8,7 @@ import type {
} from '../../../shared/agent-session-journal-types'
import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits'
import { agentSessionHostStatusBody } from '../../../shared/agent-session-host-status-rows'
import { subagentFoldKey, subagentItemBlock } from '../../../shared/native-chat-subagent-items'
export const AGENT_SESSION_HISTORY_MAX_PAGE_BYTES = REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES / 2
@@ -126,3 +127,29 @@ export function newestWholeSequenceGroups(
}
return selected.toReversed().flat()
}
/** `start` moved back to the first sibling of every subagent group the window holds part of, but
* never past `floor`: a group spread over a long turn, or helpers outside any turn, may still
* split rather than grow the page without bound. */
export function wholeSubagentGroupsStart(
items: readonly AgentJournalRenderItem[],
start: number,
floor: number
): number {
const firstOf = new Map<string, number>()
for (const item of items) {
const block = item.body.kind === 'message' ? subagentItemBlock(item.body) : null
if (block !== null && !firstOf.has(subagentFoldKey(block))) {
firstOf.set(subagentFoldKey(block), item.sequence)
}
}
let whole = start
for (let index = items.length - 1; index >= 0 && items[index]!.sequence >= whole; index--) {
const item = items[index]!
const block = item.body.kind === 'message' ? subagentItemBlock(item.body) : null
if (block !== null) {
whole = Math.max(floor, Math.min(whole, firstOf.get(subagentFoldKey(block))!))
}
}
return whole
}
@@ -33,6 +33,7 @@ import {
HISTORY_PAGE_CONTENT_BUDGET_BYTES,
historyEntryBytes,
newestWholeSequenceGroups,
wholeSubagentGroupsStart,
oversizedHistoryItem,
submissionBytesByItemId
} from './agent-session-history-page-bounds'
@@ -61,7 +62,8 @@ export type AgentSessionHistoryScope = 'every-agent' | 'own-agent'
* the conversation off the page. The page stays contiguous and starts at its first
* item, as the cursor means; the byte budget still bounds it. Fewer own rows than
* `limit` reach back to the start. With no subagent rows this is the newest `limit`
* rows, as before.
* rows, as before. A page reaches back to the first sibling of each subagent group it holds
* part of, so loading an older page does not renumber a group's ordinals or move its row.
*/
function conversationWindow(
items: readonly AgentJournalRenderItem[],
@@ -73,7 +75,13 @@ function conversationWindow(
}
const ownWindow = newestWholeSequenceGroups(own, limit)
const start = ownWindow.length < own.length ? ownWindow[0]?.sequence : undefined
return start === undefined ? [...items] : items.filter((item) => item.sequence >= start)
if (start === undefined) {
return [...items]
}
const reach = newestWholeSequenceGroups(own, 2 * limit)
const floor = reach.length < own.length ? reach[0]!.sequence : Number.NEGATIVE_INFINITY
const whole = wholeSubagentGroupsStart(items, start, floor)
return items.filter((item) => item.sequence >= whole)
}
function isSubagentRow(item: AgentJournalRenderItem): boolean {
@@ -14,7 +14,10 @@ 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 {
AgentChildWorkStopAcknowledgement,
AgentSessionBackgroundTaskStops
} from '../../../shared/agent-child-work-stop-targets'
import type {
AgentJournalAnsweredTurnIdentity,
AgentJournalItemIdentity,
@@ -362,11 +365,14 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
}): Promise<{ ok: true } | { ok: false; rejected: string }>
/** Stops exactly the tasks `taskIds` names, which the host resolves from its child records.
* `stillRunning` is the provider's own answer that one survived its stop; a throw leaves the
* effect unknown. */
* effect unknown. `acknowledge` names each task the provider accepted the stop of as its answer
* lands, so a later failure in the batch keeps it; the host alone turns it into that run's
* provisional ending. */
stopBackgroundTasks?(input: {
sessionId: string
fence: number
taskIds: readonly string[]
acknowledge?: (acknowledgement: AgentChildWorkStopAcknowledgement) => void
}): Promise<{ cancelled: boolean; stillRunning?: true }>
/** The stops this provider honours for a live session's background work; undefined when the
* adapter holds no live session for it. */
@@ -0,0 +1,62 @@
// A background Stop: the strip's Stop of one task, or of every task the records offer one.
import {
agentChildWorkStopEvidence,
agentChildWorkStopTargets,
type AgentChildWorkStopAcknowledgement
} from '../../../shared/agent-child-work-stop-targets'
import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import type { AgentSessionTurnContext } from './structured-agent-session-turns'
export type StructuredAgentSessionBackgroundStopInput = {
taskId?: string
/** The session's child records: a background Stop reaches the tasks they offer a stop. */
childWork?: () => readonly AgentChildWorkView[] | undefined
/** Hands the host the stops the provider accepted, as evidence about the runs it sent them to.
* Never throws: recording it is bookkeeping, and cannot fail the Stop. */
acknowledgeStops?: (evidence: AgentChildWorkEvidence[]) => void
/** Work the host could not list, read inside the Stop's step, and the provider child's end that
* reaches it; for an untargeted Stop only. */
unlisted?: { present: () => boolean; stop: () => Promise<void> }
}
/** `atRest`: no turn runs, so ending the agent ends only the work no record lists. */
export async function stopStructuredAgentSessionBackgroundWork(
ctx: Pick<AgentSessionTurnContext, 'adapter' | 'sessionId' | 'fence'>,
input: StructuredAgentSessionBackgroundStopInput,
atRest: () => boolean
): Promise<{ cancelled: boolean; stillRunning?: true }> {
// Captured before the stop goes out: its acknowledgement may end only these runs.
const targets = agentChildWorkStopTargets(input.childWork?.(), input.taskId)
const acknowledged: AgentChildWorkStopAcknowledgement[] = []
// Nothing listed has a stop to send; an agent without a stop route would refuse even none.
const background =
targets.length === 0
? undefined
: await ctx.adapter
.stopBackgroundTasks?.({
sessionId: ctx.sessionId,
fence: ctx.fence,
taskIds: targets.map((target) => target.providerId),
acknowledge: (acknowledgement) => void acknowledged.push(acknowledgement)
})
// A batch that fails part way still ended each run it was answered for first.
.finally(() => {
if (acknowledged.length > 0) {
input.acknowledgeStops?.(
agentChildWorkStopEvidence(targets, acknowledged, Date.now())
)
}
})
if (background?.stillRunning) {
return background
}
// Work no record lists is reached only by ending the agent, never under a turn it runs. Read after
// the listed stops: one ending can wake the agent into a turn of its own.
if (input.unlisted?.present() && atRest()) {
await input.unlisted.stop()
return { cancelled: true }
}
return { cancelled: background?.cancelled === true }
}
@@ -28,7 +28,8 @@ type Stops = { supportsTaskStop: boolean; supportsStopAll: boolean } | undefined
function channelOver(
children: () => AgentChildWorkView[] = () => [child],
stops: () => Stops = () => ({ supportsTaskStop: true, supportsStopAll: true })
stops: () => Stops = () => ({ supportsTaskStop: true, supportsStopAll: true }),
incomplete: () => boolean = () => false
) {
const sessions = new StructuredAgentSessionConversations({
deliver: () => {},
@@ -48,7 +49,8 @@ function channelOver(
async () => {
throw new Error('not opened here')
},
children
children,
incomplete
)
// 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 = {
@@ -72,6 +74,24 @@ describe('the background-task channel', () => {
// A task that finished while no pane listened, then an idle sweep that stopped the provider:
// a pane resuming must read "none", not silence it would take as "unchanged" (#24227).
// An idle parent whose live background child the store had no room for: no row lists it, so
// the strip must stay and say so rather than read as nothing running.
it('keeps the strip, with its Stop, for work the host could not record', () => {
const { sessions, channel, session } = channelOver(
() => [],
() => ({ supportsTaskStop: true, supportsStopAll: true }),
() => true
)
sessions.set('session-1', session)
expect(channel.read('session-1')).toEqual({
state: 'monitoring',
supportsTaskStop: true,
children: [],
incomplete: true
})
})
it('answers "none" for a conversation no provider holds, not "unknown"', () => {
const { sessions, channel, session } = channelOver(
() => [],
@@ -25,8 +25,8 @@ import { structuredAgentSessionConversationFence } from './structured-agent-sess
/** 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. Each subscriber's frames carry it; see
* `agent-session-subscriber-frame-fields`. */
* running child, and none the host failed to record, is `null`, and the strip hides. Each
* subscriber's frames carry it; see `agent-session-subscriber-frame-fields`. */
export class StructuredAgentSessionBackgroundTaskChannel {
constructor(
private readonly deps: StructuredAgentSessionHostDeps,
@@ -36,7 +36,8 @@ export class StructuredAgentSessionBackgroundTaskChannel {
private readonly conversation: (
sessionId: string
) => Promise<StructuredAgentSessionHostSession>,
private readonly readChildWork: (sessionId: string) => AgentChildWorkView[] | undefined
private readonly readChildWork: (sessionId: string) => AgentChildWorkView[] | undefined,
private readonly childWorkIncomplete: (sessionId: string) => boolean = () => false
) {}
/** `scope` is for in-process readers; a wire request reads every agent's rows. */
@@ -101,7 +102,9 @@ export class StructuredAgentSessionBackgroundTaskChannel {
read(sessionId: string): AgentSessionBackgroundTaskState | null {
const session = this.sessions.get(sessionId)
const views = structuredRunningChildWork((session && this.readChildWork(sessionId)) ?? [])
if (!session || views.length === 0) {
// Untracked work keeps the strip, and its Stop, even with nothing listed.
const incomplete = this.childWorkIncomplete(sessionId)
if (!session || (views.length === 0 && !incomplete)) {
return null
}
const stops = this.deps.adapter.backgroundTaskStops?.(sessionId)
@@ -113,7 +116,8 @@ export class StructuredAgentSessionBackgroundTaskChannel {
...(stops?.supportsTaskStop ? { supportsTaskStop: true } : {}),
// A session no live provider holds has nothing a stop could reach.
...(stops?.supportsStopAll ? {} : { supportsStopAll: false }),
children: views
children: views,
...(incomplete ? { incomplete: true } : {})
}
}
}
@@ -493,8 +493,8 @@ it('ends background work Claude runs when the Stop ends the child', async () =>
...background,
state: 'done',
membership: 'settled',
// Stopped with the chat, as a task's own stop reads: Interrupted, not an unknown ending.
outcome: 'cancelled'
// Claude accepted no Stop of this task: ending its process is loss of contact.
outcome: 'unknown'
})
])
expect(await agentStatus()).toEqual({
@@ -0,0 +1,300 @@
// A Claude background helper through the shipping adapter, host and child records: its saved row
// stays working past its spawn result and its turn's end until its own end frame; and a helper the
// store had no room for keeps the strip incomplete until the strip's Stop ends the agent.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
import { AGENT_STATUS_STORE_LIMITS } from '../../../shared/agent-status-store-contract'
import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject'
import { isSubagentBlock } from '../../../shared/native-chat-types'
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn'
import { acpChildWorkStatusSink } from '../../acp/acp-structured-child-work.test-support'
import { RESUMED_BY_MESSAGE } from '../../claude/claude-captured-task-frames.test-fixture'
import { WAKES_AFTER_ITS_SHELL } from '../../claude/claude-captured-wake-frames.test-fixture'
import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter'
import {
claudeStartupSettled,
fakeClaude,
PROVIDER_SESSION_ID,
type FakeConnection
} from '../../claude/claude-structured-session-test-support'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter'
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support'
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
import {
HOST_TEST_NOW as NOW,
HOST_TEST_SESSION as SESSION,
hostTestAttachParams,
hostTestMessage,
hostTestOperationId,
resetHostTestOperationIds
} from './structured-agent-session-host-test-data'
const CALLER = { callerKey: 'client-1' }
let root: string
let host: StructuredAgentSessionHost
let adapter: ClaudeStructuredSessionAdapter
let store: AgentSessionRecordStore
let claude: ReturnType<typeof fakeClaude>
let records: ReturnType<typeof acpChildWorkStatusSink>
/** The host's clock, which stamps every child's start and end. */
let clock = NOW
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-claude-subagent-row-'))
clock = NOW
resetHostTestOperationIds()
const log = recordingStructuredAgentSessionLogger()
claude = fakeClaude({ replayUuid: null })
adapter = new ClaudeStructuredSessionAdapter({
resolveLaunch: async () => ({
pathToClaudeCodeExecutable: 'claude',
options: {},
cwd: root,
claudeConfigDir: join(root, 'claude-home'),
providerSessionId: PROVIDER_SESSION_ID,
resumeLeafUuid: null,
resumesTranscript: false,
continuesChain: false
}),
onEvent: (event) => {
const mapped = structuredClaudeLifecycleEvent(event)
if (mapped) {
void host.handleAdapterEvent(mapped)
}
},
onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement),
persistHandle: async () => undefined,
logger: log.logger,
onChildWorkEvidence: (sessionId, evidence) =>
host.publishChildWorkEvidence(sessionId, evidence),
openConnection: claude.openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
now: () => clock
})
store = await openTestAgentSessionRecordStore(root)
records = acpChildWorkStatusSink()
host = new StructuredAgentSessionHost({
agents: claudeAndCodexAgents(adapter),
store,
adapter: Object.assign(adapter, { supportsCreate: () => true }),
journalDatabase: openTestJournalHostDatabase(root),
claimKeyId: 'key-1',
mintSpawnToken: () => 'spawn-a',
logger: log.logger,
// The host's child records live in a real hook server's store, as in production.
statusSink: records.sink,
now: () => clock
})
const params = hostTestAttachParams(null, {
provider: 'claude',
agent: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') },
providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null }
})
expect(await host.attach(CALLER, params)).toMatchObject({ ok: true })
await claudeStartupSettled(adapter, SESSION)
})
afterEach(async () => {
await adapter.closeAll()
await host.flushAllStreamedEvents()
await rm(root, { recursive: true, force: true })
})
function eventually<T>(assertion: () => T | Promise<T>): Promise<T> {
return vi.waitFor(assertion, { timeout: 10_000 })
}
function frame(connection: FakeConnection, message: Record<string, unknown>): void {
connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message })
}
function envelope(method: 'agentSession.send' | 'agentSession.cancel', fields: Fields) {
return {
sessionId: SESSION,
clientOperationId: hostTestOperationId(),
expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method,
sessionId: SESSION,
fields
})
}
}
type Fields = Parameters<typeof computeAgentSessionPayloadFingerprint>[0]['fields']
async function sendAndEcho(connection: FakeConnection, text: string): Promise<void> {
const body = hostTestMessage(text)
const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
expect(sent.ok).toBe(true)
await eventually(() =>
expect(connection.sent.some((message) => JSON.stringify(message).includes(text))).toBe(true)
)
frame(connection, { type: 'system', subtype: 'init', uuid: 'init-1', model: 'claude-sonnet-5' })
frame(connection, { ...connection.sent.at(-1)! })
}
async function savedRows() {
await host.flushStreamedEvents(SESSION)
return (await host.journalSnapshot(SESSION)).items.flatMap((item) =>
item.body.kind === 'message' ? item.body.blocks.filter(isSubagentBlock) : []
)
}
it("keeps a background helper's row working past its spawn result and its turn's end, until its own end", async () => {
const connection = claude.connections[0]!
await sendAndEcho(connection, 'Run it in the background.')
// Spawn, roster, task start, the spawn's "launched" result, and the parent turn's result.
for (const [index, captured] of RESUMED_BY_MESSAGE.filter(({ at }) => at <= 4_765).entries()) {
frame(connection, { uuid: `launch-${index}`, ...captured.frame })
}
await eventually(async () => expect(await savedRows()).toHaveLength(1))
await new Promise((resolve) => setTimeout(resolve, 50))
expect(await savedRows()).toEqual([
expect.objectContaining({ id: 'agent-1', state: 'working', background: true })
])
const { items } = await host.journalSnapshot(SESSION)
expect(activeStructuredAgentSessionTurnId(items)).toBeNull()
expect(items.map((item) => readAgentJournalTurn(item.body)?.state)).toContain('completed')
// Its own progress, then its own completion.
for (const [index, captured] of RESUMED_BY_MESSAGE.filter(
({ at }) => at > 4_765 && at <= 7_427
).entries()) {
frame(connection, { uuid: `run-${index}`, ...captured.frame })
}
await eventually(async () =>
expect(await savedRows()).toEqual([
expect.objectContaining({ id: 'agent-1', state: 'completed', tokens: 15_971 })
])
)
})
it("ends the agent from the strip's Stop when the store had no room for its helper, never under a running turn", async () => {
// Another session holds every child record the store has room for.
const busy = makeStructuredAgentStatusSubject(
{ executionHostId: 'local', wslDistro: null, workspaceId: 'busy', workspaceKind: 'folder' },
'busy-session'
)
records.sink.publish(
{
sessionId: 'busy-session',
workspaceId: 'busy',
agent: 'claude',
status: 'working',
latestPrompt: 'Busy',
updatedAt: 1
},
busy
)
records.sink.publishChildWork?.(
busy,
Array.from({ length: AGENT_STATUS_STORE_LIMITS.children }, (_unused, index) => ({
type: 'live' as const,
observedAt: 1,
child: {
handle: { idKind: 'task_id' as const, id: `busy-${index}` },
kind: 'agent' as const,
residency: 'background' as const,
state: 'working' as const,
stoppable: true
}
})),
'claude'
)
const connection = claude.connections[0]!
await sendAndEcho(connection, 'Run it in the background.')
for (const [index, captured] of RESUMED_BY_MESSAGE.filter(({ at }) => at <= 3_349).entries()) {
frame(connection, { uuid: `launch-${index}`, ...captured.frame })
}
const strip = () => host['backgroundTasks'].read(SESSION)
await eventually(() => expect(strip()).toMatchObject({ children: [], incomplete: true }))
const stopStrip = () => {
const fields = { turnId: 'background-tasks', scope: 'background-tasks' as const }
return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields })
}
// The turn that spawned it still runs: the strip's Stop leaves the agent and its turn alone.
await stopStrip()
expect(connection.closed).toBe(false)
expect(strip()).toMatchObject({ incomplete: true })
frame(connection, {
uuid: 'turn-end',
...RESUMED_BY_MESSAGE.find(({ at }) => at === 4_765)!.frame
})
await host.flushStreamedEvents(SESSION)
await expect(stopStrip()).resolves.toMatchObject({ ok: true, value: { cancelled: true } })
expect(connection.closed).toBe(true)
await eventually(() => expect(strip()?.incomplete).toBeUndefined())
})
it('keeps a helper working while its own shell runs and through its wake-up, ending on its answer', async () => {
const connection = claude.connections[0]!
await sendAndEcho(connection, 'Sleep in the background.')
const start = clock
let next = 0
const replay = async (until: number) => {
for (; WAKES_AFTER_ITS_SHELL[next] && WAKES_AFTER_ITS_SHELL[next]!.at <= until; next++) {
const captured = WAKES_AFTER_ITS_SHELL[next]!
clock = start + captured.at
frame(connection, { uuid: `wake-${next}`, ...captured.frame })
}
await host.flushStreamedEvents(SESSION)
}
const helper = async () => (await savedRows()).find(({ id }) => id === 'agent-1')
const strip = () =>
host['backgroundTasks'].read(SESSION)?.children?.map((child) => child.providerId) ?? []
// The CLI calls the helper completed while the shell it started runs on.
await replay(11_913)
await eventually(async () => expect(await helper()).toMatchObject({ state: 'working' }))
expect(strip()).toEqual(['agent-1', 'shell-1'])
// The shell ends, and the CLI starts the same run again under its spawn call.
await replay(44_460)
await eventually(async () => expect(await helper()).toMatchObject({ state: 'working' }))
expect(strip()).toEqual(['agent-1'])
await replay(46_199)
await eventually(async () =>
expect(await helper()).toMatchObject({
state: 'completed',
tokens: 18_181,
startedAt: start + 15,
// Its own end, not its first turn's, sets how long it ran.
settledAt: start + 46_198
})
)
expect(strip()).toEqual([])
// One saved row for the helper, revised in place across both runs.
expect((await savedRows()).filter(({ id }) => id === 'agent-1')).toHaveLength(1)
})
it('settles a helper still running when its session closes as unverifiable, claiming no Stop', async () => {
const connection = claude.connections[0]!
await sendAndEcho(connection, 'Run it in the background.')
for (const [index, captured] of RESUMED_BY_MESSAGE.filter(({ at }) => at <= 4_765).entries()) {
frame(connection, { uuid: `launch-${index}`, ...captured.frame })
}
await eventually(async () =>
expect(await savedRows()).toEqual([expect.objectContaining({ state: 'working' })])
)
await adapter.closeSession(SESSION)
expect(connection.closed).toBe(true)
await eventually(async () =>
expect(await savedRows()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
)
expect((await savedRows())[0]).not.toHaveProperty('outcomeBasis')
})
@@ -106,6 +106,9 @@ export class StructuredAgentSessionClientDelivery {
readChildWork = (sessionId: string): AgentChildWorkView[] | undefined =>
this.statusFeed.readChildWork(sessionId)
childWorkIncomplete = (sessionId: string): boolean =>
this.statusFeed.childWorkIncomplete(sessionId)
publishStatusAndSettlement = (sessionId: string): void => {
this.statusFeed.publish(sessionId)
const journal = this.sessions.get(sessionId)?.journal
@@ -106,11 +106,25 @@ export function cancelStructuredAgentSessionTurn(
}
): Promise<AgentSessionMutationResult<AgentSessionCancelResult>> {
if (params.scope) {
const { sessionId } = params.envelope
return mutateStructuredAgentSession(
context,
caller,
params.envelope,
cancelPlan({ ...params, childWork: () => context.readChildWork(params.envelope.sessionId) }),
cancelPlan({
...params,
childWork: () => context.readChildWork(sessionId),
acknowledgeStops: (evidence) => context.publishChildWork(sessionId, evidence),
// Untargeted, with work no record lists: the idle sweep's stop ends it with the agent.
...(params.taskId === undefined
? {
unlisted: {
present: () => context.childWorkIncomplete(sessionId),
stop: () => context.stopAgent(sessionId, { cause: 'evict', resting: true })
}
}
: {})
}),
openForProviderWrite(context, params.envelope)
)
}
@@ -8,8 +8,11 @@ import {
type StructuredAgentSessionStatusFeedDeps,
type StructuredAgentSessionStatusSink
} from './structured-agent-session-status-feed'
import { StructuredAgentSessionSubagentRows } from './structured-agent-session-subagent-rows'
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
/** Wire the host's own deps into a feed; keeps the host at one call site.
/** Wire the host's own deps into a feed, and each subagent's saved row onto its child records;
* 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. */
export function createStructuredAgentSessionHostStatusFeed(args: {
@@ -24,7 +27,31 @@ export function createStructuredAgentSessionHostStatusFeed(args: {
onAgentStarted?: (sessionId: string) => void
onChildWorkChanged?: (sessionId: string) => void
}): StructuredAgentSessionStatusFeed {
return new StructuredAgentSessionStatusFeed({
const subagentRows = new StructuredAgentSessionSubagentRows({
journal: (sessionId) => args.sessions.get(sessionId)?.journal,
fence: (sessionId) => structuredAgentSessionConversationFence(args.deps().store, sessionId),
// The views, with the two host-only facts a saved row also keeps from their records.
readChildWork: (sessionId) => {
const records = new Map(
feed.readChildWorkRecords(sessionId)?.map((record) => [record.childWorkId, record])
)
return feed.readChildWork(sessionId)?.map((view) => {
const { residency, outcomeBasis } = records.get(view.id) ?? {}
return {
...view,
...(residency ? { residency } : {}),
...(outcomeBasis ? { outcomeBasis } : {})
}
})
},
onWriteFailed: (sessionId, error) =>
args.deps().logger.warn('writing a subagent row failed', {
scope: 'subagent-rows',
sessionId,
error
})
})
const feed = new StructuredAgentSessionStatusFeed({
sessions: args.sessions,
getRecord: (sessionId) => args.deps().store.getRecord(sessionId),
now: args.now,
@@ -34,6 +61,10 @@ export function createStructuredAgentSessionHostStatusFeed(args: {
// field initializer, before its constructor parameters are assigned.
statusSink: () => args.deps().statusSink,
...(args.onAgentStarted ? { onAgentStarted: args.onAgentStarted } : {}),
...(args.onChildWorkChanged ? { onChildWorkChanged: args.onChildWorkChanged } : {})
onChildWorkChanged: (sessionId) => {
subagentRows.changed(sessionId)
args.onChildWorkChanged?.(sessionId)
}
})
return feed
}

Some files were not shown because too many files have changed in this diff Show More