Skip to content
Open
41 changes: 34 additions & 7 deletions packages/client/workbench/src/mock/data/showcase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
56 changes: 56 additions & 0 deletions packages/host/agent-adapter/src/__tests__/codex-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,62 @@ describe('mapCodexHistoryEvents', () => {
]);
});

it("replays MCP calls under the live adapter's mcp slug, unwrapping the plugin namespace", () => {
// Real rollout shapes: the server rides `namespace` (`mcp__<server>`, sometimes with a stray
// trailing `__`); plugin apps namespace as `mcp__codex_apps__<app>` with a leading-`_` tool.
const events = mapCodexHistoryEvents(HID, [
responseItem({
type: 'function_call',
namespace: 'mcp__node_repl',
name: 'js',
arguments: '{"code":"1 + 1"}',
call_id: 'call_mcp1',
}),
responseItem({ type: 'function_call_output', call_id: 'call_mcp1', output: '2' }),
responseItem({
type: 'function_call',
namespace: 'mcp__computer_use__',
name: 'click',
arguments: '{}',
call_id: 'call_mcp2',
}),
responseItem({
type: 'function_call',
namespace: 'mcp__codex_apps__linear',
name: '_save_comment',
arguments: '{}',
call_id: 'call_mcp3',
}),
responseItem({
type: 'function_call',
namespace: 'mcp__repo__prod',
name: 'search_files',
arguments: '{}',
call_id: 'call_mcp4',
}),
responseItem({
type: 'function_call',
namespace: 'collaboration',
name: 'send_message',
arguments: '{}',
call_id: 'call_builtin',
}),
]);

const tools = toolCalls(events);
expect(tools.map((tool) => [tool.toolCallId, tool.title])).toEqual([
['call_mcp1', 'mcp__node_repl__js'],
['call_mcp1', 'mcp__node_repl__js'],
['call_mcp2', 'mcp__computer_use__click'],
['call_mcp3', 'mcp__linear__save_comment'],
// A `__`-bearing server name would mis-split the slug — the raw dotted title survives.
['call_mcp4', 'repo__prod.search_files'],
['call_builtin', 'send_message'],
]);
expect(tools[0].kind).toBe('other');
expect(tools[1]).toMatchObject({ status: 'completed', kind: 'other' });
});

it('settles an aborted run and a declined run as failed with the raw text as the record', () => {
const events = mapCodexHistoryEvents(HID, [
responseItem({
Expand Down
87 changes: 87 additions & 0 deletions packages/host/agent-adapter/src/__tests__/codex-mcp-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { AgentEvent, StartOptions } from '@linkcode/schema';
import { describe, expect, it } from 'vitest';
import { CodexAdapter } from '../native/codex';
import type { CodexServerHandle } from '../native/codex/adapter';
import type { CodexAppServerOptions } from '../native/codex/app-server';

/** Minimal fake satisfying `CodexServerHandle`, same shape as codex-compaction.test.ts's. */
class FakeCodexServer {
constructor(private readonly opts: Omit<CodexAppServerOptions, 'binaryPath'>) {}
request(method: string): Promise<unknown> {
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<CodexAppServerOptions, 'binaryPath'>,
): Promise<CodexServerHandle> {
const server = new FakeCodexServer(opts);
this.fakeServers.push(server);
return Promise.resolve(server);
}
protected override readConfiguredSandbox() {
return Promise.resolve(undefined);
}
}

const start: StartOptions = { kind: 'codex', cwd: '/repo' };

function toolTitles(events: AgentEvent[]) {
return events.flatMap((event) => (event.type === 'tool-call' ? [event.toolCall.title] : []));
}

describe('CodexAdapter mcpToolCall items', () => {
it('emits the shared mcp slug and strips the codex_apps plugin namespace', async () => {
const adapter = new TestCodex();
const events: AgentEvent[] = [];
adapter.onEvent((e) => events.push(e));
await adapter.start(start);
const server = adapter.fakeServers[0];

server.notify('turn/started', { turn: { id: 'turn-1' } });
// Real 0.144.6 shape: plugin apps mount under ONE `codex_apps` server, plugin in the tool name.
server.notify('item/started', {
item: {
type: 'mcpToolCall',
id: 'mcp-1',
server: 'codex_apps',
tool: 'linear.list_issues',
status: 'inProgress',
arguments: { limit: 50 },
},
});
server.notify('item/started', {
item: { type: 'mcpToolCall', id: 'mcp-2', server: 'context7', tool: 'resolve_library' },
});
server.notify('item/started', {
item: { type: 'mcpToolCall', id: 'mcp-3', server: 'codex_apps', tool: 'dotless' },
});
// Codex accepts `__` in server names; the slug would mis-split, so the raw title survives.
server.notify('item/started', {
item: { type: 'mcpToolCall', id: 'mcp-4', server: 'repo__prod', tool: 'search_files' },
});
server.notify('turn/completed', { turn: { id: 'turn-1', status: 'completed' } });

// Announce + teardown settle both re-emit the full snapshot; the title must be stable.
expect([...new Set(toolTitles(events))]).toEqual([
'mcp__linear__list_issues',
'mcp__context7__resolve_library',
'mcp__codex_apps__dotless',
'repo__prod.search_files',
]);
});
});
15 changes: 11 additions & 4 deletions packages/host/agent-adapter/src/native/codex/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1279,11 +1285,12 @@ export class CodexAdapter extends BaseAgentAdapter {
break;
}
case 'mcpToolCall': {
const server = stringField(item, 'server') ?? 'mcp';
const tool = stringField(item, 'tool') ?? 'tool';
this.emitTool({
toolCallId: id,
title: `${server}.${tool}`,
title: codexMcpSlug(
stringField(item, 'server') ?? 'mcp',
stringField(item, 'tool') ?? 'tool',
),
kind: 'other',
status: mapCodexItemStatus(stringField(item, 'status')),
content: [],
Expand Down
38 changes: 38 additions & 0 deletions packages/host/agent-adapter/src/native/codex/history-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { isRecord, stringField, textFromUnknown } from '../../history-util';
import { toolKindFromName } from '../../util';
import {
CODEX_PLAN_ID,
codexMcpSlug,
codexPlanEntries,
execToolCall,
fileChangeToolCall,
Expand Down Expand Up @@ -74,6 +75,21 @@ export function codexToolAnnounce(

// function_call: JSON-encoded `arguments`.
const args = parseArguments(payload);
const mcp = codexMcpToolName(payload);
if (mcp) {
// Converge with the live adapter's `mcp__<server>__<tool>` slug (and its kind) so a
// replayed MCP call renders like the live turn did.
return {
toolCall: {
toolCallId: callId,
title: codexMcpSlug(mcp.server, mcp.tool),
kind: 'other',
status: 'in_progress',
content: [],
rawInput: args,
},
};
}
if (name === 'update_plan') {
const plan = planFromArgs(args);
if (plan) return { plan };
Expand Down Expand Up @@ -114,6 +130,28 @@ export function codexToolAnnounce(
};
}

const MCP_NAMESPACE_PREFIX = 'mcp__';
const PLUGIN_APPS_NAMESPACE_PREFIX = 'codex_apps__';

/** Rollout MCP rows: `namespace` is `mcp__<server>` (a stray trailing `__` on some real rows),
* `name` the bare tool; plugin apps namespace as `mcp__codex_apps__<app>` with a `_`-led tool. */
function codexMcpToolName(
payload: Record<string, unknown>,
): { 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(
Expand Down
18 changes: 18 additions & 0 deletions packages/host/agent-adapter/src/native/codex/tool-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CODEX_PLUGIN_APPS_SERVER has no importer — it was module-private in adapter.ts before the move and codexMcpSlug right below is still its only reader. Meanwhile history-tools.ts:134 hard-codes the duplicate 'codex_apps__' literal rather than deriving from it, so the export buys nothing.

Suggested change
export const CODEX_PLUGIN_APPS_SERVER = 'codex_apps';
const CODEX_PLUGIN_APPS_SERVER = 'codex_apps';


/** The `mcp__<server>__<tool>` 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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
12 changes: 12 additions & 0 deletions packages/presentation/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,18 @@ export const en = {
failed: 'Failed',
expand: 'Expand',
collapse: 'Collapse',
toolSearch: {
select: 'Tool selection',
selecting: 'Selecting tools',
selected: 'Selected {count, plural, one {a tool} other {# tools}}',
search: 'Tool search',
searching: 'Searching for tools',
searched: 'Searched for tools',
},
searchSummary: {
matches: '{count, plural, one {a match} other {# matches}}',
files: '{count, plural, one {a file} other {# files}}',
},
},
subagent: {
label: 'Subagent',
Expand Down
12 changes: 12 additions & 0 deletions packages/presentation/i18n/src/locales/zh-cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,18 @@ export const zhCN = {
failed: '失败',
expand: '展开',
collapse: '收起',
toolSearch: {
select: '工具选择',
selecting: '正在选择工具',
selected: '{count, plural, =1 {已选择一个工具} other {已选择 # 个工具}}',
search: '工具搜索',
searching: '正在搜索工具',
searched: '已搜索工具',
},
searchSummary: {
matches: '{count, plural, =1 {一个匹配} other {# 个匹配}}',
files: '{count, plural, =1 {一个文件} other {# 个文件}}',
},
},
subagent: {
label: '子代理',
Expand Down
Loading