diff --git a/packages/client/workbench/src/mock/data/showcase.ts b/packages/client/workbench/src/mock/data/showcase.ts index 2594af43..452d9e43 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__/codex-history.test.ts b/packages/host/agent-adapter/src/__tests__/codex-history.test.ts index 8b153364..b80947bc 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,62 @@ describe('mapCodexHistoryEvents', () => { ]); }); + it("replays MCP calls under the live adapter's mcp slug, unwrapping the plugin namespace", () => { + // Real rollout shapes: the server rides `namespace` (`mcp__`, sometimes with a stray + // trailing `__`); plugin apps namespace as `mcp__codex_apps__` with a leading-`_` tool. + 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', 'mcp__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('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 00000000..b12aea16 --- /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/codex/adapter.ts b/packages/host/agent-adapter/src/native/codex/adapter.ts index b39bd886..5d3252c0 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 7ff67542..e5989754 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, @@ -74,6 +75,21 @@ export function codexToolAnnounce( // function_call: JSON-encoded `arguments`. const args = parseArguments(payload); + const mcp = codexMcpToolName(payload); + 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 +130,28 @@ export function codexToolAnnounce( }; } +const MCP_NAMESPACE_PREFIX = 'mcp__'; +const PLUGIN_APPS_NAMESPACE_PREFIX = 'codex_apps__'; + +/** Rollout MCP rows: `namespace` is `mcp__` (a stray trailing `__` on some real rows), + * `name` the bare tool; plugin apps namespace as `mcp__codex_apps__` with a `_`-led tool. */ +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); + } else if (server.endsWith('__')) { + server = server.slice(0, -2); + } + 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/tool-view.ts b/packages/host/agent-adapter/src/native/codex/tool-view.ts index 4d8b1326..006100d3 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 55340002..95067fc9 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 9ddcecb9..c91df4f0 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 6978c123..701d1cd6 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 3761714b..5e13d39d 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 1075971f..550a07f0 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 00000000..cd7274cf --- /dev/null +++ b/packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx @@ -0,0 +1,142 @@ +// @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('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 cbb6e282..678e1b1f 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 d5d5f135..0b7038eb 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 ad379469..d80db011 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 e1e9e480..0ef93ced 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 6db5394c..cb6b29f4 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 00000000..45ad3524 --- /dev/null +++ b/packages/presentation/ui/src/chat/tool-search.tsx @@ -0,0 +1,30 @@ +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 8913f42b..ccf4905f 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; }