mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
14df0e8833
## What Introduce a `CodexResponsesMetadata` struct that defines all the core metadata we send to Responses API. Example fields are `thread_id`, `turn_id`, `window_id`, etc. Going forward, `client_metadata["x-codex-turn-metadata"]` will be the canonical way Codex sends metadata to Responses API across both HTTP and websocket transports. For now, we continue to emit the existing top-level HTTP headers and top-level `client_metadata` fields from the same `CodexResponsesMetadata` struct for compatibility reasons. Also, app-server clients who specify additional `responsesapi_client_metadata` via `turn/start` and `turn/steer` will have those fields merged into `client_metadata["x-codex-turn-metadata"]`, but cannot override the reserved fields that core uses (i.e. the fields in `CodexResponsesMetadata`). ## Why Responses API request instrumentation is the source of truth for downstream Codex analytics that join requests by Codex IDs such as session, thread, turn, and context window. Before this change, those values were assembled through several request-specific paths: HTTP request bodies, websocket handshake headers, websocket `response.create` payloads, compaction requests, and the rich `x-codex-turn-metadata` envelope all had their own wiring. That made metadata propagation easy to drift across API-key/direct Responses API requests, ChatGPT-auth/proxied requests, websocket requests, and compaction requests. It also made additions like `window_id` error-prone because a field could be added to one transport projection but missed in another. ## What changed - Added `CodexResponsesMetadata` as the core-owned snapshot for Codex metadata sent to ResponsesAPI. - Render `client_metadata["x-codex-turn-metadata"]`, flat `client_metadata` projections, and direct compatibility headers from that same snapshot. - Include the known Codex-owned fields in the turn metadata blob, including installation/session/thread/turn/window IDs, request kind, lineage, sandbox/workspace metadata, timing, and compaction details. - Treat app-server `responsesapi_client_metadata` as enrichment for the Codex turn metadata blob while preventing those extras from overriding Codex-owned fields. - Use the same metadata path for normal turns, websocket prewarm, local compaction, remote v1 compaction, and remote v2 compaction. - Keep websocket connection-only preconnect metadata separate so handshakes carry compatibility identity headers without inventing a fake turn metadata blob. ## Verification - `cargo check -p codex-core` - `just fix -p codex-core`
741 lines
27 KiB
Rust
741 lines
27 KiB
Rust
use regex_lite::Regex;
|
|
use serde_json::Value;
|
|
use similar::ChangeTag;
|
|
use similar::TextDiff;
|
|
use std::sync::OnceLock;
|
|
|
|
use crate::responses::ResponsesRequest;
|
|
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
|
|
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
|
|
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
pub enum ContextSnapshotRenderMode {
|
|
#[default]
|
|
RedactedText,
|
|
FullText,
|
|
KindOnly,
|
|
KindWithTextPrefix {
|
|
max_chars: usize,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ContextSnapshotOptions {
|
|
render_mode: ContextSnapshotRenderMode,
|
|
strip_capability_instructions: bool,
|
|
strip_agents_md_user_context: bool,
|
|
}
|
|
|
|
impl Default for ContextSnapshotOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
render_mode: ContextSnapshotRenderMode::RedactedText,
|
|
strip_capability_instructions: false,
|
|
strip_agents_md_user_context: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ContextSnapshotOptions {
|
|
pub fn render_mode(mut self, render_mode: ContextSnapshotRenderMode) -> Self {
|
|
self.render_mode = render_mode;
|
|
self
|
|
}
|
|
|
|
pub fn strip_capability_instructions(mut self) -> Self {
|
|
self.strip_capability_instructions = true;
|
|
self
|
|
}
|
|
|
|
pub fn strip_agents_md_user_context(mut self) -> Self {
|
|
self.strip_agents_md_user_context = true;
|
|
self
|
|
}
|
|
}
|
|
|
|
pub fn format_request_input_snapshot(
|
|
request: &ResponsesRequest,
|
|
options: &ContextSnapshotOptions,
|
|
) -> String {
|
|
let items = request.input();
|
|
format_response_items_snapshot(items.as_slice(), options)
|
|
}
|
|
|
|
pub fn format_response_items_snapshot(items: &[Value], options: &ContextSnapshotOptions) -> String {
|
|
items
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(idx, item)| {
|
|
let Some(item_type) = item.get("type").and_then(Value::as_str) else {
|
|
return format!("{idx:02}:<MISSING_TYPE>");
|
|
};
|
|
|
|
if options.render_mode == ContextSnapshotRenderMode::KindOnly {
|
|
return if item_type == "message" {
|
|
let role = item.get("role").and_then(Value::as_str).unwrap_or("unknown");
|
|
format!("{idx:02}:message/{role}")
|
|
} else {
|
|
format!("{idx:02}:{item_type}")
|
|
};
|
|
}
|
|
|
|
match item_type {
|
|
"message" => {
|
|
let role = item.get("role").and_then(Value::as_str).unwrap_or("unknown");
|
|
let rendered_parts = item
|
|
.get("content")
|
|
.and_then(Value::as_array)
|
|
.map(|content| {
|
|
content
|
|
.iter()
|
|
.filter_map(|entry| {
|
|
if let Some(text) = entry.get("text").and_then(Value::as_str) {
|
|
if options.strip_capability_instructions
|
|
&& role == "developer"
|
|
&& is_capability_instruction_text(text)
|
|
{
|
|
return None;
|
|
}
|
|
if options.strip_agents_md_user_context
|
|
&& role == "user"
|
|
&& text.starts_with("# AGENTS.md instructions for ")
|
|
{
|
|
return None;
|
|
}
|
|
return Some(format_snapshot_text(text, options));
|
|
}
|
|
let Some(content_type) =
|
|
entry.get("type").and_then(Value::as_str)
|
|
else {
|
|
return Some("<UNKNOWN_CONTENT_ITEM>".to_string());
|
|
};
|
|
let Some(content_object) = entry.as_object() else {
|
|
return Some(format!("<{content_type}>"));
|
|
};
|
|
let mut extra_keys = content_object
|
|
.keys()
|
|
.filter(|key| *key != "type" && *key != "text")
|
|
.cloned()
|
|
.collect::<Vec<String>>();
|
|
extra_keys.sort();
|
|
Some(if extra_keys.is_empty() {
|
|
format!("<{content_type}>")
|
|
} else {
|
|
format!("<{content_type}:{}>", extra_keys.join(","))
|
|
})
|
|
})
|
|
.collect::<Vec<String>>()
|
|
})
|
|
.unwrap_or_default();
|
|
let role = if rendered_parts.len() > 1 {
|
|
format!("{role}[{}]", rendered_parts.len())
|
|
} else {
|
|
role.to_string()
|
|
};
|
|
if rendered_parts.is_empty() {
|
|
return format!("{idx:02}:message/{role}:<NO_TEXT>");
|
|
}
|
|
if rendered_parts.len() == 1 {
|
|
return format!("{idx:02}:message/{role}:{}", rendered_parts[0]);
|
|
}
|
|
|
|
let parts = rendered_parts
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(part_idx, part)| format!(" [{:02}] {part}", part_idx + 1))
|
|
.collect::<Vec<String>>()
|
|
.join("\n");
|
|
format!("{idx:02}:message/{role}:\n{parts}")
|
|
}
|
|
"function_call" => {
|
|
let name = item.get("name").and_then(Value::as_str).unwrap_or("unknown");
|
|
format!("{idx:02}:function_call/{name}")
|
|
}
|
|
"function_call_output" => {
|
|
let output = item
|
|
.get("output")
|
|
.and_then(Value::as_str)
|
|
.map(|output| format_snapshot_text(output, options))
|
|
.unwrap_or_else(|| "<NON_STRING_OUTPUT>".to_string());
|
|
format!("{idx:02}:function_call_output:{output}")
|
|
}
|
|
"local_shell_call" => {
|
|
let command = item
|
|
.get("action")
|
|
.and_then(|action| action.get("command"))
|
|
.and_then(Value::as_array)
|
|
.map(|parts| {
|
|
parts
|
|
.iter()
|
|
.filter_map(Value::as_str)
|
|
.collect::<Vec<&str>>()
|
|
.join(" ")
|
|
})
|
|
.map(|command| format_snapshot_text(&command, options))
|
|
.filter(|cmd| !cmd.is_empty())
|
|
.unwrap_or_else(|| "<NO_COMMAND>".to_string());
|
|
format!("{idx:02}:local_shell_call:{command}")
|
|
}
|
|
"reasoning" => {
|
|
let summary_text = item
|
|
.get("summary")
|
|
.and_then(Value::as_array)
|
|
.and_then(|summary| summary.first())
|
|
.and_then(|entry| entry.get("text"))
|
|
.and_then(Value::as_str)
|
|
.map(|text| format_snapshot_text(text, options))
|
|
.unwrap_or_else(|| "<NO_SUMMARY>".to_string());
|
|
let has_encrypted_content = item
|
|
.get("encrypted_content")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|value| !value.is_empty());
|
|
format!(
|
|
"{idx:02}:reasoning:summary={summary_text}:encrypted={has_encrypted_content}"
|
|
)
|
|
}
|
|
"compaction" => {
|
|
let has_encrypted_content = item
|
|
.get("encrypted_content")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|value| !value.is_empty());
|
|
format!("{idx:02}:compaction:encrypted={has_encrypted_content}")
|
|
}
|
|
other => format!("{idx:02}:{other}"),
|
|
}
|
|
})
|
|
.collect::<Vec<String>>()
|
|
.join("\n")
|
|
}
|
|
|
|
pub fn format_labeled_requests_snapshot(
|
|
scenario: &str,
|
|
sections: &[(&str, &ResponsesRequest)],
|
|
options: &ContextSnapshotOptions,
|
|
) -> String {
|
|
let sections = sections
|
|
.iter()
|
|
.map(|(title, request)| {
|
|
format!(
|
|
"## {title}\n{}",
|
|
format_request_input_snapshot(request, options)
|
|
)
|
|
})
|
|
.collect::<Vec<String>>()
|
|
.join("\n\n");
|
|
format!("Scenario: {scenario}\n\n{sections}")
|
|
}
|
|
|
|
pub fn format_labeled_items_snapshot(
|
|
scenario: &str,
|
|
sections: &[(&str, &[Value])],
|
|
options: &ContextSnapshotOptions,
|
|
) -> String {
|
|
let sections = sections
|
|
.iter()
|
|
.map(|(title, items)| {
|
|
format!(
|
|
"## {title}\n{}",
|
|
format_response_items_snapshot(items, options)
|
|
)
|
|
})
|
|
.collect::<Vec<String>>()
|
|
.join("\n\n");
|
|
format!("Scenario: {scenario}\n\n{sections}")
|
|
}
|
|
|
|
/// Render changed JSON lines between two captured `/responses` request bodies.
|
|
///
|
|
/// Request-parity tests use this to compare the entire JSON payload while showing only fields that
|
|
/// changed, with the same redactions as the other context snapshots.
|
|
pub fn format_request_body_diff_snapshot(
|
|
scenario: &str,
|
|
before_title: &str,
|
|
before_request: &ResponsesRequest,
|
|
after_title: &str,
|
|
after_request: &ResponsesRequest,
|
|
options: &ContextSnapshotOptions,
|
|
) -> String {
|
|
let before = format_request_body_snapshot(before_request, options);
|
|
let after = format_request_body_snapshot(after_request, options);
|
|
let diff = format_changed_lines_diff(before_title, &before, after_title, &after);
|
|
format!("Scenario: {scenario}\n\n{diff}")
|
|
}
|
|
|
|
fn format_request_body_snapshot(
|
|
request: &ResponsesRequest,
|
|
options: &ContextSnapshotOptions,
|
|
) -> String {
|
|
let mut body = request.body_json();
|
|
canonicalize_json_snapshot_value(&mut body, options);
|
|
serde_json::to_string_pretty(&body).expect("request body should serialize")
|
|
}
|
|
|
|
fn canonicalize_json_snapshot_value(value: &mut Value, options: &ContextSnapshotOptions) {
|
|
match value {
|
|
Value::Array(values) => {
|
|
for value in values {
|
|
canonicalize_json_snapshot_value(value, options);
|
|
}
|
|
}
|
|
Value::Object(map) => {
|
|
// Keep request-body snapshots stable when serde_json preserves insertion order.
|
|
let mut entries = std::mem::take(map).into_iter().collect::<Vec<_>>();
|
|
entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key));
|
|
for (key, mut value) in entries {
|
|
canonicalize_json_snapshot_value(&mut value, options);
|
|
map.insert(key, value);
|
|
}
|
|
}
|
|
Value::String(text) => {
|
|
*text = format_snapshot_json_string(text, options);
|
|
}
|
|
Value::Null | Value::Bool(_) | Value::Number(_) => {}
|
|
}
|
|
}
|
|
|
|
fn format_snapshot_json_string(text: &str, options: &ContextSnapshotOptions) -> String {
|
|
let normalized = match options.render_mode {
|
|
ContextSnapshotRenderMode::RedactedText
|
|
| ContextSnapshotRenderMode::KindWithTextPrefix { .. } => {
|
|
normalize_snapshot_dynamic_values(&normalize_snapshot_line_endings(
|
|
&canonicalize_snapshot_text(text),
|
|
))
|
|
}
|
|
ContextSnapshotRenderMode::FullText => normalize_snapshot_line_endings(text),
|
|
ContextSnapshotRenderMode::KindOnly => unreachable!(),
|
|
};
|
|
match options.render_mode {
|
|
ContextSnapshotRenderMode::KindWithTextPrefix { max_chars }
|
|
if normalized.chars().count() > max_chars =>
|
|
{
|
|
let prefix = normalized.chars().take(max_chars).collect::<String>();
|
|
format!("{prefix}...")
|
|
}
|
|
ContextSnapshotRenderMode::RedactedText
|
|
| ContextSnapshotRenderMode::FullText
|
|
| ContextSnapshotRenderMode::KindWithTextPrefix { .. } => normalized,
|
|
ContextSnapshotRenderMode::KindOnly => unreachable!(),
|
|
}
|
|
}
|
|
|
|
fn format_changed_lines_diff(
|
|
before_title: &str,
|
|
before: &str,
|
|
after_title: &str,
|
|
after: &str,
|
|
) -> String {
|
|
let mut diff = format!("--- {before_title}\n+++ {after_title}\n");
|
|
for change in TextDiff::from_lines(before, after).iter_all_changes() {
|
|
match change.tag() {
|
|
ChangeTag::Equal => {}
|
|
ChangeTag::Delete => {
|
|
diff.push('-');
|
|
diff.push_str(change.value());
|
|
}
|
|
ChangeTag::Insert => {
|
|
diff.push('+');
|
|
diff.push_str(change.value());
|
|
}
|
|
}
|
|
}
|
|
diff
|
|
}
|
|
|
|
fn format_snapshot_text(text: &str, options: &ContextSnapshotOptions) -> String {
|
|
match options.render_mode {
|
|
ContextSnapshotRenderMode::RedactedText => {
|
|
normalize_snapshot_line_endings(&canonicalize_snapshot_text(text)).replace('\n', "\\n")
|
|
}
|
|
ContextSnapshotRenderMode::FullText => {
|
|
normalize_snapshot_line_endings(text).replace('\n', "\\n")
|
|
}
|
|
ContextSnapshotRenderMode::KindWithTextPrefix { max_chars } => {
|
|
let normalized = normalize_snapshot_line_endings(&canonicalize_snapshot_text(text))
|
|
.replace('\n', "\\n");
|
|
if normalized.chars().count() <= max_chars {
|
|
normalized
|
|
} else {
|
|
let prefix = normalized.chars().take(max_chars).collect::<String>();
|
|
format!("{prefix}...")
|
|
}
|
|
}
|
|
ContextSnapshotRenderMode::KindOnly => unreachable!(),
|
|
}
|
|
}
|
|
|
|
fn normalize_snapshot_line_endings(text: &str) -> String {
|
|
text.replace("\r\n", "\n").replace('\r', "\n")
|
|
}
|
|
|
|
fn canonicalize_snapshot_text(text: &str) -> String {
|
|
if text.starts_with("<permissions instructions>") {
|
|
return "<PERMISSIONS_INSTRUCTIONS>".to_string();
|
|
}
|
|
if text.starts_with(APPS_INSTRUCTIONS_OPEN_TAG) {
|
|
return "<APPS_INSTRUCTIONS>".to_string();
|
|
}
|
|
if text.starts_with(SKILLS_INSTRUCTIONS_OPEN_TAG) {
|
|
return "<SKILLS_INSTRUCTIONS>".to_string();
|
|
}
|
|
if text.starts_with(PLUGINS_INSTRUCTIONS_OPEN_TAG) {
|
|
return "<PLUGINS_INSTRUCTIONS>".to_string();
|
|
}
|
|
if text.starts_with("# AGENTS.md instructions for ") {
|
|
return "<AGENTS_MD>".to_string();
|
|
}
|
|
if text.starts_with("<environment_context>") {
|
|
let subagent_count = text
|
|
.split_once("<subagents>")
|
|
.and_then(|(_, rest)| rest.split_once("</subagents>"))
|
|
.map(|(subagents, _)| {
|
|
subagents
|
|
.lines()
|
|
.filter(|line| line.trim_start().starts_with("- "))
|
|
.count()
|
|
})
|
|
.unwrap_or(0);
|
|
let subagents_suffix = if subagent_count > 0 {
|
|
format!(":subagents={subagent_count}")
|
|
} else {
|
|
String::new()
|
|
};
|
|
if let (Some(cwd_start), Some(cwd_end)) = (text.find("<cwd>"), text.find("</cwd>")) {
|
|
let cwd = &text[cwd_start + "<cwd>".len()..cwd_end];
|
|
return if cwd.ends_with("PRETURN_CONTEXT_DIFF_CWD") {
|
|
format!("<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD{subagents_suffix}>")
|
|
} else {
|
|
format!("<ENVIRONMENT_CONTEXT:cwd=<CWD>{subagents_suffix}>")
|
|
};
|
|
}
|
|
return if subagent_count > 0 {
|
|
format!("<ENVIRONMENT_CONTEXT{subagents_suffix}>")
|
|
} else {
|
|
"<ENVIRONMENT_CONTEXT>".to_string()
|
|
};
|
|
}
|
|
if text.starts_with("You are performing a CONTEXT CHECKPOINT COMPACTION.") {
|
|
return "<SUMMARIZATION_PROMPT>".to_string();
|
|
}
|
|
if text.starts_with("Another language model started to solve this problem")
|
|
&& let Some((_, summary)) = text.split_once('\n')
|
|
{
|
|
return format!("<COMPACTION_SUMMARY>\n{summary}");
|
|
}
|
|
normalize_dynamic_snapshot_paths(text)
|
|
}
|
|
|
|
fn is_capability_instruction_text(text: &str) -> bool {
|
|
text.starts_with(APPS_INSTRUCTIONS_OPEN_TAG)
|
|
|| text.starts_with(SKILLS_INSTRUCTIONS_OPEN_TAG)
|
|
|| text.starts_with(PLUGINS_INSTRUCTIONS_OPEN_TAG)
|
|
}
|
|
|
|
fn normalize_dynamic_snapshot_paths(text: &str) -> String {
|
|
static SYSTEM_SKILL_PATH_RE: OnceLock<Regex> = OnceLock::new();
|
|
let system_skill_path_re = SYSTEM_SKILL_PATH_RE.get_or_init(|| {
|
|
Regex::new(r"/[^)\n]*/skills/\.system/([^/\n]+)/SKILL\.md")
|
|
.expect("system skill path regex should compile")
|
|
});
|
|
system_skill_path_re
|
|
.replace_all(text, "<SYSTEM_SKILLS_ROOT>/$1/SKILL.md")
|
|
.into_owned()
|
|
}
|
|
|
|
fn normalize_snapshot_dynamic_values(text: &str) -> String {
|
|
static UUID_RE: OnceLock<Regex> = OnceLock::new();
|
|
let uuid_re = UUID_RE.get_or_init(|| {
|
|
Regex::new(
|
|
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b",
|
|
)
|
|
.expect("uuid regex should compile")
|
|
});
|
|
static TURN_STARTED_AT_UNIX_MS_RE: OnceLock<Regex> = OnceLock::new();
|
|
let turn_started_at_unix_ms_re = TURN_STARTED_AT_UNIX_MS_RE.get_or_init(|| {
|
|
Regex::new(r#""turn_started_at_unix_ms":\d+"#)
|
|
.expect("turn_started_at_unix_ms regex should compile")
|
|
});
|
|
static SANDBOX_RE: OnceLock<Regex> = OnceLock::new();
|
|
let sandbox_re = SANDBOX_RE
|
|
.get_or_init(|| Regex::new(r#""sandbox":"[^"]+""#).expect("sandbox regex should compile"));
|
|
let text = uuid_re.replace_all(text, "<UUID>");
|
|
let text =
|
|
turn_started_at_unix_ms_re.replace_all(&text, r#""turn_started_at_unix_ms":<UNIX_MS>"#);
|
|
sandbox_re
|
|
.replace_all(&text, r#""sandbox":"<SANDBOX>""#)
|
|
.into_owned()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::ContextSnapshotOptions;
|
|
use super::ContextSnapshotRenderMode;
|
|
use super::format_response_items_snapshot;
|
|
use super::format_snapshot_json_string;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn full_text_mode_preserves_unredacted_text() {
|
|
let items = vec![json!({
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{
|
|
"type": "input_text",
|
|
"text": "# AGENTS.md instructions for /tmp/example\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>"
|
|
}]
|
|
})];
|
|
|
|
let rendered = format_response_items_snapshot(
|
|
&items,
|
|
&ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::FullText),
|
|
);
|
|
|
|
assert_eq!(
|
|
rendered,
|
|
r"00:message/user:# AGENTS.md instructions for /tmp/example\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn full_text_mode_normalizes_crlf_line_endings() {
|
|
let items = vec![json!({
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{
|
|
"type": "input_text",
|
|
"text": "line one\r\n\r\nline two"
|
|
}]
|
|
})];
|
|
|
|
let rendered = format_response_items_snapshot(
|
|
&items,
|
|
&ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::FullText),
|
|
);
|
|
|
|
assert_eq!(rendered, r"00:message/user:line one\n\nline two");
|
|
}
|
|
|
|
#[test]
|
|
fn redacted_text_mode_keeps_canonical_placeholders() {
|
|
let items = vec![json!({
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{
|
|
"type": "input_text",
|
|
"text": "# AGENTS.md instructions for /tmp/example\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>"
|
|
}]
|
|
})];
|
|
|
|
let rendered = format_response_items_snapshot(
|
|
&items,
|
|
&ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::RedactedText),
|
|
);
|
|
|
|
assert_eq!(rendered, "00:message/user:<AGENTS_MD>");
|
|
}
|
|
|
|
#[test]
|
|
fn redacted_text_mode_keeps_capability_instruction_placeholders() {
|
|
let items = vec![json!({
|
|
"type": "message",
|
|
"role": "developer",
|
|
"content": [
|
|
{
|
|
"type": "input_text",
|
|
"text": "<apps_instructions>\n## Apps\nbody\n</apps_instructions>"
|
|
},
|
|
{
|
|
"type": "input_text",
|
|
"text": "<skills_instructions>\n## Skills\nbody\n</skills_instructions>"
|
|
},
|
|
{
|
|
"type": "input_text",
|
|
"text": "<plugins_instructions>\n## Plugins\nbody\n</plugins_instructions>"
|
|
}
|
|
]
|
|
})];
|
|
|
|
let rendered = format_response_items_snapshot(
|
|
&items,
|
|
&ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::RedactedText),
|
|
);
|
|
|
|
assert_eq!(
|
|
rendered,
|
|
"00:message/developer[3]:\n [01] <APPS_INSTRUCTIONS>\n [02] <SKILLS_INSTRUCTIONS>\n [03] <PLUGINS_INSTRUCTIONS>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn strip_capability_instructions_omits_capability_parts_from_developer_messages() {
|
|
let items = vec![json!({
|
|
"type": "message",
|
|
"role": "developer",
|
|
"content": [
|
|
{ "type": "input_text", "text": "<permissions instructions>\n...</permissions instructions>" },
|
|
{ "type": "input_text", "text": "<skills_instructions>\n## Skills\n...</skills_instructions>" },
|
|
{ "type": "input_text", "text": "<plugins_instructions>\n## Plugins\n...</plugins_instructions>" }
|
|
]
|
|
})];
|
|
|
|
let rendered = format_response_items_snapshot(
|
|
&items,
|
|
&ContextSnapshotOptions::default()
|
|
.render_mode(ContextSnapshotRenderMode::RedactedText)
|
|
.strip_capability_instructions(),
|
|
);
|
|
|
|
assert_eq!(rendered, "00:message/developer:<PERMISSIONS_INSTRUCTIONS>");
|
|
}
|
|
|
|
#[test]
|
|
fn strip_agents_md_user_context_omits_agents_fragment_from_user_messages() {
|
|
let items = vec![json!({
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "input_text",
|
|
"text": "# AGENTS.md instructions for /tmp/example\n\n<INSTRUCTIONS>\n- test\n</INSTRUCTIONS>"
|
|
},
|
|
{
|
|
"type": "input_text",
|
|
"text": "<environment_context>\n <cwd>/tmp/example</cwd>\n</environment_context>"
|
|
}
|
|
]
|
|
})];
|
|
|
|
let rendered = format_response_items_snapshot(
|
|
&items,
|
|
&ContextSnapshotOptions::default()
|
|
.render_mode(ContextSnapshotRenderMode::RedactedText)
|
|
.strip_agents_md_user_context(),
|
|
);
|
|
|
|
assert_eq!(rendered, "00:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>");
|
|
}
|
|
|
|
#[test]
|
|
fn redacted_text_mode_normalizes_environment_context_with_subagents() {
|
|
let items = vec![json!({
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{
|
|
"type": "input_text",
|
|
"text": "<environment_context>\n <cwd>/tmp/example</cwd>\n <shell>bash</shell>\n <subagents>\n - agent-1: atlas\n - agent-2\n </subagents>\n</environment_context>"
|
|
}]
|
|
})];
|
|
|
|
let rendered = format_response_items_snapshot(
|
|
&items,
|
|
&ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::RedactedText),
|
|
);
|
|
|
|
assert_eq!(
|
|
rendered,
|
|
"00:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>:subagents=2>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn kind_with_text_prefix_mode_normalizes_crlf_line_endings() {
|
|
let items = vec![json!({
|
|
"type": "message",
|
|
"role": "developer",
|
|
"content": [{
|
|
"type": "input_text",
|
|
"text": "<realtime_conversation>\r\nRealtime conversation started.\r\n\r\nYou are..."
|
|
}]
|
|
})];
|
|
|
|
let rendered = format_response_items_snapshot(
|
|
&items,
|
|
&ContextSnapshotOptions::default()
|
|
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }),
|
|
);
|
|
|
|
assert_eq!(
|
|
rendered,
|
|
r"00:message/developer:<realtime_conversation>\nRealtime conversation started.\n\nYou a..."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn image_only_message_is_rendered_as_non_text_span() {
|
|
let items = vec![json!({
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{
|
|
"type": "input_image",
|
|
"image_url": "data:image/png;base64,AAAA"
|
|
}]
|
|
})];
|
|
|
|
let rendered = format_response_items_snapshot(&items, &ContextSnapshotOptions::default());
|
|
|
|
assert_eq!(rendered, "00:message/user:<input_image:image_url>");
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_text_and_image_message_keeps_image_span() {
|
|
let items = vec![json!({
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "input_text",
|
|
"text": "<image>"
|
|
},
|
|
{
|
|
"type": "input_image",
|
|
"image_url": "data:image/png;base64,AAAA"
|
|
},
|
|
{
|
|
"type": "input_text",
|
|
"text": "</image>"
|
|
}
|
|
]
|
|
})];
|
|
|
|
let rendered = format_response_items_snapshot(&items, &ContextSnapshotOptions::default());
|
|
|
|
assert_eq!(
|
|
rendered,
|
|
"00:message/user[3]:\n [01] <image>\n [02] <input_image:image_url>\n [03] </image>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn redacted_text_mode_normalizes_system_skill_temp_paths() {
|
|
let items = vec![json!({
|
|
"type": "message",
|
|
"role": "developer",
|
|
"content": [{
|
|
"type": "input_text",
|
|
"text": "## Skills\n- openai-docs: helper (file: /private/var/folders/yk/p4jp9nzs79s5q84csslkgqtm0000gn/T/.tmpAnGVww/skills/.system/openai-docs/SKILL.md)"
|
|
}]
|
|
})];
|
|
|
|
let rendered = format_response_items_snapshot(&items, &ContextSnapshotOptions::default());
|
|
|
|
assert_eq!(
|
|
rendered,
|
|
"00:message/developer:## Skills\\n- openai-docs: helper (file: <SYSTEM_SKILLS_ROOT>/openai-docs/SKILL.md)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn redacted_text_mode_normalizes_turn_metadata_dynamic_json_strings() {
|
|
let rendered = format_snapshot_json_string(
|
|
r#"{"turn_id":"019eaded-ba5c-7d40-8a81-a4dcebc4679e","sandbox":"seccomp","turn_started_at_unix_ms":1781035793002}"#,
|
|
&ContextSnapshotOptions::default(),
|
|
);
|
|
|
|
assert_eq!(
|
|
rendered,
|
|
r#"{"turn_id":"<UUID>","sandbox":"<SANDBOX>","turn_started_at_unix_ms":<UNIX_MS>}"#
|
|
);
|
|
}
|
|
}
|