mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user