mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
## Description This PR adds an optional `metadata` field to `ResponseItem` for Responses API calls. Only mechanical plumbing, no actual values populated and sent yet. Turns out just adding a new field to `ResponseItem` has quite a large blast radius already. This change is backwards compatible because `metadata` is optional and omitted when absent, so existing response items and rollout history without it still deserialize and requests that do not set it keep the same wire shape. For provider compatibility, we strip out `metadata` before non-OpenAI Responses requests so Azure and AWS Bedrock never see this field. My followup PR here will actually make use of it to start storing and passing along `turn_id`: https://github.com/openai/codex/pull/28360 ## What changed - Added `ResponseItemMetadata` with optional `turn_id`, plus optional `metadata` on Responses API item variants and inter-agent communication. - Preserved item metadata through response-item rewrites such as truncation, missing tool-output synthesis, compaction history rebuilding, visible-history conversion, rollout/resume, and generated app-server schemas/types. - Strip item metadata from non-OpenAI Responses requests while preserving it for OpenAI-shaped requests. - Updated the mechanical fixture/test construction churn required by the new optional field.
333 lines
11 KiB
Rust
333 lines
11 KiB
Rust
use super::CURRENT_THREAD_SECTION_TOKEN_BUDGET;
|
|
use super::NOTES_SECTION_TOKEN_BUDGET;
|
|
use super::RECENT_WORK_SECTION_TOKEN_BUDGET;
|
|
use super::STARTUP_CONTEXT_HEADER;
|
|
use super::WORKSPACE_SECTION_TOKEN_BUDGET;
|
|
use super::build_current_thread_section;
|
|
use super::build_recent_work_section;
|
|
use super::build_workspace_section_with_user_root;
|
|
use super::format_section;
|
|
use super::format_startup_context_blob;
|
|
use chrono::TimeZone;
|
|
use chrono::Utc;
|
|
use codex_git_utils::GitSha;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::GitInfo;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_thread_store::StoredThread;
|
|
use core_test_support::PathBufExt;
|
|
use core_test_support::PathExt;
|
|
use pretty_assertions::assert_eq;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use tempfile::TempDir;
|
|
|
|
fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThread {
|
|
StoredThread {
|
|
thread_id: ThreadId::new(),
|
|
extra_config: None,
|
|
rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")),
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
preview: first_user_message.to_string(),
|
|
name: (!title.is_empty()).then(|| title.to_string()),
|
|
model_provider: "test-provider".to_string(),
|
|
model: Some("gpt-5.2".to_string()),
|
|
reasoning_effort: None,
|
|
created_at: Utc
|
|
.timestamp_opt(1_709_251_100, 0)
|
|
.single()
|
|
.expect("valid timestamp"),
|
|
updated_at: Utc
|
|
.timestamp_opt(1_709_251_200, 0)
|
|
.single()
|
|
.expect("valid timestamp"),
|
|
archived_at: None,
|
|
cwd: PathBuf::from(cwd),
|
|
cli_version: "test".to_string(),
|
|
source: SessionSource::Cli,
|
|
thread_source: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
agent_path: None,
|
|
git_info: Some(GitInfo {
|
|
commit_hash: Some(GitSha::new("abcdef")),
|
|
branch: Some("main".to_string()),
|
|
repository_url: None,
|
|
}),
|
|
approval_mode: AskForApproval::Never,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
token_usage: None,
|
|
first_user_message: Some(first_user_message.to_string()),
|
|
history: None,
|
|
}
|
|
}
|
|
|
|
fn message(role: &str, content: ContentItem) -> ResponseItem {
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: role.to_string(),
|
|
content: vec![content],
|
|
phase: None,
|
|
metadata: None,
|
|
}
|
|
}
|
|
|
|
fn user_message(text: impl Into<String>) -> ResponseItem {
|
|
message("user", ContentItem::InputText { text: text.into() })
|
|
}
|
|
|
|
fn assistant_message(text: impl Into<String>) -> ResponseItem {
|
|
message("assistant", ContentItem::OutputText { text: text.into() })
|
|
}
|
|
|
|
fn long_turn_text(index: usize) -> String {
|
|
format!(
|
|
"turn-{index}-start {} turn-{index}-middle {} turn-{index}-end",
|
|
"head filler ".repeat(160),
|
|
"tail filler ".repeat(240),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn current_thread_section_includes_short_turns_newest_first_until_budget() {
|
|
let items = vec![
|
|
user_message("user turn 1"),
|
|
assistant_message("assistant turn 1"),
|
|
user_message("user turn 2"),
|
|
assistant_message("assistant turn 2"),
|
|
user_message("user turn 3"),
|
|
assistant_message("assistant turn 3"),
|
|
user_message("user turn 4"),
|
|
assistant_message("assistant turn 4"),
|
|
];
|
|
|
|
assert_eq!(
|
|
build_current_thread_section(&items),
|
|
Some(
|
|
r#"Most recent user/assistant turns from this exact thread. Use them for continuity when responding.
|
|
|
|
### Latest turn
|
|
User:
|
|
user turn 4
|
|
|
|
Assistant:
|
|
assistant turn 4
|
|
|
|
### Previous turn 1
|
|
User:
|
|
user turn 3
|
|
|
|
Assistant:
|
|
assistant turn 3
|
|
|
|
### Previous turn 2
|
|
User:
|
|
user turn 2
|
|
|
|
Assistant:
|
|
assistant turn 2
|
|
|
|
### Previous turn 3
|
|
User:
|
|
user turn 1
|
|
|
|
Assistant:
|
|
assistant turn 1"#
|
|
.to_string()
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn current_thread_turn_truncation_preserves_start_and_end() {
|
|
let items = vec![user_message(long_turn_text(/*index*/ 0))];
|
|
let section = build_current_thread_section(&items).expect("current thread section");
|
|
|
|
assert_eq!(
|
|
(
|
|
section.contains("turn-0-start"),
|
|
section.contains("turn-0-middle"),
|
|
section.contains("turn-0-end"),
|
|
section.contains("tokens truncated"),
|
|
),
|
|
(true, false, true, true),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn current_thread_section_keeps_latest_turns_when_history_exceeds_budget() {
|
|
let mut items = Vec::new();
|
|
for index in 1..=8 {
|
|
items.push(user_message(long_turn_text(index)));
|
|
items.push(assistant_message(format!("assistant turn {index}")));
|
|
}
|
|
|
|
let section = build_current_thread_section(&items).expect("current thread section");
|
|
|
|
assert_eq!(
|
|
(
|
|
section.contains("turn-8-start"),
|
|
section.contains("turn-8-end"),
|
|
section.contains("### Previous turn 2"),
|
|
section.contains("turn-1-start"),
|
|
section.contains("turn-1-end"),
|
|
),
|
|
(true, true, true, false, false),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn startup_context_blob_is_wrapped_in_tags_without_final_truncation() {
|
|
let body = "Startup context from Codex.\n## Current Thread\nhello";
|
|
let wrapped = format_startup_context_blob(body);
|
|
|
|
assert_eq!(
|
|
wrapped,
|
|
"<startup_context>\nStartup context from Codex.\n## Current Thread\nhello\n</startup_context>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fixed_section_budgets_apply_per_section_without_total_blob_truncation() {
|
|
let body = [
|
|
STARTUP_CONTEXT_HEADER.to_string(),
|
|
format_section(
|
|
"Current Thread",
|
|
Some("current thread ".repeat(2_000)),
|
|
CURRENT_THREAD_SECTION_TOKEN_BUDGET,
|
|
)
|
|
.expect("current thread section"),
|
|
format_section(
|
|
"Recent Work",
|
|
Some("recent work ".repeat(3_000)),
|
|
RECENT_WORK_SECTION_TOKEN_BUDGET,
|
|
)
|
|
.expect("recent work section"),
|
|
format_section(
|
|
"Machine / Workspace Map",
|
|
Some("workspace map ".repeat(2_500)),
|
|
WORKSPACE_SECTION_TOKEN_BUDGET,
|
|
)
|
|
.expect("workspace section"),
|
|
format_section(
|
|
"Notes",
|
|
Some("notes ".repeat(500)),
|
|
NOTES_SECTION_TOKEN_BUDGET,
|
|
)
|
|
.expect("notes section"),
|
|
]
|
|
.join("\n\n");
|
|
|
|
let wrapped = format_startup_context_blob(&body);
|
|
|
|
assert!(wrapped.starts_with("<startup_context>\n"));
|
|
assert!(wrapped.ends_with("\n</startup_context>"));
|
|
assert!(wrapped.contains("tokens truncated"));
|
|
assert!(wrapped.contains("## Current Thread"));
|
|
assert!(wrapped.contains("## Recent Work"));
|
|
assert!(wrapped.contains("## Machine / Workspace Map"));
|
|
assert!(wrapped.contains("## Notes"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_section_requires_meaningful_structure() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
assert_eq!(
|
|
build_workspace_section_with_user_root(&cwd.path().abs(), /*user_root*/ None).await,
|
|
None
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_section_includes_tree_when_entries_exist() {
|
|
let cwd = TempDir::new().expect("tempdir");
|
|
fs::create_dir(cwd.path().join("docs")).expect("create docs dir");
|
|
fs::write(cwd.path().join("README.md"), "hello").expect("write readme");
|
|
|
|
let section =
|
|
build_workspace_section_with_user_root(&cwd.path().abs(), /*user_root*/ None)
|
|
.await
|
|
.expect("workspace section");
|
|
assert!(section.contains("Working directory tree:"));
|
|
assert!(section.contains("- docs/"));
|
|
assert!(section.contains("- README.md"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn workspace_section_includes_user_root_tree_when_distinct() {
|
|
let root = TempDir::new().expect("tempdir");
|
|
let cwd = root.path().join("cwd");
|
|
let git_root = root.path().join("git");
|
|
let user_root = root.path().join("home");
|
|
|
|
fs::create_dir_all(cwd.join("docs")).expect("create cwd docs dir");
|
|
fs::write(cwd.join("README.md"), "hello").expect("write cwd readme");
|
|
fs::create_dir_all(git_root.join(".git")).expect("create git dir");
|
|
fs::write(git_root.join("Cargo.toml"), "[workspace]").expect("write git root marker");
|
|
fs::create_dir_all(user_root.join("code")).expect("create user root child");
|
|
fs::write(user_root.join(".zshrc"), "export TEST=1").expect("write home file");
|
|
|
|
let section = build_workspace_section_with_user_root(&cwd.abs(), Some(user_root))
|
|
.await
|
|
.expect("workspace section");
|
|
assert!(section.contains("User root tree:"));
|
|
assert!(section.contains("- code/"));
|
|
assert!(!section.contains("- .zshrc"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recent_work_section_groups_threads_by_cwd() {
|
|
let root = TempDir::new().expect("tempdir");
|
|
let repo = root.path().join("repo");
|
|
let workspace_a = repo.join("workspace-a");
|
|
let workspace_b = repo.join("workspace-b");
|
|
let outside = root.path().join("outside");
|
|
|
|
fs::create_dir(&repo).expect("create repo dir");
|
|
Command::new("git")
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
|
.args(["init"])
|
|
.current_dir(&repo)
|
|
.output()
|
|
.expect("git init");
|
|
fs::create_dir_all(&workspace_a).expect("create workspace a");
|
|
fs::create_dir_all(&workspace_b).expect("create workspace b");
|
|
fs::create_dir_all(&outside).expect("create outside dir");
|
|
|
|
let recent_threads = vec![
|
|
stored_thread(
|
|
workspace_a.to_string_lossy().as_ref(),
|
|
"Investigate realtime startup context",
|
|
"Log the startup context before sending it",
|
|
),
|
|
stored_thread(
|
|
workspace_b.to_string_lossy().as_ref(),
|
|
"Trim websocket startup payload",
|
|
"Remove memories from the realtime startup context",
|
|
),
|
|
stored_thread(outside.to_string_lossy().as_ref(), "", "Inspect flaky test"),
|
|
];
|
|
let current_cwd = workspace_a;
|
|
let repo = repo.abs();
|
|
|
|
let section = build_recent_work_section(¤t_cwd.abs(), &recent_threads)
|
|
.await
|
|
.expect("recent work section");
|
|
assert!(section.contains(&format!("### Git repo: {}", repo.display())));
|
|
assert!(section.contains("Recent sessions: 2"));
|
|
assert!(section.contains("User asks:"));
|
|
assert!(section.contains(&format!(
|
|
"- {}: Log the startup context before sending it",
|
|
current_cwd.display()
|
|
)));
|
|
assert!(section.contains(&format!("### Directory: {}", outside.display())));
|
|
assert!(section.contains(&format!("- {}: Inspect flaky test", outside.display())));
|
|
}
|