Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
1f702e9
feat(native-chat): publish the host's child records to the status sum…
brennanb2025 Sep 23, 2026
3bd627f
feat(native-chat): the chat strip reads the host's child records with…
brennanb2025 Sep 24, 2026
f132afc
test(native-chat): a newer host's view degrades, an older reader keep…
brennanb2025 Sep 24, 2026
b70d652
refactor(native-chat): the summary's task list is the legacy projecti…
brennanb2025 Sep 24, 2026
a83f030
test(native-chat): type the switch tests' mocks instead of asserting …
brennanb2025 Sep 24, 2026
14c2b00
test: remote clients advertise reading child views
brennanb2025 Sep 24, 2026
d296499
docs(agent-status): the structured row folds the store's child records
brennanb2025 Sep 24, 2026
2ece5c2
fix(agent-status): keep the view reader's header from reading as a va…
brennanb2025 Sep 24, 2026
abfce6d
refactor(native-chat): the status summary's broadcast equality gets i…
brennanb2025 Sep 24, 2026
38ecf1f
fix(native-chat): command admission reads the strip's child records
brennanb2025 Sep 24, 2026
46b695a
refactor(native-chat): command admission takes only what it reads of …
brennanb2025 Sep 24, 2026
abc964b
fix(native-chat): the session list drops a session's children when th…
brennanb2025 Sep 25, 2026
bb1b369
test(native-chat): write the Codex frame script's parent row out step…
brennanb2025 Sep 25, 2026
6b2020e
fix(native-chat): the idle sweep and the restart snapshot read the ho…
brennanb2025 Sep 25, 2026
0b5293a
test(native-chat): the child-record tests follow the merged command l…
brennanb2025 Sep 28, 2026
f606187
refactor(native-chat): the status feed's journal projection cache get…
brennanb2025 Sep 28, 2026
e528dae
test(native-chat): the admission test's compaction resolves with a re…
brennanb2025 Sep 29, 2026
6905809
feat(native-chat): the sidebar lists running subagents; the strip, ru…
brennanb2025 Sep 29, 2026
896ad6b
fix(sidebar): one rule for what the worktree sidebar lists: running c…
brennanb2025 Sep 29, 2026
c01de97
test(native-chat): the switch test passes the startup child key main'…
brennanb2025 Sep 29, 2026
ed1e755
Merge remote-tracking branch 'origin/main' into brennanb2025/c5-read-…
brennanb2025 Sep 30, 2026
d7bb26a
Merge remote-tracking branch 'origin/main' into brennanb2025/c5-read-…
brennanb2025 Sep 30, 2026
c8385c7
Merge remote-tracking branch 'origin/main' into brennanb2025/c5-read-…
brennanb2025 Sep 30, 2026
02b3521
fix(native-chat): a finished child stays until the user's next send, …
brennanb2025 Sep 30, 2026
4734efd
fix(native-chat): a background Stop reaches the tasks the child recor…
brennanb2025 Sep 30, 2026
9f05338
fix(native-chat): one rule for a finished child that still owns live …
brennanb2025 Sep 30, 2026
cb7d453
fix(native-chat): an older client sees a Codex child's shell as it di…
brennanb2025 Sep 30, 2026
737c428
fix(native-chat): the chat decodes a roster's child rows once, as the…
brennanb2025 Sep 30, 2026
59e3bbd
fix(native-chat): the strip channel forgets a closed conversation's r…
brennanb2025 Sep 30, 2026
0788ed9
docs(native-chat): rewrap the retention comment
brennanb2025 Sep 30, 2026
73d9f61
Merge remote-tracking branch 'origin/main' into brennanb2025/c5-read-…
brennanb2025 Sep 30, 2026
2c85759
fix(native-chat): a task's own ending replaces a Stop's, and a child …
brennanb2025 Sep 30, 2026
66afd16
fix(native-chat): the strip keeps one empty list for a roster that om…
brennanb2025 Sep 30, 2026
bacc1ba
fix(native-chat): a strip row whose owner the 100-row budget cut rend…
brennanb2025 Sep 30, 2026
a020c1e
Merge remote-tracking branch 'origin/main' into brennanb2025/c5-read-…
brennanb2025 Sep 30, 2026
dbd2439
fix(native-chat): a stop-all that times out stops asking, and "no chi…
brennanb2025 Sep 30, 2026
bc7bc6e
fix(native-chat): the status feed reads a journal snapshot with no su…
brennanb2025 Sep 30, 2026
e769c09
Merge remote-tracking branch 'origin/main' into brennanb2025/c5-read-…
brennanb2025 Sep 30, 2026
b9e1ef8
fix(native-chat): the chat strip lists running children only, by the …
brennanb2025 Sep 30, 2026
4430535
fix(native-chat): the strip shows running work only from any host, an…
brennanb2025 Sep 30, 2026
9313ec2
Merge remote-tracking branch 'origin/main' into brennanb2025/c5-read-…
brennanb2025 Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions docs/reference/agent-status-store.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,16 @@ The structured feed keeps its job of projecting a session's journal into a
summary and streaming it to subscribers. On every publish it additionally
ingests the summary into the hook server as a status row:

| Row field | From |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it |
| `tabId` | `structuredAgentSessionTabId(sessionId)` |
| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) |
| `state` | `structuredAgentSessionAgentStatus(summary).state`: the lead's own status folded with its live `backgroundTasks`, so a settled lead whose subagent still runs reads `working` |
| `workingMode` | `'monitoring'` from the same fold when watch loops are the only live child work; omitted otherwise, which clears it on the row |
| `mainAgent` | the main agent's own state before the fold, its last-turn verdict (`summary.turnOutcome`, present only while idle) and its own clock; see "The main agent fact" below |
| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it |
| prompt, tool, last message, model, provider session | the summary's fields |
| Row field | From |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `paneKey` | `structuredAgentSessionPaneKey(tabId, sessionId)`, the key the renderer already uses; its leaf is UUID-shaped so pane-key validation accepts it |
| `tabId` | `structuredAgentSessionTabId(sessionId)` |
| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) |
| `state` | `structuredAgentSessionAgentStatus(summary).state`: the lead's own status folded with the live child records the store holds for the session (not the summary's task list), so a settled lead whose subagent still runs reads `working` |
| `workingMode` | `'monitoring'` from the same fold when watch loops are the only live child work; omitted otherwise, which clears it on the row |
| `mainAgent` | the main agent's own state before the fold, its last-turn verdict (`summary.turnOutcome`, present only while idle) and its own clock; see "The main agent fact" below |
| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it |
| prompt, tool, last message, model, provider session | the summary's fields |

Sessions with no request (`status === null`) produce no row. A request is a
turn record, an assistant message, a user message the provider journaled itself
Expand Down
2 changes: 2 additions & 0 deletions src/cli/runtime/websocket-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
} from '../../shared/protocol-version'
import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability'

vi.mock('./launch', () => ({
launchOrcaApp: vi.fn()
Expand Down Expand Up @@ -75,6 +76,7 @@ describe('CLI remote WebSocket transport', () => {
clientCapabilities: [
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY,
AGENT_SESSION_TURN_ITEM_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
Expand Down
33 changes: 25 additions & 8 deletions src/main/agent-hooks/server-ingest-structured-state-clock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { beforeEach, describe, expect, it } from 'vitest'
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
import { AgentHookServer, _internals } from './server'

Expand Down Expand Up @@ -32,6 +33,22 @@ function summary(over: Partial<AgentSessionStatusSummary> = {}): AgentSessionSta
}
}

const CHILD = { idKind: 'task_id', id: 'child-1' } as const

function liveChild(observedAt: number): AgentChildWorkEvidence {
return {
type: 'live',
observedAt,
child: {
handle: CHILD,
kind: 'agent',
residency: 'background',
state: 'working',
stoppable: true
}
}
}

function row(server: AgentHookServer) {
return server.getStatusSnapshot()[0]
}
Expand Down Expand Up @@ -68,16 +85,16 @@ describe("the host row's state clock", () => {
summary({ status: 'working', statusStartedAt: 10_000, updatedAt: 10_000 }),
SUBJECT
)
server.ingestStructuredStatus(
summary({
statusStartedAt: SETTLED,
updatedAt: 24_000,
backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }]
}),
SUBJECT
)
// The row reads child liveness from the host's records, not the summary's task list.
server.ingestStructuredChildWork(SUBJECT, [liveChild(23_000)], 'codex')
server.ingestStructuredStatus(summary({ statusStartedAt: SETTLED, updatedAt: 24_000 }), SUBJECT)
expect(row(server)).toMatchObject({ state: 'working', stateStartedAt: 10_000 })

server.ingestStructuredChildWork(
SUBJECT,
[{ type: 'ended', observedAt: 25_000, handle: CHILD, outcome: 'succeeded' }],
'codex'
)
server.ingestStructuredStatus(summary({ statusStartedAt: SETTLED, updatedAt: 26_000 }), SUBJECT)
expect(row(server)).toMatchObject({ state: 'done', stateStartedAt: SETTLED })
})
Expand Down
98 changes: 56 additions & 42 deletions src/main/agent-hooks/server-ingest-structured-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'nod
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence'
import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-freshness'
import {
structuredAgentSessionPaneKey,
Expand Down Expand Up @@ -39,6 +40,30 @@ const TAB = structuredAgentSessionTabId(SESSION)
const STRUCTURED_PANE = structuredAgentSessionPaneKey(TAB, SESSION)
const OBSERVED_AT = 1_757_030_400_000

/** A child the provider reports live, as the structured producers hand it to the host. */
function liveChild(id: string, kind: 'agent' | 'command'): AgentChildWorkEvidence {
return {
type: 'live',
observedAt: OBSERVED_AT,
child: {
handle: { idKind: 'task_id', id },
kind,
residency: 'background',
state: 'working',
stoppable: true
}
}
}

function endedChild(id: string): AgentChildWorkEvidence {
return {
type: 'ended',
observedAt: OBSERVED_AT + 1,
handle: { idKind: 'task_id', id },
outcome: 'succeeded'
}
}

function summary(over: Partial<AgentSessionStatusSummary> = {}): AgentSessionStatusSummary {
return {
sessionId: SESSION,
Expand Down Expand Up @@ -103,66 +128,60 @@ describe('AgentHookServer ingestStructuredStatus', () => {
expect(server.getStatusSnapshot()[0]?.state).toBe('done')
})

it('folds live background tasks into an idle session the way the hook lane folds a roster', () => {
it("folds the host's live child records into an idle session the way the hook lane folds a roster", () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(
summary({
status: 'idle',
backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }]
}),
SUBJECT
)
server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT)
server.ingestStructuredChildWork(SUBJECT, [liveChild('child-1', 'agent')], 'codex')
server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 1 }), SUBJECT)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
expect(server.getStatusSnapshot()[0]).not.toHaveProperty('workingMode')

server.ingestStructuredStatus(
summary({
status: 'idle',
updatedAt: OBSERVED_AT + 1,
backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }]
}),
SUBJECT
server.ingestStructuredChildWork(
SUBJECT,
[endedChild('child-1'), liveChild('shell-1', 'command')],
'codex'
)
server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 2 }), SUBJECT)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
workingMode: 'monitoring'
})

server.ingestStructuredChildWork(SUBJECT, [endedChild('shell-1')], 'codex')
server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT + 3 }), SUBJECT)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' })
expect(server.getStatusSnapshot()[0]).not.toHaveProperty('workingMode')
})

// The summary's task list is derived from these records for older clients; the row reads the
// records themselves, so a list that disagrees with them cannot hold the parent open.
it("reads child liveness from the host's records, never from the summary's task list", () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(
summary({
status: 'idle',
updatedAt: OBSERVED_AT + 2,
backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'done' }]
backgroundTasks: [{ id: 'child-1', kind: 'agent', state: 'working' }]
}),
SUBJECT
)
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' })
expect(server.getStatusSnapshot()[0]).not.toHaveProperty('workingMode')
})

// The timer beside the row belongs to the state it labels: monitoring that becomes a real turn
// must not report the watch loop's age as how long the agent has been working.
it('restarts the state clock when monitoring becomes a real turn', () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(
summary({
status: 'idle',
backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }]
}),
SUBJECT
)
server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT)
server.ingestStructuredChildWork(SUBJECT, [liveChild('shell-1', 'command')], 'codex')
server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
workingMode: 'monitoring',
stateStartedAt: OBSERVED_AT
})

server.ingestStructuredStatus(
summary({
status: 'working',
updatedAt: OBSERVED_AT + 2_700_000,
backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }]
}),
summary({ status: 'working', updatedAt: OBSERVED_AT + 2_700_000 }),
SUBJECT
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
Expand All @@ -178,14 +197,9 @@ describe('AgentHookServer ingestStructuredStatus', () => {
it('dates a task edge by when the host saw it, not by the journal clock', () => {
const server = new AgentHookServer()
const before = Date.now()
server.ingestStructuredStatus(
summary({
status: 'idle',
updatedAt: OBSERVED_AT,
backgroundTasks: [{ id: 'shell-1', kind: 'command', state: 'working' }]
}),
SUBJECT
)
server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT }), SUBJECT)
server.ingestStructuredChildWork(SUBJECT, [liveChild('shell-1', 'command')], 'codex')
server.ingestStructuredStatus(summary({ status: 'idle', updatedAt: OBSERVED_AT }), SUBJECT)
const row = server.getStatusSnapshot()[0]
expect(row).toMatchObject({ state: 'working', workingMode: 'monitoring' })
expect(row?.evidenceObservedAt).toBeGreaterThanOrEqual(before)
Expand Down Expand Up @@ -354,17 +368,17 @@ describe('structured rows and last-status.json', () => {
describe('the main agent fact on a structured row', () => {
it('publishes the main agent beside the folded state, on the journal clock with continuity', () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(
summary({ status: 'idle', backgroundTasks: [{ id: 'c', kind: 'agent', state: 'working' }] }),
SUBJECT
)
server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT)
server.ingestStructuredChildWork(SUBJECT, [liveChild('c', 'agent')], 'codex')
server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
mainAgent: { state: 'done', stateStartedAt: OBSERVED_AT }
})
expect(server.getStatusSnapshot()[0]?.mainAgent).not.toHaveProperty('outcome')

// The main agent is still done while its child drains: the main agent's clock does not move.
server.ingestStructuredChildWork(SUBJECT, [endedChild('c')], 'codex')
server.ingestStructuredStatus(
summary({ status: 'idle', updatedAt: OBSERVED_AT + 5, turnOutcome: 'failure' }),
SUBJECT
Expand Down
13 changes: 13 additions & 0 deletions src/main/agent-hooks/server/server-ingest-structured-children.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {
reconcileAgentChildWorkEvidence,
type AgentChildWorkReconcileOutcome
} from '../../../shared/agent-status-child-work-reconciliation'
import {
projectAgentChildWorkViews,
type AgentChildWorkView
} from '../../../shared/agent-status-child-work-view'
import {
parseAgentStatusSubject,
type AgentStatusStructuredSessionSubject
Expand Down Expand Up @@ -57,4 +61,13 @@ export abstract class AgentHookServerIngestStructuredChildren extends AgentHookS
const parent = parseAgentStatusSubject(subject)
return parent ? this.canonicalStatusStore.getChildren(parent) : []
}

/** The same records as the views every surface reads; the one record-to-view projection. */
getStructuredChildWorkViews(subject: AgentStatusStructuredSessionSubject): AgentChildWorkView[] {
const children = this.getStructuredChildWork(subject)
return projectAgentChildWorkViews(
children,
children.flatMap((child) => this.canonicalStatusStore.getAliasesForChild(child.childWorkId))
)
}
}
4 changes: 3 additions & 1 deletion src/main/agent-hooks/server/server-ingest-structured.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng
}
const previous = this.canonicalStatusStore.getParent(parsed)
const priorStatus = previous?.status
// The host's own child records, not the summary's lossy task list: the summary is derived
// from these records, and reading them here keeps the row and every view of it on one source.
const agentStatus = structuredAgentSessionAgentStatus({
status: summary.status,
backgroundTasks: summary.backgroundTasks,
childWork: this.canonicalStatusStore.getChildren(parsed),
turnOutcome: summary.turnOutcome
})
const { state, workingMode } = agentStatus
Expand Down
10 changes: 7 additions & 3 deletions src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
//
// Env contract (set by the test):
// ORCA_SDK_CONTRACT_SCENARIO_PATH — JSON file
// { steps: Step[], controlResponses?: { [subtype]: <response> } } where a Step is
// { steps: Step[], controlResponses?: { [subtype]: <response> },
// controlResponseLeadingFrames?: { [subtype]: <frame>[] } } where a Step is
// { emit: <frame> } | { awaitUserMessage: true } | { stderr: <text> } |
// { awaitControlResponse: <request_id> } | { delayMs: <n> } | { exit: <code> }
// ORCA_SDK_CONTRACT_REPORT_PATH — where argv/env observations are written
Expand Down Expand Up @@ -93,7 +94,7 @@ createInterface({ input: process.stdin }).on('line', (line) => {
if (process.env.ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS) {
return
}
emit({
const response = {
type: 'control_response',
response: {
subtype: 'success',
Expand All @@ -103,7 +104,10 @@ createInterface({ input: process.stdin }).on('line', (line) => {
models: []
}
}
})
}
// Frames the CLI wrote just before its answer, in the same write: one read chunk.
const leading = scenario.controlResponseLeadingFrames?.[frame.request?.subtype] ?? []
process.stdout.write([...leading, response].map((line) => `${JSON.stringify(line)}\n`).join(''))
return
}
if (frame.type === 'control_response') {
Expand Down
Loading
Loading