diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts index fbb1a963..7cb268e1 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts @@ -444,6 +444,7 @@ describe('AcpMessageHandler', () => { ); expect(toolCall).toBeDefined(); expect(toolCall!.input).toEqual({ command: 'df -hT' }); + expect(toolCall).toMatchObject({ title: 'df -hT', kind: 'execute' }); }); it('strips "Shell: " prefix from title when deriving execute input (Kimi)', () => { diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.ts b/cli/src/agent/backends/acp/AcpMessageHandler.ts index 06a7616f..69240669 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.ts @@ -704,7 +704,9 @@ export class AcpMessageHandler { id: toolCallId, name, input, - status + status, + ...(asString(update.title) ? { title: asString(update.title)! } : {}), + ...(asString(update.kind) ? { kind: asString(update.kind)! } : {}) }); } @@ -714,6 +716,10 @@ export class AcpMessageHandler { const status = normalizeStatus(update.status); const existing = this.toolCalls.get(toolCallId); + const presentation = { + ...(asString(update.title) ? { title: asString(update.title)! } : {}), + ...(asString(update.kind) ? { kind: asString(update.kind)! } : {}) + }; if (isUsableRawInput(update.rawInput)) { const derivedName = deriveToolNameFromUpdate(update); @@ -725,7 +731,8 @@ export class AcpMessageHandler { id: toolCallId, name, input, - status + status, + ...presentation }); } else if (existing) { // Enrich existing.input from update's kind+title when initial tool_call @@ -758,7 +765,8 @@ export class AcpMessageHandler { id: toolCallId, name, input, - status + status, + ...presentation }); } } @@ -784,7 +792,8 @@ export class AcpMessageHandler { id: toolCallId, name: hoisted.name, input: hoisted.input, - status + status, + ...presentation }); } } diff --git a/cli/src/agent/messageConverter.test.ts b/cli/src/agent/messageConverter.test.ts index 86ca29cc..496bd8ce 100644 --- a/cli/src/agent/messageConverter.test.ts +++ b/cli/src/agent/messageConverter.test.ts @@ -20,6 +20,23 @@ describe('convertAgentMessage', () => { }); }); + it('preserves ACP native presentation metadata', () => { + const converted = convertAgentMessage({ + type: 'tool_call', + id: 'call-native', + name: 'Bash', + input: { command: 'free -h' }, + status: 'in_progress', + title: 'Shell: free -h', + kind: 'execute' + }); + + expect(converted).toMatchObject({ + nativeTitle: 'Shell: free -h', + nativeKind: 'execute' + }); + }); + it('marks failed tool results as error', () => { const converted = convertAgentMessage({ type: 'tool_result', diff --git a/cli/src/agent/messageConverter.ts b/cli/src/agent/messageConverter.ts index 4e1dbed5..0425ca19 100644 --- a/cli/src/agent/messageConverter.ts +++ b/cli/src/agent/messageConverter.ts @@ -24,6 +24,8 @@ export type CodexMessage = callId: string; input: unknown; status?: 'pending' | 'in_progress' | 'completed' | 'failed'; + nativeTitle?: string; + nativeKind?: string; } | { type: 'tool-call-result'; @@ -64,7 +66,9 @@ export function convertAgentMessage(message: AgentMessage): CodexMessage | null name: message.name, callId: message.id, input: message.input, - status: message.status + status: message.status, + ...(message.title ? { nativeTitle: message.title } : {}), + ...(message.kind ? { nativeKind: message.kind } : {}) }; case 'tool_result': return { diff --git a/cli/src/agent/types.ts b/cli/src/agent/types.ts index 5c8df7a7..3140b542 100644 --- a/cli/src/agent/types.ts +++ b/cli/src/agent/types.ts @@ -31,7 +31,15 @@ export type PlanItem = { export type AgentMessage = | { type: 'text'; text: string } | { type: 'reasoning'; text: string; id?: string; live?: boolean } - | { type: 'tool_call'; id: string; name: string; input: unknown; status: 'pending' | 'in_progress' | 'completed' | 'failed' } + | { + type: 'tool_call'; + id: string; + name: string; + input: unknown; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + title?: string; + kind?: string; + } | { type: 'tool_result'; id: string; output: unknown; status: 'completed' | 'failed' } | { type: 'usage'; diff --git a/cli/src/opencode/opencodeLocalLauncher.ts b/cli/src/opencode/opencodeLocalLauncher.ts index 0f49ae3b..ad32e8b7 100644 --- a/cli/src/opencode/opencodeLocalLauncher.ts +++ b/cli/src/opencode/opencodeLocalLauncher.ts @@ -335,7 +335,8 @@ export async function opencodeLocalLauncher( type: 'tool-call', name: toolCall.name, callId: toolCall.callId, - input: toolCall.input + input: toolCall.input, + ...(toolCall.title ? { nativeTitle: toolCall.title } : {}) }); } @@ -349,7 +350,8 @@ export async function opencodeLocalLauncher( type: 'tool-call', name: toolCall.name, callId: toolCall.callId, - input: toolCall.input + input: toolCall.input, + ...(toolCall.title ? { nativeTitle: toolCall.title } : {}) }); } sentToolResults.add(toolResult.callId); @@ -418,7 +420,8 @@ export async function opencodeLocalLauncher( type: 'tool-call', name, callId, - input: toolInput + input: toolInput, + ...(getString(tool.title ?? record.title) ? { nativeTitle: getString(tool.title ?? record.title) } : {}) }); return; } @@ -431,7 +434,8 @@ export async function opencodeLocalLauncher( type: 'tool-call', name, callId, - input: toolInput + input: toolInput, + ...(getString(tool.title ?? record.title) ? { nativeTitle: getString(tool.title ?? record.title) } : {}) }); } sentToolResults.add(callId); diff --git a/cli/src/opencode/utils/opencodeLocalToolParse.test.ts b/cli/src/opencode/utils/opencodeLocalToolParse.test.ts index cbfe167d..33121389 100644 --- a/cli/src/opencode/utils/opencodeLocalToolParse.test.ts +++ b/cli/src/opencode/utils/opencodeLocalToolParse.test.ts @@ -28,6 +28,19 @@ function collectMessages(parts: unknown[]): Array<{ type: string; name?: string; } describe('OpenCode local tool part parsing', () => { + it('preserves the native state title', () => { + expect(parseToolCall({ + type: 'tool', + tool: 'bash', + callID: 'call-title', + state: { + status: 'running', + input: { command: 'bun test' }, + title: 'Run project tests' + } + })).toMatchObject({ title: 'Run project tests' }); + }); + it('does not emit tool-call on pending with empty input; emits on running with real args', () => { const callId = 'call-6049b4cf-0272-4651-be9a-402c3a40c933-0'; const messages = collectMessages([ diff --git a/cli/src/opencode/utils/opencodeLocalToolParse.ts b/cli/src/opencode/utils/opencodeLocalToolParse.ts index fc7daf8f..55d4c023 100644 --- a/cli/src/opencode/utils/opencodeLocalToolParse.ts +++ b/cli/src/opencode/utils/opencodeLocalToolParse.ts @@ -4,6 +4,7 @@ export type ParsedToolCall = { callId: string; name: string; input: unknown; + title?: string; }; export type ParsedToolResult = { @@ -77,10 +78,12 @@ export function parseToolCall(part: unknown): ParsedToolCall | null { return null; } const input = parseMaybeJson(state.input ?? state.raw ?? record.input ?? record.args ?? record.arguments); - return { callId, name, input }; + const title = getString(state.title); + return { callId, name, input, ...(title ? { title } : {}) }; } const input = parseMaybeJson(record.input ?? record.args ?? record.arguments ?? record.raw); - return { callId, name, input }; + const title = getString(record.title); + return { callId, name, input, ...(title ? { title } : {}) }; } export function parseToolResult(part: unknown): ParsedToolResult | null { diff --git a/web/src/chat/normalizeAgent.test.ts b/web/src/chat/normalizeAgent.test.ts index 5251247b..41bdf31f 100644 --- a/web/src/chat/normalizeAgent.test.ts +++ b/web/src/chat/normalizeAgent.test.ts @@ -2,6 +2,29 @@ import { describe, expect, it } from 'vitest' import { normalizeAgentRecord } from '@/chat/normalizeAgent' describe('normalizeAgentRecord — agentTimestamp exposure', () => { + it('preserves normalized native tool presentation metadata', () => { + const normalized = normalizeAgentRecord('msg-native', null, 1, { + type: 'codex', + data: { + type: 'tool-call', + callId: 'call-native', + name: 'Bash', + input: { command: 'bun test' }, + nativeTitle: 'Run project tests', + nativeKind: 'execute' + } + }) + + expect(normalized).toMatchObject({ + role: 'agent', + content: [{ + type: 'tool-call', + nativeTitle: 'Run project tests', + nativeKind: 'execute' + }] + }) + }) + it('parses data.timestamp into agentTimestamp for an assistant tool_use record', () => { const normalized = normalizeAgentRecord('msg-1', null, 1_783_953_478_235, { type: 'output', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 200e689c..ee1802fc 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -738,7 +738,9 @@ export function normalizeAgentRecord( id: data.callId, name: asString(data.name) ?? 'unknown', input: data.input, - description: null, + description: asString(data.description), + nativeTitle: asString(data.nativeTitle ?? data.title), + nativeKind: asString(data.nativeKind ?? data.kind), uuid, parentUUID: null }], diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index 84c6b095..1af70f01 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -880,6 +880,8 @@ export function reduceTimeline( name: c.name, input: c.input, description: c.description, + nativeTitle: c.nativeTitle, + nativeKind: c.nativeKind, permission, agentTimestamp: msg.agentTimestamp }) diff --git a/web/src/chat/reducerTools.ts b/web/src/chat/reducerTools.ts index d87009a8..609d624e 100644 --- a/web/src/chat/reducerTools.ts +++ b/web/src/chat/reducerTools.ts @@ -65,6 +65,8 @@ export function ensureToolBlock( name: string input: unknown description: string | null + nativeTitle?: string | null + nativeKind?: string | null permission?: ToolPermission /** Claude entry execution-machine timestamp for the tool_use, if known (see `ChatToolCall.execStartedAt`). */ agentTimestamp?: number | null @@ -99,6 +101,12 @@ export function ensureToolBlock( if (seed.description !== null) { existing.tool = { ...existing.tool, description: seed.description } } + if (seed.nativeTitle != null) { + existing.tool = { ...existing.tool, nativeTitle: seed.nativeTitle } + } + if (seed.nativeKind != null) { + existing.tool = { ...existing.tool, nativeKind: seed.nativeKind } + } // The first call (tool_use) records when the tool was invoked. The // second call (tool_result) carries the result message's invokedAt, // which is when the result was processed — not when the tool was @@ -139,6 +147,8 @@ export function ensureToolBlock( execStartedAt: initialState === 'running' ? (seed.agentTimestamp ?? null) : null, execCompletedAt: null, description: seed.description, + nativeTitle: seed.nativeTitle ?? null, + nativeKind: seed.nativeKind ?? null, permission: seed.permission } diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 8b003cb0..0c0130e6 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -45,6 +45,8 @@ export type ToolUse = { name: string input: unknown description: string | null + nativeTitle?: string | null + nativeKind?: string | null uuid: string parentUUID: string | null } @@ -175,6 +177,8 @@ export type ChatToolCall = { execStartedAt: number | null execCompletedAt: number | null description: string | null + nativeTitle?: string | null + nativeKind?: string | null result?: unknown permission?: ToolPermission } diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index 7563b8f6..e7277960 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -319,13 +319,14 @@ function ToolCardInner(props: ToolCardProps) { input: props.block.tool.input, result: props.block.tool.result, childrenCount: props.block.children.length, - description: props.block.tool.description, + description: props.block.tool.nativeTitle ?? 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.nativeTitle, props.block.tool.description, props.metadata, t diff --git a/web/src/components/ToolCard/ToolGroupCard.tsx b/web/src/components/ToolCard/ToolGroupCard.tsx index e886b61d..d3818887 100644 --- a/web/src/components/ToolCard/ToolGroupCard.tsx +++ b/web/src/components/ToolCard/ToolGroupCard.tsx @@ -74,7 +74,7 @@ function RowLabel(props: { block: ToolCallBlock; metadata: SessionMetadataSummar input: props.block.tool.input, result: props.block.tool.result, childrenCount: props.block.children.length, - description: props.block.tool.description, + description: props.block.tool.nativeTitle ?? props.block.tool.description, metadata: props.metadata }, t), [props.block, props.metadata, t]) @@ -213,7 +213,7 @@ export function ToolGroupCard(props: { input: selectedTool.tool.input, result: selectedTool.tool.result, childrenCount: selectedTool.children.length, - description: selectedTool.tool.description, + description: selectedTool.tool.nativeTitle ?? selectedTool.tool.description, metadata: props.metadata }, t) }, [selectedTool, props.metadata, t]) diff --git a/web/src/components/ToolCard/helpers.tsx b/web/src/components/ToolCard/helpers.tsx index 3ad8e893..6302b355 100644 --- a/web/src/components/ToolCard/helpers.tsx +++ b/web/src/components/ToolCard/helpers.tsx @@ -18,7 +18,7 @@ export function formatTaskChildLabel( input: child.tool.input, result: child.tool.result, childrenCount: child.children.length, - description: child.tool.description, + description: child.tool.nativeTitle ?? child.tool.description, metadata, }, t) diff --git a/web/src/components/ToolCard/knownTools.test.tsx b/web/src/components/ToolCard/knownTools.test.tsx index 8954403e..b7165a40 100644 --- a/web/src/components/ToolCard/knownTools.test.tsx +++ b/web/src/components/ToolCard/knownTools.test.tsx @@ -216,6 +216,22 @@ describe('getToolPresentation — Codex agent tools', () => { }) }) +describe('getToolPresentation — native titles', () => { + it('uses a preserved native title for unknown lowercase tools', () => { + const presentation = getToolPresentation({ + toolName: 'bash', + input: { command: 'bun test' }, + result: null, + childrenCount: 0, + description: 'Run project tests', + metadata: null, + }) + + expect(presentation.title).toBe('Run project tests') + expect(presentation.subtitle).toBe('bun test') + }) +}) + describe('getToolPresentation — request_user_input', () => { it('uses the question header instead of exposing its protocol id', () => { const presentation = getToolPresentation({ diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 99bafe25..80968e87 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -574,7 +574,7 @@ export function getToolPresentation( // 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 + let title = opts.description ?? 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'