From 287e4f7dbf2e9002d8100bd3c5346f5c9f4cdaed Mon Sep 17 00:00:00 2001 From: felixxia-oai Date: Thu, 10 Sep 2026 15:47:12 +0000 Subject: [PATCH] Preserve Guardian authorization evidence until request budgeting (#44570) ## Why Transcript limits could shorten user instructions or omit later messages before Guardian evaluated the complete request budget, losing restrictions or prior approvals even when they would fit. ## What changed - Keep user messages and manual approvals complete and in source order through transcript collection and retention. - Preserve historical instructions while synchronous review can still compact history. If the final request still cannot fit, discard optional evidence before shortening older historical entries with truncation markers, preserving later restrictions where possible. - Warn that shortened instructions and approvals are incomplete and that missing evidence does not authorize actions. - Let asynchronous review defer to synchronous review when complete instructions exceed its budget. ## Testing Add coverage for complete instruction retention, approval and restriction ordering, marked truncation of oversized Unicode text, compaction before truncation, and asynchronous fallback when instructions do not fit. GitOrigin-RevId: e6159e876231347986b937ee7e07f944b83bfbe6 --- .../src/context/guardian_budget_omission.rs | 4 +- codex-rs/core/src/guardian/input_budget.rs | 5 +- .../core/src/guardian/input_budget_tests.rs | 6 +- codex-rs/core/src/guardian/tests.rs | 14 +- codex-rs/core/src/session/turn.rs | 17 +- .../tests/suite/guardian_context_budget.rs | 164 +++++++++++++++ .../src/async_scorer/budget_tests.rs | 73 ++++++- .../src/async_scorer/extension_tests.rs | 12 +- .../src/async_scorer/transcript_tests.rs | 198 +++++------------- codex-rs/guardian-context/src/composition.rs | 2 +- codex-rs/guardian-context/src/enforcement.rs | 86 +++++++- .../guardian-context/src/enforcement_tests.rs | 87 ++++++++ codex-rs/guardian-context/src/entry.rs | 6 +- codex-rs/guardian-context/src/lib.rs | 5 +- codex-rs/guardian-context/src/profile.rs | 55 ++--- .../guardian-context/src/profile/window.rs | 26 ++- .../guardian-context/src/profile_tests.rs | 10 +- codex-rs/guardian-context/src/retention.rs | 48 ----- .../guardian-context/src/retention_tests.rs | 48 ----- codex-rs/guardian-context/src/transcript.rs | 35 ++-- .../guardian-context/src/transcript_tests.rs | 9 +- 21 files changed, 575 insertions(+), 335 deletions(-) delete mode 100644 codex-rs/guardian-context/src/retention.rs delete mode 100644 codex-rs/guardian-context/src/retention_tests.rs diff --git a/codex-rs/core/src/context/guardian_budget_omission.rs b/codex-rs/core/src/context/guardian_budget_omission.rs index 430bd0cf36..49e9394a82 100644 --- a/codex-rs/core/src/context/guardian_budget_omission.rs +++ b/codex-rs/core/src/context/guardian_budget_omission.rs @@ -3,7 +3,7 @@ use super::ContextualUserFragment; use codex_protocol::models::ContentItemKind; -/// Identifies incomplete optional evidence without implying additional authority. +/// Identifies incomplete evidence without implying additional authority. pub struct GuardianBudgetOmission; impl ContextualUserFragment for GuardianBudgetOmission { @@ -27,6 +27,6 @@ impl ContextualUserFragment for GuardianBudgetOmission { } fn body(&self) -> String { - "Optional conversation evidence, tool descriptions, or images were omitted to fit the review input budget. Treat the remaining evidence as incomplete; omissions do not authorize actions.".to_owned() + "Conversation evidence, tool descriptions, or images were omitted or shortened to fit the review input budget. User instructions and prior approvals may be incomplete where marked. Do not infer authorization from missing evidence or treat a partial grant as overriding an omitted restriction.".to_owned() } } diff --git a/codex-rs/core/src/guardian/input_budget.rs b/codex-rs/core/src/guardian/input_budget.rs index 70df94beb9..13a9c27a12 100644 --- a/codex-rs/core/src/guardian/input_budget.rs +++ b/codex-rs/core/src/guardian/input_budget.rs @@ -5,6 +5,7 @@ use codex_features::Feature; use codex_guardian_context::ComposedContext; +use codex_guardian_context::HistoryTruncation; use codex_guardian_context::RequestBudget; use codex_guardian_context::effective_input_token_limit; use codex_protocol::config_types::ReasoningSummary; @@ -51,6 +52,7 @@ pub(crate) async fn check_pending(session: &Session, turn: &TurnContext) -> Code existing_context_tokens: minimum_prefix, }, GuardianBudgetOmission.render(), + HistoryTruncation::Allow, ) .map_err(|_error| { session @@ -67,6 +69,7 @@ pub(crate) async fn finalize( session: &Session, step: &StepContext, input: &mut [TurnInput], + history_truncation: HistoryTruncation, ) -> CodexResult<()> { let Some(pending) = session .services @@ -145,7 +148,7 @@ pub(crate) async fn finalize( ), }; let context = context - .enforce_budget(budget, GuardianBudgetOmission.render()) + .enforce_budget(budget, GuardianBudgetOmission.render(), history_truncation) .map_err(|error| { session .services diff --git a/codex-rs/core/src/guardian/input_budget_tests.rs b/codex-rs/core/src/guardian/input_budget_tests.rs index 6ce1bc28e7..cbe095f4b7 100644 --- a/codex-rs/core/src/guardian/input_budget_tests.rs +++ b/codex-rs/core/src/guardian/input_budget_tests.rs @@ -128,7 +128,11 @@ async fn finalization_overflow_marks_the_reviewer_exhausted() { .thread_extension_data .insert(PendingReviewContext(context)); - assert!(finalize(&session, &step, &mut input).await.is_err()); + assert!( + finalize(&session, &step, &mut input, HistoryTruncation::Preserve) + .await + .is_err() + ); assert!( session .services diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 9ecea33961..c47c2c299c 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1778,10 +1778,11 @@ fn build_guardian_transcript_preserves_recent_tool_context_when_user_history_is_ let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS); let (transcript, omission) = render_guardian_transcript_entries(&entries); - assert!( - transcript - .iter() - .any(|entry| entry.starts_with("[1] user: ")) + assert_eq!( + transcript[..8], + (1..=8) + .map(|index| format!("[{index}] user: {repeated}")) + .collect::>() ); assert!(transcript.iter().any(|entry| { entry.contains("tool shell call:") @@ -1794,10 +1795,7 @@ fn build_guardian_transcript_preserves_recent_tool_context_when_user_history_is_ .any(|entry| entry .contains("tool shell result: sandbox blocked outbound network access")) ); - assert_eq!( - omission, - Some("Some conversation entries were omitted.".to_string()) - ); + assert_eq!(omission, None); } enum GuardianTestCatalog { diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 497ca64dd9..5d5fa3a0aa 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -321,8 +321,13 @@ pub(crate) async fn run_turn( return Ok(None); } if crate::guardian::is_basic_session_source(&turn_context.session_source) - && let Err(error) = - crate::guardian::finalize_guardian_input(&sess, &first_step_context, &mut input).await + && let Err(error) = crate::guardian::finalize_guardian_input( + &sess, + &first_step_context, + &mut input, + codex_guardian_context::HistoryTruncation::Preserve, + ) + .await { // Token-budget compaction resets history, which can discard the evidence // referenced by a pending delta review. Leave budget failures unreusable. @@ -349,7 +354,13 @@ pub(crate) async fn run_turn( world_state = sess .record_context_updates_and_set_reference_context_item(first_step_context.as_ref()) .await?; - crate::guardian::finalize_guardian_input(&sess, &first_step_context, &mut input).await?; + crate::guardian::finalize_guardian_input( + &sess, + &first_step_context, + &mut input, + codex_guardian_context::HistoryTruncation::Allow, + ) + .await?; } let mut can_drain_pending_input = input.is_empty(); if run_hooks_and_record_inputs( diff --git a/codex-rs/core/tests/suite/guardian_context_budget.rs b/codex-rs/core/tests/suite/guardian_context_budget.rs index c8933c9096..29be5e7a59 100644 --- a/codex-rs/core/tests/suite/guardian_context_budget.rs +++ b/codex-rs/core/tests/suite/guardian_context_budget.rs @@ -9,6 +9,8 @@ use codex_core::config::CurrentTimeReminderConfig; use codex_core::config::RolloutBudgetConfig; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::AutoReviewMessages; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -30,6 +32,168 @@ use pretty_assertions::assert_eq; use serde_json::json; use test_case::test_case; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[test_case(160_000, &[0, 0]; "complete_instructions_fit")] +#[test_case(28_000, &[0, 1]; "oversized_history_compacts_then_truncates")] +#[test_case(16_000, &[1]; "first_review_can_shorten_oversized_instructions")] +async fn review_preserves_user_instructions_until_request_budgeting( + window: i64, + compactions_per_turn: &[usize], +) -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "Guardian approval actions require host-native paths" + ); + let server = responses::start_mock_server().await; + let test = test_codex() + .with_model_info_override("gpt-5.6-luna", move |model| { + model.context_window = Some(window); + }) + .with_model_info_override("gpt-5.5", |model| { + model.auto_review_model_override = Some("gpt-5.6-luna".to_owned()); + }) + .with_config(|config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + }) + .build_with_auto_env(&server) + .await?; + let command = json!({ + "cmd": "echo complete-instructions", + "sandbox_permissions": "require_escalated", + "justification": "Run the requested command." + }) + .to_string(); + let events = compactions_per_turn + .iter() + .enumerate() + .flat_map(|(turn, compactions)| { + let mut events = vec![ + sse(vec![ + ev_function_call(&format!("exec-{turn}"), "exec_command", &command), + ev_completed(&format!("parent-{turn}")), + ]), + sse(vec![ + ev_assistant_message( + "decision", + r#"{"risk_level":"low","user_authorization":"high","outcome":"allow"}"#, + ), + ev_completed(&format!("review-{turn}")), + ]), + sse(vec![ + ev_assistant_message("done", "done"), + ev_completed(&format!("done-{turn}")), + ]), + ]; + for _ in 0..*compactions { + events.insert( + /*index*/ 1, + sse(vec![ + json!({ + "type": "response.output_item.done", + "item": {"type": "compaction", "encrypted_content": "Earlier reviewer context."}, + }), + ev_completed("review-compaction"), + ]), + ); + } + events + }) + .collect(); + let responses = responses::mount_sse_sequence(&server, events).await; + let padding = "é🙂 ".repeat(/*n*/ 3_500); + let initial = format!( + "{padding}Run the requested echo command. You may edit scratch files only.{padding}" + ); + test.submit_text_turn(&initial).await?; + // This whole message exceeds the old transcript allowance. A following + // restriction must still reach the reviewer, with the original source order. + let followup = format!("{padding}{padding}Keep all files private.{padding}{padding}"); + let approval = format!( + "{}\nApproved action: {command}", + codex_guardian_context::MANUAL_APPROVAL_DEVELOPER_PREFIX + ); + let restriction = "Revoke permission to edit files. Only run the echo command."; + // V2 retains the first review's user input. The smallest window exercises + // first-review truncation only; the larger windows cover follow-up delivery. + if compactions_per_turn.len() == 2 { + test.codex + .inject_response_items(vec![ + responses::user_message_item(&followup), + ResponseItem::Message { + id: None, + role: "developer".to_owned(), + content: vec![ContentItem::InputText { + text: approval.clone(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }, + ]) + .await?; + test.submit_text_turn(restriction).await?; + } + let (compact_requests, requests): (Vec<_>, Vec<_>) = responses + .requests() + .into_iter() + .partition(|request| !request.inputs_of_type("compaction_trigger").is_empty()); + let reviews = requests + .iter() + .filter(|request| request.body_json()["client_metadata"]["x-openai-subagent"] == "guardian") + .collect::>(); + assert_eq!(reviews.len(), compactions_per_turn.len()); + let initial_inputs = reviews[0].message_input_text_groups("user"); + let initial_context = initial_inputs.last().expect("first review input").concat(); + if compactions_per_turn[0] == 0 { + assert!(initial_context.contains(&initial)); + } else { + assert!(initial_context.contains(" Result Result<( assert_catalog_budget(BudgetEvidence::Image).await } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn contributor_preserves_complete_user_instructions_or_defers_to_sync() -> Result<()> { + skip_if_no_network!(Ok(())); + assert_catalog_budget(BudgetEvidence::UserInstructions).await +} + async fn assert_catalog_budget(evidence: BudgetEvidence) -> Result<()> { let windows = match evidence { BudgetEvidence::Checkpoint => [18_000, 32_000], // In the smaller window the text fits, but the 10K image reservation cannot. BudgetEvidence::Image => [14_000, 40_000], + BudgetEvidence::UserInstructions => [18_000, 50_000], }; for (window, outcome) in windows .into_iter() @@ -97,8 +106,18 @@ async fn assert_catalog_budget(evidence: BudgetEvidence) -> Result<()> { ]), ) .await; - let instruction = "Inspect README.md; do not change files."; - let mut user = user_instruction(instruction); + let instruction = if matches!(evidence, BudgetEvidence::UserInstructions) { + let padding = "é🙂 ".repeat(/*n*/ 3_500); + format!("{padding}You may edit files only in the scratch directory.{padding}") + } else { + "Inspect README.md; do not change files.".to_owned() + }; + let restriction = "Revoke permission to edit files. Only read README.md."; + let approval = format!( + "{}\nApproved action: read_file README.md", + codex_guardian_context::MANUAL_APPROVAL_DEVELOPER_PREFIX + ); + let mut user = user_instruction(&instruction); let (checkpoint, commentary) = match evidence { BudgetEvidence::Checkpoint => ( Some(ResponseItem::ContextCompaction { @@ -125,9 +144,22 @@ async fn assert_catalog_budget(evidence: BudgetEvidence) -> Result<()> { }); (None, vec!["optional old commentary ".repeat(/*n*/ 500)]) } + BudgetEvidence::UserInstructions => (None, Vec::new()), }; let mut history = checkpoint.iter().cloned().collect::>(); history.push(user); + if matches!(evidence, BudgetEvidence::UserInstructions) { + history.push(ResponseItem::Message { + id: None, + role: "developer".to_owned(), + content: vec![ContentItem::InputText { + text: approval.clone(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }); + history.push(user_instruction(restriction)); + } history.extend(commentary.into_iter().map(|text| ResponseItem::Message { id: None, role: "assistant".to_owned(), @@ -146,9 +178,32 @@ async fn assert_catalog_budget(evidence: BudgetEvidence) -> Result<()> { current: TestConversationHistory(history), compaction_model_hash: Some("budget-checkpoint".to_owned()), }), - BudgetEvidence::Image => fixture.test.codex.conversation_history_snapshot().await, + BudgetEvidence::Image | BudgetEvidence::UserInstructions => { + fixture.test.codex.conversation_history_snapshot().await + } }; let thread_store = fixture.test.codex.thread_extension_data(); + if matches!(evidence, BudgetEvidence::UserInstructions) { + thread_store.insert(SecurityRiskScore { + scores: BTreeMap::from([("action_risk".to_owned(), 0.25)]), + call_id: None, + action: None, + sampled_at: None, + }); + let progress = thread_store.get::().unwrap(); + let authorization = ScoreAuthorization::current(&fixture.test.codex).await; + *progress.authorization.lock().unwrap() = Some(authorization); + assert_eq!( + cached_approval( + &fixture.registry, + thread_store, + "review action", + /*metrics*/ None + ) + .await, + Some(ReviewDecision::Approved) + ); + } let turn_store = ExtensionData::new("turn-1"); let tool_name = ToolName::plain("read_file"); let payload = ToolPayload::Function { @@ -192,16 +247,22 @@ async fn assert_catalog_budget(evidence: BudgetEvidence) -> Result<()> { } }) .await?; - let request = response.single_request().body_json(); + let request = response.single_request(); + let text = request.message_input_texts("user").concat(); + let request = request.body_json(); let input = request["input"].to_string(); if let Some(checkpoint) = checkpoint { assert_eq!(request["input"][2], serde_json::to_value(checkpoint)?); assert!(input.contains("commentary 0:")); - } else { + } else if matches!(evidence, BudgetEvidence::Image) { assert!(input.contains("input_image")); assert!(input.contains("optional old commentary")); + } else { + assert!(text.contains(&format!( + "[1] user: {instruction}\n[2] developer: {approval}\n[3] user: {restriction}\n" + ))); } - assert!(input.contains(instruction)); + assert!(text.contains(&instruction)); assert!(input.contains("read_file")); assert_eq!( cached_approval( 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 c6c05509ec..dc5469ae74 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 @@ -2825,9 +2825,12 @@ async fn contributor_sends_compacted_conversation_history_to_luna() -> Result<() .filter_map(|entry| entry["text"].as_str()) .collect::>(); - assert!(entries.iter().any(|entry| entry.contains("user turn 0:"))); - assert!(entries.iter().any(|entry| entry.contains("user turn 7:"))); - assert!(!entries.iter().any(|entry| entry.contains("user turn 1:"))); + for index in 0..8 { + assert!(entries.iter().any(|entry| entry.contains(&format!( + "user turn {index}: {}", + "authorization ".repeat(/*n*/ 1_000) + )))); + } assert!( entries .iter() @@ -2876,6 +2879,9 @@ async fn contributor_sends_compacted_conversation_history_to_luna() -> Result<() for entry in entries.into_iter().filter(|entry| entry.starts_with('[')) { let (label, text) = entry.split_once(": ").expect("numbered transcript entry"); + if label.ends_with(" user") { + continue; + } let max_tokens = if label.contains("tool ") { MAX_TOOL_ENTRY_TOKENS } else { diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs index 1dd594451c..ed1bc9ab6a 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs @@ -222,7 +222,7 @@ fn transcript_keeps_conversation_and_configured_sources() { } #[test] -fn transcript_truncates_oversized_entries_without_splitting_characters() { +fn transcript_truncates_oversized_assistant_entries_without_splitting_characters() { let prefix = "start é"; let suffix = "é end"; let oversized_message = format!( @@ -231,24 +231,8 @@ fn transcript_truncates_oversized_entries_without_splitting_characters() { ); let original_bytes = oversized_message.len(); let items = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: oversized_message, - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }, - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "latest response".to_string(), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }, + assistant_message(oversized_message, MessagePhase::FinalAnswer), + assistant_message("latest response", MessagePhase::FinalAnswer), ]; let mut rendered = TranscriptConfig::default() @@ -268,14 +252,14 @@ fn transcript_truncates_oversized_entries_without_splitting_characters() { let transcript = rendered.transcript_entries(); assert_eq!(transcript.len(), 2); - let user_entry = &transcript[0]; - assert!(user_entry.starts_with(&format!("[1] user: {prefix}"))); - assert!(user_entry.contains(">(), vec![( - "transcript_user", + "transcript_message", original_bytes, - user_entry.len() - "[1] user: \n".len() + entry.len() - "[1] assistant: \n".len() )] ); } -#[test] -fn transcript_preserves_first_and_latest_user_messages_and_recent_history() { - let oversized_user_message = "authorization ".repeat(1_000); - let mut items = (0..8) - .map(|index| ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("user turn {index}: {oversized_user_message}"), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }) - .collect::>(); - items.push(ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "Most recent assistant context.".to_string(), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }); - - let transcript = TranscriptConfig::default() - .build_context(ContextInput { - target: ContextTarget::Async, - history: &TestConversationHistory(&items), - root_conversation: &[], - trusted_user_answers: &[], - planned_action: None, - previous_reviews: None, - trusted_tool: None, - trusted_skill_paths: &[], - node_repl_images: None, - }) - .expect("collect transcript") - .transcript_entries(); - - assert!(transcript[0].starts_with("[1] user: user turn 0:")); - assert!( - transcript - .iter() - .any(|entry| entry.starts_with("[8] user: user turn 7:")) - ); - assert!( - !transcript - .iter() - .any(|entry| entry.contains("user turn 1:")) - ); - assert!( - transcript - .iter() - .any(|entry| entry.contains("user turn 6:")) - ); - assert_eq!( - transcript.last().map(String::as_str), - Some("[9] assistant: Most recent assistant context.\n") - ); - assert!( - transcript - .iter() - .map(|entry| TruncationPolicy::Bytes(entry.len()).token_budget()) - .sum::() - <= MAX_MESSAGE_TRANSCRIPT_TOKENS - ); -} - #[test] fn transcript_preserves_user_restrictions_before_final_assistant_messages() { let first_user_entry = "[1] user: Find a flight to New York.\n"; @@ -1190,62 +1106,52 @@ fn transcript_preserves_outputs_with_call_ids_or_explicit_names() { #[test] fn configured_reasoning_counts_against_message_budget() { - let mut items = (0..8) - .map(|index| ResponseItem::Message { + for (repeats, include_reasoning) in [(200, true), (1_000, false)] { + let mut expected = Vec::new(); + let mut items = (0..8) + .map(|index| { + let text = format!("user turn {index}: {}", "authorization ".repeat(repeats)); + expected.push(format!("[{}] user: {text}\n", index + 1)); + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { text }], + phase: None, + internal_chat_message_metadata_passthrough: None, + } + }) + .collect::>(); + items.push(ResponseItem::Reasoning { id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("user turn {index}: {}", "authorization ".repeat(1_000)), + summary: vec![ReasoningItemReasoningSummary::SummaryText { + text: "Recent reasoning evidence.".to_string(), }], - phase: None, + content: None, + encrypted_content: None, internal_chat_message_metadata_passthrough: None, + }); + if include_reasoning { + expected.push("[9] reasoning: Recent reasoning evidence.\n".to_owned()); + } + let transcript = TranscriptConfig { + sources: vec![TranscriptSource::Reasoning], + ..TranscriptConfig::default() + } + .build_context(ContextInput { + target: ContextTarget::Async, + history: &TestConversationHistory(&items), + root_conversation: &[], + trusted_user_answers: &[], + planned_action: None, + previous_reviews: None, + trusted_tool: None, + trusted_skill_paths: &[], + node_repl_images: None, }) - .collect::>(); - items.push(ResponseItem::Reasoning { - id: None, - summary: vec![ReasoningItemReasoningSummary::SummaryText { - text: "Recent reasoning evidence.".to_string(), - }], - content: None, - encrypted_content: None, - internal_chat_message_metadata_passthrough: None, - }); - - let transcript = TranscriptConfig { - sources: vec![TranscriptSource::Reasoning], - ..TranscriptConfig::default() + .expect("collect transcript") + .transcript_entries(); + assert_eq!(transcript, expected); } - .build_context(ContextInput { - target: ContextTarget::Async, - history: &TestConversationHistory(&items), - root_conversation: &[], - trusted_user_answers: &[], - planned_action: None, - previous_reviews: None, - trusted_tool: None, - trusted_skill_paths: &[], - node_repl_images: None, - }) - .expect("collect transcript") - .transcript_entries(); - - assert!(transcript[0].contains("user turn 0:")); - assert!( - transcript - .iter() - .any(|entry| entry.contains("user turn 7:")) - ); - assert_eq!( - transcript.last().map(String::as_str), - Some("[9] reasoning: Recent reasoning evidence.\n") - ); - assert!( - transcript - .iter() - .map(|entry| TruncationPolicy::Bytes(entry.len()).token_budget()) - .sum::() - <= MAX_MESSAGE_TRANSCRIPT_TOKENS - ); } #[test] diff --git a/codex-rs/guardian-context/src/composition.rs b/codex-rs/guardian-context/src/composition.rs index 04aa6bae56..29c7427870 100644 --- a/codex-rs/guardian-context/src/composition.rs +++ b/codex-rs/guardian-context/src/composition.rs @@ -25,7 +25,7 @@ pub enum ContextPresentation<'a> { Async, } -/// Host-selected, already bounded transcript entries and omission notice. +/// Host-selected transcript entries and omission notice, before request admission. pub struct RenderedTranscript { pub items: Vec>, pub omission_note: Option, diff --git a/codex-rs/guardian-context/src/enforcement.rs b/codex-rs/guardian-context/src/enforcement.rs index 816a9dc10c..99c88dff12 100644 --- a/codex-rs/guardian-context/src/enforcement.rs +++ b/codex-rs/guardian-context/src/enforcement.rs @@ -1,11 +1,13 @@ //! Fits newly composed evidence into the remaining complete-request allowance. -//! Required evidence is never truncated. Optional content is removed in a stable -//! order, and a host-owned omission fragment is reserved before any removal. +//! Required action evidence is never truncated. Optional evidence leaves first; +//! hosts may shorten historical instructions after compaction cannot make room. +//! Every reduction reserves an omission notice and preserves source order. use std::collections::HashSet; use codex_protocol::models::ContentItem; use codex_protocol::models::ImageDetail; +use codex_protocol::protocol::TruncationPolicy; use crate::ComposedContext; use crate::RequestBudget; @@ -42,6 +44,13 @@ impl Budgeted { } } + pub(crate) fn historical(content: T) -> Self { + Self { + content, + retention: Retention::Historical, + } + } + pub(crate) fn optional(content: T, priority: BudgetPriority) -> Self { Self { content, @@ -62,9 +71,19 @@ impl std::fmt::Debug for Budgeted { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum Retention { Required, + Historical, Optional(BudgetPriority), } +/// Whether the host has reached the final attempt to fit historical evidence. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HistoryTruncation { + /// Preserve original instructions while the host can still compact history. + Preserve, + /// Shorten older historical entries only after optional evidence is exhausted. + Allow, +} + impl ComposedContext { /// Applies host image admission before aggregate selection, preserving section /// identity and each retained item's selection policy. @@ -77,12 +96,13 @@ impl ComposedContext { } } - /// Returns complete evidence that fits, or no context at all. The host owns - /// the bounded omission fragment and the existing reviewer history/checkpoint. + /// Fits evidence according to the host recovery phase, or returns no context. + /// The host owns the bounded omission fragment and existing reviewer history. pub fn enforce_budget( mut self, budget: RequestBudget, omission_notice: String, + history_truncation: HistoryTruncation, ) -> Result { let remaining = budget .max_input_tokens @@ -130,6 +150,62 @@ impl ComposedContext { remaining_items[section_index] = content.len(); } } + let mut history_truncated = false; + if history_truncation == HistoryTruncation::Allow && required_tokens > remaining { + // Source order makes older evidence yield first. Keep each source's + // label, both ends and the standard marker; later restrictions stay + // complete whenever older entries can supply the needed space. + 'history: for section in &mut self.sections { + let SectionDelivery::UserContent(content) = &mut section.delivery else { + continue; + }; + for item in content { + if item.retention != Retention::Historical { + continue; + } + let original_tokens = content_tokens(&item.content); + let ContentItem::InputText { text } = &mut item.content else { + continue; + }; + let target = original_tokens.saturating_sub(required_tokens - remaining); + let mut upper = TruncationPolicy::Bytes(text.len()).token_budget(); + let mut lower = 32.min(upper); + let mut shortened = crate::truncate_text(text, lower); + while lower < upper { + let mid = lower + (upper - lower).div_ceil(2); + let candidate = crate::truncate_text(text, mid); + if content_tokens(&ContentItem::InputText { + text: candidate.clone(), + }) <= target + { + lower = mid; + shortened = candidate; + } else { + upper = mid - 1; + } + } + let saved = + original_tokens.saturating_sub(content_tokens(&ContentItem::InputText { + text: shortened.clone(), + })); + if saved == 0 { + continue; + } + self.truncations.push(TruncationObservation { + component: section.id, + original_bytes: text.len(), + retained_bytes: shortened.len(), + }); + *text = shortened; + history_truncated = true; + required_tokens = required_tokens.saturating_sub(saved); + needed = needed.saturating_sub(saved); + if required_tokens <= remaining { + break 'history; + } + } + } + } // Evidence that cannot fit beside the required content and notice must // leave first, without evicting useful smaller entries on its behalf. let optional_allowance = @@ -162,7 +238,7 @@ impl ComposedContext { } needed = needed.saturating_sub(remove(section_index, index, tokens)); } - if removed.is_empty() { + if removed.is_empty() && !history_truncated { return Err(SectionError::EvidenceLimitExceeded { section: "request_budget", }); diff --git a/codex-rs/guardian-context/src/enforcement_tests.rs b/codex-rs/guardian-context/src/enforcement_tests.rs index 366e805035..893e3fea64 100644 --- a/codex-rs/guardian-context/src/enforcement_tests.rs +++ b/codex-rs/guardian-context/src/enforcement_tests.rs @@ -12,6 +12,86 @@ fn text(value: &str) -> ContentItem { } } +#[test] +fn recovery_shortens_older_history_only_after_optional_evidence() { + let older = format!("[1] user: {}original suffix", "é🙂\"\n".repeat(/*n*/ 6_000)); + let commentary = text(&"optional commentary ".repeat(/*n*/ 1_000)); + let approval = text("[3] developer: user approved this action"); + let restriction = text("[4] user: only modify scratch files"); + let action = text("complete action"); + let notice = SectionOutput { + id: "budget_omission", + delivery: SectionDelivery::UserContent(vec![Budgeted::required(text( + "evidence omitted or shortened", + ))]), + }; + let context = ComposedContext { + sections: vec![SectionOutput { + id: "conversation_transcript", + delivery: SectionDelivery::UserContent(vec![ + Budgeted::historical(text(&older)), + Budgeted::optional(commentary.clone(), BudgetPriority::Commentary), + Budgeted::historical(approval.clone()), + Budgeted::historical(restriction.clone()), + Budgeted::required(action.clone()), + ]), + }], + truncations: Vec::new(), + }; + for reduction in [0, 4_000] { + let available = context.estimated_tokens() - content_tokens(&commentary) + + section_tokens(¬ice) + - reduction; + let budget = RequestBudget { + max_input_tokens: available + 2_000, + existing_context_tokens: 2_000, + }; + if reduction > 0 { + assert!( + context + .clone() + .enforce_budget( + budget, + "evidence omitted or shortened".to_owned(), + HistoryTruncation::Preserve + ) + .is_err() + ); + } + let selected = context + .clone() + .enforce_budget( + budget, + "evidence omitted or shortened".to_owned(), + HistoryTruncation::Allow, + ) + .unwrap(); + assert!(selected.estimated_tokens() <= available); + let SectionDelivery::UserContent(content) = &selected.sections[0].delivery else { + panic!("expected user evidence") + }; + let ContentItem::InputText { text: retained } = &content[0].content else { + panic!("expected historical text") + }; + if reduction == 0 { + assert_eq!(retained, &older); + } else { + assert!(retained.starts_with("[1] user: ")); + assert!(retained.ends_with("original suffix")); + assert!(retained.contains(" TranscriptEntryKind::User, - ConversationTranscriptEntryKind::Developer - | ConversationTranscriptEntryKind::ProtectedAssistant => { + ConversationTranscriptEntryKind::Developer => { + TranscriptEntryKind::ManualApproval + } + ConversationTranscriptEntryKind::ProtectedAssistant => { TranscriptEntryKind::ProtectedMessage } ConversationTranscriptEntryKind::Assistant @@ -143,30 +146,27 @@ impl ContextProfile { } }) .collect::>(); - let user_messages = entries - .iter() - .enumerate() - .filter_map(|(index, entry)| { - (entry.kind == TranscriptEntryKind::User).then_some(UserMessageCost { - index, - tokens: entry.tokens, - }) - }) - .collect::>(); - let selection = - select_user_messages(&user_messages, self.retention.max_message_transcript_tokens); let mut included = vec![false; entries.len()]; - for index in selection.indices { - included[index] = true; + let mut authorization_tokens = 0usize; + for (index, entry) in entries.iter().enumerate() { + if matches!( + entry.kind, + TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval + ) { + included[index] = true; + authorization_tokens = authorization_tokens.saturating_add(entry.tokens); + } } match self.target { ContextTarget::Sync => { - let mut message_tokens = selection.tokens; + let mut message_tokens = authorization_tokens; let mut tool_tokens = 0usize; let mut retained_non_user_entries = 0; for (index, entry) in entries.iter().enumerate().rev() { - if entry.kind == TranscriptEntryKind::User - || retained_non_user_entries >= self.retention.max_recent_non_user_entries + if matches!( + entry.kind, + TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval + ) || retained_non_user_entries >= self.retention.max_recent_non_user_entries { continue; } @@ -189,7 +189,7 @@ impl ContextProfile { let available = self .retention .max_message_transcript_tokens - .saturating_sub(selection.tokens); + .saturating_sub(authorization_tokens); let mut window = TranscriptWindow::new(&entries, &self.retention, available); for index in 0..entries.len() { window.insert(index); @@ -209,9 +209,9 @@ impl ContextProfile { .filter_map(|(index, entry)| { let component = match entry.kind { TranscriptEntryKind::User => "transcript_user", - TranscriptEntryKind::ProtectedMessage | TranscriptEntryKind::Message => { - "transcript_message" - } + TranscriptEntryKind::ManualApproval + | TranscriptEntryKind::ProtectedMessage + | TranscriptEntryKind::Message => "transcript_message", TranscriptEntryKind::Tool => "transcript_tool", }; let retained_bytes = if included[index] { @@ -228,9 +228,10 @@ impl ContextProfile { } if included[index] { Some(match entry.kind { - TranscriptEntryKind::User | TranscriptEntryKind::ProtectedMessage => { - Budgeted::required(entry.text) + TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval => { + Budgeted::historical(entry.text) } + TranscriptEntryKind::ProtectedMessage => Budgeted::required(entry.text), TranscriptEntryKind::Message => { Budgeted::optional(entry.text, BudgetPriority::Commentary) } diff --git a/codex-rs/guardian-context/src/profile/window.rs b/codex-rs/guardian-context/src/profile/window.rs index f82173b56e..127b817ef8 100644 --- a/codex-rs/guardian-context/src/profile/window.rs +++ b/codex-rs/guardian-context/src/profile/window.rs @@ -66,8 +66,10 @@ impl<'a> TranscriptWindow<'a> { let kind = self.entries[index].kind; let tokens = self.entries[index].tokens; - if kind == TranscriptEntryKind::User - || self.max_entries == 0 + if matches!( + kind, + TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval + ) || self.max_entries == 0 || !self.make_room_for_token_budget(kind, tokens) || !self.make_room_for_entry_limit(kind) { @@ -91,7 +93,9 @@ impl<'a> TranscriptWindow<'a> { self.message_token_budget } TranscriptEntryKind::Tool => self.tool_token_budget, - TranscriptEntryKind::User => unreachable!("user entries were selected separately"), + TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval => { + unreachable!("user entries were selected separately") + } }; let protected_tokens = if kind == TranscriptEntryKind::Message { self.protected_messages.tokens @@ -111,7 +115,9 @@ impl<'a> TranscriptWindow<'a> { ], TranscriptEntryKind::Message => &[TranscriptEntryKind::Message], TranscriptEntryKind::Tool => &[TranscriptEntryKind::Tool], - TranscriptEntryKind::User => unreachable!("user entries were selected separately"), + TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval => { + unreachable!("user entries were selected separately") + } }; let entries = self.entries; @@ -201,7 +207,9 @@ impl<'a> TranscriptWindow<'a> { .tokens .saturating_add(self.ordinary_messages.tokens), TranscriptEntryKind::Tool => self.tools.tokens, - TranscriptEntryKind::User => unreachable!("user entries were selected separately"), + TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval => { + unreachable!("user entries were selected separately") + } }; retained_tokens.saturating_add(tokens) <= token_budget @@ -212,7 +220,9 @@ impl<'a> TranscriptWindow<'a> { TranscriptEntryKind::ProtectedMessage => &self.protected_messages, TranscriptEntryKind::Message => &self.ordinary_messages, TranscriptEntryKind::Tool => &self.tools, - TranscriptEntryKind::User => unreachable!("user entries were selected separately"), + TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval => { + unreachable!("user entries were selected separately") + } } } @@ -221,7 +231,9 @@ impl<'a> TranscriptWindow<'a> { TranscriptEntryKind::ProtectedMessage => &mut self.protected_messages, TranscriptEntryKind::Message => &mut self.ordinary_messages, TranscriptEntryKind::Tool => &mut self.tools, - TranscriptEntryKind::User => unreachable!("user entries were selected separately"), + TranscriptEntryKind::User | TranscriptEntryKind::ManualApproval => { + unreachable!("user entries were selected separately") + } } } } diff --git a/codex-rs/guardian-context/src/profile_tests.rs b/codex-rs/guardian-context/src/profile_tests.rs index bd89168067..3805939289 100644 --- a/codex-rs/guardian-context/src/profile_tests.rs +++ b/codex-rs/guardian-context/src/profile_tests.rs @@ -8,8 +8,8 @@ fn profiles_preserve_distinct_retention_and_original_numbering() { let entries = [ (ConversationTranscriptEntryKind::User, "inspect only"), ( - ConversationTranscriptEntryKind::Developer, - "approved action", + ConversationTranscriptEntryKind::ProtectedAssistant, + "proposed action", ), (ConversationTranscriptEntryKind::Assistant, "working"), ] @@ -30,7 +30,7 @@ fn profiles_preserve_distinct_retention_and_original_numbering() { (sync.items, sync.omission_note), ( vec![ - Budgeted::required("[8] user: inspect only".to_owned()), + Budgeted::historical("[8] user: inspect only".to_owned()), Budgeted::optional( "[10] assistant: working".to_owned(), BudgetPriority::Commentary @@ -43,8 +43,8 @@ fn profiles_preserve_distinct_retention_and_original_numbering() { (asynchronous.items, asynchronous.omission_note), ( vec![ - Budgeted::required("[1] user: inspect only\n".to_owned()), - Budgeted::required("[2] developer: approved action\n".to_owned()) + Budgeted::historical("[1] user: inspect only\n".to_owned()), + Budgeted::required("[2] assistant: proposed action\n".to_owned()) ], None, ), diff --git a/codex-rs/guardian-context/src/retention.rs b/codex-rs/guardian-context/src/retention.rs deleted file mode 100644 index 459db6138b..0000000000 --- a/codex-rs/guardian-context/src/retention.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Shared user-message selection, independent of rendering and non-user eviction. -//! -//! Callers supply costs including their own formatting. The first user message -//! remains an anchor even when it exceeds the budget, preserving existing behavior. - -/// Rendered cost and original transcript position of a genuine user message. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct UserMessageCost { - pub index: usize, - pub tokens: usize, -} - -/// Selected original indices and their total rendered token cost. -#[derive(Debug, Default, Eq, PartialEq)] -pub struct UserMessageSelection { - pub indices: Vec, - pub tokens: usize, -} - -/// Keeps the first and latest user messages, then fills from newest to oldest. -/// -/// Supply messages in conversation order with distinct original indices. The -/// returned indices are in selection order; callers render in conversation order. -/// Non-user evidence uses the remaining budget under the caller's own policy. -pub fn select_user_messages( - messages: &[UserMessageCost], - max_message_tokens: usize, -) -> UserMessageSelection { - let Some((first, remaining)) = messages.split_first() else { - return UserMessageSelection::default(); - }; - let mut selection = UserMessageSelection { - indices: vec![first.index], - tokens: first.tokens, - }; - // The latest message is considered first, including when it cannot fit. - for message in remaining.iter().rev() { - if selection.tokens.saturating_add(message.tokens) <= max_message_tokens { - selection.indices.push(message.index); - selection.tokens += message.tokens; - } - } - selection -} - -#[cfg(test)] -#[path = "retention_tests.rs"] -mod tests; diff --git a/codex-rs/guardian-context/src/retention_tests.rs b/codex-rs/guardian-context/src/retention_tests.rs deleted file mode 100644 index a5095b5cc0..0000000000 --- a/codex-rs/guardian-context/src/retention_tests.rs +++ /dev/null @@ -1,48 +0,0 @@ -use super::*; -use pretty_assertions::assert_eq; - -#[test] -fn user_selection_preserves_anchors_and_fills_from_newest() { - let messages = [ - UserMessageCost { - index: 2, - tokens: 4, - }, - UserMessageCost { - index: 5, - tokens: 3, - }, - UserMessageCost { - index: 8, - tokens: 6, - }, - UserMessageCost { - index: 10, - tokens: 2, - }, - ]; - for (budget, indices, tokens) in [ - (0, vec![2], 4), - (4, vec![2], 4), - (6, vec![2, 10], 6), - (9, vec![2, 10, 5], 9), - (12, vec![2, 10, 8], 12), - (15, vec![2, 10, 8, 5], 15), - ] { - assert_eq!( - select_user_messages(&messages, budget), - UserMessageSelection { indices, tokens }, - ); - } - assert_eq!( - select_user_messages(&[], /*max_message_tokens*/ 10), - UserMessageSelection::default(), - ); - assert_eq!( - select_user_messages(&messages[..1], /*max_message_tokens*/ 10), - UserMessageSelection { - indices: vec![2], - tokens: 4 - }, - ); -} diff --git a/codex-rs/guardian-context/src/transcript.rs b/codex-rs/guardian-context/src/transcript.rs index 3213a28112..9b341c42ff 100644 --- a/codex-rs/guardian-context/src/transcript.rs +++ b/codex-rs/guardian-context/src/transcript.rs @@ -1,7 +1,7 @@ //! Collects bounded conversation evidence before consumer-specific rendering. //! //! Both Guardian consumers receive the same role and tool-source attribution, -//! with per-entry caps applied before accumulation. Resolved context profiles +//! with complete user messages and capped non-user entries. Resolved context profiles //! apply aggregate retention after the host selects its full/delta slice. Tool outputs with a //! call ID retain their generic label when the call is unavailable. Outputs //! without a call ID require an explicit name. @@ -55,7 +55,7 @@ impl Default for ConversationTranscriptOptions { /// Per-entry caps resolved by the caller for the current review. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct TranscriptEntryLimits { - /// Cap for user, developer, assistant, and plaintext reasoning entries. + /// Cap for assistant and plaintext reasoning entries; user and manual approvals stay complete. pub message_tokens: usize, /// Cap for tool calls and ordinary tool outputs. pub tool_tokens: usize, @@ -66,14 +66,15 @@ pub struct TranscriptEntryLimits { /// Aggregate limits for retaining rendered transcript entries. /// /// Context profiles apply the sync or async selection rules using these limits. -/// Collection applies per-entry caps before profile retention. +/// User messages and manual approvals survive these soft limits; the complete +/// request budget can shorten them with markers after other recovery is exhausted. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct TranscriptRetentionConfig { /// Budget for rendered user, developer, assistant, and reasoning entries. pub max_message_transcript_tokens: usize, /// Separate budget for rendered tool calls and results. pub max_tool_transcript_tokens: usize, - /// Maximum retained entries other than user messages. + /// Maximum retained entries other than user messages and manual approvals. pub max_recent_non_user_entries: usize, } @@ -104,7 +105,7 @@ impl SectionContributor for ConversationTranscriptSection { /// Extracts bounded transcript entries without composing other context sections. /// -/// Entries preserve conversation order and role/tool attribution. Per-entry +/// Entries preserve conversation order and role/tool attribution. Non-user /// limits apply during collection; context profiles own aggregate retention. pub fn collect_transcript( history: &dyn SectionHistory, @@ -302,22 +303,28 @@ pub fn collect_transcript( if text.trim().is_empty() { continue; } - let token_cap = match &kind { - ConversationTranscriptEntryKind::User - | ConversationTranscriptEntryKind::Developer - | ConversationTranscriptEntryKind::Assistant + let original_bytes = text.len(); + let text = match &kind { + ConversationTranscriptEntryKind::User | ConversationTranscriptEntryKind::Developer => { + text + } + ConversationTranscriptEntryKind::Assistant | ConversationTranscriptEntryKind::ProtectedAssistant - | ConversationTranscriptEntryKind::Reasoning => config.entry_limits.message_tokens, + | ConversationTranscriptEntryKind::Reasoning => { + truncate_text(&text, config.entry_limits.message_tokens) + } ConversationTranscriptEntryKind::ToolCall(_) - | ConversationTranscriptEntryKind::ToolOutput(_) => config.entry_limits.tool_tokens, + | ConversationTranscriptEntryKind::ToolOutput(_) => { + truncate_text(&text, config.entry_limits.tool_tokens) + } ConversationTranscriptEntryKind::NodeReplToolOutput(_) => { - config.entry_limits.node_repl_output_tokens + truncate_text(&text, config.entry_limits.node_repl_output_tokens) } }; entries.push(ConversationTranscriptEntry { kind, - text: truncate_text(&text, token_cap), - original_bytes: text.len(), + text, + original_bytes, }); } diff --git a/codex-rs/guardian-context/src/transcript_tests.rs b/codex-rs/guardian-context/src/transcript_tests.rs index fd85f5f363..d9e653a2f8 100644 --- a/codex-rs/guardian-context/src/transcript_tests.rs +++ b/codex-rs/guardian-context/src/transcript_tests.rs @@ -41,7 +41,10 @@ fn entry(kind: ConversationTranscriptEntryKind, text: &str) -> ConversationTrans #[test] fn registered_transcript_preserves_shared_roles_and_node_repl_tool_attribution() { - let approved_action = format!("{MANUAL_APPROVAL_DEVELOPER_PREFIX}\nApproved action: {{}}"); + let approved_action = format!( + "{MANUAL_APPROVAL_DEVELOPER_PREFIX}\nApproved action: {}", + "exact action ".repeat(/*n*/ 1_000) + ); let history = vec![ ResponseItem::Message { id: None, @@ -340,7 +343,7 @@ fn reused_registry_applies_current_history_sources_and_entry_limits() { transcript_items(§ions[0]), vec![ConversationTranscriptEntry { kind: ConversationTranscriptEntryKind::User, - text: truncate_text(&text, message_tokens), + text: text.clone(), original_bytes: text.len(), }] ); @@ -377,7 +380,7 @@ fn reused_registry_applies_current_history_sources_and_entry_limits() { .expect("transcript collection should succeed"); let mut expected = vec![ConversationTranscriptEntry { kind: ConversationTranscriptEntryKind::User, - text: truncate_text(&text, /*max_tokens*/ 60), + text: text.clone(), original_bytes: text.len(), }]; if include_tool_calls {