diff --git a/crates/fkst-framework/src/sdk_codex.rs b/crates/fkst-framework/src/sdk_codex.rs index e4dd109..d293cab 100644 --- a/crates/fkst-framework/src/sdk_codex.rs +++ b/crates/fkst-framework/src/sdk_codex.rs @@ -40,6 +40,8 @@ const DEFAULT_CODEX_LOG_MAX_AGE: Duration = Duration::from_secs(48 * 60 * 60); const CODEX_STATUS_RECENT_LIMIT: usize = 50; const CODEX_OUTPUT_TAIL_MAX_LINES: usize = 40; const CODEX_OUTPUT_TAIL_MAX_BYTES: usize = 4096; +const CODEX_DIAGNOSTIC_MAX_RECORDS: usize = 64; +const CODEX_DIAGNOSTIC_MAX_BYTES: usize = 4096; const CODEX_STATUS_LOG_PREFIX: &str = "CODEX_STATUS:"; const CODEX_OUTPUT_LOG_BEGIN: &str = "CODEX_OUTPUT_BEGIN"; const CODEX_OUTPUT_LOG_END: &str = "CODEX_OUTPUT_END\n"; @@ -150,6 +152,14 @@ struct CodexStatusRecord { output_tail_path: Option, #[serde(default)] codex_error_info: Option, + #[serde(default)] + diagnostics: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +struct CodexDiagnostic { + line: usize, + raw: String, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -179,6 +189,8 @@ struct CodexAdoptionRecord { error: Option, #[serde(default)] codex_error_info: Option, + #[serde(default)] + diagnostics: Vec, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -189,6 +201,8 @@ struct CodexAdoptionResultRecord { error: Option, #[serde(default)] codex_error_info: Option, + #[serde(default)] + diagnostics: Vec, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -202,12 +216,15 @@ struct CodexAdoptionEffectRecord { error: Option, #[serde(default)] codex_error_info: Option, + #[serde(default)] + diagnostics: Vec, } // worker threads return the same result shape before Lua table conversion. // crate-internal test seams expose only data needed by extracted integration tests. pub(crate) struct CodexResult { stdout: String, + raw_stdout: Option, stderr: String, exit_code: i32, log_path: String, @@ -215,6 +232,7 @@ pub(crate) struct CodexResult { error_class: Option, error: Option, codex_error_info: Option, + diagnostics: Vec, } // spawn_codex returns a pipeline-local opaque handle consumed by await_all. @@ -292,6 +310,7 @@ impl CodexResult { .map(|class| class.label().to_string()); Self { stdout, + raw_stdout: None, stderr, exit_code, log_path, @@ -299,14 +318,34 @@ impl CodexResult { error_class, error: None, codex_error_info: None, + diagnostics: Vec::new(), } } fn from_process(stdout: String, stderr: String, exit_code: i32, log_path: String) -> Self { - let (projected_stdout, codex_error_info) = project_codex_jsonl(&stdout); - let mut result = Self::success(projected_stdout, stderr, exit_code, log_path); - result.codex_error_info = codex_error_info; - result + let raw_stdout = stdout.clone(); + match parse_codex_jsonl(&stdout) { + Ok(parsed) => { + let mut result = Self::success(parsed.stdout, stderr, exit_code, log_path); + result.raw_stdout = Some(raw_stdout); + result.codex_error_info = parsed.codex_error_info; + result.diagnostics = parsed.diagnostics; + result + } + Err(error) => { + let mut result = Self::failure( + "structured_adapter", + error.message, + String::new(), + stderr, + exit_code, + log_path, + ); + result.raw_stdout = Some(raw_stdout); + result.diagnostics = error.diagnostics; + result + } + } } fn failure( @@ -324,6 +363,7 @@ impl CodexResult { }; Self { stdout, + raw_stdout: None, stderr: combined_stderr, exit_code, log_path, @@ -335,6 +375,7 @@ impl CodexResult { ), error: Some(message), codex_error_info: None, + diagnostics: Vec::new(), } } @@ -342,6 +383,7 @@ impl CodexResult { let message = err.to_string(); Self { stdout: String::new(), + raw_stdout: None, stderr: message.clone(), exit_code: -1, log_path: String::new(), @@ -353,6 +395,7 @@ impl CodexResult { ), error: Some(message), codex_error_info: None, + diagnostics: Vec::new(), } } @@ -391,50 +434,177 @@ fn result_table( Ok(t) } -fn project_codex_jsonl(stdout: &str) -> (String, Option) { - let mut saw_jsonl = false; - let mut final_message = None; - let mut codex_error_info = None; - for line in stdout.lines() { - let Ok(event) = serde_json::from_str::(line) else { - if saw_jsonl { - return (stdout.to_string(), None); +#[derive(Debug)] +struct CodexJsonlResult { + stdout: String, + codex_error_info: Option, + diagnostics: Vec, +} + +#[derive(Debug)] +struct CodexJsonlError { + message: String, + diagnostics: Vec, +} + +fn parse_codex_jsonl(stdout: &str) -> std::result::Result { + let lines = stdout + .lines() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()); + let mut records: Vec<(usize, serde_json::Value)> = Vec::new(); + let mut diagnostics = Vec::new(); + for (index, line) in lines { + let value = serde_json::from_str::(line).map_err(|error| { + let mut retained = diagnostics.clone(); + if retained.len() < CODEX_DIAGNOSTIC_MAX_RECORDS { + retained.push(CodexDiagnostic { + line: index + 1, + raw: line.chars().take(CODEX_DIAGNOSTIC_MAX_BYTES).collect(), + }); } - continue; - }; - let Some(event_type) = event.get("type").and_then(serde_json::Value::as_str) else { - continue; - }; - if !matches!(event_type, "turn.failed" | "item.completed") { - continue; - } - saw_jsonl = true; - if event_type == "turn.failed" { - codex_error_info = event - .get("error") - .and_then(|error| error.get("codex_error_info")) - .and_then(serde_json::Value::as_str) - .map(str::to_string); - } else if event - .get("item") - .and_then(|item| item.get("type")) + CodexJsonlError { + message: format!( + "codex structured adapter: malformed JSON at line {}: {error}", + index + 1 + ), + diagnostics: retained, + } + })?; + let object = value.as_object().ok_or_else(|| CodexJsonlError { + message: format!( + "codex structured adapter: record at line {} is not an object", + index + 1 + ), + diagnostics: diagnostics.clone(), + })?; + let event_type = object + .get("type") .and_then(serde_json::Value::as_str) - == Some("agent_message") - { - if let Some(text) = event - .get("item") - .and_then(|item| item.get("text")) - .and_then(serde_json::Value::as_str) - { - final_message = Some(text.to_string()); + .ok_or_else(|| CodexJsonlError { + message: format!( + "codex structured adapter: record at line {} has no string type", + index + 1 + ), + diagnostics: diagnostics.clone(), + })?; + match event_type { + "turn.failed" => { + let error = object + .get("error") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| CodexJsonlError { + message: format!( + "codex structured adapter: turn.failed at line {} has malformed error", + index + 1 + ), + diagnostics: diagnostics.clone(), + })?; + if error + .get("message") + .and_then(serde_json::Value::as_str) + .is_none() + { + return Err(CodexJsonlError { + message: format!("codex structured adapter: turn.failed at line {} has no string error message", index + 1), + diagnostics: diagnostics.clone(), + }); + } } + "item.completed" => { + let item = object.get("item").and_then(serde_json::Value::as_object).ok_or_else(|| CodexJsonlError { + message: format!("codex structured adapter: item.completed at line {} has malformed item", index + 1), + diagnostics: diagnostics.clone(), + })?; + if item + .get("type") + .and_then(serde_json::Value::as_str) + .is_none() + { + return Err(CodexJsonlError { + message: format!("codex structured adapter: item.completed at line {} has no string item type", index + 1), + diagnostics: diagnostics.clone(), + }); + } + if item.get("type").and_then(serde_json::Value::as_str) == Some("agent_message") + && item + .get("text") + .and_then(serde_json::Value::as_str) + .is_none() + { + return Err(CodexJsonlError { + message: format!( + "codex structured adapter: agent_message at line {} has no string text", + index + 1 + ), + diagnostics: diagnostics.clone(), + }); + } + } + _ => {} } + records.push((index, value)); } - if saw_jsonl { - (final_message.unwrap_or_default(), codex_error_info) - } else { - (stdout.to_string(), None) + if records.is_empty() { + return Ok(CodexJsonlResult { + stdout: stdout.to_string(), + codex_error_info: None, + diagnostics: Vec::new(), + }); } + let mut final_message = None; + let mut codex_error_info = None; + diagnostics.clear(); + for (index, value) in records { + let event_type = value + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap(); + match event_type { + "turn.failed" => { + let error = value + .get("error") + .and_then(serde_json::Value::as_object) + .unwrap(); + if let Some(value) = error.get("codex_error_info") { + if !value.is_null() && value.as_str().is_none() { + return Err(CodexJsonlError { + message: format!("codex structured adapter: turn.failed at line {} has non-string codex_error_info", index + 1), + diagnostics, + }); + } + codex_error_info = value.as_str().map(str::to_string); + } + } + "item.completed" => { + let item = value + .get("item") + .and_then(serde_json::Value::as_object) + .unwrap(); + if item.get("type").and_then(serde_json::Value::as_str) == Some("agent_message") { + final_message = item + .get("text") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + } + } + _ => { + if diagnostics.len() < CODEX_DIAGNOSTIC_MAX_RECORDS { + let raw = serde_json::to_string(&value).unwrap_or_else(|_| "{}".to_string()); + let raw = raw.chars().take(CODEX_DIAGNOSTIC_MAX_BYTES).collect(); + diagnostics.push(CodexDiagnostic { + line: index + 1, + raw, + }); + } + } + } + } + Ok(CodexJsonlResult { + stdout: final_message.unwrap_or_default(), + codex_error_info, + diagnostics, + }) } pub fn register( @@ -711,7 +881,7 @@ fn run_codex_request( &command_line_for_request(&request), request.timeout_seconds, ); - status.finish(-1, None); + status.finish(-1, None, Vec::new()); write_codex_status(&request.log_path, &status); return Ok(CodexResult::failure( "permit", @@ -738,7 +908,7 @@ fn run_codex_request( &command_line_for_request(&request), request.timeout_seconds, ); - status.finish(-1, None); + status.finish(-1, None, Vec::new()); write_codex_status(&request.log_path, &status); return Ok(CodexResult::failure( "permit", @@ -752,7 +922,11 @@ fn run_codex_request( }; let result = run_codex_request_with_permit(request, config, lifetime_witness.as_raw_fd()); - status.finish(result.exit_code, result.codex_error_info.clone()); + status.finish( + result.exit_code, + result.codex_error_info.clone(), + result.diagnostics.clone(), + ); write_codex_status(Path::new(&result.log_path), &status); Ok(result) } @@ -821,7 +995,10 @@ fn run_mocked_codex_request( runner.record( invocation, MockCommandResult { - stdout: result.stdout.clone(), + stdout: result + .raw_stdout + .clone() + .unwrap_or_else(|| result.stdout.clone()), stderr: result.stderr.clone(), exit_code: result.exit_code, }, @@ -846,14 +1023,19 @@ fn run_mocked_codex_request( &cmd_line, request.timeout_seconds, ); - status.finish(result.exit_code, None); - write_codex_status(&request.log_path, &status); - Ok(CodexResult::success( - result.stdout, - result.stderr, + let parsed = CodexResult::from_process( + result.stdout.clone(), + result.stderr.clone(), result.exit_code, request.log_path.to_string_lossy().into_owned(), - )) + ); + status.finish( + parsed.exit_code, + parsed.codex_error_info.clone(), + parsed.diagnostics.clone(), + ); + write_codex_status(&request.log_path, &status); + Ok(parsed) } fn adoption_paths_for_request( @@ -968,6 +1150,7 @@ fn read_completed_adoption_result(paths: &CodexAdoptionPaths) -> Result Result anyhow::Result anyhow::Result) { + fn finish( + &mut self, + exit_code: i32, + codex_error_info: Option, + diagnostics: Vec, + ) { let ended_at_ms = unix_duration().as_millis() as u64; self.ended_at = Some(unix_millis_to_iso8601(ended_at_ms)); self.ended_at_ms = Some(ended_at_ms); @@ -2069,6 +2271,7 @@ impl CodexStatusRecord { self.status = "completed".to_string(); self.exit_code = Some(exit_code); self.codex_error_info = codex_error_info; + self.diagnostics = diagnostics; } } @@ -2144,6 +2347,14 @@ fn codex_status_record_table(lua: &Lua, record: &CodexStatusRecord, now_ms: u64) if let Some(codex_error_info) = record.codex_error_info.as_deref() { table.set("codex_error_info", codex_error_info)?; } + let diagnostics = lua.create_table()?; + for (index, diagnostic) in record.diagnostics.iter().enumerate() { + let item = lua.create_table()?; + item.set("line", diagnostic.line)?; + item.set("raw", diagnostic.raw.clone())?; + diagnostics.set(index + 1, item)?; + } + table.set("diagnostics", diagnostics)?; if let Some(permit_slot) = record.permit_slot { table.set("permit_slot", permit_slot)?; } @@ -2409,6 +2620,7 @@ fn codex_status_record_from_adoption( .into_owned(), ), codex_error_info: record.codex_error_info, + diagnostics: record.diagnostics, } } @@ -2700,6 +2912,7 @@ fn write_codex_effect_receipt(request: &CodexRequest, result: &CodexResult) -> a error_kind: result.error_kind.clone(), error: result.error.clone(), codex_error_info: result.codex_error_info.clone(), + diagnostics: result.diagnostics.clone(), }; append_codex_effect_log(effect_log_path, &receipt) } @@ -3616,6 +3829,7 @@ mod tests { permit_slot: None, output_tail_path: None, codex_error_info: None, + diagnostics: Vec::new(), }; append_codex_status_log(&log_path, &running).unwrap(); let mut completed = running.clone(); @@ -3664,6 +3878,7 @@ mod tests { permit_slot: None, output_tail_path: None, codex_error_info: None, + diagnostics: Vec::new(), }; append_codex_status_log(&log_path, &running).unwrap(); let mut injected = running.clone(); @@ -3713,6 +3928,7 @@ mod tests { permit_slot: None, output_tail_path: None, codex_error_info: None, + diagnostics: Vec::new(), }; append_codex_status_log(&log_path, &running).unwrap(); let mut injected = running.clone(); @@ -4094,4 +4310,44 @@ mod tests { }) })); } + + #[test] + fn jsonl_parser_projects_final_message_and_retains_additive_records() { + let output = concat!( + "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n", + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"final answer\"}}\n", + "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":1}}\n", + ); + + let parsed = parse_codex_jsonl(output).expect("structured stream should parse"); + + assert_eq!(parsed.stdout, "final answer"); + assert_eq!(parsed.codex_error_info, None); + assert_eq!(parsed.diagnostics.len(), 2); + assert_eq!( + parsed.diagnostics[0].raw, + "{\"thread_id\":\"t1\",\"type\":\"thread.started\"}" + ); + assert_eq!( + parsed.diagnostics[1].raw, + "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":1}}" + ); + } + + #[test] + fn jsonl_parser_rejects_malformed_records_and_trailing_data() { + for output in [ + "not-json\n", + "42\n", + "not-json\n{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"ok\"}}\n", + "42\n{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"ok\"}}\n", + "{\"type\":\"item.completed\"}\n", + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\"}}\n", + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"ok\"}}\nnot-json\n", + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"ok\"}}\n[]\n", + ] { + let error = parse_codex_jsonl(output).expect_err("malformed JSONL must fail closed"); + assert!(error.message.contains("codex structured adapter")); + } + } } diff --git a/crates/fkst-framework/tests/example_codex_package.rs b/crates/fkst-framework/tests/example_codex_package.rs index c6bcce6..d8f78cd 100644 --- a/crates/fkst-framework/tests/example_codex_package.rs +++ b/crates/fkst-framework/tests/example_codex_package.rs @@ -159,7 +159,7 @@ fn codex_demo_raises_fake_codex_result() { &format!( r#"#!/bin/sh cat > "{}" -echo FAKE_CODEX_OK +echo '{{"type":"item.completed","item":{{"type":"agent_message","text":"FAKE_CODEX_OK"}}}}' exit 0 "#, prompt_path.display() diff --git a/crates/fkst-framework/tests/run_graph_cli.rs b/crates/fkst-framework/tests/run_graph_cli.rs index 58d3434..0e974b5 100644 --- a/crates/fkst-framework/tests/run_graph_cli.rs +++ b/crates/fkst-framework/tests/run_graph_cli.rs @@ -296,7 +296,10 @@ end return { test_run_graph_codex_logs_are_hermetic_per_run = function() - t.mock_command("codex exec", { stdout = "ambient", exit_code = 0 }) + t.mock_command("codex exec", { + stdout = "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"ambient\"}}\n", + exit_code = 0, + }) local ambient = spawn_codex_sync({ prompt = "ambient codex work", dedup_key = "shared-exec-ref", @@ -304,8 +307,14 @@ return { t.eq(ambient.exit_code, 0) t.is_true(matching_run_exists(), "ambient codex status seed was not visible") - t.mock_command("codex exec", { stdout = "first graph", exit_code = 0 }) - t.mock_command("codex exec", { stdout = "second graph", exit_code = 0 }) + t.mock_command("codex exec", { + stdout = "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"first graph\"}}\n", + exit_code = 0, + }) + t.mock_command("codex exec", { + stdout = "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"second graph\"}}\n", + exit_code = 0, + }) local event = { queue = "PLACEHOLDER.start", payload = {}, diff --git a/crates/fkst-framework/tests/sdk_codex.rs b/crates/fkst-framework/tests/sdk_codex.rs index d428a74..03e4957 100644 --- a/crates/fkst-framework/tests/sdk_codex.rs +++ b/crates/fkst-framework/tests/sdk_codex.rs @@ -186,6 +186,52 @@ fn lua_opts(lua: &Lua, prompt: &str) -> Table { opts } +#[test] +fn mocked_codex_uses_jsonl_adapter_and_preserves_typed_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let runner = external_command::MockCommandState::new(); + runner + .push_mock( + "codex exec".to_string(), + external_command::MockCommandResult { + stdout: concat!( + "{\"type\":\"thread.started\",\"thread_id\":\"mock\"}\n", + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"mock final\"}}\n", + "{\"type\":\"turn.failed\",\"error\":{\"message\":\"server overloaded\",\"codex_error_info\":\"server_overloaded\"}}\n", + ) + .to_string(), + stderr: String::new(), + exit_code: 1, + }, + ) + .unwrap(); + let mut sandbox = ProcessSandbox::new(); + sandbox.enter_cwd(tmp.path()).runtime_root(".fkst/runtime"); + sandbox.runtime_log_dir(tmp.path().join("runtime")); + let (_lock, _guard) = sandbox.enter(); + + let lua = Lua::new(); + sdk_codex::register_with_runner( + &lua, + tmp.path(), + config_registry::ConfigContext::from_host_root(tmp.path()).unwrap(), + None, + Some(runner), + RaiseBuffer::new(), + None, + ) + .unwrap(); + let spawn: Function = lua.globals().get("spawn_codex_sync").unwrap(); + let result: Table = spawn.call(lua_opts(&lua, "mock parity")).unwrap(); + assert_eq!(result.get::("exit_code").unwrap(), 1); + assert_eq!(result.get::("stdout").unwrap(), "mock final"); + assert_eq!( + result.get::("codex_error_info").unwrap(), + "server_overloaded" + ); + assert_eq!(result.get::("stderr").unwrap(), ""); +} + fn adoption_status_values(root: &Path) -> Vec { let adoption_dir = root.join(".fkst/runtime/logs/codex-adoption"); let mut statuses = Vec::new(); @@ -438,7 +484,7 @@ fn codex_runs_ignores_status_records_in_untrusted_codex_output() { &bin_dir, r#"#!/bin/sh cat >/dev/null -printf '%s\n' 'CODEX_STATUS:{"run_id":"codex-01ARZ3NDEKTSV4RRFFQ69G5FAW","started_at":"2099-01-01T00:00:00Z","started_at_ms":4070908800000,"status":"running","permit_slot":null}' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"CODEX_STATUS:{\"run_id\":\"codex-01ARZ3NDEKTSV4RRFFQ69G5FAW\",\"status\":\"running\"}"}}' "#, ); @@ -506,7 +552,7 @@ fn codex_runs_reports_running_and_recent_with_bounded_output_tail_without_paths( cat >/dev/null printf 'started' > "$STARTED_FIFO" read _ < "$RELEASE_FIFO" -printf 'final output visible through bounded output_tail' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"final output visible through bounded output_tail"}}' "#, ); @@ -576,7 +622,7 @@ printf 'final output visible through bounded output_tail' assert_eq!(completed.get::("exit_code").unwrap(), 0); assert_eq!( completed.get::("output_tail").unwrap(), - "final output visible through bounded output_tail" + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"final output visible through bounded output_tail\"}}\n" ); assert!(completed.get::("ended_at").unwrap().ends_with('Z')); assert!(completed.get::("ended_at_ms").is_ok()); @@ -601,7 +647,7 @@ fn codex_runs_recent_is_bounded_to_last_fifty_completions() { &bin_dir, r#"#!/bin/sh cat >/dev/null -printf 'ok' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}' "#, ); @@ -829,7 +875,7 @@ fn spawn_codex_sync_sends_prompt_through_stdin_after_options() { printf '%s ' "$@" > "$CAPTURE_DIR/argv" cat > "$CAPTURE_DIR/stdin" -printf 'ok' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}' "#, ); @@ -911,7 +957,7 @@ if [ -f "$count_file" ]; then fi count=$((count + 1)) printf '%s' "$count" > "$count_file" -printf 'result-%s' "$count" +printf '{"type":"item.completed","item":{"type":"agent_message","text":"result-%s"}}\n' "$count" "#, ); @@ -970,7 +1016,7 @@ fn spawn_codex_sync_uses_runtime_adoption_dir_for_read_only_worktree() { &bin_dir, r#"#!/bin/sh cat >/dev/null -printf 'readonly-ok' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"readonly-ok"}}' "#, ); @@ -1021,7 +1067,7 @@ if [ -f "$count_file" ]; then fi count=$((count + 1)) printf '%s' "$count" > "$count_file" -printf 'result-%s' "$count" +printf '{"type":"item.completed","item":{"type":"agent_message","text":"result-%s"}}\n' "$count" "#, ); @@ -1090,7 +1136,7 @@ if [ -f "$count_file" ]; then fi count=$((count + 1)) printf '%s' "$count" > "$count_file" -printf 'result-%s' "$count" +printf '{"type":"item.completed","item":{"type":"agent_message","text":"result-%s"}}\n' "$count" "#, ); @@ -1142,7 +1188,7 @@ fn spawn_codex_sync_publishes_visible_intent_before_adoption_worker_spawn() { r#"#!/bin/sh cat >/dev/null printf 'spawned' > "$CAPTURE_DIR/spawned" -printf 'intent-ok' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"intent-ok"}}' "#, ); @@ -1230,7 +1276,7 @@ if [ -f "$count_file" ]; then fi count=$((count + 1)) printf '%s' "$count" > "$count_file" -printf 'redrive-%s' "$count" +printf '{"type":"item.completed","item":{"type":"agent_message","text":"redrive-%s"}}\n' "$count" "#, ); @@ -1309,7 +1355,7 @@ if [ -f "$count_file" ]; then fi count=$((count + 1)) printf '%s' "$count" > "$count_file" -printf 'recovered-%s' "$count" +printf '{"type":"item.completed","item":{"type":"agent_message","text":"recovered-%s"}}\n' "$count" "#, ); @@ -1376,7 +1422,7 @@ if [ -f "$count_file" ]; then fi count=$((count + 1)) printf '%s' "$count" > "$count_file" -printf 'effect-key-%s' "$count" +printf '{"type":"item.completed","item":{"type":"agent_message","text":"effect-key-%s"}}\n' "$count" "#, ); @@ -1452,7 +1498,7 @@ if [ -f "$count_file" ]; then fi count=$((count + 1)) printf '%s' "$count" > "$count_file" -printf 'external-effect-%s' "$count" +printf '{"type":"item.completed","item":{"type":"agent_message","text":"external-effect-%s"}}\n' "$count" "#, ); @@ -1556,7 +1602,7 @@ count=$((count + 1)) printf '%s' "$count" > "$count_file" printf 'started' > "$STARTED_FIFO" read _ < "$RELEASE_FIFO" -printf 'adopted-%s' "$count" +printf '{"type":"item.completed","item":{"type":"agent_message","text":"adopted-%s"}}\n' "$count" "#, ); @@ -1748,7 +1794,7 @@ if [ "$count" -eq 1 ]; then printf 'started' > "$STARTED_FIFO" while :; do sleep 1; done fi -printf 'redrive-%s' "$count" +printf '{"type":"item.completed","item":{"type":"agent_message","text":"redrive-%s"}}\n' "$count" "#, ); @@ -1866,7 +1912,7 @@ if [ "$count" -eq 1 ]; then fi printf 'started' > "$REDRIVE_STARTED_FIFO" sleep 4 -printf 'redrive-%s' "$count" +printf '{"type":"item.completed","item":{"type":"agent_message","text":"redrive-%s"}}\n' "$count" "#, ); @@ -1976,7 +2022,7 @@ count=$((count + 1)) printf '%s' "$count" > "$count_file" printf 'started' > "$STARTED_FIFO" read _ < "$RELEASE_FIFO" -printf 'race-%s' "$count" +printf '{"type":"item.completed","item":{"type":"agent_message","text":"race-%s"}}\n' "$count" "#, ); @@ -2074,10 +2120,10 @@ fn codex_runs_reads_running_adoption_record_without_status_log() { &bin_dir, r#"#!/bin/sh cat >/dev/null -printf 'adoption-live-tail\n' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"adoption-live-tail"}}' printf 'started' > "$STARTED_FIFO" read _ < "$RELEASE_FIFO" -printf 'adoption-done' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"adoption-done"}}' "#, ); @@ -2128,7 +2174,7 @@ printf 'adoption-done' let running: Table = status.get("running").unwrap(); if running.raw_len() == 1 { let item: Table = running.get(1).unwrap(); - if item.get::("output_tail").unwrap() == "adoption-live-tail\n" { + if item.get::("output_tail").unwrap() == "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"adoption-live-tail\"}}\n" { active = Some(item); break; } @@ -2156,7 +2202,7 @@ printf 'adoption-done' .ends_with('Z')); assert_eq!( active.get::("output_tail").unwrap(), - "adoption-live-tail\n" + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"adoption-live-tail\"}}\n" ); assert!(tmp .path() @@ -2169,7 +2215,7 @@ printf 'adoption-done' write_fifo(&release_fifo, "go\n"); assert_eq!( recv_result(&result_rx, "adopted status result"), - "adoption-live-tail\nadoption-done" + "adoption-done" ); worker_thread.join().unwrap(); } @@ -2183,7 +2229,7 @@ fn spawn_codex_sync_records_minimal_completed_status() { &bin_dir, r#"#!/bin/sh cat >/dev/null -printf 'sensitive output body' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"sensitive output body"}}' "#, ); @@ -2219,7 +2265,7 @@ printf 'sensitive output body' assert_eq!(item.get::("exit_code").unwrap(), 0); assert_eq!( item.get::("output_tail").unwrap(), - "sensitive output body" + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"sensitive output body\"}}\n" ); assert!(item.get::("run_id").unwrap().starts_with("codex-")); assert!(item.get::("started_at_ms").unwrap() > 0); @@ -2259,7 +2305,7 @@ fn spawn_codex_running_status_is_queryable_before_exit() { cat >/dev/null printf 'started' > "$STARTED_FIFO" read _ < "$RELEASE_FIFO" -printf 'done' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"done"}}' "#, ); @@ -2320,7 +2366,7 @@ fn codex_runs_retains_last_fifty_completed_runs() { &bin_dir, r#"#!/bin/sh cat >/dev/null -printf 'ok' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}' "#, ); @@ -2363,7 +2409,7 @@ fn spawn_codex_sync_keeps_codex_permit_and_prepares_rate_shims() { &bin_dir, r#"#!/bin/sh cat >/dev/null -printf 'codex-ok' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"codex-ok"}}' "#, ); @@ -2538,7 +2584,7 @@ fn spawn_codex_sync_prunes_aged_codex_logs_and_retains_fresh_logs() { &bin_dir, r#"#!/bin/sh cat >/dev/null -printf 'ok' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}' "#, ); @@ -2590,7 +2636,7 @@ fn spawn_codex_sync_prunes_oldest_codex_logs_to_size_budget() { &bin_dir, r#"#!/bin/sh cat >/dev/null -printf 'ok' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}' "#, ); @@ -2638,7 +2684,7 @@ fn spawn_codex_sync_respects_age_override_when_pruning_codex_logs() { &bin_dir, r#"#!/bin/sh cat >/dev/null -printf 'ok' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}' "#, ); @@ -2683,7 +2729,7 @@ fn spawn_codex_returns_handle_before_child_finishes() { cat >/dev/null printf '%s' "$$" > "$PGID_FIFO" read _ < "$RELEASE_FIFO" -printf 'released' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"released"}}' "#, ); @@ -2729,7 +2775,7 @@ read prompt if [ "$prompt" = "slow" ]; then read _ < "$RELEASE_SLOW" fi -printf '%s' "$prompt" +printf '{"type":"item.completed","item":{"type":"agent_message","text":"%s"}}\n' "$prompt" "#, ); @@ -2769,7 +2815,7 @@ if [ "$prompt" = "bad" ]; then printf 'bad-stderr' >&2 exit 7 fi -printf 'ok-%s' "$prompt" +printf '{"type":"item.completed","item":{"type":"agent_message","text":"ok-%s"}}\n' "$prompt" "#, ); @@ -2805,7 +2851,7 @@ fn await_all_rejects_reused_or_foreign_handle() { &bin_dir, r#"#!/bin/sh cat >/dev/null -printf 'ok' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}' "#, ); @@ -2870,7 +2916,7 @@ fn spawn_codex_holds_permit_until_child_exit() { cat >/dev/null printf 'started' > "$STARTED_FIFO" read _ < "$RELEASE_FIFO" -printf 'done' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"done"}}' "#, ); diff --git a/crates/fkst-framework/tests/test_runner_cli.rs b/crates/fkst-framework/tests/test_runner_cli.rs index ac7cac3..7232a5c 100644 --- a/crates/fkst-framework/tests/test_runner_cli.rs +++ b/crates/fkst-framework/tests/test_runner_cli.rs @@ -2254,7 +2254,10 @@ return { end, test_02_codex_sync_uses_mock_and_records_prompt = function() - t.mock_command("codex exec", { stdout = "draft", exit_code = 0 }) + t.mock_command("codex exec", { + stdout = "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"draft\"}}\n", + exit_code = 0, + }) local result = spawn_codex_sync({ prompt = "write draft" }) t.eq(result.stdout, "draft") t.eq(result.stderr, "") @@ -2362,7 +2365,10 @@ return { end, test_11_spawn_codex_await_all_uses_mock = function() - t.mock_command("codex exec", { stdout = "async draft", exit_code = 0 }) + t.mock_command("codex exec", { + stdout = "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"async draft\"}}\n", + exit_code = 0, + }) local handle = spawn_codex({ prompt = "async prompt" }) local results = await_all({ handle }) t.eq(results[1].stdout, "async draft") @@ -2439,7 +2445,10 @@ end return { test_01_records_mocked_codex_status = function() - t.mock_command("codex exec", { stdout = "done", exit_code = 0 }) + t.mock_command("codex exec", { + stdout = "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"done\"}}\n", + exit_code = 0, + }) local result = spawn_codex_sync({ prompt = "record top-level test status", dedup_key = "top-level-test-a", @@ -2499,7 +2508,7 @@ fn test_runner_records_and_replays_external_command_cassettes() { let codex = bin_dir.join("codex"); fs::write( &codex, - "#!/bin/sh\ncat >/dev/null\nprintf 'codex-secret-token'\n", + "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' '{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"codex-secret-token\"}}'\n", ) .unwrap(); #[cfg(unix)] @@ -2553,7 +2562,7 @@ return { local calls = t.command_calls() t.eq(#calls, 2) t.eq(calls[1].stdout, "") - t.eq(calls[2].stdout, "codex-") + t.eq(calls[2].stdout, "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"codex-\"}}\n") end, test_03_replay_mismatch_fails_closed = function()