From 3226492b454b9d9e6447f7167d478c8f8fb0f82d Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 3 Aug 2026 00:02:22 +0800 Subject: [PATCH 01/10] test(engine): isolate git fixture signing --- packages/host/engine/tests/integration/git-mutations.test.ts | 1 + 1 file changed, 1 insertion(+) 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'); From 369498bbaec903f6e2fd95f4af6c0ba34aa8462d Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 31 Jul 2026 19:19:53 +0800 Subject: [PATCH 02/10] feat(ui,workbench,i18n): humanize search tool rows and render ToolSearch results inline --- .../workbench/src/mock/data/showcase.ts | 41 +++++-- packages/presentation/i18n/src/locales/en.ts | 10 ++ .../presentation/i18n/src/locales/zh-cn.ts | 10 ++ .../__tests__/tool-call-metadata.test.tsx | 35 +++++- .../__tests__/tool-result-content.test.ts | 63 +++++++++++ .../src/chat/__tests__/tool-search.test.tsx | 104 ++++++++++++++++++ .../ui/src/chat/tool-call-item.tsx | 36 +++++- .../ui/src/chat/tool-result-content.ts | 29 +++++ .../ui/src/chat/tool-result-preview.tsx | 14 +-- .../presentation/ui/src/chat/tool-search.tsx | 31 ++++++ packages/presentation/ui/src/tool-utils.ts | 35 ++++-- 11 files changed, 371 insertions(+), 37 deletions(-) create mode 100644 packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx create mode 100644 packages/presentation/ui/src/chat/tool-search.tsx 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/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 9ddcecb9..462afc5b 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -156,6 +156,16 @@ export const en = { failed: 'Failed', expand: 'Expand', collapse: 'Collapse', + toolSearch: { + selecting: 'Selecting tools', + selected: 'Selected {count, plural, one {a tool} other {# tools}}', + 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..50e18335 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -152,6 +152,16 @@ export const zhCN = { failed: '失败', expand: '展开', collapse: '收起', + toolSearch: { + selecting: '正在选择工具', + selected: '{count, plural, =1 {已选择一个工具} other {已选择 # 个工具}}', + 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..9864b530 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 @@ -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,14 @@ describe('tool metadata policy', () => { content: [], }; + expect(toolCallMetadata(toolCall)).toEqual([]); + 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 +69,25 @@ 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 }); + + render(); + + expect(screen.getByText('· searchSummary.matches · searchSummary.files')).toBeDefined(); + expect(screen.queryByText('permission-request|tool-call|plan')).toBeNull(); + }); + it('previews an allowlisted fetch response without exposing its envelopes', () => { const toolCall: ToolCall = { toolCallId: 'fetch-1', @@ -329,7 +351,8 @@ describe('tool metadata policy', () => { expect(calls.map(toolCallHeaderSummary)).toEqual([ { label: 'README.md:3', tooltip: 'README.md:3' }, - { label: 'ToolCallBody' }, + // Search queries are raw machine strings and never summarize the header. + undefined, { 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..e77f95ed 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,65 @@ 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('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..45b2ba40 --- /dev/null +++ b/packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom + +import type { ToolCall } from '@linkcode/schema'; +import { cleanup, 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 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(); + }); +}); diff --git a/packages/presentation/ui/src/chat/tool-call-item.tsx b/packages/presentation/ui/src/chat/tool-call-item.tsx index d5d5f135..3b865d7e 100644 --- a/packages/presentation/ui/src/chat/tool-call-item.tsx +++ b/packages/presentation/ui/src/chat/tool-call-item.tsx @@ -9,9 +9,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 { @@ -88,10 +89,39 @@ 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 = toolCall.status === 'pending' || toolCall.status === 'in_progress'; + + // 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); + if (toolSearch) { + title = + toolSearch.mode === 'select' + ? running + ? tt('toolSearch.selecting') + : tt('toolSearch.selected', { count: toolSearch.names.length }) + : running + ? tt('toolSearch.searching') + : tt('toolSearch.searched'); + 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 }; + } return ( diff --git a/packages/presentation/ui/src/chat/tool-result-content.ts b/packages/presentation/ui/src/chat/tool-result-content.ts index e1e9e480..6894c21a 100644 --- a/packages/presentation/ui/src/chat/tool-result-content.ts +++ b/packages/presentation/ui/src/chat/tool-result-content.ts @@ -64,6 +64,35 @@ 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 (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..7e674a7a 100644 --- a/packages/presentation/ui/src/chat/tool-result-preview.tsx +++ b/packages/presentation/ui/src/chat/tool-result-preview.tsx @@ -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,20 +57,12 @@ 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 ( @@ -325,6 +319,8 @@ export function ToolResultPreview({ toolCall, TerminalBlockComponent, }: ToolResultPreviewProps): React.ReactNode { + const toolSearch = toolSearchPresentation(toolCall); + if (toolSearch) return ; const content = toolCallDisplayContent(toolCall); const file = toolCallFilePresentation(toolCall); if (file) { 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..31a0e390 --- /dev/null +++ b/packages/presentation/ui/src/chat/tool-search.tsx @@ -0,0 +1,31 @@ +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, so the body is only the result). MCP slugs shed their `mcp____` envelope + * like tool headers do, 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..58b7af40 100644 --- a/packages/presentation/ui/src/tool-utils.ts +++ b/packages/presentation/ui/src/tool-utils.ts @@ -9,7 +9,6 @@ import { toolCallExecuteText, toolCallFetchStatus, toolCallFetchUrl, - toolCallSearchQuery, } from './chat/tool-result-content'; export { toolCallDisplayContent } from './chat/tool-result-content'; @@ -93,16 +92,10 @@ export function toolCallMetadata(toolCall: ToolCall): ToolMetadata[] { case 'delete': case 'move': return []; - case 'search': { - 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) }); - return metadata; - } + // Search rows carry no badges: the header summary owns the counts, and the raw query lives + // only in the expanded result card. + case 'search': + return []; case 'fetch': { const metadata: ToolMetadata[] = []; const url = toolCallFetchUrl(toolCall); @@ -159,6 +152,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 +189,9 @@ export function toolCallHeaderSummary(toolCall: ToolCall): ToolCallHeaderSummary if (file) return { label: file.label, tooltip: file.tooltip }; break; } + // Search queries are raw machine strings (regexes, select: lists) — the localized header + // composes counts via toolCallSearchCounts instead, and the query stays in the body card. case 'search': - label = toolCallSearchQuery(toolCall); break; case 'fetch': label = toolCallFetchUrl(toolCall); From 2642d69766de75c8b60cb7803699e22b8a2b58a6 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sun, 2 Aug 2026 21:53:28 +0800 Subject: [PATCH 03/10] feat(ui): give search and ToolSearch rows expressive icons --- packages/presentation/ui/src/chat/activity-run.tsx | 4 ++-- packages/presentation/ui/src/chat/tool-call-item.tsx | 12 +++++++++++- packages/presentation/ui/src/chat/tool-kind-icons.ts | 4 ++-- .../presentation/ui/src/chat/tool-result-preview.tsx | 4 ++-- 4 files changed, 17 insertions(+), 7 deletions(-) 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 3b865d7e..a411bdcd 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, @@ -99,6 +101,14 @@ export function ToolCallItem({ 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' @@ -131,7 +141,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-preview.tsx b/packages/presentation/ui/src/chat/tool-result-preview.tsx index 7e674a7a..392ba4aa 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'; @@ -63,7 +63,7 @@ function SearchRows({ toolCall, text }: { toolCall: ToolCall; text: string }): R // splitting an unbounded grep result into rows can freeze the Electron renderer. return (

From 1d54c812e83900ec158a3afe8669363fb019e3cf Mon Sep 17 00:00:00 2001
From: Zerlight Wu 
Date: Mon, 3 Aug 2026 00:03:12 +0800
Subject: [PATCH 04/10] fix(ui): preserve search context across states

---
 packages/presentation/i18n/src/locales/en.ts  |  2 +
 .../presentation/i18n/src/locales/zh-cn.ts    |  2 +
 .../__tests__/tool-call-metadata.test.tsx     | 53 ++++++++++++++++++-
 .../__tests__/tool-result-content.test.ts     | 14 +++++
 .../src/chat/__tests__/tool-search.test.tsx   | 30 ++++++++++-
 .../ui/src/chat/tool-call-item.tsx            | 19 +++++--
 .../ui/src/chat/tool-result-content.ts        |  6 ++-
 .../ui/src/chat/tool-result-preview.tsx       | 11 ++--
 packages/presentation/ui/src/tool-utils.ts    | 22 ++++++--
 9 files changed, 143 insertions(+), 16 deletions(-)

diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts
index 462afc5b..c91df4f0 100644
--- a/packages/presentation/i18n/src/locales/en.ts
+++ b/packages/presentation/i18n/src/locales/en.ts
@@ -157,8 +157,10 @@ export const en = {
       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',
       },
diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts
index 50e18335..701d1cd6 100644
--- a/packages/presentation/i18n/src/locales/zh-cn.ts
+++ b/packages/presentation/i18n/src/locales/zh-cn.ts
@@ -153,8 +153,10 @@ export const zhCN = {
       expand: '展开',
       collapse: '收起',
       toolSearch: {
+        select: '工具选择',
         selecting: '正在选择工具',
         selected: '{count, plural, =1 {已选择一个工具} other {已选择 # 个工具}}',
+        search: '工具搜索',
         searching: '正在搜索工具',
         searched: '已搜索工具',
       },
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 9864b530..7829ec95 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,
@@ -51,7 +51,11 @@ describe('tool metadata policy', () => {
       content: [],
     };
 
-    expect(toolCallMetadata(toolCall)).toEqual([]);
+    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();
@@ -81,6 +85,11 @@ describe('tool metadata policy', () => {
     };
 
     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();
 
@@ -88,6 +97,46 @@ describe('tool metadata policy', () => {
     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 output-less search query in an 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();
+
+    expect(container.querySelector('button')?.textContent).not.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('previews an allowlisted fetch response without exposing its envelopes', () => {
     const toolCall: ToolCall = {
       toolCallId: 'fetch-1',
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 e77f95ed..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
@@ -171,6 +171,20 @@ describe('tool search presentation', () => {
     });
   });
 
+  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',
diff --git a/packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx b/packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx
index 45b2ba40..aac2db2b 100644
--- a/packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx
+++ b/packages/presentation/ui/src/chat/__tests__/tool-search.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 } from '../../tool-utils';
 import { ToolCallBody, ToolCallItem } from '../tool-call-item';
@@ -101,4 +101,32 @@ describe('tool search presentation', () => {
     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/tool-call-item.tsx b/packages/presentation/ui/src/chat/tool-call-item.tsx
index a411bdcd..a57b3935 100644
--- a/packages/presentation/ui/src/chat/tool-call-item.tsx
+++ b/packages/presentation/ui/src/chat/tool-call-item.tsx
@@ -52,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 ? (
@@ -93,7 +94,8 @@ export function ToolCallItem({
   const hasBody = hasToolBody(toolCall);
   const diffTotals = toolCallDiffStats(toolCall);
   const mcp = mcpToolName(toolCall.title);
-  const running = toolCall.status === 'pending' || toolCall.status === 'in_progress';
+  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.
@@ -114,10 +116,14 @@ export function ToolCallItem({
       toolSearch.mode === 'select'
         ? running
           ? tt('toolSearch.selecting')
-          : tt('toolSearch.selected', { count: toolSearch.names.length })
+          : completed
+            ? tt('toolSearch.selected', { count: toolSearch.names.length })
+            : tt('toolSearch.select')
         : running
           ? tt('toolSearch.searching')
-          : tt('toolSearch.searched');
+          : completed
+            ? tt('toolSearch.searched')
+            : tt('toolSearch.search');
     summary = toolSearch.mode === 'search' ? { label: toolSearch.query } : undefined;
   } else if (searchCounts) {
     const label = [
@@ -130,7 +136,10 @@ export function ToolCallItem({
     ]
       .filter((part) => part !== undefined)
       .join(' · ');
-    summary = { label };
+    summary = {
+      label: summary ? `${summary.label} · ${label}` : label,
+      tooltip: summary?.tooltip,
+    };
   }
 
   return (
diff --git a/packages/presentation/ui/src/chat/tool-result-content.ts b/packages/presentation/ui/src/chat/tool-result-content.ts
index 6894c21a..0ef93ced 100644
--- a/packages/presentation/ui/src/chat/tool-result-content.ts
+++ b/packages/presentation/ui/src/chat/tool-result-content.ts
@@ -87,7 +87,11 @@ export function toolSearchPresentation(toolCall: ToolCall): ToolSearchPresentati
   const mode = query.startsWith('select:') ? 'select' : 'search';
   const text = toolCallDisplayText(toolCall);
   const lines = [...new Set(text.split('\n').filter((line) => line.length > 0))];
-  if (lines.length > 0 && lines.every((line) => TOOL_NAME_LINE_RE.test(line))) {
+  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 };
diff --git a/packages/presentation/ui/src/chat/tool-result-preview.tsx b/packages/presentation/ui/src/chat/tool-result-preview.tsx
index 392ba4aa..cb6b29f4 100644
--- a/packages/presentation/ui/src/chat/tool-result-preview.tsx
+++ b/packages/presentation/ui/src/chat/tool-result-preview.tsx
@@ -66,9 +66,11 @@ function SearchRows({ toolCall, text }: { toolCall: ToolCall; text: string }): R
       icon={TextSearchIcon}
       title={toolCallSearchQuery(toolCall) ?? toolCallDisplayTitle(toolCall)}
     >
-      
-        {text}
-      
+ {text ? ( +
+          {text}
+        
+ ) : null} ); } @@ -322,6 +324,9 @@ export function ToolResultPreview({ 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/tool-utils.ts b/packages/presentation/ui/src/tool-utils.ts index 58b7af40..be80a233 100644 --- a/packages/presentation/ui/src/tool-utils.ts +++ b/packages/presentation/ui/src/tool-utils.ts @@ -9,6 +9,8 @@ import { toolCallExecuteText, toolCallFetchStatus, toolCallFetchUrl, + toolCallSearchQuery, + toolSearchPresentation, } from './chat/tool-result-content'; export { toolCallDisplayContent } from './chat/tool-result-content'; @@ -92,10 +94,19 @@ export function toolCallMetadata(toolCall: ToolCall): ToolMetadata[] { case 'delete': case 'move': return []; - // Search rows carry no badges: the header summary owns the counts, and the raw query lives - // only in the expanded result card. - case 'search': - return []; + case 'search': { + const metadata: ToolMetadata[] = []; + const query = toolCallSearchQuery(toolCall); + if (query) metadata.push({ key: 'query', value: query }); + 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': { const metadata: ToolMetadata[] = []; const url = toolCallFetchUrl(toolCall); @@ -222,5 +233,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; } From 2fb82cbaea3dd6bac02f507ae3fa5b36ca289957 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 3 Aug 2026 15:08:12 +0800 Subject: [PATCH 05/10] fix(agent-adapter): normalize codex MCP tool titles to the shared mcp slug --- .../src/__tests__/codex-mcp-tools.test.ts | 82 +++++++++++++++++++ .../agent-adapter/src/native/codex/adapter.ts | 18 +++- 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 packages/host/agent-adapter/src/__tests__/codex-mcp-tools.test.ts 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..89f0a2e6 --- /dev/null +++ b/packages/host/agent-adapter/src/__tests__/codex-mcp-tools.test.ts @@ -0,0 +1,82 @@ +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' }, + }); + 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', + ]); + }); +}); diff --git a/packages/host/agent-adapter/src/native/codex/adapter.ts b/packages/host/agent-adapter/src/native/codex/adapter.ts index b39bd886..0257654d 100644 --- a/packages/host/agent-adapter/src/native/codex/adapter.ts +++ b/packages/host/agent-adapter/src/native/codex/adapter.ts @@ -155,6 +155,8 @@ export type CodexServerHandle = Pick__` slug — the UI's server/tool join key — instead + // of codex's raw `server.tool`. Plugin apps all mount under the one `codex_apps` server + // with the plugin in the tool's first segment; surface the plugin as the server. + let server = stringField(item, 'server') ?? 'mcp'; + let tool = stringField(item, 'tool') ?? 'tool'; + if (server === CODEX_PLUGIN_APPS_SERVER) { + const plugin = tool.indexOf('.'); + if (plugin > 0 && plugin < tool.length - 1) { + server = tool.slice(0, plugin); + tool = tool.slice(plugin + 1); + } + } this.emitTool({ toolCallId: id, - title: `${server}.${tool}`, + title: `mcp__${server}__${tool}`, kind: 'other', status: mapCodexItemStatus(stringField(item, 'status')), content: [], From 1e8785caec2c40a9f3db52c54a7cbeeebbcbe721 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 4 Aug 2026 10:41:35 +0800 Subject: [PATCH 06/10] fix(ui): keep ToolSearch select wording neutral without result rows --- .../ui/src/chat/__tests__/tool-search.test.tsx | 10 ++++++++++ packages/presentation/ui/src/chat/tool-call-item.tsx | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx b/packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx index aac2db2b..cd7274cf 100644 --- a/packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx +++ b/packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx @@ -93,6 +93,16 @@ describe('tool search presentation', () => { 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 }); diff --git a/packages/presentation/ui/src/chat/tool-call-item.tsx b/packages/presentation/ui/src/chat/tool-call-item.tsx index a57b3935..0b7038eb 100644 --- a/packages/presentation/ui/src/chat/tool-call-item.tsx +++ b/packages/presentation/ui/src/chat/tool-call-item.tsx @@ -116,7 +116,9 @@ export function ToolCallItem({ toolSearch.mode === 'select' ? running ? tt('toolSearch.selecting') - : completed + : // 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 From ef1855016e15ccc3bb8531c759d81e4beb89ae34 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 4 Aug 2026 10:43:01 +0800 Subject: [PATCH 07/10] fix(agent-adapter): normalize codex MCP rollout replay titles to the live mcp slug --- .../src/__tests__/codex-history.test.ts | 47 +++++++++++++++++++ .../src/native/codex/history-tools.ts | 40 ++++++++++++++++ 2 files changed, 87 insertions(+) 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..61b3b30b 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,53 @@ 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: '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'], + ['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/native/codex/history-tools.ts b/packages/host/agent-adapter/src/native/codex/history-tools.ts index 7ff67542..8b02230e 100644 --- a/packages/host/agent-adapter/src/native/codex/history-tools.ts +++ b/packages/host/agent-adapter/src/native/codex/history-tools.ts @@ -74,6 +74,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: `mcp__${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 +129,31 @@ export function codexToolAnnounce( }; } +const MCP_NAMESPACE_PREFIX = 'mcp__'; +const PLUGIN_APPS_NAMESPACE_PREFIX = 'codex_apps__'; + +/** Rollout MCP rows are `function_call`s whose sibling `namespace` is `mcp__` (observed + * with a stray trailing `__` on some rows); `name` is the bare tool. Plugin apps namespace as + * `mcp__codex_apps__` with one leading `_` on the tool name — surface the app as the + * server, like the live adapter does. Built-ins carry no namespace or a non-`mcp__` one. + * (Verified against real 0.131–0.146 rollouts, 2026-08.) */ +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( From 9a3fcb97d5dfa7b3d069218f74c1cae70c67d955 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sun, 9 Aug 2026 15:58:00 +0800 Subject: [PATCH 08/10] fix(ui): keep uncounted search queries as the header summary --- .../__tests__/tool-call-metadata.test.tsx | 27 ++++++++++++++++--- .../presentation/ui/src/chat/tool-search.tsx | 3 +-- packages/presentation/ui/src/tool-utils.ts | 5 ++-- 3 files changed, 27 insertions(+), 8 deletions(-) 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 7829ec95..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 @@ -115,7 +115,7 @@ describe('tool metadata policy', () => { expect(screen.queryByText('ToolCallItem')).toBeNull(); }); - it('keeps an output-less search query in an expandable body card', () => { + it('keeps an uncounted search query in the header and its expandable body card', () => { const toolCall: ToolCall = { toolCallId: 'search-empty', title: 'Grep', @@ -129,7 +129,8 @@ describe('tool metadata policy', () => { const { container } = render(); - expect(container.querySelector('button')?.textContent).not.toContain( + // 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')); @@ -137,6 +138,24 @@ describe('tool metadata policy', () => { 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', @@ -400,8 +419,8 @@ describe('tool metadata policy', () => { expect(calls.map(toolCallHeaderSummary)).toEqual([ { label: 'README.md:3', tooltip: 'README.md:3' }, - // Search queries are raw machine strings and never summarize the header. - undefined, + // 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/tool-search.tsx b/packages/presentation/ui/src/chat/tool-search.tsx index 31a0e390..45ad3524 100644 --- a/packages/presentation/ui/src/chat/tool-search.tsx +++ b/packages/presentation/ui/src/chat/tool-search.tsx @@ -3,8 +3,7 @@ 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, so the body is only the result). MCP slugs shed their `mcp____` envelope - * like tool headers do, keeping the server as a muted suffix. */ + * what happened); MCP slugs shed their envelope, keeping the server as a muted suffix. */ export function ToolSearchResult({ presentation, }: { diff --git a/packages/presentation/ui/src/tool-utils.ts b/packages/presentation/ui/src/tool-utils.ts index be80a233..ccf4905f 100644 --- a/packages/presentation/ui/src/tool-utils.ts +++ b/packages/presentation/ui/src/tool-utils.ts @@ -200,9 +200,10 @@ export function toolCallHeaderSummary(toolCall: ToolCall): ToolCallHeaderSummary if (file) return { label: file.label, tooltip: file.tooltip }; break; } - // Search queries are raw machine strings (regexes, select: lists) — the localized header - // composes counts via toolCallSearchCounts instead, and the query stays in the body card. + // 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': + if (!toolCallSearchCounts(toolCall)) label = toolCallSearchQuery(toolCall); break; case 'fetch': label = toolCallFetchUrl(toolCall); From c4fa4a177ecf3b86ee79fc1842b2005209b4642a Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sun, 9 Aug 2026 15:59:32 +0800 Subject: [PATCH 09/10] fix(agent-adapter): keep codex's dotted title for __-bearing MCP server names --- .../src/__tests__/codex-history.test.ts | 9 +++++++ .../src/__tests__/codex-mcp-tools.test.ts | 5 ++++ .../agent-adapter/src/native/codex/adapter.ts | 27 ++++++++----------- .../src/native/codex/history-tools.ts | 10 +++---- .../src/native/codex/tool-view.ts | 18 +++++++++++++ 5 files changed, 47 insertions(+), 22 deletions(-) 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 61b3b30b..b80947bc 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-history.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-history.test.ts @@ -443,6 +443,13 @@ describe('mapCodexHistoryEvents', () => { 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', @@ -458,6 +465,8 @@ describe('mapCodexHistoryEvents', () => { ['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'); 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 index 89f0a2e6..b12aea16 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-mcp-tools.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-mcp-tools.test.ts @@ -70,6 +70,10 @@ describe('CodexAdapter mcpToolCall items', () => { 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. @@ -77,6 +81,7 @@ describe('CodexAdapter mcpToolCall items', () => { '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 0257654d..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 { @@ -155,8 +161,6 @@ export type CodexServerHandle = Pick__` slug — the UI's server/tool join key — instead - // of codex's raw `server.tool`. Plugin apps all mount under the one `codex_apps` server - // with the plugin in the tool's first segment; surface the plugin as the server. - let server = stringField(item, 'server') ?? 'mcp'; - let tool = stringField(item, 'tool') ?? 'tool'; - if (server === CODEX_PLUGIN_APPS_SERVER) { - const plugin = tool.indexOf('.'); - if (plugin > 0 && plugin < tool.length - 1) { - server = tool.slice(0, plugin); - tool = tool.slice(plugin + 1); - } - } this.emitTool({ toolCallId: id, - title: `mcp__${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 8b02230e..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, @@ -81,7 +82,7 @@ export function codexToolAnnounce( return { toolCall: { toolCallId: callId, - title: `mcp__${mcp.server}__${mcp.tool}`, + title: codexMcpSlug(mcp.server, mcp.tool), kind: 'other', status: 'in_progress', content: [], @@ -132,11 +133,8 @@ export function codexToolAnnounce( const MCP_NAMESPACE_PREFIX = 'mcp__'; const PLUGIN_APPS_NAMESPACE_PREFIX = 'codex_apps__'; -/** Rollout MCP rows are `function_call`s whose sibling `namespace` is `mcp__` (observed - * with a stray trailing `__` on some rows); `name` is the bare tool. Plugin apps namespace as - * `mcp__codex_apps__` with one leading `_` on the tool name — surface the app as the - * server, like the live adapter does. Built-ins carry no namespace or a non-`mcp__` one. - * (Verified against real 0.131–0.146 rollouts, 2026-08.) */ +/** 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 { 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..0b6bd761 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 } }]; } +export 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: { From 794b93550c40fd6dea64da9ca4ba30a394978bfc Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sun, 9 Aug 2026 19:51:35 +0800 Subject: [PATCH 10/10] refactor(agent-adapter): keep the codex plugin-apps server name module-private --- packages/host/agent-adapter/src/native/codex/tool-view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0b6bd761..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,7 +21,7 @@ export function textContent(text: string): ToolCallContent[] { return [{ type: 'content', content: { type: 'text', text } }]; } -export const CODEX_PLUGIN_APPS_SERVER = 'codex_apps'; +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. */