Skip to content
37 changes: 12 additions & 25 deletions evals/cli/lib/detours.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,12 @@ import {
skipEnvOptions,
} from './detours.js';

// `cwd`/`endedAt` mirror the fields newer core versions add to ToolCallRecord.
type RecordFields = Partial<Omit<ToolCallRecord, 'cwd' | 'endedAt'>> & {
cwd?: unknown;
endedAt?: unknown;
};
const record = (fields: RecordFields): ToolCallRecord =>
({ tool: {}, body: {}, ts: 0, ...fields }) as ToolCallRecord;
const record = (fields: Partial<ToolCallRecord>): ToolCallRecord => ({
tool: { kind: 'other', toolName: 'shell' },
body: {},
ts: 0,
...fields,
});

// Exact `.source` text of the DETOUR_PATTERNS entries in detours.ts, so a
// reorder of that array doesn't silently weaken these assertions. Order
Expand Down Expand Up @@ -412,7 +411,7 @@ describe('extractCommandEntries', () => {
body: { command: ['supabase', 'stop'] },
cwd: '/tmp/sandbox-x/legacy-import',
}),
record({ command: 'supabase start', cwd: 42 }),
record({ command: 'supabase start' }),
record({ command: 'supabase status' }),
];

Expand All @@ -422,7 +421,7 @@ describe('extractCommandEntries', () => {
);
});

it('carries a string cwd and omits a missing or non-string one', () => {
it('carries a cwd when the record has one', () => {
expect(extractCommandEntries(calls)).toEqual([
{ command: 'ls', cwd: '/tmp/sandbox-x' },
{ command: 'supabase stop', cwd: '/tmp/sandbox-x/legacy-import' },
Expand All @@ -431,27 +430,15 @@ describe('extractCommandEntries', () => {
]);
});

it('takes the call time from endedAt', () => {
it('takes the call time from resultTs, whatever ts holds', () => {
expect(
extractCommandEntries([
record({ command: 'supabase stop', ts: 1000, endedAt: 2000 }),
])
).toEqual([{ command: 'supabase stop', at: 2000 }]);
});

it('omits the time when endedAt is missing or not a positive number, whatever ts holds', () => {
expect(
extractCommandEntries([
record({ command: 'supabase stop', ts: 1000 }),
record({ command: 'supabase start', ts: 1500, endedAt: 'soon' }),
record({ command: 'supabase status', ts: 1700, endedAt: 0 }),
record({ command: 'supabase db reset', ts: 0 }),
record({ command: 'supabase stop', ts: 1000, resultTs: 2000 }),
record({ command: 'supabase start', ts: 1500 }),
])
).toEqual([
{ command: 'supabase stop' },
{ command: 'supabase stop', at: 2000 },
{ command: 'supabase start' },
{ command: 'supabase status' },
{ command: 'supabase db reset' },
]);
});
});
Expand Down
19 changes: 4 additions & 15 deletions evals/cli/lib/detours.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,36 +513,25 @@ function recordCommand(record: ToolCallRecord): string {
* with it. `cwd` comes from agent parsers that record a per-call directory
* (Codex, OpenCode) and is absent otherwise; a `cd` persisting across separate
* tool calls in a persistent shell (e.g. Claude Code) is not tracked. `at` is
* the call's completion time (epoch ms), only present when the agent parser records it.
* the call's `resultTs` (epoch ms) when the agent records it, never `ts`, which
* parsers disagree on whether it marks issue or completion.
*/
export function extractCommandEntries(
toolCalls: readonly ToolCallRecord[]
): Array<{ command: string; cwd?: string; at?: number }> {
return toolCalls.flatMap((record) => {
const command = recordCommand(record);
if (command.length === 0) return [];
// `cwd` isn't on every core version's ToolCallRecord yet.
const cwd = (record as { cwd?: unknown }).cwd;
const at = recordTime(record);
return [
{
command,
...(typeof cwd === 'string' ? { cwd } : {}),
...(at === undefined ? {} : { at }),
...(record.cwd === undefined ? {} : { cwd: record.cwd }),
...(record.resultTs === undefined ? {} : { at: record.resultTs }),
},
];
});
}

// `endedAt` isn't on every core version's ToolCallRecord. `ts` is never used:
// parsers disagree on whether it marks when a call was issued or finished.
function recordTime(record: ToolCallRecord): number | undefined {
const { endedAt } = record as { endedAt?: unknown };
return typeof endedAt === 'number' && Number.isFinite(endedAt) && endedAt > 0
? endedAt
: undefined;
}

/** Numbers `commands` in order for the detour judge's input, each passed through in full. */
export function formatDetourJudgeInput(commands: readonly string[]): string {
return commands.map((command, i) => `${i + 1}. ${command}`).join('\n');
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/agents/claude-code/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ describe('adaptTranscript', () => {
result: 'file1\nfile2',
error: undefined,
ts: Date.parse('2026-06-18T10:00:00.000Z'),
resultTs: Date.parse('2026-06-18T10:00:01.000Z'),
},
{
tool: { kind: 'mcp', server: 'supabase', toolName: 'search_docs' },
Expand Down
148 changes: 141 additions & 7 deletions packages/core/src/agents/codex/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ describe('codexParser', () => {
expect(adapted.transcript[0]).toEqual({
type: 'tool_call',
name: 'execute_sql',
id: 'item_7',
input: args,
output: toolResult,
error: undefined,
Expand Down Expand Up @@ -422,7 +423,47 @@ describe('enrichFromRollout', () => {
expect(events.map((e) => [e.timestamp, e.requestId, e.usage])).toEqual([
['t1', 'r1', usage],
['t2', 'r1', usage],
['t5', undefined, undefined],
['t4', undefined, undefined],
]);
});

it("sets a paired tool call's cwd from the rollout item, resolving file:// URLs", () => {
const item = (id: string, command: string, cwd?: string) =>
JSON.stringify({
timestamp: 't',
type: 'event_msg',
payload: {
type: 'item_completed',
item: {
type: 'CommandExecution',
id,
command: ['/bin/bash', '-lc', command],
...(cwd === undefined ? {} : { cwd }),
},
},
});
const call = (id: string, command: string): TranscriptEvent => ({
type: 'tool_call',
tool: {
name: 'shell',
originalName: 'command_execution',
id,
command: `/bin/bash -lc ${command}`,
},
});
const events = [call('i1', 'a'), call('i2', 'b'), call('i3', 'c')];
enrichFromRollout(
events,
[
item('c1', 'a', '/work/client-a'),
item('c2', 'b', 'file:///work/my%20app'),
item('c3', 'c'),
].join('\n')
);
expect(events.map((e) => e.tool?.cwd)).toEqual([
'/work/client-a',
'/work/my app',
undefined,
]);
});

Expand Down Expand Up @@ -530,12 +571,46 @@ describe('enrichFromRollout tool pairing', () => {
).toEqual(['t1', 't2']);
});

it('skips all pairing when one command has extra arguments', () => {
it('takes a yielded command completion time from its item_completed', () => {
const completedAt = (id: string, at: string) =>
line(at, 'event_msg', {
type: 'item_completed',
item: {
type: 'CommandExecution',
id,
command: ['/bin/bash', '-lc', 'sleep 90'],
},
});
expect(
times(call('item_1', '/bin/bash -lc "sleep 90"'), [
issued('c1', 't1'),
finished('c1', 't2'),
issued('c2', 't3'),
finished('c2', 't4'),
completedAt('c1', 't5'),
])
).toEqual(['t1', 't5']);
});

it('falls back to the output time without an item_completed timestamp', () => {
expect(
times(call('item_1', '/bin/bash -lc "sleep 90"'), [
issued('c1', 't1'),
finished('c1', 't2'),
issued('c2', 't3'),
finished('c2', 't4'),
completed('c1', 'sleep 90'),
])
).toEqual(['t1', 't2']);
});

it('leaves a command with extra arguments unpaired and pairs the rest', () => {
expect(
times(
[
...call('item_1', '/bin/bash -lc pwd'),
...call('item_2', '/bin/bash -lc ls -la'),
...call('item_3', '/bin/bash -lc date'),
],
[
issued('c1', 't1'),
Expand All @@ -544,9 +619,12 @@ describe('enrichFromRollout tool pairing', () => {
issued('c2', 't3'),
completed('c2', 'ls'),
finished('c2', 't4'),
issued('c3', 't5'),
completed('c3', 'date'),
finished('c3', 't6'),
]
)
).toEqual([undefined, undefined, undefined, undefined]);
).toEqual(['t1', 't2', undefined, undefined, 't5', 't6']);
});

it('matches a redacted secret', () => {
Expand Down Expand Up @@ -575,7 +653,7 @@ describe('enrichFromRollout tool pairing', () => {
).toEqual([undefined, undefined]);
});

it('skips all pairing when the rollout is missing a command', () => {
it('pairs what it can when the rollout has fewer commands', () => {
expect(
times(
[
Expand All @@ -584,10 +662,66 @@ describe('enrichFromRollout tool pairing', () => {
],
[issued('c1', 't1'), completed('c1', 'pwd'), finished('c1', 't2')]
)
).toEqual([undefined, undefined, undefined, undefined]);
).toEqual(['t1', 't2', undefined, undefined]);
});

it('adds only itemless empty messages when tools do not pair', () => {
it('skips a rollout command the stream never listed', () => {
const events = [
...call('item_1', '/bin/bash -lc pwd'),
...call('item_2', '/bin/bash -lc ls'),
];
const withCwd = (id: string, script: string, cwd: string) =>
line('', 'event_msg', {
type: 'item_completed',
item: {
type: 'CommandExecution',
id,
cwd,
command: ['/bin/bash', '-lc', script],
},
});
enrichFromRollout(
events,
[
issued('c1', 't1'),
withCwd('c1', 'pwd', '/a'),
finished('c1', 't2'),
issued('c2', 't3'),
withCwd('c2', 'supabase functions serve', '/killed'),
finished('c2', 't4'),
issued('c3', 't5'),
withCwd('c3', 'ls', '/b'),
finished('c3', 't6'),
].join('\n')
);
expect(events.map((e) => [e.timestamp, e.tool?.cwd])).toEqual([
['t1', '/a'],
['t2', undefined],
['t5', '/b'],
['t6', undefined],
]);
});

it('never pairs out of order, even when a later item matches an earlier call', () => {
expect(
times(
[
...call('item_1', '/bin/bash -lc ls'),
...call('item_2', '/bin/bash -lc pwd'),
],
[
issued('c1', 't1'),
completed('c1', 'pwd'),
finished('c1', 't2'),
issued('c2', 't3'),
completed('c2', 'ls'),
finished('c2', 't4'),
]
)
).toEqual(['t3', 't4', undefined, undefined]);
});

it('adds only itemless empty messages when a tool call does not pair', () => {
const events = [
...call('item_1', '/bin/bash -lc pwd'),
...call('item_2', '/bin/bash -lc ls'),
Expand All @@ -605,7 +739,7 @@ describe('enrichFromRollout tool pairing', () => {
].join('\n')
);
expect(events.map((e) => [e.type, e.requestId])).toEqual([
['tool_call', undefined],
['tool_call', 'r1'],
['tool_result', undefined],
['tool_call', undefined],
['tool_result', undefined],
Expand Down
Loading