diff --git a/docs/formats/codex.md b/docs/formats/codex.md index 5516f18..4877a68 100644 --- a/docs/formats/codex.md +++ b/docs/formats/codex.md @@ -83,7 +83,15 @@ the form `["bash"|"sh"|"zsh", "-lc"|"-c", cmd]` collapse to the inner command. representation keeps every payload as raw JSON, so native ↔ disk round-trips are lossless. - **`apply_patch` is best-effort.** Only a lone single-hunk update maps to `Edit` and a lone file add to `Write`; multi-file, multi-hunk, delete, and move patches stay as a raw `ApplyPatch` - with the touched paths listed. + with the touched paths listed. On export, `Edit` and `Write` become `apply_patch` + custom-tool calls, and raw `ApplyPatch` envelopes are unwrapped. Their results use + `custom_tool_call_output`, preserving the error flag. Patches are line-based: + `replace_all` becomes one hunk, Codex can normalize trailing blank lines, and the Common + reader does not retain the final line terminator. Exported `Write` calls use an add-file + patch, which does not encode whether the original call created or overwrote a file. +- **Shell export.** `Bash` becomes `exec_command` with `cmd` and optional `workdir`. + The canonical timeout, description, and background fields have no matching fields in + this mapping and are omitted. Other tools keep their canonical function-call form. - **Resume is picky.** `from_common` must emit `model_provider: "openai"` in `session_meta` — current Codex resolves a null provider to the empty name and fails resume with ``Model provider `` not found``. `base_instructions` may be null (defaults substitute). Foreign @@ -98,7 +106,9 @@ the form `["bash"|"sh"|"zsh", "-lc"|"-c", cmd]` collapse to the inner command. malformed lines are skipped by the JSONL parser rather than aborting the file; a `session_meta` without an id disqualifies a file from discovery instead of producing a broken session. - **Duplicate results are by design.** Seeing both `exec_command_end` and a matching - `function_call_output` in a file is normal; only one becomes a `ToolResult`. + `function_call_output` in a file is normal; only one becomes a `ToolResult`. Matching + and duplicate suppression follow each function/custom call occurrence, so a completed + ID can be reused without changing the result type or dropping an earlier result. ## References diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 65e8ae6..429f6d5 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -59,7 +59,7 @@ struct Queued { timestamp: DateTime, model: Option, usage: Option, - result_call_id: Option, + result_key: Option<(String, usize)>, is_fallback_result: bool, } @@ -101,7 +101,8 @@ fn lines_to_messages(lines: &[Line], fallback_ts: DateTime) -> Vec let mut turn_models: HashMap = HashMap::new(); let mut turn_usage: HashMap = HashMap::new(); let mut last_assistant_text_by_turn: HashMap = HashMap::new(); - let mut canonical_results: HashSet = HashSet::new(); + let mut canonical_results = HashSet::new(); + let mut call_occurrences = HashMap::new(); let mut pending_web_search_ids: HashMap> = HashMap::new(); let mut unresolved_web_search_indices: HashMap> = HashMap::new(); @@ -116,6 +117,23 @@ fn lines_to_messages(lines: &[Line], fallback_ts: DateTime) -> Vec .and_then(parse_ts) .unwrap_or(fallback_ts); let payload = &line.payload; + // Function/custom IDs may be reused after a call completes. Scope + // mirror suppression to the latest occurrence, not the entire log. + if line.kind == "response_item" + && matches!( + payload.get("type").and_then(Value::as_str), + Some("function_call" | "custom_tool_call") + ) + && let Some(id) = payload.get("call_id").and_then(Value::as_str) + { + call_occurrences.insert(id, queued.len() + 1); + } + let result_key = |id: &str| { + ( + id.to_string(), + call_occurrences.get(id).copied().unwrap_or(0), + ) + }; match line.kind.as_str() { "turn_context" => { current_turn_id = payload @@ -165,10 +183,10 @@ fn lines_to_messages(lines: &[Line], fallback_ts: DateTime) -> Vec .and_then(Value::as_str) .map(String::from) { - canonical_results.insert(call_id.clone()); + canonical_results.insert(result_key(&call_id)); queued.push(tool_result( ts, - call_id, + result_key(&call_id), ToolOutput::Text(format_exec_output(payload)), payload .get("exit_code") @@ -202,10 +220,10 @@ fn lines_to_messages(lines: &[Line], fallback_ts: DateTime) -> Vec { id.clone_from(&call_id); } - canonical_results.insert(call_id.clone()); + canonical_results.insert(result_key(&call_id)); queued.push(tool_result( ts, - call_id, + result_key(&call_id), ToolOutput::Text(format_web_search_result(payload)), false, false, @@ -313,7 +331,13 @@ fn lines_to_messages(lines: &[Line], fallback_ts: DateTime) -> Vec .map_or(ToolOutput::Text(String::new()), |s| { ToolOutput::Text(s.to_string()) }); - queued.push(tool_result(ts, call_id, content, false, true)); + queued.push(tool_result( + ts, + result_key(&call_id), + content, + false, + true, + )); } } "custom_tool_call" => { @@ -357,8 +381,14 @@ fn lines_to_messages(lines: &[Line], fallback_ts: DateTime) -> Vec .and_then(Value::as_str) .unwrap_or_default(); let (content, is_error) = parse_custom_tool_output(raw); - canonical_results.insert(call_id.clone()); - queued.push(tool_result(ts, call_id, content, is_error, false)); + canonical_results.insert(result_key(&call_id)); + queued.push(tool_result( + ts, + result_key(&call_id), + content, + is_error, + false, + )); } } "web_search_call" => { @@ -398,12 +428,12 @@ fn lines_to_messages(lines: &[Line], fallback_ts: DateTime) -> Vec } } - // Drop the fallback function_call_output when a canonical result exists. + // Drop a fallback only when this call occurrence has a canonical result. queued .into_iter() .filter(|q| { !q.is_fallback_result - || q.result_call_id + || q.result_key .as_ref() .is_none_or(|c| !canonical_results.contains(c)) }) @@ -425,7 +455,7 @@ fn plain(role: Role, content: Vec, ts: DateTime, model: Option, - call_id: String, + result_key: (String, usize), content: ToolOutput, is_error: bool, is_fallback: bool, @@ -461,14 +491,14 @@ fn tool_result( Queued { role: Role::User, content: vec![Block::ToolResult { - tool_use_id: call_id.clone(), + tool_use_id: result_key.0.clone(), content, is_error, }], timestamp: ts, model: None, usage: None, - result_call_id: Some(call_id), + result_key: Some(result_key), is_fallback_result: is_fallback, } } @@ -501,17 +531,9 @@ fn messages_to_lines(meta: &Meta, messages: &[Message]) -> Vec { } lines.push(meta_line(&meta.timestamp, "session_meta", payload)); - // Calls Codex issued as `custom_tool_call`, looked up when emitting - // results: a patch call pairs with `custom_tool_call_output`, - // everything else with `function_call_output`. - let patch_ids: HashSet<&str> = messages - .iter() - .flat_map(|msg| &msg.content) - .filter_map(|block| match block { - Block::ToolUse { id, tool } if is_patch_tool(tool) => Some(id.as_str()), - _ => None, - }) - .collect(); + // Only pending patch calls need custom-tool results. Completed IDs can + // be reused by a different tool later in the transcript. + let mut pending_patch_ids = HashSet::new(); for (i, msg) in messages.iter().enumerate() { let ts = msg.timestamp.to_rfc3339_opts(SecondsFormat::Millis, true); @@ -528,7 +550,7 @@ fn messages_to_lines(meta: &Meta, messages: &[Message]) -> Vec { lines.push(meta_line(&msg.timestamp, "turn_context", tc)); } - push_message_lines(&mut lines, msg, &ts, &patch_ids); + push_message_lines(&mut lines, msg, &ts, &mut pending_patch_ids); if matches!(msg.role, Role::Assistant) && let Some(usage) = msg.usage.as_ref() @@ -556,7 +578,12 @@ fn messages_to_lines(meta: &Meta, messages: &[Message]) -> Vec { } /// Emit the `response_item` (and paired display `event_msg`) lines for one message. -fn push_message_lines(lines: &mut Vec, msg: &Message, ts: &str, patch_ids: &HashSet<&str>) { +fn push_message_lines<'a>( + lines: &mut Vec, + msg: &'a Message, + ts: &str, + pending_patch_ids: &mut HashSet<&'a str>, +) { let role_str = match msg.role { Role::User => "user", Role::Assistant => "assistant", @@ -605,33 +632,32 @@ fn push_message_lines(lines: &mut Vec, msg: &Message, ts: &str, patch_ids: json!({ "type": "agent_reasoning", "text": text }), )); } - Block::ToolUse { id, tool } => push_tool_use_lines(lines, ts, id, tool), + Block::ToolUse { id, tool } => { + if is_patch_tool(tool) { + pending_patch_ids.insert(id.as_str()); + } else { + pending_patch_ids.remove(id.as_str()); + } + push_tool_use_lines(lines, ts, id, tool); + } Block::ToolResult { tool_use_id, content, is_error, } => { - if patch_ids.contains(tool_use_id.as_str()) { - lines.push(meta_line_str( - ts, - "response_item", - json!({ - "type": "custom_tool_call_output", - "call_id": tool_use_id, - "output": custom_tool_output(content, *is_error), - }), - )); + let (kind, output) = if pending_patch_ids.remove(tool_use_id.as_str()) { + ( + "custom_tool_call_output", + custom_tool_output(content, *is_error), + ) } else { - lines.push(meta_line_str( - ts, - "response_item", - json!({ - "type": "function_call_output", - "call_id": tool_use_id, - "output": tool_output_text(content), - }), - )); - } + ("function_call_output", tool_output_text(content)) + }; + lines.push(meta_line_str( + ts, + "response_item", + json!({ "type": kind, "call_id": tool_use_id, "output": output }), + )); } } } @@ -656,8 +682,8 @@ fn push_message_lines(lines: &mut Vec, msg: &Message, ts: &str, patch_ids: } /// Emit the native call line for one tool invocation: `exec_command` for -/// shell, `custom_tool_call` for edits, `web_search_call` for search — the -/// exact reverse of the inbound normalization, so Codex validates the replay. +/// shell and `custom_tool_call` for edits. Foreign tools keep their canonical +/// function-call form and their paired function-call results. fn push_tool_use_lines(lines: &mut Vec, ts: &str, id: &str, tool: &Tool) { match tool { Tool::Bash { @@ -683,18 +709,6 @@ fn push_tool_use_lines(lines: &mut Vec, ts: &str, id: &str, tool: &Tool) { }), )); } - Tool::Raw { tool_name, input } if tool_name == "WebSearch" => { - lines.push(meta_line_str( - ts, - "response_item", - json!({ - "type": "web_search_call", - "status": "completed", - "call_id": id, - "action": input, - }), - )); - } Tool::Edit { file_path, old_string, @@ -705,26 +719,24 @@ fn push_tool_use_lines(lines: &mut Vec, ts: &str, id: &str, tool: &Tool) { lines, ts, id, - &Value::String(apply_patch_update(file_path, old_string, new_string)), + &apply_patch_update(file_path, old_string, new_string), ); } Tool::Write { file_path, content } => { - push_custom_tool_call( - lines, - ts, - id, - &Value::String(apply_patch_add(file_path, content)), - ); + push_custom_tool_call(lines, ts, id, &apply_patch_add(file_path, content)); } Tool::Raw { tool_name, input } if tool_name == "ApplyPatch" => { // The fallback shape inbound keeps is // `{"patch": , "files": [...]}`; unwrap it // so live Codex sees the string input it wrote. - let input = match input { - Value::Object(obj) if obj.get("patch").is_some_and(Value::is_string) => { - obj["patch"].clone() - } - _ => input.clone(), + let input = match input.get("patch").and_then(Value::as_str) { + Some(patch) => patch.to_owned(), + None => match input { + Value::String(text) => text.clone(), + // Even a malformed historical call must satisfy Codex's + // string input type. The reader decodes JSON strings. + other => other.to_string(), + }, }; push_custom_tool_call(lines, ts, id, &input); } @@ -777,7 +789,7 @@ fn apply_patch_add(file_path: &str, content: &str) -> String { patch.join("\n") } -fn push_custom_tool_call(lines: &mut Vec, ts: &str, id: &str, input: &Value) { +fn push_custom_tool_call(lines: &mut Vec, ts: &str, id: &str, input: &str) { lines.push(meta_line_str( ts, "response_item", diff --git a/tests/integration/codex.rs b/tests/integration/codex.rs index 0f0387a..7f10699 100644 --- a/tests/integration/codex.rs +++ b/tests/integration/codex.rs @@ -279,7 +279,14 @@ fn codec_fixpoint_through_common_loses_nothing() { #[test] fn from_common_denormalizes_bash_to_exec_command() { - let common = sample_common(); + let mut common = sample_common(); + if let common::Block::ToolUse { + tool: common::Tool::Bash { workdir, .. }, + .. + } = &mut common.body[2].content[0] + { + *workdir = Some("/repo with spaces".into()); + } let native = codex::Codex::from_common(&common).unwrap(); let mut found = false; for line in &native.body { @@ -306,6 +313,8 @@ fn from_common_denormalizes_bash_to_exec_command() { .and_then(|s| s.parse().ok()) .unwrap_or(serde_json::Value::Null); if args.get("cmd").and_then(serde_json::Value::as_str) == Some("ls") { + assert_eq!(args["workdir"], "/repo with spaces"); + assert_eq!(line.payload["call_id"], "call-x"); found = true; } } @@ -316,52 +325,6 @@ fn from_common_denormalizes_bash_to_exec_command() { ); } -#[test] -fn from_common_denormalizes_web_search_to_web_search_call() { - let mut common = sample_common(); - common.body.push(common::Message { - role: common::Role::Assistant, - content: vec![common::Block::ToolUse { - id: "ws-call".into(), - tool: common::Tool::Raw { - tool_name: "WebSearch".into(), - input: serde_json::json!({"query": "rust lang"}), - }, - }], - timestamp: ts("2026-01-02T03:04:11.000Z"), - model: None, - stop_reason: None, - usage: None, - }); - common.body.push(common::Message { - role: common::Role::User, - content: vec![common::Block::ToolResult { - tool_use_id: "ws-call".into(), - content: common::ToolOutput::Text("found it".into()), - is_error: false, - }], - timestamp: ts("2026-01-02T03:04:12.000Z"), - model: None, - stop_reason: None, - usage: None, - }); - let native = codex::Codex::from_common(&common).unwrap(); - let found = native.body.iter().any(|line| { - line.kind == "response_item" - && line.payload.get("type").and_then(serde_json::Value::as_str) - == Some("web_search_call") - && line - .payload - .get("call_id") - .and_then(serde_json::Value::as_str) - == Some("ws-call") - }); - assert!( - found, - "from_common must emit native web_search_call instead of generic function_call" - ); -} - #[test] fn from_common_denormalizes_edit_to_apply_patch_with_error_result() { let mut common = sample_common(); @@ -437,3 +400,213 @@ fn from_common_denormalizes_edit_to_apply_patch_with_error_result() { let back = codex::Codex::to_common(&native).unwrap(); assert_eq!(common, back); } + +#[test] +fn from_common_patch_envelopes_encode_multiline_and_empty_files() { + for (tool, expected_input) in [ + ( + common::Tool::Edit { + file_path: "src/main.rs".into(), + old_string: "old\n\n".into(), + new_string: "new\n\n".into(), + replace_all: false, + }, + "*** Begin Patch\n*** Update File: src/main.rs\n@@\n-old\n-\n+new\n+\n*** End Patch", + ), + ( + common::Tool::Write { + file_path: "notes.md".into(), + content: "first\nsecond\n\n".into(), + }, + "*** Begin Patch\n*** Add File: notes.md\n+first\n+second\n+\n*** End Patch", + ), + ( + common::Tool::Write { + file_path: "empty.txt".into(), + content: String::new(), + }, + "*** Begin Patch\n*** Add File: empty.txt\n*** End Patch", + ), + ] { + let mut common = sample_common(); + common.body[2].content = vec![common::Block::ToolUse { + id: "call-x".into(), + tool, + }]; + let native = codex::Codex::from_common(&common).unwrap(); + let call = native + .body + .iter() + .find(|line| line.kind == "response_item" && line.payload["type"] == "custom_tool_call") + .unwrap(); + assert_eq!(call.payload["input"], expected_input); + } +} + +#[test] +fn from_common_raw_patch_inputs_are_always_strings() { + let patch = + "*** Begin Patch\n*** Delete File: old.rs\n*** Add File: new.rs\n+hello\n*** End Patch"; + for input in [ + serde_json::json!({"patch": patch, "files": ["old.rs", "new.rs"]}), + // Failed native calls can carry malformed arguments. Their history + // still needs a string input, as required by Codex's CustomToolCall. + serde_json::json!({"unexpected": "argument"}), + serde_json::json!(["unexpected", "array"]), + serde_json::Value::Null, + ] { + let mut common = sample_common(); + common.body[2].content = vec![common::Block::ToolUse { + id: "call-x".into(), + tool: common::Tool::Raw { + tool_name: "ApplyPatch".into(), + input, + }, + }]; + let native = codex::Codex::from_common(&common).unwrap(); + let call = native + .body + .iter() + .find(|line| line.kind == "response_item" && line.payload["type"] == "custom_tool_call") + .unwrap(); + assert!(call.payload["input"].is_string(), "{call:?}"); + let result = native + .body + .iter() + .find(|line| { + line.kind == "response_item" && line.payload["type"] == "custom_tool_call_output" + }) + .unwrap(); + assert_eq!(call.payload["call_id"], result.payload["call_id"]); + let back = codex::Codex::to_common(&native).unwrap(); + assert_eq!(common, back); + } +} + +#[test] +fn from_common_keeps_foreign_web_search_paired_with_its_function_result() { + let mut common = sample_common(); + common.body[2].content = vec![common::Block::ToolUse { + id: "call-x".into(), + tool: common::Tool::Raw { + tool_name: "WebSearch".into(), + input: serde_json::json!({"query": "rust lang"}), + }, + }]; + let native = codex::Codex::from_common(&common).unwrap(); + // A foreign search input has no native WebSearchAction type tag, and + // its function result must keep a corresponding function call. + assert!( + !native.body.iter().any(|line| { + line.kind == "response_item" && line.payload["type"] == "web_search_call" + }) + ); + assert!(native.body.iter().any(|line| { + line.kind == "response_item" + && line.payload["type"] == "function_call" + && line.payload["name"] == "WebSearch" + && line.payload["call_id"] == "call-x" + })); + assert_eq!(common, codex::Codex::to_common(&native).unwrap()); +} + +#[test] +fn interleaved_tool_calls_keep_their_results_through_disk() { + use common::{Block, Tool, ToolOutput}; + + let call = |id: &str, tool| Block::ToolUse { + id: id.into(), + tool, + }; + let result = |id: &str, content, is_error| Block::ToolResult { + tool_use_id: id.into(), + content, + is_error, + }; + let shell = Tool::Bash { + command: "ls".into(), + workdir: Some("/repo with spaces".into()), + timeout_ms: None, + description: None, + run_in_background: false, + }; + let mut common = sample_common(); + let mut calls = common.body[2].clone(); + calls.content = vec![ + call( + "a", + Tool::Edit { + file_path: "main.rs".into(), + old_string: "old".into(), + new_string: "new".into(), + replace_all: false, + }, + ), + call( + "b", + Tool::Write { + file_path: "new.rs".into(), + content: "new".into(), + }, + ), + call("c", shell.clone()), + ]; + // Finish c and b first, then reuse b while a is still pending. + let mut results = common.body[3].clone(); + results.content = vec![ + result("c", ToolOutput::Text("shell c".into()), false), + result("b", ToolOutput::Text("write b".into()), false), + ]; + let mut reused = calls.clone(); + reused.content = vec![call("b", shell)]; + let mut remaining = results.clone(); + remaining.content = vec![ + result( + "a", + ToolOutput::Json(serde_json::json!({"error": "conflict"})), + true, + ), + result("b", ToolOutput::Text("shell b".into()), false), + ]; + common.body = vec![calls, results, reused, remaining]; + + let dir = tempfile::tempdir().unwrap(); + let store = codex::CodexStore::new(dir.path()); + let native = codex::Codex::from_common(&common).unwrap(); + let saved = store.save(&native).unwrap(); + let reloaded = store.load(&saved.reference).unwrap(); + let kinds: Vec<_> = reloaded + .body + .iter() + .filter(|line| line.kind == "response_item") + .map(|line| { + ( + line.payload["call_id"].as_str().unwrap(), + line.payload["type"].as_str().unwrap(), + ) + }) + .collect(); + assert_eq!( + kinds, + [ + ("a", "custom_tool_call"), + ("b", "custom_tool_call"), + ("c", "function_call"), + ("c", "function_call_output"), + ("b", "custom_tool_call_output"), + ("b", "function_call"), + ("a", "custom_tool_call_output"), + ("b", "function_call_output"), + ] + ); + let back = codex::Codex::to_common(&reloaded).unwrap(); + // Codex stores one response item per block, splitting multi-block messages. + let blocks = |transcript: Transcript| { + transcript + .body + .into_iter() + .flat_map(|msg| msg.content) + .collect::>() + }; + assert_eq!(blocks(common), blocks(back)); +} diff --git a/tests/regression/codex_pairing.rs b/tests/regression/codex_pairing.rs new file mode 100644 index 0000000..e90d521 --- /dev/null +++ b/tests/regression/codex_pairing.rs @@ -0,0 +1,192 @@ +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +//! Regressions for Codex call/result pairing when completed IDs are reused. + +use serde_json::json; +use txcript::common::{Block, Role, Tool, ToolOutput}; +use txcript::harness::codex; +use txcript::{Codec, Common, TextCodec, Transcript}; + +use super::{meta, msg}; + +/// Fixed in 7b854de ("pair reused tool IDs by call occurrence"), issue #59. +/// A global patch-ID set made a shell result use the custom-tool envelope +/// if any later edit reused that ID. Each completed call must retain its +/// native result type and survive reading the exported transcript. +#[test] +fn reused_call_ids_preserve_result_types_and_round_trip() { + let mut common = Transcript::::new(meta("reused-calls"), Vec::new()); + let call_template = msg(Role::Assistant, Vec::new(), 0); + let result_template = msg(Role::User, Vec::new(), 1); + let bash = Tool::Bash { + command: "ls".into(), + workdir: None, + timeout_ms: None, + description: None, + run_in_background: false, + }; + let tools = [ + bash.clone(), + Tool::Edit { + file_path: "main.rs".into(), + old_string: "old".into(), + new_string: "new".into(), + replace_all: false, + }, + bash.clone(), + Tool::Write { + file_path: "new.rs".into(), + content: "new".into(), + }, + bash.clone(), + Tool::Raw { + tool_name: "ApplyPatch".into(), + input: serde_json::json!({ + "patch": "*** Begin Patch\n*** Delete File: old.rs\n*** End Patch", + "files": ["old.rs"], + }), + }, + bash, + ]; + for (i, tool) in tools.into_iter().enumerate() { + let mut call = call_template.clone(); + call.content = vec![Block::ToolUse { + id: "reused".into(), + tool, + }]; + let mut result = result_template.clone(); + result.content = vec![Block::ToolResult { + tool_use_id: "reused".into(), + content: ToolOutput::Text(format!("result {i}")), + is_error: i % 2 == 1, + }]; + common.body.extend([call, result]); + } + + let native = codex::Codex::from_common(&common).unwrap(); + let kinds: Vec<_> = native + .body + .iter() + .filter(|line| line.kind == "response_item" && line.payload["call_id"] == "reused") + .map(|line| line.payload["type"].as_str().unwrap()) + .collect(); + assert_eq!( + kinds, + [ + "function_call", + "function_call_output", + "custom_tool_call", + "custom_tool_call_output", + "function_call", + "function_call_output", + "custom_tool_call", + "custom_tool_call_output", + "function_call", + "function_call_output", + "custom_tool_call", + "custom_tool_call_output", + "function_call", + "function_call_output", + ] + ); + assert_eq!(common, codex::Codex::to_common(&native).unwrap()); +} + +/// Fixed in 7b854de ("pair reused tool IDs by call occurrence"), issue #59. +/// Deduplicating canonical results by ID across the entire transcript +/// erased other calls' only results when they reused that ID. Suppress only +/// the matching mirror, regardless of which copy arrives first. +#[test] +fn reused_call_ids_deduplicate_only_their_own_mirrors() { + let shell = json!({ + "type": "function_call", "name": "exec_command", + "arguments": "{\"cmd\":\"ls\"}", "call_id": "reused", + }); + let patch = json!({ + "type": "custom_tool_call", "name": "apply_patch", "call_id": "reused", + "input": "*** Begin Patch\n*** Delete File: old.rs\n*** End Patch", + }); + let fallback = |output| { + json!({ + "type": "function_call_output", "call_id": "reused", "output": output, + }) + }; + let cases = [ + ( + shell.clone(), + "event_msg", + json!({ + "type": "exec_command_end", "call_id": "reused", + "aggregated_output": "command failed", "exit_code": 1, + }), + ToolOutput::Text("command failed".into()), + ), + ( + patch, + "response_item", + json!({ + "type": "custom_tool_call_output", "call_id": "reused", + "output": json!({ + "output": {"error": "missing file", "files": ["old.rs"]}, + "metadata": {"exit_code": 1}, + }).to_string(), + }), + ToolOutput::Json(json!({"error": "missing file", "files": ["old.rs"]})), + ), + ]; + for (call, canonical_kind, canonical, content) in cases { + for canonical_first in [false, true] { + let mut pair = [ + (canonical_kind, canonical.clone()), + ("response_item", fallback("mirror")), + ]; + if !canonical_first { + pair.reverse(); + } + let records = [ + ("response_item", shell.clone()), + ("response_item", fallback("first")), + ("response_item", call.clone()), + pair[0].clone(), + pair[1].clone(), + ("response_item", shell.clone()), + ("response_item", fallback("last")), + ]; + let text: String = records + .into_iter() + .map(|(kind, payload)| json!({"type": kind, "payload": payload}).to_string() + "\n") + .collect(); + let native = codex::Codex::from_text(&text).unwrap(); + let common = codex::Codex::to_common(&native).unwrap(); + let results: Vec<_> = common + .body + .iter() + .flat_map(|msg| &msg.content) + .filter(|block| matches!(block, Block::ToolResult { .. })) + .cloned() + .collect(); + assert_eq!( + results, + [ + Block::ToolResult { + tool_use_id: "reused".into(), + content: ToolOutput::Text("first".into()), + is_error: false, + }, + Block::ToolResult { + tool_use_id: "reused".into(), + content: content.clone(), + is_error: true, + }, + Block::ToolResult { + tool_use_id: "reused".into(), + content: ToolOutput::Text("last".into()), + is_error: false, + }, + ], + "{}; canonical_first={canonical_first}", + call["type"] + ); + } + } +} diff --git a/tests/regression/main.rs b/tests/regression/main.rs index f8204ad..79d638e 100644 --- a/tests/regression/main.rs +++ b/tests/regression/main.rs @@ -6,6 +6,8 @@ //! incident can be understood from this file alone. General invariants //! belong in `tests/integration/`; see `tests/README.md`. +mod codex_pairing; + use chrono::{DateTime, Utc}; use txcript::common::{Block, Message, Meta, Role, Tool, ToolOutput}; use txcript::harness::{claude_code, codex, grok};