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
This commit is contained in:
felixxia-oai
2026-08-28 20:53:38 +00:00
committed by copyberry
parent f742dabc6f
commit 0918cd2c08
7 changed files with 753 additions and 5 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -3327,6 +3327,7 @@ version = "0.0.0"
dependencies = [
"codex-protocol",
"pretty_assertions",
"serde_json",
]
[[package]]

View File

@@ -14,6 +14,7 @@ workspace = true
[dependencies]
codex-protocol = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }

View File

@@ -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),

View File

@@ -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<dyn Iterator<Item = &ResponseItem> + Send + '_>;
}
impl SectionHistory for Vec<ResponseItem> {
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_> {
Box::new(self.iter())
}
}
impl<const LENGTH: usize> SectionHistory for [ResponseItem; LENGTH] {
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + 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<Arc<dyn SectionContributor>>,
}
/// 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<SectionRegistry> = LazyLock::new(|| {
let mut registry = SectionRegistry::default();
registry.register(ConversationTranscriptSection);
registry
});
&REGISTRY
}
impl SectionRegistry {
/// Adds a contributor to the end of the section collection order.
pub fn register(&mut self, contributor: impl SectionContributor + 'static) {

View File

@@ -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<Option<&'static str>, SectionError>,
@@ -28,7 +31,7 @@ impl SectionContributor for TestContributor {
fn contribute(&self, input: &SectionInput<'_>) -> Result<Option<ContextSection>, 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())
);

View File

@@ -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<Option<ContextSection>, 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<ConversationTranscriptEntry> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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;

View File

@@ -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);
}
}