Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
47 changes: 47 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,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__<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: '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({
Expand Down
82 changes: 82 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,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<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' },
});
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',
]);
});
});
18 changes: 15 additions & 3 deletions packages/host/agent-adapter/src/native/codex/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ export type CodexServerHandle = Pick<CodexAppServer, 'request' | 'setRequestHand

const CODEX_AUTH_FAILED_MESSAGE = 'Codex authentication failed — sign in to your ChatGPT account';

const CODEX_PLUGIN_APPS_SERVER = 'codex_apps';

/** Whether an app-server `error` notification reports the 401 of a signed-out/expired login. The
* structured status (`codexErrorInfo.responseStreamDisconnected.httpStatusCode`) rides only the
* mid-retry notifications; the final no-retry error leaves the 401 in prose — match both
Expand Down Expand Up @@ -1279,11 +1281,21 @@ export class CodexAdapter extends BaseAgentAdapter {
break;
}
case 'mcpToolCall': {
const server = stringField(item, 'server') ?? 'mcp';
const tool = stringField(item, 'tool') ?? 'tool';
// Emit the shared `mcp__<server>__<tool>` 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.
Comment on lines +1284 to +1286
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: [],
Expand Down
40 changes: 40 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 @@ -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__<server>__<tool>` 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 };
Expand Down Expand Up @@ -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__<server>` (observed
* with a stray trailing `__` on some rows); `name` is the bare tool. Plugin apps namespace as
* `mcp__codex_apps__<app>` 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.) */
Comment on lines +135 to +139
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
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