mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user