mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(gemini): surface tool_call input on Gemini ACP cards (#562)
* fix(acp): derive tool_call input from kind+title fallback
Gemini 2.5 Flash and 3 Flash Preview omit rawInput entirely on
tool_call events while emitting prose (non-JSON) thoughts. Neither
the existing rawInput path nor JSON-thought hoisting fires, so the
UI shows "Input: null" alongside a perfectly readable title like
"README.md" or "ls -la /tmp".
Add a conservative fallback that maps known kinds to a minimal
input shape:
read -> { file_path: title }
execute -> { command: title }
search -> { pattern: title }
think -> null (topic-update prose has no clean arg mapping)
unknown -> null (no guessing on shapes we have not verified)
Priority: rawInput > hoisted JSON thought > kind+title derive.
Lock the new behaviour with synthetic unit tests (8 cases) and a
real-Gemini fixture suite captured from gemini-3-flash-preview
and gemini-2.5-flash via ACP stdio (4 fixtures, 33/27/13/4 raw
sessionUpdate events). The fixtures double as regression guards
against future ACP handler changes.
* fix(web): suppress duplicate subtitle when equal to tool title
Gemini ACP emits a tool_call whose title field is a human-readable
summary (often the verbatim shell command or file path). Combined with
the kind+title input fallback, an unknown-tool card ends up with the
same string in both the title and subtitle slots — e.g. title
"cat /tmp/hello.txt" over subtitle "cat /tmp/hello.txt".
Add a guard in getToolPresentation's unknown-tool branch: emit
subtitle only when it differs from toolName. The known-tool and
mcp__* branches are unaffected.
* test(acp): align Gemini fixtures to current model set
- Drop gemini-2.5-flash fixtures: the captures came from a model that
is not part of the PR's evidence model set, and re-running the
capture is gated on quota that is not currently available.
- Refresh gemini-3-flash-preview read_file / run_shell fixtures with
a fresh live capture so they reflect the latest ACP shape (e.g.
a `kind: think` tool_call expressing reasoning when the model emits
no agent_thought_chunk).
- Update fixture-replay expectations: read_file no longer requires
reasoning chunks (zero are emitted on this path) and now requires
>= 2 tool_calls (think + read).
* feat(web): promote semantic title for Gemini ACP tool cards
When the unknown-tool ToolCard would render the same string as both
the title and the subtitle, promote a semantic label to the title
slot so the card reads like a sentence:
cat /tmp/hello.txt → Run shell / cat /tmp/hello.txt
README.md → Read file / README.md
*.ts → Search / *.ts
This is a web-only ergonomic change; the underlying ACP message
shape (tool_name = title, input = derived from kind+title) is
unchanged. Builds on the dedup guard so the title-equals-subtitle
case is now handled by promotion rather than by hiding the subtitle.
* fix(acp): derive tool_call.input for kind=edit from locations[0].path
Gemini's write_file and replace tools both surface as ACP tool_call
with kind="edit" and rawInput omitted. The path lives on locations[0]
from the very first event; the title is prose like "Writing to foo.txt"
or "foo.txt: old => new", which is not safely usable as a file_path.
Extend the kind+title fallback to read locations[0].path when kind is
"edit", and synthesize { file_path } from it. Title fallback is
intentionally not used here so we never feed prose into file_path.
Lock the behaviour in with two new fixtures captured live from
gemini-3-flash-preview (write_file and replace) plus two synthetic
unit tests covering the locations-present and locations-empty paths.
* test(acp): add gemini-3.1-pro-preview fixtures for regression coverage
Captured 4 raw ACP `sessionUpdate` sequences from a live
`gemini-3.1-pro-preview` session via the same isolated hub +
runner + spawn pattern used for the existing flash captures
(read_file 31 events / run_shell 83 events / write_file 4 events /
edit_file 11 events).
The pro tier reuses the same kind/title shape as flash:
`rawInput` is omitted on every tool_call across read / execute /
edit kinds, so the kind+title (and locations[0].path for edit)
fallback is exactly what derives the modal Input. Locking these
fixtures in guards against future regressions on a second model.
The fixture-based regression test gains 4 entries (read / shell /
write / edit) mirroring the flash matrix; assertions are unchanged.
ACP handler suite: 53 -> 57 pass.
This commit is contained in:
@@ -59,7 +59,11 @@ function getTaskSummaryChildren(block: ToolCallBlock): { visible: ToolCallBlock[
|
||||
return { visible, remaining: children.length - visible.length }
|
||||
}
|
||||
|
||||
function renderTaskSummary(block: ToolCallBlock, metadata: SessionMetadataSummary | null): ReactNode | null {
|
||||
function renderTaskSummary(
|
||||
block: ToolCallBlock,
|
||||
metadata: SessionMetadataSummary | null,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): ReactNode | null {
|
||||
const summary = getTaskSummaryChildren(block)
|
||||
if (!summary) return null
|
||||
|
||||
@@ -76,7 +80,7 @@ function renderTaskSummary(block: ToolCallBlock, metadata: SessionMetadataSummar
|
||||
<TaskStateIcon state={child.tool.state} />
|
||||
</span>
|
||||
<span className="align-middle break-all">
|
||||
{formatTaskChildLabel(child, metadata)}
|
||||
{formatTaskChildLabel(child, metadata, t)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,19 +269,20 @@ function ToolCardInner(props: ToolCardProps) {
|
||||
childrenCount: props.block.children.length,
|
||||
description: props.block.tool.description,
|
||||
metadata: props.metadata
|
||||
}), [
|
||||
}, t), [
|
||||
props.block.tool.name,
|
||||
props.block.tool.input,
|
||||
props.block.tool.result,
|
||||
props.block.children.length,
|
||||
props.block.tool.description,
|
||||
props.metadata
|
||||
props.metadata,
|
||||
t
|
||||
])
|
||||
|
||||
const toolName = props.block.tool.name
|
||||
const toolTitle = presentation.title
|
||||
const subtitle = presentation.subtitle ?? props.block.tool.description
|
||||
const taskSummary = renderTaskSummary(props.block, props.metadata)
|
||||
const taskSummary = renderTaskSummary(props.block, props.metadata, t)
|
||||
const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt
|
||||
const showInline = !presentation.minimal && toolName !== 'Task'
|
||||
const CompactToolView = showInline ? getToolViewComponent(toolName) : null
|
||||
|
||||
@@ -11,6 +11,7 @@ import { truncate } from '@/lib/toolInputUtils'
|
||||
export function formatTaskChildLabel(
|
||||
child: ToolCallBlock,
|
||||
metadata: SessionMetadataSummary | null,
|
||||
t?: (key: string, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const presentation = getToolPresentation({
|
||||
toolName: child.tool.name,
|
||||
@@ -19,7 +20,7 @@ export function formatTaskChildLabel(
|
||||
childrenCount: child.children.length,
|
||||
description: child.tool.description,
|
||||
metadata,
|
||||
})
|
||||
}, t)
|
||||
|
||||
if (presentation.subtitle) {
|
||||
return truncate(`${presentation.title}: ${presentation.subtitle}`, 140)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getToolPresentation } from '@/components/ToolCard/knownTools'
|
||||
|
||||
describe('getToolPresentation — unknown tool semantic title + subtitle dedup', () => {
|
||||
it('promotes semantic title "Run shell" when toolName equals input.command (Gemini ACP case)', () => {
|
||||
const presentation = getToolPresentation({
|
||||
toolName: 'cat /tmp/hello.txt',
|
||||
input: { command: 'cat /tmp/hello.txt' },
|
||||
result: null,
|
||||
childrenCount: 0,
|
||||
description: null,
|
||||
metadata: null,
|
||||
})
|
||||
|
||||
expect(presentation.title).toBe('Run shell')
|
||||
expect(presentation.subtitle).toBe('cat /tmp/hello.txt')
|
||||
})
|
||||
|
||||
it('promotes semantic title "Read file" when toolName equals input.file_path', () => {
|
||||
const presentation = getToolPresentation({
|
||||
toolName: 'README.md',
|
||||
input: { file_path: 'README.md' },
|
||||
result: null,
|
||||
childrenCount: 0,
|
||||
description: null,
|
||||
metadata: null,
|
||||
})
|
||||
|
||||
expect(presentation.title).toBe('Read file')
|
||||
expect(presentation.subtitle).toBe('README.md')
|
||||
})
|
||||
|
||||
it('promotes semantic title "Search" when toolName equals input.pattern', () => {
|
||||
const presentation = getToolPresentation({
|
||||
toolName: '*.ts',
|
||||
input: { pattern: '*.ts' },
|
||||
result: null,
|
||||
childrenCount: 0,
|
||||
description: null,
|
||||
metadata: null,
|
||||
})
|
||||
|
||||
expect(presentation.title).toBe('Search')
|
||||
expect(presentation.subtitle).toBe('*.ts')
|
||||
})
|
||||
|
||||
it('keeps the original toolName when subtitle differs (no promotion needed)', () => {
|
||||
const presentation = getToolPresentation({
|
||||
toolName: 'run_shell_command',
|
||||
input: { command: 'ls -la /tmp' },
|
||||
result: null,
|
||||
childrenCount: 0,
|
||||
description: null,
|
||||
metadata: null,
|
||||
})
|
||||
|
||||
expect(presentation.title).toBe('run_shell_command')
|
||||
expect(presentation.subtitle).toBe('ls -la /tmp')
|
||||
})
|
||||
|
||||
it('returns null subtitle when no recognized input field is present', () => {
|
||||
const presentation = getToolPresentation({
|
||||
toolName: 'mystery_tool',
|
||||
input: { foo: 'bar' },
|
||||
result: null,
|
||||
childrenCount: 0,
|
||||
description: null,
|
||||
metadata: null,
|
||||
})
|
||||
|
||||
expect(presentation.title).toBe('mystery_tool')
|
||||
expect(presentation.subtitle).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -446,7 +446,12 @@ export const knownTools: Record<string, {
|
||||
}
|
||||
}
|
||||
|
||||
export function getToolPresentation(opts: Omit<ToolOpts, 'metadata'> & { metadata: SessionMetadataSummary | null }): ToolPresentation {
|
||||
type Translator = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
export function getToolPresentation(
|
||||
opts: Omit<ToolOpts, 'metadata'> & { metadata: SessionMetadataSummary | null },
|
||||
t?: Translator
|
||||
): ToolPresentation {
|
||||
if (opts.toolName.startsWith('mcp__')) {
|
||||
return {
|
||||
icon: <PuzzleIcon className={DEFAULT_ICON_CLASS} />,
|
||||
@@ -475,10 +480,25 @@ export function getToolPresentation(opts: Omit<ToolOpts, 'metadata'> & { metadat
|
||||
|
||||
const subtitle = filePath ?? command ?? pattern ?? url ?? query
|
||||
|
||||
// Some ACP agents emit `tool_call.title` as a verbatim argument (the shell
|
||||
// command or the file path itself). When it equals the input field,
|
||||
// promote a semantic label to the title slot and let the verbatim arg
|
||||
// become the subtitle, so the card reads like a sentence instead of
|
||||
// showing the same string twice. Labels are translated when a Translator
|
||||
// is supplied; tests and call sites without i18n fall back to English.
|
||||
let title = opts.toolName
|
||||
if (subtitle && subtitle === title) {
|
||||
if (filePath) title = t ? t('tool.semanticTitle.readFile') : 'Read file'
|
||||
else if (command) title = t ? t('tool.semanticTitle.runShell') : 'Run shell'
|
||||
else if (pattern) title = t ? t('tool.semanticTitle.search') : 'Search'
|
||||
else if (url) title = t ? t('tool.semanticTitle.openUrl') : 'Open URL'
|
||||
else if (query) title = t ? t('tool.semanticTitle.query') : 'Query'
|
||||
}
|
||||
|
||||
return {
|
||||
icon: <WrenchIcon className={DEFAULT_ICON_CLASS} />,
|
||||
title: opts.toolName,
|
||||
subtitle: subtitle ? truncate(subtitle, 80) : null,
|
||||
title,
|
||||
subtitle: subtitle && subtitle !== title ? truncate(subtitle, 80) : null,
|
||||
minimal: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ type TraceChildRowProps = {
|
||||
|
||||
function TraceChildRow({ child, metadata, expanded, onToggle }: TraceChildRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const label = formatTaskChildLabel(child, metadata)
|
||||
const label = formatTaskChildLabel(child, metadata, t)
|
||||
const FullInputView = getToolFullViewComponent(child.tool.name)
|
||||
const ResultView = getToolResultViewComponent(child.tool.name)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user