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
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 } }];
}

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