From 528d9e0d12bdb09ae450bfa36b38454eed138d7d Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 3 Aug 2026 00:02:22 +0800 Subject: [PATCH 01/12] 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 55340002e..95067fc98 100644 --- a/packages/host/engine/tests/integration/git-mutations.test.ts +++ b/packages/host/engine/tests/integration/git-mutations.test.ts @@ -22,6 +22,7 @@ function makeRepo(): string { git(cwd, 'init', '-b', 'main'); git(cwd, 'config', 'user.email', 'test@test'); git(cwd, 'config', 'user.name', 'test'); + git(cwd, 'config', 'commit.gpgsign', 'false'); writeFileSync(join(cwd, 'file.txt'), 'one\n'); git(cwd, 'add', '--all'); git(cwd, 'commit', '-m', 'initial'); From 4832405ee84f136727afdd760c55003c63e102a5 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Fri, 31 Jul 2026 19:19:53 +0800 Subject: [PATCH 02/12] 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 2594af43f..452d9e439 100644 --- a/packages/client/workbench/src/mock/data/showcase.ts +++ b/packages/client/workbench/src/mock/data/showcase.ts @@ -384,21 +384,48 @@ export function createShowcaseToolBursts(terminalId = SHOWCASE_TERMINAL_ID): Sho title: 'Search chat renderers', kind: 'search', status: 'completed', - content: [], + content: [ + { + type: 'content', + content: textBlock( + 'packages/presentation/ui/src/chat/conversation-view.tsx\npackages/client/core/src/conversation.ts', + ), + }, + ], rawInput: { query: 'permission-request|tool-call|plan', glob: '**/*.{ts,tsx}', cwd: '/mock/linkcode', }, + // Claude's real Grep envelope: scalar counts, no matches array. + rawOutput: { mode: 'files_with_matches', numFiles: 2, numMatches: 12 }, + }, + { + toolCallId: 'mock-tool-toolsearch-select', + title: 'ToolSearch', + kind: 'search', + status: 'completed', + content: [ + { + type: 'content', + content: textBlock('WebSearch\nmcp__linear__get_issue\nmcp__linear__save_issue'), + }, + ], + rawInput: { query: 'select:WebSearch,mcp__linear__get_issue,mcp__linear__save_issue' }, rawOutput: { - matches: [ - 'packages/presentation/ui/src/chat/conversation-view.tsx', - 'packages/client/core/src/conversation.ts', - ], - files: 2, - elapsedMs: 17, + query: 'select:WebSearch,mcp__linear__get_issue,mcp__linear__save_issue', + total_deferred_tools: 110, }, }, + { + toolCallId: 'mock-tool-toolsearch-empty', + title: 'ToolSearch', + kind: 'search', + status: 'completed', + content: [{ type: 'content', content: textBlock('No matching deferred tools found') }], + rawInput: { query: '+jupyter notebook edit', max_results: 5 }, + rawOutput: { query: '+jupyter notebook edit', total_deferred_tools: 110 }, + }, ], files: [ { diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 88fa8eb0e..9ac49dc49 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 85adeac7c..4bd5e41d2 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 3761714b6..9864b5302 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 1075971f2..e77f95ede 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 000000000..45b2ba404 --- /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 d5d5f1354..3b865d7e7 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 e1e9e480d..6894c21ab 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 6db5394c0..7e674a7a0 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 000000000..31a0e3901 --- /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 8913f42b3..58b7af402 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 d4a6076f56d4b785fe325f639c0bab3cbe414ac1 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sun, 2 Aug 2026 21:53:28 +0800 Subject: [PATCH 03/12] 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 cbb6e282a..678e1b1f0 100644 --- a/packages/presentation/ui/src/chat/activity-run.tsx +++ b/packages/presentation/ui/src/chat/activity-run.tsx @@ -1,5 +1,5 @@ import { Collapsible, CollapsibleTrigger } from 'coss-ui/components/collapsible'; -import { PencilIcon, SearchIcon, SparklesIcon, TerminalIcon, WrenchIcon } from 'lucide-react'; +import { PencilIcon, SparklesIcon, TelescopeIcon, TerminalIcon, WrenchIcon } from 'lucide-react'; import { useState } from 'react'; import { useTranslations } from 'use-intl'; import { cn } from '../lib/cn'; @@ -189,7 +189,7 @@ const ACTIVITY_ICONS: Record< files: PencilIcon, integration: WrenchIcon, command: TerminalIcon, - explore: SearchIcon, + explore: TelescopeIcon, thinking: SparklesIcon, }; diff --git a/packages/presentation/ui/src/chat/tool-call-item.tsx b/packages/presentation/ui/src/chat/tool-call-item.tsx index 3b865d7e7..a411bdcdd 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 ad379469c..d80db011f 100644 --- a/packages/presentation/ui/src/chat/tool-kind-icons.ts +++ b/packages/presentation/ui/src/chat/tool-kind-icons.ts @@ -5,9 +5,9 @@ import { FileTextIcon, GlobeIcon, PencilIcon, - SearchIcon, SparklesIcon, TerminalIcon, + TextSearchIcon, Trash2Icon, WrenchIcon, } from 'lucide-react'; @@ -20,7 +20,7 @@ export const TOOL_KIND_ICONS: Record< edit: PencilIcon, delete: Trash2Icon, move: FileOutputIcon, - search: SearchIcon, + search: TextSearchIcon, execute: TerminalIcon, think: SparklesIcon, fetch: GlobeIcon, diff --git a/packages/presentation/ui/src/chat/tool-result-preview.tsx b/packages/presentation/ui/src/chat/tool-result-preview.tsx index 7e674a7a0..392ba4aa2 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 3ffcf86261d85662800d620d94c09eed266accbc Mon Sep 17 00:00:00 2001
From: Zerlight Wu 
Date: Mon, 3 Aug 2026 00:03:12 +0800
Subject: [PATCH 04/12] 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 9ac49dc49..3416e09e6 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 4bd5e41d2..cc6bf9ab5 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 9864b5302..7829ec956 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 e77f95ede..550a07f0d 100644
--- a/packages/presentation/ui/src/chat/__tests__/tool-result-content.test.ts
+++ b/packages/presentation/ui/src/chat/__tests__/tool-result-content.test.ts
@@ -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 45b2ba404..aac2db2be 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 a411bdcdd..a57b3935b 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 6894c21ab..0ef93ced5 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 392ba4aa2..cb6b29f4f 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 58b7af402..be80a233a 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 19ab81cb9b1da5d6144958b2741e955d06cfc193 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Mon, 3 Aug 2026 15:08:12 +0800 Subject: [PATCH 05/12] 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 000000000..89f0a2e6f --- /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 b39bd886b..0257654dd 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 8dfb16d9635cc48d6fe150b7372e0b0847df3241 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 4 Aug 2026 10:41:35 +0800 Subject: [PATCH 06/12] 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 aac2db2be..cd7274cf9 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 a57b3935b..0b7038eb3 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 55670363105ec860e451f154fb0870bb0aec1bb4 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Tue, 4 Aug 2026 10:43:01 +0800 Subject: [PATCH 07/12] 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 8b1533647..61b3b30be 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 7ff67542a..8b02230e9 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 a413cb4a0704ee7e94427db9de29db5137d39db7 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sun, 9 Aug 2026 15:58:00 +0800 Subject: [PATCH 08/12] 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 7829ec956..5e13d39de 100644 --- a/packages/presentation/ui/src/chat/__tests__/tool-call-metadata.test.tsx +++ b/packages/presentation/ui/src/chat/__tests__/tool-call-metadata.test.tsx @@ -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 31a0e3901..45ad35245 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 be80a233a..ccf4905f1 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 99605b4325e0dad4a11fe15ffc603b3e7c140a57 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sun, 9 Aug 2026 15:59:32 +0800 Subject: [PATCH 09/12] 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 61b3b30be..b80947bc9 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 89f0a2e6f..b12aea163 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 0257654dd..5d3252c05 100644 --- a/packages/host/agent-adapter/src/native/codex/adapter.ts +++ b/packages/host/agent-adapter/src/native/codex/adapter.ts @@ -60,7 +60,13 @@ import { readCodexTranscriptSummaries, readJsonlFile, } from './history'; -import { CODEX_PLAN_ID, codexPlanEntries, execToolCall, fileChangeToolCall } from './tool-view'; +import { + CODEX_PLAN_ID, + codexMcpSlug, + codexPlanEntries, + execToolCall, + fileChangeToolCall, +} from './tool-view'; import { diffContentFromUnified } from './unified-diff'; interface CodexSkillCommand extends AgentCommand { @@ -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 8b02230e9..e59897548 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 4d8b13268..0b6bd761f 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 154f5a99eec63c733b77b51214f22c0bb54db206 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Sun, 9 Aug 2026 19:51:35 +0800 Subject: [PATCH 10/12] 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 0b6bd761f..006100d33 100644 --- a/packages/host/agent-adapter/src/native/codex/tool-view.ts +++ b/packages/host/agent-adapter/src/native/codex/tool-view.ts @@ -21,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. */ From 347ffab9921b7f48acf54e787c961f4c88aab733 Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Wed, 12 Aug 2026 20:02:30 +0800 Subject: [PATCH 11/12] fix(tools): preserve replay identity and multiline output --- .../src/__tests__/codex-history.test.ts | 63 +++++++++++++++++-- .../src/native/codex/history-tools.ts | 8 +-- .../agent-adapter/src/native/codex/history.ts | 36 ++++++++++- .../src/chat/__tests__/tool-search.test.tsx | 14 +++++ .../presentation/ui/src/chat/tool-search.tsx | 4 +- 5 files changed, 112 insertions(+), 13 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 b80947bc9..4bb9e2a97 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-history.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-history.test.ts @@ -417,9 +417,8 @@ 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. + it("replays MCP callable names under the live adapter's mcp slug", () => { + // Callable namespaces are only a fallback and cannot distinguish a delimiter from a server suffix. const events = mapCodexHistoryEvents(HID, [ responseItem({ type: 'function_call', @@ -463,7 +462,7 @@ describe('mapCodexHistoryEvents', () => { 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_mcp2', 'computer_use__.click'], ['call_mcp3', 'mcp__linear__save_comment'], // A `__`-bearing server name would mis-split the slug — the raw dotted title survives. ['call_mcp4', 'repo__prod.search_files'], @@ -473,6 +472,62 @@ describe('mapCodexHistoryEvents', () => { expect(tools[1]).toMatchObject({ status: 'completed', kind: 'other' }); }); + it('prefers persisted raw MCP identity over lossy callable names', () => { + const longToolName = + 'extremely-lengthy-function-name-that-absolutely-surpasses-all-reasonable-limits'; + const events = mapCodexHistoryEvents(HID, [ + responseItem({ + type: 'function_call', + namespace: 'mcp__acme__', + name: 'lookup', + arguments: '{}', + call_id: 'call_trailing_delimiter', + }), + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call_trailing_delimiter', + invocation: { server: 'acme__', tool: 'lookup', arguments: {} }, + }, + }, + responseItem({ + type: 'function_call_output', + call_id: 'call_trailing_delimiter', + output: 'found', + }), + responseItem({ + type: 'function_call', + namespace: 'mcp__server_one', + name: 'extremely_lengthy_function_name_that_absolut_0123456789ab', + arguments: '{}', + call_id: 'call_sanitized', + }), + { + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'McpToolCall', + id: 'call_sanitized', + server: 'server.one', + tool: longToolName, + arguments: {}, + status: 'completed', + }, + }, + }, + responseItem({ type: 'function_call_output', call_id: 'call_sanitized', output: 'found' }), + ]); + + expect(toolCalls(events).map((tool) => [tool.toolCallId, tool.title])).toEqual([ + ['call_trailing_delimiter', 'acme__.lookup'], + ['call_trailing_delimiter', 'acme__.lookup'], + ['call_sanitized', `mcp__server.one__${longToolName}`], + ['call_sanitized', `mcp__server.one__${longToolName}`], + ]); + }); + it('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 e59897548..0c326573d 100644 --- a/packages/host/agent-adapter/src/native/codex/history-tools.ts +++ b/packages/host/agent-adapter/src/native/codex/history-tools.ts @@ -23,6 +23,7 @@ export type CodexToolAnnounce = { toolCall: ToolCall } | { plan: Plan }; export function codexToolAnnounce( callId: string, payload: Record, + persistedMcpIdentity?: { server: string; tool: string }, ): CodexToolAnnounce { const payloadType = stringField(payload, 'type'); const name = stringField(payload, 'name'); @@ -75,7 +76,7 @@ export function codexToolAnnounce( // function_call: JSON-encoded `arguments`. const args = parseArguments(payload); - const mcp = codexMcpToolName(payload); + const mcp = persistedMcpIdentity ?? 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. @@ -133,8 +134,7 @@ 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. */ +/** Callable names are lossy; use them only when a completed MCP identity was not persisted. */ function codexMcpToolName( payload: Record, ): { server: string; tool: string } | undefined { @@ -146,8 +146,6 @@ function codexMcpToolName( 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; } diff --git a/packages/host/agent-adapter/src/native/codex/history.ts b/packages/host/agent-adapter/src/native/codex/history.ts index 17e3b7dfb..5a05806a9 100644 --- a/packages/host/agent-adapter/src/native/codex/history.ts +++ b/packages/host/agent-adapter/src/native/codex/history.ts @@ -456,8 +456,8 @@ const CODEX_TOOL_OUTPUT_TYPES = new Set([ /** * Replays the rollout as the event stream the live turn emitted: text plus tool announce/settle * pairs correlated by `call_id`, mapped to the live presentation shapes by `history-tools.ts`. - * History ids are NOT converged with the live app-server item ids (the rollout persists only - * `call_id`; message rows carry no id) — the seed relies on the `uptoSeq` cut. Known lossiness + * Message history ids are NOT converged with the live app-server item ids because rollout message + * rows carry no id — the seed relies on the `uptoSeq` cut. Known lossiness * (CODE-97): reasoning stays unreplayable (`encrypted_content` only), and replayed edit diffs are * reconstructed from the `*** Begin Patch` envelope, not the app-server's richer live unified diff. */ @@ -467,6 +467,7 @@ export function mapCodexHistoryEvents( ): AgentHistoryEvent[] { const events: AgentHistoryEvent[] = []; const announced = new Map(); + const persistedMcpIdentities = collectCodexMcpIdentities(rows); const promptTexts = collectCodexPromptTexts(rows); /** update_plan call_ids, so their `Plan updated` receipts don't settle a phantom tool row. */ const planCalls = new Set(); @@ -515,7 +516,7 @@ export function mapCodexHistoryEvents( const callId = stringField(payload, 'call_id'); if (payloadType !== undefined && callId !== undefined) { if (CODEX_TOOL_ANNOUNCE_TYPES.has(payloadType)) { - const mapped = codexToolAnnounce(callId, payload); + const mapped = codexToolAnnounce(callId, payload, persistedMcpIdentities.get(callId)); if ('plan' in mapped) { planCalls.add(callId); events.push({ historyId, itemId: callId, event: { type: 'plan', plan: mapped.plan } }); @@ -553,6 +554,35 @@ export function mapCodexHistoryEvents( return events; } +function collectCodexMcpIdentities( + rows: JsonRecord[], +): Map { + const identities = new Map(); + for (const row of rows) { + if (stringField(row, 'type') !== 'event_msg') continue; + const payload = recordField(row, 'payload'); + if (!payload) continue; + if (stringField(payload, 'type') === 'item_completed') { + const item = recordField(payload, 'item'); + if (!item || stringField(item, 'type') !== 'McpToolCall') continue; + const callId = stringField(item, 'id'); + const server = stringField(item, 'server'); + const tool = stringField(item, 'tool'); + if (callId && server && tool) identities.set(callId, { server, tool }); + continue; + } + if (stringField(payload, 'type') !== 'mcp_tool_call_end') continue; + const callId = stringField(payload, 'call_id'); + const invocation = recordField(payload, 'invocation'); + const server = invocation ? stringField(invocation, 'server') : undefined; + const tool = invocation ? stringField(invocation, 'tool') : undefined; + if (callId && server && tool && !identities.has(callId)) { + identities.set(callId, { server, tool }); + } + } + return identities; +} + function idFromFilename(path: string): string { const name = basename(path, '.jsonl'); return name.length > 0 ? name : path; diff --git a/packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx b/packages/presentation/ui/src/chat/__tests__/tool-search.test.tsx index cd7274cf9..153ed70d1 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,20 @@ describe('tool search presentation', () => { expect(screen.getByText('No matching deferred tools found')).toBeDefined(); }); + it('preserves line breaks in prose messages', () => { + const message = 'Connection failed\nRetry later'; + const toolCall = toolSearch({ + content: [{ type: 'content', content: { type: 'text', text: message } }], + }); + + const { container } = render(); + const paragraph = container.querySelector('p'); + + expect(paragraph?.textContent).toBe(message); + expect(paragraph?.classList.contains('whitespace-pre-wrap')).toBe(true); + expect(paragraph?.classList.contains('break-words')).toBe(true); + }); + it('keeps neutral wording when a settled selection has no recoverable result rows', () => { // The cold-history shape: completed, but the SDK stripped the tool_use_result rows. const toolCall = toolSearch({ content: [] }); diff --git a/packages/presentation/ui/src/chat/tool-search.tsx b/packages/presentation/ui/src/chat/tool-search.tsx index 45ad35245..ab9f84a4e 100644 --- a/packages/presentation/ui/src/chat/tool-search.tsx +++ b/packages/presentation/ui/src/chat/tool-search.tsx @@ -11,7 +11,9 @@ export function ToolSearchResult({ }): React.ReactNode { const { names, message } = presentation; if (names.length === 0) { - return message ?

{message}

: null; + return message ? ( +

{message}

+ ) : null; } return (

From cf1e906734cf315d8397796b0dc5df86dfd00f2f Mon Sep 17 00:00:00 2001 From: Zerlight Wu Date: Wed, 12 Aug 2026 21:02:53 +0800 Subject: [PATCH 12/12] fix(agent-adapter): preserve replay tool identity --- .../__tests__/claude-code-compaction.test.ts | 161 +++++++++++++++--- .../src/__tests__/codex-history.test.ts | 80 +++++++++ .../agent-adapter/src/native/claude-code.ts | 49 ++++-- .../src/native/codex/history-tools.ts | 29 +++- 4 files changed, 279 insertions(+), 40 deletions(-) diff --git a/packages/host/agent-adapter/src/__tests__/claude-code-compaction.test.ts b/packages/host/agent-adapter/src/__tests__/claude-code-compaction.test.ts index 768643b0b..b941ee802 100644 --- a/packages/host/agent-adapter/src/__tests__/claude-code-compaction.test.ts +++ b/packages/host/agent-adapter/src/__tests__/claude-code-compaction.test.ts @@ -308,6 +308,7 @@ describe('ClaudeCodeAdapter readHistory transcript supplement', () => { records: new Map(), droppedRows: [], parentUuidByUuid: new Map(), + toolUses: new Map(), toolUseResults: new Map(), toolUsePatches: new Map(), ...partial, @@ -320,6 +321,7 @@ describe('ClaudeCodeAdapter readHistory transcript supplement', () => { constructor( private readonly messages: SessionMessage[], private readonly supplement: ClaudeTranscriptSupplement, + private readonly paginate = false, ) { super(); } @@ -327,7 +329,12 @@ describe('ClaudeCodeAdapter readHistory transcript supplement', () => { protected override loadSdk(): Promise { return Promise.resolve({ getSessionInfo: () => Promise.resolve(undefined), - getSessionMessages: () => Promise.resolve(this.messages), + getSessionMessages: (_sessionId: string, options?: { limit?: number; offset?: number }) => { + if (!this.paginate) return Promise.resolve(this.messages); + const offset = options?.offset ?? 0; + const end = options?.limit === undefined ? undefined : offset + options.limit; + return Promise.resolve(this.messages.slice(offset, end)); + }, listSubagents: () => Promise.resolve([]), } as T); } @@ -431,7 +438,9 @@ describe('ClaudeCodeAdapter readHistory transcript supplement', () => { } }); - it('replays an Edit settle with the recovered patch instead of the announce fragment', async () => { + it('recovers a page-start Edit settle with the patch replacing its announce fragment', async () => { + const input = { file_path: 'src/a.ts', old_string: 'a', new_string: 'b' }; + const parentToolCallId = 'toolu_parent'; const patch = { type: 'diff' as const, change: 'modify' as const, @@ -440,29 +449,135 @@ describe('ClaudeCodeAdapter readHistory transcript supplement', () => { newText: 'b', patch: { format: 'git_patch' as const, text: '@@ -12,3 +12,3 @@\n ctx\n-a\n+b' }, }; - const adapter = new HistoryClaude( - [ - row('assistant', 'a0', [ + const announce = row('assistant', 'a0', [ + { type: 'tool_use', id: 'toolu_edit', name: 'Edit', input }, + ]); + const settle = row('user', 'u0', [ + { type: 'tool_result', tool_use_id: 'toolu_edit', content: 'updated' }, + ]); + const toolUseResult = { + filePath: 'src/a.ts', + oldString: 'a', + newString: 'b', + userModified: false, + replaceAll: true, + structuredPatch: [ + { oldStart: 12, oldLines: 3, newStart: 12, newLines: 3, lines: [' ctx', '-a', '+b'] }, + ], + }; + const supplement = buildClaudeTranscriptSupplement([ + JSON.stringify({ + type: 'assistant', + uuid: announce.uuid, + sessionId: SESSION, + parent_tool_use_id: parentToolCallId, + message: announce.message, + }), + JSON.stringify({ + type: 'user', + uuid: settle.uuid, + sessionId: SESSION, + message: settle.message, + toolUseResult, + }), + ]); + const adapter = new HistoryClaude([announce, settle], supplement, true); + + const first = await adapter.readHistory({ historyId: asHistoryId(SESSION), limit: 1 }); + expect(first.cursor).toBe('1'); + if (!first.cursor) throw new Error('expected a second history page'); + const second = await adapter.readHistory({ + historyId: asHistoryId(SESSION), + limit: 1, + cursor: first.cursor, + }); + expect(second.events).toHaveLength(1); + const event = second.events[0].event; + expect(event.type).toBe('tool-call'); + if (event.type === 'tool-call') { + expect(event.toolCall).toEqual({ + toolCallId: 'toolu_edit', + parentToolCallId, + title: 'Edit', + kind: 'edit', + status: 'completed', + content: [patch, { type: 'content', content: { type: 'text', text: 'updated' } }], + rawInput: input, + rawOutput: { + filePath: 'src/a.ts', + oldString: 'a', + newString: 'b', + userModified: false, + replaceAll: true, + }, + }); + } + }); + + it('recovers a ToolSearch snapshot when a cursor page begins with its settle', async () => { + const input = { query: 'select:WebSearch' }; + const parentToolCallId = 'toolu_parent'; + const announce = row('assistant', 'a0', [ + { type: 'tool_use', id: 'toolu_search', name: 'ToolSearch', input }, + ]); + const settle = row('user', 'u0', [ + { + type: 'tool_result', + tool_use_id: 'toolu_search', + content: [{ type: 'tool_reference', tool_name: 'WebSearch' }], + }, + ]); + const supplement = buildClaudeTranscriptSupplement([ + JSON.stringify({ + type: 'assistant', + uuid: announce.uuid, + sessionId: SESSION, + parent_tool_use_id: parentToolCallId, + message: announce.message, + }), + JSON.stringify({ + type: 'user', + uuid: settle.uuid, + sessionId: SESSION, + message: settle.message, + }), + ]); + const adapter = new HistoryClaude([announce, settle], supplement, true); + + const first = await adapter.readHistory({ historyId: asHistoryId(SESSION), limit: 1 }); + expect(first.events.map((event) => `${event.event.type}:${event.itemId ?? ''}`)).toEqual([ + 'tool-call:toolu_search', + ]); + expect(first.cursor).toBe('1'); + if (!first.cursor) throw new Error('expected a second history page'); + + const second = await adapter.readHistory({ + historyId: asHistoryId(SESSION), + limit: 1, + cursor: first.cursor, + }); + expect(second.cursor).toBeUndefined(); + expect(second.events.map((event) => `${event.event.type}:${event.itemId ?? ''}`)).toEqual([ + 'tool-call:toolu_search', + ]); + const event = second.events[0].event; + expect(event.type).toBe('tool-call'); + if (event.type === 'tool-call') { + expect(event.toolCall).toEqual({ + toolCallId: 'toolu_search', + parentToolCallId, + title: 'ToolSearch', + kind: 'search', + status: 'completed', + content: [ { - type: 'tool_use', - id: 'toolu_1', - name: 'Edit', - input: { file_path: 'src/a.ts', old_string: 'a', new_string: 'b' }, + type: 'content', + content: { type: 'text', text: 'WebSearch' }, }, - ]), - row('user', 'u0', [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'updated' }]), - ], - supplementOf({ toolUsePatches: new Map([['toolu_1', [patch]]]) }), - ); - const result = await adapter.readHistory({ historyId: asHistoryId(SESSION) }); - const settle = result.events.at(-1)?.event; - expect(settle?.type).toBe('tool-call'); - if (settle?.type === 'tool-call') { - // Superseded, not stacked — one diff, matching what the live settle emits. - expect(settle.toolCall.content).toEqual([ - patch, - { type: 'content', content: { type: 'text', text: 'updated' } }, - ]); + ], + rawInput: input, + rawOutput: [{ type: 'tool_reference', tool_name: 'WebSearch' }], + }); } }); diff --git a/packages/host/agent-adapter/src/__tests__/codex-history.test.ts b/packages/host/agent-adapter/src/__tests__/codex-history.test.ts index 4bb9e2a97..cf5ae88a0 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-history.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-history.test.ts @@ -528,6 +528,86 @@ describe('mapCodexHistoryEvents', () => { ]); }); + it('recovers only matching legacy dotless Codex Apps callable identities', () => { + const events = mapCodexHistoryEvents(HID, [ + responseItem({ + type: 'function_call', + namespace: 'mcp__codex_apps__github', + name: '_create_issue', + arguments: '{}', + call_id: 'call_legacy_app', + }), + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call_legacy_app', + invocation: { server: 'codex_apps', tool: 'github_create_issue', arguments: {} }, + }, + }, + responseItem({ type: 'function_call_output', call_id: 'call_legacy_app', output: 'created' }), + responseItem({ + type: 'function_call', + namespace: 'mcp__codex_apps__linear', + name: '_create_issue', + arguments: '{}', + call_id: 'call_mismatched_app', + }), + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call_mismatched_app', + invocation: { server: 'codex_apps', tool: 'github_create_issue', arguments: {} }, + }, + }, + responseItem({ + type: 'function_call_output', + call_id: 'call_mismatched_app', + output: 'created', + }), + responseItem({ + type: 'function_call', + namespace: 'mcp__codex_apps__linear', + name: '_create_issue', + arguments: '{}', + call_id: 'call_modern_app', + }), + { + type: 'event_msg', + payload: { + type: 'mcp_tool_call_end', + call_id: 'call_modern_app', + invocation: { server: 'codex_apps', tool: 'linear_create_issue', arguments: {} }, + }, + }, + { + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'McpToolCall', + id: 'call_modern_app', + server: 'codex_apps', + tool: 'github.create_issue', + arguments: {}, + status: 'completed', + }, + }, + }, + responseItem({ type: 'function_call_output', call_id: 'call_modern_app', output: 'created' }), + ]); + + expect(toolCalls(events).map((tool) => [tool.toolCallId, tool.title])).toEqual([ + ['call_legacy_app', 'mcp__github__create_issue'], + ['call_legacy_app', 'mcp__github__create_issue'], + ['call_mismatched_app', 'mcp__codex_apps__github_create_issue'], + ['call_mismatched_app', 'mcp__codex_apps__github_create_issue'], + ['call_modern_app', 'mcp__github__create_issue'], + ['call_modern_app', 'mcp__github__create_issue'], + ]); + }); + it('settles an aborted run and a declined run as failed with the raw text as the record', () => { const events = mapCodexHistoryEvents(HID, [ responseItem({ diff --git a/packages/host/agent-adapter/src/native/claude-code.ts b/packages/host/agent-adapter/src/native/claude-code.ts index fe1453c02..085754dee 100644 --- a/packages/host/agent-adapter/src/native/claude-code.ts +++ b/packages/host/agent-adapter/src/native/claude-code.ts @@ -405,6 +405,7 @@ const EMPTY_SUPPLEMENT: ClaudeTranscriptSupplement = { records: new Map(), droppedRows: [], parentUuidByUuid: new Map(), + toolUses: new Map(), toolUseResults: new Map(), toolUsePatches: new Map(), }; @@ -701,6 +702,7 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { supplement.toolUseResults, supplement.toolUsePatches, supplement.parentUuidByUuid, + supplement.toolUses, ); const events: AgentHistoryEvent[] = []; // Splice each subagent's transcript right after its spawn announce so children land inside the @@ -1745,6 +1747,9 @@ export interface ClaudeTranscriptSupplement { /** Message uuid → raw transcript predecessor. The SDK projection strips `parentUuid`, but Claude * requires the predecessor message id when forking immediately before a historical prompt. */ parentUuidByUuid: Map; + /** tool_use_id → announce snapshot. Cursor pages can begin at the matching result row, after + * the stateful mapper's in-page announce map has been reset. */ + toolUses: Map; /** tool_use_id → projected `toolUseResult` envelope (`toolUseResultEnvelope`), another field * `getSessionMessages` strips per row. Keyed only for unambiguous single-result rows. */ toolUseResults: Map>; @@ -1769,6 +1774,7 @@ export function buildClaudeTranscriptSupplement( ): ClaudeTranscriptSupplement { const records = new Map(); const parentUuidByUuid = new Map(); + const toolUses = new Map(); const toolUseResults = new Map>(); const toolUsePatches = new Map(); /** Conversation rows in file order, with the index of the last boundary seen before each. */ @@ -1804,11 +1810,12 @@ export function buildClaudeTranscriptSupplement( records.set(uuid, pending ?? { compactionId: uuid }); pending = null; } else if (row.type !== 'user' && row.type !== 'assistant') continue; - // Harvested before the exclusions: tool_use ids are globally unique, so keying a row the - // timeline itself skips is harmless. + // Result fields are harvested before the exclusions: tool_use ids are globally unique, so + // keying a result row the timeline itself skips is harmless. if (row.type === 'user') harvestToolUseResult(toolUseResults, toolUsePatches, row); // Same exclusions as the SDK's own reader: meta rows, sidechains, and teammate rows. if (row.isMeta === true || row.isSidechain === true || row.teamName) continue; + if (row.type === 'assistant') harvestToolUses(toolUses, row); rows.push({ row: { type: row.type, @@ -1831,11 +1838,21 @@ export function buildClaudeTranscriptSupplement( if (r.boundariesBefore < boundaries) dropped.push(r.row); return dropped; }, []), + toolUses, toolUseResults, toolUsePatches, }; } +function harvestToolUses(toolUses: Map, row: Record): void { + const parentToolCallId = stringField(row, 'parent_tool_use_id'); + for (const block of messageContentBlocks(row.message)) { + if (isToolUseBlock(block)) { + toolUses.set(block.id, claudeToolCallFromUse(block, parentToolCallId)); + } + } +} + /** Key a raw result row's `toolUseResult` projections by its tool_use id. The field is row-level, so * only a row with exactly one tool_result block pairs unambiguously. The envelope and the Edit patch * are independent projections of that one field — either can be absent. */ @@ -1987,6 +2004,8 @@ export function createClaudeHistoryEventMapper( toolUsePatches?: ReadonlyMap, /** Raw message ancestry recovered from the transcript; absent for nested subagent reads. */ parentUuidByUuid?: ReadonlyMap, + /** Announce snapshots recovered from the raw transcript, for cursor pages starting at settle. */ + toolUses?: ReadonlyMap, ): (message: SessionMessage) => AgentHistoryEvent[] { const announced = new Map(); /** Last model announced to the timeline; assistant rows re-announce only on change. */ @@ -2047,24 +2066,14 @@ export function createClaudeHistoryEventMapper( if (text) events.push(text); for (const block of blocks) { if (!isToolUseBlock(block)) continue; - events.push( - toolEvent({ - toolCallId: block.id, - parentToolCallId: parent, - title: block.name, - kind: claudeToolKind(block.name), - status: 'in_progress', - content: toolInputContent(block.name, block.input) ?? [], - rawInput: block.input, - }), - ); + events.push(toolEvent(claudeToolCallFromUse(block, parent))); } return events; } const results = blocks.filter((block) => isToolResultBlock(block)); for (const block of results) { - const existing = announced.get(block.tool_use_id); + const existing = announced.get(block.tool_use_id) ?? toolUses?.get(block.tool_use_id); events.push( toolEvent({ toolCallId: block.tool_use_id, @@ -2119,6 +2128,18 @@ interface ClaudeToolResultBlock { content?: unknown; } +function claudeToolCallFromUse(block: ClaudeToolUseBlock, parentToolCallId?: string): ToolCall { + return { + toolCallId: block.id, + parentToolCallId, + title: block.name, + kind: claudeToolKind(block.name), + status: 'in_progress', + content: toolInputContent(block.name, block.input) ?? [], + rawInput: block.input, + }; +} + interface ClaudeThinkingBlock { type: 'thinking'; thinking: string; diff --git a/packages/host/agent-adapter/src/native/codex/history-tools.ts b/packages/host/agent-adapter/src/native/codex/history-tools.ts index 0c326573d..f4d177992 100644 --- a/packages/host/agent-adapter/src/native/codex/history-tools.ts +++ b/packages/host/agent-adapter/src/native/codex/history-tools.ts @@ -76,7 +76,7 @@ export function codexToolAnnounce( // function_call: JSON-encoded `arguments`. const args = parseArguments(payload); - const mcp = persistedMcpIdentity ?? codexMcpToolName(payload); + const mcp = codexReplayMcpIdentity(payload, persistedMcpIdentity); if (mcp) { // Converge with the live adapter's `mcp____` slug (and its kind) so a // replayed MCP call renders like the live turn did. @@ -132,9 +132,32 @@ export function codexToolAnnounce( } const MCP_NAMESPACE_PREFIX = 'mcp__'; -const PLUGIN_APPS_NAMESPACE_PREFIX = 'codex_apps__'; +const PLUGIN_APPS_SERVER = 'codex_apps'; +const PLUGIN_APPS_NAMESPACE_PREFIX = `${PLUGIN_APPS_SERVER}__`; -/** Callable names are lossy; use them only when a completed MCP identity was not persisted. */ +function codexReplayMcpIdentity( + payload: Record, + persisted: { server: string; tool: string } | undefined, +): { server: string; tool: string } | undefined { + const callable = codexMcpToolName(payload); + const namespace = stringField(payload, 'namespace'); + const name = stringField(payload, 'name'); + // Legacy Codex Apps persisted `github_create_issue`; only the callable retained its connector + // boundary. + if ( + callable && + persisted?.server === PLUGIN_APPS_SERVER && + !persisted.tool.includes('.') && + namespace?.startsWith(`${MCP_NAMESPACE_PREFIX}${PLUGIN_APPS_NAMESPACE_PREFIX}`) && + name?.[0] === '_' && + persisted.tool === `${callable.server}_${callable.tool}` + ) { + return callable; + } + return persisted ?? callable; +} + +/** Callable names are lossy; raw completed identities take precedence outside the legacy case. */ function codexMcpToolName( payload: Record, ): { server: string; tool: string } | undefined {