feat(web): show subagent's executed model in Task/Agent card header (#1045)

* feat(web): show subagent's executed model in Task/Agent card header

Task/Agent trace cards previously gave no indication of which model a
subagent actually ran under, even though the model can differ from the
calling session's (e.g. main session on opus, subagent on haiku) and
can even change mid-run when --fallback-model kicks in under overload.

The data already reaches the frontend: each child block produced from
a subagent's own sidechain carries the model of the assistant message
it came from. Derive it in getSubagentModel() from the tool call's own
children (not the parent ToolCallBlock.model, which reflects the
calling session and would misattribute the model), collecting distinct
raw values in first-seen order and joining them the same way
aggregateResponseGroups already does for top-level multi-turn message
metadata.

Full SDK model ids (e.g. claude-sonnet-4-5-20250929) are long and not
great for a compact label, so formatSubagentModelLabel() extends this
repo's existing "friendly label, else raw fallback" idiom
(getClaudeModelLabel(model) ?? model in claudeModelOptions.ts, which
only covers the short preset aliases) with a narrow second fallback
that extracts just the name and version from the SDK id shape and
drops the date suffix (-> "Sonnet 4.5"). Anything else is left as-is.

Renders the result as a small chip in the header's existing right-side
meta cluster (next to ElapsedView/status icon), always visible without
opening the detail dialog.

* fix(web): cap subagent model badge width to avoid squeezing the card header

Addresses HAPI Bot review on #1045: formatSubagentModelLabel() returns
unrecognized model ids (Gemini, Codex, future formats) unchanged, and
those can be long. Bound the chip with max-w + truncate so it can't
push the title/status area off narrow cards, with a title attribute
so the full value is still reachable on hover.
This commit is contained in:
Junmo Kim
2026-07-16 12:33:22 +08:00
committed by GitHub
parent f8657dae3a
commit 89df3fd5f2
2 changed files with 171 additions and 3 deletions
+104 -1
View File
@@ -1,5 +1,40 @@
import { describe, expect, it } from 'vitest'
import { shouldShowInlineToolCardBody, shouldUseCompactTerminalToolCard } from '@/components/ToolCard/ToolCard'
import { formatSubagentModelLabel, getSubagentModel, shouldShowInlineToolCardBody, shouldUseCompactTerminalToolCard } from '@/components/ToolCard/ToolCard'
import type { AgentTextBlock, ChatBlock, ToolCallBlock } from '@/chat/types'
function makeAgentTextBlock(overrides: Partial<AgentTextBlock> = {}): AgentTextBlock {
return {
kind: 'agent-text',
id: 'agent-text-1',
localId: null,
createdAt: 0,
text: 'hello',
model: null,
...overrides
}
}
function makeToolCallChild(overrides: Partial<ToolCallBlock> = {}): ToolCallBlock {
return {
kind: 'tool-call',
id: 'tool-1',
localId: null,
createdAt: 0,
model: null,
tool: {
id: 'tool-1',
name: 'Bash',
state: 'completed',
input: {},
createdAt: 0,
startedAt: 0,
completedAt: 0,
description: null
},
children: [],
...overrides
}
}
describe('ToolCard terminal display mode helpers', () => {
it('treats terminal-related cards as compact by default', () => {
@@ -26,3 +61,71 @@ describe('ToolCard terminal display mode helpers', () => {
expect(shouldShowInlineToolCardBody('Read', true, 'detailed')).toBe(false)
})
})
describe('formatSubagentModelLabel', () => {
it('uses getClaudeModelLabel for a preset alias', () => {
expect(formatSubagentModelLabel('opus')).toBe('Opus')
expect(formatSubagentModelLabel('sonnet[1m]')).toBe('Sonnet 1M')
})
it('extracts name + version from a full SDK model id, dropping the date suffix', () => {
expect(formatSubagentModelLabel('claude-sonnet-4-5-20250929')).toBe('Sonnet 4.5')
expect(formatSubagentModelLabel('claude-haiku-4-5-20251001')).toBe('Haiku 4.5')
expect(formatSubagentModelLabel('claude-opus-4-8')).toBe('Opus 4.8')
})
it('falls back to the raw string for formats it does not recognize', () => {
expect(formatSubagentModelLabel('gemini-3-flash-preview')).toBe('gemini-3-flash-preview')
expect(formatSubagentModelLabel('some-completely-unfamiliar-id')).toBe('some-completely-unfamiliar-id')
})
})
describe('getSubagentModel', () => {
it('returns null when there are no children', () => {
expect(getSubagentModel([])).toBeNull()
})
it('returns null when no child carries a model', () => {
const children: ChatBlock[] = [makeAgentTextBlock({ model: null }), makeToolCallChild({ model: null })]
expect(getSubagentModel(children)).toBeNull()
})
it('returns the single formatted model when every carrying child agrees, across mixed block kinds', () => {
const children: ChatBlock[] = [
makeToolCallChild({ model: null }),
makeAgentTextBlock({ model: 'claude-haiku-4-5-20251001' }),
makeAgentTextBlock({ model: 'claude-haiku-4-5-20251001' })
]
expect(getSubagentModel(children)).toBe('Haiku 4.5')
})
it('picks up a non-null model from a ToolCallBlock child, not just AgentTextBlock', () => {
const children: ChatBlock[] = [
makeAgentTextBlock({ model: null }),
makeToolCallChild({ model: 'claude-haiku-4-5-20251001' })
]
expect(getSubagentModel(children)).toBe('Haiku 4.5')
})
it('joins distinct formatted models in first-seen order when the subagent switches mid-run (e.g. --fallback-model)', () => {
const children: ChatBlock[] = [
makeAgentTextBlock({ model: 'claude-sonnet-4-5-20250929' }),
makeAgentTextBlock({ model: 'claude-haiku-4-5-20251001' })
]
expect(getSubagentModel(children)).toBe('Sonnet 4.5, Haiku 4.5')
})
it('dedups a raw model value that repeats across turns instead of listing it twice', () => {
const children: ChatBlock[] = [
makeAgentTextBlock({ model: 'claude-sonnet-4-5-20250929' }),
makeAgentTextBlock({ model: 'claude-haiku-4-5-20251001' }),
makeAgentTextBlock({ model: 'claude-sonnet-4-5-20250929' })
]
expect(getSubagentModel(children)).toBe('Sonnet 4.5, Haiku 4.5')
})
it('ignores empty-string model values', () => {
const children: ChatBlock[] = [makeAgentTextBlock({ model: '' }), makeAgentTextBlock({ model: 'claude-haiku-4-5-20251001' })]
expect(getSubagentModel(children)).toBe('Haiku 4.5')
})
})
+67 -2
View File
@@ -1,8 +1,8 @@
import type { ToolCallBlock } from '@/chat/types'
import type { ChatBlock, ToolCallBlock } from '@/chat/types'
import type { ApiClient } from '@/api/client'
import type { SessionMetadataSummary } from '@/types/api'
import { memo, useEffect, useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { isObject, safeStringify } from '@hapi/protocol'
import { getClaudeModelLabel, isObject, safeStringify } from '@hapi/protocol'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { CodeBlock } from '@/components/CodeBlock'
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
@@ -67,6 +67,62 @@ function ElapsedView(props: { from: number; active: boolean }) {
)
}
// Matches the full SDK model ids Claude Code echoes back for subagents
// (e.g. `claude-sonnet-4-5-20250929`, `claude-opus-4-8`) — a lowercase name,
// a major/minor version, and an optional 8-digit date suffix to discard.
const CLAUDE_SDK_MODEL_ID_PATTERN = /^claude-([a-z]+)-(\d+)-(\d+)(?:-\d{8})?$/
/**
* Formats a raw model id for compact display in the subagent badge.
*
* Reuses this repo's existing "friendly label, else raw fallback" idiom
* (see `getClaudeComposerModelOptions` in claudeModelOptions.ts, which does
* `getClaudeModelLabel(model) ?? model`): `getClaudeModelLabel` only maps the
* short preset aliases ('sonnet'/'opus'/'fable'), not the full SDK model ids
* a subagent's own `model` field actually carries, so this adds a second,
* narrow fallback that extracts just the name + version from the SDK id
* shape and drops the date suffix. Anything that matches neither (Gemini,
* Codex, OpenCode, or any future format) is returned as-is — this
* deliberately doesn't try to parse formats it doesn't recognize.
*/
export function formatSubagentModelLabel(model: string): string {
const presetLabel = getClaudeModelLabel(model)
if (presetLabel) return presetLabel
const match = model.match(CLAUDE_SDK_MODEL_ID_PATTERN)
if (match) {
const [, name, major, minor] = match
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${major}.${minor}`
}
return model
}
/**
* Derives the model(s) a subagent (Task/Agent tool call) actually executed
* under, from its own child blocks — not from the parent `ToolCallBlock.model`,
* which reflects the *calling* session's model and would misattribute the
* subagent's model if used directly (see reducerTimeline.ts sidechain handling).
*
* Every child block produced by reducing the subagent's sidechain carries the
* `model` of the assistant message it came from. A subagent run can switch
* models mid-run (e.g. `--fallback-model` kicking in under overload), so this
* collects the distinct non-null/non-empty raw values in first-seen order —
* the same "seenModels" pattern `aggregateResponseGroups`
* (web/src/lib/assistant-runtime.ts) already uses for top-level multi-turn
* message metadata, reused here rather than inventing a new convention — then
* formats each for display and joins them.
*/
export function getSubagentModel(children: ChatBlock[]): string | null {
const seenModels: string[] = []
for (const child of children) {
if ('model' in child && child.model && !seenModels.includes(child.model)) {
seenModels.push(child.model)
}
}
return seenModels.length > 0 ? seenModels.map(formatSubagentModelLabel).join(', ') : null
}
function getTaskSummaryChildren(block: ToolCallBlock): { visible: ToolCallBlock[]; remaining: number } | null {
if (!isSubagentToolName(block.tool.name)) return null
@@ -279,6 +335,7 @@ function ToolCardInner(props: ToolCardProps) {
const toolTitle = presentation.title
const subtitle = presentation.subtitle ?? props.block.tool.description
const taskSummary = renderTaskSummary(props.block, props.metadata, t)
const subagentModel = isSubagentToolName(toolName) ? getSubagentModel(props.block.children) : null
const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt
const isCodexAgentCard = toolName === 'CodexAgent'
const useCompactTerminalCard = shouldUseCompactTerminalToolCard(toolName, props.terminalToolDisplayMode)
@@ -338,6 +395,14 @@ function ToolCardInner(props: ToolCardProps) {
'flex shrink-0 items-center gap-2 self-center text-[var(--app-hint)]',
subtitle ? '-translate-y-0.5' : null
)}>
{subagentModel ? (
<span
className="inline-block max-w-28 truncate rounded-full bg-[var(--app-subtle-bg)] px-1.5 py-px font-mono text-[10px] leading-tight text-[var(--app-hint)] sm:max-w-40"
title={subagentModel}
>
{subagentModel}
</span>
) : null}
<ElapsedView from={runningFrom} active={props.block.tool.state === 'running'} />
<span className={stateColor}>
<ToolStatusIcon state={props.block.tool.state} />