From daea9d6140e3809cb2c9a74b61e00337a62a88bb Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 1 Oct 2026 13:08:00 +0100 Subject: [PATCH 1/5] feat(core): record the working directory of agent tool calls --- packages/core/src/agents/codex/parser.test.ts | 126 +++++++++++++++++- packages/core/src/agents/codex/parser.ts | 124 ++++++++++++++++- packages/core/src/agents/codex/runner.test.ts | 25 ++++ packages/core/src/agents/codex/runner.ts | 1 + packages/core/src/agents/engine.test.ts | 40 +++++- packages/core/src/agents/engine.ts | 3 +- .../core/src/agents/opencode/parser.test.ts | 26 ++++ packages/core/src/agents/opencode/parser.ts | 7 +- packages/core/src/agents/types.ts | 2 + packages/core/src/index.ts | 2 + packages/core/src/parsers/adapt.ts | 1 + packages/core/src/parsers/shared/extract.ts | 3 + packages/core/src/parsers/types.ts | 2 + packages/core/src/transcript/types.ts | 2 + 14 files changed, 356 insertions(+), 8 deletions(-) diff --git a/packages/core/src/agents/codex/parser.test.ts b/packages/core/src/agents/codex/parser.test.ts index 532e5f4e..f95ea7d2 100644 --- a/packages/core/src/agents/codex/parser.test.ts +++ b/packages/core/src/agents/codex/parser.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { codexParser } from './parser.js'; +import { codexParser, extractCommandCwds } from './parser.js'; import { codexRunner } from './runner.js'; import { adaptTranscript } from '../../parsers/adapt.js'; @@ -249,6 +249,130 @@ describe('codexParser', () => { }); }); +function commandItem(id: string, command: string): string { + return JSON.stringify({ + type: 'item.completed', + item: { + id, + type: 'command_execution', + command, + aggregated_output: '', + exit_code: 0, + status: 'completed', + }, + }); +} + +/** A session-rollout `CommandExecution` completion, as Codex writes it to disk. */ +function rolloutCommand(script: string, cwd?: string): string { + return JSON.stringify({ + type: 'event_msg', + payload: { + type: 'item_completed', + item: { + type: 'CommandExecution', + id: 'call_1', + command: ['/bin/bash', '-lc', script], + cwd, + }, + }, + }); +} + +describe('codexParser command cwd', () => { + it('joins each command to its rollout cwd by order', () => { + const stream = [ + commandItem('c1', "/bin/bash -lc 'supabase start'"), + JSON.stringify({ + type: 'item.completed', + item: { + id: 'f1', + type: 'file_change', + changes: [], + status: 'completed', + }, + }), + commandItem('c2', "/bin/bash -lc 'supabase stop --no-backup'"), + ].join('\n'); + const sessionLog = [ + rolloutCommand('supabase start', 'file:///tmp/s/client-a'), + 'not json', + JSON.stringify({ type: 'event_msg', payload: { type: 'token_count' } }), + rolloutCommand('supabase stop --no-backup', 'file:///tmp/s'), + ].join('\n'); + + const parsed = codexParser.parseTranscript(stream, { sessionLog }); + expect(parsed.errors).toEqual([]); + const { toolCalls } = adaptTranscript(parsed.events); + expect(toolCalls.map((t) => t.cwd)).toEqual([ + '/tmp/s/client-a', + undefined, + '/tmp/s', + ]); + }); + + it('leaves cwd unset when the rollout command does not match', () => { + const stream = [ + commandItem('c1', "/bin/bash -lc 'ls'"), + commandItem('c2', "/bin/bash -lc 'pwd'"), + ].join('\n'); + const sessionLog = [ + rolloutCommand('ls', 'file:///tmp/s'), + rolloutCommand('whoami', 'file:///tmp/s'), + ].join('\n'); + + const { toolCalls } = adaptTranscript( + codexParser.parseTranscript(stream, { sessionLog }).events + ); + expect(toolCalls.map((t) => t.cwd)).toEqual(['/tmp/s', undefined]); + }); + + it('matches shell-quoted commands against the rollout argv', () => { + const stream = commandItem( + 'c1', + String.raw`/bin/bash -lc "printf '%s\\n' \"\$HOME\"; echo 'it'\''s'"` + ); + const sessionLog = rolloutCommand( + String.raw`printf '%s\n' "$HOME"; echo 'it'\''s'`, + 'file:///tmp/s/client-b' + ); + + const { toolCalls } = adaptTranscript( + codexParser.parseTranscript(stream, { sessionLog }).events + ); + expect(toolCalls[0].cwd).toBe('/tmp/s/client-b'); + }); + + it('records no cwd without a session log', () => { + const { toolCalls } = adaptTranscript( + codexParser.parseTranscript(commandItem('c1', "/bin/bash -lc 'ls'")) + .events + ); + expect(toolCalls[0].cwd).toBeUndefined(); + }); +}); + +describe('extractCommandCwds', () => { + it('lists rollout commands in order with normalized cwds', () => { + const rollout = [ + rolloutCommand('ls', 'file:///tmp/s/client-a'), + '{"truncated":', + rolloutCommand('pwd', '/tmp/s'), + rolloutCommand('whoami'), + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'exec_command' }, + }), + ].join('\n'); + expect(extractCommandCwds(rollout)).toEqual([ + { argv: ['/bin/bash', '-lc', 'ls'], cwd: '/tmp/s/client-a' }, + { argv: ['/bin/bash', '-lc', 'pwd'], cwd: '/tmp/s' }, + { argv: ['/bin/bash', '-lc', 'whoami'], cwd: undefined }, + ]); + expect(extractCommandCwds('')).toEqual([]); + }); +}); + describe('codexRunner.deriveStopReason', () => { const ok: Parameters>[1] = { ok: true, diff --git a/packages/core/src/agents/codex/parser.ts b/packages/core/src/agents/codex/parser.ts index f02afe10..fc9ee441 100644 --- a/packages/core/src/agents/codex/parser.ts +++ b/packages/core/src/agents/codex/parser.ts @@ -15,16 +15,21 @@ * (correlated by the item id) so the adapter can attach the output. * * NB: this is the `--json` event schema, NOT the `~/.codex/sessions` rollout - * format (event_msg/response_item) that older parsers targeted. + * format (event_msg/response_item) that older parsers targeted. The rollout is + * read only to recover each command's cwd (see `extractCommandCwds`). */ +import { fileURLToPath } from 'node:url'; 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, @@ -224,8 +229,120 @@ function recordToEvents(data: Record): TranscriptEvent[] { } } +/** A rollout `CommandExecution`'s argv and the absolute directory it ran in. */ +export interface CommandCwd { + argv?: string[]; + cwd?: string; +} + +/** + * Each completed `CommandExecution` in a Codex session rollout, in order. Kept + * even when a field is unreadable so positions still line up with the stream. + */ +export function extractCommandCwds(rollout: string): CommandCwd[] { + const { records } = parseJsonlRecords(rollout); + const commands: CommandCwd[] = []; + for (const record of records) { + if (record.type !== 'event_msg' || !isRecord(record.payload)) continue; + if (record.payload.type !== 'item_completed') continue; + const item = record.payload.item; + if (!isRecord(item) || item.type !== 'CommandExecution') continue; + const argv = + Array.isArray(item.command) && + item.command.every((part) => typeof part === 'string') + ? item.command + : undefined; + commands.push({ argv, cwd: directoryPath(item.cwd) }); + } + return commands; +} + +function directoryPath(value: unknown): string | undefined { + if (typeof value !== 'string' || !value) return undefined; + if (!value.startsWith('file:')) return value; + try { + return fileURLToPath(value); + } catch { + return undefined; + } +} + +/** + * POSIX shell word splitting (quotes and backslashes, no expansion), enough to + * recover the argv Codex shell-joined into a `command_execution` command. + * Undefined on an unterminated quote or trailing backslash. + */ +function splitShellWords(command: string): string[] | undefined { + const words: string[] = []; + let word: string | undefined; + for (let i = 0; i < command.length; i += 1) { + const ch = command[i]; + if (ch === "'") { + const end = command.indexOf("'", i + 1); + if (end === -1) return undefined; + word = (word ?? '') + command.slice(i + 1, end); + i = end; + } else if (ch === '"') { + let quoted = ''; + i += 1; + for (; i < command.length && command[i] !== '"'; i += 1) { + const next = command[i + 1]; + if ( + command[i] === '\\' && + next !== undefined && + '$`"\\\n'.includes(next) + ) { + i += 1; + if (next === '\n') continue; + } + quoted += command[i]; + } + if (i >= command.length) return undefined; + word = (word ?? '') + quoted; + } else if (ch === '\\') { + i += 1; + if (i >= command.length) return undefined; + if (command[i] !== '\n') word = (word ?? '') + command[i]; + } else if (/\s/.test(ch)) { + if (word !== undefined) words.push(word); + word = undefined; + } else { + word = (word ?? '') + ch; + } + } + if (word !== undefined) words.push(word); + return words; +} + +// The --json stream omits each command's cwd. The rollout lists the same +// commands in the same order, so join by position and keep only argv matches. +function attachCommandCwds(events: TranscriptEvent[], rollout: string): void { + const commands = extractCommandCwds(rollout); + let index = 0; + for (const event of events) { + const tool = event.tool; + if (event.type !== 'tool_call' || !tool) continue; + if ( + tool.call?.kind !== 'other' || + tool.originalName !== 'command_execution' + ) + continue; + const { argv, cwd } = commands[index] ?? {}; + index += 1; + const words = + typeof tool.args?.command === 'string' + ? splitShellWords(tool.args.command) + : undefined; + if (cwd && argv && words && sameWords(words, argv)) tool.cwd = cwd; + } +} + +function sameWords(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((word, i) => word === b[i]); +} + export const codexParser: AgentTranscriptParser = { - parseTranscript(raw: string): ParsedTranscript { + parseTranscript(raw: string, ctx?: ParseContext): ParsedTranscript { const { records, errors } = parseJsonlRecords(raw); const events: TranscriptEvent[] = []; for (const record of records) { @@ -235,6 +352,7 @@ export const codexParser: AgentTranscriptParser = { errors.push(e instanceof Error ? e.message : String(e)); } } + if (ctx?.sessionLog) attachCommandCwds(events, ctx.sessionLog); return { events, errors }; }, }; diff --git a/packages/core/src/agents/codex/runner.test.ts b/packages/core/src/agents/codex/runner.test.ts index 50a8abc6..75b2138c 100644 --- a/packages/core/src/agents/codex/runner.test.ts +++ b/packages/core/src/agents/codex/runner.test.ts @@ -38,6 +38,31 @@ describe('codexRunner.exec', () => { }); }); +describe('codexRunner.exec session log', () => { + it('returns the session rollout for the parser', async () => { + const rollout = JSON.stringify({ + type: 'event_msg', + payload: { type: 'token_count' }, + }); + const result = await codexRunner.exec({ + sandbox: { + workspace: '/w', + exec: async (cmd) => + cmd.includes('rollout-*.jsonl') ? { ...ok, stdout: rollout } : ok, + readFile: async () => '', + copyToHost: async () => {}, + }, + model: 'gpt-5.4', + apiKey: 'k', + userPromptPath: '"$HOME/.eval/user-prompt.txt"', + mcpServers: {}, + timeoutSec: 1, + }); + expect(result.sessionLog).toBe(rollout); + expect(result.stepCount).toBe(1); + }); +}); + describe('codexRunner.extractUsage', () => { const extract = codexRunner.extractUsage!; diff --git a/packages/core/src/agents/codex/runner.ts b/packages/core/src/agents/codex/runner.ts index c3c17e99..88746ae2 100644 --- a/packages/core/src/agents/codex/runner.ts +++ b/packages/core/src/agents/codex/runner.ts @@ -108,6 +108,7 @@ export const codexRunner: AgentRunner = { command, raw: command.stdout, stepCount: countModelResponses(rollout.stdout), + sessionLog: rollout.stdout, }; }, diff --git a/packages/core/src/agents/engine.test.ts b/packages/core/src/agents/engine.test.ts index 85395f2f..e9968a73 100644 --- a/packages/core/src/agents/engine.test.ts +++ b/packages/core/src/agents/engine.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { CommandResult } from '../index.js'; -import type { AgentTranscriptParser } from '../parsers/types.js'; +import type { AgentTranscriptParser, ParseContext } from '../parsers/types.js'; import { createCliAgent } from './engine.js'; import { USER_PROMPT_PATH } from './shared.js'; import type { AgentRunner } from './types.js'; @@ -91,6 +91,44 @@ describe('createCliAgent prompt staging', () => { }); }); +describe('createCliAgent session log', () => { + beforeEach(() => { + process.env[API_KEY_ENV_VAR] = 'k'; + }); + + it("hands the runner's session log to the parser", async () => { + const contexts: (ParseContext | undefined)[] = []; + const runner: AgentRunner = { + id: 'claude-code', + displayName: 'Fake CLI', + apiKeyEnvVar: API_KEY_ENV_VAR, + cliPackage: 'fake-cli', + defaultCliVersion: '1.0.0', + defaultModel: 'fake-model', + install: async () => {}, + exec: async () => ({ command: ok, raw: '{}', sessionLog: 'rollout' }), + }; + const recordingParser: AgentTranscriptParser = { + parseTranscript: (raw, ctx) => { + contexts.push(ctx); + return parser.parseTranscript(raw, ctx); + }, + }; + await createCliAgent(runner, recordingParser, { model: 'fake-model' }).run({ + systemPrompt: '', + userPrompt: 'the task', + timeoutSec: 1, + sandbox: { + workspace: '/w', + exec: async () => ok, + readFile: async () => '', + copyToHost: async () => {}, + }, + }); + expect(contexts).toEqual([{ mcpServerNames: [], sessionLog: 'rollout' }]); + }); +}); + describe('createCliAgent session archive', () => { let home: string; diff --git a/packages/core/src/agents/engine.ts b/packages/core/src/agents/engine.ts index 757e8401..d6466632 100644 --- a/packages/core/src/agents/engine.ts +++ b/packages/core/src/agents/engine.ts @@ -110,7 +110,7 @@ export function createCliAgent( await writeSandboxFile(sandbox, USER_PROMPT_PATH, args.userPrompt); const start = Date.now(); - const { command, raw, stepCount } = await runner.exec({ + const { command, raw, stepCount, sessionLog } = await runner.exec({ sandbox, model: options.model, apiKey, @@ -126,6 +126,7 @@ export function createCliAgent( const { events } = raw ? parser.parseTranscript(raw, { mcpServerNames: Object.keys(args.mcpServers ?? {}), + sessionLog, }) : { events: [] }; const adapted = adaptTranscript(events); diff --git a/packages/core/src/agents/opencode/parser.test.ts b/packages/core/src/agents/opencode/parser.test.ts index cb7faa64..d1174814 100644 --- a/packages/core/src/agents/opencode/parser.test.ts +++ b/packages/core/src/agents/opencode/parser.test.ts @@ -111,6 +111,32 @@ describe('opencodeParser', () => { expect(results.every((e) => e.tool?.success === true)).toBe(true); }); + it("normalizes bash's workdir to the call's cwd", () => { + const stream = JSON.stringify({ + type: 'tool_use', + part: { + type: 'tool', + tool: 'bash', + callID: 'tool_1', + state: { + status: 'completed', + input: { command: 'supabase start', workdir: '/work/client-a' }, + output: '', + metadata: { exit: 0 }, + }, + }, + }); + + const { toolCalls } = adaptTranscript( + opencodeParser.parseTranscript(stream).events + ); + expect(toolCalls[0].cwd).toBe('/work/client-a'); + expect( + adaptTranscript(opencodeParser.parseTranscript(SESSION).events) + .toolCalls[0].cwd + ).toBeUndefined(); + }); + it('surfaces reasoning + the assistant report via the adapter', () => { const events = opencodeParser.parseTranscript(SESSION).events; expect( diff --git a/packages/core/src/agents/opencode/parser.ts b/packages/core/src/agents/opencode/parser.ts index 0a2e2909..34e2bac3 100644 --- a/packages/core/src/agents/opencode/parser.ts +++ b/packages/core/src/agents/opencode/parser.ts @@ -104,13 +104,15 @@ const OPENCODE_TOOLS: AgentToolMap = { /** * opencode tool args → normalized fields. `bash` carries the command in - * `command`; file tools the path in `filePath` (or `path`); `webfetch` the URL - * in `url`. The shared extractor reads whichever keys this map names. + * `command` (and an optional `workdir`); file tools the path in `filePath` (or + * `path`); `webfetch` the URL in `url`. The shared extractor reads whichever + * keys this map names. */ const OPENCODE_ARG_FIELDS: ArgFieldMap = { path: ['filePath', 'file_path', 'path'], command: ['command'], url: ['url'], + cwd: ['workdir'], }; /** Epoch-ms (or pass-through ISO) → ISO string. */ @@ -194,6 +196,7 @@ function partToEvents( if (normalized.path) tool.path = normalized.path; if (normalized.command) tool.command = normalized.command; if (normalized.url) tool.url = normalized.url; + if (normalized.cwd) tool.cwd = normalized.cwd; const loadedSkills = loadedSkillsFromOpencodeCall(tool); if (loadedSkills.length > 0) tool.loadedSkills = loadedSkills; diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 1db6ae62..83855fa3 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -81,6 +81,8 @@ export interface RunnerExecResult { * eval-metadata.ts for what counts as one. */ stepCount?: number; + /** The CLI's on-disk session log, handed to the parser as `ParseContext.sessionLog`. */ + sessionLog?: string; } /** A CLI coding agent's execution strategy. `M` is its SDK model-id type. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 76966770..41ef5517 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -206,6 +206,8 @@ export interface ToolCallRecord { path?: string; command?: string; url?: string; + /** Absolute directory the agent ran the call in, when its harness records one per call. */ + cwd?: string; /** Canonical tool category, set by CLI agent parsers; unset for ai-sdk tools which have no normalization layer. */ name?: ToolName; /** Skill names loaded by this call, when the harness can identify any. */ diff --git a/packages/core/src/parsers/adapt.ts b/packages/core/src/parsers/adapt.ts index 58e916ca..2b900044 100644 --- a/packages/core/src/parsers/adapt.ts +++ b/packages/core/src/parsers/adapt.ts @@ -76,6 +76,7 @@ export function adaptTranscript(events: TranscriptEvent[]): AdaptedTranscript { path: event.tool.path, command: event.tool.command, url: event.tool.url, + cwd: event.tool.cwd, loadedSkills: event.tool.loadedSkills, result: resolved?.error === undefined ? resolved?.result : undefined, error: resolved?.error, diff --git a/packages/core/src/parsers/shared/extract.ts b/packages/core/src/parsers/shared/extract.ts index ce727e8d..bddb175a 100644 --- a/packages/core/src/parsers/shared/extract.ts +++ b/packages/core/src/parsers/shared/extract.ts @@ -17,6 +17,7 @@ export interface ArgFieldMap { path?: readonly string[]; command?: readonly string[]; url?: readonly string[]; + cwd?: readonly string[]; } /** Normalized values extracted from a tool call's args. */ @@ -24,6 +25,7 @@ export interface ExtractedArgs { path?: string; command?: string; url?: string; + cwd?: string; } // Skill reads can appear as bare paths or quoted shell args in tool-call logs. @@ -63,6 +65,7 @@ export function extractArgs( path: firstField(args, map.path), command: firstField(args, map.command), url: firstField(args, map.url), + cwd: firstField(args, map.cwd), }; } diff --git a/packages/core/src/parsers/types.ts b/packages/core/src/parsers/types.ts index 434a30c3..7b2980f1 100644 --- a/packages/core/src/parsers/types.ts +++ b/packages/core/src/parsers/types.ts @@ -21,6 +21,8 @@ import type { ParsedTranscript } from '../transcript/types.js'; export interface ParseContext { /** Names of the MCP servers configured for this run. */ mcpServerNames?: string[]; + /** The CLI's on-disk session log, for details its transcript stream omits (Codex's rollout). */ + sessionLog?: string; } export interface AgentTranscriptParser { diff --git a/packages/core/src/transcript/types.ts b/packages/core/src/transcript/types.ts index abca11e9..123ea639 100644 --- a/packages/core/src/transcript/types.ts +++ b/packages/core/src/transcript/types.ts @@ -81,6 +81,8 @@ export interface TranscriptEvent { path?: string; command?: string; url?: string; + /** Absolute directory the call ran in, when the agent records one per call. */ + cwd?: string; /** Skill names loaded by this call, when the parser can identify any. */ loadedSkills?: string[]; /** Tool result payload (for `tool_result`). */ From 6cd13b3035c2ff426ab735f5fb2bbd44b4f39683 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 1 Oct 2026 15:42:01 +0100 Subject: [PATCH 2/5] feat(core): record codex tool call completion times from the rollout --- packages/core/src/agents/codex/parser.test.ts | 93 +++++++++++++++++-- packages/core/src/agents/codex/parser.ts | 43 ++++++--- packages/core/src/index.ts | 2 + packages/core/src/parsers/adapt.ts | 1 + packages/core/src/transcript/types.ts | 2 + 5 files changed, 120 insertions(+), 21 deletions(-) diff --git a/packages/core/src/agents/codex/parser.test.ts b/packages/core/src/agents/codex/parser.test.ts index f95ea7d2..30173bc1 100644 --- a/packages/core/src/agents/codex/parser.test.ts +++ b/packages/core/src/agents/codex/parser.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { codexParser, extractCommandCwds } from './parser.js'; +import { codexParser, extractCommandExecutions } from './parser.js'; import { codexRunner } from './runner.js'; import { adaptTranscript } from '../../parsers/adapt.js'; @@ -264,8 +264,13 @@ function commandItem(id: string, command: string): string { } /** A session-rollout `CommandExecution` completion, as Codex writes it to disk. */ -function rolloutCommand(script: string, cwd?: string): string { +function rolloutCommand( + script: string, + cwd?: string, + timestamp?: unknown +): string { return JSON.stringify({ + timestamp, type: 'event_msg', payload: { type: 'item_completed', @@ -352,10 +357,78 @@ describe('codexParser command cwd', () => { }); }); -describe('extractCommandCwds', () => { - it('lists rollout commands in order with normalized cwds', () => { +describe('codexParser command completion time', () => { + it('sets endedAt from the matched rollout line timestamp', () => { + const stream = [ + commandItem('c1', "/bin/bash -lc 'supabase start'"), + commandItem('c2', "/bin/bash -lc 'supabase status'"), + ].join('\n'); + const sessionLog = [ + rolloutCommand( + 'supabase start', + 'file:///tmp/s', + '2026-10-01T11:48:01.731Z' + ), + rolloutCommand('supabase status', undefined, '2026-10-01T11:48:09.002Z'), + ].join('\n'); + + const { toolCalls } = adaptTranscript( + codexParser.parseTranscript(stream, { sessionLog }).events + ); + expect(toolCalls.map((t) => t.endedAt)).toEqual([ + Date.parse('2026-10-01T11:48:01.731Z'), + Date.parse('2026-10-01T11:48:09.002Z'), + ]); + expect(toolCalls.map((t) => t.cwd)).toEqual(['/tmp/s', undefined]); + }); + + it('leaves endedAt unset when the rollout command does not match', () => { + const stream = commandItem('c1', "/bin/bash -lc 'pwd'"); + const sessionLog = rolloutCommand( + 'whoami', + 'file:///tmp/s', + '2026-10-01T11:48:01.731Z' + ); + + const { toolCalls } = adaptTranscript( + codexParser.parseTranscript(stream, { sessionLog }).events + ); + expect(toolCalls[0].endedAt).toBeUndefined(); + expect(toolCalls[0].cwd).toBeUndefined(); + }); + + it('leaves endedAt unset when the rollout timestamp is missing or invalid', () => { + const stream = [ + commandItem('c1', "/bin/bash -lc 'ls'"), + commandItem('c2', "/bin/bash -lc 'pwd'"), + commandItem('c3', "/bin/bash -lc 'whoami'"), + ].join('\n'); + const sessionLog = [ + rolloutCommand('ls', 'file:///tmp/s'), + rolloutCommand('pwd', 'file:///tmp/s', 'not a date'), + rolloutCommand('whoami', 'file:///tmp/s', 1759319281731), + ].join('\n'); + + const parsed = codexParser.parseTranscript(stream, { sessionLog }); + expect(parsed.errors).toEqual([]); + const { toolCalls } = adaptTranscript(parsed.events); + expect(toolCalls.map((t) => t.endedAt)).toEqual([ + undefined, + undefined, + undefined, + ]); + expect(toolCalls.map((t) => t.cwd)).toEqual(['/tmp/s', '/tmp/s', '/tmp/s']); + }); +}); + +describe('extractCommandExecutions', () => { + it('lists rollout commands in order with normalized cwds and completion times', () => { const rollout = [ - rolloutCommand('ls', 'file:///tmp/s/client-a'), + rolloutCommand( + 'ls', + 'file:///tmp/s/client-a', + '2026-10-01T11:48:01.731Z' + ), '{"truncated":', rolloutCommand('pwd', '/tmp/s'), rolloutCommand('whoami'), @@ -364,12 +437,16 @@ describe('extractCommandCwds', () => { payload: { type: 'function_call', name: 'exec_command' }, }), ].join('\n'); - expect(extractCommandCwds(rollout)).toEqual([ - { argv: ['/bin/bash', '-lc', 'ls'], cwd: '/tmp/s/client-a' }, + expect(extractCommandExecutions(rollout)).toEqual([ + { + argv: ['/bin/bash', '-lc', 'ls'], + cwd: '/tmp/s/client-a', + endedAt: Date.parse('2026-10-01T11:48:01.731Z'), + }, { argv: ['/bin/bash', '-lc', 'pwd'], cwd: '/tmp/s' }, { argv: ['/bin/bash', '-lc', 'whoami'], cwd: undefined }, ]); - expect(extractCommandCwds('')).toEqual([]); + expect(extractCommandExecutions('')).toEqual([]); }); }); diff --git a/packages/core/src/agents/codex/parser.ts b/packages/core/src/agents/codex/parser.ts index fc9ee441..9ba9d540 100644 --- a/packages/core/src/agents/codex/parser.ts +++ b/packages/core/src/agents/codex/parser.ts @@ -16,7 +16,8 @@ * * NB: this is the `--json` event schema, NOT the `~/.codex/sessions` rollout * format (event_msg/response_item) that older parsers targeted. The rollout is - * read only to recover each command's cwd (see `extractCommandCwds`). + * read only to recover each command's cwd and completion time (see + * `extractCommandExecutions`). */ import { fileURLToPath } from 'node:url'; @@ -229,19 +230,20 @@ function recordToEvents(data: Record): TranscriptEvent[] { } } -/** A rollout `CommandExecution`'s argv and the absolute directory it ran in. */ -export interface CommandCwd { +/** A rollout `CommandExecution`'s argv, the absolute directory it ran in, and when it completed (epoch ms). */ +export interface CommandExecution { argv?: string[]; cwd?: string; + endedAt?: number; } /** * Each completed `CommandExecution` in a Codex session rollout, in order. Kept * even when a field is unreadable so positions still line up with the stream. */ -export function extractCommandCwds(rollout: string): CommandCwd[] { +export function extractCommandExecutions(rollout: string): CommandExecution[] { const { records } = parseJsonlRecords(rollout); - const commands: CommandCwd[] = []; + const commands: CommandExecution[] = []; for (const record of records) { if (record.type !== 'event_msg' || !isRecord(record.payload)) continue; if (record.payload.type !== 'item_completed') continue; @@ -252,11 +254,21 @@ export function extractCommandCwds(rollout: string): CommandCwd[] { item.command.every((part) => typeof part === 'string') ? item.command : undefined; - commands.push({ argv, cwd: directoryPath(item.cwd) }); + commands.push({ + argv, + cwd: directoryPath(item.cwd), + endedAt: epochMs(record.timestamp), + }); } return commands; } +function epochMs(value: unknown): number | undefined { + if (typeof value !== 'string') return undefined; + const ms = Date.parse(value); + return Number.isNaN(ms) ? undefined : ms; +} + function directoryPath(value: unknown): string | undefined { if (typeof value !== 'string' || !value) return undefined; if (!value.startsWith('file:')) return value; @@ -314,10 +326,13 @@ function splitShellWords(command: string): string[] | undefined { return words; } -// The --json stream omits each command's cwd. The rollout lists the same -// commands in the same order, so join by position and keep only argv matches. -function attachCommandCwds(events: TranscriptEvent[], rollout: string): void { - const commands = extractCommandCwds(rollout); +// The --json stream omits each command's cwd and timing. The rollout lists the +// same commands in the same order, so join by position and keep only argv matches. +function attachCommandExecutions( + events: TranscriptEvent[], + rollout: string +): void { + const commands = extractCommandExecutions(rollout); let index = 0; for (const event of events) { const tool = event.tool; @@ -327,13 +342,15 @@ function attachCommandCwds(events: TranscriptEvent[], rollout: string): void { tool.originalName !== 'command_execution' ) continue; - const { argv, cwd } = commands[index] ?? {}; + const { argv, cwd, endedAt } = commands[index] ?? {}; index += 1; const words = typeof tool.args?.command === 'string' ? splitShellWords(tool.args.command) : undefined; - if (cwd && argv && words && sameWords(words, argv)) tool.cwd = cwd; + if (!argv || !words || !sameWords(words, argv)) continue; + if (cwd) tool.cwd = cwd; + if (endedAt !== undefined) tool.endedAt = endedAt; } } @@ -352,7 +369,7 @@ export const codexParser: AgentTranscriptParser = { errors.push(e instanceof Error ? e.message : String(e)); } } - if (ctx?.sessionLog) attachCommandCwds(events, ctx.sessionLog); + if (ctx?.sessionLog) attachCommandExecutions(events, ctx.sessionLog); return { events, errors }; }, }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 41ef5517..24905e47 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -208,6 +208,8 @@ export interface ToolCallRecord { url?: string; /** Absolute directory the agent ran the call in, when its harness records one per call. */ cwd?: string; + /** Epoch ms the call completed, when its harness records it per call. */ + endedAt?: number; /** Canonical tool category, set by CLI agent parsers; unset for ai-sdk tools which have no normalization layer. */ name?: ToolName; /** Skill names loaded by this call, when the harness can identify any. */ diff --git a/packages/core/src/parsers/adapt.ts b/packages/core/src/parsers/adapt.ts index 2b900044..9759dd94 100644 --- a/packages/core/src/parsers/adapt.ts +++ b/packages/core/src/parsers/adapt.ts @@ -77,6 +77,7 @@ export function adaptTranscript(events: TranscriptEvent[]): AdaptedTranscript { command: event.tool.command, url: event.tool.url, cwd: event.tool.cwd, + endedAt: event.tool.endedAt, loadedSkills: event.tool.loadedSkills, result: resolved?.error === undefined ? resolved?.result : undefined, error: resolved?.error, diff --git a/packages/core/src/transcript/types.ts b/packages/core/src/transcript/types.ts index 123ea639..7ffed1b7 100644 --- a/packages/core/src/transcript/types.ts +++ b/packages/core/src/transcript/types.ts @@ -83,6 +83,8 @@ export interface TranscriptEvent { url?: string; /** Absolute directory the call ran in, when the agent records one per call. */ cwd?: string; + /** Epoch ms the call completed, when the agent records it per call. */ + endedAt?: number; /** Skill names loaded by this call, when the parser can identify any. */ loadedSkills?: string[]; /** Tool result payload (for `tool_result`). */ From 4f6842253f5c74489dbc5b4009c4dc02eeb985ae Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 6 Oct 2026 09:46:57 +0100 Subject: [PATCH 3/5] refactor(core): record tool call cwd on top of the rollout pairing Set each Codex tool call's cwd from the rollout item #351 already pairs it with, copy it and the result time onto ToolCallRecord, and read both directly in the CLI detour helpers. --- evals/cli/lib/detours.test.ts | 37 ++++++----------- evals/cli/lib/detours.ts | 19 ++------- .../src/agents/claude-code/parser.test.ts | 1 + packages/core/src/agents/codex/parser.test.ts | 41 +++++++++++++++++++ packages/core/src/agents/codex/parser.ts | 14 +++++++ .../core/src/agents/opencode/parser.test.ts | 28 +++++++++++++ packages/core/src/agents/opencode/parser.ts | 7 +++- packages/core/src/index.ts | 4 ++ packages/core/src/parsers/adapt.test.ts | 36 ++++++++++++++++ packages/core/src/parsers/adapt.ts | 2 + packages/core/src/parsers/shared/extract.ts | 3 ++ packages/core/src/transcript/types.ts | 2 + 12 files changed, 152 insertions(+), 42 deletions(-) create mode 100644 packages/core/src/parsers/adapt.test.ts diff --git a/evals/cli/lib/detours.test.ts b/evals/cli/lib/detours.test.ts index 10d43c43..68bc7b1e 100644 --- a/evals/cli/lib/detours.test.ts +++ b/evals/cli/lib/detours.test.ts @@ -16,13 +16,12 @@ import { skipEnvOptions, } from './detours.js'; -// `cwd`/`endedAt` mirror the fields newer core versions add to ToolCallRecord. -type RecordFields = Partial> & { - cwd?: unknown; - endedAt?: unknown; -}; -const record = (fields: RecordFields): ToolCallRecord => - ({ tool: {}, body: {}, ts: 0, ...fields }) as ToolCallRecord; +const record = (fields: Partial): 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 @@ -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' }), ]; @@ -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' }, @@ -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' }, ]); }); }); diff --git a/evals/cli/lib/detours.ts b/evals/cli/lib/detours.ts index aab27a68..88ba56ef 100644 --- a/evals/cli/lib/detours.ts +++ b/evals/cli/lib/detours.ts @@ -513,7 +513,8 @@ 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[] @@ -521,28 +522,16 @@ export function extractCommandEntries( 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'); diff --git a/packages/core/src/agents/claude-code/parser.test.ts b/packages/core/src/agents/claude-code/parser.test.ts index 9db0115c..c1710ca3 100644 --- a/packages/core/src/agents/claude-code/parser.test.ts +++ b/packages/core/src/agents/claude-code/parser.test.ts @@ -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' }, diff --git a/packages/core/src/agents/codex/parser.test.ts b/packages/core/src/agents/codex/parser.test.ts index 416098b0..c52953c5 100644 --- a/packages/core/src/agents/codex/parser.test.ts +++ b/packages/core/src/agents/codex/parser.test.ts @@ -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, @@ -426,6 +427,46 @@ describe('enrichFromRollout', () => { ]); }); + 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, + ]); + }); + it('adds an empty message for a request with no events', () => { const line = (timestamp: string, type: string, payload: object) => JSON.stringify({ timestamp, type, payload }); diff --git a/packages/core/src/agents/codex/parser.ts b/packages/core/src/agents/codex/parser.ts index d9e8842b..0d7efcc5 100644 --- a/packages/core/src/agents/codex/parser.ts +++ b/packages/core/src/agents/codex/parser.ts @@ -21,6 +21,7 @@ * format (event_msg/response_item) that older parsers targeted. */ +import { fileURLToPath } from 'node:url'; import { isRecord, parseJsonlRecords } from '../../json.js'; import type { ParsedTranscript, @@ -356,6 +357,8 @@ export function enrichFromRollout( const itemId = String(toolItems[i].id); const start = callStarts.get(itemId); if (event.tool?.id) callIdByItem.set(event.tool.id, itemId); + const cwd = directoryPath(toolItems[i].cwd); + if (cwd && event.tool) event.tool.cwd = cwd; if (!start) return; event.timestamp = start.at ?? event.timestamp; tag(event, start.request); @@ -412,6 +415,17 @@ function sameCall(event: TranscriptEvent, item: Record) { return true; } +/** A rollout `cwd` is a plain path or, for some Codex versions, a `file://` URL. */ +function directoryPath(value: unknown): string | undefined { + if (typeof value !== 'string' || !value) return undefined; + if (!value.startsWith('file:')) return value; + try { + return fileURLToPath(value); + } catch { + return undefined; + } +} + const REDACTED = '[REDACTED_SECRET]'; function sameWord(word: string, raw: string): boolean { diff --git a/packages/core/src/agents/opencode/parser.test.ts b/packages/core/src/agents/opencode/parser.test.ts index 218c97a9..cec507d4 100644 --- a/packages/core/src/agents/opencode/parser.test.ts +++ b/packages/core/src/agents/opencode/parser.test.ts @@ -111,6 +111,32 @@ describe('opencodeParser', () => { expect(results.every((e) => e.tool?.success === true)).toBe(true); }); + it("normalizes bash's workdir to the call's cwd", () => { + const stream = JSON.stringify({ + type: 'tool_use', + part: { + type: 'tool', + tool: 'bash', + callID: 'tool_1', + state: { + status: 'completed', + input: { command: 'supabase start', workdir: '/work/client-a' }, + output: '', + metadata: { exit: 0 }, + }, + }, + }); + + const { toolCalls } = adaptTranscript( + opencodeParser.parseTranscript(stream).events + ); + expect(toolCalls[0].cwd).toBe('/work/client-a'); + expect( + adaptTranscript(opencodeParser.parseTranscript(SESSION).events) + .toolCalls[0].cwd + ).toBeUndefined(); + }); + it('surfaces reasoning + the assistant report via the adapter', () => { const events = opencodeParser.parseTranscript(SESSION).events; expect( @@ -131,6 +157,7 @@ describe('opencodeParser', () => { result: 'file1\nfile2', error: undefined, ts: 1782295624290, // epoch ms preserved through toISO -> parseTs + resultTs: 1782295624290, }, { tool: { kind: 'other', toolName: 'write' }, @@ -140,6 +167,7 @@ describe('opencodeParser', () => { result: 'written', error: undefined, ts: 1782295624300, + resultTs: 1782295624300, }, ]); }); diff --git a/packages/core/src/agents/opencode/parser.ts b/packages/core/src/agents/opencode/parser.ts index 4fd3e809..29e71eb9 100644 --- a/packages/core/src/agents/opencode/parser.ts +++ b/packages/core/src/agents/opencode/parser.ts @@ -105,13 +105,15 @@ const OPENCODE_TOOLS: AgentToolMap = { /** * opencode tool args → normalized fields. `bash` carries the command in - * `command`; file tools the path in `filePath` (or `path`); `webfetch` the URL - * in `url`. The shared extractor reads whichever keys this map names. + * `command` (and an optional `workdir`); file tools the path in `filePath` (or + * `path`); `webfetch` the URL in `url`. The shared extractor reads whichever + * keys this map names. */ const OPENCODE_ARG_FIELDS: ArgFieldMap = { path: ['filePath', 'file_path', 'path'], command: ['command'], url: ['url'], + cwd: ['workdir'], }; /** @@ -221,6 +223,7 @@ function partToEvents( if (normalized.path) tool.path = normalized.path; if (normalized.command) tool.command = normalized.command; if (normalized.url) tool.url = normalized.url; + if (normalized.cwd) tool.cwd = normalized.cwd; const loadedSkills = loadedSkillsFromOpencodeCall(tool); if (loadedSkills.length > 0) tool.loadedSkills = loadedSkills; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 77af9646..9c4a5825 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -220,6 +220,8 @@ export interface ToolCallRecord { path?: string; command?: string; url?: string; + /** Absolute directory the call ran in, when the agent records one per call. */ + cwd?: string; /** Canonical tool category, set by CLI agent parsers; unset for ai-sdk tools which have no normalization layer. */ name?: ToolName; /** Skill names loaded by this call, when the harness can identify any. */ @@ -227,6 +229,8 @@ export interface ToolCallRecord { result?: unknown; error?: string; ts: number; + /** Epoch ms the call's result arrived, when the agent records it per call. */ + resultTs?: number; } export interface CommandResult { diff --git a/packages/core/src/parsers/adapt.test.ts b/packages/core/src/parsers/adapt.test.ts new file mode 100644 index 00000000..1a339134 --- /dev/null +++ b/packages/core/src/parsers/adapt.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import type { TranscriptEvent } from '../transcript/types.js'; +import { adaptTranscript } from './adapt.js'; + +describe('adaptTranscript', () => { + const events: TranscriptEvent[] = [ + { + type: 'tool_call', + timestamp: '2026-06-18T10:00:00.000Z', + tool: { + name: 'shell', + originalName: 'Bash', + id: 't1', + command: 'supabase start', + cwd: '/work/client-a', + }, + }, + { + type: 'tool_result', + timestamp: '2026-06-18T10:00:05.000Z', + tool: { name: 'shell', originalName: 'Bash', id: 't1', result: 'ok' }, + }, + { + type: 'tool_call', + tool: { name: 'shell', originalName: 'Bash', id: 't2', command: 'ls' }, + }, + ]; + + it("copies a call's cwd and result time onto its ToolCallRecord", () => { + const [first, second] = adaptTranscript(events).toolCalls; + expect(first.cwd).toBe('/work/client-a'); + expect(first.resultTs).toBe(Date.parse('2026-06-18T10:00:05.000Z')); + expect(second.cwd).toBeUndefined(); + expect(second.resultTs).toBeUndefined(); + }); +}); diff --git a/packages/core/src/parsers/adapt.ts b/packages/core/src/parsers/adapt.ts index 525963b3..605fb358 100644 --- a/packages/core/src/parsers/adapt.ts +++ b/packages/core/src/parsers/adapt.ts @@ -90,10 +90,12 @@ export function adaptTranscript(events: TranscriptEvent[]): AdaptedTranscript { path: event.tool.path, command: event.tool.command, url: event.tool.url, + cwd: event.tool.cwd, loadedSkills: event.tool.loadedSkills, result: resolved?.error === undefined ? resolved?.result : undefined, error: resolved?.error, ts: parseTs(event.timestamp), + ...(resolved?.ts ? { resultTs: resolved.ts } : {}), }); } } diff --git a/packages/core/src/parsers/shared/extract.ts b/packages/core/src/parsers/shared/extract.ts index ce727e8d..bddb175a 100644 --- a/packages/core/src/parsers/shared/extract.ts +++ b/packages/core/src/parsers/shared/extract.ts @@ -17,6 +17,7 @@ export interface ArgFieldMap { path?: readonly string[]; command?: readonly string[]; url?: readonly string[]; + cwd?: readonly string[]; } /** Normalized values extracted from a tool call's args. */ @@ -24,6 +25,7 @@ export interface ExtractedArgs { path?: string; command?: string; url?: string; + cwd?: string; } // Skill reads can appear as bare paths or quoted shell args in tool-call logs. @@ -63,6 +65,7 @@ export function extractArgs( path: firstField(args, map.path), command: firstField(args, map.command), url: firstField(args, map.url), + cwd: firstField(args, map.cwd), }; } diff --git a/packages/core/src/transcript/types.ts b/packages/core/src/transcript/types.ts index c0582674..88096b8c 100644 --- a/packages/core/src/transcript/types.ts +++ b/packages/core/src/transcript/types.ts @@ -92,6 +92,8 @@ export interface TranscriptEvent { path?: string; command?: string; url?: string; + /** Absolute directory the call ran in, when the agent records one per call. */ + cwd?: string; /** Skill names loaded by this call, when the parser can identify any. */ loadedSkills?: string[]; /** Tool result payload (for `tool_result`). */ From 003e8a308e7b2fecebd38584bc7f27644df5a92c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 6 Oct 2026 10:47:11 +0100 Subject: [PATCH 4/5] fix(core): pair codex rollout commands per call instead of all-or-nothing --- packages/core/src/agents/codex/parser.test.ts | 72 +++++++++++++++++-- packages/core/src/agents/codex/parser.ts | 69 ++++++++++-------- 2 files changed, 107 insertions(+), 34 deletions(-) diff --git a/packages/core/src/agents/codex/parser.test.ts b/packages/core/src/agents/codex/parser.test.ts index c52953c5..9843a0d6 100644 --- a/packages/core/src/agents/codex/parser.test.ts +++ b/packages/core/src/agents/codex/parser.test.ts @@ -571,12 +571,13 @@ describe('enrichFromRollout tool pairing', () => { ).toEqual(['t1', 't2']); }); - it('skips all pairing when one command has extra arguments', () => { + 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'), @@ -585,9 +586,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', () => { @@ -616,7 +620,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( [ @@ -625,10 +629,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'), @@ -646,7 +706,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], diff --git a/packages/core/src/agents/codex/parser.ts b/packages/core/src/agents/codex/parser.ts index 0d7efcc5..f91a0880 100644 --- a/packages/core/src/agents/codex/parser.ts +++ b/packages/core/src/agents/codex/parser.ts @@ -265,12 +265,13 @@ const ROLLOUT_TOOL_ITEMS = new Set([ * Fills in event times, model requests, and per-request usage from the session * rollout, which the `--json` stream lacks. The two streams list tool items and * assistant messages in the same order but under different ids, so they're - * paired by position. Tool pairs must also agree on the command or MCP tool, - * and any count or content mismatch leaves those messages or tool calls - * untouched. A request no event came from, like a compaction call, gets an - * empty message. If the messages or tool calls didn't pair, only a request with - * no `response_item` records gets one, since the others may just be missing - * their requestId. + * paired by order. Messages pair only when their counts match. Each tool call + * pairs with the next rollout item that agrees on its command or MCP tool, so + * a call with no such item stays untouched without affecting the others. A + * request no event came from, like a compaction call, gets an empty message. + * If the messages or any tool call didn't pair, only a request with no + * `response_item` records gets one, since the others may just be missing their + * requestId. * * rollout: reasoning → message → function_call(c1) → token_usage_record(r1) * → function_call_output(c1) @@ -348,27 +349,36 @@ export function enrichFromRollout( }); } const calls = events.filter((e) => e.type === 'tool_call'); - const toolsPaired = - calls.length === toolItems.length && - calls.every((event, i) => sameCall(event, toolItems[i])); - if (toolsPaired) { - const callIdByItem = new Map(); - calls.forEach((event, i) => { - const itemId = String(toolItems[i].id); - const start = callStarts.get(itemId); - if (event.tool?.id) callIdByItem.set(event.tool.id, itemId); - const cwd = directoryPath(toolItems[i].cwd); - if (cwd && event.tool) event.tool.cwd = cwd; - if (!start) return; - event.timestamp = start.at ?? event.timestamp; - tag(event, start.request); - }); - for (const event of events) { - if (event.type !== 'tool_result' || !event.tool?.id) continue; - const callId = callIdByItem.get(event.tool.id); - const end = callId ? callEnds.get(callId) : undefined; - if (end) event.timestamp = end; - } + // The rollout keeps items the stream omits, like a killed long-running + // command, so each call takes the next matching item and skips the rest. + let cursor = 0; + const items = calls.map((event) => { + const found = toolItems.findIndex( + (item, i) => i >= cursor && sameCall(event, item) + ); + if (found === -1) return undefined; + cursor = found + 1; + return toolItems[found]; + }); + const toolsPaired = items.every((item) => item); + const callIdByItem = new Map(); + calls.forEach((event, i) => { + const item = items[i]; + if (!item) return; + const itemId = String(item.id); + const start = callStarts.get(itemId); + if (event.tool?.id) callIdByItem.set(event.tool.id, itemId); + const cwd = directoryPath(item.cwd); + if (cwd && event.tool) event.tool.cwd = cwd; + if (!start) return; + event.timestamp = start.at ?? event.timestamp; + tag(event, start.request); + }); + for (const event of events) { + if (event.type !== 'tool_result' || !event.tool?.id) continue; + const callId = callIdByItem.get(event.tool.id); + const end = callId ? callEnds.get(callId) : undefined; + if (end) event.timestamp = end; } const tagged = new Set(events.map((e) => e.requestId)); for (const { at, request } of requests) { @@ -412,7 +422,10 @@ function sameCall(event: TranscriptEvent, item: Record) { if (item.type === 'McpToolCall') { return event.tool?.call?.toolName === item.tool; } - return true; + if (item.type === 'FileChange') { + return event.tool?.call?.toolName === 'file_change'; + } + return event.tool?.call?.toolName === 'web_search'; } /** A rollout `cwd` is a plain path or, for some Codex versions, a `file://` URL. */ From 79c48f7a1120ea9621cbe3e3b6a69c8a203baa15 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 7 Oct 2026 11:47:45 +0100 Subject: [PATCH 5/5] fix(core): take codex command completion time from item_completed --- packages/core/src/agents/codex/parser.test.ts | 35 ++++++++++++++++++- packages/core/src/agents/codex/parser.ts | 9 ++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agents/codex/parser.test.ts b/packages/core/src/agents/codex/parser.test.ts index 9843a0d6..f126c8a8 100644 --- a/packages/core/src/agents/codex/parser.test.ts +++ b/packages/core/src/agents/codex/parser.test.ts @@ -423,7 +423,7 @@ 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], ]); }); @@ -571,6 +571,39 @@ describe('enrichFromRollout tool pairing', () => { ).toEqual(['t1', 't2']); }); + 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( diff --git a/packages/core/src/agents/codex/parser.ts b/packages/core/src/agents/codex/parser.ts index f91a0880..9d15bf16 100644 --- a/packages/core/src/agents/codex/parser.ts +++ b/packages/core/src/agents/codex/parser.ts @@ -277,6 +277,9 @@ const ROLLOUT_TOOL_ITEMS = new Set([ * → function_call_output(c1) * events: message → tool_call → tool_result, each tagged requestId r1 * + * A command finishes when its item completes, not when its output arrives: + * a yielded long-running command outputs early, then `write_stdin` polls it. + * * Returns when the rollout recorded the first user message, in epoch ms. */ export function enrichFromRollout( @@ -293,6 +296,7 @@ export function enrichFromRollout( const callEnds = new Map(); const messages: { at?: string; request: Request }[] = []; const toolItems: Record[] = []; + const itemEnds = new Map(); const requests: { at?: string; request: Request }[] = []; let open: Request | undefined; let promptAt: number | undefined; @@ -330,6 +334,7 @@ export function enrichFromRollout( const { type, id } = payload.item; if (typeof id === 'string' && ROLLOUT_TOOL_ITEMS.has(String(type))) { toolItems.push(payload.item); + if (at && type === 'CommandExecution') itemEnds.set(id, at); } } } @@ -377,7 +382,9 @@ export function enrichFromRollout( for (const event of events) { if (event.type !== 'tool_result' || !event.tool?.id) continue; const callId = callIdByItem.get(event.tool.id); - const end = callId ? callEnds.get(callId) : undefined; + const end = + (callId ? itemEnds.get(callId) : undefined) ?? + (callId ? callEnds.get(callId) : undefined); if (end) event.timestamp = end; } const tagged = new Set(events.map((e) => e.requestId));