diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 00a9f957f..60667bb5d 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -239,6 +239,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { ); CodexStatus::Running } + ServerNotification::Warning(notification) => self.process_warning(notification.message), ServerNotification::Error(notification) => { eprintln!( "{} {}", diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 89e41eacb..79bf88eaa 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -430,6 +430,11 @@ impl EventProcessorWithJsonOutput { })); CodexStatus::Running } + ServerNotification::Warning(notification) => { + let warning = self.collect_warning(notification.message); + events.extend(warning.events); + warning.status + } ServerNotification::Error(notification) => { let message = match notification.error.additional_details { Some(details) if !details.is_empty() => { diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index b8b0267fc..f8521071e 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -59,6 +59,33 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { ); } +#[test] +fn runtime_warning_emits_a_non_fatal_error_item() { + let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); + + let collected = processor.collect_thread_events(ServerNotification::Warning( + codex_app_server_protocol::WarningNotification { + thread_id: Some("thread-1".to_string()), + message: "invalid global instructions".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::Error(ErrorItem { + message: "invalid global instructions".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + #[test] fn mcp_tool_call_result_preserves_meta_in_jsonl_event() { let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index fbecfe562..5dce65d71 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1256,6 +1256,11 @@ fn should_process_notification( ) -> bool { match notification { ServerNotification::ConfigWarning(_) | ServerNotification::DeprecationNotice(_) => true, + // TODO(anp) resolve duplicate startup warnings + ServerNotification::Warning(notification) => notification + .thread_id + .as_deref() + .is_none_or(|candidate| candidate == thread_id), ServerNotification::Error(notification) => { notification.thread_id == thread_id && notification.turn_id == turn_id } diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 8b2f21498..de81f60d7 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -267,6 +267,35 @@ fn lagged_event_warning_message_is_explicit() { ); } +#[test] +fn runtime_warnings_are_filtered_to_the_primary_thread() { + let primary_thread_id = "thread-1"; + let turn_id = "turn-1"; + let outcomes = [ + codex_app_server_protocol::WarningNotification { + thread_id: None, + message: "global warning".to_string(), + }, + codex_app_server_protocol::WarningNotification { + thread_id: Some(primary_thread_id.to_string()), + message: "primary warning".to_string(), + }, + codex_app_server_protocol::WarningNotification { + thread_id: Some("thread-2".to_string()), + message: "other warning".to_string(), + }, + ] + .map(|warning| { + should_process_notification( + &ServerNotification::Warning(warning), + primary_thread_id, + turn_id, + ) + }); + + assert_eq!(outcomes, [true, true, false]); +} + #[tokio::test] async fn resume_lookup_model_providers_filters_only_last_lookup() { let codex_home = tempdir().expect("create temp codex home"); diff --git a/codex-rs/exec/tests/suite/agents_md.rs b/codex-rs/exec/tests/suite/agents_md.rs index 2891721ae..d2a53d88e 100644 --- a/codex-rs/exec/tests/suite/agents_md.rs +++ b/codex-rs/exec/tests/suite/agents_md.rs @@ -2,6 +2,8 @@ use core_test_support::responses; use core_test_support::test_codex_exec::test_codex_exec; +use predicates::prelude::PredicateBooleanExt; +use predicates::str::contains; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_includes_workspace_agents_md_in_request() -> anyhow::Result<()> { @@ -72,3 +74,71 @@ async fn exec_prefers_workspace_agents_override_md() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_surfaces_project_instruction_loading_warnings() -> anyhow::Result<()> { + let test = test_codex_exec(); + let project_agents_path = test.cwd_path().join("AGENTS.md"); + std::fs::write(&project_agents_path, b"project\xFFinstructions")?; + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + responses::mount_sse_once(&server, body).await; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("tell me something") + .assert() + .success() + .stderr(contains("invalid UTF-8").and(contains(project_agents_path.display().to_string()))); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_json_surfaces_project_instruction_loading_warnings() -> anyhow::Result<()> { + let test = test_codex_exec(); + let project_agents_path = test.cwd_path().join("AGENTS.md"); + std::fs::write(&project_agents_path, b"project\xFFinstructions")?; + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + responses::mount_sse_once(&server, body).await; + + let output = test + .cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("--json") + .arg("tell me something") + .assert() + .success() + .get_output() + .stdout + .clone(); + let events = String::from_utf8(output)? + .lines() + .map(serde_json::from_str::) + .collect::, _>>()?; + + assert!( + events.iter().any(|event| { + event["type"] == "item.completed" + && event["item"]["type"] == "error" + && event["item"]["message"].as_str().is_some_and(|message| { + message.contains("invalid UTF-8") + && message.contains(project_agents_path.display().to_string().as_str()) + }) + }), + "expected a JSONL warning event; observed: {events:?}" + ); + + Ok(()) +}