feat(native-chat): show Codex subagent activity instead of opcode rows

Codex spawns subagents and reports their lifecycle, but Orca rendered only
gray `codex · item:subAgentActivity` opcode rows. Build the real display: one
summary row per spawn group with a live working count and token usage.

State is accumulated from `subAgentActivity.kind` alone. A live probe against
app-server 0.152.1 showed `agentsStates` arrives empty even in a real subagent
run, and that every activity item is delivered twice (item/started and
item/completed), so every transition is idempotent and terminal states latch.

Children never receive `thread/started`, so there is no nickname, role, or
depth to read; the row labels from the trailing segment of `agentPath`.

Two sweeps keep a row from claiming work forever: the parent turn's terminal
event settles still-running children, and session start marks a pre-restart
roster unverifiable rather than exited, since Codex resume replays no
non-message items and no event can ever settle them.

The roster rides a new NativeChatBlock variant paired with a plain-text twin.
A journal item kind could not be used: that union is closed, and an unknown
kind parses as malformed, which is the corrupt-journal class that can hide the
chat tab. Block types are explicitly admissible when unknown, so an older
client drops the block and renders the sentence.

MessageRow moves out of NativeChatMessageList to keep both files under the
max-lines budget without a disable.
This commit is contained in:
Merge Sim
2026-09-04 19:37:21 -07:00
parent e2b70a5eba
commit fccb28cc7b
19 changed files with 1860 additions and 164 deletions
@@ -7,3 +7,10 @@ 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
/** Spawn-group rows kept live per session, and children per row. Both bound an
* event-accumulated map that no provider snapshot ever prunes. */
export const MAX_CODEX_SUBAGENT_GROUPS = 32
export const MAX_CODEX_SUBAGENTS_PER_GROUP = 64
/** 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
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
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'
const SESSION_ID = 'session-1'
const THREAD_ID = 'thread-abc'
const TURN_ID = 'turn-1'
type Row = { key: string; body: AgentJournalItemBody }
function harness() {
const rows: Row[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity: AgentJournalItemIdentity, body) =>
rows.push({ key: agentJournalItemKey(identity), body }),
appendTombstone: () => {},
publish: () => {}
}
const translator = createCodexJournalTranslator({
sink,
primaryThreadId: () => THREAD_ID,
schedule: (run: () => void) => {
run()
return () => {}
}
})
return { translator, rows }
}
function notification(method: string, params: unknown): CodexStructuredSessionEvent {
return { type: 'notification', sessionId: SESSION_ID, threadId: THREAD_ID, method, params }
}
function subagentItem(kind: string, agentThreadId: string, agentPath: string): unknown {
return {
turnId: TURN_ID,
item: {
type: 'subAgentActivity',
id: `item-${agentThreadId}-${kind}`,
kind,
agentThreadId,
agentPath
}
}
}
/** Every activity item reaches the wire twice. */
function deliverActivity(
translator: ReturnType<typeof createCodexJournalTranslator>,
params: unknown
): void {
translator.handle(notification('item/started', params))
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', () => {
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.
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)
})
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 }])
})
it('sweeps a child that never reported completion when the turn ends', () => {
const { translator, rows } = harness()
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
deliverActivity(translator, subagentItem('started', 'child-1', '/root/read'))
deliverActivity(translator, subagentItem('completed', 'child-2', '/root/search'))
translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))
expect(rosterAgents(rows)).toMatchObject([
{ id: 'child-1', state: 'unverifiable' },
{ id: 'child-2', state: 'completed' }
])
})
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,3 +1,6 @@
import { CODEX_TOKEN_USAGE_METHOD } from './codex-subagent-activity'
import { CodexSubagentRoster } from './codex-subagent-roster'
import { readCodexThreadItem } from './codex-structured-item-translation'
import { CodexJournalGenericFrames } from './codex-structured-journal-generic-frames'
import { CodexJournalItems } from './codex-structured-journal-items'
import { CodexJournalPrompts } from './codex-structured-journal-prompts'
@@ -53,6 +56,11 @@ export function createCodexJournalTranslator(
const prompts = new CodexJournalPrompts(deps, (threadId, itemId) =>
items.detailFor(threadId, itemId)
)
const subagents = new CodexSubagentRoster({
sink: deps.sink,
primaryThreadId: () => deps.primaryThreadId?.() ?? null,
activeTurn: (threadId) => activeTurns.current(threadId)
})
const flushStreams = (): CodexJournalTranslationAdmission =>
items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' }
@@ -94,6 +102,11 @@ 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
}
items.activeItems.clear()
prompts.pending.clear()
activeTurns.clear()
@@ -133,7 +146,19 @@ export function createCodexJournalTranslator(
if (event.method === 'turn/completed') {
return completeTurn(event)
}
if (event.method === CODEX_TOKEN_USAGE_METHOD) {
// Classified `status-chrome`, so the generic-frame path swallows it
// before the journal. The roster consumes it as a typed notification.
const admission = subagents.handleTokenUsage(event.params)
if (admission) {
return admission
}
}
if (event.method === 'item/started' || event.method === 'item/completed') {
const subagentAdmission = handleSubagentItem(event)
if (subagentAdmission) {
return subagentAdmission
}
const translated = items.handle(event)
return translated.handled
? translated.admission
@@ -158,10 +183,31 @@ export function createCodexJournalTranslator(
items.dispose()
prompts.dispose()
genericFrames.dispose()
subagents.dispose()
activeTurns.clear()
}
}
/** Routes a `subAgentActivity` item to the roster; null when it is not one. */
function handleSubagentItem(event: {
threadId: string
params: unknown
}): CodexJournalTranslationAdmission | null {
const params =
typeof event.params === 'object' && event.params !== null
? (event.params as Record<string, unknown>)
: {}
const item = readCodexThreadItem(params.item)
if (!item) {
return null
}
return subagents.handleItem({
threadId: event.threadId,
turnId: readCodexTurnId(event.params) ?? activeTurns.current(event.threadId),
item
})
}
function settleOversizedNotification(event: {
sessionId: string
threadId: string
@@ -223,6 +269,12 @@ export function createCodexJournalTranslator(
if (!turnId) {
return CODEX_JOURNAL_ADMITTED
}
// The turn is over however it ended (completion, failure, or abort), so a
// child still reported as working will never be settled by an event.
const sweep = subagents.settleTurn(event.threadId, turnId)
if (!sweep.accepted) {
return sweep
}
const admission = settleCodexJournalTurn({
sink: deps.sink,
sessionId: event.sessionId,
+110
View File
@@ -0,0 +1,110 @@
// Reading Codex's subagent wire shapes.
//
// Established by a live probe against `codex app-server` 0.152.1, not inferred:
// * `subAgentActivity` items carry `{kind, agentThreadId, agentPath}`, and each
// one arrives TWICE — via `item/started` and again via `item/completed`.
// * `agentPath` is a tree path (`/root`, `/root/list_directory`); the trailing
// segment is a semantic task name and the only label available. There is no
// `thread/started` for a child, so nickname/role/depth do not exist.
// * `agentsStates` on `collabAgentToolCall` is ALWAYS `{}`. Nothing here reads it.
// * `thread/tokenUsage/updated` reports a per-thread RUNNING TOTAL, so the
// latest frame replaces the previous one — it is never accumulated.
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import type { CodexThreadItem } from './codex-structured-item-translation'
export const CODEX_SUBAGENT_ITEM_TYPE = 'subAgentActivity'
export const CODEX_TOKEN_USAGE_METHOD = 'thread/tokenUsage/updated'
export type CodexSubagentActivity = {
kind: string
agentThreadId: string
agentPath: string | null
}
function nonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function record(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
export function readCodexSubagentActivity(item: CodexThreadItem): CodexSubagentActivity | null {
if (item.type !== CODEX_SUBAGENT_ITEM_TYPE) {
return null
}
const agentThreadId = nonEmptyString(item.agentThreadId)
if (!agentThreadId) {
return null
}
return {
kind: nonEmptyString(item.kind) ?? '',
agentThreadId,
agentPath: nonEmptyString(item.agentPath)
}
}
/**
* The state a `kind` implies for the child it names.
*
* An unrecognized kind means "this child exists and reported something we
* cannot classify" — `working`, which the turn-end sweep will later settle to
* `unverifiable`. Claiming a terminal state from an unknown kind would assert
* an outcome the wire never gave us.
*/
export function codexSubagentStateForKind(kind: string): NativeChatSubagentState {
if (kind === 'completed') {
return 'completed'
}
if (kind === 'interrupted') {
return 'stopped'
}
return 'working'
}
/** Path segments, empty ones dropped: `/root/list_directory` → 2 segments. */
export function codexSubagentPathSegments(agentPath: string | null): string[] {
return agentPath === null ? [] : agentPath.split('/').filter((part) => part.length > 0)
}
/**
* Whether an activity item describes the ROOT of the agent tree rather than a
* spawned child. The root's path is a single segment (`/root`); every child
* carries at least one segment beneath it. Counting the root would make the
* parent turn report itself as its own subagent.
*
* A path-less item cannot be placed in the tree at all, so it is treated as a
* child: dropping it would lose a real spawn, while an extra row is visible and
* self-correcting.
*/
export function isCodexRootAgentActivity(activity: CodexSubagentActivity): boolean {
return codexSubagentPathSegments(activity.agentPath).length === 1
}
/** Row label: the agent path's trailing segment. */
export function codexSubagentLabel(activity: CodexSubagentActivity): string | null {
return codexSubagentPathSegments(activity.agentPath).at(-1) ?? null
}
export type CodexThreadTokenTotal = { threadId: string; totalTokens: number }
/** `{threadId, tokenUsage: {total: {totalTokens}}}`. Older builds put the total
* on the envelope, so both shapes are accepted. */
export function readCodexThreadTokenTotal(params: unknown): CodexThreadTokenTotal | null {
const root = record(params)
if (!root) {
return null
}
const threadId = nonEmptyString(root.threadId) ?? nonEmptyString(record(root.thread)?.id)
if (!threadId) {
return null
}
const usage = record(root.tokenUsage)
const total = record(usage?.total)?.totalTokens ?? usage?.totalTokens ?? root.totalTokens
return typeof total === 'number' && Number.isFinite(total) && total >= 0
? { threadId, totalTokens: total }
: null
}
@@ -0,0 +1,325 @@
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 { isSubagentGroupBlock, type NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
CodexSubagentRoster,
codexSubagentGroupIdentity,
codexSubagentGroupId
} from './codex-subagent-roster'
import type { CodexThreadItem } from './codex-structured-item-translation'
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({
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 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 {
// Every activity item reaches the wire twice: item/started, then item/completed.
roster.handleItem({ threadId: THREAD, turnId, item })
roster.handleItem({ threadId: THREAD, turnId, item })
}
describe('CodexSubagentRoster', () => {
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 — 1 working')
})
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('rule 2 — a first event of any kind creates the entry in the state it implies', () => {
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 turn-end 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.settleTurn(THREAD, TURN)
expect(agents()).toMatchObject([
{ id: 'child-1', state: 'unverifiable' },
{ id: 'child-2', state: 'completed' }
])
})
it('rule 4 — the session sweep settles every group and never un-terminals one', () => {
const { roster, agents, appended } = createHarness()
deliver(
roster,
activity({ kind: 'interacted', 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')
})
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()
})
it('propagates sink backpressure instead of reporting the row as written', () => {
const roster = new CodexSubagentRoster({
sink: {
appendItem: () => {},
appendTombstone: () => {},
publish: () => {},
tryAppendItem: () => ({ accepted: false, reason: 'backpressure' }),
tryPublish: () => ({ accepted: true })
},
primaryThreadId: () => THREAD,
activeTurn: () => TURN
})
expect(
roster.handleItem({
threadId: THREAD,
turnId: TURN,
item: activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
})
).toEqual({ accepted: false, reason: 'backpressure' })
})
})
+286
View File
@@ -0,0 +1,286 @@
// The Codex subagent roster: one journal row per spawn group, revised in place.
//
// There is no snapshot to read. `agentsStates` is always empty and children get
// no `thread/started`, so the roster is accumulated purely from
// `subAgentActivity` items — each of which arrives TWICE (`item/started` and
// `item/completed`). Every transition here is therefore idempotent, and a
// terminal state latches: duplicate and out-of-order delivery must not resurrect
// a settled child.
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type {
StructuredAgentSessionEventSink,
StructuredAgentSessionSinkAdmission
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
codexSubagentLabel,
codexSubagentStateForKind,
isCodexRootAgentActivity,
readCodexSubagentActivity,
readCodexThreadTokenTotal
} from './codex-subagent-activity'
import 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 ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true }
/** The turn a group belongs to when Codex reports activity outside any turn.
* Mirrors the generic-frame bucket name so the two read alike in the journal. */
const OUTSIDE_TURN = 'outside-turn'
const UNLABELLED_AGENT = 'subagent'
type RosterGroup = {
groupId: string
identity: AgentJournalItemIdentity
/** Insertion order is the display order; the map holds the state. */
entries: Map<string, NativeChatSubagentEntry>
/** Times each label has been claimed, so a repeat gets an ordinal suffix. */
labelCounts: Map<string, number>
/** Last body written, so an idempotent replay writes no new revision. */
lastSerialized: string | null
}
/** 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}`
}
/** 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}` }
}
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
now?: () => number
}
export class CodexSubagentRoster {
private readonly groups = new Map<string, RosterGroup>()
/** Latest reported total per thread, retained UNCONDITIONALLY: a usage frame
* can arrive before the child's first activity item, and filtering at receipt
* would lose it permanently. Children are selected at write time. */
private readonly tokensByThread = new Map<string, number>()
private readonly now: () => number
constructor(private readonly deps: CodexSubagentRosterDeps) {
this.now = deps.now ?? (() => Date.now())
}
/** Consume a `subAgentActivity` item. Returns null when the item is not one. */
handleItem(input: {
threadId: string
turnId: string | null
item: CodexThreadItem
}): StructuredAgentSessionSinkAdmission | null {
const activity = readCodexSubagentActivity(input.item)
if (!activity) {
return null
}
// The root node is the parent turn itself, not a child it spawned.
if (isCodexRootAgentActivity(activity)) {
return ADMITTED
}
const group = this.groupFor(input.threadId, input.turnId)
const existing = group.entries.get(activity.agentThreadId)
const state = codexSubagentStateForKind(activity.kind)
if (!existing) {
// Rule: the first event for a child may be ANY kind. An `interacted` or
// `completed` with no prior `started` creates the entry in the state its
// kind implies rather than being dropped for lacking a roster row.
if (group.entries.size >= MAX_CODEX_SUBAGENTS_PER_GROUP) {
return ADMITTED
}
const now = this.now()
group.entries.set(activity.agentThreadId, {
id: activity.agentThreadId,
label: this.claimLabel(group, codexSubagentLabel(activity)),
state,
startedAt: now,
...(isTerminalSubagentState(state) ? { settledAt: now } : {})
})
} else if (!isTerminalSubagentState(existing.state)) {
// Terminal latches. Re-applying the same non-terminal state is a no-op,
// which is what makes the duplicate `item/started` + `item/completed`
// delivery idempotent.
group.entries.set(activity.agentThreadId, {
...existing,
state,
...(isTerminalSubagentState(state) ? { settledAt: this.now() } : {})
})
}
return this.write(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
}
// A running total: the newest frame REPLACES the previous one. Summing
// updates would multiply a single child's usage by its frame count.
this.tokensByThread.set(usage.threadId, usage.totalTokens)
while (this.tokensByThread.size > MAX_CODEX_TOKEN_USAGE_THREADS) {
const oldest = this.tokensByThread.keys().next().value
if (typeof oldest !== 'string') {
break
}
this.tokensByThread.delete(oldest)
}
for (const group of this.groups.values()) {
if (!group.entries.has(usage.threadId)) {
continue
}
const admission = this.write(group)
if (!admission.accepted) {
return admission
}
}
return ADMITTED
}
/**
* The parent turn ended. Any child still reported as working will never be
* settled by an event — Codex sends nothing more for it — so it becomes
* `unverifiable`: contact was lost, which is NOT evidence the child exited.
*/
settleTurn(threadId: string, turnId: string | null): StructuredAgentSessionSinkAdmission {
const ownerThreadId = this.deps.primaryThreadId() ?? threadId
return this.sweep(this.groups.get(codexSubagentGroupId(ownerThreadId, turnId)))
}
/** The provider is gone; sweep every group the same way. */
settleSession(): StructuredAgentSessionSinkAdmission {
for (const group of this.groups.values()) {
const admission = this.sweep(group)
if (!admission.accepted) {
return admission
}
}
return ADMITTED
}
dispose(): void {
this.groups.clear()
this.tokensByThread.clear()
}
private sweep(group: RosterGroup | undefined): StructuredAgentSessionSinkAdmission {
if (!group) {
return ADMITTED
}
let changed = false
for (const [id, entry] of group.entries) {
if (isTerminalSubagentState(entry.state)) {
continue
}
group.entries.set(id, { ...entry, state: 'unverifiable', settledAt: this.now() })
changed = true
}
return changed ? this.write(group) : ADMITTED
}
private groupFor(threadId: string, turnId: string | null): RosterGroup {
const ownerThreadId = this.deps.primaryThreadId() ?? threadId
const ownerTurnId =
ownerThreadId === threadId ? turnId : (this.deps.activeTurn(ownerThreadId) ?? turnId)
const groupId = codexSubagentGroupId(ownerThreadId, ownerTurnId)
const existing = this.groups.get(groupId)
if (existing) {
return existing
}
const group: RosterGroup = {
groupId,
identity: codexSubagentGroupIdentity(groupId),
entries: new Map(),
labelCounts: new Map(),
lastSerialized: null
}
this.groups.set(groupId, group)
while (this.groups.size > MAX_CODEX_SUBAGENT_GROUPS) {
const oldest = this.groups.keys().next().value
if (typeof oldest !== 'string' || oldest === groupId) {
break
}
this.groups.delete(oldest)
}
return group
}
/** Two children can share a trailing path segment; the ordinal keeps their
* rows apart without inventing a name the provider never sent. */
private claimLabel(group: RosterGroup, label: string | null): string {
const base = label ?? UNLABELLED_AGENT
const seen = group.labelCounts.get(base) ?? 0
group.labelCounts.set(base, seen + 1)
return seen === 0 ? base : `${base} ${seen + 1}`
}
private write(group: RosterGroup): StructuredAgentSessionSinkAdmission {
const agents = [...group.entries.values()].map((entry) => {
const tokens = this.tokensByThread.get(entry.id)
return typeof tokens === 'number' ? { ...entry, tokens } : entry
})
const body = codexSubagentGroupBody(group.groupId, agents)
const serialized = JSON.stringify(body)
if (serialized === group.lastSerialized) {
// Nothing changed — a duplicate delivery must not burn a revision.
return ADMITTED
}
group.lastSerialized = serialized
const options = { coalescingKey: `codex-subagents:${group.groupId}` }
const admission = this.deps.sink.tryAppendItem
? this.deps.sink.tryAppendItem(group.identity, body, options)
: (this.deps.sink.appendItem(group.identity, body, options), ADMITTED)
if (!admission.accepted) {
group.lastSerialized = null
return admission
}
return this.deps.sink.tryPublish
? this.deps.sink.tryPublish(options)
: (this.deps.sink.publish(options), ADMITTED)
}
}
/** The roster row: the structured block plus the plain sentence an older client
* renders in its place. A message whose only block is the new variant would
* reach such a client with nothing it can draw. */
export function codexSubagentGroupBody(
groupId: string,
agents: readonly NativeChatSubagentEntry[]
): AgentJournalItemBody {
return {
kind: 'message',
role: 'system',
blocks: [
{ type: 'text', text: subagentGroupFallbackText(agents) },
{ type: 'subagent-group', groupId, agents: [...agents] }
]
}
}
/** Plain-text stand-in for the roster, for clients without the block type. */
export function subagentGroupFallbackText(agents: readonly NativeChatSubagentEntry[]): string {
const working = agents.filter((agent) => !isTerminalSubagentState(agent.state)).length
const noun = agents.length === 1 ? 'subagent' : 'subagents'
return working > 0
? `Kicked off ${agents.length} ${noun} — ${working} working`
: `Ran ${agents.length} ${noun}`
}
@@ -6,6 +6,7 @@ import {
isDeltaShapedProviderFrameKind,
PROVIDER_FRAME_CLASSIFICATIONS
} from './provider-frame-disposition'
import { unhandledProviderFrameJournalItem } from './unhandled-provider-frame'
describe('provider frame classification catalog', () => {
it('classifies every pinned Codex app-server notification method', () => {
@@ -113,3 +114,43 @@ describe('provider frame classification catalog', () => {
expect(classifyProviderFrame('codex', 'item:futureThing', {})).toBe('timeline-substantive')
})
})
describe('codex subagent item disposition', () => {
it('keeps subagent lifecycle out of the transcript now that it renders as a roster row', () => {
expect(
classifyProviderFrame('codex', 'item:subAgentActivity', {
type: 'subAgentActivity',
kind: 'started',
agentThreadId: 'child-1',
agentPath: '/root/read'
})
).toBe('status-chrome')
expect(
classifyProviderFrame('codex', 'item:collabAgentToolCall', {
type: 'collabAgentToolCall',
agentsStates: {}
})
).toBe('status-chrome')
})
it('journals no fallback row for either type', () => {
expect(
unhandledProviderFrameJournalItem('codex', 'item:subAgentActivity', {
kind: 'completed',
agentThreadId: 'child-1'
})
).toBeNull()
expect(
unhandledProviderFrameJournalItem('codex', 'item:collabAgentToolCall', { agentsStates: {} })
).toBeNull()
})
it('still surfaces a subagent frame that reports a failure', () => {
expect(
classifyProviderFrame('codex', 'item:collabAgentToolCall', {
type: 'collabAgentToolCall',
status: 'failed'
})
).toBe('error-surface')
})
})
@@ -197,7 +197,12 @@ function hasProviderError(payload: unknown): boolean {
const CODEX_ITEM_CLASSIFICATIONS: Record<string, ProviderFrameClassification> = {
// The `thread/compacted` notification is already chrome; its item form is the
// same event and must not read as a mysterious opcode row.
contextCompaction: 'status-chrome'
contextCompaction: 'status-chrome',
// Subagent lifecycle renders as the spawn-group roster row. Leaving these
// substantive prints a gray `codex · item:<type>` row beside it for every
// event — and every one of them arrives twice.
subAgentActivity: 'status-chrome',
collabAgentToolCall: 'status-chrome'
}
function notificationKind(kind: string): string {
@@ -1,27 +1,17 @@
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ArrowDown } from 'lucide-react'
import CommentMarkdown, {
type CommentMarkdownLinkClickHandler
} from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
import { translate } from '@/i18n/i18n'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import { orderNativeChatMessages } from './native-chat-message-grouping'
import { stripNoiseMessages } from './native-chat-noise'
import { foldToolMessages, splitNativeChatBlocks } from './native-chat-tool-fold'
import { foldToolMessages } from './native-chat-tool-fold'
import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll'
import { NativeChatToolRun } from './NativeChatToolRun'
import { NativeChatMessageRow } from './NativeChatMessageRow'
import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator'
import { NativeChatWorkingStatus } from './NativeChatWorkingStatus'
import { useNativeChatTurnStatus } from './use-native-chat-turn-status'
import { nativeChatProseToMarkdown } from './native-chat-prose'
import { NativeChatTypingIndicatorRow } from './NativeChatTypingIndicatorRow'
import {
NativeChatAgentControls,
NativeChatImageAttachments,
ProviderFrameRow
} from './NativeChatTranscriptChrome'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
export { ProviderFrameRow } from './NativeChatTranscriptChrome'
@@ -32,152 +22,6 @@ function geometryOf(el: HTMLElement): ScrollGeometry {
const MAX_EXPANDED_TURNS = 128
/** One message: its prose first, then a collapsible run folding all of the
* turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a
* lifted card, assistant prose as body copy, reasoning de-emphasized. */
function MessageRow({
message,
expandSignal,
activeTurnIsWorking,
onScrollMessageToTop,
onLinkClick,
allowFileUriLinks = false,
deliveryFailed = false,
activityExpandOverride,
structuredActivityUi = true,
runtimeContext
}: {
message: NativeChatMessage
expandSignal: boolean
activeTurnIsWorking?: boolean
/** Align this message's top to the top of the scroll viewport. */
onScrollMessageToTop: (el: HTMLElement) => void
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
deliveryFailed?: boolean
activityExpandOverride?: boolean
structuredActivityUi?: boolean
runtimeContext?: RuntimeFileOperationArgs | null
}): React.JSX.Element | null {
const rowRef = useRef<HTMLDivElement | null>(null)
const { prose, tools } = useMemo(() => splitNativeChatBlocks(message.blocks), [message.blocks])
const markdown = nativeChatProseToMarkdown(prose)
const hasImages = prose.some((block) => block.type === 'image-ref')
const isUser = message.role === 'user'
const isReasoning = message.role === 'reasoning'
const isSystem = message.role === 'system'
const providerFrame = message.blocks.find((block) => block.type === 'text' && block.providerFrame)
const scrollToTop = useCallback(() => {
if (rowRef.current) {
onScrollMessageToTop(rowRef.current)
}
}, [onScrollMessageToTop])
// Skip rows with nothing renderable so the transcript shows no empty/ghost
// bubble.
// After all hooks, so hook order stays unconditional.
if (markdown.length === 0 && !hasImages && tools.length === 0) {
return null
}
if (providerFrame) {
return (
<div ref={rowRef}>
<ProviderFrameRow block={providerFrame} />
</div>
)
}
if (isUser) {
return (
<div ref={rowRef} className="flex flex-col items-end gap-0.5">
{/* User turns get a distinct muted fill (not the card/canvas color) so
the prompt reads apart from the assistant's body copy. */}
<div className="max-w-[85%] rounded-lg rounded-tr-sm bg-muted px-3.5 py-2.5 text-sm text-foreground">
{markdown ? (
<>
<NativeChatImageAttachments
blocks={prose}
runtimeContext={runtimeContext}
enablePreview={runtimeContext !== undefined}
/>
<CommentMarkdown
content={markdown}
variant="document"
className="text-sm"
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
/>
</>
) : (
<NativeChatImageAttachments
blocks={prose}
runtimeContext={runtimeContext}
enablePreview={runtimeContext !== undefined}
/>
)}
</div>
{deliveryFailed ? (
<div className="max-w-[85%] text-[11px] text-destructive/80">
{translate(
'components.native-chat.launchPromptNotDelivered',
'Not delivered — check the terminal'
)}
</div>
) : null}
</div>
)
}
// Plain assistant prose is the copyable unit; reasoning/system asides stay
// chrome-free. The controls reveal on hover (and on keyboard focus-within).
const showControls = !isReasoning && !isSystem && markdown.length > 0
return (
<div
ref={rowRef}
className={cn(
'group relative max-w-full select-text text-sm leading-relaxed text-foreground',
// Reasoning is the agent thinking aloud — quieter, italic, like an aside.
isReasoning && 'border-l-2 border-border/60 pl-3 italic text-muted-foreground',
isSystem && 'text-xs text-muted-foreground'
)}
>
<NativeChatImageAttachments
blocks={prose}
runtimeContext={runtimeContext}
enablePreview={runtimeContext !== undefined}
/>
{markdown ? (
<CommentMarkdown
content={markdown}
variant="document"
className="text-sm"
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
/>
) : null}
{tools.length > 0 ? (
<NativeChatToolRun
blocks={tools}
expandSignal={expandSignal}
expandOverride={activityExpandOverride}
activeTurnIsWorking={activeTurnIsWorking}
structuredActivityUi={structuredActivityUi}
/>
) : null}
{showControls ? (
<NativeChatAgentControls
markdown={markdown}
onScrollToTop={scrollToTop}
className="pointer-events-none mt-1 -mb-5 w-fit select-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100"
/>
) : null}
</div>
)
}
export function NativeChatMessageList({
session,
isWorking,
@@ -381,7 +225,7 @@ export function NativeChatMessageList({
: undefined
return (
<Fragment key={message.id}>
<MessageRow
<NativeChatMessageRow
message={message}
expandSignal={expandSignal}
// A missing transcript lifecycle is not evidence that the turn
@@ -0,0 +1,176 @@
import { useCallback, useMemo, useRef } from 'react'
import CommentMarkdown, {
type CommentMarkdownLinkClickHandler
} from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { subagentGroupBlocks } from '../../../../shared/native-chat-subagent-summary'
import { isSubagentGroupBlock, type NativeChatMessage } from '../../../../shared/native-chat-types'
import { splitNativeChatBlocks } from './native-chat-tool-fold'
import { nativeChatProseToMarkdown } from './native-chat-prose'
import { NativeChatToolRun } from './NativeChatToolRun'
import {
NativeChatAgentControls,
NativeChatImageAttachments,
ProviderFrameRow
} from './NativeChatTranscriptChrome'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
/** One message: its prose first, then a collapsible run folding all of the
* turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a
* lifted card, assistant prose as body copy, reasoning de-emphasized. */
export function NativeChatMessageRow({
message,
expandSignal,
activeTurnIsWorking,
onScrollMessageToTop,
onLinkClick,
allowFileUriLinks = false,
deliveryFailed = false,
activityExpandOverride,
structuredActivityUi = true,
runtimeContext
}: {
message: NativeChatMessage
expandSignal: boolean
activeTurnIsWorking?: boolean
/** Align this message's top to the top of the scroll viewport. */
onScrollMessageToTop: (el: HTMLElement) => void
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
deliveryFailed?: boolean
activityExpandOverride?: boolean
structuredActivityUi?: boolean
runtimeContext?: RuntimeFileOperationArgs | null
}): React.JSX.Element | null {
const rowRef = useRef<HTMLDivElement | null>(null)
const split = useMemo(() => splitNativeChatBlocks(message.blocks), [message.blocks])
const tools = split.tools
const subagentGroups = useMemo(() => subagentGroupBlocks(split.prose), [split.prose])
// A spawn-group row carries a plain-text twin so a client without the block
// type still reads the roster. This one draws the block, so the twin is
// dropped rather than printed beside it.
const prose = useMemo(
() =>
subagentGroups.length === 0
? split.prose
: split.prose.filter((block) => block.type !== 'text' && !isSubagentGroupBlock(block)),
[split.prose, subagentGroups.length]
)
const markdown = nativeChatProseToMarkdown(prose)
const hasImages = prose.some((block) => block.type === 'image-ref')
const isUser = message.role === 'user'
const isReasoning = message.role === 'reasoning'
const isSystem = message.role === 'system'
const providerFrame = message.blocks.find((block) => block.type === 'text' && block.providerFrame)
const scrollToTop = useCallback(() => {
if (rowRef.current) {
onScrollMessageToTop(rowRef.current)
}
}, [onScrollMessageToTop])
// Skip rows with nothing renderable so the transcript shows no empty/ghost
// bubble.
// After all hooks, so hook order stays unconditional.
if (markdown.length === 0 && !hasImages && tools.length === 0 && subagentGroups.length === 0) {
return null
}
if (providerFrame) {
return (
<div ref={rowRef}>
<ProviderFrameRow block={providerFrame} />
</div>
)
}
if (isUser) {
return (
<div ref={rowRef} className="flex flex-col items-end gap-0.5">
{/* User turns get a distinct muted fill (not the card/canvas color) so
the prompt reads apart from the assistant's body copy. */}
<div className="max-w-[85%] rounded-lg rounded-tr-sm bg-muted px-3.5 py-2.5 text-sm text-foreground">
{markdown ? (
<>
<NativeChatImageAttachments
blocks={prose}
runtimeContext={runtimeContext}
enablePreview={runtimeContext !== undefined}
/>
<CommentMarkdown
content={markdown}
variant="document"
className="text-sm"
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
/>
</>
) : (
<NativeChatImageAttachments
blocks={prose}
runtimeContext={runtimeContext}
enablePreview={runtimeContext !== undefined}
/>
)}
</div>
{deliveryFailed ? (
<div className="max-w-[85%] text-[11px] text-destructive/80">
{translate(
'components.native-chat.launchPromptNotDelivered',
'Not delivered — check the terminal'
)}
</div>
) : null}
</div>
)
}
// Plain assistant prose is the copyable unit; reasoning/system asides stay
// chrome-free. The controls reveal on hover (and on keyboard focus-within).
const showControls = !isReasoning && !isSystem && markdown.length > 0
return (
<div
ref={rowRef}
className={cn(
'group relative max-w-full select-text text-sm leading-relaxed text-foreground',
// Reasoning is the agent thinking aloud — quieter, italic, like an aside.
isReasoning && 'border-l-2 border-border/60 pl-3 italic text-muted-foreground',
isSystem && 'text-xs text-muted-foreground'
)}
>
<NativeChatImageAttachments
blocks={prose}
runtimeContext={runtimeContext}
enablePreview={runtimeContext !== undefined}
/>
{markdown ? (
<CommentMarkdown
content={markdown}
variant="document"
className="text-sm"
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
/>
) : null}
{tools.length > 0 || subagentGroups.length > 0 ? (
<NativeChatToolRun
blocks={tools}
subagentGroups={subagentGroups}
expandSignal={expandSignal}
expandOverride={activityExpandOverride}
activeTurnIsWorking={activeTurnIsWorking}
structuredActivityUi={structuredActivityUi}
/>
) : null}
{showControls ? (
<NativeChatAgentControls
markdown={markdown}
onScrollToTop={scrollToTop}
className="pointer-events-none mt-1 -mb-5 w-fit select-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100"
/>
) : null}
</div>
)
}
@@ -0,0 +1,117 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type {
NativeChatSubagentEntry,
NativeChatSubagentGroupBlock
} from '../../../../shared/native-chat-types'
import { NativeChatSubagentRun, reconcileSubagentRoster } from './NativeChatSubagentRun'
import { NativeChatToolRun } from './NativeChatToolRun'
afterEach(cleanup)
function group(agents: NativeChatSubagentEntry[]): NativeChatSubagentGroupBlock {
return { type: 'subagent-group', groupId: 'thread:turn-1', agents }
}
describe('NativeChatSubagentRun', () => {
it('reads as a live spawn while children work', () => {
render(
<NativeChatSubagentRun
block={group([
{ id: 'a', label: 'read', state: 'working' },
{ id: 'b', label: 'search', state: 'completed', tokens: 40661 }
])}
activeTurnIsWorking
/>
)
expect(screen.getByText('Kicked off 2 subagents')).toBeInTheDocument()
expect(screen.getByRole('button')).toHaveTextContent('1 working')
expect(screen.getByRole('button')).toHaveTextContent('40.7k tokens')
})
it('switches to Ran with a check once every child completed', () => {
render(
<NativeChatSubagentRun
block={group([
{ id: 'a', label: 'read', state: 'completed' },
{ id: 'b', label: 'search', state: 'completed' }
])}
activeTurnIsWorking={false}
/>
)
expect(screen.getByText('Ran 2 subagents')).toBeInTheDocument()
expect(screen.getByRole('button')).toHaveTextContent('completed')
})
it('shows the worst settled verdict, not the count of finished children', () => {
render(
<NativeChatSubagentRun
block={group([
{ id: 'a', label: 'read', state: 'failed' },
{ id: 'b', label: 'search', state: 'failed' },
{ id: 'c', label: 'list', state: 'completed' }
])}
activeTurnIsWorking={false}
/>
)
expect(screen.getByRole('button')).toHaveTextContent('2 failed')
})
it('reconciles a roster persisted before a restart to unverifiable', () => {
render(
<NativeChatSubagentRun
block={group([{ id: 'a', label: 'read', state: 'working' }])}
activeTurnIsWorking={false}
/>
)
expect(screen.getByRole('button')).toHaveTextContent('unverifiable')
expect(screen.getByRole('button')).not.toHaveTextContent('working')
})
it('leaves a live turn working — a settled roster is never asserted early', () => {
expect(
reconcileSubagentRoster([{ id: 'a', label: 'read', state: 'working' }], true)
).toMatchObject([{ state: 'working' }])
expect(
reconcileSubagentRoster([{ id: 'a', label: 'read', state: 'working' }], false)
).toMatchObject([{ state: 'unverifiable' }])
})
})
describe('NativeChatToolRun with a spawn group', () => {
it('renders a roster with no tool calls without inventing a tool count', () => {
render(
<NativeChatToolRun
blocks={[]}
subagentGroups={[group([{ id: 'a', label: 'read', state: 'working' }])]}
expandSignal={false}
activeTurnIsWorking
/>
)
expect(screen.getByText('Kicked off 1 subagent')).toBeInTheDocument()
expect(screen.queryByText('1 tool call')).toBeNull()
})
it('renders the roster alongside the tool activity of its turn', () => {
render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: 'shell', input: { command: 'ls' } }]}
subagentGroups={[group([{ id: 'a', label: 'read', state: 'completed' }])]}
expandSignal={false}
activeTurnIsWorking={false}
/>
)
expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument()
expect(screen.getByText('shell ls')).toBeInTheDocument()
})
})
@@ -0,0 +1,253 @@
import { useMemo, useState } from 'react'
import { Check, ChevronRight, Users } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { useNow } from '@/hooks/use-now'
import {
normalizeSubagentState,
summarizeSubagentGroup
} from '../../../../shared/native-chat-subagent-summary'
import type {
NativeChatSubagentEntry,
NativeChatSubagentGroupBlock,
NativeChatSubagentState
} from '../../../../shared/native-chat-types'
import { formatNativeChatDuration } from './NativeChatWorkingStatus'
/**
* A roster read back from the journal after the writing process is gone.
*
* The state map is process-local and Codex resume replays no non-message items,
* so no event will ever settle a child that was working when the host died.
* Once the owning turn is no longer live, such a child is `unverifiable` — the
* repo's loss-of-contact verdict. It is NOT evidence the child exited.
*/
export function reconcileSubagentRoster(
agents: readonly NativeChatSubagentEntry[],
turnIsLive: boolean
): NativeChatSubagentEntry[] {
return agents.map((agent) => {
const state = normalizeSubagentState(agent.state)
const reconciled: NativeChatSubagentState =
state === 'working' && !turnIsLive ? 'unverifiable' : state
return reconciled === agent.state ? agent : { ...agent, state: reconciled }
})
}
/** Compact token counts: the row shows scale, not an exact ledger. */
function formatSubagentTokens(tokens: number): string {
if (tokens < 1_000) {
return String(Math.round(tokens))
}
const scaled = tokens < 1_000_000 ? tokens / 1_000 : tokens / 1_000_000
const suffix = tokens < 1_000_000 ? 'k' : 'M'
return `${scaled.toFixed(1).replace(/\.0$/, '')}${suffix}`
}
/** The group's one-line verdict. A single-child group reads as a bare word; any
* larger group always carries the count, because "working" alone would not say
* how many of the children it covers. `completed` never takes one: every child
* finishing is the whole group finishing. */
function subagentStateLabel(
state: NativeChatSubagentState,
count: number,
groupTotal: number
): string {
if (state === 'completed') {
return translate('components.native-chat.subagents.state.completed', 'completed')
}
if (groupTotal <= 1) {
switch (state) {
case 'working':
return translate('components.native-chat.subagents.state.working', 'working')
case 'idle':
return translate('components.native-chat.subagents.state.idle', 'idle')
case 'failed':
return translate('components.native-chat.subagents.state.failed', 'failed')
case 'stopped':
return translate('components.native-chat.subagents.state.stopped', 'stopped')
default:
return translate('components.native-chat.subagents.state.unverifiable', 'unverifiable')
}
}
switch (state) {
case 'working':
return translate(
'components.native-chat.subagents.state.workingCount',
'{{value0}} working',
{
value0: count
}
)
case 'idle':
return translate('components.native-chat.subagents.state.idleCount', '{{value0}} idle', {
value0: count
})
case 'failed':
return translate('components.native-chat.subagents.state.failedCount', '{{value0}} failed', {
value0: count
})
case 'stopped':
return translate(
'components.native-chat.subagents.state.stoppedCount',
'{{value0}} stopped',
{
value0: count
}
)
default:
return translate(
'components.native-chat.subagents.state.unverifiableCount',
'{{value0}} unverifiable',
{ value0: count }
)
}
}
const STATE_DOT_CLASS: Record<NativeChatSubagentState, string> = {
working: 'bg-foreground/70 animate-pulse motion-reduce:animate-none',
idle: 'bg-muted-foreground/40',
completed: 'bg-muted-foreground/60',
failed: 'bg-destructive',
stopped: 'bg-muted-foreground',
unverifiable: 'bg-muted-foreground'
}
function StatusDot({ state }: { state: NativeChatSubagentState }): React.JSX.Element {
return (
<span
aria-hidden="true"
className={cn('size-1.5 shrink-0 rounded-full', STATE_DOT_CLASS[state])}
/>
)
}
/** Leaf so the shared 1s clock re-renders only the digits, never the roster. */
function SubagentElapsed({
startedAt,
settledAt,
counting
}: {
startedAt: number
settledAt: number | null
counting: boolean
}): React.JSX.Element {
const now = useNow(1_000, counting)
const end = counting ? now : (settledAt ?? now)
return <>{formatNativeChatDuration(Math.max(0, (end - startedAt) / 1000))}</>
}
/** One spawn group: how many children are working, their settled verdict, and
* the tokens they consumed. Deliberately flat — children are summarized here,
* never nested into the transcript as turns of their own. */
export function NativeChatSubagentRun({
block,
activeTurnIsWorking
}: {
block: NativeChatSubagentGroupBlock
/** False once the owning turn is no longer live; undefined means unknown. */
activeTurnIsWorking?: boolean
}): React.JSX.Element | null {
const [open, setOpen] = useState(false)
const agents = useMemo(
() => reconcileSubagentRoster(block.agents, activeTurnIsWorking !== false),
[block.agents, activeTurnIsWorking]
)
const summary = useMemo(() => summarizeSubagentGroup(agents), [agents])
if (summary.total === 0) {
return null
}
const working = summary.working > 0
const headline = working
? summary.total === 1
? translate('components.native-chat.subagents.startedOne', 'Kicked off 1 subagent')
: translate('components.native-chat.subagents.startedN', 'Kicked off {{value0}} subagents', {
value0: summary.total
})
: summary.total === 1
? translate('components.native-chat.subagents.ranOne', 'Ran 1 subagent')
: translate('components.native-chat.subagents.ranN', 'Ran {{value0}} subagents', {
value0: summary.total
})
const verdictState: NativeChatSubagentState = working
? 'working'
: (summary.settledState ?? 'idle')
const verdict = working
? subagentStateLabel('working', summary.working, summary.total)
: subagentStateLabel(verdictState, summary.settledCount, summary.total)
return (
<div>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="group flex min-h-6 w-full items-center gap-1.5 rounded-md py-0.5 text-left text-sm leading-relaxed text-muted-foreground hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
aria-expanded={open}
aria-live="polite"
>
<StatusDot state={verdictState} />
<span className="flex size-6 shrink-0 items-center justify-center text-muted-foreground">
{verdictState === 'completed' ? (
<Check className="size-3.5" />
) : (
<Users className="size-4" />
)}
</span>
<span className={cn('min-w-0 flex-1 truncate', working && 'text-foreground/85')}>
{headline}
</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
{verdict}
{summary.startedAt !== null ? (
<>
{' · '}
<SubagentElapsed
startedAt={summary.startedAt}
settledAt={summary.settledAt}
counting={working}
/>
</>
) : null}
{summary.tokens !== null
? ` · ${translate('components.native-chat.subagents.tokens', '{{value0}} tokens', {
value0: formatSubagentTokens(summary.tokens)
})}`
: null}
</span>
<ChevronRight
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition-all',
open ? 'rotate-90 opacity-100' : 'opacity-0 group-hover:opacity-100'
)}
/>
</button>
{open ? (
<ul className="mt-1 space-y-0.5">
{agents.map((agent) => {
const state = normalizeSubagentState(agent.state)
return (
<li key={agent.id} className="flex items-center gap-1.5 py-0.5">
<StatusDot state={state} />
<code
className={cn(
'min-w-0 truncate font-mono text-[11px]',
state === 'idle' ? 'text-muted-foreground/70' : 'text-foreground/80'
)}
>
{agent.label}
</code>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
{subagentStateLabel(state, 1, 1)}
{typeof agent.tokens === 'number'
? ` · ${formatSubagentTokens(agent.tokens)}`
: null}
</span>
</li>
)
})}
</ul>
) : null}
</div>
)
}
@@ -5,7 +5,8 @@ import { translate } from '@/i18n/i18n'
import {
isToolCallBlock,
isToolResultBlock,
type NativeChatBlock
type NativeChatBlock,
type NativeChatSubagentGroupBlock
} from '../../../../shared/native-chat-types'
import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff'
import {
@@ -15,6 +16,10 @@ import {
truncateToolDetail
} from './native-chat-tool-summary'
import { NativeChatDiffView } from './NativeChatDiffView'
import { NativeChatSubagentRun } from './NativeChatSubagentRun'
/** Stable empty default: a fresh array literal per render breaks memoization. */
const NO_SUBAGENT_GROUPS: NativeChatSubagentGroupBlock[] = []
const COMMAND_TOOL_NAMES = new Set([
'bash',
@@ -156,12 +161,15 @@ function ToolLine({
* toolbar toggle drive every run at once while still allowing per-run override. */
export function NativeChatToolRun({
blocks,
subagentGroups = NO_SUBAGENT_GROUPS,
expandSignal,
activeTurnIsWorking,
expandOverride,
structuredActivityUi = true
}: {
blocks: NativeChatBlock[]
/** Spawn-group rosters that belong with this run's activity, one row each. */
subagentGroups?: NativeChatSubagentGroupBlock[]
/** Toolbar-driven desired open state. Each change re-syncs this run's state. */
expandSignal: boolean
/** Per-turn disclosure state controlled by the completed turn status row. */
@@ -174,6 +182,13 @@ export function NativeChatToolRun({
// Re-sync when the global toolbar toggle flips.
useEffect(() => setOpen(expandOverride ?? expandSignal), [expandOverride, expandSignal])
const subagentRows = subagentGroups.map((group) => (
<NativeChatSubagentRun
key={group.groupId}
block={group}
activeTurnIsWorking={activeTurnIsWorking}
/>
))
const callCount = countToolCalls(blocks) || blocks.length
const summary = summarizeToolRun(blocks)
const calls = blocks.filter(isToolCallBlock)
@@ -212,10 +227,17 @@ export function NativeChatToolRun({
return null
}
// A roster with no tool calls beside it is the whole run: rendering the tool
// header too would announce "1 tool call" for activity that has none.
if (blocks.length === 0) {
return subagentRows.length > 0 ? <div className="mt-3">{subagentRows}</div> : null
}
return (
// Extra top margin sets the tool run apart from the assistant prose above it
// so the turn's activity doesn't crowd the message text.
<div className="mt-3">
{subagentRows}
{latestActiveCall ? (
<button
type="button"
@@ -203,3 +203,52 @@ describe('splitNativeChatBlocks', () => {
expect(tools.map((b) => b.type)).toEqual(['tool-call', 'tool-result'])
})
})
describe('spawn-group roster rows', () => {
const roster = msg({
id: 'roster',
role: 'system',
blocks: [
{ type: 'text', text: 'Kicked off 1 subagent — 1 working' },
{
type: 'subagent-group',
groupId: 'thread:turn-1',
agents: [{ id: 'child-1', label: 'read', state: 'working' }]
}
]
})
it('does not end the assistant run the following tool messages fold into', () => {
const folded = foldToolMessages([
msg({
id: 'a',
role: 'assistant',
blocks: [
{ type: 'text', text: 'working' },
{ type: 'tool-call', name: 'Bash', input: {} }
]
}),
roster,
msg({ id: 't', role: 'tool', blocks: [{ type: 'tool-result', output: 'done' }] })
])
expect(folded.map((message) => message.id)).toEqual(['a', 'roster'])
expect(folded[0]?.blocks.map((block) => block.type)).toEqual([
'text',
'tool-call',
'tool-result'
])
})
it('survives the noise strip so the roster still reaches the transcript', () => {
expect(stripNoiseMessages([roster]).map((message) => message.id)).toEqual(['roster'])
})
it('keeps the roster out of the tool array so mobile draws no empty tool run', () => {
const { prose, tools } = splitNativeChatBlocks(roster.blocks)
expect(tools).toEqual([])
// The plain-text twin stays in prose: a client without the block type reads it.
expect(prose.map((block) => block.type)).toEqual(['text', 'subagent-group'])
})
})
+23 -1
View File
@@ -34,7 +34,24 @@ const ProviderFrame = z.object({
payload: BoundedPayload
})
const KNOWN_BLOCK_TYPES = new Set(['text', 'tool-call', 'tool-result', 'image-ref'])
const KNOWN_BLOCK_TYPES = new Set([
'text',
'tool-call',
'tool-result',
'image-ref',
'subagent-group'
])
/** Child-agent lifecycle stays an open string for the same reason tool states
* do: a state a newer build writes must not turn the row malformed. */
const SubagentEntry = z.object({
id: z.string(),
label: z.string(),
state: z.string().min(1),
tokens: z.number().optional(),
startedAt: z.number().optional(),
settledAt: z.number().optional()
})
/** Renderers select blocks by `type` equality and skip what they cannot draw,
* so an unknown block type stays admissible; a known type with a broken
@@ -59,6 +76,11 @@ const Block = z.union([
path: z.string().optional(),
url: z.string().optional(),
alt: z.string().optional()
}),
z.object({
type: z.literal('subagent-group'),
groupId: z.string(),
agents: z.array(SubagentEntry)
})
]),
z.object({ type: z.string() }).refine((block) => !KNOWN_BLOCK_TYPES.has(block.type))
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import {
isTerminalSubagentState,
normalizeSubagentState,
summarizeSubagentGroup
} from './native-chat-subagent-summary'
import type { NativeChatSubagentEntry } from './native-chat-types'
function agent(entry: Partial<NativeChatSubagentEntry>): NativeChatSubagentEntry {
return { id: 'a', label: 'task', state: 'working', ...entry }
}
describe('summarizeSubagentGroup', () => {
it('collapses in-flight children into one working count', () => {
const summary = summarizeSubagentGroup([
agent({ id: 'a', state: 'working' }),
agent({ id: 'b', state: 'working' }),
agent({ id: 'c', state: 'completed' })
])
expect(summary).toMatchObject({ total: 3, working: 2, settledState: null, settledCount: 0 })
})
it('ranks the settled verdict worst-first and reports ✓ completed last', () => {
const cascade: [NativeChatSubagentEntry['state'][], string][] = [
[['failed', 'stopped', 'idle', 'completed'], 'failed'],
[['stopped', 'idle', 'completed'], 'stopped'],
[['unverifiable', 'idle', 'completed'], 'unverifiable'],
[['idle', 'completed'], 'idle'],
[['completed', 'completed'], 'completed']
]
for (const [states, expected] of cascade) {
const summary = summarizeSubagentGroup(
states.map((state, index) => agent({ id: `a${index}`, state }))
)
expect(summary.settledState).toBe(expected)
}
})
it('counts how many children hold the winning verdict', () => {
const summary = summarizeSubagentGroup([
agent({ id: 'a', state: 'failed' }),
agent({ id: 'b', state: 'failed' }),
agent({ id: 'c', state: 'completed' })
])
expect(summary).toMatchObject({ settledState: 'failed', settledCount: 2 })
})
it('sums the per-child token snapshots and leaves them null when none reported', () => {
expect(
summarizeSubagentGroup([
agent({ id: 'a', tokens: 40661 }),
agent({ id: 'b', tokens: 1000 }),
agent({ id: 'c' })
]).tokens
).toBe(41661)
expect(summarizeSubagentGroup([agent({ id: 'a' })]).tokens).toBeNull()
})
it('reports the earliest start and withholds a settled time while work continues', () => {
const working = summarizeSubagentGroup([
agent({ id: 'a', state: 'completed', startedAt: 50, settledAt: 80 }),
agent({ id: 'b', state: 'working', startedAt: 20 })
])
const settled = summarizeSubagentGroup([
agent({ id: 'a', state: 'completed', startedAt: 50, settledAt: 80 }),
agent({ id: 'b', state: 'stopped', startedAt: 20, settledAt: 95 })
])
expect(working).toMatchObject({ startedAt: 20, settledAt: null })
expect(settled).toMatchObject({ startedAt: 20, settledAt: 95 })
})
it('reads a state this build does not know as unverifiable, never as working', () => {
expect(normalizeSubagentState('paused-for-review')).toBe('unverifiable')
expect(isTerminalSubagentState('paused-for-review')).toBe(true)
expect(summarizeSubagentGroup([agent({ state: 'unheard-of' as 'working' })])).toMatchObject({
working: 0,
settledState: 'unverifiable'
})
})
it('keeps working the only non-terminal state', () => {
expect(isTerminalSubagentState('working')).toBe(false)
for (const state of ['idle', 'completed', 'failed', 'stopped', 'unverifiable']) {
expect(isTerminalSubagentState(state)).toBe(true)
}
})
})
+103
View File
@@ -0,0 +1,103 @@
// One spawn group's roster → the numbers a single flat row needs.
//
// Shared because the desktop transcript and the mobile summary path must agree
// on what "N working" means, and because the producer uses the same terminal
// predicate the renderer does — a state that reads terminal here must latch
// terminal there.
import {
isSubagentGroupBlock,
type NativeChatBlock,
type NativeChatSubagentEntry,
type NativeChatSubagentGroupBlock,
type NativeChatSubagentState
} from './native-chat-types'
/** Every state a child cannot leave. `working` is the only in-flight state:
* providers report several (started/interacted, pending/running/paused) and the
* producer collapses them before the roster is written. */
const TERMINAL_SUBAGENT_STATES: ReadonlySet<string> = new Set([
'idle',
'completed',
'failed',
'stopped',
'unverifiable'
])
/** Settled-state precedence for the group's one-line verdict: the worst
* outcome wins, and `completed` only shows when nothing else is left. */
const SETTLED_PRECEDENCE = ['failed', 'stopped', 'unverifiable', 'idle', 'completed'] as const
/** A state this build does not know reads as `unverifiable`, never as working:
* a roster written by a newer build must not leave the row spinning forever. */
export function normalizeSubagentState(state: string): NativeChatSubagentState {
if (state === 'working') {
return 'working'
}
return TERMINAL_SUBAGENT_STATES.has(state) ? (state as NativeChatSubagentState) : 'unverifiable'
}
export function isTerminalSubagentState(state: string): boolean {
return normalizeSubagentState(state) !== 'working'
}
export type NativeChatSubagentSummary = {
total: number
working: number
/** The group's verdict once nothing is in flight; null while any child works. */
settledState: NativeChatSubagentState | null
/** How many children hold `settledState`. */
settledCount: number
/** Sum of the latest per-child totals. Null when no child reported one.
* Children's counters are disjoint from the parent's, so this never
* double-counts — and the parent's own usage is deliberately excluded. */
tokens: number | null
/** Earliest child start, for the live elapsed clock. */
startedAt: number | null
/** Latest terminal timestamp, once the group has settled. */
settledAt: number | null
}
export function summarizeSubagentGroup(
agents: readonly NativeChatSubagentEntry[]
): NativeChatSubagentSummary {
const counts = new Map<NativeChatSubagentState, number>()
let working = 0
let tokens: number | null = null
let startedAt: number | null = null
let settledAt: number | null = null
for (const agent of agents) {
const state = normalizeSubagentState(agent.state)
if (state === 'working') {
working += 1
} else {
counts.set(state, (counts.get(state) ?? 0) + 1)
}
if (typeof agent.tokens === 'number' && Number.isFinite(agent.tokens)) {
tokens = (tokens ?? 0) + agent.tokens
}
if (typeof agent.startedAt === 'number') {
startedAt = startedAt === null ? agent.startedAt : Math.min(startedAt, agent.startedAt)
}
if (typeof agent.settledAt === 'number') {
settledAt = settledAt === null ? agent.settledAt : Math.max(settledAt, agent.settledAt)
}
}
const settledState =
working > 0 ? null : (SETTLED_PRECEDENCE.find((state) => counts.has(state)) ?? null)
return {
total: agents.length,
working,
settledState,
settledCount: settledState === null ? 0 : (counts.get(settledState) ?? 0),
tokens,
startedAt,
settledAt: working > 0 ? null : settledAt
}
}
export function subagentGroupBlocks(
blocks: readonly NativeChatBlock[]
): NativeChatSubagentGroupBlock[] {
return blocks.filter(isSubagentGroupBlock)
}
+12 -1
View File
@@ -1,4 +1,5 @@
import {
isSubagentGroupBlock,
isToolCallBlock,
isToolResultBlock,
type NativeChatBlock,
@@ -35,6 +36,13 @@ function isHarnessSidecarToolMessage(message: NativeChatMessage): boolean {
)
}
/** The spawn-group roster row lands mid-turn, between the assistant's tool
* calls. It is activity chrome, not a new turn, so it must not end the run the
* following tool messages fold into. */
function isSubagentRosterMessage(message: NativeChatMessage): boolean {
return message.blocks.some(isSubagentGroupBlock)
}
function isInterruptionBoundary(message: NativeChatMessage): boolean {
return message.blocks.some(
(block) =>
@@ -104,7 +112,10 @@ export function foldToolMessages(messages: readonly NativeChatMessage[]): Native
if (message.role === 'assistant') {
mutableAssistantIndex = output.length - 1
clonedAssistantIndex = -1
} else if (!isNoiseMessage(message) || isInterruptionBoundary(message)) {
} else if (
!isSubagentRosterMessage(message) &&
(!isNoiseMessage(message) || isInterruptionBoundary(message))
) {
mutableAssistantIndex = -1
clonedAssistantIndex = -1
}
+46
View File
@@ -71,11 +71,51 @@ export type NativeChatImageRefBlock = {
alt?: string
}
/** Lifecycle of one spawned child agent, as the display collapses it.
* `unverifiable` is the repo's loss-of-contact verdict (see
* docs/reference/ssh-execution-boundary.md): the child stopped reporting and
* nothing proves it exited. Every in-flight provider state collapses to
* `working`; `idle` is a child that exists but is not currently working. */
export const NATIVE_CHAT_SUBAGENT_STATES = [
'working',
'idle',
'completed',
'failed',
'stopped',
'unverifiable'
] as const
export type NativeChatSubagentState = (typeof NATIVE_CHAT_SUBAGENT_STATES)[number]
/** One child agent in a spawn group. */
export type NativeChatSubagentEntry = {
/** Provider's child id (Codex: the child thread id). The roster key. */
id: string
/** Row label — the provider's task name, disambiguated by ordinal on collision. */
label: string
state: NativeChatSubagentState
/** Latest total tokens the provider reported FOR THIS CHILD, never a running sum. */
tokens?: number
/** Epoch ms of the first event that created the entry. */
startedAt?: number
/** Epoch ms the entry latched terminal. */
settledAt?: number
}
/** One spawn group's roster, revised in place as its children report activity.
* Provider-agnostic on purpose: the Codex and Claude lanes both feed this. */
export type NativeChatSubagentGroupBlock = {
type: 'subagent-group'
/** Stable group key — the parent turn that spawned these children. */
groupId: string
agents: NativeChatSubagentEntry[]
}
export type NativeChatBlock =
| NativeChatTextBlock
| NativeChatToolCallBlock
| NativeChatToolResultBlock
| NativeChatImageRefBlock
| NativeChatSubagentGroupBlock
export type NativeChatMessage = {
/** Stable across re-reads/appends so the assembler and the renderer list can
@@ -159,3 +199,9 @@ export function isInterruptedStatusMessage(message: NativeChatMessage): boolean
export function isImageRefBlock(block: NativeChatBlock): block is NativeChatImageRefBlock {
return block.type === 'image-ref'
}
export function isSubagentGroupBlock(
block: NativeChatBlock
): block is NativeChatSubagentGroupBlock {
return block.type === 'subagent-group'
}