diff --git a/packages/client/workbench/src/mock/data/showcase.ts b/packages/client/workbench/src/mock/data/showcase.ts index 2594af43f..452d9e439 100644 --- a/packages/client/workbench/src/mock/data/showcase.ts +++ b/packages/client/workbench/src/mock/data/showcase.ts @@ -384,21 +384,48 @@ export function createShowcaseToolBursts(terminalId = SHOWCASE_TERMINAL_ID): Sho title: 'Search chat renderers', kind: 'search', status: 'completed', - content: [], + content: [ + { + type: 'content', + content: textBlock( + 'packages/presentation/ui/src/chat/conversation-view.tsx\npackages/client/core/src/conversation.ts', + ), + }, + ], rawInput: { query: 'permission-request|tool-call|plan', glob: '**/*.{ts,tsx}', cwd: '/mock/linkcode', }, + // Claude's real Grep envelope: scalar counts, no matches array. + rawOutput: { mode: 'files_with_matches', numFiles: 2, numMatches: 12 }, + }, + { + toolCallId: 'mock-tool-toolsearch-select', + title: 'ToolSearch', + kind: 'search', + status: 'completed', + content: [ + { + type: 'content', + content: textBlock('WebSearch\nmcp__linear__get_issue\nmcp__linear__save_issue'), + }, + ], + rawInput: { query: 'select:WebSearch,mcp__linear__get_issue,mcp__linear__save_issue' }, rawOutput: { - matches: [ - 'packages/presentation/ui/src/chat/conversation-view.tsx', - 'packages/client/core/src/conversation.ts', - ], - files: 2, - elapsedMs: 17, + query: 'select:WebSearch,mcp__linear__get_issue,mcp__linear__save_issue', + total_deferred_tools: 110, }, }, + { + toolCallId: 'mock-tool-toolsearch-empty', + title: 'ToolSearch', + kind: 'search', + status: 'completed', + content: [{ type: 'content', content: textBlock('No matching deferred tools found') }], + rawInput: { query: '+jupyter notebook edit', max_results: 5 }, + rawOutput: { query: '+jupyter notebook edit', total_deferred_tools: 110 }, + }, ], files: [ { diff --git a/packages/host/agent-adapter/src/__tests__/claude-code-compaction.test.ts b/packages/host/agent-adapter/src/__tests__/claude-code-compaction.test.ts index 768643b0b..b941ee802 100644 --- a/packages/host/agent-adapter/src/__tests__/claude-code-compaction.test.ts +++ b/packages/host/agent-adapter/src/__tests__/claude-code-compaction.test.ts @@ -308,6 +308,7 @@ describe('ClaudeCodeAdapter readHistory transcript supplement', () => { records: new Map(), droppedRows: [], parentUuidByUuid: new Map(), + toolUses: new Map(), toolUseResults: new Map(), toolUsePatches: new Map(), ...partial, @@ -320,6 +321,7 @@ describe('ClaudeCodeAdapter readHistory transcript supplement', () => { constructor( private readonly messages: SessionMessage[], private readonly supplement: ClaudeTranscriptSupplement, + private readonly paginate = false, ) { super(); } @@ -327,7 +329,12 @@ describe('ClaudeCodeAdapter readHistory transcript supplement', () => { protected override loadSdk(): Promise { return Promise.resolve({ getSessionInfo: () => Promise.resolve(undefined), - getSessionMessages: () => Promise.resolve(this.messages), + getSessionMessages: (_sessionId: string, options?: { limit?: number; offset?: number }) => { + if (!this.paginate) return Promise.resolve(this.messages); + const offset = options?.offset ?? 0; + const end = options?.limit === undefined ? undefined : offset + options.limit; + return Promise.resolve(this.messages.slice(offset, end)); + }, listSubagents: () => Promise.resolve([]), } as T); } @@ -431,7 +438,9 @@ describe('ClaudeCodeAdapter readHistory transcript supplement', () => { } }); - it('replays an Edit settle with the recovered patch instead of the announce fragment', async () => { + it('recovers a page-start Edit settle with the patch replacing its announce fragment', async () => { + const input = { file_path: 'src/a.ts', old_string: 'a', new_string: 'b' }; + const parentToolCallId = 'toolu_parent'; const patch = { type: 'diff' as const, change: 'modify' as const, @@ -440,29 +449,135 @@ describe('ClaudeCodeAdapter readHistory transcript supplement', () => { newText: 'b', patch: { format: 'git_patch' as const, text: '@@ -12,3 +12,3 @@\n ctx\n-a\n+b' }, }; - const adapter = new HistoryClaude( - [ - row('assistant', 'a0', [ + const announce = row('assistant', 'a0', [ + { type: 'tool_use', id: 'toolu_edit', name: 'Edit', input }, + ]); + const settle = row('user', 'u0', [ + { type: 'tool_result', tool_use_id: 'toolu_edit', content: 'updated' }, + ]); + const toolUseResult = { + filePath: 'src/a.ts', + oldString: 'a', + newString: 'b', + userModified: false, + replaceAll: true, + structuredPatch: [ + { oldStart: 12, oldLines: 3, newStart: 12, newLines: 3, lines: [' ctx', '-a', '+b'] }, + ], + }; + const supplement = buildClaudeTranscriptSupplement([ + JSON.stringify({ + type: 'assistant', + uuid: announce.uuid, + sessionId: SESSION, + parent_tool_use_id: parentToolCallId, + message: announce.message, + }), + JSON.stringify({ + type: 'user', + uuid: settle.uuid, + sessionId: SESSION, + message: settle.message, + toolUseResult, + }), + ]); + const adapter = new HistoryClaude([announce, settle], supplement, true); + + const first = await adapter.readHistory({ historyId: asHistoryId(SESSION), limit: 1 }); + expect(first.cursor).toBe('1'); + if (!first.cursor) throw new Error('expected a second history page'); + const second = await adapter.readHistory({ + historyId: asHistoryId(SESSION), + limit: 1, + cursor: first.cursor, + }); + expect(second.events).toHaveLength(1); + const event = second.events[0].event; + expect(event.type).toBe('tool-call'); + if (event.type === 'tool-call') { + expect(event.toolCall).toEqual({ + toolCallId: 'toolu_edit', + parentToolCallId, + title: 'Edit', + kind: 'edit', + status: 'completed', + content: [patch, { type: 'content', content: { type: 'text', text: 'updated' } }], + rawInput: input, + rawOutput: { + filePath: 'src/a.ts', + oldString: 'a', + newString: 'b', + userModified: false, + replaceAll: true, + }, + }); + } + }); + + it('recovers a ToolSearch snapshot when a cursor page begins with its settle', async () => { + const input = { query: 'select:WebSearch' }; + const parentToolCallId = 'toolu_parent'; + const announce = row('assistant', 'a0', [ + { type: 'tool_use', id: 'toolu_search', name: 'ToolSearch', input }, + ]); + const settle = row('user', 'u0', [ + { + type: 'tool_result', + tool_use_id: 'toolu_search', + content: [{ type: 'tool_reference', tool_name: 'WebSearch' }], + }, + ]); + const supplement = buildClaudeTranscriptSupplement([ + JSON.stringify({ + type: 'assistant', + uuid: announce.uuid, + sessionId: SESSION, + parent_tool_use_id: parentToolCallId, + message: announce.message, + }), + JSON.stringify({ + type: 'user', + uuid: settle.uuid, + sessionId: SESSION, + message: settle.message, + }), + ]); + const adapter = new HistoryClaude([announce, settle], supplement, true); + + const first = await adapter.readHistory({ historyId: asHistoryId(SESSION), limit: 1 }); + expect(first.events.map((event) => `${event.event.type}:${event.itemId ?? ''}`)).toEqual([ + 'tool-call:toolu_search', + ]); + expect(first.cursor).toBe('1'); + if (!first.cursor) throw new Error('expected a second history page'); + + const second = await adapter.readHistory({ + historyId: asHistoryId(SESSION), + limit: 1, + cursor: first.cursor, + }); + expect(second.cursor).toBeUndefined(); + expect(second.events.map((event) => `${event.event.type}:${event.itemId ?? ''}`)).toEqual([ + 'tool-call:toolu_search', + ]); + const event = second.events[0].event; + expect(event.type).toBe('tool-call'); + if (event.type === 'tool-call') { + expect(event.toolCall).toEqual({ + toolCallId: 'toolu_search', + parentToolCallId, + title: 'ToolSearch', + kind: 'search', + status: 'completed', + content: [ { - type: 'tool_use', - id: 'toolu_1', - name: 'Edit', - input: { file_path: 'src/a.ts', old_string: 'a', new_string: 'b' }, + type: 'content', + content: { type: 'text', text: 'WebSearch' }, }, - ]), - row('user', 'u0', [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'updated' }]), - ], - supplementOf({ toolUsePatches: new Map([['toolu_1', [patch]]]) }), - ); - const result = await adapter.readHistory({ historyId: asHistoryId(SESSION) }); - const settle = result.events.at(-1)?.event; - expect(settle?.type).toBe('tool-call'); - if (settle?.type === 'tool-call') { - // Superseded, not stacked — one diff, matching what the live settle emits. - expect(settle.toolCall.content).toEqual([ - patch, - { type: 'content', content: { type: 'text', text: 'updated' } }, - ]); + ], + rawInput: input, + rawOutput: [{ type: 'tool_reference', tool_name: 'WebSearch' }], + }); } }); diff --git a/packages/host/agent-adapter/src/__tests__/codex-history.test.ts b/packages/host/agent-adapter/src/__tests__/codex-history.test.ts index 8b1533647..cf5ae88a0 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-history.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-history.test.ts @@ -417,6 +417,197 @@ describe('mapCodexHistoryEvents', () => { ]); }); + it("replays MCP callable names under the live adapter's mcp slug", () => { + // Callable namespaces are only a fallback and cannot distinguish a delimiter from a server suffix. + const events = mapCodexHistoryEvents(HID, [ + responseItem({ + type: 'function_call', + namespace: 'mcp__node_repl', + name: 'js', + arguments: '{"code":"1 + 1"}', + call_id: 'call_mcp1', + }), + responseItem({ type: 'function_call_output', call_id: 'call_mcp1', output: '2' }), + responseItem({ + type: 'function_call', + namespace: 'mcp__computer_use__', + name: 'click', + arguments: '{}', + call_id: 'call_mcp2', + }), + responseItem({ + type: 'function_call', + namespace: 'mcp__codex_apps__linear', + name: '_save_comment', + arguments: '{}', + call_id: 'call_mcp3', + }), + responseItem({ + type: 'function_call', + namespace: 'mcp__repo__prod', + name: 'search_files', + arguments: '{}', + call_id: 'call_mcp4', + }), + responseItem({ + type: 'function_call', + namespace: 'collaboration', + name: 'send_message', + arguments: '{}', + call_id: 'call_builtin', + }), + ]); + + const tools = toolCalls(events); + expect(tools.map((tool) => [tool.toolCallId, tool.title])).toEqual([ + ['call_mcp1', 'mcp__node_repl__js'], + ['call_mcp1', 'mcp__node_repl__js'], + ['call_mcp2', 'computer_use__.click'], + ['call_mcp3', 'mcp__linear__save_comment'], + // A `__`-bearing server name would mis-split the slug — the raw dotted title survives. + ['call_mcp4', 'repo__prod.search_files'], + ['call_builtin', 'send_message'], + ]); + expect(tools[0].kind).toBe('other'); + expect(tools[1]).toMatchObject({ status: 'completed', kind: 'other' }); + }); + + it('prefers persisted raw MCP identity over lossy callable names', () => { + const longToolName = + 'extremely-lengthy-function-name-that-absolutely-surpasses-all-reasonable-limits'; + const events = mapCodexHistoryEvents(HID, [ + responseItem({ + type: 'function_call', + namespace: 'mcp__acme__', + name: 'lookup', + arguments: '{}', + call_id: 'call_trailing_delimiter', + }), + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call_trailing_delimiter', + invocation: { server: 'acme__', tool: 'lookup', arguments: {} }, + }, + }, + responseItem({ + type: 'function_call_output', + call_id: 'call_trailing_delimiter', + output: 'found', + }), + responseItem({ + type: 'function_call', + namespace: 'mcp__server_one', + name: 'extremely_lengthy_function_name_that_absolut_0123456789ab', + arguments: '{}', + call_id: 'call_sanitized', + }), + { + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'McpToolCall', + id: 'call_sanitized', + server: 'server.one', + tool: longToolName, + arguments: {}, + status: 'completed', + }, + }, + }, + responseItem({ type: 'function_call_output', call_id: 'call_sanitized', output: 'found' }), + ]); + + expect(toolCalls(events).map((tool) => [tool.toolCallId, tool.title])).toEqual([ + ['call_trailing_delimiter', 'acme__.lookup'], + ['call_trailing_delimiter', 'acme__.lookup'], + ['call_sanitized', `mcp__server.one__${longToolName}`], + ['call_sanitized', `mcp__server.one__${longToolName}`], + ]); + }); + + it('recovers only matching legacy dotless Codex Apps callable identities', () => { + const events = mapCodexHistoryEvents(HID, [ + responseItem({ + type: 'function_call', + namespace: 'mcp__codex_apps__github', + name: '_create_issue', + arguments: '{}', + call_id: 'call_legacy_app', + }), + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call_legacy_app', + invocation: { server: 'codex_apps', tool: 'github_create_issue', arguments: {} }, + }, + }, + responseItem({ type: 'function_call_output', call_id: 'call_legacy_app', output: 'created' }), + responseItem({ + type: 'function_call', + namespace: 'mcp__codex_apps__linear', + name: '_create_issue', + arguments: '{}', + call_id: 'call_mismatched_app', + }), + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call_mismatched_app', + invocation: { server: 'codex_apps', tool: 'github_create_issue', arguments: {} }, + }, + }, + responseItem({ + type: 'function_call_output', + call_id: 'call_mismatched_app', + output: 'created', + }), + responseItem({ + type: 'function_call', + namespace: 'mcp__codex_apps__linear', + name: '_create_issue', + arguments: '{}', + call_id: 'call_modern_app', + }), + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call_modern_app', + invocation: { server: 'codex_apps', tool: 'linear_create_issue', arguments: {} }, + }, + }, + { + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'McpToolCall', + id: 'call_modern_app', + server: 'codex_apps', + tool: 'github.create_issue', + arguments: {}, + status: 'completed', + }, + }, + }, + responseItem({ type: 'function_call_output', call_id: 'call_modern_app', output: 'created' }), + ]); + + expect(toolCalls(events).map((tool) => [tool.toolCallId, tool.title])).toEqual([ + ['call_legacy_app', 'mcp__github__create_issue'], + ['call_legacy_app', 'mcp__github__create_issue'], + ['call_mismatched_app', 'mcp__codex_apps__github_create_issue'], + ['call_mismatched_app', 'mcp__codex_apps__github_create_issue'], + ['call_modern_app', 'mcp__github__create_issue'], + ['call_modern_app', 'mcp__github__create_issue'], + ]); + }); + it('settles an aborted run and a declined run as failed with the raw text as the record', () => { const events = mapCodexHistoryEvents(HID, [ responseItem({ diff --git a/packages/host/agent-adapter/src/__tests__/codex-mcp-tools.test.ts b/packages/host/agent-adapter/src/__tests__/codex-mcp-tools.test.ts new file mode 100644 index 000000000..b12aea163 --- /dev/null +++ b/packages/host/agent-adapter/src/__tests__/codex-mcp-tools.test.ts @@ -0,0 +1,87 @@ +import type { AgentEvent, StartOptions } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { CodexAdapter } from '../native/codex'; +import type { CodexServerHandle } from '../native/codex/adapter'; +import type { CodexAppServerOptions } from '../native/codex/app-server'; + +/** Minimal fake satisfying `CodexServerHandle`, same shape as codex-compaction.test.ts's. */ +class FakeCodexServer { + constructor(private readonly opts: Omit) {} + request(method: string): Promise { + if (method === 'thread/start' || method === 'thread/resume') { + return Promise.resolve({ thread: { id: 'thread-1' } }); + } + return Promise.resolve({}); + } + setRequestHandler(): void { + // Approvals never fire on this path. + } + close(): void { + // Nothing to reap. + } + notify(method: string, params: unknown): void { + this.opts.onNotification(method, params); + } +} + +class TestCodex extends CodexAdapter { + fakeServers: FakeCodexServer[] = []; + protected override startAppServer( + opts: Omit, + ): Promise { + const server = new FakeCodexServer(opts); + this.fakeServers.push(server); + return Promise.resolve(server); + } + protected override readConfiguredSandbox() { + return Promise.resolve(undefined); + } +} + +const start: StartOptions = { kind: 'codex', cwd: '/repo' }; + +function toolTitles(events: AgentEvent[]) { + return events.flatMap((event) => (event.type === 'tool-call' ? [event.toolCall.title] : [])); +} + +describe('CodexAdapter mcpToolCall items', () => { + it('emits the shared mcp slug and strips the codex_apps plugin namespace', async () => { + const adapter = new TestCodex(); + const events: AgentEvent[] = []; + adapter.onEvent((e) => events.push(e)); + await adapter.start(start); + const server = adapter.fakeServers[0]; + + server.notify('turn/started', { turn: { id: 'turn-1' } }); + // Real 0.144.6 shape: plugin apps mount under ONE `codex_apps` server, plugin in the tool name. + server.notify('item/started', { + item: { + type: 'mcpToolCall', + id: 'mcp-1', + server: 'codex_apps', + tool: 'linear.list_issues', + status: 'inProgress', + arguments: { limit: 50 }, + }, + }); + server.notify('item/started', { + item: { type: 'mcpToolCall', id: 'mcp-2', server: 'context7', tool: 'resolve_library' }, + }); + server.notify('item/started', { + item: { type: 'mcpToolCall', id: 'mcp-3', server: 'codex_apps', tool: 'dotless' }, + }); + // Codex accepts `__` in server names; the slug would mis-split, so the raw title survives. + server.notify('item/started', { + item: { type: 'mcpToolCall', id: 'mcp-4', server: 'repo__prod', tool: 'search_files' }, + }); + server.notify('turn/completed', { turn: { id: 'turn-1', status: 'completed' } }); + + // Announce + teardown settle both re-emit the full snapshot; the title must be stable. + expect([...new Set(toolTitles(events))]).toEqual([ + 'mcp__linear__list_issues', + 'mcp__context7__resolve_library', + 'mcp__codex_apps__dotless', + 'repo__prod.search_files', + ]); + }); +}); diff --git a/packages/host/agent-adapter/src/native/claude-code.ts b/packages/host/agent-adapter/src/native/claude-code.ts index fe1453c02..085754dee 100644 --- a/packages/host/agent-adapter/src/native/claude-code.ts +++ b/packages/host/agent-adapter/src/native/claude-code.ts @@ -405,6 +405,7 @@ const EMPTY_SUPPLEMENT: ClaudeTranscriptSupplement = { records: new Map(), droppedRows: [], parentUuidByUuid: new Map(), + toolUses: new Map(), toolUseResults: new Map(), toolUsePatches: new Map(), }; @@ -701,6 +702,7 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { supplement.toolUseResults, supplement.toolUsePatches, supplement.parentUuidByUuid, + supplement.toolUses, ); const events: AgentHistoryEvent[] = []; // Splice each subagent's transcript right after its spawn announce so children land inside the @@ -1745,6 +1747,9 @@ export interface ClaudeTranscriptSupplement { /** Message uuid → raw transcript predecessor. The SDK projection strips `parentUuid`, but Claude * requires the predecessor message id when forking immediately before a historical prompt. */ parentUuidByUuid: Map; + /** tool_use_id → announce snapshot. Cursor pages can begin at the matching result row, after + * the stateful mapper's in-page announce map has been reset. */ + toolUses: Map; /** tool_use_id → projected `toolUseResult` envelope (`toolUseResultEnvelope`), another field * `getSessionMessages` strips per row. Keyed only for unambiguous single-result rows. */ toolUseResults: Map>; @@ -1769,6 +1774,7 @@ export function buildClaudeTranscriptSupplement( ): ClaudeTranscriptSupplement { const records = new Map(); const parentUuidByUuid = new Map(); + const toolUses = new Map(); const toolUseResults = new Map>(); const toolUsePatches = new Map(); /** Conversation rows in file order, with the index of the last boundary seen before each. */ @@ -1804,11 +1810,12 @@ export function buildClaudeTranscriptSupplement( records.set(uuid, pending ?? { compactionId: uuid }); pending = null; } else if (row.type !== 'user' && row.type !== 'assistant') continue; - // Harvested before the exclusions: tool_use ids are globally unique, so keying a row the - // timeline itself skips is harmless. + // Result fields are harvested before the exclusions: tool_use ids are globally unique, so + // keying a result row the timeline itself skips is harmless. if (row.type === 'user') harvestToolUseResult(toolUseResults, toolUsePatches, row); // Same exclusions as the SDK's own reader: meta rows, sidechains, and teammate rows. if (row.isMeta === true || row.isSidechain === true || row.teamName) continue; + if (row.type === 'assistant') harvestToolUses(toolUses, row); rows.push({ row: { type: row.type, @@ -1831,11 +1838,21 @@ export function buildClaudeTranscriptSupplement( if (r.boundariesBefore < boundaries) dropped.push(r.row); return dropped; }, []), + toolUses, toolUseResults, toolUsePatches, }; } +function harvestToolUses(toolUses: Map, row: Record): void { + const parentToolCallId = stringField(row, 'parent_tool_use_id'); + for (const block of messageContentBlocks(row.message)) { + if (isToolUseBlock(block)) { + toolUses.set(block.id, claudeToolCallFromUse(block, parentToolCallId)); + } + } +} + /** Key a raw result row's `toolUseResult` projections by its tool_use id. The field is row-level, so * only a row with exactly one tool_result block pairs unambiguously. The envelope and the Edit patch * are independent projections of that one field — either can be absent. */ @@ -1987,6 +2004,8 @@ export function createClaudeHistoryEventMapper( toolUsePatches?: ReadonlyMap, /** Raw message ancestry recovered from the transcript; absent for nested subagent reads. */ parentUuidByUuid?: ReadonlyMap, + /** Announce snapshots recovered from the raw transcript, for cursor pages starting at settle. */ + toolUses?: ReadonlyMap, ): (message: SessionMessage) => AgentHistoryEvent[] { const announced = new Map(); /** Last model announced to the timeline; assistant rows re-announce only on change. */ @@ -2047,24 +2066,14 @@ export function createClaudeHistoryEventMapper( if (text) events.push(text); for (const block of blocks) { if (!isToolUseBlock(block)) continue; - events.push( - toolEvent({ - toolCallId: block.id, - parentToolCallId: parent, - title: block.name, - kind: claudeToolKind(block.name), - status: 'in_progress', - content: toolInputContent(block.name, block.input) ?? [], - rawInput: block.input, - }), - ); + events.push(toolEvent(claudeToolCallFromUse(block, parent))); } return events; } const results = blocks.filter((block) => isToolResultBlock(block)); for (const block of results) { - const existing = announced.get(block.tool_use_id); + const existing = announced.get(block.tool_use_id) ?? toolUses?.get(block.tool_use_id); events.push( toolEvent({ toolCallId: block.tool_use_id, @@ -2119,6 +2128,18 @@ interface ClaudeToolResultBlock { content?: unknown; } +function claudeToolCallFromUse(block: ClaudeToolUseBlock, parentToolCallId?: string): ToolCall { + return { + toolCallId: block.id, + parentToolCallId, + title: block.name, + kind: claudeToolKind(block.name), + status: 'in_progress', + content: toolInputContent(block.name, block.input) ?? [], + rawInput: block.input, + }; +} + interface ClaudeThinkingBlock { type: 'thinking'; thinking: string; diff --git a/packages/host/agent-adapter/src/native/codex/adapter.ts b/packages/host/agent-adapter/src/native/codex/adapter.ts index b39bd886b..5d3252c05 100644 --- a/packages/host/agent-adapter/src/native/codex/adapter.ts +++ b/packages/host/agent-adapter/src/native/codex/adapter.ts @@ -60,7 +60,13 @@ import { readCodexTranscriptSummaries, readJsonlFile, } from './history'; -import { CODEX_PLAN_ID, codexPlanEntries, execToolCall, fileChangeToolCall } from './tool-view'; +import { + CODEX_PLAN_ID, + codexMcpSlug, + codexPlanEntries, + execToolCall, + fileChangeToolCall, +} from './tool-view'; import { diffContentFromUnified } from './unified-diff'; interface CodexSkillCommand extends AgentCommand { @@ -1279,11 +1285,12 @@ export class CodexAdapter extends BaseAgentAdapter { break; } case 'mcpToolCall': { - const server = stringField(item, 'server') ?? 'mcp'; - const tool = stringField(item, 'tool') ?? 'tool'; this.emitTool({ toolCallId: id, - title: `${server}.${tool}`, + title: codexMcpSlug( + stringField(item, 'server') ?? 'mcp', + stringField(item, 'tool') ?? 'tool', + ), kind: 'other', status: mapCodexItemStatus(stringField(item, 'status')), content: [], diff --git a/packages/host/agent-adapter/src/native/codex/history-tools.ts b/packages/host/agent-adapter/src/native/codex/history-tools.ts index 7ff67542a..f4d177992 100644 --- a/packages/host/agent-adapter/src/native/codex/history-tools.ts +++ b/packages/host/agent-adapter/src/native/codex/history-tools.ts @@ -3,6 +3,7 @@ import { isRecord, stringField, textFromUnknown } from '../../history-util'; import { toolKindFromName } from '../../util'; import { CODEX_PLAN_ID, + codexMcpSlug, codexPlanEntries, execToolCall, fileChangeToolCall, @@ -22,6 +23,7 @@ export type CodexToolAnnounce = { toolCall: ToolCall } | { plan: Plan }; export function codexToolAnnounce( callId: string, payload: Record, + persistedMcpIdentity?: { server: string; tool: string }, ): CodexToolAnnounce { const payloadType = stringField(payload, 'type'); const name = stringField(payload, 'name'); @@ -74,6 +76,21 @@ export function codexToolAnnounce( // function_call: JSON-encoded `arguments`. const args = parseArguments(payload); + const mcp = codexReplayMcpIdentity(payload, persistedMcpIdentity); + if (mcp) { + // Converge with the live adapter's `mcp____` slug (and its kind) so a + // replayed MCP call renders like the live turn did. + return { + toolCall: { + toolCallId: callId, + title: codexMcpSlug(mcp.server, mcp.tool), + kind: 'other', + status: 'in_progress', + content: [], + rawInput: args, + }, + }; + } if (name === 'update_plan') { const plan = planFromArgs(args); if (plan) return { plan }; @@ -114,6 +131,48 @@ export function codexToolAnnounce( }; } +const MCP_NAMESPACE_PREFIX = 'mcp__'; +const PLUGIN_APPS_SERVER = 'codex_apps'; +const PLUGIN_APPS_NAMESPACE_PREFIX = `${PLUGIN_APPS_SERVER}__`; + +function codexReplayMcpIdentity( + payload: Record, + persisted: { server: string; tool: string } | undefined, +): { server: string; tool: string } | undefined { + const callable = codexMcpToolName(payload); + const namespace = stringField(payload, 'namespace'); + const name = stringField(payload, 'name'); + // Legacy Codex Apps persisted `github_create_issue`; only the callable retained its connector + // boundary. + if ( + callable && + persisted?.server === PLUGIN_APPS_SERVER && + !persisted.tool.includes('.') && + namespace?.startsWith(`${MCP_NAMESPACE_PREFIX}${PLUGIN_APPS_NAMESPACE_PREFIX}`) && + name?.[0] === '_' && + persisted.tool === `${callable.server}_${callable.tool}` + ) { + return callable; + } + return persisted ?? callable; +} + +/** Callable names are lossy; raw completed identities take precedence outside the legacy case. */ +function codexMcpToolName( + payload: Record, +): { server: string; tool: string } | undefined { + const namespace = stringField(payload, 'namespace'); + const name = stringField(payload, 'name'); + if (!namespace || !name || !namespace.startsWith(MCP_NAMESPACE_PREFIX)) return undefined; + let server = namespace.slice(MCP_NAMESPACE_PREFIX.length); + let tool = name; + if (server.startsWith(PLUGIN_APPS_NAMESPACE_PREFIX)) { + server = server.slice(PLUGIN_APPS_NAMESPACE_PREFIX.length); + if (tool[0] === '_') tool = tool.slice(1); + } + return server.length > 0 && tool.length > 0 ? { server, tool } : undefined; +} + /** Settle an output row into the final snapshot, keeping the announce's diff content for edits and * unwrapping the freeform-exec output envelope for everything else. */ export function codexToolSettle( diff --git a/packages/host/agent-adapter/src/native/codex/history.ts b/packages/host/agent-adapter/src/native/codex/history.ts index 17e3b7dfb..5a05806a9 100644 --- a/packages/host/agent-adapter/src/native/codex/history.ts +++ b/packages/host/agent-adapter/src/native/codex/history.ts @@ -456,8 +456,8 @@ const CODEX_TOOL_OUTPUT_TYPES = new Set([ /** * Replays the rollout as the event stream the live turn emitted: text plus tool announce/settle * pairs correlated by `call_id`, mapped to the live presentation shapes by `history-tools.ts`. - * History ids are NOT converged with the live app-server item ids (the rollout persists only - * `call_id`; message rows carry no id) — the seed relies on the `uptoSeq` cut. Known lossiness + * Message history ids are NOT converged with the live app-server item ids because rollout message + * rows carry no id — the seed relies on the `uptoSeq` cut. Known lossiness * (CODE-97): reasoning stays unreplayable (`encrypted_content` only), and replayed edit diffs are * reconstructed from the `*** Begin Patch` envelope, not the app-server's richer live unified diff. */ @@ -467,6 +467,7 @@ export function mapCodexHistoryEvents( ): AgentHistoryEvent[] { const events: AgentHistoryEvent[] = []; const announced = new Map(); + const persistedMcpIdentities = collectCodexMcpIdentities(rows); const promptTexts = collectCodexPromptTexts(rows); /** update_plan call_ids, so their `Plan updated` receipts don't settle a phantom tool row. */ const planCalls = new Set(); @@ -515,7 +516,7 @@ export function mapCodexHistoryEvents( const callId = stringField(payload, 'call_id'); if (payloadType !== undefined && callId !== undefined) { if (CODEX_TOOL_ANNOUNCE_TYPES.has(payloadType)) { - const mapped = codexToolAnnounce(callId, payload); + const mapped = codexToolAnnounce(callId, payload, persistedMcpIdentities.get(callId)); if ('plan' in mapped) { planCalls.add(callId); events.push({ historyId, itemId: callId, event: { type: 'plan', plan: mapped.plan } }); @@ -553,6 +554,35 @@ export function mapCodexHistoryEvents( return events; } +function collectCodexMcpIdentities( + rows: JsonRecord[], +): Map { + const identities = new Map(); + for (const row of rows) { + if (stringField(row, 'type') !== 'event_msg') continue; + const payload = recordField(row, 'payload'); + if (!payload) continue; + if (stringField(payload, 'type') === 'item_completed') { + const item = recordField(payload, 'item'); + if (!item || stringField(item, 'type') !== 'McpToolCall') continue; + const callId = stringField(item, 'id'); + const server = stringField(item, 'server'); + const tool = stringField(item, 'tool'); + if (callId && server && tool) identities.set(callId, { server, tool }); + continue; + } + if (stringField(payload, 'type') !== 'mcp_tool_call_end') continue; + const callId = stringField(payload, 'call_id'); + const invocation = recordField(payload, 'invocation'); + const server = invocation ? stringField(invocation, 'server') : undefined; + const tool = invocation ? stringField(invocation, 'tool') : undefined; + if (callId && server && tool && !identities.has(callId)) { + identities.set(callId, { server, tool }); + } + } + return identities; +} + function idFromFilename(path: string): string { const name = basename(path, '.jsonl'); return name.length > 0 ? name : path; diff --git a/packages/host/agent-adapter/src/native/codex/tool-view.ts b/packages/host/agent-adapter/src/native/codex/tool-view.ts index 4d8b13268..006100d33 100644 --- a/packages/host/agent-adapter/src/native/codex/tool-view.ts +++ b/packages/host/agent-adapter/src/native/codex/tool-view.ts @@ -21,6 +21,24 @@ export function textContent(text: string): ToolCallContent[] { return [{ type: 'content', content: { type: 'text', text } }]; } +const CODEX_PLUGIN_APPS_SERVER = 'codex_apps'; + +/** The `mcp____` slug — the UI's server/tool join key. Plugin apps mount under the + * one `codex_apps` server with the plugin as the tool's first dot segment; surface it as server. */ +export function codexMcpSlug(server: string, tool: string): string { + if (server === CODEX_PLUGIN_APPS_SERVER) { + const dot = tool.indexOf('.'); + if (dot > 0 && dot < tool.length - 1) { + server = tool.slice(0, dot); + tool = tool.slice(dot + 1); + } + } + // Codex accepts `__` in server names, but the slug splits on the first `__` — a name that + // would mis-split keeps codex's raw dotted title instead. + if (server.includes('__')) return `${server}.${tool}`; + return `mcp__${server}__${tool}`; +} + /** A `commandExecution` snapshot: the command line is the title, the aggregated output (settled * runs) is the content, and the exit code travels as `rawOutput`. */ export function execToolCall(opts: { diff --git a/packages/host/engine/tests/integration/git-mutations.test.ts b/packages/host/engine/tests/integration/git-mutations.test.ts index 55340002e..95067fc98 100644 --- a/packages/host/engine/tests/integration/git-mutations.test.ts +++ b/packages/host/engine/tests/integration/git-mutations.test.ts @@ -22,6 +22,7 @@ function makeRepo(): string { git(cwd, 'init', '-b', 'main'); git(cwd, 'config', 'user.email', 'test@test'); git(cwd, 'config', 'user.name', 'test'); + git(cwd, 'config', 'commit.gpgsign', 'false'); writeFileSync(join(cwd, 'file.txt'), 'one\n'); git(cwd, 'add', '--all'); git(cwd, 'commit', '-m', 'initial'); diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 88fa8eb0e..3416e09e6 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -156,6 +156,18 @@ export const en = { failed: 'Failed', expand: 'Expand', collapse: 'Collapse', + toolSearch: { + select: 'Tool selection', + selecting: 'Selecting tools', + selected: 'Selected {count, plural, one {a tool} other {# tools}}', + search: 'Tool search', + searching: 'Searching for tools', + searched: 'Searched for tools', + }, + searchSummary: { + matches: '{count, plural, one {a match} other {# matches}}', + files: '{count, plural, one {a file} other {# files}}', + }, }, subagent: { label: 'Subagent', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 85adeac7c..cc6bf9ab5 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -152,6 +152,18 @@ export const zhCN = { failed: '失败', expand: '展开', collapse: '收起', + toolSearch: { + select: '工具选择', + selecting: '正在选择工具', + selected: '{count, plural, =1 {已选择一个工具} other {已选择 # 个工具}}', + search: '工具搜索', + searching: '正在搜索工具', + searched: '已搜索工具', + }, + searchSummary: { + matches: '{count, plural, =1 {一个匹配} other {# 个匹配}}', + files: '{count, plural, =1 {一个文件} other {# 个文件}}', + }, }, subagent: { label: '子代理', diff --git a/packages/presentation/ui/src/chat/__tests__/tool-call-metadata.test.tsx b/packages/presentation/ui/src/chat/__tests__/tool-call-metadata.test.tsx index 3761714b6..5e13d39de 100644 --- a/packages/presentation/ui/src/chat/__tests__/tool-call-metadata.test.tsx +++ b/packages/presentation/ui/src/chat/__tests__/tool-call-metadata.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import type { ToolCall } from '@linkcode/schema'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { hasToolBody, @@ -9,6 +9,7 @@ import { toolCallContextSummary, toolCallHeaderSummary, toolCallMetadata, + toolCallSearchCounts, } from '../../tool-utils'; import { ToolCallBody, ToolCallItem } from '../tool-call-item'; @@ -32,7 +33,7 @@ afterEach(() => { }); describe('tool metadata policy', () => { - it('previews search results while hiding adapter request and timing fields', () => { + it('keeps the raw search query in the body card only, without metadata badges', () => { const toolCall: ToolCall = { toolCallId: 'search-1', title: 'Search renderers', @@ -50,12 +51,18 @@ describe('tool metadata policy', () => { content: [], }; + expect(toolCallMetadata(toolCall)).toEqual([ + { key: 'query', value: 'tool-call' }, + { key: 'matches', value: '2' }, + { key: 'files', value: '2' }, + ]); + expect(toolCallSearchCounts(toolCall)).toEqual({ matches: 2, files: 2 }); + const { container } = render(); - expect(screen.getByText('query')).toBeDefined(); - expect(screen.getAllByText('tool-call')).toHaveLength(2); - expect(screen.getByText('matches')).toBeDefined(); - expect(screen.getByText('files')).toBeDefined(); + // The raw query renders once, as the result card's title — never as a badge. + expect(screen.queryByText('query')).toBeNull(); + expect(screen.getAllByText('tool-call')).toHaveLength(1); expect(container.querySelector('pre')?.textContent).toContain( 'packages/presentation/ui/src/chat/tool.tsx', ); @@ -66,6 +73,89 @@ describe('tool metadata policy', () => { expect(container.textContent).not.toContain('glob'); }); + it('summarizes search headers from real Claude envelope counts', () => { + const toolCall: ToolCall = { + toolCallId: 'search-claude', + title: 'Grep', + kind: 'search', + status: 'completed', + rawInput: { pattern: 'permission-request|tool-call|plan' }, + rawOutput: { mode: 'files_with_matches', numFiles: 3, numMatches: 12 }, + content: [{ type: 'content', content: { type: 'text', text: 'a.ts\nb.ts\nc.ts' } }], + }; + + expect(toolCallSearchCounts(toolCall)).toEqual({ matches: 12, files: 3 }); + expect(toolCallMetadata(toolCall)).toEqual([ + { key: 'query', value: 'permission-request|tool-call|plan' }, + { key: 'matches', value: '12' }, + { key: 'files', value: '3' }, + ]); + + render(); + + expect(screen.getByText('· searchSummary.matches · searchSummary.files')).toBeDefined(); + expect(screen.queryByText('permission-request|tool-call|plan')).toBeNull(); + }); + + it('keeps MCP server identity beside search counts', () => { + const toolCall: ToolCall = { + toolCallId: 'search-mcp', + title: 'mcp__repo__search_files', + kind: 'search', + status: 'completed', + rawInput: { pattern: 'ToolCallItem' }, + rawOutput: { numFiles: 3, numMatches: 12 }, + content: [{ type: 'content', content: { type: 'text', text: 'a.ts\nb.ts\nc.ts' } }], + }; + + render(); + + expect(screen.getByText('· repo · searchSummary.matches · searchSummary.files')).toBeDefined(); + expect(screen.getByText('search_files')).toBeDefined(); + expect(screen.queryByText('ToolCallItem')).toBeNull(); + }); + + it('keeps an uncounted search query in the header and its expandable body card', () => { + const toolCall: ToolCall = { + toolCallId: 'search-empty', + title: 'Grep', + kind: 'search', + status: 'in_progress', + rawInput: { pattern: 'permission-request|tool-call|plan' }, + content: [], + }; + + expect(hasToolBody(toolCall)).toBe(true); + + const { container } = render(); + + // No counts yet — the query is the only header context an in-progress search has. + expect(container.querySelector('button')?.textContent).toContain( + 'permission-request|tool-call|plan', + ); + fireEvent.click(screen.getByRole('button')); + expect(screen.getByText('permission-request|tool-call|plan')).toBeDefined(); + expect(container.querySelector('pre')).toBeNull(); + }); + + it('falls back to the query for search tools that never report counts', () => { + // WebSearch classifies as `kind: search` but its envelope carries no numMatches/numFiles — + // without the query fallback its header would collapse to a bare tool name. + const toolCall: ToolCall = { + toolCallId: 'search-web', + title: 'WebSearch', + kind: 'search', + status: 'completed', + rawInput: { query: 'linkcode release notes' }, + rawOutput: { durationSeconds: 3 }, + content: [{ type: 'content', content: { type: 'text', text: 'Release 0.4 shipped.' } }], + }; + + render(); + + expect(screen.getByText('· linkcode release notes')).toBeDefined(); + }); + it('previews an allowlisted fetch response without exposing its envelopes', () => { const toolCall: ToolCall = { toolCallId: 'fetch-1', @@ -329,6 +419,7 @@ describe('tool metadata policy', () => { expect(calls.map(toolCallHeaderSummary)).toEqual([ { label: 'README.md:3', tooltip: 'README.md:3' }, + // An uncounted search keeps its query; counted settles humanize instead (tests above). { label: 'ToolCallBody' }, { label: 'old.ts → new.ts', tooltip: 'old.ts → new.ts' }, { label: 'pnpm test' }, diff --git a/packages/presentation/ui/src/chat/__tests__/tool-result-content.test.ts b/packages/presentation/ui/src/chat/__tests__/tool-result-content.test.ts index 1075971f2..550a07f0d 100644 --- a/packages/presentation/ui/src/chat/__tests__/tool-result-content.test.ts +++ b/packages/presentation/ui/src/chat/__tests__/tool-result-content.test.ts @@ -5,6 +5,7 @@ import { toolCallDisplayText, toolCallExecuteText, toolCallReadPreviewText, + toolSearchPresentation, } from '../tool-result-content'; function call(overrides: Partial): ToolCall { @@ -123,3 +124,79 @@ describe('tool result content policy', () => { ).toBe(reminder); }); }); + +describe('tool search presentation', () => { + function toolSearch(overrides: Partial): ToolCall { + return call({ + title: 'ToolSearch', + kind: 'search', + rawInput: { query: 'select:WebSearch' }, + ...overrides, + }); + } + + it('splits a settled name-per-line result into deduplicated rows', () => { + const toolCall = toolSearch({ + content: [ + { + type: 'content', + content: { + type: 'text', + text: 'WebSearch\nmcp__linear__get_issue\nWebSearch', + }, + }, + ], + }); + + expect(toolSearchPresentation(toolCall)).toEqual({ + query: 'select:WebSearch', + mode: 'select', + names: ['WebSearch', 'mcp__linear__get_issue'], + }); + }); + + it('keeps prose settles as a message instead of rows', () => { + const toolCall = toolSearch({ + rawInput: { query: '+jupyter notebook edit' }, + content: [ + { type: 'content', content: { type: 'text', text: 'No matching deferred tools found' } }, + ], + }); + + expect(toolSearchPresentation(toolCall)).toEqual({ + query: '+jupyter notebook edit', + mode: 'search', + names: [], + message: 'No matching deferred tools found', + }); + }); + + it('keeps identifier-shaped failed settles as error prose', () => { + const toolCall = toolSearch({ + status: 'failed', + content: [{ type: 'content', content: { type: 'text', text: 'unavailable' } }], + }); + + expect(toolSearchPresentation(toolCall)).toEqual({ + query: 'select:WebSearch', + mode: 'select', + names: [], + message: 'unavailable', + }); + }); + + it('presents a running call with neither rows nor message', () => { + expect(toolSearchPresentation(toolSearch({ status: 'in_progress' }))).toEqual({ + query: 'select:WebSearch', + mode: 'select', + names: [], + message: undefined, + }); + }); + + it('matches only the exact Claude title and input shape', () => { + expect(toolSearchPresentation(toolSearch({ title: 'Grep' }))).toBeUndefined(); + expect(toolSearchPresentation(toolSearch({ kind: 'other' }))).toBeUndefined(); + expect(toolSearchPresentation(toolSearch({ rawInput: { pattern: 'x' } }))).toBeUndefined(); + }); +}); diff --git a/packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx b/packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx new file mode 100644 index 000000000..153ed70d1 --- /dev/null +++ b/packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment jsdom + +import type { ToolCall } from '@linkcode/schema'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { hasToolBody } from '../../tool-utils'; +import { ToolCallBody, ToolCallItem } from '../tool-call-item'; + +function translateKey(key: string): string { + return key; +} + +function translationsMock(): typeof translateKey { + return translateKey; +} + +vi.mock('use-intl', () => ({ + useTranslations: translationsMock, +})); + +afterEach(cleanup); + +function toolSearch(overrides: Partial): ToolCall { + return { + toolCallId: 'toolsearch-1', + title: 'ToolSearch', + kind: 'search', + status: 'completed', + content: [], + rawInput: { query: 'select:WebSearch,mcp__linear__get_issue' }, + rawOutput: { query: 'select:WebSearch,mcp__linear__get_issue', total_deferred_tools: 110 }, + ...overrides, + }; +} + +describe('tool search presentation', () => { + it('humanizes a settled select call and never shows the raw query', () => { + const toolCall = toolSearch({ + content: [ + { + type: 'content', + content: { type: 'text', text: 'WebSearch\nmcp__linear__get_issue' }, + }, + ], + }); + + const { container } = render(); + + expect(screen.getByText('toolSearch.selected')).toBeDefined(); + expect(container.textContent).not.toContain('select:'); + expect(container.textContent).not.toContain('ToolSearch'); + }); + + it('shows the keyword query beside a humanized search header', () => { + const toolCall = toolSearch({ + rawInput: { query: 'Linear issues search' }, + content: [{ type: 'content', content: { type: 'text', text: 'WebSearch' } }], + }); + + render(); + + expect(screen.getByText('toolSearch.searched')).toBeDefined(); + expect(screen.getByText('· Linear issues search')).toBeDefined(); + }); + + it('renders loaded tools as one inline line with split MCP identity', () => { + const toolCall = toolSearch({ + content: [ + { + type: 'content', + content: { type: 'text', text: 'WebSearch\nmcp__linear__get_issue' }, + }, + ], + }); + + const { container } = render(); + + expect(container.querySelector('p')?.textContent).toBe('WebSearch, get_issue (linear)'); + expect(container.querySelector('pre')).toBeNull(); + expect(screen.queryByText('query')).toBeNull(); + }); + + it('shows a zero-match settle as the tool message', () => { + const toolCall = toolSearch({ + rawInput: { query: '+jupyter notebook edit' }, + content: [ + { type: 'content', content: { type: 'text', text: 'No matching deferred tools found' } }, + ], + }); + + render(); + + expect(screen.getByText('No matching deferred tools found')).toBeDefined(); + }); + + it('preserves line breaks in prose messages', () => { + const message = 'Connection failed\nRetry later'; + const toolCall = toolSearch({ + content: [{ type: 'content', content: { type: 'text', text: message } }], + }); + + const { container } = render(); + const paragraph = container.querySelector('p'); + + expect(paragraph?.textContent).toBe(message); + expect(paragraph?.classList.contains('whitespace-pre-wrap')).toBe(true); + expect(paragraph?.classList.contains('break-words')).toBe(true); + }); + + it('keeps neutral wording when a settled selection has no recoverable result rows', () => { + // The cold-history shape: completed, but the SDK stripped the tool_use_result rows. + const toolCall = toolSearch({ content: [] }); + + render(); + + expect(screen.getByText('toolSearch.select')).toBeDefined(); + expect(screen.queryByText('toolSearch.selected')).toBeNull(); + }); + + it('keeps a running call body-less with a progressive header', () => { + const toolCall = toolSearch({ status: 'in_progress', rawOutput: undefined }); + + render(); + + expect(hasToolBody(toolCall)).toBe(false); + expect(screen.getByText('toolSearch.selecting')).toBeDefined(); + }); + + it('uses neutral wording and error prose for a failed selection', () => { + const toolCall = toolSearch({ + status: 'failed', + content: [{ type: 'content', content: { type: 'text', text: 'unavailable' } }], + }); + + render(); + + expect(screen.getByText('toolSearch.select')).toBeDefined(); + expect(screen.queryByText('toolSearch.selected')).toBeNull(); + fireEvent.click(screen.getByRole('button')); + expect(screen.getByText('unavailable')).toBeDefined(); + }); + + it('uses neutral wording when a keyword search is declined', () => { + const toolCall = toolSearch({ + status: 'in_progress', + rawInput: { query: 'Linear issues search' }, + rawOutput: undefined, + }); + + render(); + + expect(screen.getByText('toolSearch.search')).toBeDefined(); + expect(screen.queryByText('toolSearch.searching')).toBeNull(); + expect(screen.queryByText('toolSearch.searched')).toBeNull(); + }); +}); diff --git a/packages/presentation/ui/src/chat/activity-run.tsx b/packages/presentation/ui/src/chat/activity-run.tsx index cbb6e282a..678e1b1f0 100644 --- a/packages/presentation/ui/src/chat/activity-run.tsx +++ b/packages/presentation/ui/src/chat/activity-run.tsx @@ -1,5 +1,5 @@ import { Collapsible, CollapsibleTrigger } from 'coss-ui/components/collapsible'; -import { PencilIcon, SearchIcon, SparklesIcon, TerminalIcon, WrenchIcon } from 'lucide-react'; +import { PencilIcon, SparklesIcon, TelescopeIcon, TerminalIcon, WrenchIcon } from 'lucide-react'; import { useState } from 'react'; import { useTranslations } from 'use-intl'; import { cn } from '../lib/cn'; @@ -189,7 +189,7 @@ const ACTIVITY_ICONS: Record< files: PencilIcon, integration: WrenchIcon, command: TerminalIcon, - explore: SearchIcon, + explore: TelescopeIcon, thinking: SparklesIcon, }; diff --git a/packages/presentation/ui/src/chat/tool-call-item.tsx b/packages/presentation/ui/src/chat/tool-call-item.tsx index d5d5f1354..0b7038eb3 100644 --- a/packages/presentation/ui/src/chat/tool-call-item.tsx +++ b/packages/presentation/ui/src/chat/tool-call-item.tsx @@ -1,7 +1,9 @@ import type { ToolCall } from '@linkcode/schema'; import { Badge } from 'coss-ui/components/badge'; +import { ToolCaseIcon } from 'lucide-react'; import { useTranslations } from 'use-intl'; import { toolCallDiffStats } from '../diff-utils'; +import { cn } from '../lib/cn'; import type { ToolMetadata } from '../tool-utils'; import { hasToolBody, @@ -9,9 +11,10 @@ import { toolCallContextSummary, toolCallFailureMessage, toolCallMetadata, + toolCallSearchCounts, } from '../tool-utils'; import { Tool, ToolContent, ToolHeader } from './tool'; -import { toolCallDisplayText } from './tool-result-content'; +import { toolCallDisplayText, toolSearchPresentation } from './tool-result-content'; import { ToolResultPreview } from './tool-result-preview'; function ToolMetadataList({ metadata }: { metadata: ToolMetadata[] }): React.ReactNode { @@ -49,10 +52,11 @@ export function ToolCallBody({ toolCall.kind === 'execute' ? undefined : toolCallFailureMessage(toolCall); const failureMessage = rawFailureMessage && !contentText.includes(rawFailureMessage) ? rawFailureMessage : undefined; + const metadata = toolCall.kind === 'search' ? [] : toolCallMetadata(toolCall); return ( <> - + {failureMessage ? ( @@ -88,10 +92,57 @@ export function ToolCallItem({ const tt = useTranslations('workbench.tool'); const hasBody = hasToolBody(toolCall); - const summary = toolCallContextSummary(toolCall); const diffTotals = toolCallDiffStats(toolCall); const mcp = mcpToolName(toolCall.title); - const title = mcp?.tool ?? toolCall.title; + const running = !declined && (toolCall.status === 'pending' || toolCall.status === 'in_progress'); + const completed = !declined && toolCall.status === 'completed'; + + // Search headers are humanized: ToolSearch gets a localized verb (never its raw select: query), + // and other search calls summarize settle counts — raw patterns live only in the body card. + const toolSearch = toolSearchPresentation(toolCall); + const searchCounts = toolSearch ? undefined : toolCallSearchCounts(toolCall); + let title = mcp?.tool ?? toolCall.title; + let summary = toolCallContextSummary(toolCall); + let headerIcon = icon; + if (toolSearch && !headerIcon) { + headerIcon = ( + + ); + } + if (toolSearch) { + title = + toolSearch.mode === 'select' + ? running + ? tt('toolSearch.selecting') + : // History reads can lose the result rows (the SDK strips tool_use_result), so a + // settle without names keeps the neutral label instead of "Selected 0 tools". + completed && toolSearch.names.length > 0 + ? tt('toolSearch.selected', { count: toolSearch.names.length }) + : tt('toolSearch.select') + : running + ? tt('toolSearch.searching') + : completed + ? tt('toolSearch.searched') + : tt('toolSearch.search'); + summary = toolSearch.mode === 'search' ? { label: toolSearch.query } : undefined; + } else if (searchCounts) { + const label = [ + searchCounts.matches === undefined + ? undefined + : tt('searchSummary.matches', { count: searchCounts.matches }), + searchCounts.files === undefined + ? undefined + : tt('searchSummary.files', { count: searchCounts.files }), + ] + .filter((part) => part !== undefined) + .join(' · '); + summary = { + label: summary ? `${summary.label} · ${label}` : label, + tooltip: summary?.tooltip, + }; + } return ( @@ -101,7 +152,7 @@ export function ToolCallItem({ declined={declined} diffStats={diffTotals} hasBody={hasBody} - icon={icon} + icon={headerIcon} kind={toolCall.kind} status={toolCall.status} statusLabel={ diff --git a/packages/presentation/ui/src/chat/tool-kind-icons.ts b/packages/presentation/ui/src/chat/tool-kind-icons.ts index ad379469c..d80db011f 100644 --- a/packages/presentation/ui/src/chat/tool-kind-icons.ts +++ b/packages/presentation/ui/src/chat/tool-kind-icons.ts @@ -5,9 +5,9 @@ import { FileTextIcon, GlobeIcon, PencilIcon, - SearchIcon, SparklesIcon, TerminalIcon, + TextSearchIcon, Trash2Icon, WrenchIcon, } from 'lucide-react'; @@ -20,7 +20,7 @@ export const TOOL_KIND_ICONS: Record< edit: PencilIcon, delete: Trash2Icon, move: FileOutputIcon, - search: SearchIcon, + search: TextSearchIcon, execute: TerminalIcon, think: SparklesIcon, fetch: GlobeIcon, diff --git a/packages/presentation/ui/src/chat/tool-result-content.ts b/packages/presentation/ui/src/chat/tool-result-content.ts index e1e9e480d..0ef93ced5 100644 --- a/packages/presentation/ui/src/chat/tool-result-content.ts +++ b/packages/presentation/ui/src/chat/tool-result-content.ts @@ -64,6 +64,39 @@ export function toolCallDisplayText(toolCall: ToolCall): string { .join('\n'); } +export interface ToolSearchPresentation { + query: string; + /** `select` loads named tools verbatim; `search` ranks by keywords. Drives the header verb. */ + mode: 'select' | 'search'; + /** Matched tool names, one per row. */ + names: string[]; + /** Prose settle text (zero-match notice, error detail) shown instead of rows. */ + message?: string; +} + +/** Deferred-tool names are single identifier tokens; prose means the tool is talking instead. */ +const TOOL_NAME_LINE_RE = /^[\w.-]+$/; + +/** Claude's ToolSearch loads deferred tools and settles with a name-per-line list (the adapter + * flattens its `tool_reference` blocks). ToolCall carries no adapter id, so match only the exact + * Claude title/input shape. */ +export function toolSearchPresentation(toolCall: ToolCall): ToolSearchPresentation | undefined { + if (toolCall.title !== 'ToolSearch' || toolCall.kind !== 'search') return undefined; + const query = stringValue(recordValue(toolCall.rawInput), ['query']); + if (!query) return undefined; + const mode = query.startsWith('select:') ? 'select' : 'search'; + const text = toolCallDisplayText(toolCall); + const lines = [...new Set(text.split('\n').filter((line) => line.length > 0))]; + if ( + toolCall.status === 'completed' && + lines.length > 0 && + lines.every((line) => TOOL_NAME_LINE_RE.test(line)) + ) { + return { query, mode, names: lines }; + } + return { query, mode, names: [], message: text.length > 0 ? text : undefined }; +} + export function toolCallExecuteText(toolCall: ToolCall): string | undefined { const displayText = toolCallDisplayText(toolCall); if (displayText) return displayText; diff --git a/packages/presentation/ui/src/chat/tool-result-preview.tsx b/packages/presentation/ui/src/chat/tool-result-preview.tsx index 6db5394c0..cb6b29f4f 100644 --- a/packages/presentation/ui/src/chat/tool-result-preview.tsx +++ b/packages/presentation/ui/src/chat/tool-result-preview.tsx @@ -1,5 +1,5 @@ import type { ToolCall, ToolCallContent } from '@linkcode/schema'; -import { FileTextIcon, GlobeIcon, SearchIcon, WrenchIcon } from 'lucide-react'; +import { FileTextIcon, GlobeIcon, TextSearchIcon, WrenchIcon } from 'lucide-react'; import { Fragment } from 'react'; import { toolCallCommand, toolCallDisplayTitle } from '../tool-utils'; import { artifactKindForPath, fileExtension } from './artifacts/file-kind'; @@ -22,7 +22,9 @@ import { toolCallFetchUrl, toolCallReadPreviewText, toolCallSearchQuery, + toolSearchPresentation, } from './tool-result-content'; +import { ToolSearchResult } from './tool-search'; /** Host-provided replacement for the static `TerminalBlock` (e.g. the live daemon-backed one). */ export type TerminalBlockComponent = React.ComponentType<{ @@ -55,26 +57,20 @@ function RenderedContent({ return ; } +/** The expanded card is the raw query's only home — headers summarize counts instead. */ function SearchRows({ toolCall, text }: { toolCall: ToolCall; text: string }): React.ReactNode { - let resultCount = 0; - let lineStart = 0; - for (let index = 0; index < text.length; index += 1) { - if (text.codePointAt(index) !== 10) continue; - if (index > lineStart) resultCount += 1; - lineStart = index + 1; - } - if (lineStart < text.length) resultCount += 1; // Search adapters return paths, grep-style lines, or prose. Preserve their text as one node: // splitting an unbounded grep result into rows can freeze the Electron renderer. return ( -
-        {text}
-      
+ {text ? ( +
+          {text}
+        
+ ) : null}
); } @@ -325,7 +321,12 @@ export function ToolResultPreview({ toolCall, TerminalBlockComponent, }: ToolResultPreviewProps): React.ReactNode { + const toolSearch = toolSearchPresentation(toolCall); + if (toolSearch) return ; const content = toolCallDisplayContent(toolCall); + if (toolCall.kind === 'search' && content.length === 0 && toolCallSearchQuery(toolCall)) { + return ; + } const file = toolCallFilePresentation(toolCall); if (file) { const hasDiff = content.some((item) => item.type === 'diff'); diff --git a/packages/presentation/ui/src/chat/tool-search.tsx b/packages/presentation/ui/src/chat/tool-search.tsx new file mode 100644 index 000000000..ab9f84a4e --- /dev/null +++ b/packages/presentation/ui/src/chat/tool-search.tsx @@ -0,0 +1,32 @@ +import { Fragment } from 'react'; +import { mcpToolName } from '../tool-utils'; +import type { ToolSearchPresentation } from './tool-result-content'; + +/** A ToolSearch settle: the loaded tools as one inline line (the humanized header already says + * what happened); MCP slugs shed their envelope, keeping the server as a muted suffix. */ +export function ToolSearchResult({ + presentation, +}: { + presentation: ToolSearchPresentation; +}): React.ReactNode { + const { names, message } = presentation; + if (names.length === 0) { + return message ? ( +

{message}

+ ) : null; + } + return ( +

+ {names.map((name, index) => { + const mcp = mcpToolName(name); + return ( + + {index > 0 ? ', ' : null} + {mcp?.tool ?? name} + {mcp ? ({mcp.server}) : null} + + ); + })} +

+ ); +} diff --git a/packages/presentation/ui/src/tool-utils.ts b/packages/presentation/ui/src/tool-utils.ts index 8913f42b3..ccf4905f1 100644 --- a/packages/presentation/ui/src/tool-utils.ts +++ b/packages/presentation/ui/src/tool-utils.ts @@ -10,6 +10,7 @@ import { toolCallFetchStatus, toolCallFetchUrl, toolCallSearchQuery, + toolSearchPresentation, } from './chat/tool-result-content'; export { toolCallDisplayContent } from './chat/tool-result-content'; @@ -97,10 +98,13 @@ export function toolCallMetadata(toolCall: ToolCall): ToolMetadata[] { const metadata: ToolMetadata[] = []; const query = toolCallSearchQuery(toolCall); if (query) metadata.push({ key: 'query', value: query }); - const matches = countValue(output?.matches); - if (matches !== undefined) metadata.push({ key: 'matches', value: String(matches) }); - const files = countValue(output?.files); - if (files !== undefined) metadata.push({ key: 'files', value: String(files) }); + const counts = toolCallSearchCounts(toolCall); + if (counts?.matches !== undefined) { + metadata.push({ key: 'matches', value: String(counts.matches) }); + } + if (counts?.files !== undefined) { + metadata.push({ key: 'files', value: String(counts.files) }); + } return metadata; } case 'fetch': { @@ -159,6 +163,23 @@ function toolCallParamMetadata(toolCall: ToolCall): ToolMetadata[] { return metadata; } +export interface ToolCallSearchCounts { + matches?: number; + files?: number; +} + +/** Settle counts for a search call's header. Claude's Grep envelope uses `numMatches`/`numFiles` + * scalars; mock and other adapters may carry `matches`/`files` arrays or numbers. */ +export function toolCallSearchCounts(toolCall: ToolCall): ToolCallSearchCounts | undefined { + if (toolCall.kind !== 'search') return undefined; + const output = recordValue(toolCall.rawOutput); + if (!output) return undefined; + const matches = countValue(output.numMatches) ?? countValue(output.matches); + const files = countValue(output.numFiles) ?? countValue(output.files); + if (matches === undefined && files === undefined) return undefined; + return { matches, files }; +} + export interface ToolCallHeaderSummary { label: string; tooltip?: string; @@ -179,8 +200,10 @@ export function toolCallHeaderSummary(toolCall: ToolCall): ToolCallHeaderSummary if (file) return { label: file.label, tooltip: file.tooltip }; break; } + // A counted settle humanizes in the localized header and the raw query stays in the body + // card; an uncounted search (WebSearch, in-progress) keeps the query — its only context. case 'search': - label = toolCallSearchQuery(toolCall); + if (!toolCallSearchCounts(toolCall)) label = toolCallSearchQuery(toolCall); break; case 'fetch': label = toolCallFetchUrl(toolCall); @@ -211,5 +234,8 @@ export function hasToolBody(toolCall: ToolCall): boolean { if (toolCallCommand(toolCall)) return true; if (toolCallExecuteText(toolCall)) return true; } + if (toolSearchPresentation(toolCall)) { + return toolCallFailureMessage(toolCall) !== undefined; + } return toolCallMetadata(toolCall).length > 0 || toolCallFailureMessage(toolCall) !== undefined; }