diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatSubagentGroup.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatSubagentGroup.tsx index 7566b142af4..114baea0532 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatSubagentGroup.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatSubagentGroup.tsx @@ -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 ( - - - - {agent.label} - - - {subagentStateLabel(state, 1, 1, say)} - {typeof agent.tokens === 'number' - ? ` · ${formatSubagentTokens(agent.tokens)}` - : null} - + + + + + {agent.label} + + + {subagentStateLabel(state, 1, 1, say)} + {typeof agent.tokens === 'number' + ? ` · ${formatSubagentTokens(agent.tokens)}` + : null} + + + {detail ? ( + + {detail} + + ) : null} ) }) @@ -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, diff --git a/src/main/claude/child-work/claude-child-work-evidence.test.ts b/src/main/claude/child-work/claude-child-work-evidence.test.ts new file mode 100644 index 00000000000..d54119516ce --- /dev/null +++ b/src/main/claude/child-work/claude-child-work-evidence.test.ts @@ -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' }) + ]) + }) +}) diff --git a/src/main/claude/child-work/claude-child-work-evidence.ts b/src/main/claude/child-work/claude-child-work-evidence.ts index 6cea1cb0f1f..25d20f281e2 100644 --- a/src/main/claude/child-work/claude-child-work-evidence.ts +++ b/src/main/claude/child-work/claude-child-work-evidence.ts @@ -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; `` marks Claude Code's own repair text. */ +function claudeChildMessageModel(frame: Record): string | undefined { + const model = frame.type === 'assistant' ? claudeRecord(frame.message)?.model : undefined + return typeof model === 'string' && model.trim() && model !== '' ? model : undefined +} + /** Waiting requires both a written journal card and a registry request still awaiting an answer. */ function claudeWaitingChildIds( session: Pick diff --git a/src/main/codex/structured/background-work/codex-background-task-frames.ts b/src/main/codex/structured/background-work/codex-background-task-frames.ts index 8615697d5a3..da52972ec97 100644 --- a/src/main/codex/structured/background-work/codex-background-task-frames.ts +++ b/src/main/codex/structured/background-work/codex-background-task-frames.ts @@ -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) diff --git a/src/main/codex/structured/background-work/codex-child-work-evidence.ts b/src/main/codex/structured/background-work/codex-child-work-evidence.ts index 4c7bba71fab..91c422715da 100644 --- a/src/main/codex/structured/background-work/codex-child-work-evidence.ts +++ b/src/main/codex/structured/background-work/codex-child-work-evidence.ts @@ -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 } : {}), diff --git a/src/main/codex/structured/background-work/codex-structured-child-work-producer.test.ts b/src/main/codex/structured/background-work/codex-structured-child-work-producer.test.ts index 24cefaa1870..d54a61cea0d 100644 --- a/src/main/codex/structured/background-work/codex-structured-child-work-producer.test.ts +++ b/src/main/codex/structured/background-work/codex-structured-child-work-producer.test.ts @@ -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() diff --git a/src/main/codex/structured/background-work/codex-subagent-activity.ts b/src/main/codex/structured/background-work/codex-subagent-activity.ts index 1098dd83f6a..4c8d3ba6d45 100644 --- a/src/main/codex/structured/background-work/codex-subagent-activity.ts +++ b/src/main/codex/structured/background-work/codex-subagent-activity.ts @@ -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 } diff --git a/src/main/codex/structured/background-work/codex-subagent-executions.ts b/src/main/codex/structured/background-work/codex-subagent-executions.ts index ceb01772b8c..1a8983e112c 100644 --- a/src/main/codex/structured/background-work/codex-subagent-executions.ts +++ b/src/main/codex/structured/background-work/codex-subagent-executions.ts @@ -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 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(), diff --git a/src/main/codex/structured/items/codex-collab-agent-tool-call.ts b/src/main/codex/structured/items/codex-collab-agent-tool-call.ts index cf20a540329..77606f2ddd9 100644 --- a/src/main/codex/structured/items/codex-collab-agent-tool-call.ts +++ b/src/main/codex/structured/items/codex-collab-agent-tool-call.ts @@ -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 } } diff --git a/src/main/native-chat/agent-session-journal/item-lifecycle/journal-subagent-item.ts b/src/main/native-chat/agent-session-journal/item-lifecycle/journal-subagent-item.ts index 11e65de4d02..aee9589d4b1 100644 --- a/src/main/native-chat/agent-session-journal/item-lifecycle/journal-subagent-item.ts +++ b/src/main/native-chat/agent-session-journal/item-lifecycle/journal-subagent-item.ts @@ -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 { + 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 diff --git a/src/main/native-chat/agent-session-wire/observations/structured-agent-session-subagent-rows.ts b/src/main/native-chat/agent-session-wire/observations/structured-agent-session-subagent-rows.ts index 387085a598b..f7b84b1cfcb 100644 --- a/src/main/native-chat/agent-session-wire/observations/structured-agent-session-subagent-rows.ts +++ b/src/main/native-chat/agent-session-wire/observations/structured-agent-session-subagent-rows.ts @@ -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, diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.fixture.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.fixture.tsx new file mode 100644 index 00000000000..fd2881cdd75 --- /dev/null +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.fixture.tsx @@ -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 ( +
+ {}} /> +
+ ) +} + +export const enriched = defineFixture({ + width: 560, + render: () => +}) + +export const hovered = defineFixture({ + width: 560, + render: () => , + 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)) + } +}) diff --git a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.tsx b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.tsx index c334523dea5..aba13b19bc6 100644 --- a/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.tsx +++ b/src/renderer/src/components/native-chat/subagents/NativeChatSubagentRun.tsx @@ -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 = { 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 = ( - <> - - - {agent.label} + + + + + {agent.label} + + {runsOn ? ( + + {runsOn} + + ) : null} + + {subagentStateLabel(state, 1, 1, say)} + {typeof agent.tokens === 'number' + ? ` · ${formatSubagentTokens(agent.tokens)}` + : null} + - - {subagentStateLabel(state, 1, 1, say)} - {typeof agent.tokens === 'number' ? ` · ${formatSubagentTokens(agent.tokens)}` : null} - - + {detail ? ( + + {detail} + + ) : null} + ) return ( // Named so a reveal from the agents strip can bring this entry into view.
  • - {sectionOpen === undefined ? ( -
    {entry}
    - ) : ( - - )} + + {sectionOpen === undefined ? ( +
    {entry}
    + ) : ( + + )} +
  • ) })} @@ -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 ( + + {children} + +
    +
    {agent.label}
    + {role ?
    {role}
    : null} +
    + {subagentStateLabel(state, 1, 1, say)} + {agent.startedAt !== undefined ? ( + <> + {' · '} + + + ) : null} + {typeof agent.tokens === 'number' ? ` · ${formatSubagentTokens(agent.tokens)}` : null} +
    + {text ? ( +

    + {text} +

    + ) : null} +
    +
    +
    + ) +} + /** Trust journal child states: children outlive their spawning turn, and only host/provider settlement proves lost contact. */ export function NativeChatSubagentRun({ block, diff --git a/src/shared/agent-session/journal/agent-session-journal-schemas.ts b/src/shared/agent-session/journal/agent-session-journal-schemas.ts index 4bb154daff9..6aba91ad651 100644 --- a/src/shared/agent-session/journal/agent-session-journal-schemas.ts +++ b/src/shared/agent-session/journal/agent-session-journal-schemas.ts @@ -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', [ diff --git a/src/shared/agent-session/journal/agent-session-journal-subagent-schema.ts b/src/shared/agent-session/journal/agent-session-journal-subagent-schema.ts new file mode 100644 index 00000000000..06f37eb251a --- /dev/null +++ b/src/shared/agent-session/journal/agent-session-journal-subagent-schema.ts @@ -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() +}) diff --git a/src/shared/agent-status/child-work/agent-status-child-work-admission-core.ts b/src/shared/agent-status/child-work/agent-status-child-work-admission-core.ts index 1f97c495586..59aa5b739aa 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-admission-core.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-admission-core.ts @@ -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: diff --git a/src/shared/agent-status/child-work/agent-status-child-work-admission.ts b/src/shared/agent-status/child-work/agent-status-child-work-admission.ts index 5a3afc5a912..f835b2d8d8a 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-admission.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-admission.ts @@ -41,6 +41,7 @@ export type AgentChildWorkObservationFields = { description?: string agentType?: string model?: string + effort?: string totalTokens?: number providerTiming?: AgentChildWorkProviderTiming parentChildWorkId?: AgentChildWorkId diff --git a/src/shared/agent-status/child-work/agent-status-child-work-codec.ts b/src/shared/agent-status/child-work/agent-status-child-work-codec.ts index a8a6bc071aa..d0f47dc7b5f 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-codec.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-codec.ts @@ -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, diff --git a/src/shared/agent-status/child-work/agent-status-child-work-evidence-admission.ts b/src/shared/agent-status/child-work/agent-status-child-work-evidence-admission.ts index e20a90c2166..2fdcf069d77 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-evidence-admission.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-evidence-admission.ts @@ -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, diff --git a/src/shared/agent-status/child-work/agent-status-child-work-evidence.ts b/src/shared/agent-status/child-work/agent-status-child-work-evidence.ts index 7abe7298c00..ad31d483a27 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-evidence.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-evidence.ts @@ -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. */ diff --git a/src/shared/agent-status/child-work/agent-status-child-work-reconciliation.ts b/src/shared/agent-status/child-work/agent-status-child-work-reconciliation.ts index 58930bd110b..111e374a481 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-reconciliation.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-reconciliation.ts @@ -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 diff --git a/src/shared/agent-status/child-work/agent-status-child-work-view-wire.ts b/src/shared/agent-status/child-work/agent-status-child-work-view-wire.ts index 0fb7ad8557c..c0bbeceda8e 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-view-wire.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-view-wire.ts @@ -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 && diff --git a/src/shared/agent-status/child-work/agent-status-child-work-view.ts b/src/shared/agent-status/child-work/agent-status-child-work-view.ts index 48b027fcc36..6e9199d90dd 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work-view.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work-view.ts @@ -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 } : {}), diff --git a/src/shared/agent-status/child-work/agent-status-child-work.ts b/src/shared/agent-status/child-work/agent-status-child-work.ts index 4199aa6e66d..f86eed8763d 100644 --- a/src/shared/agent-status/child-work/agent-status-child-work.ts +++ b/src/shared/agent-status/child-work/agent-status-child-work.ts @@ -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. */ diff --git a/src/shared/native-chat/native-chat-types.ts b/src/shared/native-chat/native-chat-types.ts index d7590bdc32a..d0f6a63962d 100644 --- a/src/shared/native-chat/native-chat-types.ts +++ b/src/shared/native-chat/native-chat-types.ts @@ -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' diff --git a/src/shared/native-chat/transcript/native-chat-subagent-entry-detail.ts b/src/shared/native-chat/transcript/native-chat-subagent-entry-detail.ts new file mode 100644 index 00000000000..0292be4605c --- /dev/null +++ b/src/shared/native-chat/transcript/native-chat-subagent-entry-detail.ts @@ -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 +} diff --git a/src/shared/native-chat/transcript/native-chat-subagent-items.ts b/src/shared/native-chat/transcript/native-chat-subagent-items.ts index dd5f357fb2b..eecea9e1771 100644 --- a/src/shared/native-chat/transcript/native-chat-subagent-items.ts +++ b/src/shared/native-chat/transcript/native-chat-subagent-items.ts @@ -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 } : {}) } }