diff --git a/src/main/codex/codex-structured-journal-limits.ts b/src/main/codex/codex-structured-journal-limits.ts index d741a9e86d2..5137ea8dd16 100644 --- a/src/main/codex/codex-structured-journal-limits.ts +++ b/src/main/codex/codex-structured-journal-limits.ts @@ -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 diff --git a/src/main/codex/codex-structured-journal-translation-subagents.test.ts b/src/main/codex/codex-structured-journal-translation-subagents.test.ts new file mode 100644 index 00000000000..4399d864a09 --- /dev/null +++ b/src/main/codex/codex-structured-journal-translation-subagents.test.ts @@ -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, + 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' }]) + }) +}) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index b3bfccc228d..c8ecc996742 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -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) + : {} + 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, diff --git a/src/main/codex/codex-subagent-activity.ts b/src/main/codex/codex-subagent-activity.ts new file mode 100644 index 00000000000..fcddfd189d0 --- /dev/null +++ b/src/main/codex/codex-subagent-activity.ts @@ -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 | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : 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 +} diff --git a/src/main/codex/codex-subagent-roster.test.ts b/src/main/codex/codex-subagent-roster.test.ts new file mode 100644 index 00000000000..79988d23567 --- /dev/null +++ b/src/main/codex/codex-subagent-roster.test.ts @@ -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' }) + }) +}) diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts new file mode 100644 index 00000000000..746f4ccb239 --- /dev/null +++ b/src/main/codex/codex-subagent-roster.ts @@ -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 + /** Times each label has been claimed, so a repeat gets an ordinal suffix. */ + labelCounts: Map + /** 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() + /** 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() + 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}` +} diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts index 22bd645d8a6..76216411697 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts @@ -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') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 474b1385a4f..5d784568250 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -197,7 +197,12 @@ function hasProviderError(payload: unknown): boolean { const CODEX_ITEM_CLASSIFICATIONS: Record = { // 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:` row beside it for every + // event — and every one of them arrives twice. + subAgentActivity: 'status-chrome', + collabAgentToolCall: 'status-chrome' } function notificationKind(kind: string): string { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 8336f340b8c..7d8f1cf4049 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -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(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 ( -
- -
- ) - } - - if (isUser) { - return ( -
- {/* User turns get a distinct muted fill (not the card/canvas color) so - the prompt reads apart from the assistant's body copy. */} -
- {markdown ? ( - <> - - - - ) : ( - - )} -
- {deliveryFailed ? ( -
- {translate( - 'components.native-chat.launchPromptNotDelivered', - 'Not delivered — check the terminal' - )} -
- ) : null} -
- ) - } - - // 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 ( -
- - {markdown ? ( - - ) : null} - {tools.length > 0 ? ( - - ) : null} - {showControls ? ( - - ) : null} -
- ) -} - export function NativeChatMessageList({ session, isWorking, @@ -381,7 +225,7 @@ export function NativeChatMessageList({ : undefined return ( - void + onLinkClick?: CommentMarkdownLinkClickHandler + allowFileUriLinks?: boolean + deliveryFailed?: boolean + activityExpandOverride?: boolean + structuredActivityUi?: boolean + runtimeContext?: RuntimeFileOperationArgs | null +}): React.JSX.Element | null { + const rowRef = useRef(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 ( +
+ +
+ ) + } + + if (isUser) { + return ( +
+ {/* User turns get a distinct muted fill (not the card/canvas color) so + the prompt reads apart from the assistant's body copy. */} +
+ {markdown ? ( + <> + + + + ) : ( + + )} +
+ {deliveryFailed ? ( +
+ {translate( + 'components.native-chat.launchPromptNotDelivered', + 'Not delivered — check the terminal' + )} +
+ ) : null} +
+ ) + } + + // 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 ( +
+ + {markdown ? ( + + ) : null} + {tools.length > 0 || subagentGroups.length > 0 ? ( + + ) : null} + {showControls ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx new file mode 100644 index 00000000000..c44d4b6fa6b --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx @@ -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( + + ) + + 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( + + ) + + 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( + + ) + + expect(screen.getByRole('button')).toHaveTextContent('2 failed') + }) + + it('reconciles a roster persisted before a restart to unverifiable', () => { + render( + + ) + + 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( + + ) + + 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( + + ) + + expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument() + expect(screen.getByText('shell ls')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx b/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx new file mode 100644 index 00000000000..b034a808afb --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx @@ -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 = { + 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 ( +