diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 236ef471e6..e0a6dbb23d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3320,6 +3320,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", + "tracing", ] [[package]] diff --git a/codex-rs/app-server/tests/suite/v2/guardian_v2.rs b/codex-rs/app-server/tests/suite/v2/guardian_v2.rs index 1968418357..17cd0e16c1 100644 --- a/codex-rs/app-server/tests/suite/v2/guardian_v2.rs +++ b/codex-rs/app-server/tests/suite/v2/guardian_v2.rs @@ -33,6 +33,8 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_features::Feature; +use codex_state::StateRuntime; +use codex_utils_absolute_path::test_support::PathExt; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -623,6 +625,43 @@ async fn guardian_v2_routes_tool_approvals( ); } + if matches!(requirement, ModelReviewRequirement::Optional) { + let state_db = StateRuntime::init( + codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()), + "mock_provider".to_owned(), + ) + .await?; + // Exercise the same log export used by feedback/upload, including async + // classifier events that cannot rely on inheriting a thread tracing span. + let logs = timeout(TIMEOUT, async { + loop { + let logs = String::from_utf8( + state_db + .query_feedback_logs_for_threads(&[&reviewed_thread_id]) + .await?, + )?; + if logs.contains("Guardian V2 classification result") { + return anyhow::Ok(logs); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await??; + let expected = [ + "Guardian V2 classification result".to_owned(), + "call_id=guardian-action-0".into(), + format!("thread_id={reviewed_thread_id}"), + format!("action_risk={luna_score}"), + "review_threshold=0.5".into(), + "accepted=true".into(), + ]; + assert!( + logs.lines() + .any(|line| expected.iter().all(|field| line.contains(field))), + "missing feedback log with fields: {expected:?}" + ); + } + if matches!( lifecycle, ThreadLifecycle::RootRollback diff --git a/codex-rs/ext/guardian-v2/Cargo.toml b/codex-rs/ext/guardian-v2/Cargo.toml index 850b0d2d4a..ab54fd9fea 100644 --- a/codex-rs/ext/guardian-v2/Cargo.toml +++ b/codex-rs/ext/guardian-v2/Cargo.toml @@ -25,6 +25,7 @@ http = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["time"] } +tracing = { workspace = true } [dev-dependencies] anyhow = { workspace = true } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs index dd59c7e155..05f365c4ec 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs @@ -566,6 +566,7 @@ impl GuardianV2Extension { ); return; } + let call_id = input.call_id.to_owned(); let action = GuardianAction { tool_name: input.tool_name.clone(), payload: input.payload.clone(), @@ -741,12 +742,24 @@ impl GuardianV2Extension { scores, sampled_at: Some(sampled_at.into()), }; - if !thread - .thread_extension_data() - .insert_if(score.clone(), |previous| { - previous.is_none_or(|previous| previous.sampled_at < score.sampled_at) - }) - { + let accepted = + thread + .thread_extension_data() + .insert_if(score.clone(), |previous| { + previous.is_none_or(|previous| previous.sampled_at < score.sampled_at) + }); + tracing::info!( + %thread_id, + %turn_id, + %call_id, + tool_call_index, + action_risk = score.scores.get("action_risk").copied(), + review_threshold = guardian_config.review_threshold, + sampled_at = ?score.sampled_at, + accepted, + "Guardian V2 classification result" + ); + if !accepted { return Ok("superseded"); } score_progress