From 0918cd2c08f6e3b1f2b1db593e632a2e092c1ea6 Mon Sep 17 00:00:00 2001 From: felixxia-oai Date: Fri, 28 Aug 2026 20:53:38 +0000 Subject: [PATCH] Add shared Guardian transcript collection (#41422) ## What changed - Add a reusable transcript contributor for synchronous and asynchronous Guardian context, with borrowed conversation history and request-specific configuration. - Preserve conversation order and role or tool attribution while applying separate per-entry limits for messages, ordinary tools, and Node REPL-backed outputs. - Make tool calls, tool outputs, and reasoning independently configurable, and expose transcript collection without requiring section composition. - Register the transcript contributor in a process-wide default registry that retains no request state. ## Testing - Add unit coverage for role and tool attribution, optional evidence sources, orphaned and named tool outputs, registry reuse, and request-specific entry limits. GitOrigin-RevId: 2b60f8db4618de8a7175f8ee0183375a526a87db --- codex-rs/Cargo.lock | 1 + codex-rs/guardian-context/Cargo.toml | 1 + codex-rs/guardian-context/src/entry.rs | 2 +- codex-rs/guardian-context/src/lib.rs | 58 ++- .../guardian-context/src/registry_tests.rs | 21 +- codex-rs/guardian-context/src/transcript.rs | 329 +++++++++++++++++ .../guardian-context/src/transcript_tests.rs | 346 ++++++++++++++++++ 7 files changed, 753 insertions(+), 5 deletions(-) create mode 100644 codex-rs/guardian-context/src/transcript.rs create mode 100644 codex-rs/guardian-context/src/transcript_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 84555cdbf3..139e527e92 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3327,6 +3327,7 @@ version = "0.0.0" dependencies = [ "codex-protocol", "pretty_assertions", + "serde_json", ] [[package]] diff --git a/codex-rs/guardian-context/Cargo.toml b/codex-rs/guardian-context/Cargo.toml index 97ebab48f6..d9f3d8d779 100644 --- a/codex-rs/guardian-context/Cargo.toml +++ b/codex-rs/guardian-context/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] codex-protocol = { workspace = true } +serde_json = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/guardian-context/src/entry.rs b/codex-rs/guardian-context/src/entry.rs index 02ed8d5dc7..e4021d6fe6 100644 --- a/codex-rs/guardian-context/src/entry.rs +++ b/codex-rs/guardian-context/src/entry.rs @@ -17,7 +17,7 @@ pub enum ConversationTranscriptEntryKind { ProtectedAssistant, /// Named tool invocation, including shell and web-search calls. ToolCall(String), - /// Named or unnamed tool result. + /// Tool result with a call-derived, explicit, or generic fallback label. ToolOutput(String), /// Result from a Node REPL-backed tool that may receive a larger sync cap. NodeReplToolOutput(String), diff --git a/codex-rs/guardian-context/src/lib.rs b/codex-rs/guardian-context/src/lib.rs index 78a8ff026f..454d390a3c 100644 --- a/codex-rs/guardian-context/src/lib.rs +++ b/codex-rs/guardian-context/src/lib.rs @@ -1,20 +1,32 @@ //! Shared context sections for synchronous Guardian review and asynchronous scoring. //! +//! Transcript collection is also available directly, without section composition. //! Contributor failures abort collection without returning partial context. //! Sections carry structured transcript evidence without depending on either //! consumer's rendering, retention, compaction, or request lifecycle. //! Registered contributors declare their scope once and are collected only for -//! matching context consumers. +//! matching context consumers. History and collection settings are borrowed for +//! each request so the default registry can be reused without retaining state. use std::sync::Arc; +use std::sync::LazyLock; use codex_protocol::models::ResponseItem; +use transcript::ConversationTranscriptSection; + pub use entry::ConversationTranscriptEntry; pub use entry::ConversationTranscriptEntryKind; +pub use transcript::ConversationTranscriptConfig; +pub use transcript::ConversationTranscriptOptions; +pub use transcript::MANUAL_APPROVAL_DEVELOPER_PREFIX; +pub use transcript::TranscriptEntryLimits; +pub use transcript::TranscriptRetentionConfig; +pub use transcript::collect_transcript; pub use truncation::truncate_text; mod entry; +mod transcript; mod truncation; /// Consumer for which a Guardian context is composed. @@ -49,12 +61,36 @@ impl SectionScope { } /// Borrowed host inputs available while one Guardian context section is built. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy)] pub struct SectionInput<'a> { /// Consumer for which the host is collecting context sections. pub target: ContextTarget, /// Parent conversation history available to this contribution. - pub history: &'a [ResponseItem], + pub history: &'a dyn SectionHistory, + /// Evidence sources and per-entry limits for this collection. + pub transcript: &'a ConversationTranscriptConfig, +} + +/// Supplies repeatable, zero-copy access to a host-owned conversation snapshot. +/// +/// Implementations return a fresh iterator for every call so independently +/// registered contributors can inspect the same history without cloning its +/// response items or taking ownership away from the host. +pub trait SectionHistory: Send + Sync { + /// Returns borrowed response items in their original conversation order. + fn items(&self) -> Box + Send + '_>; +} + +impl SectionHistory for Vec { + fn items(&self) -> Box + Send + '_> { + Box::new(self.iter()) + } +} + +impl SectionHistory for [ResponseItem; LENGTH] { + fn items(&self) -> Box + Send + '_> { + Box::new(self.iter()) + } } /// Supplies one independently scoped section to Guardian context assembly. @@ -63,6 +99,8 @@ pub struct SectionInput<'a> { /// asynchronous scoring, or both. The registry filters contributors by scope /// before invoking them. Contributors distinguish sections that do not apply /// from required evidence that could not be collected. +/// Keep request-specific settings and history in [`SectionInput`] so the same +/// contributor can serve concurrent reviews without retaining stale state. pub trait SectionContributor: Send + Sync { /// Guardian consumers that should receive this contribution. fn scope(&self) -> SectionScope; @@ -100,6 +138,20 @@ pub struct SectionRegistry { contributors: Vec>, } +/// Shared, process-lifetime registry of built-in Guardian sections. +/// +/// Contributors store no conversation or configuration state. Each collection +/// borrows the current history and settings from [`SectionInput`], so callers +/// can reuse this registry across threads, model changes, and review targets. +pub fn default_registry() -> &'static SectionRegistry { + static REGISTRY: LazyLock = LazyLock::new(|| { + let mut registry = SectionRegistry::default(); + registry.register(ConversationTranscriptSection); + registry + }); + ®ISTRY +} + impl SectionRegistry { /// Adds a contributor to the end of the section collection order. pub fn register(&mut self, contributor: impl SectionContributor + 'static) { diff --git a/codex-rs/guardian-context/src/registry_tests.rs b/codex-rs/guardian-context/src/registry_tests.rs index c6523fa03d..c9279d52cb 100644 --- a/codex-rs/guardian-context/src/registry_tests.rs +++ b/codex-rs/guardian-context/src/registry_tests.rs @@ -7,13 +7,16 @@ use pretty_assertions::assert_eq; use super::ContextSection; use super::ContextTarget; +use super::ConversationTranscriptConfig; use super::ConversationTranscriptEntry; use super::ConversationTranscriptEntryKind; +use super::ConversationTranscriptOptions; use super::SectionContributor; use super::SectionError; use super::SectionInput; use super::SectionRegistry; use super::SectionScope; +use super::TranscriptEntryLimits; struct TestContributor { outcome: Result, SectionError>, @@ -28,7 +31,7 @@ impl SectionContributor for TestContributor { fn contribute(&self, input: &SectionInput<'_>) -> Result, SectionError> { self.invocations.fetch_add(/*val*/ 1, Ordering::Relaxed); - let history_len = input.history.len(); + let history_len = input.history.items().count(); Ok(self .outcome .clone()? @@ -47,6 +50,17 @@ fn section(label: &str, history_len: usize) -> ContextSection { } } +fn transcript_config() -> ConversationTranscriptConfig { + ConversationTranscriptConfig { + options: ConversationTranscriptOptions::default(), + entry_limits: TranscriptEntryLimits { + message_tokens: 2_000, + tool_tokens: 1_000, + node_repl_output_tokens: 2_000, + }, + } +} + #[test] fn registry_collects_target_specific_sections_in_registration_order() { let mut registry = SectionRegistry::default(); @@ -66,14 +80,17 @@ fn registry_collects_target_specific_sections_in_registration_order() { invocations.push(calls); } let history = [ResponseItem::Other]; + let transcript = transcript_config(); let sync_sections = registry.collect(&SectionInput { target: ContextTarget::Sync, history: &history, + transcript: &transcript, }); let async_sections = registry.collect(&SectionInput { target: ContextTarget::Async, history: &history, + transcript: &transcript, }); assert_eq!( @@ -107,6 +124,7 @@ fn registry_skips_optional_sections_and_stops_on_missing_required_evidence() { section: "permissions", }; for target in [ContextTarget::Sync, ContextTarget::Async] { + let transcript = transcript_config(); let mut registry = SectionRegistry::default(); let mut invocations = Vec::new(); for outcome in [ @@ -128,6 +146,7 @@ fn registry_skips_optional_sections_and_stops_on_missing_required_evidence() { registry.collect(&SectionInput { target, history: &[ResponseItem::Other], + transcript: &transcript, }), Err(error.clone()) ); diff --git a/codex-rs/guardian-context/src/transcript.rs b/codex-rs/guardian-context/src/transcript.rs new file mode 100644 index 0000000000..dda155b7bc --- /dev/null +++ b/codex-rs/guardian-context/src/transcript.rs @@ -0,0 +1,329 @@ +//! 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. Consumers retain their own +//! transcript selection, aggregate budgets, and formatting. Tool outputs with a +//! call ID retain their generic label when the call is unavailable. Outputs +//! without a call ID require an explicit name. + +use std::collections::HashMap; + +use codex_protocol::mcp::is_node_repl_backed_tool; +use codex_protocol::models::ContentItem; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::ReasoningItemContent; +use codex_protocol::models::ReasoningItemReasoningSummary; +use codex_protocol::models::ResponseItem; +use codex_protocol::models::plaintext_agent_message_content; +use codex_protocol::protocol::InterAgentCommunication; + +use crate::ContextSection; +use crate::ConversationTranscriptEntry; +use crate::ConversationTranscriptEntryKind; +use crate::SectionContributor; +use crate::SectionError; +use crate::SectionHistory; +use crate::SectionInput; +use crate::SectionScope; +use crate::truncate_text; + +/// Trusted developer marker that preserves an explicit manual action approval. +pub const MANUAL_APPROVAL_DEVELOPER_PREFIX: &str = + "The user has manually approved a specific action that was previously `Rejected`."; + +/// Evidence sources included alongside user and assistant conversation messages. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ConversationTranscriptOptions { + /// Includes submitted function, custom-tool, shell, and web-search calls. + pub include_tool_calls: bool, + /// Includes function and custom-tool outputs. + pub include_tool_outputs: bool, + /// Includes plaintext reasoning summaries and reasoning content. + pub include_reasoning: bool, +} + +impl Default for ConversationTranscriptOptions { + fn default() -> Self { + Self { + include_tool_calls: true, + include_tool_outputs: true, + include_reasoning: false, + } + } +} + +/// 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. + pub message_tokens: usize, + /// Cap for tool calls and ordinary tool outputs. + pub tool_tokens: usize, + /// Cap for Node REPL-backed outputs, which sync may retain at a larger size. + pub node_repl_output_tokens: usize, +} + +/// Aggregate limits for retaining rendered transcript entries. +/// +/// Sync and async consumers keep their existing selection rules. These limits +/// configure those rules without introducing another sync/async policy selector. +/// Collection applies per-entry caps; aggregate retention remains with the host. +#[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. + pub max_recent_non_user_entries: usize, +} + +/// Evidence sources and per-entry limits supplied on each collection. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ConversationTranscriptConfig { + /// Evidence sources to include in this request. + pub options: ConversationTranscriptOptions, + /// Per-entry limits applied before accumulating transcript text. + pub entry_limits: TranscriptEntryLimits, +} + +/// Shared contributor that extracts parent-conversation evidence. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ConversationTranscriptSection; + +impl SectionContributor for ConversationTranscriptSection { + fn scope(&self) -> SectionScope { + SectionScope::Shared + } + + fn contribute(&self, input: &SectionInput<'_>) -> Result, SectionError> { + Ok(Some(ContextSection { + items: collect_transcript(input.history, input.transcript), + })) + } +} + +/// Extracts bounded transcript entries without composing other context sections. +/// +/// Entries preserve conversation order and role/tool attribution. Per-entry +/// limits apply during collection; consumers own aggregate retention and rendering. +pub fn collect_transcript( + history: &dyn SectionHistory, + config: &ConversationTranscriptConfig, +) -> Vec { + let mut entries = Vec::new(); + let mut tool_names_by_call_id = HashMap::new(); + + for item in history.items() { + let (kind, text) = match item { + ResponseItem::Message { + role, + content, + phase, + .. + } => { + let text = content + .iter() + .filter_map(|item| match item { + ContentItem::InputText { text } | ContentItem::OutputText { text } + if !text.is_empty() => + { + Some(text.as_str()) + } + ContentItem::InputText { .. } + | ContentItem::OutputText { .. } + | ContentItem::InputImage { .. } + | ContentItem::InputAudio { .. } => None, + }) + .collect::>() + .join("\n"); + let kind = match role.as_str() { + "user" => ConversationTranscriptEntryKind::User, + "developer" if text.starts_with(MANUAL_APPROVAL_DEVELOPER_PREFIX) => { + ConversationTranscriptEntryKind::Developer + } + "assistant" + if matches!(phase, None | Some(MessagePhase::FinalAnswer)) + && !InterAgentCommunication::is_message_content(content) => + { + ConversationTranscriptEntryKind::ProtectedAssistant + } + "assistant" => ConversationTranscriptEntryKind::Assistant, + _ => continue, + }; + (kind, text) + } + ResponseItem::AgentMessage { + author, content, .. + } => { + let Some(text) = plaintext_agent_message_content(content) else { + continue; + }; + ( + ConversationTranscriptEntryKind::Assistant, + format!("Agent message from {author}:\n{text}"), + ) + } + ResponseItem::FunctionCall { + name, + namespace, + arguments, + call_id, + .. + } + | ResponseItem::CustomToolCall { + name, + namespace, + input: arguments, + call_id, + .. + } => { + tool_names_by_call_id + .insert(call_id.as_str(), (name.as_str(), namespace.as_deref())); + if !config.options.include_tool_calls { + continue; + } + ( + ConversationTranscriptEntryKind::ToolCall(format!("tool {name} call")), + arguments.clone(), + ) + } + ResponseItem::FunctionCallOutput { + call_id: Some(call_id), + output, + .. + } + | ResponseItem::CustomToolCallOutput { + call_id, output, .. + } => { + if !config.options.include_tool_outputs { + continue; + } + let kind = match tool_names_by_call_id.get(call_id.as_str()) { + Some((name, namespace)) if is_node_repl_backed_tool(name, *namespace) => { + ConversationTranscriptEntryKind::NodeReplToolOutput(format!( + "tool {name} result" + )) + } + Some((name, _)) => { + ConversationTranscriptEntryKind::ToolOutput(format!("tool {name} result")) + } + None => ConversationTranscriptEntryKind::ToolOutput("tool result".to_string()), + }; + let Some(text) = output.body.to_text() else { + continue; + }; + (kind, text) + } + ResponseItem::FunctionCallOutput { + call_id: None, + name: Some(name), + namespace, + output, + .. + } => { + if !config.options.include_tool_outputs { + continue; + } + let role = match namespace { + Some(namespace) => format!("tool {namespace}.{name} result"), + None => format!("tool {name} result"), + }; + ( + ConversationTranscriptEntryKind::ToolOutput(role), + output + .body + .to_text() + .unwrap_or_else(|| "[non-text output]".into()), + ) + } + ResponseItem::Reasoning { + summary, content, .. + } => { + if !config.options.include_reasoning { + continue; + } + let text = summary + .iter() + .map(|item| match item { + ReasoningItemReasoningSummary::SummaryText { text } => text.as_str(), + }) + .chain(content.iter().flatten().map(|item| match item { + ReasoningItemContent::ReasoningText { text } + | ReasoningItemContent::Text { text } => text.as_str(), + })) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n"); + (ConversationTranscriptEntryKind::Reasoning, text) + } + ResponseItem::LocalShellCall { action, .. } => { + if !config.options.include_tool_calls { + continue; + } + let Ok(text) = serde_json::to_string(action) else { + continue; + }; + ( + ConversationTranscriptEntryKind::ToolCall("tool shell call".to_string()), + text, + ) + } + ResponseItem::WebSearchCall { action, .. } => { + if !config.options.include_tool_calls { + continue; + } + let Some(action) = action else { + continue; + }; + let Ok(text) = serde_json::to_string(action) else { + continue; + }; + ( + ConversationTranscriptEntryKind::ToolCall("tool web_search call".to_string()), + text, + ) + } + ResponseItem::FunctionCallOutput { + call_id: None, + name: None, + .. + } + | ResponseItem::AdditionalTools { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger { .. } + | ResponseItem::ContextCompaction { .. } + | ResponseItem::Other => continue, + }; + + if text.trim().is_empty() { + continue; + } + let token_cap = match &kind { + ConversationTranscriptEntryKind::User + | ConversationTranscriptEntryKind::Developer + | ConversationTranscriptEntryKind::Assistant + | ConversationTranscriptEntryKind::ProtectedAssistant + | ConversationTranscriptEntryKind::Reasoning => config.entry_limits.message_tokens, + ConversationTranscriptEntryKind::ToolCall(_) + | ConversationTranscriptEntryKind::ToolOutput(_) => config.entry_limits.tool_tokens, + ConversationTranscriptEntryKind::NodeReplToolOutput(_) => { + config.entry_limits.node_repl_output_tokens + } + }; + entries.push(ConversationTranscriptEntry { + kind, + text: truncate_text(&text, token_cap), + original_bytes: text.len(), + }); + } + + entries +} + +#[cfg(test)] +#[path = "transcript_tests.rs"] +mod tests; diff --git a/codex-rs/guardian-context/src/transcript_tests.rs b/codex-rs/guardian-context/src/transcript_tests.rs new file mode 100644 index 0000000000..4975fed9cf --- /dev/null +++ b/codex-rs/guardian-context/src/transcript_tests.rs @@ -0,0 +1,346 @@ +use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::LocalShellAction; +use codex_protocol::models::LocalShellExecAction; +use codex_protocol::models::LocalShellStatus; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::ResponseItem; +use pretty_assertions::assert_eq; + +use super::ConversationTranscriptConfig; +use super::ConversationTranscriptEntry; +use super::ConversationTranscriptEntryKind; +use super::ConversationTranscriptOptions; +use super::MANUAL_APPROVAL_DEVELOPER_PREFIX; +use super::TranscriptEntryLimits; +use crate::ContextSection; +use crate::ContextTarget; +use crate::SectionInput; +use crate::collect_transcript; +use crate::default_registry; +use crate::truncate_text; + +fn transcript_config() -> ConversationTranscriptConfig { + ConversationTranscriptConfig { + options: ConversationTranscriptOptions::default(), + entry_limits: TranscriptEntryLimits { + message_tokens: 2_000, + tool_tokens: 1_000, + node_repl_output_tokens: 2_000, + }, + } +} + +fn entry(kind: ConversationTranscriptEntryKind, text: &str) -> ConversationTranscriptEntry { + ConversationTranscriptEntry { + kind, + text: text.to_string(), + original_bytes: text.len(), + } +} + +#[test] +fn registered_transcript_preserves_shared_roles_and_node_repl_tool_attribution() { + let approved_action = format!("{MANUAL_APPROVAL_DEVELOPER_PREFIX}\nApproved action: {{}}"); + let history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "Inspect the workspace.".to_string(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: approved_action.clone(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "Inspection complete.".to_string(), + }], + phase: Some(MessagePhase::FinalAnswer), + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::FunctionCall { + id: None, + name: "read_file".to_string(), + namespace: Some("mcp__node_repl__".to_string()), + arguments: "{}".to_string(), + call_id: "call-1".to_string(), + encrypted_function_args: None, + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::FunctionCallOutput { + id: None, + call_id: Some("call-1".to_string()), + name: None, + namespace: None, + output: FunctionCallOutputPayload::from_text("file contents".to_string()), + internal_chat_message_metadata_passthrough: None, + }, + ]; + let registry = default_registry(); + let config = transcript_config(); + + let sections = registry + .collect(&SectionInput { + target: ContextTarget::Async, + history: &history, + transcript: &config, + }) + .expect("transcript collection should succeed"); + + assert_eq!( + sections, + vec![ContextSection { + items: vec![ + entry( + ConversationTranscriptEntryKind::User, + "Inspect the workspace." + ), + entry(ConversationTranscriptEntryKind::Developer, &approved_action), + entry( + ConversationTranscriptEntryKind::ProtectedAssistant, + "Inspection complete." + ), + entry( + ConversationTranscriptEntryKind::ToolCall("tool read_file call".to_string()), + "{}" + ), + entry( + ConversationTranscriptEntryKind::NodeReplToolOutput( + "tool read_file result".to_string() + ), + "file contents" + ), + ], + }] + ); + assert_eq!( + sections, + vec![ContextSection { + items: collect_transcript(&history, &config), + }] + ); +} + +#[test] +fn excluded_tool_calls_still_attribute_included_results() { + let history = vec![ + ResponseItem::FunctionCall { + id: None, + name: "read_file".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-1".to_string(), + encrypted_function_args: None, + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::FunctionCallOutput { + id: None, + call_id: Some("call-1".to_string()), + name: None, + namespace: None, + output: FunctionCallOutputPayload::from_text("file contents".to_string()), + internal_chat_message_metadata_passthrough: None, + }, + ]; + let config = ConversationTranscriptConfig { + options: ConversationTranscriptOptions { + include_tool_calls: false, + ..ConversationTranscriptOptions::default() + }, + ..transcript_config() + }; + let sections = default_registry() + .collect(&SectionInput { + target: ContextTarget::Async, + history: &history, + transcript: &config, + }) + .expect("transcript collection should succeed"); + + assert_eq!( + sections[0].items, + vec![entry( + ConversationTranscriptEntryKind::ToolOutput("tool read_file result".to_string()), + "file contents" + )] + ); +} + +#[test] +fn outputs_with_call_ids_or_explicit_names_are_retained() { + let output = + |call_id: Option<&str>, name: Option<&str>, text: &str| ResponseItem::FunctionCallOutput { + id: None, + call_id: call_id.map(str::to_string), + name: name.map(str::to_string), + namespace: Some("slack".to_string()), + output: FunctionCallOutputPayload::from_text(text.to_string()), + internal_chat_message_metadata_passthrough: None, + }; + let custom_output = |name: Option<&str>, text: &str| ResponseItem::CustomToolCallOutput { + id: None, + call_id: "missing-custom-call".to_string(), + name: name.map(str::to_string), + output: FunctionCallOutputPayload::from_text(text.to_string()), + internal_chat_message_metadata_passthrough: None, + }; + let shell_action = LocalShellAction::Exec(LocalShellExecAction { + command: vec!["echo".to_string(), "hello".to_string()], + timeout_ms: None, + working_directory: None, + env: None, + user: None, + }); + let shell_text = serde_json::to_string(&shell_action).unwrap(); + let history = [ + output( + /*call_id*/ None, + /*name*/ None, + "anonymous output", + ), + output( + Some("missing-call"), + /*name*/ None, + "orphaned function output", + ), + output( + /*call_id*/ None, + Some("notifications"), + "named notification", + ), + output( + Some("missing-call"), + Some("notifications"), + "named orphaned function output", + ), + custom_output(/*name*/ None, "orphaned custom output"), + custom_output(Some("notifications"), "named orphaned custom output"), + ResponseItem::LocalShellCall { + id: None, + call_id: Some("shell-1".to_string()), + status: LocalShellStatus::Completed, + action: shell_action, + internal_chat_message_metadata_passthrough: None, + }, + output(Some("shell-1"), /*name*/ None, "local shell output"), + ]; + let named = entry( + ConversationTranscriptEntryKind::ToolOutput("tool slack.notifications result".to_string()), + "named notification", + ); + let generic = |text| { + entry( + ConversationTranscriptEntryKind::ToolOutput("tool result".to_string()), + text, + ) + }; + let mut config = transcript_config(); + for target in [ContextTarget::Sync, ContextTarget::Async] { + for include_tool_calls in [true, false] { + config.options.include_tool_calls = include_tool_calls; + let sections = default_registry() + .collect(&SectionInput { + target, + history: &history, + transcript: &config, + }) + .expect("transcript collection should succeed"); + let mut expected = vec![ + generic("orphaned function output"), + named.clone(), + generic("named orphaned function output"), + generic("orphaned custom output"), + generic("named orphaned custom output"), + ]; + if include_tool_calls { + expected.push(entry( + ConversationTranscriptEntryKind::ToolCall("tool shell call".to_string()), + &shell_text, + )); + } + expected.push(generic("local shell output")); + assert_eq!(sections, vec![ContextSection { items: expected }]); + } + } +} + +#[test] +fn reused_registry_applies_current_history_sources_and_entry_limits() { + let text = "é🙂".repeat(/*n*/ 10_000); + let mut history = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { text: text.clone() }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }]; + let registry = default_registry(); + let mut config = transcript_config(); + for (target, message_tokens) in [(ContextTarget::Async, 80), (ContextTarget::Sync, 120)] { + config.entry_limits.message_tokens = message_tokens; + let sections = registry + .collect(&SectionInput { + target, + history: &history, + transcript: &config, + }) + .expect("transcript collection should succeed"); + assert_eq!( + sections[0].items, + vec![ConversationTranscriptEntry { + kind: ConversationTranscriptEntryKind::User, + text: truncate_text(&text, message_tokens), + original_bytes: text.len(), + }] + ); + } + + history.push(ResponseItem::FunctionCall { + id: None, + name: "exec_command".to_string(), + namespace: None, + arguments: text.clone(), + call_id: "call-1".to_string(), + encrypted_function_args: None, + internal_chat_message_metadata_passthrough: None, + }); + config.entry_limits.message_tokens = 60; + config.entry_limits.tool_tokens = 30; + for include_tool_calls in [true, false] { + config.options.include_tool_calls = include_tool_calls; + let sections = registry + .collect(&SectionInput { + target: ContextTarget::Async, + history: &history, + transcript: &config, + }) + .expect("transcript collection should succeed"); + let mut expected = vec![ConversationTranscriptEntry { + kind: ConversationTranscriptEntryKind::User, + text: truncate_text(&text, /*max_tokens*/ 60), + original_bytes: text.len(), + }]; + if include_tool_calls { + expected.push(ConversationTranscriptEntry { + kind: ConversationTranscriptEntryKind::ToolCall( + "tool exec_command call".to_string(), + ), + text: truncate_text(&text, /*max_tokens*/ 30), + original_bytes: text.len(), + }); + } + assert_eq!(sections[0].items, expected); + } +}