diff --git a/web/src/components/ToolCard/ToolCard.test.ts b/web/src/components/ToolCard/ToolCard.test.ts index 910a9dda..1261b6b4 100644 --- a/web/src/components/ToolCard/ToolCard.test.ts +++ b/web/src/components/ToolCard/ToolCard.test.ts @@ -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 { + return { + kind: 'agent-text', + id: 'agent-text-1', + localId: null, + createdAt: 0, + text: 'hello', + model: null, + ...overrides + } +} + +function makeToolCallChild(overrides: Partial = {}): 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') + }) +}) diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index f3a2eeb6..7563b8f6 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -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 ? ( + + {subagentModel} + + ) : null}