fix(codex): record command, file-change and submitted-turn facts - #110
Conversation
An audit question asks which commands ran, what they exited with, when they ran, which files changed, and what the person actually typed. The Codex adapter dropped every `item_completed` event, so none of those facts reached a span, and it labelled harness-injected context as a human turn. - Emit one CHAIN span per `CommandExecution` item (command, cwd, exit code, process id, output, and the item's own start and end times) and one per changed path in a `FileChange` item. Each item joins the one tool call whose window contains its whole run; an item that outlives every call or falls inside two stays under the session root and says so. Item shapes the adapter cannot represent are counted on the root, never guessed. - Mark the inner spans `traces.tool_call.level=inner` so tool-call counts, loop detection and conversation text keep reading the model-issued level. - Accept a script receipt whose "Wall time" line has no colon, which left script outcomes UNSET. - Treat Codex's context blocks (`<environment_context>`, `<user_instructions>`, `<skills_instructions>`, the `<external_*>` wrappers, the injected warnings and the rest of `CONTEXTUAL_USER_FRAGMENT_MATCHERS`) as injected, and take the human turn from the record Codex writes for submitted input: the legacy `user_message` event or the current `item_completed`/`UserMessage` item, paired with its response-item copy so one turn stays one span. - Drop the parent session from `childSessionIds`: a child that messages its parent named it as a `send_message` target. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
drewstone
left a comment
There was a problem hiding this comment.
Adversarial review — T4/T5/T9
Ran the full CI set on the PR head in a clean worktree with Node 22.23.2: pnpm install --frozen-lockfile, check:source, typecheck, test (58 files, 823 tests), build, check:package — all green. gh pr checks is green on both CI Node versions (22.13.0, 24.18.0).
The change does what it claims, and I could not break the parsing logic on real input. One blocking issue: the item retention makes adapter heap grow with total raw command output, which the previous code did not.
Verification I ran
Tests discriminate. With src/ from origin/main and the branch's tests, 13 of 15 tests in tests/codex-command-facts.test.ts fail, plus the 2 new no-colon rows (× 2 variants) in tests/codex-tool-status.test.ts. The two that pass on main are the deliberate "keeps text heuristics" guard and the prefix-pairing test (see note 3).
The before/after table is exact. Running the same fixture with origin/main's adapter gives {total: 12, outerTools: 2, inner: 0, userPrompts: 6, humanTurns: 5}; the branch gives {18, 2, 6, 6, 2}.
Cross-checked against locally available Codex rollouts (counts only; no session content was read into any file, fixture or comment):
- 25 sessions, 12–30 MB each: outer TOOL span count identical between
origin/mainand the branch in all 25. Inner spans added: 140–857 per session.item_joiniscallfor 70–100%, the restunmatched;ambiguousnever occurred. - 120 sessions, human turns: 0 sessions lost a human turn, 0 gained one. Exactly 1 session changed its last human turn — on main it was a
<…>context block, on the branch a 7-character typed turn. That is F1 fixed on real input. - 59 sessions that contain
UserMessageitem records:user.promptspan count is 137 on both branches, so the de-duplication introduces no extra or missing turn spans. - Item ids: 55,785
item_completedids across the corpus are all UUID-shaped, and 0 duplicate ids inside a session, so the session-wide dedupe key is safe today (note 7). - Multi-block user messages: 210 of 970 have more than one text block; 0 mix a context block with typed text, so
blocks.some(isCodexContextBlock)does not currently swallow a real turn.
Non-regression reading. Every consumer that could have double-counted the new spans gates on TOOL kind or on tool.name: evidence.ts:150,:172, improvement.ts:313, failure-followup.ts:103, live.ts:242. span.type=tool.execution is already the "container, not a call" marker (otlp-input.ts:414). Redaction and metadata-only upload key on TOOL_IO_VALUE_KEYS for every span regardless of kind (redact.ts:96, upload.ts:197), so command text, cwd, paths and output in input.value/output.value are covered. closeSpanAt is reused, so a skewed item time is clamped and flagged rather than emitting end_time < start_time. The session-relationship change only deletes the session id and its parent id from the three child sets.
Privacy. The diff contains no session-derived content. Fixtures are synthetic (example.test URLs, /workspace/demo, gpt-fixture), and the only host-specific token is gh-drew, which src/adapters/codex.ts already contains in verificationCommand.
Blocking: adapter heap now grows with total raw command output
src/adapters/codex.ts:1131 retains the whole parsed item — stdout, stderr, aggregated_output, formatted_output — in completedItems until the spans are built after the line loop at :1284. Every other capture path caps the value at 16 KiB the moment the record is read (adapters/tool-io.ts), so retention used to be bounded by span count.
Reproduction on a synthetic rollout (400 CommandExecution items, one exec call, 256 KB aggregated_output each, ~300 MB file), node --max-old-space-size=256:
| result | |
|---|---|
origin/main |
parses, 2 spans, peak RSS 233 MB |
| this branch | FATAL ERROR: Ineffective mark-compacts near heap limit — JavaScript heap out of memory |
On a real 684 MB rollout the same difference shows without an OOM: heapUsed at the end of parse rises from 185 MB to 1045 MB. Typical 24–30 MB sessions only pay +20–30 MB, so this bites exactly the large operator sessions the brief targets. The repo already treats this as a contract for the Claude adapter (tests/adapters.test.ts:200: 100 MB file inside a 64 MB heap); Codex has no equivalent test, which is why CI stays green.
Fix without changing any output: build the span when the item is read — toolIoAttributes truncates to 16 KiB there — and keep only {span, startMs, endMs} in the list; after the loop, when toolWindows is complete, set parent_span_id and the traces.codex.item_join / item_time_source attributes. Retention then matches the existing tool spans. Worth adding a Codex bounded-heap test mirroring the Claude one so the contract is enforced rather than assumed.
Non-blocking
traces.codex.skipped_item_countsreads as data loss where there is none. The counted labels are dominated by shapes the response-item stream already covers:Reasoning,AgentMessage,Extension,McpToolCall,CollabAgentToolCallandContextCompaction, running into the hundreds per session. The PR body calls these "item shapes that produced no span" and the commit calls them "shapes the adapter cannot represent"; a downstream miner will read that as a thousand lost facts. Separate "represented elsewhere" from "unrepresented or malformed", or list the deliberately-covered types in the schema table.- Two spellings for one fact.
codex-exec.ts:304writestraces.codex.exec_exit_codeon the TOOL span; the new inner spans writeprocess.exit_code(codex.ts:501). The T6 facts table will have to read both. Emittingprocess.exit_codeadditively incodex-exec.tswould close it. tests/codex-command-facts.test.ts:238passes againstorigin/main— main drops theuser_messageevent, so one span either way. It still guards the branch'sUSER_MESSAGE_BEGINkey, but addingexpect(turn.attributes['traces.codex.user_message_event']).toBe(true)makes it fail without T5.- The record is authoritative in one direction only. When the response-item copy arrives first, the actor stays whatever the text heuristics decided; pairing only sets
user_message_event. In 59 local sessions one span carriesuser_message_event=trueandtangle.actor=injected(a 3.6 KB wrapped brief).origin/mainlabels it the same way, so this is not a regression — but if Codex's own record is the signal T5 rests on, the disagreement should either resolve or be recorded. - Attribute names differ from brief T4 (
process.command,file.path). Keeping command, cwd and path insideinput.valueis the better choice — aprocess.commandattribute would escape bothredact.tsand the metadata-only strip — but the schema table in the PR body should say so, because the facts-table follow-up must parseinput.valueJSON. - Unmatched share is higher than the brief estimated. Up to 243 of 857 inner spans (28%) in one real session land under the root as
unmatched, against the brief's 6–14%. A "which commands did this call run" question will miss those until theprocess_idlink lands. - Dedupe key is session-wide.
CommandExecution:<item.id>is safe on today's UUID ids (0 in-file duplicates over 55,785 ids), but a turn-scoped id scheme would silently drop commands as:duplicate. Scoping the key by turn id costs nothing.
Build each command and file-change span when its item_completed record is
read, so `toolIoAttributes` caps the retained command text and output at
16 KiB there, and keep only {span, startMs, endMs} until the tool-call
windows are complete. Retaining the parsed items instead made adapter heap
grow with total raw command output: a 100 MB rollout of command items dies
under a 64 MB heap, which `tests/adapters.test.ts` now enforces for Codex
the way it already does for Claude. Output is unchanged; only the join and
the parent span depend on records written after the item.
Also from review:
- Split `traces.codex.skipped_item_counts` into
`traces.codex.unmodeled_item_counts` (item types the adapter models no
span for, whose work the response_item stream records) and
`traces.codex.dropped_item_counts` (a modeled item that produced no
span: malformed, duplicate, or no text). Only the second reads as lost
facts, so they no longer share one count.
- Scope the item dedupe key by task index, so a turn-scoped id scheme
cannot make a later turn's command a `:duplicate`.
- Emit `process.exit_code` additively on codex-exec tool spans, so one
fact has one spelling across both Codex adapters.
- Assert `traces.codex.user_message_event` in the prefix-pairing test, so
it fails without the submitted-turn record.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Repair for the review — bbf1ba5Blocking finding: fixed as described. Each command and file-change span is now built at the moment its The contract is enforced rather than assumed:
I verified the new test fails against the previous revision's Non-blocking 1 — skipped counts read as data loss. Split into two censuses, because one count mixed shapes recorded elsewhere with real loss:
Non-blocking 2 — two spellings for one fact. Non-blocking 3 — test does not discriminate. Added Non-blocking 7 — session-wide dedupe key. Key is now Non-blocking 4, 5, 6 — recorded, not changed. The PR body now states that command, Full CI set green locally on Node 22.23.2: 🤖 Generated with Claude Code |
drewstone
left a comment
There was a problem hiding this comment.
Adversarial review of the track adapter PR (T4, T5, T9), read against origin/main (7633ebc) in a clean worktree at bbf1ba5.
Verdict: no blocking finding. Approve after considering items 1 and 2, both one-line.
What I verified myself
pnpm install --frozen-lockfile,pnpm check:source,pnpm typecheck,pnpm test(824 tests, 58 files),pnpm build,pnpm check:package— all pass on Node 22.23.2.gh-drew pr checksshows CI green on Node 22.13.0 and 24.18.0.mergeable: MERGEABLE.- Every new assertion fails against
origin/main'ssrc/. I copiedtests/codex-command-facts.test.ts+tests/codex-facts-fixture.tsonto a worktree oforigin/main: 14 of 15 fail there, and the one that passes (keeps text heuristics for rollouts that never recorded user_message events) is a deliberate no-regression guard. The three addedcodex-tool-statusrows: the twoWall timerows without the colon fail on main, the colon row passes (also a guard). So no test is passing for reasons unrelated to the change. - Privacy: clean. Both fixtures are synthetic and self-declared as such. The only URL is
https://example.test/...; no absolute local path, session id, host, or rollout id from this machine appears anywhere in the diff, commits, or PR body.gh-drewappears in the fixture, but it is already insrc/adapters/codex.ts'sverificationCommandregex on main, so nothing new is revealed. - No cross-adapter or format regression that I could find. Inner spans are CHAIN with no
tool.name, soevidence.tstool metrics (TOOL kind),adoption.ts:130,improvement.ts:315,hodoscope.ts(TOOL /message.assistant/ LLM only), andreactions.ts(needscontent, which inner spans do not carry) all skip them.live.tsgets an explicit guard.codetracebench-trajectory.tskeys on LLM. No CLI exit-code path is touched. The redaction argument in the PR body checks out:upload.tsstripSpanContentdeletesTOOL_IO_VALUE_KEYS, andredactSpansscrubs every attribute value, so command text and paths ininput.valueare covered by both passes, which a top-levelprocess.commandwould not have been. - The
traces.codex.turn_idstamp, hex id normalization, and parenting all apply to the new spans (the tests assertparent_span_id === scriptSpan.span_idpost-normalization).
Findings, ranked
1. unknown item shapes are filed in the census bucket the PR documents as "not lost facts". codex-format.ts codexCompletedItem computes type = nonEmptyString(item?.type) ?? 'unknown', and any item_completed whose item is missing, non-object, or typeless returns {reason:'unmodeled', label:'unknown'}. Repro (synthetic rollout, one line each): a payload with no item and a payload with item: 'not-an-object' produce traces.codex.unmodeled_item_counts = {"AgentMessage":1,"Reasoning":1,"unknown":2} and no dropped_item_counts. The PR body and the doc comment both promise that unmodeled means "the rollout records the same work as a response_item, so this is not lost facts" — which is exactly what cannot be known for a shape with no type. A miner reading the census then under-counts loss. Fix: return {reason:'dropped', label:'unknown:malformed'} when item is absent or not an object, or when it carries no type, and keep unmodeled for a well-formed item whose type string this adapter models no span for.
2. codexActor's .some(isCodexContextBlock) can turn a real human turn into an injected one, which is the failure T5 exists to fix, in the opposite direction. The rule labels the whole message injected when any one content block is a context block, on the uncited claim "Codex treats the whole message as context when any one block is" — the neighbouring claims all cite codex-rs files, this one does not. Repro: a legacy rollout (no user_message events) with one user message whose content is [{input_text: <environment_context>…}, {input_text: 'ya?'}] yields a single user.prompt span labelled injected; origin/main labels it human. The no_user_message_event fallback does not rescue it, because that path only ever moves human to injected. Modern rollouts are protected by the event pairing, so .every() (over non-empty blocks) costs nothing there and removes the regression for legacy ones. This is why I did not mark it blocking: I could not show that Codex ever writes a mixed-block user message — the ## My request for Codex: handling suggests legacy Codex concatenated context into one string, which the open/close matching already handles correctly. Worth either the .every() change or a citation in the comment.
3. Duplicate span_id if a Codex item id ever repeats across tasks. The dedupe key is deliberately scoped by task (${type}:${taskIndex}:${itemId}, with the comment "a turn-scoped id scheme must not make a later turn's command a :duplicate"), but the span id is not (command:${itemId}), and normalizeCodexIds hashes it to hex. In exactly the scenario the dedupe comment anticipates, both commands are emitted with the same span_id. Repro: two tasks, each with a CommandExecution id item-1; the parse returns 5 spans with 4 distinct span ids, and both command spans hash to the same id — so a trace://…/span/<id> citation, the evidence gate, and hodoscope's source map all collapse the two. Unreachable with today's UUID item ids, which is why it is not blocking; the fix is to scope the span id the same way the dedupe key is (command:${taskIndex}:${itemId}, file-change:${taskIndex}:${itemId}:${index}).
4. The turn pairing assumes both records of one turn land on the same side of task_started, and nothing pins that. taskIndex scopes the pairing purely to keep repeated text in different turns apart (which it does — I checked ya? twice in two tasks stays two human turns). But if a rollout ever writes user_message before task_started and the response_item copy after it, the keys never meet: repro gives two user.prompt spans, both human, both 'Please fix the parser.' — the exact double-count the PR fixes. The fixture exercises both record orders but always inside one task. Either add a fixture row that straddles task_started, or pair on key plus a time window instead of the task index, or cite the ordering guarantee.
5. The no_user_message_event fallback is only checked in the direction that helps. Any user-role response_item in a task that saw at least one user_message event and did not pair is forced to injected. Repro: a task with one paired turn plus a second user-role item ('Also bump the version.') with no event of its own — the second is labelled injected with traces.codex.actor_evidence=no_user_message_event, and disappears from the human-turn list. Whether that is right depends on a fact the PR does not state: does a message queued while a task is running get its own user_message event? If not, this drops real human turns from the metric T5 is built to fix. Worth answering in the PR body, since the whole fallback rests on it.
6. The 28% unmatched rate probably has a mechanical cause worth one more look before it is written off. The PR reports 28% of inner spans unmatched against the brief's measured 6-14% "inside exactly one call window" — a 2-4x gap, on the same join definition, which suggests the window map rather than the poll-later hypothesis. One concrete candidate: toolWindows.set(...) runs only in the tool-output branch, so a call with no output record (an aborted turn, an interrupted session, a write_stdin handle left open) contributes no window at all, and every command inside it is unmatched. Repro: a custom_tool_call with no custom_tool_call_output and a command squarely inside its span — the command lands under the root as unmatched. Counting how many unmatched items fall inside a call span that never closed would separate the two explanations cheaply.
7. All-or-nothing item parsing discards known-good siblings, and an empty patch is reported as malformed. fileChangeEntries returns undefined for the whole item if one entry is unrecognized, and also for an empty changes map. Repro: a FileChange with {'/w/a.ts': {type:'update'}, '/w/b.ts': null} and a second with changes: {} produce zero file.change spans and {"FileChange:malformed": 2} — the valid a.ts path is lost to a bad sibling, and an empty patch is labelled malformed. commandValue behaves the same way for an argv array with one non-string element. Emitting the entries that parsed and counting the entries that did not would match the brief's "count unknown shapes instead of guessing" more closely than dropping the item.
8. Three of the ten new CODEX_CONTEXT_BLOCKS entries already exist in INJECT_MARKERS in the same file — # AGENTS.md instructions, <codex_internal_context, <subagent_notification>. Behaviour is unchanged (the looser substring check still runs as a fallback), but the same marker is now matched by two rules with different semantics — bracketed open/close versus substring-anywhere — in one function. Worth collapsing so a future edit to one list does not silently disagree with the other.
9. traces live action counts change for Codex sessions, and the new facts do not reach the classifier. classifyLiveActions maps every span, so each command and file-change span becomes an other action: actionCounts.other rises on Codex sessions and the action list grows. Findings are safe (the repeated-failure rule needs action.toolName, and context.tools filters isTool), so this is cosmetic — but isVerification/isChange/isRead all early-return on !isTool, so a pnpm test recorded as an inner command is still not a verify action. The before/after table in the PR body covers span counts but not the live action mix; one line there would close it.
10. Minor. isCodexContextBlock is exported but used only inside actor.ts. FileChange items carry stdout/stderr that the file-change spans do not record as output.value, unlike command spans. The brief named process.command and file.path as attributes and this PR puts both inside input.value instead — the stated reason (metadata-only upload strips TOOL_IO_VALUE_KEYS, a top-level attribute would escape it) is correct and I verified it, and the PR body flags the consequence for downstream miners, so this is recorded, not disputed.
On the specific things I was asked to break
- Each test fails on main: yes, checked directly, see above.
- Regression in other adapters, analyzers, report formats, evidence JSONL, exit codes: none found; the equality assertions between all-spans and outer-only in the counts test are real (I confirmed the pipelines and evidence paths they exercise are the ones the rest of the CLI uses), and the one intended difference (a failed inner command adds one execution error) is asserted rather than hidden. Note that the
Wall timeregex fix separately flips previously-UNSET Codex script spans to OK or ERROR, which moveserroredToolCallCounton existing sessions — correct, but it is a metric shift on top of the span-count shift, and the evidence-schema section only mentions the latter. - Real-session content: none.
- Unknown shapes counted, not guessed: yes, with the bucketing caveat in item 1.
- Anything duplicated the repo already has: item 8 only; the new parsing has no counterpart in
codex-exec.ts(different stream, lower-case item types), and the additiveprocess.exit_codethere is the right way to converge the two spellings.
What this fixes
Failure classes from the audit brief:
item_completedevent, so per-command records, exit codes, process ids, command times and patch paths never reached a span.commandsandfile changesnow exist as spans, so a question can enumerate and time them instead of regexing raw JSON.Wall time:; real receipts printWall time 1.2 seconds, which left script outcomes UNSET. Also,childSessionIdslisted the session's own parent, because a child that messages its parent names it as asend_messagetarget.<environment_context>and the other Codex context blocks were labelled human turns, so "the last human turn" returned harness text. The human turn now comes from the record Codex writes for submitted input.What changed
Command and file-change spans (T4). Command,
cwdand path stay inside theinput.valueJSON rather than becomingprocess.command/file.pathattributes as brief T4 names them:redact.ts:96and the metadata-only strip atupload.ts:197key onTOOL_IO_VALUE_KEYS, so a top-levelprocess.commandattribute would escape both. The facts-table follow-up parsesinput.valueJSON.One CHAIN span per
CommandExecutionitem withinput.value(command, cwd),output.value,process.exit_code,traces.codex.process_id,traces.codex.command_source, and the item's ownstarted_at_ms/completed_at_msas span times. One span per changed path in aFileChangeitem, withfile change kindand the path ininput.value. Codex item ids never equal call ids, so an item joins the one tool call whose window (call record → output record) contains its whole run;traces.codex.item_joinrecordscall,unmatched(ran past every window — the poll-later case) orambiguous(inside two windows), and an unjoined item parents to the session root rather than guessing. Parsing is defensive: a missing or mistyped required field is counted by shape intraces.codex.skipped_item_countson the root span, never guessed.Human turns (T5).
codexActornow takes the message's separate text blocks and rejects any block that is a Codex context fragment; the marker list mirrorsCONTEXTUAL_USER_FRAGMENT_MATCHERSin openai/codexcodex-rs/core/src/context/contextual_user_message.rs, matched the waycodex-rs/context-fragments/src/fragment.rsmatches (trimmed, open and close markers, ASCII-case-insensitive). The turn itself comes from Codex's own record of submitted input — the legacyuser_messageevent, or the currentitem_completed/UserMessageitem, since the rollout persists one or the other by history mode (codex-rs/rollout/src/policy.rs). The response-item copy of the same turn pairs with that record (keyed on the typed text after the legacy## My request for Codex:marker) so a turn logged twice stays one span. Where a task has such records, a user-role message without one is labelled injected withtraces.codex.actor_evidence=no_user_message_event, which catches wrappers the marker list does not know.Parent as child (T9).
describeSessionRelationshipremoves the session id and its parent id from the child sets.Evidence schema for downstream miners
New attributes; nothing existing was renamed or removed.
traces.tool_call.level=innertool.name, so tool-call counters, loop detection and conversation text stay at the model-issued level — select this value to read the inner facts.traces.codex.item_type,traces.codex.item_id,traces.codex.item_statustraces.codex.item_joincall,unmatchedorambiguous.traces.codex.item_time_sourcecompleted_onlyorrecord.process.exit_code,traces.codex.process_id,traces.codex.command_sourcewrite_stdinoutput, and Codex's command source.traces.codex.file_change_kindadd,deleteorupdate.traces.codex.unmodeled_item_countsitem_completedtypes this adapter builds no inner span for (Reasoning,AgentMessage,McpToolCall, and any type added later). The rollout records the same work as aresponse_item, and that record is what becomes a span, so this is not lost facts.traces.codex.dropped_item_counts<type>:malformed,<type>:duplicate,UserMessage:no_text. This is the count that reads as lost facts.traces.codex.user_message_eventtraces.codex.actor_evidenceno_user_message_eventwhen the actor was set from the absence of that record.Span counts rise on Codex sessions: one span per recorded command and per changed path. Anything counting spans as tool calls should filter
traces.tool_call.level !== 'inner', which is whatevidence.tsand the pipelines already do through the TOOL kind.Review repairs
Blocking, fixed. The first revision retained each parsed
item_completed—stdout,stderr,aggregated_output— until the spans were built after the line loop, so adapter heap grew with total raw command output rather than with span count. Each command and file-change span is now built when its item is read, wheretoolIoAttributesalready caps the value at 16 KiB; only{span, startMs, endMs}is retained until the tool-call windows are complete, and the join and the parent are set afterwards. Output is unchanged.tests/adapters.test.tsnow enforces this for Codex the way it already did for Claude: a 100 MB rollout of 101CommandExecutionitems (1 MiBaggregated_outputeach) parsed in a child at--max-old-space-size=64. Against the previous revision's adapter that child dies withFATAL ERROR: Ineffective mark-compacts near heap limit; it now parses, joins all 101 commands to the call, and every command span'sinput.valueandoutput.valuestay within 16 KiB.Also from review.
traces.codex.skipped_item_countsis split into the two attributes in the table above, because one count mixed "recorded elsewhere in this rollout" with real loss. The item dedupe key is scoped by task index, so a turn-scoped id scheme cannot drop a later turn's command as:duplicate.codex-exec.tsnow emitsprocess.exit_codealongsidetraces.codex.exec_exit_code, so the T6 facts table reads one spelling. The prefix-pairing test assertstraces.codex.user_message_event, so it fails without T5.Recorded, not changed here.
user_message_event.origin/mainlabels these spans the same way, so this is not a regression; resolving the disagreement needs a decision about which signal wins.item_join=unmatchedreaches 28% of inner spans on a large session, above the 6-14% the brief estimated. Those commands sit under the session root until theprocess_idlink lands, so "which commands did this call run" misses them.Tests
tests/codex-facts-fixture.tsholds one synthetic operator rollout — injected AGENTS.md and environment blocks, a substantive request logged twice, a code-mode script around three commands (one exiting 1), a command that outlives its call, anapply_patchinsideexectouching two files, two item shapes the adapter cannot represent, a short last typed turn (ya?), and one more injected block after it.tests/codex-command-facts.test.tsasserts against it: three inner spans with their own times, exit codes and process ids under the script call; the outliving command under the root asunmatched; a command inside two overlapping calls asambiguous; two file spans under the patch call; the skipped-shape counts; the script receipt now OK; each human turn once with its text and timestamp in both record orders; an injected block never a human turn; a turn reported as anitem_completedUserMessage; a turn whose two records differ by a context prefix; and a forked child that does not list its parent as a child.tests/codex-tool-status.test.tsgains the receipt rows with and without theWall timecolon.Before and after on the same fixture, measured by parsing
tests/codex-facts-fixture.tswith the adapter atorigin/main(7633ebc) and with this branch:<environment_context>blockya?The counts test also asserts that evidence tool metrics, signals,
toolUseand stuck-loop findings are identical whether or not the inner spans are present; the one intended difference is that a failed inner command adds one execution-error event.pnpm check:source,pnpm typecheck,pnpm test(824 tests, 58 files),pnpm buildandpnpm check:packagepass locally on Node 22.23.2.Follow-up that needs agent-eval
Not in this PR and not blocked by it:
searchTracestill truncates at 500 hits. The facts land in spans; reaching all of them needs the agent-eval store change.contextInputTokensstill adds cached tokens to input for Codex (brief A5).🤖 Generated with Claude Code