feat(native-chat): show each subagent's model, current tool and result in the chat (#41766)

* feat(native-chat): show each subagent's model, current tool and result in the roster

Subagent roster rows now carry the child's role, model, reasoning effort,
the tool it is running and the last thing it said, so a row says what the
subagent is doing and, once done, what it found. Codex children take model
and effort from the spawn call; Claude children take the model from their
own messages. A hover card shows the longer text.

* feat(native-chat): name subagent models and roles the way people say them

Show Opus 5.5 · High instead of claude-opus-5-5 · high, reusing the
model-id label and effort picker copy, and General purpose instead of
general-purpose.

* fix(native-chat): read Codex subagent model and effort from its started activity

Current Codex announces a spawn only through subAgentActivity, which carries
the model and reasoning effort the child resolved to.

* fix(native-chat): use the hover card's own spacing variant for the subagent preview

* fix(native-chat): show a subagent's reply as plain text in its roster row and preview

Its last message is markdown; the row now drops backticks, emphasis, list
and heading marks and joins lines into one readable line.
This commit is contained in:
Kelvin Amoaba
2026-11-13 19:01:39 +00:00
committed by GitHub
parent 17a1dc373b
commit 39ccf4fb56
27 changed files with 482 additions and 77 deletions
@@ -6,7 +6,12 @@ import {
subagentStateLabel
} from '../../../../../src/shared/native-chat/transcript/native-chat-subagent-group-header'
import { sayMobileNativeChatSubagentGroup as say } from './native-chat-subagent-group-copy'
import { sayMobileNativeChatSessionOption as sayOption } from '../input/native-chat-session-option-copy'
import { normalizeSubagentState } from '../../../../../src/shared/native-chat/transcript/native-chat-subagent-summary'
import {
subagentEntryDetail,
subagentEntryRunsOn
} from '../../../../../src/shared/native-chat/transcript/native-chat-subagent-entry-detail'
import { formatNativeChatDuration } from '../../../../../src/shared/native-chat/transcript/native-chat-turn-status'
import type {
NativeChatSubagentGroupBlock,
@@ -113,21 +118,31 @@ export function MobileNativeChatSubagentGroup({
{open
? block.agents.map((agent) => {
const state = normalizeSubagentState(agent.state)
const detail = [subagentEntryRunsOn(agent, sayOption), subagentEntryDetail(agent)]
.filter((part): part is string => part !== null)
.join(' · ')
return (
<View key={agent.id} testID="subagent-group-entry" style={styles.entry}>
<View style={[styles.dot, stateDotStyle(state)]} />
<Text
style={[styles.entryLabel, state === 'idle' && styles.entryLabelIdle]}
numberOfLines={1}
>
{agent.label}
</Text>
<Text style={styles.entryState} numberOfLines={1}>
{subagentStateLabel(state, 1, 1, say)}
{typeof agent.tokens === 'number'
? ` · ${formatSubagentTokens(agent.tokens)}`
: null}
</Text>
<View key={agent.id} testID="subagent-group-entry" style={styles.entryBlock}>
<View style={styles.entry}>
<View style={[styles.dot, stateDotStyle(state)]} />
<Text
style={[styles.entryLabel, state === 'idle' && styles.entryLabelIdle]}
numberOfLines={1}
>
{agent.label}
</Text>
<Text style={styles.entryState} numberOfLines={1}>
{subagentStateLabel(state, 1, 1, say)}
{typeof agent.tokens === 'number'
? ` · ${formatSubagentTokens(agent.tokens)}`
: null}
</Text>
</View>
{detail ? (
<Text style={styles.entryDetail} numberOfLines={2}>
{detail}
</Text>
) : null}
</View>
)
})
@@ -179,12 +194,20 @@ const styles = themedStyleSheet(() => ({
caretOpen: {
transform: [{ rotate: '90deg' }]
},
entryBlock: {
paddingLeft: spacing.lg + spacing.xs
},
entry: {
minHeight: 24,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingLeft: spacing.lg + spacing.xs
gap: spacing.sm
},
// Aligns under the label, past the 6pt dot and its gap.
entryDetail: {
paddingLeft: 6 + spacing.sm,
color: colors.textMuted,
fontSize: typography.metaSize
},
entryLabel: {
flexShrink: 1,
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { claudeChildOperation } from './claude-child-work-evidence'
describe('Claude child operation evidence', () => {
it("carries the model the subagent's own message ran on", () => {
const frame = {
type: 'assistant',
session_id: 'provider-1',
uuid: 'uuid-1',
parent_tool_use_id: 'toolu_spawn',
message: {
role: 'assistant',
model: 'claude-sonnet-5-5',
content: [{ type: 'tool_use', id: 'toolu_read', name: 'Read', input: {} }]
}
}
const evidence = claudeChildOperation(
frame,
() => ({ agentId: 'agent-1', openTool: null }),
100
)
expect(evidence).toEqual([
expect.objectContaining({ type: 'operation', childId: 'agent-1', model: 'claude-sonnet-5-5' })
])
})
})
@@ -152,6 +152,7 @@ export function claudeChildOperation(
}
const { agentId, openTool } = activityOf(parentRef)
const input = openTool ? deriveToolInputPreview(openTool.name, openTool.input) : undefined
const model = claudeChildMessageModel(message)
return [
{
type: 'operation',
@@ -159,11 +160,18 @@ export function claudeChildOperation(
childId: agentId,
operation: openTool
? { toolName: openTool.name, ...(input ? { input } : {}), basis: 'open', observedAt }
: null
: null,
...(model ? { model } : {})
}
]
}
/** Claude names the model on each assistant message; `<synthetic>` marks Claude Code's own repair text. */
function claudeChildMessageModel(frame: Record<string, unknown>): string | undefined {
const model = frame.type === 'assistant' ? claudeRecord(frame.message)?.model : undefined
return typeof model === 'string' && model.trim() && model !== '<synthetic>' ? model : undefined
}
/** Waiting requires both a written journal card and a registry request still awaiting an answer. */
function claudeWaitingChildIds(
session: Pick<ClaudeSession, 'prompts' | 'translator'>
@@ -1,5 +1,5 @@
import type { NativeChatSubagentState } from '../../../../shared/native-chat/native-chat-types'
import { readCodexSubagentAnnouncements } from './codex-subagent-activity'
import { readCodexSubagentAnnouncements, type CodexChildLaunch } from './codex-subagent-activity'
import { codexChildTurnState, type CodexSubagentExecutions } from './codex-subagent-executions'
import { readRecord } from '../../app-server/codex-item-field-readers'
import '../items/codex-structured-item-translation'
@@ -11,6 +11,7 @@ export type CodexAnnouncedChild = {
label: string | null
/** Set only by spawn announcements, whose reporting thread is the spawner. */
spawnerThreadId: string | undefined
launch?: CodexChildLaunch
}
export type CodexBackgroundTaskFrame =
@@ -78,7 +79,8 @@ export function readCodexBackgroundTaskFrame(
agentThreadId: announcement.agentThreadId,
label: announcement.label,
// Only a spawn names the spawner: other announcements ride whichever agent acted.
spawnerThreadId: announcement.spawned ? event.threadId : undefined
spawnerThreadId: announcement.spawned ? event.threadId : undefined,
launch: announcement.launch
})
)
return children.length > 0 ? { kind: 'subagents', children } : null
@@ -92,7 +94,7 @@ export function observeCodexExecutionFrame(
): void {
if (frame?.kind === 'subagents') {
for (const child of frame.children) {
executions.register(child.agentThreadId, child.label, child.spawnerThreadId)
executions.register(child.agentThreadId, child.label, child.spawnerThreadId, child.launch)
}
} else if (frame?.kind === 'thread-closed') {
executions.closeThread(frame.threadId)
@@ -266,6 +266,8 @@ export class CodexChildWorkEvidence {
residency: 'background',
state: facts.waiting ? 'waiting' : 'working',
...(child.label ? { description: child.label } : {}),
...(child.model ? { model: child.model } : {}),
...(child.effort ? { effort: child.effort } : {}),
...(facts.totalTokens !== undefined ? { totalTokens: facts.totalTokens } : {}),
...(lastMessage ? { lastMessage } : {}),
...(spawner && spawner !== this.primaryThreadId ? { ownerId: spawner } : {}),
@@ -198,6 +198,24 @@ describe('Codex structured child-work producer', () => {
])
})
it('records the model and reasoning effort Codex resolved for the child', async () => {
const { send, records } = await producer()
send(turn('turn/started', THREAD_ID, 'p1'))
send(
item('item/completed', THREAD_ID, 'p1', {
type: 'subAgentActivity',
id: 'spawn-reviewer',
kind: 'started',
agentThreadId: REVIEWER,
agentPath: '/root/inspect_folder',
model: 'gpt-6.1-sol',
reasoning_effort: 'xhigh'
})
)
send(turn('turn/started', REVIEWER, 'r1'))
expect(records()).toEqual([expect.objectContaining({ model: 'gpt-6.1-sol', effort: 'xhigh' })])
})
it('ends an approval left unanswered when its turn ends: Codex never ran the command', async () => {
const { adapter, codex, send, records, recordedLiveness, display, byDescription } =
await producer()
@@ -17,6 +17,8 @@ export type CodexSubagentActivity = {
kind: string
agentThreadId: string
agentPath: string | null
/** What the child resolved to at creation, reported on `started`. */
launch: CodexChildLaunch
}
function nonEmptyString(value: unknown): string | null {
@@ -40,10 +42,18 @@ export function readCodexSubagentActivity(item: CodexThreadItem): CodexSubagentA
return {
kind: nonEmptyString(item.kind) ?? '',
agentThreadId,
agentPath: nonEmptyString(item.agentPath)
agentPath: nonEmptyString(item.agentPath),
launch: codexChildLaunch(
nonEmptyString(item.model),
nonEmptyString(item.reasoningEffort) ?? nonEmptyString(item.reasoning_effort)
)
}
}
function codexChildLaunch(model: string | null, effort: string | null): CodexChildLaunch {
return { ...(model ? { model } : {}), ...(effort ? { effort } : {}) }
}
const CODEX_ROOT_AGENT_SEGMENT = 'root'
/** Normalize root paths like labels; missing paths remain children to avoid losing a real spawn. */
@@ -61,8 +71,12 @@ export type CodexSubagentAnnouncement = {
label: string | null
/** Only spawn items establish the carrying thread as the parent. */
spawned: boolean
launch?: CodexChildLaunch
}
/** What a spawn asked the child to run on. */
export type CodexChildLaunch = { model?: string; effort?: string }
/** Collab references recover compacted-out spawns; even failed spawns can name a runnable child. */
export function readCodexSubagentAnnouncements(
item: CodexThreadItem,
@@ -80,7 +94,8 @@ function readAnnouncements(item: CodexThreadItem): CodexSubagentAnnouncement[] {
{
agentThreadId: activity.agentThreadId,
label: codexSubagentLabel(activity),
spawned: activity.kind === 'started'
spawned: activity.kind === 'started',
launch: activity.launch
}
]
}
@@ -91,7 +106,8 @@ function readAnnouncements(item: CodexThreadItem): CodexSubagentAnnouncement[] {
const spawned = call.tool === 'spawnAgent'
// Follow-up prompts are messages, not the child's task label.
const label = spawned ? codexCollabHelperLabel(call.prompt) : null
return call.helperThreadIds.map((agentThreadId) => ({ agentThreadId, label, spawned }))
const launch = spawned ? codexChildLaunch(call.model, call.reasoningEffort) : undefined
return call.helperThreadIds.map((agentThreadId) => ({ agentThreadId, label, spawned, launch }))
}
export type CodexThreadTokenTotal = { threadId: string; totalTokens: number }
@@ -1,4 +1,5 @@
import type { NativeChatSubagentState } from '../../../../shared/native-chat/native-chat-types'
import type { CodexChildLaunch } from './codex-subagent-activity'
import {
MAX_SUBAGENT_FIELD_CHARS,
canReplaceSubagentState
@@ -20,6 +21,8 @@ export type CodexExecutionChild = {
label: string | null
/** Spawn announcements arrive on the parent's thread. */
spawnerThreadId: string | null
model: string | null
effort: string | null
execution: CodexChildExecution | null
turnOrdinals: Map<string, number>
turnCount: number
@@ -37,12 +40,15 @@ export class CodexSubagentExecutions {
register(
agentThreadId: string,
label: string | null,
spawnerThreadId?: string
spawnerThreadId?: string,
launch?: CodexChildLaunch
): CodexExecutionChild {
const child = this.child(agentThreadId)
const previousLabel = child.label
const previous = [child.label, child.model, child.effort]
// Keep the original spawner across duplicate announcement frames.
child.spawnerThreadId ??= spawnerThreadId ?? null
child.model ??= launch?.model ?? null
child.effort ??= launch?.effort ?? null
child.registered = true
// Retain one overflow unit so the journal can append its per-row truncation marker.
child.label ??=
@@ -51,7 +57,9 @@ export class CodexSubagentExecutions {
.replace(/\s+/g, ' ')
.slice(0, MAX_SUBAGENT_FIELD_CHARS + 1) || null
this.updateWorking(child)
if (previousLabel !== child.label) {
if (
[child.label, child.model, child.effort].some((value, index) => value !== previous[index])
) {
this.changed.add(agentThreadId)
}
return child
@@ -210,6 +218,8 @@ export class CodexSubagentExecutions {
observationOrder: this.children.size,
registered: false,
label: null,
model: null,
effort: null,
spawnerThreadId: null,
execution: null,
turnOrdinals: new Map(),
@@ -20,6 +20,9 @@ export type CodexCollabAgentToolCall = {
/** The receivers that are helpers: every one but those the call reports `notFound`. */
helperThreadIds: string[]
prompt: string | null
/** What a spawn asked the child to run on. */
model: string | null
reasoningEffort: string | null
/** Caller snapshots in receiverThreadIds order, excluding receivers with no snapshot. */
states: { threadId: string; status: string | null; message: string | null }[]
}
@@ -53,6 +56,8 @@ export function readCodexCollabAgentToolCall(
(threadId) => readString(readRecord(snapshots[threadId]), 'status') !== 'notFound'
),
prompt: readString(item, 'prompt'),
model: readString(item, 'model'),
reasoningEffort: readString(item, 'reasoningEffort'),
states
}
}
@@ -26,7 +26,18 @@ export function subagentJournalBody(block: NativeChatSubagentBlock): AgentJourna
label: boundSubagentLabel(block.label),
groupId: boundSubagentEntryId(block.groupId),
...(block.parentId !== undefined ? { parentId: boundSubagentEntryId(block.parentId) } : {}),
...(block.run !== undefined ? { run: boundSubagentEntryId(block.run) } : {})
...(block.run !== undefined ? { run: boundSubagentEntryId(block.run) } : {}),
...boundOptionalLabels(block),
...(block.operation !== undefined
? {
operation: {
toolName: boundSubagentLabel(block.operation.toolName),
...(block.operation.input !== undefined
? { input: boundSubagentLabel(block.operation.input) }
: {})
}
}
: {})
}
return {
kind: 'message',
@@ -35,6 +46,18 @@ export function subagentJournalBody(block: NativeChatSubagentBlock): AgentJourna
}
}
const OPTIONAL_LABELS = ['agentType', 'model', 'effort', 'lastMessage'] as const
function boundOptionalLabels(
block: NativeChatSubagentBlock
): Pick<NativeChatSubagentBlock, (typeof OPTIONAL_LABELS)[number]> {
return Object.fromEntries(
OPTIONAL_LABELS.flatMap((key) =>
block[key] === undefined ? [] : [[key, boundSubagentLabel(block[key])]]
)
)
}
export function journaledSubagent(
journal: { itemBody: (itemId: string) => AgentJournalItemBody | null },
id: string
@@ -181,7 +181,7 @@ export class StructuredAgentSessionSubagentRows {
}
}
/** Retain saved name, tokens and parent when a later run lacks them. */
/** Retain saved name, tokens, parent, role and model when a later run lacks them. */
function subagentRowBlock(
child: AgentChildWorkRowView,
id: string,
@@ -201,6 +201,12 @@ function subagentRowBlock(
: saved.run === run
? (saved.startedAt ?? child.firstObservedAt)
: child.observedAt
const agentType = child.agentType ?? saved?.agentType
const model = child.model ?? saved?.model
const effort = child.effort ?? saved?.effort
const operation = state === 'working' ? child.operation : undefined
// A new run's silence must not repeat the previous run's result.
const lastMessage = child.lastMessage ?? (saved?.run === run ? saved.lastMessage : undefined)
return {
type: 'subagent',
id,
@@ -209,6 +215,18 @@ function subagentRowBlock(
...(tokens !== undefined ? { tokens } : {}),
startedAt,
...(state !== 'working' && child.settledAt !== undefined ? { settledAt: child.settledAt } : {}),
...(agentType !== undefined ? { agentType } : {}),
...(model !== undefined ? { model } : {}),
...(effort !== undefined ? { effort } : {}),
...(operation !== undefined
? {
operation: {
toolName: operation.toolName,
...(operation.input !== undefined ? { input: operation.input } : {})
}
}
: {}),
...(lastMessage !== undefined ? { lastMessage } : {}),
groupId: saved?.groupId ?? (scope.kind === 'turn' ? scope.turnItemId : THREAD_GROUP),
...(parent !== undefined ? { parentId: parent } : {}),
run,
@@ -0,0 +1,68 @@
import { defineFixture } from '../../../component-fixtures/component-fixture'
import type { NativeChatSubagentGroupBlock } from '../../../../../shared/native-chat/native-chat-types'
import { NativeChatSubagentRun } from './NativeChatSubagentRun'
const now = Date.now()
const block: NativeChatSubagentGroupBlock = {
type: 'subagent-group',
groupId: 'turn-1',
agents: [
{
id: 'a1',
label: 'Audit auth flow',
state: 'working',
tokens: 31_200,
startedAt: now - 42_000,
agentType: 'general-purpose',
model: 'claude-opus-5-5',
effort: 'high',
operation: { toolName: 'Read', input: 'src/auth/session.ts' }
},
{
id: 'a2',
label: 'Fix flaky login test',
state: 'working',
tokens: 48_900,
startedAt: now - 63_000,
model: 'gpt-5.5',
effort: 'xhigh',
operation: { toolName: 'Bash', input: 'pnpm test login.spec.ts' }
},
{
id: 'a3',
label: 'Map token refresh',
state: 'completed',
tokens: 8_100,
startedAt: now - 80_000,
settledAt: now - 62_000,
model: 'claude-haiku-4-5-20251001',
lastMessage:
'Refresh happens in **two** places:\n- `middleware.ts` retries once on 401\n- the client refreshes on a timer 60 s before expiry'
}
]
}
function Roster() {
return (
<div className="native-chat-appearance p-4 pt-36">
<NativeChatSubagentRun block={block} open onSetOpen={() => {}} />
</div>
)
}
export const enriched = defineFixture({
width: 560,
render: () => <Roster />
})
export const hovered = defineFixture({
width: 560,
render: () => <Roster />,
interact: async (frame) => {
frame
.querySelector('[data-native-chat-subagent-entry="a1"] > div')
?.dispatchEvent(new PointerEvent('pointerover', { bubbles: true, pointerType: 'mouse' }))
await new Promise((resolve) => setTimeout(resolve, 500))
}
})
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Bot, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/class-names'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { useNow } from '@/hooks/use-now'
import {
formatSubagentTokens,
@@ -8,6 +9,12 @@ import {
subagentStateLabel
} from '../../../../../shared/native-chat/transcript/native-chat-subagent-group-header'
import { normalizeSubagentState } from '../../../../../shared/native-chat/transcript/native-chat-subagent-summary'
import {
subagentEntryDetail,
subagentEntryRunsOn,
subagentMessageText,
subagentRoleLabel
} from '../../../../../shared/native-chat/transcript/native-chat-subagent-entry-detail'
import type {
NativeChatSubagentEntry,
NativeChatSubagentGroupBlock,
@@ -16,6 +23,7 @@ import type {
import { formatNativeChatDuration } from '../../../../../shared/native-chat/transcript/native-chat-turn-status'
import { nativeChatSubagentEntryRuns } from './native-chat-subagent-sections'
import { sayNativeChatSubagentGroupTranslated as say } from './native-chat-subagent-group-words-text'
import { sayNativeChatSessionOptionTranslated as sayOption } from '../session/options/controls/native-chat-session-option-labels-text'
const STATE_DOT_CLASS: Record<NativeChatSubagentState, string> = {
working: 'bg-foreground/70',
@@ -87,47 +95,65 @@ export function NativeChatSubagentEntries({
{agents.map((agent) => {
const state = normalizeSubagentState(agent.state)
const sectionOpen = sections?.get(agent.id)
const detail = subagentEntryDetail(agent)
const runsOn = subagentEntryRunsOn(agent, sayOption)
const entry = (
<>
<StatusDot state={state} pulsing={state === 'working'} />
<span
className={cn(
'min-w-0 truncate font-sans text-sm',
state === 'idle' ? 'text-chat-foreground-faint' : 'text-chat-foreground'
)}
>
{agent.label}
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-1.5">
<StatusDot state={state} pulsing={state === 'working'} />
<span
className={cn(
'min-w-0 truncate font-sans text-sm',
state === 'idle' ? 'text-chat-foreground-faint' : 'text-chat-foreground'
)}
>
{agent.label}
</span>
{runsOn ? (
<span className="min-w-0 shrink truncate rounded-full border border-border px-1.5 font-sans text-3xs text-chat-foreground-faint">
{runsOn}
</span>
) : null}
<span className="ml-auto shrink-0 font-sans text-xs tabular-nums text-chat-foreground-faint">
{subagentStateLabel(state, 1, 1, say)}
{typeof agent.tokens === 'number'
? ` · ${formatSubagentTokens(agent.tokens)}`
: null}
</span>
</span>
<span className="ml-auto shrink-0 font-sans text-xs tabular-nums text-chat-foreground-faint">
{subagentStateLabel(state, 1, 1, say)}
{typeof agent.tokens === 'number' ? ` · ${formatSubagentTokens(agent.tokens)}` : null}
</span>
</>
{detail ? (
<span className="truncate pl-3 font-sans text-xs text-chat-foreground-faint">
{detail}
</span>
) : null}
</span>
)
return (
// Named so a reveal from the agents strip can bring this entry into view.
<li key={agent.id} data-native-chat-subagent-entry={agent.id}>
{sectionOpen === undefined ? (
<div className="flex items-center gap-1.5 py-0.5">{entry}</div>
) : (
<button
type="button"
onClick={() => onSetSectionOpen?.(agent.id, !sectionOpen)}
aria-expanded={sectionOpen}
className="group/subagent-entry flex w-full items-center gap-1.5 rounded-md py-0.5 text-left hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
{entry}
<ChevronRight
aria-hidden
className={cn(
'size-3.5 shrink-0 text-chat-foreground-faint transition-all motion-reduce:transition-none',
sectionOpen
? 'rotate-90 opacity-100'
: 'opacity-0 group-hover/subagent-entry:opacity-100'
)}
/>
</button>
)}
<SubagentEntryPreview agent={agent} state={state} detail={detail} runsOn={runsOn}>
{sectionOpen === undefined ? (
<div className="flex items-start gap-1.5 py-0.5">{entry}</div>
) : (
<button
type="button"
onClick={() => onSetSectionOpen?.(agent.id, !sectionOpen)}
aria-expanded={sectionOpen}
className="group/subagent-entry flex w-full items-start gap-1.5 rounded-md py-0.5 text-left hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
{entry}
<ChevronRight
aria-hidden
className={cn(
'mt-1 size-3.5 shrink-0 text-chat-foreground-faint transition-all motion-reduce:transition-none',
sectionOpen
? 'rotate-90 opacity-100'
: 'opacity-0 group-hover/subagent-entry:opacity-100'
)}
/>
</button>
)}
</SubagentEntryPreview>
</li>
)
})}
@@ -135,6 +161,57 @@ export function NativeChatSubagentEntries({
)
}
/** Hover preview: role, model, timing and the longer text the row truncates. */
function SubagentEntryPreview({
agent,
state,
detail,
runsOn,
children
}: {
agent: NativeChatSubagentEntry
state: NativeChatSubagentState
detail: string | null
runsOn: string | null
children: React.ReactElement
}): React.JSX.Element {
const role = [subagentRoleLabel(agent.agentType), runsOn].filter(Boolean).join(' · ')
const text =
state === 'working' && agent.operation
? detail
: subagentMessageText(agent.lastMessage ?? '') || null
return (
<HoverCard openDelay={300} closeDelay={100}>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent side="top" align="start" variant="workspace-details" className="w-72">
<div className="space-y-1">
<div className="truncate text-sm font-medium text-foreground">{agent.label}</div>
{role ? <div className="truncate text-muted-foreground">{role}</div> : null}
<div className="tabular-nums text-muted-foreground">
{subagentStateLabel(state, 1, 1, say)}
{agent.startedAt !== undefined ? (
<>
{' · '}
<SubagentElapsed
startedAt={agent.startedAt}
settledAt={agent.settledAt ?? null}
counting={state === 'working'}
/>
</>
) : null}
{typeof agent.tokens === 'number' ? ` · ${formatSubagentTokens(agent.tokens)}` : null}
</div>
{text ? (
<p className="line-clamp-6 whitespace-pre-wrap break-words pt-1 text-foreground">
{text}
</p>
) : null}
</div>
</HoverCardContent>
</HoverCard>
)
}
/** Trust journal child states: children outlive their spawning turn, and only host/provider settlement proves lost contact. */
export function NativeChatSubagentRun({
block,
@@ -10,6 +10,7 @@ import { AgentSessionContextUsageSchema } from '../agent-session-context-usage-s
import { AgentJournalThreadGoalStateSchema } from './agent-session-journal-thread-goal-schema'
import { AgentSessionFailureFactSchema } from '../failures/agent-session-failure-fact-schema'
import { knownTags, openDiscriminatedUnion } from './agent-session-journal-open-union'
import { SubagentEntry } from './agent-session-journal-subagent-schema'
import type {
AgentJournalItemBody,
AgentJournalMessageItem,
@@ -43,16 +44,6 @@ const ProviderCallId = z
.string()
.refine((value) => value.trim().length > 0, 'callId must contain a non-whitespace character')
/** Keep lifecycle strings open so newer states do not make persisted rows 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()
})
/** Skip unknown block types, but reject malformed payloads of known types. */
const Block = openDiscriminatedUnion(
z.discriminatedUnion('type', [
@@ -0,0 +1,16 @@
import * as z from 'zod'
/** Keep lifecycle strings open so newer states do not make persisted rows malformed. */
export 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(),
agentType: z.string().optional(),
model: z.string().optional(),
effort: z.string().optional(),
operation: z.object({ toolName: z.string(), input: z.string().optional() }).optional(),
lastMessage: z.string().optional()
})
@@ -180,6 +180,7 @@ function parseObservationFacts(
),
agentType: normalizeChildWorkText(request.agentType, AGENT_CHILD_WORK_LABEL_MAX_LENGTH),
model: normalizeChildWorkText(request.model, AGENT_CHILD_WORK_LABEL_MAX_LENGTH),
effort: normalizeChildWorkText(request.effort, AGENT_CHILD_WORK_LABEL_MAX_LENGTH),
totalTokens: isChildWorkTokenCount(request.totalTokens) ? request.totalTokens : undefined,
providerTiming: timing ?? undefined,
parentChildWorkId: isAgentChildWorkOwner(request.parentChildWorkId, host.childWorkId)
@@ -206,6 +207,7 @@ function mergeObservationFacts(
description: said.description ?? prior?.description,
agentType: said.agentType ?? prior?.agentType,
model: said.model ?? prior?.model,
effort: said.effort ?? prior?.effort,
residency: said.residency ?? prior?.residency,
// Cumulative, so a late or duplicate frame never shrinks it.
totalTokens:
@@ -41,6 +41,7 @@ export type AgentChildWorkObservationFields = {
description?: string
agentType?: string
model?: string
effort?: string
totalTokens?: number
providerTiming?: AgentChildWorkProviderTiming
parentChildWorkId?: AgentChildWorkId
@@ -162,6 +162,7 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput |
'description',
'agentType',
'model',
'effort',
'totalTokens',
'providerTiming',
'parentChildWorkId',
@@ -204,7 +205,8 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput |
name: parseOptionalLabel(value.name),
description: parseOptionalLabel(value.description, AGENT_CHILD_WORK_DESCRIPTION_MAX_LENGTH),
agentType: parseOptionalLabel(value.agentType),
model: parseOptionalLabel(value.model)
model: parseOptionalLabel(value.model),
effort: parseOptionalLabel(value.effort)
}
const activity = parseAgentChildWorkActivityFields(value, {
childWorkId: value.childWorkId,
@@ -268,6 +270,7 @@ export function parseAgentChildWorkInput(value: unknown): AgentChildWorkInput |
...(labels.description ? { description: labels.description } : {}),
...(labels.agentType ? { agentType: labels.agentType } : {}),
...(labels.model ? { model: labels.model } : {}),
...(labels.effort ? { effort: labels.effort } : {}),
...(typeof value.totalTokens === 'number' ? { totalTokens: value.totalTokens } : {}),
...(timing ? { providerTiming: timing } : {}),
...activity,
@@ -101,6 +101,8 @@ function liveFields(
...(child.name !== undefined ? { name: child.name } : {}),
...(child.description !== undefined ? { description: child.description } : {}),
...(child.agentType !== undefined ? { agentType: child.agentType } : {}),
...(child.model !== undefined ? { model: child.model } : {}),
...(child.effort !== undefined ? { effort: child.effort } : {}),
...(child.totalTokens !== undefined ? { totalTokens: child.totalTokens } : {}),
...(owner !== undefined ? { parentChildWorkId: owner } : {}),
residency: child.residency,
@@ -27,6 +27,8 @@ export type AgentChildWorkLiveObservation = {
name?: string
description?: string
agentType?: string
model?: string
effort?: string
totalTokens?: number
/** `null`: the operation that was open has ended. Absent: this edge says nothing about it. */
operation?: AgentChildWorkOperation | null
@@ -52,6 +54,8 @@ export type AgentChildWorkOperationEvidence = {
childId: string
/** `null`: the call it had open has ended. */
operation: AgentChildWorkOperation | null
/** The model the child's own message ran on. */
model?: string
}
/** The child's own terminal frame. `unknown` is an ending whose status the provider did not say. */
@@ -70,7 +70,7 @@ function applyStopAcknowledged(
}
}
/** A live child's current operation, and nothing else about it. */
/** A live child's current operation and the model it runs on, and nothing else about it. */
function applyOperation(ctx: ReconcileContext, edge: AgentChildWorkOperationEvidence): void {
const resolution = resolveAgentChildWorkHandle(
ctx,
@@ -93,7 +93,8 @@ function applyOperation(ctx: ReconcileContext, edge: AgentChildWorkOperationEvid
residency: record.residency,
state: record.state,
stoppable: record.stoppable,
operation: edge.operation
operation: edge.operation,
...(edge.model !== undefined ? { model: edge.model } : {})
},
edge.observedAt,
false
@@ -87,6 +87,7 @@ function decodeView(value: unknown): AgentChildWorkView | null {
const description = text(view.description)
const agentType = text(view.agentType)
const model = text(view.model)
const effort = text(view.effort)
const lastMessage = text(view.lastMessage)
const parentChildWorkId = text(view.parentChildWorkId)
const totalTokens = clock(view.totalTokens)
@@ -97,6 +98,7 @@ function decodeView(value: unknown): AgentChildWorkView | null {
...(description ? { description } : {}),
...(agentType ? { agentType } : {}),
...(model ? { model } : {}),
...(effort ? { effort } : {}),
...(lastMessage ? { lastMessage } : {}),
...(parentChildWorkId ? { parentChildWorkId } : {}),
kind: arm(KINDS, view.kind) ?? 'unknown',
@@ -150,6 +152,7 @@ function viewsEqual(a: AgentChildWorkView, b: AgentChildWorkView, clockTolerance
a.description === b.description &&
a.agentType === b.agentType &&
a.model === b.model &&
a.effort === b.effort &&
a.state === b.state &&
a.membership === b.membership &&
a.outcome === b.outcome &&
@@ -27,6 +27,7 @@ export type AgentChildWorkView = {
description?: string
agentType?: string
model?: string
effort?: string
state: AgentChildWorkState
membership: AgentChildWorkMembership
outcome?: AgentChildWorkOutcome
@@ -126,6 +127,7 @@ export function projectAgentChildWorkViews(
...(record.description !== undefined ? { description: record.description } : {}),
...(record.agentType !== undefined ? { agentType: record.agentType } : {}),
...(record.model !== undefined ? { model: record.model } : {}),
...(record.effort !== undefined ? { effort: record.effort } : {}),
state: record.state,
membership: record.membership,
...(record.outcome !== undefined ? { outcome: record.outcome } : {}),
@@ -84,6 +84,8 @@ export type AgentChildWorkInput = {
description?: string
agentType?: string
model?: string
/** Reasoning effort, in the provider's own words (`high`, `medium`). */
effort?: string
totalTokens?: number
providerTiming?: AgentChildWorkProviderTiming
/** Invocation owner (spawner or launching agent); absence identifies the session lead. */
@@ -137,8 +137,19 @@ export type NativeChatSubagentEntry = {
startedAt?: number
/** Epoch ms the entry latched terminal. */
settledAt?: number
/** The child's role (Claude's `subagent_type`), when it has one. */
agentType?: string
model?: string
/** Reasoning effort, in the provider's own words. */
effort?: string
/** The tool it is running now; only while it works. */
operation?: NativeChatSubagentOperation
/** The newest thing it said; once settled, its result. */
lastMessage?: string
}
export type NativeChatSubagentOperation = { toolName: string; input?: string }
/** Provider-neutral roster revised in place by Claude/Codex activity. */
export type NativeChatSubagentGroupBlock = {
type: 'subagent-group'
@@ -0,0 +1,66 @@
// What a subagent roster entry says under its name, shared by desktop and mobile.
import { formatAgentToolPreviewLine } from '../../agent-status/presentation/agent-tool-preview'
import { claudeListedModelLabel } from '../../claude-model-list-probe'
import {
nativeChatSessionChoiceShortLabel,
type NativeChatSessionOptionSay
} from '../input/native-chat-session-option-labels'
import type { NativeChatSubagentEntry } from '../native-chat-types'
import { normalizeSubagentState } from './native-chat-subagent-summary'
/** While it works: the tool it runs, else what it last said. Once settled: what it said last, its result. */
export function subagentEntryDetail(entry: NativeChatSubagentEntry): string | null {
const working = normalizeSubagentState(entry.state) === 'working'
const operation =
working && entry.operation
? formatAgentToolPreviewLine(
{ toolName: entry.operation.toolName, toolInput: entry.operation.input },
false
)
: ''
// Lines become separate clauses, so list items don't run together on one row.
const text =
operation ||
subagentMessageText(entry.lastMessage ?? '')
.replace(/:\s*\n\s*/g, ': ')
.replace(/\s*\n\s*/g, ' · ')
.replace(/\s+/g, ' ')
return text || null
}
/** What the subagent said, without the markdown marks a plain row or preview can't render. */
export function subagentMessageText(markdown: string): string {
return markdown
.replace(/^\s*```.*$/gm, '')
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+)/gm, '')
.replace(/[`*~]|__/g, '')
.trim()
}
/** `claude-opus-5-5` reads as `Opus 5.5`, `gpt-5.5` as `GPT-5.5`: the chat already names the provider. */
export function subagentModelLabel(model: string): string {
return claudeListedModelLabel(model.trim())
.replace(/^Claude /, '')
.replace(/^GPT (\d)/, 'GPT-$1')
}
/** `general-purpose` reads as `General purpose`; Codex's bare `default` role says nothing. */
export function subagentRoleLabel(agentType: string | undefined): string | null {
const words = agentType?.replace(/[-_]+/g, ' ').trim()
return words && words !== 'default' ? words.charAt(0).toUpperCase() + words.slice(1) : null
}
/** `Opus 5.5 · High`, from whichever the provider reported. */
export function subagentEntryRunsOn(
entry: NativeChatSubagentEntry,
say: NativeChatSessionOptionSay
): string | null {
const model = entry.model?.trim() ? subagentModelLabel(entry.model) : null
const effort = entry.effort?.trim()
? nativeChatSessionChoiceShortLabel({ value: entry.effort, label: entry.effort }, say, 'effort')
: null
const parts = [model, effort].filter((part): part is string => part !== null)
return parts.length > 0 ? parts.join(' · ') : null
}
@@ -32,7 +32,12 @@ export function subagentEntryOf(
state: block.state,
...(block.tokens !== undefined ? { tokens: block.tokens } : {}),
...(block.startedAt !== undefined ? { startedAt: block.startedAt } : {}),
...(block.settledAt !== undefined ? { settledAt: block.settledAt } : {})
...(block.settledAt !== undefined ? { settledAt: block.settledAt } : {}),
...(block.agentType !== undefined ? { agentType: block.agentType } : {}),
...(block.model !== undefined ? { model: block.model } : {}),
...(block.effort !== undefined ? { effort: block.effort } : {}),
...(block.operation !== undefined ? { operation: block.operation } : {}),
...(block.lastMessage !== undefined ? { lastMessage: block.lastMessage } : {})
}
}