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
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ function formatActionsForJudge(toolCalls: ToolCallRecord[]): string {
outcome = `\n output: ${truncMiddle(res, 600)}`;
}

return `#${i + 1} [${tc.endpoint}] ${truncHead(String(action), 300)}${outcome}`;
return `#${i + 1} [${tc.tool.toolName}] ${truncHead(String(action), 300)}${outcome}`;
})
.join('\n');
}
Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/agents/claude-code/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,17 @@ describe('claudeCodeParser', () => {

const toolCalls = events.filter((e) => e.type === 'tool_call');
expect(toolCalls.map((e) => e.tool?.name)).toEqual(['shell', 'tool_use']);
// originalName stays raw as the agent emitted it.
expect(toolCalls.map((e) => e.tool?.originalName)).toEqual([
'Bash',
'mcp__supabase__search_docs',
]);
// `call` carries the agent-agnostic identity: MCP tools are split into
// server + bare toolName; built-ins are `other`.
expect(toolCalls.map((e) => e.tool?.call)).toEqual([
{ kind: 'other', toolName: 'Bash' },
{ kind: 'mcp', server: 'supabase', toolName: 'search_docs' },
]);
// the parser normalizes the shell command onto the event (args left raw)
expect(toolCalls[0].tool?.command).toBe('ls -la');
expect(toolCalls[0].tool?.args).toEqual({ command: 'ls -la' });
Expand Down Expand Up @@ -183,7 +190,7 @@ describe('adaptTranscript', () => {
it('builds tool calls keyed by the original tool name, with results paired in', () => {
expect(adapted.toolCalls).toEqual([
{
endpoint: 'Bash',
tool: { kind: 'other', toolName: 'Bash' },
body: { command: 'ls -la' },
name: 'shell',
command: 'ls -la',
Expand All @@ -192,7 +199,7 @@ describe('adaptTranscript', () => {
ts: Date.parse('2026-06-18T10:00:00.000Z'),
},
{
endpoint: 'mcp__supabase__search_docs',
tool: { kind: 'mcp', server: 'supabase', toolName: 'search_docs' },
body: { query: 'rls' },
name: 'tool_use',
result: undefined,
Expand All @@ -214,7 +221,7 @@ describe('adaptTranscript', () => {
},
{
type: 'tool_call',
name: 'mcp__supabase__search_docs',
name: 'search_docs',
input: { query: 'rls' },
output: undefined,
error: 'boom',
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/agents/claude-code/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import type {
ParsedTranscript,
ToolCall,
TranscriptEvent,
} from '../../transcript/types.js';
import type { AgentTranscriptParser } from '../../parsers/types.js';
Expand Down Expand Up @@ -53,6 +54,32 @@ const CLAUDE_CODE_TOOLS: AgentToolMap = {
},
};

/**
* Claude Code names MCP tools `mcp__<server>__<tool>` (fixture:
* `mcp__supabase-mcp__query_logs`). The `mcp__` marker and `__` delimiters make
* the server and bare tool name structurally recoverable, so scorers get an
* agent-agnostic `toolName` plus the originating `server`. Anything else is a
* built-in / native tool.
*
* Exported so tests elsewhere that need a Claude Code-shaped `ToolCall` (e.g.
* `docs-results.test.ts`) reuse this instead of re-deriving the split.
*/
export function parseClaudeCodeToolCall(rawName: string): ToolCall {
if (rawName.startsWith('mcp__')) {
// ['mcp', '<server>', '<tool...>'] — rejoin trailing segments so a tool
// name that itself contains '__' survives.
const parts = rawName.split('__');
if (parts.length >= 3) {
return {
kind: 'mcp',
server: parts[1]!,
toolName: parts.slice(2).join('__'),
};
}
}
return { kind: 'other', toolName: rawName };
}

/**
* Claude Code's tool args → normalized fields. Owned here, not in shared: Read/
* Write/Edit carry the path in `file_path`, NotebookEdit in `notebook_path`,
Expand Down Expand Up @@ -232,6 +259,7 @@ function recordToEvents(data: Record<string, unknown>): TranscriptEvent[] {
tool: {
name: normalizeToolName(use.name, CLAUDE_CODE_TOOLS),
originalName: use.name,
call: parseClaudeCodeToolCall(use.name),
id: use.id,
args: use.input,
},
Expand Down
44 changes: 42 additions & 2 deletions packages/core/src/agents/codex/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('codexParser', () => {
expect(adapted.steps).toBe(2); // two agent_message turns
expect(adapted.toolCalls).toEqual([
{
endpoint: 'command_execution',
tool: { kind: 'other', toolName: 'command_execution' },
body: { command: "/bin/zsh -lc 'echo hi'" },
name: 'shell',
command: "/bin/zsh -lc 'echo hi'",
Expand All @@ -86,7 +86,7 @@ describe('codexParser', () => {
ts: 0,
},
{
endpoint: 'file_change',
tool: { kind: 'other', toolName: 'file_change' },
body: { changes: [{ path: '/work/note.txt', kind: 'add' }] },
name: 'file_write',
path: '/work/note.txt',
Expand Down Expand Up @@ -168,6 +168,46 @@ describe('codexParser', () => {
expect(result?.tool?.originalName).toBe('search_docs');
});

it('attributes an mcp_tool_call to its server (explicit field or sole configured server)', () => {
const withServer = JSON.stringify({
type: 'item.completed',
item: {
id: 'm1',
type: 'mcp_tool_call',
server: 'supabase-mcp',
tool: 'query_logs',
result: 'ok',
},
});
const explicit = codexParser
.parseTranscript(withServer)
.events.find((e) => e.type === 'tool_call');
expect(explicit?.tool?.call).toEqual({
kind: 'mcp',
server: 'supabase-mcp',
toolName: 'query_logs',
});

// No server field: fall back to the sole configured MCP server.
const noServer = JSON.stringify({
type: 'item.completed',
item: {
id: 'm2',
type: 'mcp_tool_call',
tool: 'query_logs',
result: 'ok',
},
});
const fallback = codexParser
.parseTranscript(noServer, { mcpServerNames: ['supabase-mcp'] })
.events.find((e) => e.type === 'tool_call');
expect(fallback?.tool?.call).toEqual({
kind: 'mcp',
server: 'supabase-mcp',
toolName: 'query_logs',
});
});

it("keeps a web_search item's action, which says what the hosted tool did", () => {
const url = 'https://supabase.com/changelog.md';
const stream = JSON.stringify({
Expand Down
48 changes: 37 additions & 11 deletions packages/core/src/agents/codex/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@
import { isRecord, parseJsonlRecords } from '../../json.js';
import type {
ParsedTranscript,
ToolCall,
TranscriptEvent,
} from '../../transcript/types.js';
import type { AgentTranscriptParser } from '../../parsers/types.js';
import type {
AgentTranscriptParser,
ParseContext,
} from '../../parsers/types.js';
import {
normalizeToolName,
type AgentToolMap,
Expand Down Expand Up @@ -96,12 +100,16 @@ function toolCallPair(
args: Record<string, unknown>,
result: unknown,
success: boolean | undefined,
normalized: ExtractedArgs = {}
normalized: ExtractedArgs = {},
// MCP items pass an explicit call identity; native items default to `other`
// with the item-type name as the bare tool name.
call: ToolCall = { kind: 'other', toolName: originalName }
): TranscriptEvent[] {
const name = normalizeToolName(originalName, CODEX_TOOLS);
const tool: NonNullable<TranscriptEvent['tool']> = {
name,
originalName,
call,
id,
args,
};
Expand All @@ -125,7 +133,10 @@ function loadedSkillsFromCodexCall(
return [];
}

function itemToEvents(item: Record<string, unknown>): TranscriptEvent[] {
function itemToEvents(
item: Record<string, unknown>,
soleServer?: string
): TranscriptEvent[] {
const id = str(item.id) ?? '';
const itemType = str(item.type);

Expand Down Expand Up @@ -169,15 +180,23 @@ function itemToEvents(item: Record<string, unknown>): TranscriptEvent[] {
}
case 'mcp_tool_call': {
// Shape not pinned across versions — be defensive about field names and
// treat a missing status as unknown (not success).
const tool =
// treat a missing status as unknown (not success). `item.tool` is the
// bare tool name; `item.server` names the MCP server when present.
const bare =
str(item.tool) ?? str(item.name) ?? str(item.server) ?? 'mcp_tool_call';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I know it's not new in this PR, but this fallback chain looks strange. I wouldn't expect the tool name to fall back to server name, maybe better to keep it undefined or generic fallback text if it's truly something that can go missing (Codex source seems to indicate it's not optional)

// Prefer the explicit server field; fall back to the sole configured MCP
// server when the (unpinned) shape omits it.
const server = str(item.server) ?? soleServer;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we can remove the concept of "soleServer" as a fallback entirely, seems like dead code given the version of Codex we're on. I'd assume it will always tell us which server an MCP tool call was associated with, otherwise I'd want it fail validation in some visible way so we can stay aware of parser behaviors.

return toolCallPair(
id,
tool,
bare,
item,
item.result ?? item.output,
statusSuccess(item.status)
statusSuccess(item.status),
{},
server
? { kind: 'mcp', server, toolName: bare }
: { kind: 'other', toolName: bare }
);
}
case 'web_search': {
Expand All @@ -198,10 +217,13 @@ function itemToEvents(item: Record<string, unknown>): TranscriptEvent[] {
}
}

function recordToEvents(data: Record<string, unknown>): TranscriptEvent[] {
function recordToEvents(
data: Record<string, unknown>,
soleServer?: string
): TranscriptEvent[] {
switch (data.type) {
case 'item.completed':
return isRecord(data.item) ? itemToEvents(data.item) : [];
return isRecord(data.item) ? itemToEvents(data.item, soleServer) : [];
case 'turn.failed':
case 'error': {
const message =
Expand All @@ -215,12 +237,16 @@ function recordToEvents(data: Record<string, unknown>): TranscriptEvent[] {
}

export const codexParser: AgentTranscriptParser = {
parseTranscript(raw: string): ParsedTranscript {
parseTranscript(raw: string, ctx?: ParseContext): ParsedTranscript {
const { records, errors } = parseJsonlRecords(raw);
// When the mcp_tool_call shape omits the server, attribute to the sole
// configured MCP server if there's exactly one.
const soleServer =
ctx?.mcpServerNames?.length === 1 ? ctx.mcpServerNames[0] : undefined;
const events: TranscriptEvent[] = [];
for (const record of records) {
try {
events.push(...recordToEvents(record));
events.push(...recordToEvents(record, soleServer));
} catch (e) {
errors.push(e instanceof Error ? e.message : String(e));
}
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/agents/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ export function createCliAgent<M extends string = string>(
timeoutSec: args.timeoutSec,
});

const { events } = raw ? parser.parseTranscript(raw) : { events: [] };
const { events } = raw
? parser.parseTranscript(raw, {
mcpServerNames: Object.keys(args.mcpServers ?? {}),
})
: { events: [] };
const adapted = adaptTranscript(events);

// Surface run failures that would otherwise be invisible in results
Expand Down
30 changes: 24 additions & 6 deletions packages/core/src/agents/opencode/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,28 @@ describe('opencodeParser', () => {
state: { status: 'completed', input: { schemas: ['public'] } },
},
});
const { events } = opencodeParser.parseTranscript(record);
const call = events.find((e) => e.type === 'tool_call');
expect(call?.tool?.name).toBe('tool_use');
expect(call?.tool?.originalName).toBe('supabase-mcp_list_tables');
// Without the configured server name, the `<server>_<tool>` join is
// unsplittable, so the server is unattributed (`other`).
const bare = opencodeParser.parseTranscript(record);
const bareCall = bare.events.find((e) => e.type === 'tool_call');
expect(bareCall?.tool?.name).toBe('tool_use');
expect(bareCall?.tool?.originalName).toBe('supabase-mcp_list_tables');
expect(bareCall?.tool?.call).toEqual({
kind: 'other',
toolName: 'supabase-mcp_list_tables',
});

// Given the configured server name, the prefix is stripped and the call is
// attributed to that MCP server with the bare tool name.
const attributed = opencodeParser.parseTranscript(record, {
mcpServerNames: ['supabase-mcp'],
});
const call = attributed.events.find((e) => e.type === 'tool_call');
expect(call?.tool?.call).toEqual({
kind: 'mcp',
server: 'supabase-mcp',
toolName: 'list_tables',
});
});

it('maps bash + write to canonical tool calls, paired with results by callID', () => {
Expand Down Expand Up @@ -106,7 +124,7 @@ describe('opencodeParser', () => {
expect(adapted.steps).toBe(2); // two assistant text turns
expect(adapted.toolCalls).toEqual([
{
endpoint: 'bash',
tool: { kind: 'other', toolName: 'bash' },
body: { command: 'ls -la', description: 'List files' },
name: 'shell',
command: 'ls -la',
Expand All @@ -115,7 +133,7 @@ describe('opencodeParser', () => {
ts: 1782295624290, // epoch ms preserved through toISO -> parseTs
},
{
endpoint: 'write',
tool: { kind: 'other', toolName: 'write' },
body: { filePath: '/work/note.txt', content: 'hi' },
name: 'file_write',
path: '/work/note.txt',
Expand Down
Loading