Merge origin/upstream-merge: adopt MCP conversation id refactor

This commit is contained in:
James Peter
2025-09-19 09:45:42 +10:00
37 changed files with 624 additions and 587 deletions
+20 -4
View File
@@ -64,7 +64,6 @@ async fn run_command_under_sandbox(
sandbox_type: SandboxType,
) -> anyhow::Result<()> {
let sandbox_mode = create_sandbox_mode(full_auto);
let cwd = std::env::current_dir()?;
let config = Config::load_with_cli_overrides(
config_overrides
.parse_overrides()
@@ -75,13 +74,29 @@ async fn run_command_under_sandbox(
..Default::default()
},
)?;
// In practice, this should be `std::env::current_dir()` because this CLI
// does not support `--cwd`, but let's use the config value for consistency.
let cwd = config.cwd.clone();
// For now, we always use the same cwd for both the command and the
// sandbox policy. In the future, we could add a CLI option to set them
// separately.
let sandbox_policy_cwd = cwd.clone();
let stdio_policy = StdioPolicy::Inherit;
let env = create_env(&config.shell_environment_policy);
let mut child = match sandbox_type {
SandboxType::Seatbelt => {
spawn_command_under_seatbelt(command, &config.sandbox_policy, cwd, stdio_policy, env)
.await?
spawn_command_under_seatbelt(
command,
cwd,
&config.sandbox_policy,
sandbox_policy_cwd.as_path(),
stdio_policy,
env,
)
.await?
}
SandboxType::Landlock => {
#[expect(clippy::expect_used)]
@@ -91,8 +106,9 @@ async fn run_command_under_sandbox(
spawn_command_under_linux_sandbox(
codex_linux_sandbox_exe,
command,
&config.sandbox_policy,
cwd,
&config.sandbox_policy,
sandbox_policy_cwd.as_path(),
stdio_policy,
env,
)
+7 -2
View File
@@ -4,9 +4,9 @@ name = "codex-core"
version = { workspace = true }
[lib]
doctest = false
name = "codex_core"
path = "src/lib.rs"
doctest = false
[lints]
workspace = true
@@ -41,7 +41,12 @@ similar = "2.7.0"
strum_macros = "0.27.2"
tempfile = "3"
thiserror = "2.0.16"
time = { version = "0.3", features = ["formatting", "parsing", "local-offset", "macros"] }
time = { version = "0.3", features = [
"formatting",
"parsing",
"local-offset",
"macros",
] }
tokio = { version = "1", features = [
"io-std",
"macros",
+6 -3
View File
@@ -73,6 +73,9 @@ pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option<V
}
}
// Walk uses a stack (LIFO), so re-sort by position to restore source order.
command_nodes.sort_by_key(|node| node.start_byte());
let mut commands = Vec::new();
for node in command_nodes {
if let Some(words) = parse_plain_command_from_node(node, src) {
@@ -150,10 +153,10 @@ mod tests {
let src = "ls && pwd; echo 'hi there' | wc -l";
let cmds = parse_seq(src).unwrap();
let expected: Vec<Vec<String>> = vec![
vec!["wc".to_string(), "-l".to_string()],
vec!["echo".to_string(), "hi there".to_string()],
vec!["pwd".to_string()],
vec!["ls".to_string()],
vec!["pwd".to_string()],
vec!["echo".to_string(), "hi there".to_string()],
vec!["wc".to_string(), "-l".to_string()],
];
assert_eq!(cmds, expected);
}
+5 -5
View File
@@ -91,14 +91,13 @@ impl Default for Prompt {
}
impl Prompt {
pub(crate) fn get_full_instructions(&self, model: &ModelFamily) -> Cow<'_, str> {
pub(crate) fn get_full_instructions<'a>(&'a self, model: &'a ModelFamily) -> Cow<'a, str> {
let effective_model = self.model_family_override.as_ref().unwrap_or(model);
let base = self
.base_instructions_override
.as_deref()
.unwrap_or(effective_model.base_instructions.deref());
let mut sections: Vec<&str> = vec![base];
let _sections: Vec<&str> = vec![base];
// When there are no custom instructions, add apply_patch_tool_instructions if:
// - the model needs special instructions (4.1)
// AND
@@ -112,9 +111,10 @@ impl Prompt {
&& effective_model.needs_special_apply_patch_instructions
&& !is_apply_patch_tool_present
{
sections.push(APPLY_PATCH_TOOL_INSTRUCTIONS);
Cow::Owned(format!("{base}\n{APPLY_PATCH_TOOL_INSTRUCTIONS}"))
} else {
Cow::Borrowed(base)
}
Cow::Owned(sections.join("\n"))
}
fn get_formatted_user_instructions(&self) -> Option<String> {
+15 -17
View File
@@ -43,7 +43,7 @@ use crate::protocol::WebSearchCompleteEvent;
use codex_protocol::models::WebSearchAction;
use codex_protocol::protocol::RolloutItem;
mod compact;
pub mod compact;
use self::compact::build_compacted_history;
use self::compact::collect_user_messages;
@@ -1356,17 +1356,11 @@ impl Session {
self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone(), seq_hint, output_index, attempt_req)
.await;
let ExecInvokeArgs { params, sandbox_type, sandbox_policy, codex_linux_sandbox_exe, stdout_stream } = exec_args;
let ExecInvokeArgs { params, sandbox_type, sandbox_policy, sandbox_cwd, codex_linux_sandbox_exe, stdout_stream } = exec_args;
let tracking_command = params.command.clone();
let dry_run_analysis = analyze_command(&tracking_command);
let result = process_exec_tool_call(
params,
sandbox_type,
sandbox_policy,
codex_linux_sandbox_exe,
stdout_stream,
)
let result = process_exec_tool_call(params, sandbox_type, sandbox_policy, sandbox_cwd, codex_linux_sandbox_exe, stdout_stream)
.await;
let output_stderr;
@@ -1975,7 +1969,7 @@ async fn submission_loop(
match RolloutRecorder::new(
&config,
crate::rollout::recorder::RolloutRecorderParams::new(
codex_protocol::mcp_protocol::ConversationId(session_id),
codex_protocol::mcp_protocol::ConversationId::from(session_id),
effective_user_instructions.clone(),
),
)
@@ -4343,6 +4337,7 @@ pub struct ExecInvokeArgs<'a> {
pub params: ExecParams,
pub sandbox_type: SandboxType,
pub sandbox_policy: &'a SandboxPolicy,
pub sandbox_cwd: &'a std::path::Path,
pub codex_linux_sandbox_exe: &'a Option<PathBuf>,
pub stdout_stream: Option<StdoutStream>,
}
@@ -5586,13 +5581,14 @@ async fn handle_container_exec_with_params(
.run_exec_with_events(
turn_diff_tracker,
exec_command_context,
ExecInvokeArgs {
params: patch_params.clone(),
sandbox_type: match safety { SafetyCheck::AutoApprove { sandbox_type } => sandbox_type, SafetyCheck::AskUser => SandboxType::None, SafetyCheck::Reject { .. } => SandboxType::None },
sandbox_policy: &sess.sandbox_policy,
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
stdout_stream: None,
},
ExecInvokeArgs {
params: patch_params.clone(),
sandbox_type: match safety { SafetyCheck::AutoApprove { sandbox_type } => sandbox_type, SafetyCheck::AskUser => SandboxType::None, SafetyCheck::Reject { .. } => SandboxType::None },
sandbox_policy: &sess.sandbox_policy,
sandbox_cwd: sess.get_cwd(),
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
stdout_stream: None,
},
seq_hint, // occupy the provided sequence range first
output_index,
attempt_req,
@@ -5961,6 +5957,7 @@ async fn handle_container_exec_with_params(
params: params.clone(),
sandbox_type,
sandbox_policy: &sess.sandbox_policy,
sandbox_cwd: sess.get_cwd(),
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
stdout_stream: if exec_command_context.apply_patch.is_some() {
None
@@ -6102,6 +6099,7 @@ async fn handle_sandbox_error(
params,
sandbox_type: SandboxType::None,
sandbox_policy: &sess.sandbox_policy,
sandbox_cwd: sess.get_cwd(),
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
stdout_stream: if exec_command_context.apply_patch.is_some() {
None
+113 -167
View File
@@ -1,33 +1,28 @@
use std::sync::Arc;
use super::get_last_assistant_message_from_turn;
use super::AgentTask;
use super::MutexExt;
use super::Session;
use super::TurnContext;
use super::debug_history;
use super::get_last_assistant_message_from_turn;
use super::response_input_from_core_items;
use crate::Prompt;
use crate::client_common::ResponseEvent;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::model_family::find_family_for_model;
use crate::protocol::AgentMessageEvent;
use crate::protocol::ErrorEvent;
use crate::protocol::EventMsg;
use crate::protocol::InputItem;
use crate::protocol::TaskCompleteEvent;
use crate::util::backoff;
use askama::Template;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::InputMessageKind;
use codex_protocol::protocol::RolloutItem;
use futures::prelude::*;
use codex_protocol::protocol::TurnContextItem;
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::protocol::AskForApproval as AskForApprovalCore;
use crate::protocol::SandboxPolicy as SandboxPolicyCore;
pub(super) const COMPACT_TRIGGER_TEXT: &str =
"Start Summarization. Only include details found in the prior conversation.";
pub(super) const COMPACT_TRIGGER_TEXT: &str = "Start Summarization";
const SUMMARIZATION_PROMPT: &str = include_str!("../../templates/compact/prompt.md");
#[derive(Template)]
@@ -58,167 +53,139 @@ pub(super) async fn run_inline_auto_compact_task(
turn_context: Arc<TurnContext>,
) -> Vec<ResponseItem> {
let sub_id = sess.next_internal_sub_id();
let input = vec![InputItem::Text {
text: COMPACT_TRIGGER_TEXT.to_string(),
}];
perform_compaction(
sess,
turn_context,
let input = vec![InputItem::Text { text: COMPACT_TRIGGER_TEXT.to_string() }];
match perform_compaction(
Arc::clone(&sess),
Arc::clone(&turn_context),
sub_id,
input,
SUMMARIZATION_PROMPT.to_string(),
false,
)
.await
{
Ok(history) => history,
Err(_) => Vec::new(),
}
}
pub(super) async fn perform_compaction(
#[allow(dead_code)]
pub(super) async fn run_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
compact_instructions: String,
) {
let _ = perform_compaction(sess, turn_context, sub_id, input, compact_instructions, true).await;
}
/// Perform a compact operation and return the rebuilt conversation history.
///
/// This minimal implementation avoids invoking the model and instead composes
/// a compacted history using the last assistant message as the summary.
pub(super) async fn perform_compaction(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
sub_id: String,
_input: Vec<InputItem>,
_compact_instructions: String,
remove_task_on_completion: bool,
) -> Vec<ResponseItem> {
sess.notify_background_event(&sub_id, "Compacting conversation...")
.await;
) -> CodexResult<Vec<ResponseItem>> {
// Signal task begin
let start_event = sess.make_event(&sub_id, EventMsg::TaskStarted);
sess.send_event(start_event).await;
let initial_input_for_turn = response_input_from_core_items(input);
let instructions_override = compact_instructions;
let turn_input = sess.turn_input_with_history(vec![initial_input_for_turn.clone().into()]);
let mut prompt = Prompt {
input: turn_input,
store: !sess.disable_response_storage,
user_instructions: None,
environment_context: None,
tools: Vec::new(),
status_items: Vec::new(),
base_instructions_override: Some(instructions_override),
include_additional_instructions: false,
text_format: None,
model_override: None,
model_family_override: None,
};
if turn_context.client.get_model() == "gpt-5-codex" {
prompt.model_override = Some("gpt-5".to_string());
if let Some(family) = find_family_for_model("gpt-5") {
prompt.model_family_override = Some(family);
}
}
let max_retries = turn_context.client.get_provider().stream_max_retries();
let mut retries = 0;
loop {
let attempt_result = drain_to_completed(&sess, turn_context.as_ref(), &prompt).await;
match attempt_result {
Ok(()) => {
break;
}
Err(CodexErr::Interrupted) => {
return Vec::new();
}
Err(e) => {
if retries < max_retries {
retries += 1;
let delay = backoff(retries);
sess.notify_stream_error(
&sub_id,
format!(
"stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…"
),
)
.await;
tokio::time::sleep(delay).await;
continue;
} else {
let event = sess.make_event(
&sub_id,
EventMsg::Error(ErrorEvent {
message: e.to_string(),
}),
);
sess.send_event(event).await;
return Vec::new();
}
}
}
}
if remove_task_on_completion {
sess.remove_task(&sub_id);
}
// Snapshot current history
let history_snapshot = {
let state = sess.state.lock_unchecked();
state.history.contents()
};
// Build compacted history using the last assistant message as the summary
let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default();
let user_messages = collect_user_messages(&history_snapshot);
let initial_context = sess.build_initial_context(turn_context.as_ref());
let new_history = build_compacted_history(initial_context, &user_messages, &summary_text);
{
let mut state = sess.state.lock_unchecked();
state.history = super::ConversationHistory::new();
}
sess.record_conversation_items(&new_history).await;
{
let state = sess.state.lock_unchecked();
let snapshot = state.history.contents();
debug_history("after_compact_record", &snapshot);
}
let rollout_item = RolloutItem::Compacted(CompactedItem {
message: summary_text.clone(),
// Persist rollout items for traceability
let ctx_item = RolloutItem::TurnContext(TurnContextItem {
cwd: turn_context.cwd.clone(),
approval_policy: map_approval(turn_context.approval_policy),
sandbox_policy: map_sandbox(&turn_context.sandbox_policy),
model: turn_context.client.get_model(),
effort: Some(map_effort(turn_context.client.get_reasoning_effort())),
summary: map_summary(turn_context.client.get_reasoning_summary()),
});
sess.persist_rollout_items(&[rollout_item]).await;
let compact_item = RolloutItem::Compacted(CompactedItem { message: summary_text.clone() });
sess.persist_rollout_items(&[ctx_item, compact_item]).await;
let message = if summary_text.trim().is_empty() {
"Compact task completed.".to_string()
} else {
summary_text.clone()
};
let event = sess.make_event(
&sub_id,
EventMsg::AgentMessage(AgentMessageEvent {
message: message.clone(),
}),
);
sess.send_event(event).await;
let assistant_summary = ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText { text: message }],
};
{
let mut state = sess.state.lock_unchecked();
state
.history
.record_items(std::slice::from_ref(&assistant_summary));
let snapshot = state.history.contents();
debug_history("after_compact_summary", &snapshot);
if remove_task_on_completion {
sess.remove_task(&sub_id);
}
sess.persist_rollout_items(&[RolloutItem::ResponseItem(assistant_summary.clone())])
.await;
let event = sess.make_event(
&sub_id,
EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
);
sess.send_event(event).await;
{
let state = sess.state.lock_unchecked();
state.history.contents()
// Notify completion
let done_msg = sess.make_event(&sub_id, EventMsg::AgentMessage(AgentMessageEvent {
message: "Compact task completed".to_string(),
}));
sess.send_event(done_msg).await;
let complete = sess.make_event(
&sub_id,
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message: None }),
);
sess.send_event(complete).await;
Ok(new_history)
}
fn map_approval(a: AskForApprovalCore) -> codex_protocol::protocol::AskForApproval {
match a {
AskForApprovalCore::UnlessTrusted => codex_protocol::protocol::AskForApproval::UnlessTrusted,
AskForApprovalCore::OnFailure => codex_protocol::protocol::AskForApproval::OnFailure,
AskForApprovalCore::OnRequest => codex_protocol::protocol::AskForApproval::OnRequest,
AskForApprovalCore::Never => codex_protocol::protocol::AskForApproval::Never,
}
}
fn content_items_to_text(content: &[ContentItem]) -> Option<String> {
fn map_sandbox(s: &SandboxPolicyCore) -> codex_protocol::protocol::SandboxPolicy {
match s {
SandboxPolicyCore::DangerFullAccess => codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
SandboxPolicyCore::ReadOnly => codex_protocol::protocol::SandboxPolicy::ReadOnly,
SandboxPolicyCore::WorkspaceWrite {
writable_roots,
network_access,
exclude_tmpdir_env_var,
exclude_slash_tmp,
..
} => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots.clone(),
network_access: *network_access,
exclude_tmpdir_env_var: *exclude_tmpdir_env_var,
exclude_slash_tmp: *exclude_slash_tmp,
},
}
}
fn map_effort(e: ReasoningEffortConfig) -> codex_protocol::config_types::ReasoningEffort {
match e {
ReasoningEffortConfig::Minimal => codex_protocol::config_types::ReasoningEffort::Minimal,
ReasoningEffortConfig::Low => codex_protocol::config_types::ReasoningEffort::Low,
ReasoningEffortConfig::Medium => codex_protocol::config_types::ReasoningEffort::Medium,
ReasoningEffortConfig::High => codex_protocol::config_types::ReasoningEffort::High,
ReasoningEffortConfig::None => codex_protocol::config_types::ReasoningEffort::Minimal,
}
}
fn map_summary(s: ReasoningSummaryConfig) -> codex_protocol::config_types::ReasoningSummary {
match s {
ReasoningSummaryConfig::Auto => codex_protocol::config_types::ReasoningSummary::Auto,
ReasoningSummaryConfig::Concise => codex_protocol::config_types::ReasoningSummary::Concise,
ReasoningSummaryConfig::Detailed => codex_protocol::config_types::ReasoningSummary::Detailed,
ReasoningSummaryConfig::None => codex_protocol::config_types::ReasoningSummary::None,
}
}
pub fn content_items_to_text(content: &[ContentItem]) -> Option<String> {
let mut pieces = Vec::new();
for item in content {
match item {
@@ -250,7 +217,7 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec<String> {
.collect()
}
fn is_session_prefix_message(text: &str) -> bool {
pub fn is_session_prefix_message(text: &str) -> bool {
matches!(
InputMessageKind::from(("user", text)),
InputMessageKind::UserInstructions | InputMessageKind::EnvironmentContext
@@ -288,32 +255,11 @@ pub(crate) fn build_compacted_history(
history
}
async fn drain_to_completed(
sess: &Session,
turn_context: &TurnContext,
prompt: &Prompt,
) -> CodexResult<()> {
let mut stream = turn_context.client.clone().stream(prompt).await?;
loop {
let maybe_event = stream.next().await;
let Some(event) = maybe_event else {
return Err(CodexErr::Stream(
"stream closed before response.completed".into(),
None,
));
};
match event {
Ok(ResponseEvent::OutputItemDone { item, .. }) => {
let mut state = sess.state.lock_unchecked();
state.history.record_items(std::slice::from_ref(&item));
}
Ok(ResponseEvent::Completed { .. }) => {
return Ok(());
}
Ok(_) => continue,
Err(e) => return Err(e),
}
}
#[allow(dead_code)]
async fn drain_to_completed() -> CodexResult<()> {
// Legacy streaming path is replaced by minimal compaction above.
// Keeping a stub to preserve upstream symbol without build warnings.
Ok(())
}
#[cfg(test)]
+14
View File
@@ -1862,6 +1862,7 @@ pub fn log_dir(cfg: &Config) -> std::io::Result<PathBuf> {
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use crate::config_types::HistoryPersistence;
use crate::config_types::Notifications;
use super::*;
use pretty_assertions::assert_eq;
@@ -1899,6 +1900,19 @@ persistence = "none"
);
}
#[test]
fn tui_config_missing_notifications_field_defaults_to_disabled() {
let cfg = r#"
[tui]
"#;
let parsed = toml::from_str::<ConfigToml>(cfg)
.expect("TUI config without notifications should succeed");
let tui = parsed.tui.expect("config should include tui section");
assert_eq!(tui.notifications, Notifications::Enabled(false));
}
#[test]
fn test_sandbox_config_parsing() {
let sandbox_full_access = r#"
+14 -4
View File
@@ -3,6 +3,7 @@ use std::os::unix::process::ExitStatusExt;
use std::collections::HashMap;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::process::ExitStatus;
use std::time::Duration;
@@ -89,6 +90,7 @@ pub async fn process_exec_tool_call(
params: ExecParams,
sandbox_type: SandboxType,
sandbox_policy: &SandboxPolicy,
sandbox_cwd: &Path,
codex_linux_sandbox_exe: &Option<PathBuf>,
stdout_stream: Option<StdoutStream>,
) -> Result<ExecToolCallOutput> {
@@ -101,12 +103,16 @@ pub async fn process_exec_tool_call(
SandboxType::None => exec(params, sandbox_policy, stdout_stream.clone()).await,
SandboxType::MacosSeatbelt => {
let ExecParams {
command, cwd, env, ..
command,
cwd: command_cwd,
env,
..
} = params;
let child = spawn_command_under_seatbelt(
command,
command_cwd,
sandbox_policy,
cwd,
sandbox_cwd,
StdioPolicy::RedirectForShellTool,
env,
)
@@ -115,7 +121,10 @@ pub async fn process_exec_tool_call(
}
SandboxType::LinuxSeccomp => {
let ExecParams {
command, cwd, env, ..
command,
cwd: command_cwd,
env,
..
} = params;
let codex_linux_sandbox_exe = codex_linux_sandbox_exe
@@ -124,8 +133,9 @@ pub async fn process_exec_tool_call(
let child = spawn_command_under_linux_sandbox(
codex_linux_sandbox_exe,
command,
command_cwd,
sandbox_policy,
cwd,
sandbox_cwd,
StdioPolicy::RedirectForShellTool,
env,
)
+3
View File
@@ -23,3 +23,6 @@ pub use session_manager::result_into_payload;
// Upstream removed the global SESSION_MANAGER; we now manage a per-session
// instance. Keep the ExecSessionManager name for minimal churn.
pub type ExecSessionManager = session_manager::SessionManager;
// Re-export ExecCommandSession for crate-internal consumers.
pub(crate) use exec_command_session::ExecCommandSession;
+9 -5
View File
@@ -16,21 +16,22 @@ use tokio::process::Child;
pub async fn spawn_command_under_linux_sandbox<P>(
codex_linux_sandbox_exe: P,
command: Vec<String>,
command_cwd: PathBuf,
sandbox_policy: &SandboxPolicy,
cwd: PathBuf,
sandbox_policy_cwd: &Path,
stdio_policy: StdioPolicy,
env: HashMap<String, String>,
) -> std::io::Result<Child>
where
P: AsRef<Path>,
{
let args = create_linux_sandbox_command_args(command, sandbox_policy, &cwd);
let args = create_linux_sandbox_command_args(command, sandbox_policy, sandbox_policy_cwd);
let arg0 = Some("codex-linux-sandbox");
spawn_child_async(
codex_linux_sandbox_exe.as_ref().to_path_buf(),
args,
arg0,
cwd,
command_cwd,
sandbox_policy,
stdio_policy,
env,
@@ -42,10 +43,13 @@ where
fn create_linux_sandbox_command_args(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
cwd: &Path,
sandbox_policy_cwd: &Path,
) -> Vec<String> {
#[expect(clippy::expect_used)]
let sandbox_policy_cwd = cwd.to_str().expect("cwd must be valid UTF-8").to_string();
let sandbox_policy_cwd = sandbox_policy_cwd
.to_str()
.expect("cwd must be valid UTF-8")
.to_string();
#[expect(clippy::expect_used)]
let sandbox_policy_json =
+40 -30
View File
@@ -1,34 +1,27 @@
//! Root of the `codex-core` library.
//! This module provides the core functionality for the codex CLI.
// Prevent accidental direct writes to stdout/stderr in library code. All
// user-visible output must go through the appropriate abstraction (e.g.,
// the TUI or the tracing stack).
#![deny(clippy::print_stdout, clippy::print_stderr)]
mod agent_tool;
pub mod agent_defaults;
mod apply_patch;
pub mod auth;
mod bash;
pub mod bash;
mod chat_completions;
mod client;
mod client_common;
pub mod codex;
pub mod debug_logger;
pub mod slash_commands;
pub use codex::Codex;
pub use codex::CodexSpawnOk;
mod codex_conversation;
pub mod token_data;
pub use codex_conversation::CodexConversation;
pub mod config;
pub mod config_edit;
pub mod config_profile;
pub mod config_types;
pub mod config_edit;
mod conversation_history;
mod dry_run_guard;
pub mod custom_prompts;
pub mod debug_logger;
mod environment_context;
pub mod error;
pub mod exec;
@@ -36,21 +29,31 @@ mod exec_command;
pub mod exec_env;
mod flags;
pub mod git_info;
pub mod git_worktree;
mod image_comparison;
pub mod internal_storage;
mod is_safe_command;
pub mod landlock;
pub mod mcp_connection_manager;
pub mod http_client;
mod mcp_connection_manager;
mod mcp_tool_call;
mod message_history;
mod model_provider_info;
pub mod agent_defaults;
mod agent_tool;
mod dry_run_guard;
mod image_comparison;
pub mod git_worktree;
pub mod slash_commands;
pub mod parse_command;
mod truncate;
mod unified_exec;
mod user_instructions;
pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID;
pub use model_provider_info::ModelProviderInfo;
pub use model_provider_info::WireApi;
pub use model_provider_info::built_in_model_providers;
pub use model_provider_info::create_oss_provider_with_base_url;
mod conversation_manager;
pub mod protocol;
mod event_mapping;
pub mod review_format;
pub use codex_protocol::protocol::InitialHistory;
@@ -72,32 +75,39 @@ pub mod shell;
pub mod spawn;
pub mod terminal;
mod tool_apply_patch;
mod user_instructions;
pub mod turn_diff_tracker;
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
pub use rollout::RolloutRecorder;
pub use rollout::SESSIONS_SUBDIR;
pub use rollout::SessionMeta;
pub use rollout::find_conversation_path_by_id_str;
pub use rollout::list::ConversationItem;
pub use rollout::list::ConversationsPage;
pub use rollout::list::Cursor;
pub use rollout::list::find_conversation_path_by_id_str;
pub use rollout::recorder::RolloutRecorder;
mod user_notification;
pub mod util;
pub mod http_client;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
pub use safety::get_platform_sandbox;
// Use our local protocol definitions to preserve custom events and input items.
pub mod protocol;
// Optionally expose upstream protocol config enums for callers that need them.
// Use our internal protocol module for crate-internal types and helpers.
// External callers should rely on specific re-exports below.
// Re-export protocol config enums to ensure call sites can use the same types
// as those in the protocol crate when constructing protocol messages.
pub use codex_protocol::config_types as protocol_config_types;
// Re-export protocol models for compatibility with existing imports.
// Preserve `codex_core::models::...` imports as an alias to the protocol models.
pub use codex_protocol::models as models;
// Public re-exports for API compatibility with downstream users and tests.
// Keep these stable to avoid breaking callers.
pub use crate::client::ModelClient;
pub use crate::client_common::Prompt;
pub use crate::client_common::TextFormat;
pub use crate::client_common::ResponseEvent;
pub use crate::client_common::ResponseStream;
// Upstream also exports REVIEW_PROMPT; include it to preserve compatibility.
pub use crate::client_common::REVIEW_PROMPT;
pub use client::ModelClient;
pub use client_common::Prompt;
pub use client_common::TextFormat;
pub use client_common::REVIEW_PROMPT;
pub use client_common::ResponseEvent;
pub use client_common::ResponseStream;
pub use codex::compact::content_items_to_text;
pub use codex::compact::is_session_prefix_message;
pub use codex_protocol::models::ContentItem;
pub use codex_protocol::models::LocalShellAction;
pub use codex_protocol::models::LocalShellExecAction;
pub use codex_protocol::models::LocalShellStatus;
pub use codex_protocol::models::ReasoningItemContent;
pub use codex_protocol::models::ResponseItem;
+2 -2
View File
@@ -335,7 +335,7 @@ Default timeout: 120000 ms (120s). Override via the `timeout` parameter."#,
/// Responses API:
/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
pub fn create_tools_json_for_responses_api(
tools: &Vec<OpenAiTool>,
tools: &[OpenAiTool],
) -> crate::error::Result<Vec<serde_json::Value>> {
let mut tools_json = Vec::new();
@@ -350,7 +350,7 @@ pub fn create_tools_json_for_responses_api(
/// Chat Completions API:
/// https://platform.openai.com/docs/guides/function-calling?api-mode=chat
pub(crate) fn create_tools_json_for_chat_completions_api(
tools: &Vec<OpenAiTool>,
tools: &[OpenAiTool],
) -> crate::error::Result<Vec<serde_json::Value>> {
// We start with the JSON for the Responses API and than rewrite it to match
// the chat completions tool call format.
+5 -5
View File
@@ -22,14 +22,14 @@ pub fn format_review_findings_block(
selection: Option<&[bool]>,
) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push(String::new());
// Header
let header = if findings.len() > 1 {
"Full review comments:"
if findings.len() > 1 {
lines.push("Full review comments:".to_string());
} else {
"Review comment:"
};
lines.push(header.to_string());
lines.push("Review comment:".to_string());
}
for (idx, item) in findings.iter().enumerate() {
lines.push(String::new());
+1 -1
View File
@@ -240,7 +240,7 @@ impl RolloutRecorder {
_ => None,
})
.collect();
(resumed.conversation_id.0, resumed.history, events)
(uuid::Uuid::from(resumed.conversation_id), resumed.history, events)
}
_ => (uuid::Uuid::new_v4(), Vec::new(), Vec::new()),
};
+6 -5
View File
@@ -18,19 +18,20 @@ const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";
pub async fn spawn_command_under_seatbelt(
command: Vec<String>,
command_cwd: PathBuf,
sandbox_policy: &SandboxPolicy,
cwd: PathBuf,
sandbox_policy_cwd: &Path,
stdio_policy: StdioPolicy,
mut env: HashMap<String, String>,
) -> std::io::Result<Child> {
let args = create_seatbelt_command_args(command, sandbox_policy, &cwd);
let args = create_seatbelt_command_args(command, sandbox_policy, sandbox_policy_cwd);
let arg0 = None;
env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
spawn_child_async(
PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE),
args,
arg0,
cwd,
command_cwd,
sandbox_policy,
stdio_policy,
env,
@@ -41,7 +42,7 @@ pub async fn spawn_command_under_seatbelt(
fn create_seatbelt_command_args(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
cwd: &Path,
sandbox_policy_cwd: &Path,
) -> Vec<String> {
let (file_write_policy, extra_cli_args) = {
if sandbox_policy.has_full_disk_write_access() {
@@ -51,7 +52,7 @@ fn create_seatbelt_command_args(
Vec::<String>::new(),
)
} else {
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
let mut writable_folder_policies: Vec<String> = Vec::new();
let mut cli_args: Vec<String> = Vec::new();
+16 -10
View File
@@ -33,15 +33,19 @@ pub enum Shell {
impl Shell {
pub fn format_default_shell_invocation(&self, command: Vec<String>) -> Option<Vec<String>> {
match self {
Shell::Zsh(zsh) => {
format_shell_invocation_with_rc(&command, &zsh.shell_path, &zsh.zshrc_path)
}
Shell::Bash(bash) => {
format_shell_invocation_with_rc(&command, &bash.shell_path, &bash.bashrc_path)
}
Shell::Zsh(zsh) => format_shell_invocation_with_rc(
command.as_slice(),
&zsh.shell_path,
&zsh.zshrc_path,
),
Shell::Bash(bash) => format_shell_invocation_with_rc(
command.as_slice(),
&bash.shell_path,
&bash.bashrc_path,
),
Shell::PowerShell(ps) => {
// If model generated a bash command, prefer a detected bash fallback
if let Some(script) = strip_bash_lc(&command) {
if let Some(script) = strip_bash_lc(command.as_slice()) {
return match &ps.bash_exe_fallback {
Some(bash) => Some(vec![
bash.to_string_lossy().to_string(),
@@ -103,7 +107,7 @@ impl Shell {
}
fn format_shell_invocation_with_rc(
command: &Vec<String>,
command: &[String],
shell_path: &str,
rc_path: &str,
) -> Option<Vec<String>> {
@@ -119,8 +123,8 @@ fn format_shell_invocation_with_rc(
Some(vec![shell_path.to_string(), "-lc".to_string(), rc_command])
}
fn strip_bash_lc(command: &Vec<String>) -> Option<String> {
match command.as_slice() {
fn strip_bash_lc(command: &[String]) -> Option<String> {
match command {
// exactly three items
[first, second, third]
// first two must be "bash", "-lc"
@@ -361,6 +365,7 @@ mod tests {
},
SandboxType::None,
&SandboxPolicy::DangerFullAccess,
temp_home.path(),
&None,
None,
)
@@ -470,6 +475,7 @@ mod macos_tests {
},
SandboxType::None,
&SandboxPolicy::DangerFullAccess,
temp_home.path(),
&None,
None,
)
+1
View File
@@ -5,6 +5,7 @@
/// preserving the beginning and the end. Returns the possibly truncated
/// string and `Some(original_token_count)` (estimated at 4 bytes/token)
/// if truncation occurred; otherwise returns the original string and `None`.
#[allow(dead_code)]
pub(crate) fn truncate_middle(s: &str, max_bytes: usize) -> (String, Option<u64>) {
if s.len() <= max_bytes {
return (s.to_string(), None);
+1
View File
@@ -1,3 +1,4 @@
#![allow(dead_code)]
use thiserror::Error;
#[derive(Debug, Error)]
+3
View File
@@ -1,3 +1,4 @@
#![allow(dead_code)]
use portable_pty::CommandBuilder;
use portable_pty::PtySize;
use portable_pty::native_pty_system;
@@ -557,6 +558,7 @@ mod tests {
#[cfg(unix)]
#[tokio::test]
#[ignore] // Ignored while we have a better way to test this.
async fn requests_with_large_timeout_are_capped() -> Result<(), UnifiedExecError> {
let manager = UnifiedExecSessionManager::default();
@@ -578,6 +580,7 @@ mod tests {
#[cfg(unix)]
#[tokio::test]
#[ignore] // Ignored while we have a better way to test this.
async fn completed_commands_do_not_persist_sessions() -> Result<(), UnifiedExecError> {
let manager = UnifiedExecSessionManager::default();
let result = manager
+15
View File
@@ -861,8 +861,18 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_
.await
.unwrap();
let mut auto_compact_lifecycle_events = Vec::new();
loop {
let event = codex.next_event().await.unwrap();
if event.id.starts_with("auto-compact-")
&& matches!(
event.msg,
EventMsg::TaskStarted(_) | EventMsg::TaskComplete(_)
)
{
auto_compact_lifecycle_events.push(event);
continue;
}
if let EventMsg::TaskComplete(_) = &event.msg
&& !event.id.starts_with("auto-compact-")
{
@@ -870,6 +880,11 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_
}
}
assert!(
auto_compact_lifecycle_events.is_empty(),
"auto compact should not emit task lifecycle events"
);
let request_bodies: Vec<String> = responder
.recorded_requests()
.into_iter()
@@ -74,7 +74,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
"compact+resume test expects resumed path {resumed_path:?} to exist",
);
let forked = fork_conversation(&manager, &config, resumed_path, 1).await;
let forked = fork_conversation(&manager, &config, resumed_path, 2).await;
user_turn(&forked, "AFTER_FORK").await;
// 3. Capture the requests to the model and validate the history slices.
@@ -100,17 +100,15 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
"after-resume input should have at least as many items as after-compact",
);
assert_eq!(compact_arr.as_slice(), &resume_arr[..compact_arr.len()]);
eprint!(
"len of compact: {}, len of fork: {}",
compact_arr.len(),
fork_arr.len()
);
eprintln!("input_after_fork:{}", json!(input_after_fork));
assert!(
compact_arr.len() <= fork_arr.len(),
"after-fork input should have at least as many items as after-compact",
);
assert_eq!(compact_arr.as_slice(), &fork_arr[..compact_arr.len()]);
assert_eq!(
&compact_arr.as_slice()[..compact_arr.len()],
&fork_arr[..compact_arr.len()]
);
let prompt = requests[0]["instructions"]
.as_str()
@@ -824,14 +822,15 @@ async fn resume_conversation(
conversation
}
#[cfg(test)]
async fn fork_conversation(
manager: &ConversationManager,
config: &Config,
path: std::path::PathBuf,
back_steps: usize,
nth_user_message: usize,
) -> Arc<CodexConversation> {
let NewConversation { conversation, .. } = manager
.fork_conversation(back_steps, config.clone(), path)
.fork_conversation(nth_user_message, config.clone(), path)
.await
.expect("fork conversation");
conversation
+1 -1
View File
@@ -39,7 +39,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result<ExecToolCallOutput
let policy = SandboxPolicy::new_read_only_policy();
process_exec_tool_call(params, sandbox_type, &policy, &None, None).await
process_exec_tool_call(params, sandbox_type, &policy, tmp.path(), &None, None).await
}
/// Command succeeds with exit code 0 normally
@@ -49,9 +49,10 @@ async fn test_exec_stdout_stream_events_echo() {
"printf 'hello-world\n'".to_string(),
];
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let params = ExecParams {
command: cmd,
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
cwd: cwd.clone(),
timeout_ms: Some(5_000),
env: HashMap::new(),
with_escalated_permissions: None,
@@ -64,6 +65,7 @@ async fn test_exec_stdout_stream_events_echo() {
params,
SandboxType::None,
&policy,
cwd.as_path(),
&None,
Some(stdout_stream),
)
@@ -99,9 +101,10 @@ async fn test_exec_stderr_stream_events_echo() {
"printf 'oops\n' 1>&2".to_string(),
];
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let params = ExecParams {
command: cmd,
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
cwd: cwd.clone(),
timeout_ms: Some(5_000),
env: HashMap::new(),
with_escalated_permissions: None,
@@ -114,6 +117,7 @@ async fn test_exec_stderr_stream_events_echo() {
params,
SandboxType::None,
&policy,
cwd.as_path(),
&None,
Some(stdout_stream),
)
@@ -152,9 +156,10 @@ async fn test_aggregated_output_interleaves_in_order() {
"printf 'O1\\n'; sleep 0.01; printf 'E1\\n' 1>&2; sleep 0.01; printf 'O2\\n'; sleep 0.01; printf 'E2\\n' 1>&2".to_string(),
];
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let params = ExecParams {
command: cmd,
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
cwd: cwd.clone(),
timeout_ms: Some(5_000),
env: HashMap::new(),
with_escalated_permissions: None,
@@ -163,9 +168,16 @@ async fn test_aggregated_output_interleaves_in_order() {
let policy = SandboxPolicy::new_read_only_policy();
let result = process_exec_tool_call(params, SandboxType::None, &policy, &None, None)
.await
.expect("process_exec_tool_call");
let result = process_exec_tool_call(
params,
SandboxType::None,
&policy,
cwd.as_path(),
&None,
None,
)
.await
.expect("process_exec_tool_call");
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.text, "O1\nO2\n");
@@ -182,9 +194,10 @@ async fn test_exec_timeout_returns_partial_output() {
"printf 'before\\n'; sleep 2; printf 'after\\n'".to_string(),
];
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let params = ExecParams {
command: cmd,
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
cwd: cwd.clone(),
timeout_ms: Some(200),
env: HashMap::new(),
with_escalated_permissions: None,
@@ -193,7 +206,15 @@ async fn test_exec_timeout_returns_partial_output() {
let policy = SandboxPolicy::new_read_only_policy();
let result = process_exec_tool_call(params, SandboxType::None, &policy, &None, None).await;
let result = process_exec_tool_call(
params,
SandboxType::None,
&policy,
cwd.as_path(),
&None,
None,
)
.await;
let Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) = result else {
panic!("expected timeout error");
+10 -8
View File
@@ -5,6 +5,8 @@ use codex_core::ModelProviderInfo;
use codex_core::NewConversation;
use codex_core::ResponseItem;
use codex_core::built_in_model_providers;
use codex_core::content_items_to_text;
use codex_core::is_session_prefix_message;
use codex_core::protocol::ConversationPathResponseEvent;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
@@ -104,13 +106,16 @@ async fn fork_conversation_twice_drops_to_first_message() {
items
};
// Compute expected prefixes after each fork by truncating base rollout at nth-from-last user input.
// Compute expected prefixes after each fork by truncating base rollout
// strictly before the nth user input (0-based).
let base_items = read_items(&base_path);
let find_user_input_positions = |items: &[RolloutItem]| -> Vec<usize> {
let mut pos = Vec::new();
for (i, it) in items.iter().enumerate() {
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = it
&& role == "user"
&& content_items_to_text(content)
.is_some_and(|text| !is_session_prefix_message(&text))
{
// Consider any user message as an input boundary; recorder stores both EventMsg and ResponseItem.
// We specifically look for input items, which are represented as ContentItem::InputText.
@@ -126,11 +131,8 @@ async fn fork_conversation_twice_drops_to_first_message() {
};
let user_inputs = find_user_input_positions(&base_items);
// After dropping last user input (n=1), cut strictly before that input if present, else empty.
let cut1 = user_inputs
.get(user_inputs.len().saturating_sub(1))
.copied()
.unwrap_or(0);
// After cutting at nth user input (n=1 → second user message), cut strictly before that input.
let cut1 = user_inputs.get(1).copied().unwrap_or(0);
let expected_after_first: Vec<RolloutItem> = base_items[..cut1].to_vec();
// After dropping again (n=1 on fork1), compute expected relative to fork1's rollout.
@@ -161,12 +163,12 @@ async fn fork_conversation_twice_drops_to_first_message() {
serde_json::to_value(&expected_after_first).unwrap()
);
// Fork again with n=1 → drops the (new) last user message, leaving only the first.
// Fork again with n=0 → drops the (new) last user message, leaving only the first.
let NewConversation {
conversation: codex_fork2,
..
} = conversation_manager
.fork_conversation(1, config_for_fork.clone(), fork1_path.clone())
.fork_conversation(0, config_for_fork.clone(), fork1_path.clone())
.await
.expect("fork 2");
+42 -1
View File
@@ -159,6 +159,44 @@ async fn read_only_forbids_all_writes() {
.await;
}
/// Verify that user lookups via `pwd.getpwuid(os.getuid())` work under the
/// seatbelt sandbox. Prior to allowing the necessary mach‑lookup for
/// OpenDirectory libinfo, this would fail with `KeyError: getpwuid(): uid not found`.
#[tokio::test]
async fn python_getpwuid_works_under_seatbelt() {
if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) {
eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test.");
return;
}
// ReadOnly is sufficient here since we are only exercising user lookup.
let policy = SandboxPolicy::ReadOnly;
let command_cwd = std::env::current_dir().expect("getcwd");
let sandbox_cwd = command_cwd.clone();
let mut child = spawn_command_under_seatbelt(
vec![
"python3".to_string(),
"-c".to_string(),
// Print the passwd struct; success implies lookup worked.
"import pwd, os; print(pwd.getpwuid(os.getuid()))".to_string(),
],
command_cwd,
&policy,
sandbox_cwd.as_path(),
StdioPolicy::RedirectForShellTool,
HashMap::new(),
)
.await
.expect("should be able to spawn python under seatbelt");
let status = child
.wait()
.await
.expect("should be able to wait for child process");
assert!(status.success(), "python exited with {status:?}");
}
#[expect(clippy::expect_used)]
fn create_test_scenario(tmp: &TempDir) -> TestScenario {
let repo_parent = tmp.path().to_path_buf();
@@ -181,13 +219,16 @@ fn create_test_scenario(tmp: &TempDir) -> TestScenario {
/// Note that `path` must be absolute.
async fn touch(path: &Path, policy: &SandboxPolicy) -> bool {
assert!(path.is_absolute(), "Path must be absolute: {path:?}");
let command_cwd = std::env::current_dir().expect("getcwd");
let sandbox_cwd = command_cwd.clone();
let mut child = spawn_command_under_seatbelt(
vec![
"/usr/bin/touch".to_string(),
path.to_string_lossy().to_string(),
],
command_cwd,
policy,
std::env::current_dir().expect("should be able to get current dir"),
sandbox_cwd.as_path(),
StdioPolicy::RedirectForShellTool,
HashMap::new(),
)
@@ -20,10 +20,13 @@ use codex_core::protocol::McpToolCallEndEvent;
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::SessionConfiguredEvent;
// Stream errors are surfaced via Error events in our fork
use codex_core::protocol::TaskCompleteEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_core::protocol::TurnDiffEvent;
use codex_core::protocol::WebSearchBeginEvent;
use codex_core::protocol::WebSearchCompleteEvent;
use codex_protocol::num_format::format_with_separators;
use owo_colors::OwoColorize;
use owo_colors::Style;
use shlex::try_join;
@@ -63,8 +66,6 @@ pub(crate) struct EventProcessorWithHumanOutput {
reasoning_started: bool,
raw_reasoning_started: bool,
last_message_path: Option<PathBuf>,
had_error: bool,
answer_bullet_filter: LeadingBulletFilter,
}
impl EventProcessorWithHumanOutput {
@@ -93,8 +94,6 @@ impl EventProcessorWithHumanOutput {
reasoning_started: false,
raw_reasoning_started: false,
last_message_path,
had_error: false,
answer_bullet_filter: LeadingBulletFilter::default(),
}
} else {
Self {
@@ -113,8 +112,6 @@ impl EventProcessorWithHumanOutput {
reasoning_started: false,
raw_reasoning_started: false,
last_message_path,
had_error: false,
answer_bullet_filter: LeadingBulletFilter::default(),
}
}
}
@@ -129,99 +126,6 @@ struct PatchApplyBegin {
auto_approved: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LeadingBulletState {
Pending,
Removed,
Keep,
}
impl Default for LeadingBulletState {
fn default() -> Self {
Self::Pending
}
}
#[derive(Default)]
struct LeadingBulletFilter {
state: LeadingBulletState,
buffer: String,
}
impl LeadingBulletFilter {
fn reset(&mut self) {
self.state = LeadingBulletState::Pending;
self.buffer.clear();
}
fn ingest_delta(&mut self, delta: &str) -> Option<String> {
match self.state {
LeadingBulletState::Pending => {
self.buffer.push_str(delta);
self.finalize_pending()
}
LeadingBulletState::Removed | LeadingBulletState::Keep => Some(delta.to_string()),
}
}
fn sanitize_full(&mut self, text: &str) -> String {
self.reset();
self.buffer.push_str(text);
match self.finalize_pending() {
Some(out) => out,
None => {
if self.buffer == "-" {
self.buffer.clear();
self.state = LeadingBulletState::Removed;
String::new()
} else {
self.state = LeadingBulletState::Keep;
std::mem::take(&mut self.buffer)
}
}
}
}
fn finalize_pending(&mut self) -> Option<String> {
if self.buffer.is_empty() {
return Some(String::new());
}
let mut chars = self.buffer.char_indices();
let Some((_, first)) = chars.next() else {
return Some(String::new());
};
if first != '-' {
self.state = LeadingBulletState::Keep;
return Some(std::mem::take(&mut self.buffer));
}
match chars.next() {
Some((second_idx, second_char)) => {
if matches!(second_char, ' ' | '\t') {
let drain_end = second_idx + second_char.len_utf8();
self.buffer.drain(..drain_end);
self.state = LeadingBulletState::Removed;
Some(std::mem::take(&mut self.buffer))
} else if matches!(second_char, '\n' | '\r') {
self.buffer.drain(..second_idx);
self.state = LeadingBulletState::Removed;
Some(std::mem::take(&mut self.buffer))
} else if second_char.is_whitespace() {
let drain_end = second_idx + second_char.len_utf8();
self.buffer.drain(..drain_end);
self.state = LeadingBulletState::Removed;
Some(std::mem::take(&mut self.buffer))
} else {
self.state = LeadingBulletState::Keep;
Some(std::mem::take(&mut self.buffer))
}
}
None => None,
}
}
}
// Timestamped println helper. The timestamp is styled with self.dimmed.
#[macro_export]
macro_rules! ts_println {
@@ -238,8 +142,12 @@ impl EventProcessor for EventProcessorWithHumanOutput {
/// for the session. This mirrors the information shown in the TUI welcome
/// screen.
fn print_config_summary(&mut self, config: &Config, prompt: &str) {
let version = codex_version::version();
ts_println!(self, "Code v{}\n--------", version);
const VERSION: &str = env!("CARGO_PKG_VERSION");
ts_println!(
self,
"OpenAI Codex v{} (research preview)\n--------",
VERSION
);
let entries = create_config_summary_entries(config);
@@ -266,12 +174,10 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::Error(ErrorEvent { message }) => {
let prefix = "ERROR:".style(self.red);
ts_println!(self, "{prefix} {message}");
self.had_error = true;
}
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
ts_println!(self, "{}", message.style(self.dimmed));
}
// Stream errors are surfaced as Error/Background events in core
EventMsg::TaskStarted => {
// Ignore.
}
@@ -281,22 +187,22 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
return CodexStatus::InitiateShutdown;
}
EventMsg::TokenCount(token_usage) => {
ts_println!(self, "tokens used: {}", token_usage.blended_total());
EventMsg::TokenCount(ev) => {
// Our fork carries TokenUsage directly
ts_println!(
self,
"tokens used: {}",
format_with_separators(ev.blended_total())
);
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
if !self.answer_started {
ts_println!(self, "{}\n", "codex".style(self.italic).style(self.magenta));
self.answer_started = true;
self.answer_bullet_filter.reset();
}
if let Some(out) = self.answer_bullet_filter.ingest_delta(&delta) {
if !out.is_empty() {
print!("{out}");
#[expect(clippy::expect_used)]
std::io::stdout().flush().expect("could not flush stdout");
}
}
print!("{delta}");
#[expect(clippy::expect_used)]
std::io::stdout().flush().expect("could not flush stdout");
}
EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
if !self.show_agent_reasoning {
@@ -352,18 +258,15 @@ impl EventProcessor for EventProcessorWithHumanOutput {
// if answer_started is false, this means we haven't received any
// delta. Thus, we need to print the message as a new answer.
if !self.answer_started {
let sanitized = self.answer_bullet_filter.sanitize_full(&message);
ts_println!(
self,
"{}\n{}",
"codex".style(self.italic).style(self.magenta),
sanitized,
message,
);
self.answer_bullet_filter.reset();
} else {
println!();
self.answer_started = false;
self.answer_bullet_filter.reset();
}
}
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
@@ -373,7 +276,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
parsed_cmd: _,
}) => {
self.call_id_to_command.insert(
call_id.clone(),
call_id,
ExecCommandBegin {
command: command.clone(),
},
@@ -387,13 +290,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
);
}
EventMsg::ExecCommandOutputDelta(_) => {}
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
stdout,
stderr,
duration,
exit_code,
}) => {
EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id, stdout, stderr, duration, exit_code }) => {
let exec_command = self.call_id_to_command.remove(&call_id);
let (duration, call) = if let Some(ExecCommandBegin { command, .. }) = exec_command
{
@@ -404,44 +301,23 @@ impl EventProcessor for EventProcessorWithHumanOutput {
} else {
("".to_string(), format!("exec('{call_id}')"))
};
// Always compute truncated stdout and stderr so we can present
// a clear separation when the command fails. The model cannot
// rely on ANSI colors, so we include a plain "ERROR" divider
// before stderr in the failure case.
let truncated_stdout = stdout
let output = if exit_code == 0 { &stdout } else { &stderr };
let truncated_output = output
.lines()
.take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL)
.collect::<Vec<_>>()
.join("\n");
let truncated_stderr = stderr
.lines()
.take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL)
.collect::<Vec<_>>()
.join("\n");
match exit_code {
0 => {
let title = format!("{call} succeeded{duration}:");
ts_println!(self, "{}", title.style(self.green));
if !truncated_stdout.is_empty() {
println!("{}", truncated_stdout.style(self.dimmed));
}
}
_ => {
let title = format!("{call} exited {exit_code}{duration}:");
ts_println!(self, "{}", title.style(self.red));
if !truncated_stdout.is_empty() {
println!("{}", truncated_stdout.style(self.dimmed));
println!();
}
// Separator visible to both humans and the model
println!("ERROR");
if !truncated_stderr.is_empty() {
println!("{}", truncated_stderr);
}
}
}
println!("{}", truncated_output.style(self.dimmed));
}
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: _,
@@ -484,11 +360,10 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
}
}
EventMsg::WebSearchBegin(WebSearchBeginEvent { .. }) => {}
EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id: _, .. }) => {}
EventMsg::WebSearchComplete(WebSearchCompleteEvent { call_id: _, query }) => {
if let Some(query) = query {
ts_println!(self, "🌐 Search: {query}");
}
let q = query.unwrap_or_default();
ts_println!(self, "🌐 Searched: {q}");
}
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id,
@@ -498,7 +373,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
// Store metadata so we can calculate duration later when we
// receive the corresponding PatchApplyEnd event.
self.call_id_to_patch.insert(
call_id.clone(),
call_id,
PatchApplyBegin {
start_time: Instant::now(),
auto_approved,
@@ -605,15 +480,8 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
}
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => {
// Suppress noisy full-turn diffs in CI unless explicitly allowed.
// Set CODE_SUPPRESS_TURN_DIFF=1 in CI to silence this block.
let suppress = std::env::var("CODE_SUPPRESS_TURN_DIFF")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if !suppress {
ts_println!(self, "{}", "turn diff:".style(self.magenta));
println!("{unified_diff}");
}
ts_println!(self, "{}", "turn diff:".style(self.magenta));
println!("{unified_diff}");
}
EventMsg::ExecApprovalRequest(_) => {
// Should we exit?
@@ -637,139 +505,49 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
}
EventMsg::SessionConfigured(session_configured_event) => {
let SessionConfiguredEvent {
session_id,
model,
history_log_id: _,
history_entry_count: _,
} = session_configured_event;
let SessionConfiguredEvent { session_id: conversation_id, model, .. } = session_configured_event;
ts_println!(
self,
"{} {}",
"codex session".style(self.magenta).style(self.bold),
session_id.to_string().style(self.dimmed)
conversation_id.to_string().style(self.dimmed)
);
ts_println!(self, "model: {}", model);
println!();
}
EventMsg::PlanUpdate(plan_update_event) => {
let UpdatePlanArgs { name, plan } = plan_update_event;
ts_println!(self, "name: {name:?}");
let UpdatePlanArgs { plan, .. } = plan_update_event;
ts_println!(self, "plan: {plan:?}");
}
EventMsg::GetHistoryEntryResponse(_) => {
// Currently ignored in exec output.
}
EventMsg::ReplayHistory(_) => {
// Replay is a TUI concern; ignore in headless output
}
EventMsg::BrowserScreenshotUpdate(_) => {
// Currently ignored in exec output.
}
EventMsg::AgentStatusUpdate(_) => {
// Currently ignored in exec output.
}
EventMsg::TurnAborted(abort_reason) => match abort_reason.reason {
TurnAbortReason::Interrupted => {
ts_println!(self, "task interrupted");
}
TurnAbortReason::Replaced => {
ts_println!(self, "task aborted: replaced by a new task");
}
TurnAbortReason::ReviewEnded => {
ts_println!(self, "task aborted: review ended");
}
},
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
EventMsg::CustomToolCallBegin(event) => {
ts_println!(
self,
"{} {}",
"tool".style(self.magenta),
event.tool_name.style(self.bold),
);
if let Some(params) = &event.parameters {
if let Ok(formatted) = serde_json::to_string_pretty(params) {
for line in formatted.lines() {
println!("{}", line.style(self.dimmed));
}
}
}
}
EventMsg::CustomToolCallEnd(event) => {
let status = if event.result.is_ok() {
"success".style(self.green)
} else {
"failed".style(self.red)
};
ts_println!(
self,
"{} {} {}",
"tool".style(self.magenta),
event.tool_name.style(self.bold),
status,
);
// Print the tool's textual result for visibility in exec mode
match &event.result {
Ok(content) => {
if !content.is_empty() {
// Keep output concise; print as-is (it may be pre-formatted)
for line in content.lines() {
println!("{}", line.style(self.dimmed));
}
}
}
Err(err) => {
if !err.is_empty() {
for line in err.lines() {
println!("{}", line.style(self.red));
}
}
}
}
}
EventMsg::ConversationPath(_) => {}
EventMsg::TurnAborted(_) => {}
EventMsg::UserMessage(_) => {}
EventMsg::EnteredReviewMode(_) => {}
EventMsg::ExitedReviewMode(_) => {}
// Ignore other events that are not user-visible in exec output
_ => {}
}
CodexStatus::Running
}
// exit_code handled by CLI; suppress unused warnings by omitting method.
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter_sanitizes_full_message() {
let mut filter = LeadingBulletFilter::default();
let sanitized = filter.sanitize_full("- Quick summary\nMore");
assert_eq!(sanitized, "Quick summary\nMore");
}
#[test]
fn filter_handles_multi_delta_stream() {
let mut filter = LeadingBulletFilter::default();
assert!(
filter.ingest_delta("-").is_none(),
"first dash should defer"
);
let out = filter
.ingest_delta(" Lead")
.expect("delta should produce output");
assert_eq!(out, "Lead");
let subsequent = filter
.ingest_delta("ing continues")
.expect("subsequent delta");
assert_eq!(subsequent, "ing continues");
}
#[test]
fn filter_keeps_non_bullet_prefix() {
let mut filter = LeadingBulletFilter::default();
let sanitized = filter.sanitize_full("-value remains");
assert_eq!(sanitized, "-value remains");
}
}
// Extend trait with exit_code override
// Add exit_code method to the existing EventProcessor impl
fn escape_command(command: &[String]) -> String {
try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" "))
}
@@ -777,7 +555,7 @@ fn escape_command(command: &[String]) -> String {
fn format_file_change(change: &FileChange) -> &'static str {
match change {
FileChange::Add { .. } => "A",
FileChange::Delete { .. } => "D",
FileChange::Delete => "D",
FileChange::Update {
move_path: Some(_), ..
} => "R",
+107 -6
View File
@@ -4,27 +4,39 @@ use codex_core::spawn::StdioPolicy;
use std::collections::HashMap;
use std::future::Future;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::process::ExitStatus;
use tokio::fs::create_dir_all;
use tokio::process::Child;
#[cfg(target_os = "macos")]
async fn spawn_command_under_sandbox(
command: Vec<String>,
command_cwd: PathBuf,
sandbox_policy: &SandboxPolicy,
cwd: PathBuf,
sandbox_cwd: &Path,
stdio_policy: StdioPolicy,
env: HashMap<String, String>,
) -> std::io::Result<Child> {
use codex_core::seatbelt::spawn_command_under_seatbelt;
spawn_command_under_seatbelt(command, sandbox_policy, cwd, stdio_policy, env).await
spawn_command_under_seatbelt(
command,
command_cwd,
sandbox_policy,
sandbox_cwd,
stdio_policy,
env,
)
.await
}
#[cfg(target_os = "linux")]
async fn spawn_command_under_sandbox(
command: Vec<String>,
command_cwd: PathBuf,
sandbox_policy: &SandboxPolicy,
cwd: PathBuf,
sandbox_cwd: &Path,
stdio_policy: StdioPolicy,
env: HashMap<String, String>,
) -> std::io::Result<Child> {
@@ -33,8 +45,9 @@ async fn spawn_command_under_sandbox(
spawn_command_under_linux_sandbox(
codex_linux_sandbox_exe,
command,
command_cwd,
sandbox_policy,
cwd,
sandbox_cwd,
stdio_policy,
env,
)
@@ -74,14 +87,17 @@ if __name__ == '__main__':
p.join()
"#;
let command_cwd = std::env::current_dir().expect("should be able to get current dir");
let sandbox_cwd = command_cwd.clone();
let mut child = spawn_command_under_sandbox(
vec![
"python3".to_string(),
"-c".to_string(),
python_code.to_string(),
],
command_cwd,
&policy,
std::env::current_dir().expect("should be able to get current dir"),
sandbox_cwd.as_path(),
StdioPolicy::Inherit,
HashMap::new(),
)
@@ -92,6 +108,88 @@ if __name__ == '__main__':
assert!(status.success(), "python exited with {status:?}");
}
#[tokio::test]
async fn sandbox_distinguishes_command_and_policy_cwds() {
let temp = tempfile::tempdir().expect("should be able to create temp dir");
let sandbox_root = temp.path().join("sandbox");
let command_root = temp.path().join("command");
create_dir_all(&sandbox_root).await.expect("mkdir");
create_dir_all(&command_root).await.expect("mkdir");
let canonical_sandbox_root = tokio::fs::canonicalize(&sandbox_root)
.await
.expect("canonicalize sandbox root");
let canonical_allowed_path = canonical_sandbox_root.join("allowed.txt");
let disallowed_path = command_root.join("forbidden.txt");
// Note writable_roots is empty: verify that `canonical_allowed_path` is
// writable only because it is under the sandbox policy cwd, not because it
// is under a writable root.
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
// Attempt to write inside the command cwd, which is outside of the sandbox policy cwd.
let mut child = spawn_command_under_sandbox(
vec![
"bash".to_string(),
"-lc".to_string(),
"echo forbidden > forbidden.txt".to_string(),
],
command_root.clone(),
&policy,
canonical_sandbox_root.as_path(),
StdioPolicy::Inherit,
HashMap::new(),
)
.await
.expect("should spawn command writing to forbidden path");
let status = child
.wait()
.await
.expect("should wait for forbidden command");
assert!(
!status.success(),
"sandbox unexpectedly allowed writing to command cwd: {status:?}"
);
let forbidden_exists = tokio::fs::try_exists(&disallowed_path)
.await
.expect("try_exists failed");
assert!(
!forbidden_exists,
"forbidden path should not have been created"
);
// Writing to the sandbox policy cwd after changing directories into it should succeed.
let mut child = spawn_command_under_sandbox(
vec![
"/usr/bin/touch".to_string(),
canonical_allowed_path.to_string_lossy().into_owned(),
],
command_root,
&policy,
canonical_sandbox_root.as_path(),
StdioPolicy::Inherit,
HashMap::new(),
)
.await
.expect("should spawn command writing to sandbox root");
let status = child.wait().await.expect("should wait for allowed command");
assert!(
status.success(),
"sandbox blocked allowed write: {status:?}"
);
let allowed_exists = tokio::fs::try_exists(&canonical_allowed_path)
.await
.expect("try_exists allowed failed");
assert!(allowed_exists, "allowed path should exist");
}
fn unix_sock_body() {
unsafe {
let mut fds = [0i32; 2];
@@ -200,10 +298,13 @@ where
cmds.push(test_selector.into());
// Your existing launcher:
let command_cwd = std::env::current_dir().expect("should be able to get current dir");
let sandbox_cwd = command_cwd.clone();
let mut child = spawn_command_under_sandbox(
cmds,
command_cwd,
policy,
std::env::current_dir().expect("should be able to get current dir"),
sandbox_cwd.as_path(),
stdio_policy,
HashMap::from([("IN_SANDBOX".into(), "1".into())]),
)
@@ -35,9 +35,11 @@ fn create_env_from_core_vars() -> HashMap<String, String> {
#[expect(clippy::print_stdout, clippy::expect_used, clippy::unwrap_used)]
async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
let cwd = std::env::current_dir().expect("cwd should exist");
let sandbox_cwd = cwd.clone();
let params = ExecParams {
command: cmd.iter().map(|elm| elm.to_string()).collect(),
cwd: std::env::current_dir().expect("cwd should exist"),
cwd,
timeout_ms: Some(timeout_ms),
env: create_env_from_core_vars(),
with_escalated_permissions: None,
@@ -59,6 +61,7 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
params,
SandboxType::LinuxSeccomp,
&sandbox_policy,
sandbox_cwd.as_path(),
&codex_linux_sandbox_exe,
None,
)
@@ -133,6 +136,7 @@ async fn test_timeout() {
#[expect(clippy::expect_used)]
async fn assert_network_blocked(cmd: &[&str]) {
let cwd = std::env::current_dir().expect("cwd should exist");
let sandbox_cwd = cwd.clone();
let params = ExecParams {
command: cmd.iter().map(|s| s.to_string()).collect(),
cwd,
@@ -151,6 +155,7 @@ async fn assert_network_blocked(cmd: &[&str]) {
params,
SandboxType::LinuxSeccomp,
&sandbox_policy,
sandbox_cwd.as_path(),
&codex_linux_sandbox_exe,
None,
)
+1 -1
View File
@@ -37,7 +37,7 @@ tokio = { version = "1", features = [
toml = "0.9"
tracing = { version = "0.1.41", features = ["log"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
uuid = { version = "1", features = ["serde", "v4"] }
uuid = { version = "1", features = ["serde", "v7"] }
[dev-dependencies]
assert_cmd = "2"
@@ -274,7 +274,7 @@ impl CodexMessageProcessor {
else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("conversation not found: {}", conversation_id.0),
message: format!("conversation not found: {conversation_id}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
+2 -2
View File
@@ -510,7 +510,7 @@ impl MessageProcessor {
let codex = match self
.conversation_manager
.get_conversation(codex_protocol::mcp_protocol::ConversationId(session_id))
.get_conversation(codex_protocol::mcp_protocol::ConversationId::from(session_id))
.await
{
Ok(c) => c,
@@ -596,7 +596,7 @@ impl MessageProcessor {
// Obtain the Codex conversation from the server.
let codex_arc = match self
.conversation_manager
.get_conversation(codex_protocol::mcp_protocol::ConversationId(session_id))
.get_conversation(codex_protocol::mcp_protocol::ConversationId::from(session_id))
.await
{
Ok(c) => c,
@@ -141,8 +141,8 @@ async fn test_list_and_resume_conversations() {
conversation_id, ..
} = to_response::<ResumeConversationResponse>(resume_resp)
.expect("deserialize resumeConversation response");
// conversation id should be a valid UUID
let _ = uuid::Uuid::from_bytes(conversation_id.0.into_bytes());
// conversation id should be a valid UUID (simple check)
assert!(!conversation_id.to_string().is_empty());
}
fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str, preview: &str) {
@@ -136,7 +136,7 @@ async fn test_send_message_session_not_found() {
.expect("timeout")
.expect("init");
let unknown = ConversationId(uuid::Uuid::new_v4());
let unknown = ConversationId::new();
let req_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id: unknown,
+62 -11
View File
@@ -19,13 +19,35 @@ use strum_macros::Display;
use ts_rs::TS;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS, Hash)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, TS, Hash)]
#[ts(type = "string")]
pub struct ConversationId(pub Uuid);
pub struct ConversationId {
uuid: Uuid,
}
impl ConversationId {
pub fn new() -> Self {
Self(Uuid::new_v4())
Self {
uuid: Uuid::new_v4(),
}
}
pub fn from_string(s: &str) -> Result<Self, uuid::Error> {
Ok(Self {
uuid: Uuid::parse_str(s)?,
})
}
}
impl From<Uuid> for ConversationId {
fn from(uuid: Uuid) -> Self {
Self { uuid }
}
}
impl From<ConversationId> for Uuid {
fn from(id: ConversationId) -> Self {
id.uuid
}
}
@@ -37,19 +59,27 @@ impl Default for ConversationId {
impl Display for ConversationId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
write!(f, "{}", self.uuid)
}
}
impl From<Uuid> for ConversationId {
fn from(value: Uuid) -> Self {
Self(value)
impl Serialize for ConversationId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.collect_str(&self.uuid)
}
}
impl From<ConversationId> for Uuid {
fn from(value: ConversationId) -> Self {
value.0
impl<'de> Deserialize<'de> for ConversationId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
let uuid = Uuid::parse_str(&value).map_err(serde::de::Error::custom)?;
Ok(Self { uuid })
}
}
@@ -719,6 +749,27 @@ mod tests {
#[test]
fn test_conversation_id_default_is_not_zeroes() {
let id = ConversationId::default();
assert_ne!(id.0, Uuid::nil());
assert_ne!(id.uuid, Uuid::nil());
}
#[test]
fn conversation_id_serializes_as_plain_string() {
let id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap();
assert_eq!(
json!("67e55044-10b1-426f-9247-bb680e5fe0c8"),
serde_json::to_value(id).unwrap()
);
}
#[test]
fn conversation_id_deserializes_from_plain_string() {
let id: ConversationId =
serde_json::from_value(json!("67e55044-10b1-426f-9247-bb680e5fe0c8")).unwrap();
assert_eq!(
ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(),
id,
);
}
}
+3 -1
View File
@@ -1283,6 +1283,7 @@ pub struct TurnAbortedEvent {
pub enum TurnAbortReason {
Interrupted,
Replaced,
ReviewEnded,
}
#[cfg(test)]
@@ -1295,7 +1296,8 @@ mod tests {
/// amount of nesting.
#[test]
fn serialize_event() {
let conversation_id = ConversationId(uuid::uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"));
let conversation_id =
ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap();
let rollout_file = NamedTempFile::new().unwrap();
let event = Event {
id: "1234".to_string(),
+1
View File
@@ -24,6 +24,7 @@ fn main() -> anyhow::Result<()> {
let usage = run_main(inner, codex_linux_sandbox_exe).await?;
if !usage.is_zero() {
println!("{}", codex_core::protocol::FinalOutput::from(usage));
// Conversation id hint not available in fork run_main() return type.
}
Ok(())
})
@@ -4,10 +4,10 @@ expression: term.backend()
---
"/ T R A N S C R I P T / / / / / / / / / "
"alpha "
" "
"beta "
" "
"gamma "
"~ "
"~ "
"───────────────────────────────── 100% ─"
" ↑/↓ scroll PgUp/PgDn page Home/End "
" q quit Esc edit prev "