diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 50afd0c1dc..63d861ae44 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server/tests/suite/v2/guardian_v2_history_tests.rs b/codex-rs/app-server/tests/suite/v2/guardian_v2_history_tests.rs index 7f8ea10e22..a63d9e0cf2 100644 --- a/codex-rs/app-server/tests/suite/v2/guardian_v2_history_tests.rs +++ b/codex-rs/app-server/tests/suite/v2/guardian_v2_history_tests.rs @@ -56,9 +56,10 @@ use super::user_input_request_events; use super::wait_for_luna_request; #[derive(Clone, Copy)] -enum AnswerSize { +enum EvidenceSize { Normal, - Oversized, + OversizedAnswer, + OversizedInstruction, } #[derive(Clone, Copy)] @@ -67,25 +68,26 @@ enum ContextPath { ThreadOwned, } -#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("matching"), 0, AnswerSize::Normal; "compatible checkpoint")] -#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("different"), 0, AnswerSize::Normal; "incompatible checkpoint")] -#[test_case(ContextPath::ThreadOwned, Some("matching"), None, 0, AnswerSize::Normal; "unknown Luna compatibility")] -#[test_case(ContextPath::ThreadOwned, Some("matching"), Some(""), 0, AnswerSize::Normal; "empty Luna compatibility")] -#[test_case(ContextPath::ThreadOwned, None, Some("matching"), 0, AnswerSize::Normal; "unknown producer remains unknown after model switch")] -#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("matching"), 140, AnswerSize::Normal; "source call evicted")] -#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("matching"), 0, AnswerSize::Oversized; "incomplete answers reject fresh low score")] -#[test_case(ContextPath::Legacy, Some("matching"), Some("matching"), 0, AnswerSize::Normal; "legacy answers remain runtime only")] -#[test_case(ContextPath::Legacy, Some("matching"), Some("different"), 0, AnswerSize::Normal; "legacy incompatible checkpoint still samples")] -#[test_case(ContextPath::Legacy, Some("matching"), None, 0, AnswerSize::Normal; "legacy unknown Luna compatibility still samples")] -#[test_case(ContextPath::Legacy, Some("matching"), Some("matching"), 140, AnswerSize::Normal; "legacy source call evicted")] -#[test_case(ContextPath::Legacy, Some("matching"), Some("matching"), 0, AnswerSize::Oversized; "legacy answer truncation")] +#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("matching"), 0, EvidenceSize::OversizedInstruction; "instruction budget preserves fresh low score")] +#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("matching"), 0, EvidenceSize::Normal; "compatible checkpoint")] +#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("different"), 0, EvidenceSize::Normal; "incompatible checkpoint")] +#[test_case(ContextPath::ThreadOwned, Some("matching"), None, 0, EvidenceSize::Normal; "unknown Luna compatibility")] +#[test_case(ContextPath::ThreadOwned, Some("matching"), Some(""), 0, EvidenceSize::Normal; "empty Luna compatibility")] +#[test_case(ContextPath::ThreadOwned, None, Some("matching"), 0, EvidenceSize::Normal; "unknown producer remains unknown after model switch")] +#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("matching"), 140, EvidenceSize::Normal; "source call evicted")] +#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("matching"), 0, EvidenceSize::OversizedAnswer; "incomplete answers reject fresh low score")] +#[test_case(ContextPath::Legacy, Some("matching"), Some("matching"), 0, EvidenceSize::Normal; "legacy answers remain runtime only")] +#[test_case(ContextPath::Legacy, Some("matching"), Some("different"), 0, EvidenceSize::Normal; "legacy incompatible checkpoint still samples")] +#[test_case(ContextPath::Legacy, Some("matching"), None, 0, EvidenceSize::Normal; "legacy unknown Luna compatibility still samples")] +#[test_case(ContextPath::Legacy, Some("matching"), Some("matching"), 140, EvidenceSize::Normal; "legacy source call evicted")] +#[test_case(ContextPath::Legacy, Some("matching"), Some("matching"), 0, EvidenceSize::OversizedAnswer; "legacy answer truncation")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollback( context_path: ContextPath, parent_hash: Option<&str>, luna_hash: Option<&str>, tool_traffic: usize, - answer_size: AnswerSize, + evidence_size: EvidenceSize, ) -> Result<()> { skip_if_no_network!(Ok(())); @@ -94,9 +96,17 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac const SUMMARY: &str = "Repository inspection was summarized."; let compatible = parent_hash == Some("matching") && luna_hash == parent_hash; let requires_sync = matches!(context_path, ContextPath::ThreadOwned) && !compatible; - let answer = match answer_size { - AnswerSize::Normal => USER_INPUT_RESTRICTION.to_owned(), - AnswerSize::Oversized => "Never publish this repository publicly. ".repeat(200), + let oversized_instruction = matches!(evidence_size, EvidenceSize::OversizedInstruction); + let answer = match evidence_size { + EvidenceSize::Normal | EvidenceSize::OversizedInstruction => { + USER_INPUT_RESTRICTION.to_owned() + } + EvidenceSize::OversizedAnswer => "Never publish this repository publicly. ".repeat(200), + }; + let restriction = if oversized_instruction { + RESTRICTION.repeat(150) + } else { + RESTRICTION.to_owned() }; let expected_output = format!("\"echoed\":\"{EVIDENCE}\""); let parent_requests = Arc::new(Mutex::new(Vec::::new())); @@ -108,11 +118,11 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac "encrypted_content": "encrypted summary" }); let rejects_incomplete_score = matches!( - (context_path, answer_size), - (ContextPath::ThreadOwned, AnswerSize::Oversized) + (context_path, evidence_size), + (ContextPath::ThreadOwned, EvidenceSize::OversizedAnswer) ); let classifier = Arc::new(MockResponsesState { - luna_score: if rejects_incomplete_score || requires_sync { + luna_score: if rejects_incomplete_score || oversized_instruction || requires_sync { 0.0 } else { 1.0 @@ -270,7 +280,7 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac let thread_id = thread.id.clone(); for (index, prompt) in [ - RESTRICTION, + restriction.as_str(), "Recheck the repository.", "Inspect after resume.", "Inspect after partial rollback.", @@ -419,10 +429,14 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac .split_once(">>> TRANSCRIPT END") .expect("transcript end") .0; - assert!( - transcript.contains(prompt), - "current user input missing: {transcript}" - ); + if index == 0 && oversized_instruction { + assert!(content.contains("some retained user instructions are unavailable")); + } else { + assert!( + transcript.contains(prompt), + "current user input missing: {transcript}" + ); + } { let parent = parent_requests.lock().expect("request log lock"); @@ -527,12 +541,12 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac panic!("missing {consumer} answers at step {index}: {text}") }) .1; - match answer_size { - AnswerSize::Normal => { + match evidence_size { + EvidenceSize::Normal | EvidenceSize::OversizedInstruction => { assert!(answers.contains("assistant: Can I keep using the browser?")); assert!(answers.contains(&format!("user: {USER_INPUT_RESTRICTION}"))); } - AnswerSize::Oversized => match context_path { + EvidenceSize::OversizedAnswer => match context_path { ContextPath::ThreadOwned => { assert!( answers.contains("some verified user answers are unavailable") @@ -555,7 +569,7 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac } } classifier.allow_luna.notify_one(); - if rejects_incomplete_score || (index == 0 && requires_sync) { + if rejects_incomplete_score || oversized_instruction || (index == 0 && requires_sync) { // Wait for host publication, not merely a mock response. The next action must // see a fresh low score with unchanged authorization, rather than a stale score. let path = thread.path.as_ref().expect("legacy rollout path"); @@ -578,7 +592,14 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac }) .await?; } - if rejects_incomplete_score { + if rejects_incomplete_score || oversized_instruction { + let notice = match evidence_size { + EvidenceSize::OversizedInstruction => { + "some retained user instructions are unavailable" + } + EvidenceSize::OversizedAnswer => "some verified user answers are unavailable", + EvidenceSize::Normal => unreachable!("incomplete evidence fixture"), + }; let completed = app_server .start_turn_and_wait_for_completion(TurnStartParams { thread_id: thread_id.clone(), @@ -588,21 +609,19 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac .await?; assert_eq!(completed.turn.status, TurnStatus::Completed); let fresh_sample = wait_for_luna_request(&classifier, /*index*/ 2).await?; - assert!( - fresh_sample - .to_string() - .contains("some verified user answers are unavailable") - ); + assert!(fresh_sample.to_string().contains(notice)); let reviews = review_requests.lock().expect("request log lock"); assert_eq!( reviews.len(), - 2, - "an incomplete fresh score must still require sync review" + 1 + usize::from(rejects_incomplete_score), + "only incomplete verified answers require another sync review" ); assert!( - reviews[1] + reviews + .last() + .expect("initial sync review") .to_string() - .contains("some verified user answers are unavailable") + .contains(notice) ); classifier.allow_luna.notify_one(); break; diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 998c7e1cd4..34f281df9e 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -16,6 +16,7 @@ use codex_context_fragments::set_annotated_content; use codex_context_fragments::to_annotated_content; use codex_extension_api::ExtensionDataInit; use codex_features::Feature; +use codex_history::ResponseItemEnvelope; use codex_protocol::intersect_effective_permission_profiles; use codex_protocol::protocol::EnvironmentConfigState; use codex_utils_path_uri::PathUri; @@ -932,7 +933,19 @@ impl AgentControl { // Scrub inherited hints and replace only the parent's developer-instruction fragment. // Compaction stores response items separately, so sanitize both top-level messages and // compacted replacement histories with the same policy. - let retain_forked_item = |response_item: &mut ResponseItem, replaced: &mut bool| { + let retain_forked_item = |envelope: &mut ResponseItemEnvelope, replaced: &mut bool| { + if config.features.enabled(Feature::GuardianThreadContext) + && multi_agent_version == MultiAgentVersion::V2 + && matches!(&envelope.item, ResponseItem::Message { role, .. } if role == "user") + { + // Persist the scope of every inherited user message, including the suffix + // after a checkpoint. Resume must not recapture it as local authorization. + envelope + .metadata + .get_or_insert_default() + .inherited_user_message = true; + } + let response_item = &mut envelope.item; if matches!(response_item, ResponseItem::AgentMessage { .. }) { return false; } @@ -1016,7 +1029,12 @@ impl AgentControl { // Parent-local review evidence must not become the child's authorization. // Root user authorization is collected separately by the host. compacted.guardian_history = None; - compacted.retained_context = None; + // Only V2 fetches root authorization live. Its local scope starts known-empty; + // V1 must remain incomplete when inherited authorization has been stripped. + compacted.retained_context = + (config.features.enabled(Feature::GuardianThreadContext) + && multi_agent_version == MultiAgentVersion::V2) + .then(codex_history::RetainedContext::default); if let Some(replacement_history) = compacted.replacement_history.as_mut() { // Matches before this checkpoint cannot survive its replacement history. replaced_parent_developer_instructions = false; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index c7c605c256..61ebf719ba 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1385,9 +1385,17 @@ async fn spawn_agent_without_fork_from_paginated_parent_stays_fresh_and_paginate .expect("parent shutdown should submit"); } +#[test_case::test_case(true; "thread context enabled")] +#[test_case::test_case(false; "thread context disabled")] #[tokio::test] -async fn spawn_agent_fork_drops_inherited_token_usage_state() { - let harness = AgentControlHarness::new().await; +async fn spawn_agent_fork_drops_inherited_token_usage_state(thread_context_enabled: bool) { + let mut harness = AgentControlHarness::new().await; + let _ = harness.config.features.disable(Feature::MultiAgentV2); + harness + .config + .features + .set_enabled(Feature::GuardianThreadContext, thread_context_enabled) + .expect("test context mode"); let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await; let parent_usage = TokenUsage { total_tokens: 120, @@ -1445,6 +1453,15 @@ async fn spawn_agent_fork_drops_inherited_token_usage_state() { total_tokens: 80, ..TokenUsage::default() }; + assert!( + !child_thread + .session + .clone_history() + .await + .retained_context() + .user_messages_complete(), + "V1 forks lack complete retained authorization in both context modes" + ); let turn_context = child_thread.session.new_default_turn().await; child_thread .session @@ -2121,7 +2138,6 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history( ); let mut inherited_context = codex_history::RetainedContext::default(); if thread_context_enabled { - inherited_context.restore(/*checkpoint*/ None); inherited_context.reserve_order(); } else { inherited_context.mark_user_messages_incomplete(); diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index ab8cf48895..b2f6fd9a0c 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -87,6 +87,7 @@ struct SharedConversationHistory { items: Arc>, review_history: Option, retained_context: Arc, + expose_retained_context: bool, history_version: u64, user_message_revision: u64, } @@ -112,7 +113,8 @@ impl ConversationHistorySnapshot for SharedConversationHistory { } fn retained_context(&self) -> Option<&RetainedContext> { - Some(&self.retained_context) + self.expose_retained_context + .then_some(&self.retained_context) } fn review_items(&self) -> Box + Send + '_> { @@ -174,6 +176,7 @@ impl ContextManager { items: Arc::clone(&self.items), review_history: self.review_history.clone(), retained_context: Arc::clone(&self.retained_context), + expose_retained_context: self.retain_user_messages, history_version: self.history_version, user_message_revision: self.user_message_revision, }) @@ -329,7 +332,9 @@ impl ContextManager { } Arc::make_mut(&mut self.items).push(processed); if crate::context::is_user_authorization_message(item) { - if self.retain_user_messages + if !self.retain_user_messages { + Arc::make_mut(&mut self.retained_context).mark_user_messages_incomplete(); + } else if !metadata.is_some_and(|metadata| metadata.inherited_user_message) && let ResponseItem::Message { content, internal_chat_message_metadata_passthrough, @@ -365,8 +370,6 @@ impl ContextManager { }, metadata.and_then(|metadata| metadata.user_input_order), ); - } else { - Arc::make_mut(&mut self.retained_context).mark_user_messages_incomplete(); } self.user_message_revision = self.user_message_revision.saturating_add(1); } diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 281d4dba16..1e408783a9 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -496,6 +496,10 @@ pub(super) fn collect_guardian_context( struct GuardianReviewHistory<'a>(&'a dyn ConversationHistorySnapshot); impl SectionHistory for GuardianReviewHistory<'_> { + fn retained_context(&self) -> Option<&codex_history::RetainedContext> { + self.0.retained_context() + } + fn items(&self) -> Box + Send + '_> { self.0.review_items() } @@ -504,6 +508,10 @@ impl SectionHistory for GuardianReviewHistory<'_> { struct FilteredGuardianHistory<'a>(&'a dyn SectionHistory); impl SectionHistory for FilteredGuardianHistory<'_> { + fn retained_context(&self) -> Option<&codex_history::RetainedContext> { + self.0.retained_context() + } + fn items(&self) -> Box + Send + '_> { Box::new(self.0.items().filter(|item| { !matches!( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index f302a92a07..0847c59ce2 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8783,7 +8783,10 @@ where session_configuration.session_source.clone(), ); - let state = SessionState::new(session_configuration.clone()); + let mut state = SessionState::new(session_configuration.clone()); + if config.features.enabled(Feature::GuardianThreadContext) { + state.history.enable_user_message_retention(); + } let (environment_manager, resolved_turn_environments) = resolved_environments_for_configuration(&session_configuration, &default_environments) .await; diff --git a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs index 56f430d6a3..ddf261983f 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs @@ -196,16 +196,27 @@ async fn request_user_input_sets_non_blocking_outside_plan_mode( .expect("request_user_input handler should succeed"); assert!(output.success_for_logging()); let history = session.conversation_history_snapshot().await; - let RenderedVerifiedAnswers { - fragments, - complete, - } = codex_guardian_context::render_verified_answers( - history.retained_context().expect("host context snapshot"), - ); if thread_context_enabled { + let RenderedVerifiedAnswers { + fragments, + complete, + } = codex_guardian_context::render_verified_answers( + history.retained_context().expect("host context snapshot"), + ); + let expected_fragments = expected + .fragments + .iter() + .map(|fragment| { + if expected.complete { + format!("Retained source order: 0\n{fragment}") + } else { + fragment.clone() + } + }) + .collect::>(); assert_eq!( (&fragments, complete), - (&expected.fragments, expected.complete) + (&expected_fragments, expected.complete) ); } else { assert_eq!( @@ -239,7 +250,11 @@ async fn request_user_input_sets_non_blocking_outside_plan_mode( } _ => assert_eq!( legacy_answer, - fragments.first().cloned().filter(|_| complete) + expected + .fragments + .first() + .cloned() + .filter(|_| expected.complete) ), } } diff --git a/codex-rs/core/tests/suite/guardian_retained_context.rs b/codex-rs/core/tests/suite/guardian_retained_context.rs index 3c0c7b9442..75c4475fba 100644 --- a/codex-rs/core/tests/suite/guardian_retained_context.rs +++ b/codex-rs/core/tests/suite/guardian_retained_context.rs @@ -4,12 +4,18 @@ use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; use anyhow::Context; use anyhow::Result; use codex_core::CodexThread; use codex_core::TurnInputRequest; +use codex_core::config::Config; use codex_core::config::ThreadStoreConfig; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::ToolLifecycleContributor; +use codex_extension_api::ToolLifecycleFuture; +use codex_extension_api::ToolStartInput; use codex_features::Feature; use codex_history::InitialHistory; use codex_history::ResumedHistory; @@ -33,6 +39,7 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_function_call_with_namespace; +use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -44,7 +51,27 @@ use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; use serde_json::json; use test_case::test_case; +use tokio::sync::Notify; use wiremock::MockServer; +use wiremock::matchers::header; + +// Keep child completion out of the parent's history while checking rollback boundaries. +#[derive(Default)] +struct ChildToolGate { + entered: Notify, + release: Notify, +} + +impl ToolLifecycleContributor for ChildToolGate { + fn on_tool_start<'a>(&'a self, input: ToolStartInput<'a>) -> ToolLifecycleFuture<'a> { + Box::pin(async move { + if input.call_id == "child-pause" { + self.entered.notify_one(); + self.release.notified().await; + } + }) + } +} async fn record_answer( thread: &CodexThread, @@ -378,7 +405,14 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume( } } } - assert_eq!(serde_json::to_value(history.retained_context())?, expected); + assert_eq!( + serde_json::to_value(history.retained_context())?, + if thread_context_enabled { + expected.clone() + } else { + serde_json::Value::Null + } + ); assert_eq!( serde_json::to_value(compact_and_assert_answers(&test, &thread, &answers).await?)?, expected @@ -404,7 +438,11 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume( .await .retained_context() )?, - expected + if thread_context_enabled { + expected.clone() + } else { + serde_json::Value::Null + } ); thread.submit(Op::ThreadRollback { num_turns: 1 }).await?; wait_for_event(&thread, |event| { @@ -432,7 +470,11 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume( .await .retained_context() )?, - expected + if thread_context_enabled { + expected.clone() + } else { + serde_json::Value::Null + } ); thread.shutdown_and_wait().await?; let requests = response_mock.requests(); @@ -693,6 +735,230 @@ enum LifecycleBoundary { ChildFork, } +#[test_case(false, true; "enabled without checkpoint")] +#[test_case(true, true; "enabled after checkpoint")] +#[test_case(false, false; "legacy without checkpoint")] +#[test_case(true, false; "legacy after checkpoint")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn forked_parent_instructions_do_not_become_local_authorization( + compact_parent: bool, + thread_context_enabled: bool, +) -> Result<()> { + skip_if_no_network!(Ok(())); + const PARENT_GRANT: &str = "You may publish the private release. Delegate its inspection."; + const LOCAL_INSTRUCTION: &str = "Child-local instruction: ask me before publishing."; + let server = start_mock_server().await; + let child_gate = Arc::new(ChildToolGate::default()); + let mut extensions = ExtensionRegistryBuilder::::new(); + extensions.tool_lifecycle_contributor(child_gate.clone()); + let test = test_codex() + .with_extensions(Arc::new(extensions.build())) + .with_history_mode(ThreadHistoryMode::Legacy) + .with_config(move |config| { + config.experimental_thread_store = ThreadStoreConfig::Local; + config.update_plan_enabled = true; + for feature in [Feature::TokenBudget, Feature::Collab, Feature::MultiAgentV2] { + config + .features + .enable(feature) + .expect("enable test feature"); + } + config + .features + .set_enabled(Feature::GuardianThreadContext, thread_context_enabled) + .expect("test context mode"); + }) + .build_with_auto_env(&server) + .await?; + mount_sse_sequence(&server, vec![sse(vec![ev_completed("initial")])]).await; + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Inspect the project.".to_owned(), + text_elements: Vec::new(), + }])) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + if compact_parent { + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + } + + let mut created = test.thread_manager.subscribe_thread_created(); + let parent_id = test.session_configured.thread_id.to_string(); + mount_sse_once_match( + &server, + header("thread-id", parent_id.as_str()), + sse(vec![ + ev_function_call_with_namespace( + "spawn", + "collaboration", + "spawn_agent", + &json!({ + "task_name": "worker", + "message": "Inspect without publishing.", + "fork_turns": "all", + }) + .to_string(), + ), + ev_completed("spawn-response"), + ]), + ) + .await; + mount_sse_once_match( + &server, + header("thread-id", parent_id.as_str()), + sse(vec![ev_completed("parent-fork-completion")]), + ) + .await; + // Hold the child at a real tool boundary until rollback is checked. Its completion + // notification must not add a parent message or consume a parent mock response. + let child_requests = mount_sse_once_match( + &server, + move |request: &wiremock::Request| { + request + .headers + .get("thread-id") + .and_then(|value| value.to_str().ok()) + .is_some_and(|thread_id| thread_id != parent_id) + }, + sse(vec![ + ev_function_call( + "child-pause", + "update_plan", + &json!({"plan": [{"step": "Inspect the project", "status": "in_progress"}]}) + .to_string(), + ), + ev_completed("child-paused"), + ]), + ) + .await; + // This grant is either in uncompacted history or in the checkpoint's replay suffix. + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: PARENT_GRANT.to_owned(), + text_elements: Vec::new(), + }])) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + let child = test.thread_manager.get_thread(created.try_recv()?).await?; + tokio::time::timeout(Duration::from_secs(30), child_gate.entered.notified()) + .await + .context("child did not reach the paused tool call")?; + let child_id = child.session_configured().thread_id.to_string(); + let requests = child_requests.requests(); + let child_request = requests + .iter() + .find(|request| request.body_json()["client_metadata"]["thread_id"] == child_id) + .context("child model request")?; + assert!(serde_json::to_string(&child_request.input())?.contains(PARENT_GRANT)); + let history = child.conversation_history_snapshot().await; + assert_eq!( + history.retained_context().map(|context| ( + context.ordered_entries().count(), + context.user_messages_complete() + )), + thread_context_enabled.then_some((0, true)), + "inherited conversation must not populate child-local authorization", + ); + let root_before = child.guardian_root_snapshot().await.context("live root")?; + assert!( + root_before + .messages + .contains(&codex_core::GuardianRootMessage::User( + PARENT_GRANT.to_owned() + )) + ); + + test.codex + .submit(Op::ThreadRollback { num_turns: 1 }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ThreadRolledBack(_)) + }) + .await; + let root_after = child + .guardian_root_snapshot() + .await + .context("live root after rollback")?; + assert_ne!( + root_before.authorization_version, + root_after.authorization_version + ); + assert!( + !root_after + .messages + .contains(&codex_core::GuardianRootMessage::User( + PARENT_GRANT.to_owned() + )) + ); + + mount_sse_once_match( + &server, + header("thread-id", child_id.as_str()), + sse(vec![ev_completed("child-fork-completion")]), + ) + .await; + child_gate.release.notify_one(); + wait_for_event(&child, |event| matches!(event, EventMsg::TurnComplete(_))).await; + mount_sse_once_match( + &server, + header("thread-id", child_id.as_str()), + sse(vec![ev_completed("local-instruction")]), + ) + .await; + child + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: LOCAL_INSTRUCTION.to_owned(), + text_elements: Vec::new(), + }])) + .await?; + wait_for_event(&child, |event| matches!(event, EventMsg::TurnComplete(_))).await; + let history = child.conversation_history_snapshot().await; + let expected = history.retained_context().cloned(); + assert_eq!( + expected.as_ref().map(|context| context + .ordered_entries() + .map(|entry| match entry { + codex_history::RetainedContextEntry::UserMessage(message) => message.text.as_str(), + codex_history::RetainedContextEntry::VerifiedAnswer(_) => + panic!("unexpected answer"), + }) + .collect::>()), + thread_context_enabled.then_some(vec![LOCAL_INSTRUCTION]), + "genuine child-local instructions must still be captured", + ); + let child = resume(&test, &child).await?; + assert_eq!( + child + .conversation_history_snapshot() + .await + .retained_context(), + expected.as_ref() + ); + child.submit(Op::Compact).await?; + wait_for_event(&child, |event| matches!(event, EventMsg::TurnComplete(_))).await; + let child = resume(&test, &child).await?; + assert_eq!( + child + .conversation_history_snapshot() + .await + .retained_context(), + expected.as_ref() + ); + child.shutdown_and_wait().await?; + test.codex.shutdown_and_wait().await?; + Ok(()) +} + #[test_case(ThreadHistoryMode::Legacy, LifecycleBoundary::Rollback; "legacy rollback")] #[test_case(ThreadHistoryMode::Legacy, LifecycleBoundary::ChildFork; "legacy child fork")] #[test_case(ThreadHistoryMode::Paginated, LifecycleBoundary::ChildFork; "paginated child fork")] diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 112f64e08d..bd9f20dbc8 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -2760,7 +2760,8 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt( ); let history = test.codex.conversation_history_snapshot().await; - let retained = serde_json::to_value(history.retained_context())?; + assert_eq!(history.retained_context().is_some(), thread_context_enabled); + let retained = serde_json::to_value(history.retained_context().cloned().unwrap_or_default())?; assert_eq!( retained["user_messages"] .as_array() diff --git a/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs b/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs index 8942a384c5..6e60264f30 100644 --- a/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs +++ b/codex-rs/ext/extension-api/src/capabilities/conversation_history.rs @@ -18,6 +18,7 @@ pub trait ConversationHistorySnapshot: Send + Sync { fn items(&self) -> Box + Send + '_>; /// Host-owned retained facts captured atomically with the parent model window. + /// Legacy hosts can withhold these facts to preserve their existing reviewer policy. fn retained_context(&self) -> Option<&RetainedContext> { None } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs index e41367ef06..5079d7ace5 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs @@ -373,6 +373,23 @@ impl ExtensionMetrics for RecordingMetrics { } } +fn user_instruction(text: &str) -> ResponseItem { + ResponseItem::Message { + id: Some(ResponseItemId::new("msg")), + role: "user".to_owned(), + content: vec![ContentItem::InputText { + text: text.to_owned(), + }], + phase: None, + internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough { + content_item_kinds: Some(vec![codex_protocol::models::ContentItemKind( + "user.text".to_owned(), + )]), + ..Default::default() + }), + } +} + struct TestConversationHistory(Vec); struct TestRetainedHistory { @@ -1089,7 +1106,7 @@ async fn sample_configured_conversation_history_with_source( ) .await?; } - let conversation_history = TestConversationHistory(conversation_history); + let conversation_history = test.codex.conversation_history_snapshot().await; registry.tool_lifecycle_contributors()[0] .on_tool_start(ToolStartInput { @@ -1102,7 +1119,7 @@ async fn sample_configured_conversation_history_with_source( tool_name: &tool_name, mcp_tool: None, payload: &tool_payload, - conversation_history: Arc::new(conversation_history), + conversation_history, source, }) .await; @@ -1491,15 +1508,7 @@ max_tool_transcript_tokens = 128 max_recent_non_user_entries = 8 "#; let conversation_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_owned(), - content: vec![ContentItem::InputText { - text: "Review the pending action.".to_owned(), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }, + user_instruction("Review the pending action."), ResponseItem::Reasoning { id: None, summary: vec![ReasoningItemReasoningSummary::SummaryText { @@ -1812,6 +1821,8 @@ max_recent_non_user_entries = 8 async fn contributor_includes_transcript_images_by_default() -> Result<()> { skip_if_no_network!(Ok(())); + let user_image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGPgEpEDAABoAD1UCKP3AAAAAElFTkSuQmCC"; + let tool_image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGOQE+ECAACQAD304kFaAAAAAElFTkSuQmCC"; let history = vec![ ResponseItem::Message { id: None, @@ -1821,13 +1832,22 @@ async fn contributor_includes_transcript_images_by_default() -> Result<()> { text: "Review what is shown on screen.".to_owned(), }, ContentItem::InputImage { - image_url: "data:image/png;base64,user-screenshot".to_owned(), + image_url: user_image.to_owned(), detail: Some(ImageDetail::High), }, ], phase: None, internal_chat_message_metadata_passthrough: None, }, + ResponseItem::FunctionCall { + id: None, + name: "screenshot".to_owned(), + namespace: None, + arguments: "{}".to_owned(), + encrypted_function_args: None, + call_id: "previous-call".to_owned(), + internal_chat_message_metadata_passthrough: None, + }, ResponseItem::FunctionCallOutput { id: None, call_id: Some("previous-call".to_owned()), @@ -1838,8 +1858,8 @@ async fn contributor_includes_transcript_images_by_default() -> Result<()> { text: "Screenshot captured.".to_owned(), }, FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,tool-screenshot".to_owned(), - detail: Some(ImageDetail::Low), + image_url: tool_image.to_owned(), + detail: Some(ImageDetail::High), }, ]), internal_chat_message_metadata_passthrough: None, @@ -1866,11 +1886,11 @@ enabled = true [ json!({ "type": "input_image", - "image_url": "data:image/png;base64,user-screenshot", + "image_url": user_image, }), json!({ "type": "input_image", - "image_url": "data:image/png;base64,tool-screenshot", + "image_url": tool_image, }), ] ); @@ -1900,15 +1920,7 @@ async fn contributor_uses_model_defaults_and_preserves_local_overrides() -> Resu max_parent_compaction_tokens: Some(384), }; let conversation_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_owned(), - content: vec![ContentItem::InputText { - text: "Review the pending action.".to_owned(), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }, + user_instruction("Review the pending action."), ResponseItem::Reasoning { id: None, summary: vec![ReasoningItemReasoningSummary::SummaryText { @@ -2019,18 +2031,19 @@ async fn contributor_uses_model_defaults_and_preserves_local_overrides() -> Resu #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result<()> { + assert_luna_pool_context(/*thread_context_enabled*/ true).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn legacy_contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result<()> { + assert_luna_pool_context(/*thread_context_enabled*/ false).await +} + +async fn assert_luna_pool_context(thread_context_enabled: bool) -> Result<()> { skip_if_no_network!(Ok(())); let conversation_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_owned(), - content: vec![ContentItem::InputText { - text: "Inspect the repository guidelines.".to_owned(), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }, + user_instruction("Inspect the repository guidelines."), ResponseItem::Reasoning { id: None, summary: vec![ReasoningItemReasoningSummary::SummaryText { @@ -2067,10 +2080,12 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result< internal_chat_message_metadata_passthrough: None, }, ]; - let (request, test, registry) = sample_conversation_history( + let (request, test, registry) = sample_configured_conversation_history( conversation_history, r#"{"path":"README.md"}"#, Some(TEST_GUARDIAN_POLICY), + &format!("[features]\nguardian_thread_context = {thread_context_enabled}\n"), + /*model_defaults*/ None, ) .await?; let thread_id = test.session_configured.thread_id; @@ -2126,28 +2141,35 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result< }], }) ); - assert_eq!( - request["input"][2]["content"], - json!([ - {"type": "input_text", "text": ">>> TRANSCRIPT START\n"}, - {"type": "input_text", "text": "[1] user: Inspect the repository guidelines.\n"}, - {"type": "input_text", "text": "[2] tool list_dir call: {\"path\":\".\"}\n"}, - {"type": "input_text", "text": "[3] tool list_dir result: README.md\n"}, - {"type": "input_text", "text": "[4] tool read_file call: {\"path\":\"README.md\"}\n"}, - {"type": "input_text", "text": ">>> TRANSCRIPT END\n\n"}, - { - "type": "input_text", - "text": "The Codex agent has requested the following action:\n" - }, - {"type": "input_text", "text": ">>> APPROVAL REQUEST START\n"}, - {"type": "input_text", "text": "Planned action JSON:\n"}, - { - "type": "input_text", - "text": "{\n \"path\": \"README.md\",\n \"tool\": \"read_file\"\n}\n" - }, - {"type": "input_text", "text": ">>> APPROVAL REQUEST END\n"}, - ]) - ); + let mut expected_content = json!([ + {"type": "input_text", "text": ">>> RETAINED USER INSTRUCTIONS START\nHost: Retained source order labels across instructions and verified answers reflect original acceptance, not section order. Later instructions may revoke earlier grants.\n"}, + {"type": "input_text", "text": "Retained source order: 0\nuser: Inspect the repository guidelines.\n"}, + {"type": "input_text", "text": ">>> RETAINED USER INSTRUCTIONS END\n"}, + {"type": "input_text", "text": ">>> TRANSCRIPT START\n"}, + {"type": "input_text", "text": "[1] user: Inspect the repository guidelines.\n"}, + {"type": "input_text", "text": "[2] tool list_dir call: {\"path\":\".\"}\n"}, + {"type": "input_text", "text": "[3] tool list_dir result: README.md\n"}, + {"type": "input_text", "text": "[4] tool read_file call: {\"path\":\"README.md\"}\n"}, + {"type": "input_text", "text": ">>> TRANSCRIPT END\n\n"}, + { + "type": "input_text", + "text": "The Codex agent has requested the following action:\n" + }, + {"type": "input_text", "text": ">>> APPROVAL REQUEST START\n"}, + {"type": "input_text", "text": "Planned action JSON:\n"}, + { + "type": "input_text", + "text": "{\n \"path\": \"README.md\",\n \"tool\": \"read_file\"\n}\n" + }, + {"type": "input_text", "text": ">>> APPROVAL REQUEST END\n"}, + ]); + if !thread_context_enabled { + expected_content + .as_array_mut() + .expect("content array") + .drain(..3); + } + assert_eq!(request["input"][2]["content"], expected_content); let score = tokio::time::timeout(ASYNC_TEST_TIMEOUT, async { loop { if let Some(score) = thread_store.get::() { @@ -3154,15 +3176,7 @@ async fn assert_parent_compaction_reuse(thread_context_enabled: bool) -> Result< internal_chat_message_metadata_passthrough: None, }, latest_compaction.clone(), - ResponseItem::Message { - id: None, - role: "user".to_owned(), - content: vec![ContentItem::InputText { - text: "Inspect the repository guidelines.".to_owned(), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }, + user_instruction("Inspect the repository guidelines."), ]); Box::pin( @@ -3331,15 +3345,7 @@ async fn contributor_can_disable_parent_compaction_reuse() -> Result<()> { }; let conversation_history = vec![ oversized_compaction, - ResponseItem::Message { - id: None, - role: "user".to_owned(), - content: vec![ContentItem::InputText { - text: "Inspect the repository guidelines.".to_owned(), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }, + user_instruction("Inspect the repository guidelines."), ]; let configuration = "[features]\nguardian_thread_context = true\n\n[features.guardianv2]\nenabled = true\nreuse_parent_compaction = false\nmax_parent_compaction_tokens = 256\n"; let (request, test, _registry) = sample_configured_conversation_history( diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs b/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs index a29f263020..a510fd1718 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs @@ -311,6 +311,10 @@ impl TranscriptConfig { struct SnapshotHistory<'a>(&'a dyn ConversationHistorySnapshot); impl SectionHistory for SnapshotHistory<'_> { + fn retained_context(&self) -> Option<&codex_history::RetainedContext> { + self.0.retained_context() + } + fn items(&self) -> Box + Send + '_> { self.0.review_items() } diff --git a/codex-rs/guardian-context/src/composition.rs b/codex-rs/guardian-context/src/composition.rs index d6c53e0929..499e77139a 100644 --- a/codex-rs/guardian-context/src/composition.rs +++ b/codex-rs/guardian-context/src/composition.rs @@ -30,6 +30,7 @@ impl SectionRegistry { context.transcript.extend(items) } ContextSection::RootConversation { items } + | ContextSection::RetainedUserInstructions { items } | ContextSection::TrustedUserAnswers { items } => { context.authorization.extend(items) } diff --git a/codex-rs/guardian-context/src/lib.rs b/codex-rs/guardian-context/src/lib.rs index a36ce20538..609fbe55ad 100644 --- a/codex-rs/guardian-context/src/lib.rs +++ b/codex-rs/guardian-context/src/lib.rs @@ -16,6 +16,7 @@ use codex_protocol::models::ResponseItem; use authorization::RootConversationSection; use authorization::TrustedUserAnswersSection; +use retained_instructions::RetainedUserInstructionsSection; use transcript::ConversationTranscriptSection; pub use authorization::GuardianRootMessage; @@ -40,6 +41,8 @@ pub use verified_answers::RenderedVerifiedAnswers; pub use verified_answers::render_verified_answer; pub use verified_answers::render_verified_answers; +mod retained_instructions; + mod authorization; mod composition; mod entry; @@ -102,6 +105,11 @@ pub struct SectionInput<'a> { pub trait SectionHistory: Send + Sync { /// Returns borrowed response items in their original conversation order. fn items(&self) -> Box + Send + '_>; + + /// Bounded host-owned facts from the same snapshot as the current items. + fn retained_context(&self) -> Option<&codex_history::RetainedContext> { + None + } } impl SectionHistory for Vec { @@ -170,6 +178,7 @@ pub fn default_registry() -> &'static SectionRegistry { static REGISTRY: LazyLock = LazyLock::new(|| { let mut registry = SectionRegistry::default(); registry.register(RootConversationSection); + registry.register(RetainedUserInstructionsSection); registry.register(TrustedUserAnswersSection); registry.register(ConversationTranscriptSection); registry @@ -213,6 +222,9 @@ pub enum ContextSection { TrustedUserAnswers { items: Vec, }, + RetainedUserInstructions { + items: Vec, + }, } #[cfg(test)] diff --git a/codex-rs/guardian-context/src/retained_instructions.rs b/codex-rs/guardian-context/src/retained_instructions.rs new file mode 100644 index 0000000000..f0c6634090 --- /dev/null +++ b/codex-rs/guardian-context/src/retained_instructions.rs @@ -0,0 +1,70 @@ +//! Selects original user instructions independently of model compaction and transcript selection. +//! The host owns storage and lifecycle. Omitted records remain explicit; restrictions +//! are never truncated into partial permissions, and retained source order is preserved. +//! Section omissions do not change fast-approval eligibility. + +use codex_history::RetainedContext; +use codex_history::RetainedContextEntry; +use codex_protocol::protocol::TruncationPolicy; + +use crate::ContextSection; +use crate::GuardianRootMessage; +use crate::SectionContributor; +use crate::SectionError; +use crate::SectionInput; +use crate::SectionScope; + +const MAX_INSTRUCTION_TOKENS: usize = 900; + +/// Renders bounded originals even when transcript selection also includes their source messages. +/// Presence in parent history alone cannot prove complete delivery to a reviewer. +fn render_retained_instructions(context: &RetainedContext) -> Vec { + let mut complete = context.user_messages_complete(); + let mut fragments = Vec::new(); + for (order, entry) in context.ordered_entries().enumerate() { + let RetainedContextEntry::UserMessage(message) = entry else { + continue; + }; + let text = format!( + "Retained source order: {order}\n{}", + GuardianRootMessage::User(message.text.clone()).render() + ); + if message.complete + && text.len() <= TruncationPolicy::Tokens(MAX_INSTRUCTION_TOKENS).byte_budget() + { + fragments.push(text); + } else { + complete = false; + } + } + if !complete { + fragments.insert(/*index*/ 0, "Host notice: some retained user instructions are unavailable within the evidence budget. Do not treat remaining grants as complete authorization.\n".to_owned()); + } + fragments +} + +pub(crate) struct RetainedUserInstructionsSection; + +impl SectionContributor for RetainedUserInstructionsSection { + fn scope(&self) -> SectionScope { + SectionScope::Shared + } + + fn contribute(&self, input: &SectionInput<'_>) -> Result, SectionError> { + let Some(context) = input.history.retained_context() else { + return Ok(None); + }; + let rendered = render_retained_instructions(context); + if rendered.is_empty() { + return Ok(None); + } + let mut items = vec![">>> RETAINED USER INSTRUCTIONS START\nHost: Retained source order labels across instructions and verified answers reflect original acceptance, not section order. Later instructions may revoke earlier grants.\n".to_owned()]; + items.extend(rendered); + items.push(">>> RETAINED USER INSTRUCTIONS END\n".to_owned()); + Ok(Some(ContextSection::RetainedUserInstructions { items })) + } +} + +#[cfg(test)] +#[path = "retained_instructions_tests.rs"] +mod tests; diff --git a/codex-rs/guardian-context/src/retained_instructions_tests.rs b/codex-rs/guardian-context/src/retained_instructions_tests.rs new file mode 100644 index 0000000000..9e21ece663 --- /dev/null +++ b/codex-rs/guardian-context/src/retained_instructions_tests.rs @@ -0,0 +1,65 @@ +use super::*; +use codex_history::RetainedContextEvent; +use codex_history::RetainedUserMessage; +use codex_history::VerifiedAnswer; +use codex_history::VerifiedQuestionAnswer; +use pretty_assertions::assert_eq; + +#[test] +fn instructions_preserve_source_order_and_whole_records() { + let mut context = RetainedContext::default(); + // An answer at the existing 900-token byte limit still fits with order framing. + let answer = "x".repeat(3_600 - "assistant: Publish?\nuser: \n".len()); + context.record(&RetainedContextEvent::VerifiedAnswer { + answer: VerifiedAnswer { + turn_id: "grant".to_owned(), + call_id: "ask".to_owned(), + questions: vec![VerifiedQuestionAnswer { + question: "Publish?".to_owned(), + answer: answer.clone(), + }], + }, + acceptance_order: None, + }); + context.record_user_message( + RetainedUserMessage { + turn_id: "revocation".to_owned(), + message_id: Some("msg_revoke".to_owned()), + text: "Do not publish after all.".to_owned(), + complete: true, + }, + /*acceptance_order*/ None, + ); + let rendered = render_retained_instructions(&context); + assert_eq!( + rendered, + vec!["Retained source order: 1\nuser: Do not publish after all.\n"] + ); + let answers = crate::render_verified_answers(&context); + assert_eq!( + (answers.complete, answers.fragments), + ( + true, + vec![format!( + "Retained source order: 0\nassistant: Publish?\nuser: {answer}\n" + )] + ), + ); + context.record_user_message( + RetainedUserMessage { + turn_id: "oversized".to_owned(), + message_id: Some("msg_large".to_owned()), + text: "Permission is conditional. ".repeat(200), + complete: true, + }, + /*acceptance_order*/ None, + ); + let rendered = render_retained_instructions(&context); + assert_eq!(rendered.len(), 2); + assert!(rendered[0].starts_with("Host notice:")); + assert!( + !rendered + .iter() + .any(|fragment| fragment.contains("Permission is conditional")) + ); +} diff --git a/codex-rs/guardian-context/src/verified_answers.rs b/codex-rs/guardian-context/src/verified_answers.rs index 4357ea2f4d..95d192d60a 100644 --- a/codex-rs/guardian-context/src/verified_answers.rs +++ b/codex-rs/guardian-context/src/verified_answers.rs @@ -2,6 +2,7 @@ //! Omit a record atomically when it cannot fit; never truncate a restriction out of a grant. use codex_history::RetainedContext; +use codex_history::RetainedContextEntry; use codex_history::VerifiedAnswer; use crate::GuardianRootMessage; @@ -19,9 +20,13 @@ pub struct RenderedVerifiedAnswers { pub fn render_verified_answers(context: &RetainedContext) -> RenderedVerifiedAnswers { let mut complete = context.verified_answers_complete(); let mut fragments = Vec::new(); - for answer in context.verified_answers() { + for (order, entry) in context.ordered_entries().enumerate() { + let RetainedContextEntry::VerifiedAnswer(answer) = entry else { + continue; + }; if let Some(text) = render_verified_answer(answer) { - fragments.push(text); + // The bounded order label is framing, not part of the existing answer budget. + fragments.push(format!("Retained source order: {order}\n{text}")); } else { complete = false; } diff --git a/codex-rs/history/src/lib.rs b/codex-rs/history/src/lib.rs index 43d0231ee2..9eb1c9e4ec 100644 --- a/codex-rs/history/src/lib.rs +++ b/codex-rs/history/src/lib.rs @@ -66,6 +66,10 @@ pub struct CodexHarnessMetadata { /// Thread acceptance order, independent of when queued user input reaches model history. #[serde(default, skip_serializing_if = "Option::is_none")] pub user_input_order: Option, + + /// Copied parent context stays model-visible but must not become child-local authorization. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub inherited_user_message: bool, } impl ResponseItemEnvelope { diff --git a/codex-rs/history/src/tests.rs b/codex-rs/history/src/tests.rs index 825244db87..c9a6597201 100644 --- a/codex-rs/history/src/tests.rs +++ b/codex-rs/history/src/tests.rs @@ -79,6 +79,7 @@ fn response_item_envelope_stores_metadata_beside_rollout_payload() -> Result<()> metadata: Some(CodexHarnessMetadata { client_authored: true, fallback_token_limit_override: Some(20_000), + inherited_user_message: true, ..Default::default() }), }), @@ -92,7 +93,11 @@ fn response_item_envelope_stores_metadata_beside_rollout_payload() -> Result<()> "ordinal": 7, "type": "response_item", "payload": response_item, - "metadata": { "client_authored": true, "fallback_token_limit_override": 20_000 }, + "metadata": { + "client_authored": true, + "fallback_token_limit_override": 20_000, + "inherited_user_message": true, + }, }) ); assert_eq!(serialized["payload"].get("metadata"), None); @@ -106,6 +111,7 @@ fn response_item_envelope_stores_metadata_beside_rollout_payload() -> Result<()> Some(CodexHarnessMetadata { client_authored: true, fallback_token_limit_override: Some(20_000), + inherited_user_message: true, ..Default::default() }) );