mirror of
https://github.com/openai/codex.git
synced 2026-09-10 20:26:47 +00:00
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:
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -3327,6 +3327,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-protocol",
|
||||
"pretty_assertions",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -14,6 +14,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-protocol = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
});
|
||||
®ISTRY
|
||||
}
|
||||
|
||||
impl SectionRegistry {
|
||||
/// Adds a contributor to the end of the section collection order.
|
||||
pub fn register(&mut self, contributor: impl SectionContributor + 'static) {
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
|
||||
329
codex-rs/guardian-context/src/transcript.rs
Normal file
329
codex-rs/guardian-context/src/transcript.rs
Normal 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;
|
||||
346
codex-rs/guardian-context/src/transcript_tests.rs
Normal file
346
codex-rs/guardian-context/src/transcript_tests.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user