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:
Junmo Kim
2026-05-05 18:20:54 +08:00
committed by GitHub
parent 02bc206f8a
commit 136badb86e
17 changed files with 2014 additions and 19 deletions
+10 -5
View File
@@ -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
+2 -1
View File
@@ -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()
})
})
+23 -3
View File
@@ -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
}
}
+1 -1
View File
@@ -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)