Merge 9c24c454b5 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-01-07 09:55:22 -08:00
committed by GitHub
85 changed files with 4289 additions and 687 deletions
+16
View File
@@ -0,0 +1,16 @@
# Example announcement tips for Codex TUI.
# Each [[announcements]] entry is evaluated in order; the last matching one is shown.
# Dates are UTC, formatted as YYYY-MM-DD. The from_date is inclusive and the to_date is exclusive.
# version_regex matches against the CLI version (env!("CARGO_PKG_VERSION")); omit to apply to all versions.
# target_app specify which app should display the announcement (cli, vsce, ...).
[[announcements]]
content = "Welcome to Codex! Check out the new onboarding flow."
from_date = "2024-10-01"
to_date = "2024-10-15"
target_app = "cli"
[[announcements]]
content = "This is a test announcement"
version_regex = "^0\\.0\\.0$"
to_date = "2026-01-10"
+12 -12
View File
@@ -360,7 +360,7 @@ dependencies = [
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1866,7 +1866,7 @@ dependencies = [
name = "codex-utils-cache"
version = "0.0.0"
dependencies = [
"lru 0.16.2",
"lru 0.16.3",
"sha1",
"tokio",
]
@@ -2781,7 +2781,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -2889,7 +2889,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix 1.0.8",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -3830,7 +3830,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -4151,9 +4151,9 @@ dependencies = [
[[package]]
name = "lru"
version = "0.16.2"
version = "0.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f"
checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
dependencies = [
"hashbrown 0.16.0",
]
@@ -5331,7 +5331,7 @@ dependencies = [
"once_cell",
"socket2 0.6.1",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -5459,7 +5459,7 @@ dependencies = [
"indoc",
"itertools 0.14.0",
"kasuari",
"lru 0.16.2",
"lru 0.16.3",
"strum 0.27.2",
"thiserror 2.0.17",
"unicode-segmentation",
@@ -5710,7 +5710,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -5723,7 +5723,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.9.4",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -8011,7 +8011,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
+1 -1
View File
@@ -152,7 +152,7 @@ landlock = "0.4.4"
lazy_static = "1"
libc = "0.2.177"
log = "0.4"
lru = "0.16.2"
lru = "0.16.3"
maplit = "1.0.2"
mime_guess = "2.0.5"
multimap = "0.10.0"
@@ -113,6 +113,10 @@ client_request_definitions! {
params: v2::ThreadArchiveParams,
response: v2::ThreadArchiveResponse,
},
ThreadRollback => "thread/rollback" {
params: v2::ThreadRollbackParams,
response: v2::ThreadRollbackResponse,
},
ThreadList => "thread/list" {
params: v2::ThreadListParams,
response: v2::ThreadListResponse,
@@ -6,6 +6,7 @@ use crate::protocol::v2::UserInput;
use codex_protocol::protocol::AgentReasoningEvent;
use codex_protocol::protocol::AgentReasoningRawContentEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::UserMessageEvent;
@@ -57,6 +58,7 @@ impl ThreadHistoryBuilder {
EventMsg::TokenCount(_) => {}
EventMsg::EnteredReviewMode(_) => {}
EventMsg::ExitedReviewMode(_) => {}
EventMsg::ThreadRolledBack(payload) => self.handle_thread_rollback(payload),
EventMsg::UndoCompleted(_) => {}
EventMsg::TurnAborted(payload) => self.handle_turn_aborted(payload),
_ => {}
@@ -130,6 +132,23 @@ impl ThreadHistoryBuilder {
turn.status = TurnStatus::Interrupted;
}
fn handle_thread_rollback(&mut self, payload: &ThreadRolledBackEvent) {
self.finish_current_turn();
let n = usize::try_from(payload.num_turns).unwrap_or(usize::MAX);
if n >= self.turns.len() {
self.turns.clear();
} else {
self.turns.truncate(self.turns.len().saturating_sub(n));
}
// Re-number subsequent synthetic ids so the pruned history is consistent.
self.next_turn_index =
i64::try_from(self.turns.len().saturating_add(1)).unwrap_or(i64::MAX);
let item_count: usize = self.turns.iter().map(|t| t.items.len()).sum();
self.next_item_index = i64::try_from(item_count.saturating_add(1)).unwrap_or(i64::MAX);
}
fn finish_current_turn(&mut self) {
if let Some(turn) = self.current_turn.take() {
if turn.items.is_empty() {
@@ -213,6 +232,7 @@ mod tests {
use codex_protocol::protocol::AgentMessageEvent;
use codex_protocol::protocol::AgentReasoningEvent;
use codex_protocol::protocol::AgentReasoningRawContentEvent;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::UserMessageEvent;
@@ -410,4 +430,95 @@ mod tests {
}
);
}
#[test]
fn drops_last_turns_on_thread_rollback() {
let events = vec![
EventMsg::UserMessage(UserMessageEvent {
message: "First".into(),
images: None,
}),
EventMsg::AgentMessage(AgentMessageEvent {
message: "A1".into(),
}),
EventMsg::UserMessage(UserMessageEvent {
message: "Second".into(),
images: None,
}),
EventMsg::AgentMessage(AgentMessageEvent {
message: "A2".into(),
}),
EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
EventMsg::UserMessage(UserMessageEvent {
message: "Third".into(),
images: None,
}),
EventMsg::AgentMessage(AgentMessageEvent {
message: "A3".into(),
}),
];
let turns = build_turns_from_event_msgs(&events);
let expected = vec![
Turn {
id: "turn-1".into(),
status: TurnStatus::Completed,
error: None,
items: vec![
ThreadItem::UserMessage {
id: "item-1".into(),
content: vec![UserInput::Text {
text: "First".into(),
}],
},
ThreadItem::AgentMessage {
id: "item-2".into(),
text: "A1".into(),
},
],
},
Turn {
id: "turn-2".into(),
status: TurnStatus::Completed,
error: None,
items: vec![
ThreadItem::UserMessage {
id: "item-3".into(),
content: vec![UserInput::Text {
text: "Third".into(),
}],
},
ThreadItem::AgentMessage {
id: "item-4".into(),
text: "A3".into(),
},
],
},
];
assert_eq!(turns, expected);
}
#[test]
fn thread_rollback_clears_all_turns_when_num_turns_exceeds_history() {
let events = vec![
EventMsg::UserMessage(UserMessageEvent {
message: "One".into(),
images: None,
}),
EventMsg::AgentMessage(AgentMessageEvent {
message: "A1".into(),
}),
EventMsg::UserMessage(UserMessageEvent {
message: "Two".into(),
images: None,
}),
EventMsg::AgentMessage(AgentMessageEvent {
message: "A2".into(),
}),
EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 99 }),
];
let turns = build_turns_from_event_msgs(&events);
assert_eq!(turns, Vec::<Turn>::new());
}
}
@@ -89,6 +89,7 @@ pub enum CodexErrorInfo {
InternalServerError,
Unauthorized,
BadRequest,
ThreadRollbackFailed,
SandboxError,
/// The response SSE stream disconnected in the middle of a turn before completion.
ResponseStreamDisconnected {
@@ -119,6 +120,7 @@ impl From<CoreCodexErrorInfo> for CodexErrorInfo {
CoreCodexErrorInfo::InternalServerError => CodexErrorInfo::InternalServerError,
CoreCodexErrorInfo::Unauthorized => CodexErrorInfo::Unauthorized,
CoreCodexErrorInfo::BadRequest => CodexErrorInfo::BadRequest,
CoreCodexErrorInfo::ThreadRollbackFailed => CodexErrorInfo::ThreadRollbackFailed,
CoreCodexErrorInfo::SandboxError => CodexErrorInfo::SandboxError,
CoreCodexErrorInfo::ResponseStreamDisconnected { http_status_code } => {
CodexErrorInfo::ResponseStreamDisconnected { http_status_code }
@@ -330,6 +332,15 @@ pub struct ProfileV2 {
pub additional: HashMap<String, JsonValue>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub struct AnalyticsConfig {
pub enabled: Option<bool>,
#[serde(default, flatten)]
pub additional: HashMap<String, JsonValue>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
@@ -354,6 +365,7 @@ pub struct Config {
pub model_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
pub analytics: Option<AnalyticsConfig>,
#[serde(default, flatten)]
pub additional: HashMap<String, JsonValue>,
}
@@ -1045,6 +1057,30 @@ pub struct ThreadArchiveParams {
#[ts(export_to = "v2/")]
pub struct ThreadArchiveResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadRollbackParams {
pub thread_id: String,
/// The number of turns to drop from the end of the thread. Must be >= 1.
///
/// This only modifies the thread's history and does not revert local file changes
/// that have been made by the agent. Clients are responsible for reverting these changes.
pub num_turns: u32,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadRollbackResponse {
/// The updated thread after applying the rollback, with `turns` populated.
///
/// The ThreadItems stored in each Turn are lossy since we explicitly do not
/// persist all agent interactions, such as command executions. This is the same
/// behavior as `thread/resume`.
pub thread: Thread,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1183,7 +1219,7 @@ pub struct Thread {
pub source: SessionSource,
/// Optional Git metadata captured when the thread was created.
pub git_info: Option<GitInfo>,
/// Only populated on a `thread/resume` response.
/// Only populated on `thread/resume` and `thread/rollback` responses.
/// For all other responses and notifications returning a Thread,
/// the turns field will be an empty list.
pub turns: Vec<Turn>,
+102 -27
View File
@@ -13,6 +13,7 @@ use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use clap::ArgAction;
use clap::Parser;
use clap::Subcommand;
use codex_app_server_protocol::AddConversationListenerParams;
@@ -35,6 +36,8 @@ use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginChatGptCompleteNotification;
use codex_app_server_protocol::LoginChatGptResponse;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::NewConversationResponse;
use codex_app_server_protocol::RequestId;
@@ -65,6 +68,19 @@ struct Cli {
#[arg(long, env = "CODEX_BIN", default_value = "codex")]
codex_bin: String,
/// Forwarded to the `codex` CLI as `--config key=value`. Repeatable.
///
/// Example:
/// `--config 'model_providers.mock.base_url="http://localhost:4010/v2"'`
#[arg(
short = 'c',
long = "config",
value_name = "key=value",
action = ArgAction::Append,
global = true
)]
config_overrides: Vec<String>,
#[command(subcommand)]
command: CliCommand,
}
@@ -113,32 +129,49 @@ enum CliCommand {
TestLogin,
/// Fetch the current account rate limits from the Codex app-server.
GetAccountRateLimits,
/// List the available models from the Codex app-server.
#[command(name = "model-list")]
ModelList,
}
fn main() -> Result<()> {
let Cli { codex_bin, command } = Cli::parse();
let Cli {
codex_bin,
config_overrides,
command,
} = Cli::parse();
match command {
CliCommand::SendMessage { user_message } => send_message(codex_bin, user_message),
CliCommand::SendMessageV2 { user_message } => send_message_v2(codex_bin, user_message),
CliCommand::SendMessage { user_message } => {
send_message(&codex_bin, &config_overrides, user_message)
}
CliCommand::SendMessageV2 { user_message } => {
send_message_v2(&codex_bin, &config_overrides, user_message)
}
CliCommand::TriggerCmdApproval { user_message } => {
trigger_cmd_approval(codex_bin, user_message)
trigger_cmd_approval(&codex_bin, &config_overrides, user_message)
}
CliCommand::TriggerPatchApproval { user_message } => {
trigger_patch_approval(codex_bin, user_message)
trigger_patch_approval(&codex_bin, &config_overrides, user_message)
}
CliCommand::NoTriggerCmdApproval => no_trigger_cmd_approval(codex_bin),
CliCommand::NoTriggerCmdApproval => no_trigger_cmd_approval(&codex_bin, &config_overrides),
CliCommand::SendFollowUpV2 {
first_message,
follow_up_message,
} => send_follow_up_v2(codex_bin, first_message, follow_up_message),
CliCommand::TestLogin => test_login(codex_bin),
CliCommand::GetAccountRateLimits => get_account_rate_limits(codex_bin),
} => send_follow_up_v2(
&codex_bin,
&config_overrides,
first_message,
follow_up_message,
),
CliCommand::TestLogin => test_login(&codex_bin, &config_overrides),
CliCommand::GetAccountRateLimits => get_account_rate_limits(&codex_bin, &config_overrides),
CliCommand::ModelList => model_list(&codex_bin, &config_overrides),
}
}
fn send_message(codex_bin: String, user_message: String) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin)?;
fn send_message(codex_bin: &str, config_overrides: &[String], user_message: String) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -159,46 +192,61 @@ fn send_message(codex_bin: String, user_message: String) -> Result<()> {
Ok(())
}
fn send_message_v2(codex_bin: String, user_message: String) -> Result<()> {
send_message_v2_with_policies(codex_bin, user_message, None, None)
fn send_message_v2(
codex_bin: &str,
config_overrides: &[String],
user_message: String,
) -> Result<()> {
send_message_v2_with_policies(codex_bin, config_overrides, user_message, None, None)
}
fn trigger_cmd_approval(codex_bin: String, user_message: Option<String>) -> Result<()> {
fn trigger_cmd_approval(
codex_bin: &str,
config_overrides: &[String],
user_message: Option<String>,
) -> Result<()> {
let default_prompt =
"Run `touch /tmp/should-trigger-approval` so I can confirm the file exists.";
let message = user_message.unwrap_or_else(|| default_prompt.to_string());
send_message_v2_with_policies(
codex_bin,
config_overrides,
message,
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::ReadOnly),
)
}
fn trigger_patch_approval(codex_bin: String, user_message: Option<String>) -> Result<()> {
fn trigger_patch_approval(
codex_bin: &str,
config_overrides: &[String],
user_message: Option<String>,
) -> Result<()> {
let default_prompt =
"Create a file named APPROVAL_DEMO.txt containing a short hello message using apply_patch.";
let message = user_message.unwrap_or_else(|| default_prompt.to_string());
send_message_v2_with_policies(
codex_bin,
config_overrides,
message,
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::ReadOnly),
)
}
fn no_trigger_cmd_approval(codex_bin: String) -> Result<()> {
fn no_trigger_cmd_approval(codex_bin: &str, config_overrides: &[String]) -> Result<()> {
let prompt = "Run `touch should_not_trigger_approval.txt`";
send_message_v2_with_policies(codex_bin, prompt.to_string(), None, None)
send_message_v2_with_policies(codex_bin, config_overrides, prompt.to_string(), None, None)
}
fn send_message_v2_with_policies(
codex_bin: String,
codex_bin: &str,
config_overrides: &[String],
user_message: String,
approval_policy: Option<AskForApproval>,
sandbox_policy: Option<SandboxPolicy>,
) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin)?;
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -222,11 +270,12 @@ fn send_message_v2_with_policies(
}
fn send_follow_up_v2(
codex_bin: String,
codex_bin: &str,
config_overrides: &[String],
first_message: String,
follow_up_message: String,
) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin)?;
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -259,8 +308,8 @@ fn send_follow_up_v2(
Ok(())
}
fn test_login(codex_bin: String) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin)?;
fn test_login(codex_bin: &str, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -289,8 +338,8 @@ fn test_login(codex_bin: String) -> Result<()> {
}
}
fn get_account_rate_limits(codex_bin: String) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin)?;
fn get_account_rate_limits(codex_bin: &str, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -301,6 +350,18 @@ fn get_account_rate_limits(codex_bin: String) -> Result<()> {
Ok(())
}
fn model_list(codex_bin: &str, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let response = client.model_list(ModelListParams::default())?;
println!("< model/list response: {response:?}");
Ok(())
}
struct CodexClient {
child: Child,
stdin: Option<ChildStdin>,
@@ -309,8 +370,12 @@ struct CodexClient {
}
impl CodexClient {
fn spawn(codex_bin: String) -> Result<Self> {
let mut codex_app_server = Command::new(&codex_bin)
fn spawn(codex_bin: &str, config_overrides: &[String]) -> Result<Self> {
let mut cmd = Command::new(codex_bin);
for override_kv in config_overrides {
cmd.arg("--config").arg(override_kv);
}
let mut codex_app_server = cmd
.arg("app-server")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
@@ -452,6 +517,16 @@ impl CodexClient {
self.send_request(request, request_id, "account/rateLimits/read")
}
fn model_list(&mut self, params: ModelListParams) -> Result<ModelListResponse> {
let request_id = self.request_id();
let request = ClientRequest::ModelList {
request_id: request_id.clone(),
params,
};
self.send_request(request, request_id, "model/list")
}
fn stream_conversation(&mut self, conversation_id: &ConversationId) -> Result<()> {
loop {
let notification = self.next_notification()?;
+1
View File
@@ -72,6 +72,7 @@ Example (from OpenAI's official VSCode extension):
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering.
- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success.
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications.
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
- `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
@@ -1,7 +1,13 @@
use crate::codex_message_processor::ApiVersion;
use crate::codex_message_processor::PendingInterrupts;
use crate::codex_message_processor::PendingRollbacks;
use crate::codex_message_processor::TurnSummary;
use crate::codex_message_processor::TurnSummaryStore;
use crate::codex_message_processor::read_event_msgs_from_rollout;
use crate::codex_message_processor::read_summary_from_rollout;
use crate::codex_message_processor::summary_to_thread;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::outgoing_message::OutgoingMessageSender;
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
use codex_app_server_protocol::AgentMessageDeltaNotification;
@@ -27,6 +33,7 @@ use codex_app_server_protocol::FileUpdateChange;
use codex_app_server_protocol::InterruptConversationResponse;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::McpToolCallError;
use codex_app_server_protocol::McpToolCallResult;
use codex_app_server_protocol::McpToolCallStatus;
@@ -40,6 +47,7 @@ use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::TerminalInteractionNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
use codex_app_server_protocol::Turn;
@@ -50,9 +58,11 @@ use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnPlanStep;
use codex_app_server_protocol::TurnPlanUpdatedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::build_turns_from_event_msgs;
use codex_core::CodexConversation;
use codex_core::parse_command::shlex_join;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::CodexErrorInfo as CoreCodexErrorInfo;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
@@ -78,14 +88,17 @@ use tracing::error;
type JsonValue = serde_json::Value;
#[allow(clippy::too_many_arguments)]
pub(crate) async fn apply_bespoke_event_handling(
event: Event,
conversation_id: ConversationId,
conversation: Arc<CodexConversation>,
outgoing: Arc<OutgoingMessageSender>,
pending_interrupts: PendingInterrupts,
pending_rollbacks: PendingRollbacks,
turn_summary_store: TurnSummaryStore,
api_version: ApiVersion,
fallback_model_provider: String,
) {
let Event {
id: event_turn_id,
@@ -337,6 +350,26 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
}
EventMsg::Error(ev) => {
let message = ev.message.clone();
let codex_error_info = ev.codex_error_info.clone();
// If this error belongs to an in-flight `thread/rollback` request, fail that request
// (and clear pending state) so subsequent rollbacks are unblocked.
//
// Don't send a notification for this error.
if matches!(
codex_error_info,
Some(CoreCodexErrorInfo::ThreadRollbackFailed)
) {
return handle_thread_rollback_failed(
conversation_id,
message,
&pending_rollbacks,
&outgoing,
)
.await;
};
let turn_error = TurnError {
message: ev.message,
codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from),
@@ -345,7 +378,7 @@ pub(crate) async fn apply_bespoke_event_handling(
handle_error(conversation_id, turn_error.clone(), &turn_summary_store).await;
outgoing
.send_server_notification(ServerNotification::Error(ErrorNotification {
error: turn_error,
error: turn_error.clone(),
will_retry: false,
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
@@ -690,6 +723,58 @@ pub(crate) async fn apply_bespoke_event_handling(
)
.await;
}
EventMsg::ThreadRolledBack(_rollback_event) => {
let pending = {
let mut map = pending_rollbacks.lock().await;
map.remove(&conversation_id)
};
if let Some(request_id) = pending {
let rollout_path = conversation.rollout_path();
let response = match read_summary_from_rollout(
rollout_path.as_path(),
fallback_model_provider.as_str(),
)
.await
{
Ok(summary) => {
let mut thread = summary_to_thread(summary);
match read_event_msgs_from_rollout(rollout_path.as_path()).await {
Ok(events) => {
thread.turns = build_turns_from_event_msgs(&events);
ThreadRollbackResponse { thread }
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!(
"failed to load rollout `{}`: {err}",
rollout_path.display()
),
data: None,
};
outgoing.send_error(request_id, error).await;
return;
}
}
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!(
"failed to load rollout `{}`: {err}",
rollout_path.display()
),
data: None,
};
outgoing.send_error(request_id, error).await;
return;
}
};
outgoing.send_response(request_id, response).await;
}
}
EventMsg::TurnDiff(turn_diff_event) => {
handle_turn_diff(
conversation_id,
@@ -906,6 +991,31 @@ async fn handle_turn_interrupted(
.await;
}
async fn handle_thread_rollback_failed(
conversation_id: ConversationId,
message: String,
pending_rollbacks: &PendingRollbacks,
outgoing: &OutgoingMessageSender,
) {
let pending_rollback = {
let mut map = pending_rollbacks.lock().await;
map.remove(&conversation_id)
};
if let Some(request_id) = pending_rollback {
outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: message.clone(),
data: None,
},
)
.await;
}
}
async fn handle_token_count_event(
conversation_id: ConversationId,
turn_id: String,
@@ -91,6 +91,7 @@ use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
@@ -178,6 +179,8 @@ use uuid::Uuid;
type PendingInterruptQueue = Vec<(RequestId, ApiVersion)>;
pub(crate) type PendingInterrupts = Arc<Mutex<HashMap<ConversationId, PendingInterruptQueue>>>;
pub(crate) type PendingRollbacks = Arc<Mutex<HashMap<ConversationId, RequestId>>>;
/// Per-conversation accumulation of the latest states e.g. error message while a turn runs.
#[derive(Default, Clone)]
pub(crate) struct TurnSummary {
@@ -220,6 +223,8 @@ pub(crate) struct CodexMessageProcessor {
active_login: Arc<Mutex<Option<ActiveLogin>>>,
// Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives.
pending_interrupts: PendingInterrupts,
// Queue of pending rollback requests per conversation. We reply when ThreadRollback arrives.
pending_rollbacks: PendingRollbacks,
turn_summary_store: TurnSummaryStore,
pending_fuzzy_searches: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
feedback: CodexFeedback,
@@ -275,6 +280,7 @@ impl CodexMessageProcessor {
conversation_listeners: HashMap::new(),
active_login: Arc::new(Mutex::new(None)),
pending_interrupts: Arc::new(Mutex::new(HashMap::new())),
pending_rollbacks: Arc::new(Mutex::new(HashMap::new())),
turn_summary_store: Arc::new(Mutex::new(HashMap::new())),
pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())),
feedback,
@@ -365,6 +371,9 @@ impl CodexMessageProcessor {
ClientRequest::ThreadArchive { request_id, params } => {
self.thread_archive(request_id, params).await;
}
ClientRequest::ThreadRollback { request_id, params } => {
self.thread_rollback(request_id, params).await;
}
ClientRequest::ThreadList { request_id, params } => {
self.thread_list(request_id, params).await;
}
@@ -1246,14 +1255,14 @@ impl CodexMessageProcessor {
cwd,
approval_policy,
sandbox: sandbox_mode,
config: cli_overrides,
config: request_overrides,
base_instructions,
developer_instructions,
compact_prompt,
include_apply_patch_tool,
} = params;
let overrides = ConfigOverrides {
let typesafe_overrides = ConfigOverrides {
model,
config_profile: profile,
cwd: cwd.clone().map(PathBuf::from),
@@ -1270,15 +1279,21 @@ impl CodexMessageProcessor {
// Persist windows sandbox feature.
// TODO: persist default config in general.
let mut cli_overrides = cli_overrides.unwrap_or_default();
let mut request_overrides = request_overrides.unwrap_or_default();
if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) {
cli_overrides.insert(
request_overrides.insert(
"features.experimental_windows_sandbox".to_string(),
serde_json::json!(true),
);
}
let config = match derive_config_from_params(overrides, Some(cli_overrides)).await {
let config = match derive_config_from_params(
&self.cli_overrides,
Some(request_overrides),
typesafe_overrides,
)
.await
{
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
@@ -1318,7 +1333,7 @@ impl CodexMessageProcessor {
}
async fn thread_start(&mut self, request_id: RequestId, params: ThreadStartParams) {
let overrides = self.build_thread_config_overrides(
let typesafe_overrides = self.build_thread_config_overrides(
params.model,
params.model_provider,
params.cwd,
@@ -1328,18 +1343,21 @@ impl CodexMessageProcessor {
params.developer_instructions,
);
let config = match derive_config_from_params(overrides, params.config).await {
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let config =
match derive_config_from_params(&self.cli_overrides, params.config, typesafe_overrides)
.await
{
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self.conversation_manager.new_conversation(config).await {
Ok(new_conv) => {
@@ -1506,6 +1524,52 @@ impl CodexMessageProcessor {
}
}
async fn thread_rollback(&mut self, request_id: RequestId, params: ThreadRollbackParams) {
let ThreadRollbackParams {
thread_id,
num_turns,
} = params;
if num_turns == 0 {
self.send_invalid_request_error(request_id, "numTurns must be >= 1".to_string())
.await;
return;
}
let (conversation_id, conversation) =
match self.conversation_from_thread_id(&thread_id).await {
Ok(v) => v,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
{
let mut map = self.pending_rollbacks.lock().await;
if map.contains_key(&conversation_id) {
self.send_invalid_request_error(
request_id,
"rollback already in progress for this thread".to_string(),
)
.await;
return;
}
map.insert(conversation_id, request_id.clone());
}
if let Err(err) = conversation.submit(Op::ThreadRollback { num_turns }).await {
// No ThreadRollback event will arrive if an error occurs.
// Clean up and reply immediately.
let mut map = self.pending_rollbacks.lock().await;
map.remove(&conversation_id);
self.send_internal_error(request_id, format!("failed to start rollback: {err}"))
.await;
}
}
async fn thread_list(&self, request_id: RequestId, params: ThreadListParams) {
let ThreadListParams {
cursor,
@@ -1543,7 +1607,7 @@ impl CodexMessageProcessor {
cwd,
approval_policy,
sandbox,
config: cli_overrides,
config: request_overrides,
base_instructions,
developer_instructions,
} = params;
@@ -1553,12 +1617,12 @@ impl CodexMessageProcessor {
|| cwd.is_some()
|| approval_policy.is_some()
|| sandbox.is_some()
|| cli_overrides.is_some()
|| request_overrides.is_some()
|| base_instructions.is_some()
|| developer_instructions.is_some();
let config = if overrides_requested {
let overrides = self.build_thread_config_overrides(
let typesafe_overrides = self.build_thread_config_overrides(
model,
model_provider,
cwd,
@@ -1567,7 +1631,13 @@ impl CodexMessageProcessor {
base_instructions,
developer_instructions,
);
match derive_config_from_params(overrides, cli_overrides).await {
match derive_config_from_params(
&self.cli_overrides,
request_overrides,
typesafe_overrides,
)
.await
{
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
@@ -2197,7 +2267,7 @@ impl CodexMessageProcessor {
cwd,
approval_policy,
sandbox: sandbox_mode,
config: cli_overrides,
config: request_overrides,
base_instructions,
developer_instructions,
compact_prompt,
@@ -2205,15 +2275,15 @@ impl CodexMessageProcessor {
} = overrides;
// Persist windows sandbox feature.
let mut cli_overrides = cli_overrides.unwrap_or_default();
let mut request_overrides = request_overrides.unwrap_or_default();
if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) {
cli_overrides.insert(
request_overrides.insert(
"features.experimental_windows_sandbox".to_string(),
serde_json::json!(true),
);
}
let overrides = ConfigOverrides {
let typesafe_overrides = ConfigOverrides {
model,
config_profile: profile,
cwd: cwd.map(PathBuf::from),
@@ -2228,7 +2298,12 @@ impl CodexMessageProcessor {
..Default::default()
};
derive_config_from_params(overrides, Some(cli_overrides)).await
derive_config_from_params(
&self.cli_overrides,
Some(request_overrides),
typesafe_overrides,
)
.await
}
None => Ok(self.config.as_ref().clone()),
};
@@ -3095,8 +3170,10 @@ impl CodexMessageProcessor {
let outgoing_for_task = self.outgoing.clone();
let pending_interrupts = self.pending_interrupts.clone();
let pending_rollbacks = self.pending_rollbacks.clone();
let turn_summary_store = self.turn_summary_store.clone();
let api_version_for_task = api_version;
let fallback_model_provider = self.config.model_provider_id.clone();
tokio::spawn(async move {
loop {
tokio::select! {
@@ -3152,8 +3229,10 @@ impl CodexMessageProcessor {
conversation.clone(),
outgoing_for_task.clone(),
pending_interrupts.clone(),
pending_rollbacks.clone(),
turn_summary_store.clone(),
api_version_for_task,
fallback_model_provider.clone(),
)
.await;
}
@@ -3341,20 +3420,37 @@ fn errors_to_info(
.collect()
}
/// Derive the effective [`Config`] by layering three override sources.
///
/// Precedence (lowest to highest):
/// - `cli_overrides`: process-wide startup `--config` flags.
/// - `request_overrides`: per-request dotted-path overrides (`params.config`), converted JSON->TOML.
/// - `typesafe_overrides`: Request objects such as `NewConversationParams` and
/// `ThreadStartParams` support a limited set of _explicit_ config overrides, so
/// `typesafe_overrides` is a `ConfigOverrides` derived from the respective request object.
/// Because the overrides are defined explicitly in the `*Params`, this takes priority over
/// the more general "bag of config options" provided by `cli_overrides` and `request_overrides`.
async fn derive_config_from_params(
overrides: ConfigOverrides,
cli_overrides: Option<HashMap<String, serde_json::Value>>,
cli_overrides: &[(String, TomlValue)],
request_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
) -> std::io::Result<Config> {
let cli_overrides = cli_overrides
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, json_to_toml(v)))
.collect();
let merged_cli_overrides = cli_overrides
.iter()
.cloned()
.chain(
request_overrides
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, json_to_toml(v))),
)
.collect::<Vec<_>>();
Config::load_with_cli_overrides_and_harness_overrides(cli_overrides, overrides).await
Config::load_with_cli_overrides_and_harness_overrides(merged_cli_overrides, typesafe_overrides)
.await
}
async fn read_summary_from_rollout(
pub(crate) async fn read_summary_from_rollout(
path: &Path,
fallback_provider: &str,
) -> std::io::Result<ConversationSummary> {
@@ -3413,6 +3509,24 @@ async fn read_summary_from_rollout(
})
}
pub(crate) async fn read_event_msgs_from_rollout(
path: &Path,
) -> std::io::Result<Vec<codex_protocol::protocol::EventMsg>> {
let items = match RolloutRecorder::get_rollout_history(path).await? {
InitialHistory::New => Vec::new(),
InitialHistory::Forked(items) => items,
InitialHistory::Resumed(resumed) => resumed.history,
};
Ok(items
.into_iter()
.filter_map(|item| match item {
RolloutItem::EventMsg(event) => Some(event),
_ => None,
})
.collect())
}
fn extract_conversation_summary(
path: PathBuf,
head: &[serde_json::Value],
@@ -3474,7 +3588,7 @@ fn parse_datetime(timestamp: Option<&str>) -> Option<DateTime<Utc>> {
})
}
fn summary_to_thread(summary: ConversationSummary) -> Thread {
pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread {
let ConversationSummary {
conversation_id,
path,
+1
View File
@@ -15,6 +15,7 @@ pub async fn supported_models(
.list_models(config)
.await
.into_iter()
.filter(|preset| preset.show_in_picker)
.map(model_from_preset)
.collect()
}
@@ -45,6 +45,7 @@ use codex_app_server_protocol::SetDefaultModelParams;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnStartParams;
@@ -316,6 +317,15 @@ impl McpProcess {
self.send_request("thread/archive", params).await
}
/// Send a `thread/rollback` JSON-RPC request.
pub async fn send_thread_rollback_request(
&mut self,
params: ThreadRollbackParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("thread/rollback", params).await
}
/// Send a `thread/list` JSON-RPC request.
pub async fn send_thread_list_request(
&mut self,
@@ -7,6 +7,7 @@ mod review;
mod thread_archive;
mod thread_list;
mod thread_resume;
mod thread_rollback;
mod thread_start;
mod turn_interrupt;
mod turn_start;
@@ -74,7 +74,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::XHigh,
description: "Extra high reasoning for complex problems".to_string(),
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
default_reasoning_effort: ReasoningEffort::Medium,
@@ -0,0 +1,177 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_chat_completions_server_unchecked;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput as V2UserInput;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<()> {
// Three Codex turns hit the mock model (session start + two turn/start calls).
let responses = vec![
create_final_assistant_message_sse_response("Done")?,
create_final_assistant_message_sse_response("Done")?,
create_final_assistant_message_sse_response("Done")?,
];
let server = create_mock_chat_completions_server_unchecked(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
// Start a thread.
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
// Two turns.
let first_text = "First";
let turn1_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: first_text.to_string(),
}],
..Default::default()
})
.await?;
let _turn1_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn1_id)),
)
.await??;
let _completed1 = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let turn2_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "Second".to_string(),
}],
..Default::default()
})
.await?;
let _turn2_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn2_id)),
)
.await??;
let _completed2 = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
// Roll back the last turn.
let rollback_id = mcp
.send_thread_rollback_request(ThreadRollbackParams {
thread_id: thread.id.clone(),
num_turns: 1,
})
.await?;
let rollback_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(rollback_id)),
)
.await??;
let ThreadRollbackResponse {
thread: rolled_back_thread,
} = to_response::<ThreadRollbackResponse>(rollback_resp)?;
assert_eq!(rolled_back_thread.turns.len(), 1);
assert_eq!(rolled_back_thread.turns[0].items.len(), 2);
match &rolled_back_thread.turns[0].items[0] {
ThreadItem::UserMessage { content, .. } => {
assert_eq!(
content,
&vec![V2UserInput::Text {
text: first_text.to_string()
}]
);
}
other => panic!("expected user message item, got {other:?}"),
}
// Resume and confirm the history is pruned.
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id,
..Default::default()
})
.await?;
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let ThreadResumeResponse { thread, .. } = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(thread.turns.len(), 1);
assert_eq!(thread.turns[0].items.len(), 2);
match &thread.turns[0].items[0] {
ThreadItem::UserMessage { content, .. } => {
assert_eq!(
content,
&vec![V2UserInput::Text {
text: first_text.to_string()
}]
);
}
other => panic!("expected user message item, got {other:?}"),
}
Ok(())
}
fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "chat"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}
+189
View File
@@ -0,0 +1,189 @@
use crate::CodexConversation;
use crate::agent::AgentStatus;
use crate::conversation_manager::ConversationManagerState;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use codex_protocol::ConversationId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use std::sync::Arc;
use std::sync::Weak;
/// Control-plane handle for multi-agent operations.
/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to
/// spawn new agents and the inter-agent communication layer.
#[derive(Clone, Default)]
pub(crate) struct AgentControl {
/// Weak handle back to the global conversation registry/state.
/// This is `Weak` to avoid reference cycles and shadow persistence of the form
/// `ConversationManagerState -> CodexConversation -> Session -> SessionServices -> ConversationManagerState`.
manager: Weak<ConversationManagerState>,
}
impl AgentControl {
/// Construct a new `AgentControl` that can spawn/message agents via the given manager state.
pub(crate) fn new(manager: Weak<ConversationManagerState>) -> Self {
Self { manager }
}
#[allow(dead_code)] // Used by upcoming multi-agent tooling.
/// Spawn a new agent conversation and submit the initial prompt.
///
/// If `headless` is true, a background drain task is spawned to prevent unbounded event growth
/// of the channel queue when there is no client actively reading the conversation events.
pub(crate) async fn spawn_agent(
&self,
config: crate::config::Config,
prompt: String,
headless: bool,
) -> CodexResult<ConversationId> {
let state = self.upgrade()?;
let new_conversation = state.spawn_new_conversation(config, self.clone()).await?;
if headless {
spawn_headless_drain(Arc::clone(&new_conversation.conversation));
}
self.send_prompt(new_conversation.conversation_id, prompt)
.await?;
Ok(new_conversation.conversation_id)
}
#[allow(dead_code)] // Used by upcoming multi-agent tooling.
/// Send a `user` prompt to an existing agent conversation.
pub(crate) async fn send_prompt(
&self,
agent_id: ConversationId,
prompt: String,
) -> CodexResult<String> {
let state = self.upgrade()?;
state
.send_op(
agent_id,
Op::UserInput {
items: vec![UserInput::Text { text: prompt }],
final_output_json_schema: None,
},
)
.await
}
#[allow(dead_code)] // Used by upcoming multi-agent tooling.
/// Fetch the last known status for `agent_id`, returning `NotFound` when unavailable.
pub(crate) async fn get_status(&self, agent_id: ConversationId) -> AgentStatus {
let Ok(state) = self.upgrade() else {
// No agent available if upgrade fails.
return AgentStatus::NotFound;
};
let Ok(conversation) = state.get_conversation(agent_id).await else {
return AgentStatus::NotFound;
};
conversation.agent_status().await
}
fn upgrade(&self) -> CodexResult<Arc<ConversationManagerState>> {
self.manager.upgrade().ok_or_else(|| {
CodexErr::UnsupportedOperation("conversation manager dropped".to_string())
})
}
}
/// When an agent is spawned "headless" (no UI/view attached), there may be no consumer polling
/// `CodexConversation::next_event()`. The underlying event channel is unbounded, so the producer can
/// accumulate events indefinitely. This drain task prevents that memory growth by polling and
/// discarding events until shutdown.
fn spawn_headless_drain(conversation: Arc<CodexConversation>) {
tokio::spawn(async move {
loop {
match conversation.next_event().await {
Ok(event) => {
if matches!(event.msg, EventMsg::ShutdownComplete) {
break;
}
}
Err(err) => {
tracing::warn!("failed to receive event from agent: {err:?}");
break;
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::agent_status_from_event;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::TaskCompleteEvent;
use codex_protocol::protocol::TaskStartedEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn send_prompt_errors_when_manager_dropped() {
let control = AgentControl::default();
let err = control
.send_prompt(ConversationId::new(), "hello".to_string())
.await
.expect_err("send_prompt should fail without a manager");
assert_eq!(
err.to_string(),
"unsupported operation: conversation manager dropped"
);
}
#[tokio::test]
async fn get_status_returns_not_found_without_manager() {
let control = AgentControl::default();
let got = control.get_status(ConversationId::new()).await;
assert_eq!(got, AgentStatus::NotFound);
}
#[tokio::test]
async fn on_event_updates_status_from_task_started() {
let status = agent_status_from_event(&EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}));
assert_eq!(status, Some(AgentStatus::Running));
}
#[tokio::test]
async fn on_event_updates_status_from_task_complete() {
let status = agent_status_from_event(&EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: Some("done".to_string()),
}));
let expected = AgentStatus::Completed(Some("done".to_string()));
assert_eq!(status, Some(expected));
}
#[tokio::test]
async fn on_event_updates_status_from_error() {
let status = agent_status_from_event(&EventMsg::Error(ErrorEvent {
message: "boom".to_string(),
codex_error_info: None,
}));
let expected = AgentStatus::Errored("boom".to_string());
assert_eq!(status, Some(expected));
}
#[tokio::test]
async fn on_event_updates_status_from_turn_aborted() {
let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent {
reason: TurnAbortReason::Interrupted,
}));
let expected = AgentStatus::Errored("Interrupted".to_string());
assert_eq!(status, Some(expected));
}
#[tokio::test]
async fn on_event_updates_status_from_shutdown_complete() {
let status = agent_status_from_event(&EventMsg::ShutdownComplete);
assert_eq!(status, Some(AgentStatus::Shutdown));
}
}
+6
View File
@@ -0,0 +1,6 @@
pub(crate) mod control;
pub(crate) mod status;
pub(crate) use codex_protocol::protocol::AgentStatus;
pub(crate) use control::AgentControl;
pub(crate) use status::agent_status_from_event;
+15
View File
@@ -0,0 +1,15 @@
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::EventMsg;
/// Derive the next agent status from a single emitted event.
/// Returns `None` when the event does not affect status tracking.
pub(crate) fn agent_status_from_event(msg: &EventMsg) -> Option<AgentStatus> {
match msg {
EventMsg::TaskStarted(_) => Some(AgentStatus::Running),
EventMsg::TaskComplete(ev) => Some(AgentStatus::Completed(ev.last_agent_message.clone())),
EventMsg::TurnAborted(ev) => Some(AgentStatus::Errored(format!("{:?}", ev.reason))),
EventMsg::Error(ev) => Some(AgentStatus::Errored(ev.message.clone())),
EventMsg::ShutdownComplete => Some(AgentStatus::Shutdown),
_ => None,
}
}
+7 -2
View File
@@ -195,8 +195,13 @@ pub(crate) mod tools {
LocalShell {},
// TODO: Understand why we get an error on web_search although the API docs say it's supported.
// https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C
// The `external_web_access` field determines whether the web search is over cached or live content.
// https://platform.openai.com/docs/guides/tools-web-search#live-internet-access
#[serde(rename = "web_search")]
WebSearch {},
WebSearch {
#[serde(skip_serializing_if = "Option::is_none")]
external_web_access: Option<bool>,
},
#[serde(rename = "custom")]
Freeform(FreeformTool),
}
@@ -206,7 +211,7 @@ pub(crate) mod tools {
match self {
ToolSpec::Function(tool) => tool.name.as_str(),
ToolSpec::LocalShell {} => "local_shell",
ToolSpec::WebSearch {} => "web_search",
ToolSpec::WebSearch { .. } => "web_search",
ToolSpec::Freeform(tool) => tool.name.as_str(),
}
}
+271 -8
View File
@@ -8,6 +8,9 @@ use std::sync::atomic::Ordering;
use crate::AuthManager;
use crate::SandboxState;
use crate::agent::AgentControl;
use crate::agent::AgentStatus;
use crate::agent::agent_status_from_event;
use crate::client_common::REVIEW_PROMPT;
use crate::compact;
use crate::compact::run_inline_auto_compact_task;
@@ -143,7 +146,7 @@ use crate::tools::sandboxing::ApprovalStore;
use crate::tools::spec::ToolsConfig;
use crate::tools::spec::ToolsConfigParams;
use crate::turn_diff_tracker::TurnDiffTracker;
use crate::unified_exec::UnifiedExecSessionManager;
use crate::unified_exec::UnifiedExecProcessManager;
use crate::user_instructions::DeveloperInstructions;
use crate::user_instructions::UserInstructions;
use crate::user_notification::UserNotification;
@@ -167,6 +170,8 @@ pub struct Codex {
pub(crate) next_id: AtomicU64,
pub(crate) tx_sub: Sender<Submission>,
pub(crate) rx_event: Receiver<Event>,
// Last known status of the agent.
pub(crate) agent_status: Arc<RwLock<AgentStatus>>,
}
/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`],
@@ -207,13 +212,14 @@ fn maybe_push_chat_wire_api_deprecation(
impl Codex {
/// Spawn a new [`Codex`] and initialize the session.
pub async fn spawn(
pub(crate) async fn spawn(
config: Config,
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
skills_manager: Arc<SkillsManager>,
conversation_history: InitialHistory,
session_source: SessionSource,
agent_control: AgentControl,
) -> CodexResult<CodexSpawnOk> {
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
@@ -273,6 +279,7 @@ impl Codex {
// Generate a unique ID for the lifetime of this Codex session.
let session_source_clone = session_configuration.session_source.clone();
let agent_status = Arc::new(RwLock::new(AgentStatus::PendingInit));
let session = Session::new(
session_configuration,
@@ -281,9 +288,11 @@ impl Codex {
models_manager.clone(),
exec_policy,
tx_event.clone(),
Arc::clone(&agent_status),
conversation_history,
session_source_clone,
skills_manager,
agent_control,
)
.await
.map_err(|e| {
@@ -298,6 +307,7 @@ impl Codex {
next_id: AtomicU64::new(0),
tx_sub,
rx_event,
agent_status,
};
Ok(CodexSpawnOk {
@@ -335,6 +345,11 @@ impl Codex {
.map_err(|_| CodexErr::InternalAgentDied)?;
Ok(event)
}
pub(crate) async fn agent_status(&self) -> AgentStatus {
let status = self.agent_status.read().await;
status.clone()
}
}
/// Context for an initialized model agent
@@ -343,6 +358,7 @@ impl Codex {
pub(crate) struct Session {
conversation_id: ConversationId,
tx_event: Sender<Event>,
agent_status: Arc<RwLock<AgentStatus>>,
state: Mutex<SessionState>,
/// The set of enabled features should be invariant for the lifetime of the
/// session.
@@ -546,9 +562,11 @@ impl Session {
models_manager: Arc<ModelsManager>,
exec_policy: ExecPolicyManager,
tx_event: Sender<Event>,
agent_status: Arc<RwLock<AgentStatus>>,
initial_history: InitialHistory,
session_source: SessionSource,
skills_manager: Arc<SkillsManager>,
agent_control: AgentControl,
) -> anyhow::Result<Arc<Self>> {
debug!(
"Configuring session: model={}; provider={:?}",
@@ -659,7 +677,7 @@ impl Session {
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
mcp_startup_cancellation_token: CancellationToken::new(),
unified_exec_manager: UnifiedExecSessionManager::default(),
unified_exec_manager: UnifiedExecProcessManager::default(),
notifier: UserNotifier::new(config.notify.clone()),
rollout: Mutex::new(Some(rollout_recorder)),
user_shell: Arc::new(default_shell),
@@ -670,11 +688,13 @@ impl Session {
models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
skills_manager,
agent_control,
};
let sess = Arc::new(Session {
conversation_id,
tx_event: tx_event.clone(),
agent_status: Arc::clone(&agent_status),
state: Mutex::new(state),
features: config.features.clone(),
active_turn: Mutex::new(None),
@@ -995,6 +1015,11 @@ impl Session {
}
pub(crate) async fn send_event_raw(&self, event: Event) {
// Record the last known agent status.
if let Some(status) = agent_status_from_event(&event.msg) {
let mut guard = self.agent_status.write().await;
*guard = status;
}
// Persist the event into rollout (recorder filters as needed)
let rollout_items = vec![RolloutItem::EventMsg(event.msg.clone())];
self.persist_rollout_items(&rollout_items).await;
@@ -1003,6 +1028,25 @@ impl Session {
}
}
/// Persist the event to the rollout file, flush it, and only then deliver it to clients.
///
/// Most events can be delivered immediately after queueing the rollout write, but some
/// clients (e.g. app-server thread/rollback) re-read the rollout file synchronously on
/// receipt of the event and depend on the marker already being visible on disk.
pub(crate) async fn send_event_raw_flushed(&self, event: Event) {
// Record the last known agent status.
if let Some(status) = agent_status_from_event(&event.msg) {
let mut guard = self.agent_status.write().await;
*guard = status;
}
self.persist_rollout_items(&[RolloutItem::EventMsg(event.msg.clone())])
.await;
self.flush_rollout().await;
if let Err(e) = self.tx_event.send(event).await {
error!("failed to send tool call event: {e}");
}
}
pub(crate) async fn emit_turn_item_started(&self, turn_context: &TurnContext, item: &TurnItem) {
self.send_event(
turn_context,
@@ -1220,6 +1264,9 @@ impl Session {
history.replace(rebuilt);
}
}
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => {
history.drop_last_n_user_turns(rollback.num_turns);
}
_ => {}
}
}
@@ -1659,6 +1706,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
Op::Compact => {
handlers::compact(&sess, sub.id.clone()).await;
}
Op::ThreadRollback { num_turns } => {
handlers::thread_rollback(&sess, sub.id.clone(), num_turns).await;
}
Op::RunUserShellCommand { command } => {
handlers::run_user_shell_command(
&sess,
@@ -1716,6 +1766,7 @@ mod handlers {
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::SkillsListEntry;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::WarningEvent;
@@ -2035,11 +2086,51 @@ mod handlers {
.await;
}
pub async fn thread_rollback(sess: &Arc<Session>, sub_id: String, num_turns: u32) {
if num_turns == 0 {
sess.send_event_raw(Event {
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: "num_turns must be >= 1".to_string(),
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
}),
})
.await;
return;
}
let has_active_turn = { sess.active_turn.lock().await.is_some() };
if has_active_turn {
sess.send_event_raw(Event {
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: "Cannot rollback while a turn is in progress.".to_string(),
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
}),
})
.await;
return;
}
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
let mut history = sess.clone_history().await;
history.drop_last_n_user_turns(num_turns);
sess.replace_history(history.get_history()).await;
sess.recompute_token_usage(turn_context.as_ref()).await;
sess.send_event_raw_flushed(Event {
id: turn_context.sub_id.clone(),
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns }),
})
.await;
}
pub async fn shutdown(sess: &Arc<Session>, sub_id: String) -> bool {
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
sess.services
.unified_exec_manager
.terminate_all_sessions()
.terminate_all_processes()
.await;
info!("Shutting down Codex instance");
@@ -2121,6 +2212,7 @@ async fn spawn_review_thread(
let mut review_features = sess.features.clone();
review_features
.disable(crate::features::Feature::WebSearchRequest)
.disable(crate::features::Feature::WebSearchCached)
.disable(crate::features::Feature::ViewImageTool);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_family: &review_model_family,
@@ -2948,6 +3040,131 @@ mod tests {
assert_eq!(expected, actual);
}
#[tokio::test]
async fn thread_rollback_drops_last_turn_from_history() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let initial_context = sess.build_initial_context(tc.as_ref());
sess.record_into_history(&initial_context, tc.as_ref())
.await;
let turn_1 = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "turn 1 user".to_string(),
}],
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "turn 1 assistant".to_string(),
}],
},
];
sess.record_into_history(&turn_1, tc.as_ref()).await;
let turn_2 = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "turn 2 user".to_string(),
}],
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "turn 2 assistant".to_string(),
}],
},
];
sess.record_into_history(&turn_2, tc.as_ref()).await;
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
let rollback_event = wait_for_thread_rolled_back(&rx).await;
assert_eq!(rollback_event.num_turns, 1);
let mut expected = Vec::new();
expected.extend(initial_context);
expected.extend(turn_1);
let actual = sess.clone_history().await.get_history();
assert_eq!(expected, actual);
}
#[tokio::test]
async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let initial_context = sess.build_initial_context(tc.as_ref());
sess.record_into_history(&initial_context, tc.as_ref())
.await;
let turn_1 = vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "turn 1 user".to_string(),
}],
}];
sess.record_into_history(&turn_1, tc.as_ref()).await;
handlers::thread_rollback(&sess, "sub-1".to_string(), 99).await;
let rollback_event = wait_for_thread_rolled_back(&rx).await;
assert_eq!(rollback_event.num_turns, 99);
let actual = sess.clone_history().await.get_history();
assert_eq!(initial_context, actual);
}
#[tokio::test]
async fn thread_rollback_fails_when_turn_in_progress() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let initial_context = sess.build_initial_context(tc.as_ref());
sess.record_into_history(&initial_context, tc.as_ref())
.await;
*sess.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await;
let error_event = wait_for_thread_rollback_failed(&rx).await;
assert_eq!(
error_event.codex_error_info,
Some(CodexErrorInfo::ThreadRollbackFailed)
);
let actual = sess.clone_history().await.get_history();
assert_eq!(initial_context, actual);
}
#[tokio::test]
async fn thread_rollback_fails_when_num_turns_is_zero() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let initial_context = sess.build_initial_context(tc.as_ref());
sess.record_into_history(&initial_context, tc.as_ref())
.await;
handlers::thread_rollback(&sess, "sub-1".to_string(), 0).await;
let error_event = wait_for_thread_rollback_failed(&rx).await;
assert_eq!(error_event.message, "num_turns must be >= 1");
assert_eq!(
error_event.codex_error_info,
Some(CodexErrorInfo::ThreadRollbackFailed)
);
let actual = sess.clone_history().await.get_history();
assert_eq!(initial_context, actual);
}
#[tokio::test]
async fn set_rate_limits_retains_previous_credits() {
let codex_home = tempfile::tempdir().expect("create temp dir");
@@ -3181,6 +3398,44 @@ mod tests {
assert_eq!(expected, got);
}
async fn wait_for_thread_rolled_back(
rx: &async_channel::Receiver<Event>,
) -> crate::protocol::ThreadRolledBackEvent {
let deadline = StdDuration::from_secs(2);
let start = std::time::Instant::now();
loop {
let remaining = deadline.saturating_sub(start.elapsed());
let evt = tokio::time::timeout(remaining, rx.recv())
.await
.expect("timeout waiting for event")
.expect("event");
match evt.msg {
EventMsg::ThreadRolledBack(payload) => return payload,
_ => continue,
}
}
}
async fn wait_for_thread_rollback_failed(rx: &async_channel::Receiver<Event>) -> ErrorEvent {
let deadline = StdDuration::from_secs(2);
let start = std::time::Instant::now();
loop {
let remaining = deadline.saturating_sub(start.elapsed());
let evt = tokio::time::timeout(remaining, rx.recv())
.await
.expect("timeout waiting for event")
.expect("event");
match evt.msg {
EventMsg::Error(payload)
if payload.codex_error_info == Some(CodexErrorInfo::ThreadRollbackFailed) =>
{
return payload;
}
_ => continue,
}
}
}
fn text_block(s: &str) -> ContentBlock {
ContentBlock::TextContent(TextContent {
annotations: None,
@@ -3225,7 +3480,9 @@ mod tests {
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
let models_manager = Arc::new(ModelsManager::new(auth_manager.clone()));
let agent_control = AgentControl::default();
let exec_policy = ExecPolicyManager::default();
let agent_status = Arc::new(RwLock::new(AgentStatus::PendingInit));
let model = ModelsManager::get_model_offline(config.model.as_deref());
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
@@ -3260,7 +3517,7 @@ mod tests {
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
mcp_startup_cancellation_token: CancellationToken::new(),
unified_exec_manager: UnifiedExecSessionManager::default(),
unified_exec_manager: UnifiedExecProcessManager::default(),
notifier: UserNotifier::new(None),
rollout: Mutex::new(None),
user_shell: Arc::new(default_user_shell()),
@@ -3271,6 +3528,7 @@ mod tests {
models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
skills_manager,
agent_control,
};
let turn_context = Session::make_turn_context(
@@ -3287,6 +3545,7 @@ mod tests {
let session = Session {
conversation_id,
tx_event,
agent_status: Arc::clone(&agent_status),
state: Mutex::new(state),
features: config.features.clone(),
active_turn: Mutex::new(None),
@@ -3312,7 +3571,9 @@ mod tests {
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
let models_manager = Arc::new(ModelsManager::new(auth_manager.clone()));
let agent_control = AgentControl::default();
let exec_policy = ExecPolicyManager::default();
let agent_status = Arc::new(RwLock::new(AgentStatus::PendingInit));
let model = ModelsManager::get_model_offline(config.model.as_deref());
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
@@ -3347,7 +3608,7 @@ mod tests {
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
mcp_startup_cancellation_token: CancellationToken::new(),
unified_exec_manager: UnifiedExecSessionManager::default(),
unified_exec_manager: UnifiedExecProcessManager::default(),
notifier: UserNotifier::new(None),
rollout: Mutex::new(None),
user_shell: Arc::new(default_user_shell()),
@@ -3358,6 +3619,7 @@ mod tests {
models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
skills_manager,
agent_control,
};
let turn_context = Arc::new(Session::make_turn_context(
@@ -3374,6 +3636,7 @@ mod tests {
let session = Arc::new(Session {
conversation_id,
tx_event,
agent_status: Arc::clone(&agent_status),
state: Mutex::new(state),
features: config.features.clone(),
active_turn: Mutex::new(None),
@@ -3392,7 +3655,7 @@ mod tests {
session.features = features;
session
.record_model_warning("too many unified exec sessions", &turn_context)
.record_model_warning("too many unified exec processes", &turn_context)
.await;
let mut history = session.clone_history().await;
@@ -3405,7 +3668,7 @@ mod tests {
assert_eq!(
content,
&vec![ContentItem::InputText {
text: "Warning: too many unified exec sessions".to_string(),
text: "Warning: too many unified exec processes".to_string(),
}]
);
}
+5
View File
@@ -1,3 +1,4 @@
use crate::agent::AgentStatus;
use crate::codex::Codex;
use crate::error::Result as CodexResult;
use crate::protocol::Event;
@@ -33,6 +34,10 @@ impl CodexConversation {
self.codex.next_event().await
}
pub async fn agent_status(&self) -> AgentStatus {
self.codex.agent_status().await
}
pub fn rollout_path(&self) -> PathBuf {
self.rollout_path.clone()
}
+5
View File
@@ -52,6 +52,7 @@ pub(crate) async fn run_codex_conversation_interactive(
Arc::clone(&parent_session.services.skills_manager),
initial_history.unwrap_or(InitialHistory::New),
SessionSource::SubAgent(SubAgentSource::Review),
parent_session.services.agent_control.clone(),
)
.await?;
let codex = Arc::new(codex);
@@ -86,6 +87,7 @@ pub(crate) async fn run_codex_conversation_interactive(
next_id: AtomicU64::new(0),
tx_sub: tx_ops,
rx_event: rx_sub,
agent_status: Arc::clone(&codex.agent_status),
})
}
@@ -127,6 +129,7 @@ pub(crate) async fn run_codex_conversation_one_shot(
// Bridge events so we can observe completion and shut down automatically.
let (tx_bridge, rx_bridge) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let ops_tx = io.tx_sub.clone();
let agent_status = Arc::clone(&io.agent_status);
let io_for_bridge = io;
tokio::spawn(async move {
while let Ok(event) = io_for_bridge.next_event().await {
@@ -158,6 +161,7 @@ pub(crate) async fn run_codex_conversation_one_shot(
next_id: AtomicU64::new(0),
rx_event: rx_bridge,
tx_sub: tx_closed,
agent_status,
})
}
@@ -372,6 +376,7 @@ mod tests {
next_id: AtomicU64::new(0),
tx_sub,
rx_event: rx_events,
agent_status: Default::default(),
});
let (session, ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await;
+24
View File
@@ -353,6 +353,10 @@ pub struct Config {
/// or placeholder replacement will occur for fast keypress bursts.
pub disable_paste_burst: bool,
/// When `false`, disables analytics across Codex product surfaces in this machine.
/// Defaults to `true`.
pub analytics: bool,
/// OTEL configuration (exporter type, endpoint, headers, etc.).
pub otel: crate::config::types::OtelConfig,
}
@@ -813,6 +817,10 @@ pub struct ConfigToml {
/// or placeholder replacement will occur for fast keypress bursts.
pub disable_paste_burst: Option<bool>,
/// When `false`, disables analytics across Codex product surfaces in this machine.
/// Defaults to `true`.
pub analytics: Option<crate::config::types::AnalyticsConfigToml>,
/// OTEL configuration.
pub otel: Option<crate::config::types::OtelConfigToml>,
@@ -1390,6 +1398,12 @@ impl Config {
notices: cfg.notice.unwrap_or_default(),
check_for_update_on_startup,
disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false),
analytics: config_profile
.analytics
.as_ref()
.and_then(|a| a.enabled)
.or(cfg.analytics.as_ref().and_then(|a| a.enabled))
.unwrap_or(true),
tui_notifications: cfg
.tui
.as_ref()
@@ -3039,6 +3053,9 @@ approval_policy = "untrusted"
# `ConfigOverrides`.
profile = "gpt3"
[analytics]
enabled = true
[model_providers.openai-chat-completions]
name = "OpenAI using Chat Completions"
base_url = "https://api.openai.com/v1"
@@ -3064,6 +3081,9 @@ model = "o3"
model_provider = "openai"
approval_policy = "on-failure"
[profiles.zdr.analytics]
enabled = false
[profiles.gpt5]
model = "gpt-5.1"
model_provider = "openai"
@@ -3204,6 +3224,7 @@ model_verbosity = "high"
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
analytics: true,
tui_scroll_events_per_tick: None,
tui_scroll_wheel_lines: None,
tui_scroll_trackpad_lines: None,
@@ -3287,6 +3308,7 @@ model_verbosity = "high"
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
analytics: true,
tui_scroll_events_per_tick: None,
tui_scroll_wheel_lines: None,
tui_scroll_trackpad_lines: None,
@@ -3385,6 +3407,7 @@ model_verbosity = "high"
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
analytics: false,
tui_scroll_events_per_tick: None,
tui_scroll_wheel_lines: None,
tui_scroll_trackpad_lines: None,
@@ -3469,6 +3492,7 @@ model_verbosity = "high"
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
analytics: true,
tui_scroll_events_per_tick: None,
tui_scroll_wheel_lines: None,
tui_scroll_trackpad_lines: None,
+1
View File
@@ -29,6 +29,7 @@ pub struct ConfigProfile {
pub experimental_use_freeform_apply_patch: Option<bool>,
pub tools_web_search: Option<bool>,
pub tools_view_image: Option<bool>,
pub analytics: Option<crate::config::types::AnalyticsConfigToml>,
/// Optional feature toggles scoped to this profile.
#[serde(default)]
pub features: Option<crate::features::FeaturesToml>,
+9
View File
@@ -273,6 +273,15 @@ pub enum HistoryPersistence {
None,
}
// ===== Analytics configuration =====
/// Analytics settings loaded from config.toml. Fields are optional so we can apply defaults.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
pub struct AnalyticsConfigToml {
/// When `false`, disables analytics across Codex product surfaces in this profile.
pub enabled: Option<bool>,
}
// ===== OTEL configuration =====
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
@@ -5,6 +5,9 @@ use crate::truncate::approx_token_count;
use crate::truncate::approx_tokens_from_byte_count;
use crate::truncate::truncate_function_output_items_with_policy;
use crate::truncate::truncate_text;
use crate::user_instructions::SkillInstructions;
use crate::user_instructions::UserInstructions;
use crate::user_shell_command::is_user_shell_command_text;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
@@ -152,6 +155,39 @@ impl ContextManager {
}
}
/// Drop the last `num_turns` user turns from this history.
///
/// "User turns" are identified as `ResponseItem::Message` entries whose role is `"user"`.
///
/// This mirrors thread-rollback semantics:
/// - `num_turns == 0` is a no-op
/// - if there are no user turns, this is a no-op
/// - if `num_turns` exceeds the number of user turns, all user turns are dropped while
/// preserving any items that occurred before the first user message.
pub(crate) fn drop_last_n_user_turns(&mut self, num_turns: u32) {
if num_turns == 0 {
return;
}
// Keep behavior consistent with call sites that previously operated on `get_history()`:
// normalize first (call/output invariants), then truncate based on the normalized view.
let snapshot = self.get_history();
let user_positions = user_message_positions(&snapshot);
let Some(&first_user_idx) = user_positions.first() else {
self.replace(snapshot);
return;
};
let n_from_end = usize::try_from(num_turns).unwrap_or(usize::MAX);
let cut_idx = if n_from_end >= user_positions.len() {
first_user_idx
} else {
user_positions[user_positions.len() - n_from_end]
};
self.replace(snapshot[..cut_idx].to_vec());
}
pub(crate) fn update_token_info(
&mut self,
usage: &TokenUsage,
@@ -291,6 +327,56 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize {
.saturating_sub(650)
}
fn is_session_prefix(text: &str) -> bool {
let trimmed = text.trim_start();
let lowered = trimmed.to_ascii_lowercase();
lowered.starts_with("<environment_context>")
}
fn is_user_turn_boundary(item: &ResponseItem) -> bool {
let ResponseItem::Message { role, content, .. } = item else {
return false;
};
if role != "user" {
return false;
}
if UserInstructions::is_user_instructions(content)
|| SkillInstructions::is_skill_instructions(content)
{
return false;
}
for content_item in content {
match content_item {
ContentItem::InputText { text } => {
if is_session_prefix(text) || is_user_shell_command_text(text) {
return false;
}
}
ContentItem::OutputText { text } => {
if is_session_prefix(text) {
return false;
}
}
ContentItem::InputImage { .. } => {}
}
}
true
}
fn user_message_positions(items: &[ResponseItem]) -> Vec<usize> {
let mut positions = Vec::new();
for (idx, item) in items.iter().enumerate() {
if is_user_turn_boundary(item) {
positions.push(idx);
}
}
positions
}
#[cfg(test)]
#[path = "history_tests.rs"]
mod tests;
@@ -43,6 +43,16 @@ fn user_msg(text: &str) -> ResponseItem {
}
}
fn user_input_text_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
}
}
fn reasoning_msg(text: &str) -> ResponseItem {
ResponseItem::Reasoning {
id: String::new(),
@@ -227,6 +237,127 @@ fn remove_first_item_handles_local_shell_pair() {
assert_eq!(h.contents(), vec![]);
}
#[test]
fn drop_last_n_user_turns_preserves_prefix() {
let items = vec![
assistant_msg("session prefix item"),
user_msg("u1"),
assistant_msg("a1"),
user_msg("u2"),
assistant_msg("a2"),
];
let mut history = create_history_with_items(items);
history.drop_last_n_user_turns(1);
assert_eq!(
history.get_history(),
vec![
assistant_msg("session prefix item"),
user_msg("u1"),
assistant_msg("a1"),
]
);
let mut history = create_history_with_items(vec![
assistant_msg("session prefix item"),
user_msg("u1"),
assistant_msg("a1"),
user_msg("u2"),
assistant_msg("a2"),
]);
history.drop_last_n_user_turns(99);
assert_eq!(
history.get_history(),
vec![assistant_msg("session prefix item")]
);
}
#[test]
fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
let items = vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
),
user_input_text_msg(
"<skill>\n<name>demo</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>",
),
user_input_text_msg("<user_shell_command>echo 42</user_shell_command>"),
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
user_input_text_msg("turn 2 user"),
assistant_msg("turn 2 assistant"),
];
let mut history = create_history_with_items(items);
history.drop_last_n_user_turns(1);
let expected_prefix_and_first_turn = vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
),
user_input_text_msg(
"<skill>\n<name>demo</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>",
),
user_input_text_msg("<user_shell_command>echo 42</user_shell_command>"),
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
];
assert_eq!(history.get_history(), expected_prefix_and_first_turn);
let expected_prefix_only = vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
),
user_input_text_msg(
"<skill>\n<name>demo</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>",
),
user_input_text_msg("<user_shell_command>echo 42</user_shell_command>"),
];
let mut history = create_history_with_items(vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
),
user_input_text_msg(
"<skill>\n<name>demo</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>",
),
user_input_text_msg("<user_shell_command>echo 42</user_shell_command>"),
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
user_input_text_msg("turn 2 user"),
assistant_msg("turn 2 assistant"),
]);
history.drop_last_n_user_turns(2);
assert_eq!(history.get_history(), expected_prefix_only);
let mut history = create_history_with_items(vec![
user_input_text_msg("<environment_context>ctx</environment_context>"),
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
),
user_input_text_msg(
"<skill>\n<name>demo</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>",
),
user_input_text_msg("<user_shell_command>echo 42</user_shell_command>"),
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
user_input_text_msg("turn 2 user"),
assistant_msg("turn 2 assistant"),
]);
history.drop_last_n_user_turns(3);
assert_eq!(history.get_history(), expected_prefix_only);
}
#[test]
fn remove_first_item_handles_custom_tool_pair() {
let items = vec![
+165 -148
View File
@@ -3,6 +3,7 @@ use crate::AuthManager;
use crate::CodexAuth;
#[cfg(any(test, feature = "test-support"))]
use crate::ModelProviderInfo;
use crate::agent::AgentControl;
use crate::codex::Codex;
use crate::codex::CodexSpawnOk;
use crate::codex::INITIAL_SUBMIT_ID;
@@ -15,12 +16,12 @@ use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::SessionConfiguredEvent;
use crate::rollout::RolloutRecorder;
use crate::rollout::truncation;
use crate::skills::SkillsManager;
use codex_protocol::ConversationId;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use std::collections::HashMap;
@@ -41,24 +42,34 @@ pub struct NewConversation {
/// [`ConversationManager`] is responsible for creating conversations and
/// maintaining them in memory.
pub struct ConversationManager {
state: Arc<ConversationManagerState>,
#[cfg(any(test, feature = "test-support"))]
_test_codex_home_guard: Option<TempDir>,
}
/// Shared, `Arc`-owned state for [`ConversationManager`]. This `Arc` is required to have a single
/// `Arc` reference that can be downgraded to by `AgentControl` while preventing every single
/// function to require an `Arc<&Self>`.
pub(crate) struct ConversationManagerState {
conversations: Arc<RwLock<HashMap<ConversationId, Arc<CodexConversation>>>>,
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
skills_manager: Arc<SkillsManager>,
session_source: SessionSource,
#[cfg(any(test, feature = "test-support"))]
_test_codex_home_guard: Option<TempDir>,
}
impl ConversationManager {
pub fn new(auth_manager: Arc<AuthManager>, session_source: SessionSource) -> Self {
let skills_manager = Arc::new(SkillsManager::new(auth_manager.codex_home().to_path_buf()));
Self {
conversations: Arc::new(RwLock::new(HashMap::new())),
auth_manager: auth_manager.clone(),
session_source,
models_manager: Arc::new(ModelsManager::new(auth_manager)),
skills_manager,
state: Arc::new(ConversationManagerState {
conversations: Arc::new(RwLock::new(HashMap::new())),
models_manager: Arc::new(ModelsManager::new(auth_manager.clone())),
skills_manager: Arc::new(SkillsManager::new(
auth_manager.codex_home().to_path_buf(),
)),
auth_manager,
session_source,
}),
#[cfg(any(test, feature = "test-support"))]
_test_codex_home_guard: None,
}
@@ -83,40 +94,165 @@ impl ConversationManager {
provider: ModelProviderInfo,
codex_home: PathBuf,
) -> Self {
let auth_manager = crate::AuthManager::from_auth_for_testing_with_home(auth, codex_home);
let skills_manager = Arc::new(SkillsManager::new(auth_manager.codex_home().to_path_buf()));
let auth_manager = AuthManager::from_auth_for_testing_with_home(auth, codex_home);
Self {
conversations: Arc::new(RwLock::new(HashMap::new())),
auth_manager: auth_manager.clone(),
session_source: SessionSource::Exec,
models_manager: Arc::new(ModelsManager::with_provider(auth_manager, provider)),
skills_manager,
state: Arc::new(ConversationManagerState {
conversations: Arc::new(RwLock::new(HashMap::new())),
models_manager: Arc::new(ModelsManager::with_provider(
auth_manager.clone(),
provider,
)),
skills_manager: Arc::new(SkillsManager::new(
auth_manager.codex_home().to_path_buf(),
)),
auth_manager,
session_source: SessionSource::Exec,
}),
_test_codex_home_guard: None,
}
}
pub fn session_source(&self) -> SessionSource {
self.session_source.clone()
self.state.session_source.clone()
}
pub fn skills_manager(&self) -> Arc<SkillsManager> {
self.skills_manager.clone()
self.state.skills_manager.clone()
}
pub fn get_models_manager(&self) -> Arc<ModelsManager> {
self.state.models_manager.clone()
}
pub async fn list_models(&self, config: &Config) -> Vec<ModelPreset> {
self.state.models_manager.list_models(config).await
}
pub async fn get_conversation(
&self,
conversation_id: ConversationId,
) -> CodexResult<Arc<CodexConversation>> {
self.state.get_conversation(conversation_id).await
}
pub async fn new_conversation(&self, config: Config) -> CodexResult<NewConversation> {
self.state
.spawn_conversation(
config,
InitialHistory::New,
Arc::clone(&self.state.auth_manager),
self.agent_control(),
)
.await
}
pub async fn resume_conversation_from_rollout(
&self,
config: Config,
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
) -> CodexResult<NewConversation> {
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
self.resume_conversation_with_history(config, initial_history, auth_manager)
.await
}
pub async fn resume_conversation_with_history(
&self,
config: Config,
initial_history: InitialHistory,
auth_manager: Arc<AuthManager>,
) -> CodexResult<NewConversation> {
self.state
.spawn_conversation(config, initial_history, auth_manager, self.agent_control())
.await
}
/// Removes the conversation from the manager's internal map, though the conversation is stored
/// as `Arc<CodexConversation>`, it is possible that other references to it exist elsewhere.
/// Returns the conversation if the conversation was found and removed.
pub async fn remove_conversation(
&self,
conversation_id: &ConversationId,
) -> Option<Arc<CodexConversation>> {
self.state
.conversations
.write()
.await
.remove(conversation_id)
}
/// Fork an existing conversation by taking messages up to the given position (not including
/// the message at the given position) and starting a new conversation with identical
/// configuration (unless overridden by the caller's `config`). The new conversation will have
/// a fresh id.
pub async fn fork_conversation(
&self,
nth_user_message: usize,
config: Config,
path: PathBuf,
) -> CodexResult<NewConversation> {
let history = RolloutRecorder::get_rollout_history(&path).await?;
let history = truncate_before_nth_user_message(history, nth_user_message);
self.state
.spawn_conversation(
config,
history,
Arc::clone(&self.state.auth_manager),
self.agent_control(),
)
.await
}
fn agent_control(&self) -> AgentControl {
AgentControl::new(Arc::downgrade(&self.state))
}
}
impl ConversationManagerState {
pub(crate) async fn get_conversation(
&self,
conversation_id: ConversationId,
) -> CodexResult<Arc<CodexConversation>> {
let conversations = self.conversations.read().await;
conversations
.get(&conversation_id)
.cloned()
.ok_or_else(|| CodexErr::ConversationNotFound(conversation_id))
}
pub(crate) async fn send_op(
&self,
conversation_id: ConversationId,
op: Op,
) -> CodexResult<String> {
self.get_conversation(conversation_id)
.await?
.submit(op)
.await
}
#[allow(dead_code)] // Used by upcoming multi-agent tooling.
pub(crate) async fn spawn_new_conversation(
&self,
config: Config,
agent_control: AgentControl,
) -> CodexResult<NewConversation> {
self.spawn_conversation(
config,
self.auth_manager.clone(),
self.models_manager.clone(),
InitialHistory::New,
Arc::clone(&self.auth_manager),
agent_control,
)
.await
}
async fn spawn_conversation(
pub(crate) async fn spawn_conversation(
&self,
config: Config,
initial_history: InitialHistory,
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
agent_control: AgentControl,
) -> CodexResult<NewConversation> {
let CodexSpawnOk {
codex,
@@ -124,10 +260,11 @@ impl ConversationManager {
} = Codex::spawn(
config,
auth_manager,
models_manager,
self.skills_manager.clone(),
InitialHistory::New,
Arc::clone(&self.models_manager),
Arc::clone(&self.skills_manager),
initial_history,
self.session_source.clone(),
agent_control,
)
.await?;
self.finalize_spawn(codex, conversation_id).await
@@ -138,9 +275,6 @@ impl ConversationManager {
codex: Codex,
conversation_id: ConversationId,
) -> CodexResult<NewConversation> {
// The first event must be `SessionInitialized`. Validate and forward it
// to the caller so that they can display it in the conversation
// history.
let event = codex.next_event().await?;
let session_configured = match event {
Event {
@@ -167,129 +301,13 @@ impl ConversationManager {
session_configured,
})
}
pub async fn get_conversation(
&self,
conversation_id: ConversationId,
) -> CodexResult<Arc<CodexConversation>> {
let conversations = self.conversations.read().await;
conversations
.get(&conversation_id)
.cloned()
.ok_or_else(|| CodexErr::ConversationNotFound(conversation_id))
}
pub async fn resume_conversation_from_rollout(
&self,
config: Config,
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
) -> CodexResult<NewConversation> {
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
self.resume_conversation_with_history(config, initial_history, auth_manager)
.await
}
pub async fn resume_conversation_with_history(
&self,
config: Config,
initial_history: InitialHistory,
auth_manager: Arc<AuthManager>,
) -> CodexResult<NewConversation> {
let CodexSpawnOk {
codex,
conversation_id,
} = Codex::spawn(
config,
auth_manager,
self.models_manager.clone(),
self.skills_manager.clone(),
initial_history,
self.session_source.clone(),
)
.await?;
self.finalize_spawn(codex, conversation_id).await
}
/// Removes the conversation from the manager's internal map, though the
/// conversation is stored as `Arc<CodexConversation>`, it is possible that
/// other references to it exist elsewhere. Returns the conversation if the
/// conversation was found and removed.
pub async fn remove_conversation(
&self,
conversation_id: &ConversationId,
) -> Option<Arc<CodexConversation>> {
self.conversations.write().await.remove(conversation_id)
}
/// Fork an existing conversation by taking messages up to the given position
/// (not including the message at the given position) and starting a new
/// conversation with identical configuration (unless overridden by the
/// caller's `config`). The new conversation will have a fresh id.
pub async fn fork_conversation(
&self,
nth_user_message: usize,
config: Config,
path: PathBuf,
) -> CodexResult<NewConversation> {
// Compute the prefix up to the cut point.
let history = RolloutRecorder::get_rollout_history(&path).await?;
let history = truncate_before_nth_user_message(history, nth_user_message);
// Spawn a new conversation with the computed initial history.
let auth_manager = self.auth_manager.clone();
let CodexSpawnOk {
codex,
conversation_id,
} = Codex::spawn(
config,
auth_manager,
self.models_manager.clone(),
self.skills_manager.clone(),
history,
self.session_source.clone(),
)
.await?;
self.finalize_spawn(codex, conversation_id).await
}
pub async fn list_models(&self, config: &Config) -> Vec<ModelPreset> {
self.models_manager.list_models(config).await
}
pub fn get_models_manager(&self) -> Arc<ModelsManager> {
self.models_manager.clone()
}
}
/// Return a prefix of `items` obtained by cutting strictly before the nth user message
/// (0-based) and all items that follow it.
fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> InitialHistory {
// Work directly on rollout items, and cut the vector at the nth user message input.
let items: Vec<RolloutItem> = history.get_rollout_items();
// Find indices of user message inputs in rollout order.
let mut user_positions: Vec<usize> = Vec::new();
for (idx, item) in items.iter().enumerate() {
if let RolloutItem::ResponseItem(item @ ResponseItem::Message { .. }) = item
&& matches!(
crate::event_mapping::parse_turn_item(item),
Some(TurnItem::UserMessage(_))
)
{
user_positions.push(idx);
}
}
// If fewer than or equal to n user messages exist, treat as empty (out of range).
if user_positions.len() <= n {
return InitialHistory::New;
}
// Cut strictly before the nth user message (do not keep the nth itself).
let cut_idx = user_positions[n];
let rolled: Vec<RolloutItem> = items.into_iter().take(cut_idx).collect();
let rolled = truncation::truncate_rollout_before_nth_user_message_from_start(&items, n);
if rolled.is_empty() {
InitialHistory::New
@@ -345,14 +363,13 @@ mod tests {
},
ResponseItem::FunctionCall {
id: None,
call_id: "c1".to_string(),
name: "tool".to_string(),
arguments: "{}".to_string(),
call_id: "c1".to_string(),
},
assistant_msg("a4"),
];
// Wrap as InitialHistory::Forked with response items only.
let initial: Vec<RolloutItem> = items
.iter()
.cloned()
+11 -2
View File
@@ -72,8 +72,11 @@ pub enum Feature {
UnifiedExec,
/// Include the freeform apply_patch tool.
ApplyPatchFreeform,
/// Allow the model to request web searches.
/// Allow the model to request web searches that fetch live content.
WebSearchRequest,
/// Allow the model to request web searches that fetch cached content.
/// Takes precedence over `WebSearchRequest`.
WebSearchCached,
/// Gate the execpolicy enforcement for shell/unified exec.
ExecPolicy,
/// Enable Windows sandbox (restricted token) on Windows.
@@ -330,6 +333,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: false,
},
FeatureSpec {
id: Feature::WebSearchCached,
key: "web_search_cached",
stage: Stage::Experimental,
default_enabled: false,
},
// Beta program. Rendered in the `/experimental` menu for users.
FeatureSpec {
id: Feature::UnifiedExec,
@@ -337,7 +346,7 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Beta {
name: "Background terminal",
menu_description: "Run long-running terminal commands in the background.",
announcement: "NEW! Try Background terminals for long running processes. Enable in /experimental!",
announcement: "NEW! Try Background terminals for long-running commands. Enable in /experimental!",
},
default_enabled: false,
},
+1
View File
@@ -15,6 +15,7 @@ pub mod codex;
mod codex_conversation;
mod compact_remote;
pub use codex_conversation::CodexConversation;
mod agent;
mod codex_delegate;
mod command_safety;
pub mod config;
+20 -14
View File
@@ -149,14 +149,14 @@ impl ModelsManager {
// if codex-auto-balanced exists & signed in with chatgpt mode, return it, otherwise return the default model
let auth_mode = self.auth_manager.get_auth_mode();
let remote_models = self.remote_models(config).await;
if auth_mode == Some(AuthMode::ChatGPT)
&& self
if auth_mode == Some(AuthMode::ChatGPT) {
let has_auto_balanced = self
.build_available_models(remote_models)
.iter()
.any(|m| m.model == CODEX_AUTO_BALANCED_MODEL)
{
return CODEX_AUTO_BALANCED_MODEL.to_string();
} else if auth_mode == Some(AuthMode::ChatGPT) {
.any(|model| model.model == CODEX_AUTO_BALANCED_MODEL && model.show_in_picker);
if has_auto_balanced {
return CODEX_AUTO_BALANCED_MODEL.to_string();
}
return OPENAI_DEFAULT_CHATGPT_MODEL.to_string();
}
OPENAI_DEFAULT_API_MODEL.to_string()
@@ -247,10 +247,15 @@ impl ModelsManager {
merged_presets = self.filter_visible_models(merged_presets);
let has_default = merged_presets.iter().any(|preset| preset.is_default);
if let Some(default) = merged_presets.first_mut()
&& !has_default
{
default.is_default = true;
if !has_default {
if let Some(default) = merged_presets
.iter_mut()
.find(|preset| preset.show_in_picker)
{
default.is_default = true;
} else if let Some(default) = merged_presets.first_mut() {
default.is_default = true;
}
}
merged_presets
@@ -260,7 +265,7 @@ impl ModelsManager {
let chatgpt_mode = self.auth_manager.get_auth_mode() == Some(AuthMode::ChatGPT);
models
.into_iter()
.filter(|model| model.show_in_picker && (chatgpt_mode || model.supported_in_api))
.filter(|model| chatgpt_mode || model.supported_in_api)
.collect()
}
@@ -654,12 +659,13 @@ mod tests {
let hidden_model = remote_model_with_visibility("hidden", "Hidden", 0, "hide");
let visible_model = remote_model_with_visibility("visible", "Visible", 1, "list");
let mut expected = ModelPreset::from(visible_model.clone());
expected.is_default = true;
let expected_hidden = ModelPreset::from(hidden_model.clone());
let mut expected_visible = ModelPreset::from(visible_model.clone());
expected_visible.is_default = true;
let available = manager.build_available_models(vec![hidden_model, visible_model]);
assert_eq!(available, vec![expected]);
assert_eq!(available, vec![expected_hidden, expected_visible]);
}
#[test]
@@ -112,7 +112,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
},
ReasoningEffortPreset {
effort: ReasoningEffort::XHigh,
description: "Extra high reasoning for complex problems".to_string(),
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
is_default: false,
@@ -170,7 +170,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
},
ReasoningEffortPreset {
effort: ReasoningEffort::XHigh,
description: "Extra high reasoning for complex problems".to_string(),
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
is_default: false,
@@ -322,11 +322,7 @@ fn gpt_52_codex_upgrade() -> ModelUpgrade {
}
pub(super) fn builtin_model_presets(_auth_mode: Option<AuthMode>) -> Vec<ModelPreset> {
PRESETS
.iter()
.filter(|preset| preset.show_in_picker)
.cloned()
.collect()
PRESETS.iter().cloned().collect()
}
#[cfg(any(test, feature = "test-support"))]
+1
View File
@@ -11,6 +11,7 @@ pub(crate) mod error;
pub mod list;
pub(crate) mod policy;
pub mod recorder;
pub(crate) mod truncation;
pub use codex_protocol::protocol::SessionMeta;
pub(crate) use error::map_session_init_error;
+1
View File
@@ -45,6 +45,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::ContextCompacted(_)
| EventMsg::EnteredReviewMode(_)
| EventMsg::ExitedReviewMode(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::UndoCompleted(_)
| EventMsg::TurnAborted(_) => true,
EventMsg::Error(_)
+195
View File
@@ -0,0 +1,195 @@
//! Helpers for truncating rollouts based on "user turn" boundaries.
//!
//! In core, "user turns" are detected by scanning `ResponseItem::Message` items and
//! interpreting them via `event_mapping::parse_turn_item(...)`.
use crate::event_mapping;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
/// Return the indices of user message boundaries in a rollout.
///
/// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })`
/// whose parsed turn item is `TurnItem::UserMessage`.
///
/// Rollouts can contain `ThreadRolledBack` markers. Those markers indicate that the
/// last N user turns were removed from the effective thread history; we apply them here so
/// indexing uses the post-rollback history rather than the raw stream.
pub(crate) fn user_message_positions_in_rollout(items: &[RolloutItem]) -> Vec<usize> {
let mut user_positions = Vec::new();
for (idx, item) in items.iter().enumerate() {
match item {
RolloutItem::ResponseItem(item @ ResponseItem::Message { .. })
if matches!(
event_mapping::parse_turn_item(item),
Some(TurnItem::UserMessage(_))
) =>
{
user_positions.push(idx);
}
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => {
let num_turns = usize::try_from(rollback.num_turns).unwrap_or(usize::MAX);
let new_len = user_positions.len().saturating_sub(num_turns);
user_positions.truncate(new_len);
}
_ => {}
}
}
user_positions
}
/// Return a prefix of `items` obtained by cutting strictly before the nth user message.
///
/// The boundary index is 0-based from the start of `items` (so `n_from_start = 0` returns
/// a prefix that excludes the first user message and everything after it).
///
/// If fewer than or equal to `n_from_start` user messages exist, this returns an empty
/// vector (out of range).
pub(crate) fn truncate_rollout_before_nth_user_message_from_start(
items: &[RolloutItem],
n_from_start: usize,
) -> Vec<RolloutItem> {
let user_positions = user_message_positions_in_rollout(items);
// If fewer than or equal to n user messages exist, treat as empty (out of range).
if user_positions.len() <= n_from_start {
return Vec::new();
}
// Cut strictly before the nth user message (do not keep the nth itself).
let cut_idx = user_positions[n_from_start];
items[..cut_idx].to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codex::make_session_and_context;
use assert_matches::assert_matches;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::protocol::ThreadRolledBackEvent;
use pretty_assertions::assert_eq;
fn user_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
}
}
fn assistant_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
}
}
#[test]
fn truncates_rollout_from_start_before_nth_user_only() {
let items = [
user_msg("u1"),
assistant_msg("a1"),
assistant_msg("a2"),
user_msg("u2"),
assistant_msg("a3"),
ResponseItem::Reasoning {
id: "r1".to_string(),
summary: vec![ReasoningItemReasoningSummary::SummaryText {
text: "s".to_string(),
}],
content: None,
encrypted_content: None,
},
ResponseItem::FunctionCall {
id: None,
name: "tool".to_string(),
arguments: "{}".to_string(),
call_id: "c1".to_string(),
},
assistant_msg("a4"),
];
let rollout: Vec<RolloutItem> = items
.iter()
.cloned()
.map(RolloutItem::ResponseItem)
.collect();
let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout, 1);
let expected = vec![
RolloutItem::ResponseItem(items[0].clone()),
RolloutItem::ResponseItem(items[1].clone()),
RolloutItem::ResponseItem(items[2].clone()),
];
assert_eq!(
serde_json::to_value(&truncated).unwrap(),
serde_json::to_value(&expected).unwrap()
);
let truncated2 = truncate_rollout_before_nth_user_message_from_start(&rollout, 2);
assert_matches!(truncated2.as_slice(), []);
}
#[test]
fn truncates_rollout_from_start_applies_thread_rollback_markers() {
let rollout_items = vec![
RolloutItem::ResponseItem(user_msg("u1")),
RolloutItem::ResponseItem(assistant_msg("a1")),
RolloutItem::ResponseItem(user_msg("u2")),
RolloutItem::ResponseItem(assistant_msg("a2")),
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
num_turns: 1,
})),
RolloutItem::ResponseItem(user_msg("u3")),
RolloutItem::ResponseItem(assistant_msg("a3")),
RolloutItem::ResponseItem(user_msg("u4")),
RolloutItem::ResponseItem(assistant_msg("a4")),
];
// Effective user history after applying rollback(1) is: u1, u3, u4.
// So n_from_start=2 should cut before u4 (not u3).
let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout_items, 2);
let expected = rollout_items[..7].to_vec();
assert_eq!(
serde_json::to_value(&truncated).unwrap(),
serde_json::to_value(&expected).unwrap()
);
}
#[tokio::test]
async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() {
let (session, turn_context) = make_session_and_context().await;
let mut items = session.build_initial_context(&turn_context);
items.push(user_msg("feature request"));
items.push(assistant_msg("ack"));
items.push(user_msg("second question"));
items.push(assistant_msg("answer"));
let rollout_items: Vec<RolloutItem> = items
.iter()
.cloned()
.map(RolloutItem::ResponseItem)
.collect();
let truncated = truncate_rollout_before_nth_user_message_from_start(&rollout_items, 1);
let expected: Vec<RolloutItem> = vec![
RolloutItem::ResponseItem(items[0].clone()),
RolloutItem::ResponseItem(items[1].clone()),
RolloutItem::ResponseItem(items[2].clone()),
];
assert_eq!(
serde_json::to_value(&truncated).unwrap(),
serde_json::to_value(&expected).unwrap()
);
}
}
+4 -2
View File
@@ -2,12 +2,13 @@ use std::sync::Arc;
use crate::AuthManager;
use crate::RolloutRecorder;
use crate::agent::AgentControl;
use crate::exec_policy::ExecPolicyManager;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::models_manager::manager::ModelsManager;
use crate::skills::SkillsManager;
use crate::tools::sandboxing::ApprovalStore;
use crate::unified_exec::UnifiedExecSessionManager;
use crate::unified_exec::UnifiedExecProcessManager;
use crate::user_notification::UserNotifier;
use codex_otel::otel_manager::OtelManager;
use tokio::sync::Mutex;
@@ -17,7 +18,7 @@ use tokio_util::sync::CancellationToken;
pub(crate) struct SessionServices {
pub(crate) mcp_connection_manager: Arc<RwLock<McpConnectionManager>>,
pub(crate) mcp_startup_cancellation_token: CancellationToken,
pub(crate) unified_exec_manager: UnifiedExecSessionManager,
pub(crate) unified_exec_manager: UnifiedExecProcessManager,
pub(crate) notifier: UserNotifier,
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
pub(crate) user_shell: Arc<crate::shell::Shell>,
@@ -28,4 +29,5 @@ pub(crate) struct SessionServices {
pub(crate) otel_manager: OtelManager,
pub(crate) tool_approvals: Mutex<ApprovalStore>,
pub(crate) skills_manager: Arc<SkillsManager>,
pub(crate) agent_control: AgentControl,
}
+6 -6
View File
@@ -159,7 +159,7 @@ impl Session {
for task in self.take_all_running_tasks().await {
self.handle_task_abort(task, reason.clone()).await;
}
self.close_unified_exec_sessions().await;
self.close_unified_exec_processes().await;
}
pub async fn on_task_finished(
@@ -168,7 +168,7 @@ impl Session {
last_agent_message: Option<String>,
) {
let mut active = self.active_turn.lock().await;
let should_close_sessions = if let Some(at) = active.as_mut()
let should_close_processes = if let Some(at) = active.as_mut()
&& at.remove_task(&turn_context.sub_id)
{
*active = None;
@@ -177,8 +177,8 @@ impl Session {
false
};
drop(active);
if should_close_sessions {
self.close_unified_exec_sessions().await;
if should_close_processes {
self.close_unified_exec_processes().await;
}
let event = EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message });
self.send_event(turn_context.as_ref(), event).await;
@@ -203,10 +203,10 @@ impl Session {
}
}
async fn close_unified_exec_sessions(&self) {
async fn close_unified_exec_processes(&self) {
self.services
.unified_exec_manager
.terminate_all_sessions()
.terminate_all_processes()
.await;
}
@@ -13,8 +13,8 @@ use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use crate::unified_exec::ExecCommandRequest;
use crate::unified_exec::UnifiedExecContext;
use crate::unified_exec::UnifiedExecProcessManager;
use crate::unified_exec::UnifiedExecResponse;
use crate::unified_exec::UnifiedExecSessionManager;
use crate::unified_exec::WriteStdinRequest;
use async_trait::async_trait;
use serde::Deserialize;
@@ -112,7 +112,7 @@ impl ToolHandler for UnifiedExecHandler {
}
};
let manager: &UnifiedExecSessionManager = &session.services.unified_exec_manager;
let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager;
let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone());
let response = match tool_name.as_str() {
@@ -2,7 +2,7 @@
Runtime: unified exec
Handles approval + sandbox orchestration for unified exec requests, delegating to
the session manager to spawn PTYs once an ExecEnv is prepared.
the process manager to spawn PTYs once an ExecEnv is prepared.
*/
use crate::error::CodexErr;
use crate::error::SandboxErr;
@@ -25,8 +25,8 @@ use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::with_cached_approval;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecSession;
use crate::unified_exec::UnifiedExecSessionManager;
use crate::unified_exec::UnifiedExecProcess;
use crate::unified_exec::UnifiedExecProcessManager;
use codex_protocol::protocol::ReviewDecision;
use futures::future::BoxFuture;
use std::collections::HashMap;
@@ -50,7 +50,7 @@ pub struct UnifiedExecApprovalKey {
}
pub struct UnifiedExecRuntime<'a> {
manager: &'a UnifiedExecSessionManager,
manager: &'a UnifiedExecProcessManager,
}
impl UnifiedExecRequest {
@@ -74,7 +74,7 @@ impl UnifiedExecRequest {
}
impl<'a> UnifiedExecRuntime<'a> {
pub fn new(manager: &'a UnifiedExecSessionManager) -> Self {
pub fn new(manager: &'a UnifiedExecProcessManager) -> Self {
Self { manager }
}
}
@@ -158,13 +158,13 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
}
}
impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecSession> for UnifiedExecRuntime<'a> {
impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRuntime<'a> {
async fn run(
&mut self,
req: &UnifiedExecRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx<'_>,
) -> Result<UnifiedExecSession, ToolError> {
) -> Result<UnifiedExecProcess, ToolError> {
let base_command = &req.command;
let session_shell = ctx.session.user_shell();
let command = maybe_wrap_shell_lc_with_snapshot(base_command, session_shell.as_ref());
+62 -5
View File
@@ -21,6 +21,7 @@ pub(crate) struct ToolsConfig {
pub shell_type: ConfigShellToolType,
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_request: bool,
pub web_search_cached: bool,
pub include_view_image_tool: bool,
pub experimental_supported_tools: Vec<String>,
}
@@ -38,6 +39,7 @@ impl ToolsConfig {
} = params;
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
let include_web_search_request = features.enabled(Feature::WebSearchRequest);
let include_web_search_cached = features.enabled(Feature::WebSearchCached);
let include_view_image_tool = features.enabled(Feature::ViewImageTool);
let shell_type = if !features.enabled(Feature::ShellTool) {
@@ -69,6 +71,7 @@ impl ToolsConfig {
shell_type,
apply_patch_tool_type,
web_search_request: include_web_search_request,
web_search_cached: include_web_search_cached,
include_view_image_tool,
experimental_supported_tools: model_family.experimental_supported_tools.clone(),
}
@@ -1093,8 +1096,15 @@ pub(crate) fn build_specs(
builder.register_handler("test_sync_tool", test_sync_handler);
}
if config.web_search_request {
builder.push_spec(ToolSpec::WebSearch {});
// Prefer web_search_cached flag over web_search_request
if config.web_search_cached {
builder.push_spec(ToolSpec::WebSearch {
external_web_access: Some(false),
});
} else if config.web_search_request {
builder.push_spec(ToolSpec::WebSearch {
external_web_access: Some(true),
});
}
if config.include_view_image_tool {
@@ -1137,7 +1147,7 @@ mod tests {
match tool {
ToolSpec::Function(ResponsesApiTool { name, .. }) => name,
ToolSpec::LocalShell {} => "local_shell",
ToolSpec::WebSearch {} => "web_search",
ToolSpec::WebSearch { .. } => "web_search",
ToolSpec::Freeform(FreeformTool { name, .. }) => name,
}
}
@@ -1215,7 +1225,7 @@ mod tests {
ToolSpec::Function(ResponsesApiTool { parameters, .. }) => {
strip_descriptions_schema(parameters);
}
ToolSpec::Freeform(_) | ToolSpec::LocalShell {} | ToolSpec::WebSearch {} => {}
ToolSpec::Freeform(_) | ToolSpec::LocalShell {} | ToolSpec::WebSearch { .. } => {}
}
}
@@ -1259,7 +1269,9 @@ mod tests {
create_read_mcp_resource_tool(),
PLAN_TOOL.clone(),
create_apply_patch_freeform_tool(),
ToolSpec::WebSearch {},
ToolSpec::WebSearch {
external_web_access: Some(true),
},
create_view_image_tool(),
] {
expected.insert(tool_name(&spec).to_string(), spec);
@@ -1292,6 +1304,51 @@ mod tests {
assert_eq!(&tool_names, &expected_tools,);
}
#[test]
fn web_search_cached_sets_external_web_access_false() {
let config = test_config();
let model_family = ModelsManager::construct_model_family_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::WebSearchCached);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_family: &model_family,
features: &features,
});
let (tools, _) = build_specs(&tools_config, None).build();
let tool = find_tool(&tools, "web_search");
assert_eq!(
tool.spec,
ToolSpec::WebSearch {
external_web_access: Some(false),
}
);
}
#[test]
fn web_search_cached_takes_precedence_over_web_search_request() {
let config = test_config();
let model_family = ModelsManager::construct_model_family_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::WebSearchCached);
features.enable(Feature::WebSearchRequest);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_family: &model_family,
features: &features,
});
let (tools, _) = build_specs(&tools_config, None).build();
let tool = find_tool(&tools, "web_search");
assert_eq!(
tool.spec,
ToolSpec::WebSearch {
external_web_access: Some(false),
}
);
}
#[test]
fn test_build_specs_gpt5_codex_default() {
assert_model_tools(
+19 -20
View File
@@ -7,6 +7,8 @@ use tokio::time::Duration;
use tokio::time::Instant;
use tokio::time::Sleep;
use super::UnifiedExecContext;
use super::process::UnifiedExecProcess;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::exec::ExecToolCallOutput;
@@ -19,10 +21,7 @@ use crate::protocol::ExecOutputStream;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::events::ToolEventStage;
use super::CommandTranscript;
use super::UnifiedExecContext;
use super::session::UnifiedExecSession;
use crate::unified_exec::head_tail_buffer::HeadTailBuffer;
pub(crate) const TRAILING_OUTPUT_GRACE: Duration = Duration::from_millis(100);
@@ -38,13 +37,13 @@ const UNIFIED_EXEC_OUTPUT_DELTA_MAX_BYTES: usize = 8192;
/// shared transcript, and emits ExecCommandOutputDelta events on UTF‑8
/// boundaries.
pub(crate) fn start_streaming_output(
session: &UnifiedExecSession,
process: &UnifiedExecProcess,
context: &UnifiedExecContext,
transcript: Arc<Mutex<CommandTranscript>>,
transcript: Arc<Mutex<HeadTailBuffer>>,
) {
let mut receiver = session.output_receiver();
let output_drained = session.output_drained_notify();
let exit_token = session.cancellation_token();
let mut receiver = process.output_receiver();
let output_drained = process.output_drained_notify();
let exit_token = process.cancellation_token();
let session_ref = Arc::clone(&context.session);
let turn_ref = Arc::clone(&context.turn);
@@ -105,24 +104,24 @@ pub(crate) fn start_streaming_output(
/// single ExecCommandEnd event with the aggregated transcript.
#[allow(clippy::too_many_arguments)]
pub(crate) fn spawn_exit_watcher(
session: Arc<UnifiedExecSession>,
process: Arc<UnifiedExecProcess>,
session_ref: Arc<Session>,
turn_ref: Arc<TurnContext>,
call_id: String,
command: Vec<String>,
cwd: PathBuf,
process_id: String,
transcript: Arc<Mutex<CommandTranscript>>,
transcript: Arc<Mutex<HeadTailBuffer>>,
started_at: Instant,
) {
let exit_token = session.cancellation_token();
let output_drained = session.output_drained_notify();
let exit_token = process.cancellation_token();
let output_drained = process.output_drained_notify();
tokio::spawn(async move {
exit_token.cancelled().await;
output_drained.notified().await;
let exit_code = session.exit_code().unwrap_or(-1);
let exit_code = process.exit_code().unwrap_or(-1);
let duration = Instant::now().saturating_duration_since(started_at);
emit_exec_end_for_unified_exec(
session_ref,
@@ -142,7 +141,7 @@ pub(crate) fn spawn_exit_watcher(
async fn process_chunk(
pending: &mut Vec<u8>,
transcript: &Arc<Mutex<CommandTranscript>>,
transcript: &Arc<Mutex<HeadTailBuffer>>,
call_id: &str,
session_ref: &Arc<Session>,
turn_ref: &Arc<TurnContext>,
@@ -153,7 +152,7 @@ async fn process_chunk(
while let Some(prefix) = split_valid_utf8_prefix(pending) {
{
let mut guard = transcript.lock().await;
guard.append(&prefix);
guard.push_chunk(prefix.to_vec());
}
if *emitted_deltas >= MAX_EXEC_OUTPUT_DELTAS_PER_CALL {
@@ -183,7 +182,7 @@ pub(crate) async fn emit_exec_end_for_unified_exec(
command: Vec<String>,
cwd: PathBuf,
process_id: Option<String>,
transcript: Arc<Mutex<CommandTranscript>>,
transcript: Arc<Mutex<HeadTailBuffer>>,
fallback_output: String,
exit_code: i32,
duration: Duration,
@@ -240,15 +239,15 @@ fn split_valid_utf8_prefix_with_max(buffer: &mut Vec<u8>, max_bytes: usize) -> O
}
async fn resolve_aggregated_output(
transcript: &Arc<Mutex<CommandTranscript>>,
transcript: &Arc<Mutex<HeadTailBuffer>>,
fallback: String,
) -> String {
let guard = transcript.lock().await;
if guard.data.is_empty() {
if guard.retained_bytes() == 0 {
return fallback;
}
String::from_utf8_lossy(&guard.data).to_string()
String::from_utf8_lossy(&guard.to_bytes()).to_string()
}
#[cfg(test)]
+7 -7
View File
@@ -3,11 +3,11 @@ use thiserror::Error;
#[derive(Debug, Error)]
pub(crate) enum UnifiedExecError {
#[error("Failed to create unified exec session: {message}")]
CreateSession { message: String },
// Called "session" in the model's training.
#[error("Unknown session id {process_id}")]
UnknownSessionId { process_id: String },
#[error("Failed to create unified exec process: {message}")]
CreateProcess { message: String },
// The model is trained on `session_id`, but internally we track a `process_id`.
#[error("Unknown process id {process_id}")]
UnknownProcessId { process_id: String },
#[error("failed to write to stdin")]
WriteToStdin,
#[error("missing command line for unified exec request")]
@@ -20,8 +20,8 @@ pub(crate) enum UnifiedExecError {
}
impl UnifiedExecError {
pub(crate) fn create_session(message: String) -> Self {
Self::CreateSession { message }
pub(crate) fn create_process(message: String) -> Self {
Self::CreateProcess { message }
}
pub(crate) fn sandbox_denied(message: String, output: ExecToolCallOutput) -> Self {
@@ -0,0 +1,272 @@
use crate::unified_exec::UNIFIED_EXEC_OUTPUT_MAX_BYTES;
use std::collections::VecDeque;
/// A capped buffer that preserves a stable prefix ("head") and suffix ("tail"),
/// dropping the middle once it exceeds the configured maximum. The buffer is
/// symmetric meaning 50% of the capacity is allocated to the head and 50% is
/// allocated to the tail.
#[derive(Debug)]
pub(crate) struct HeadTailBuffer {
max_bytes: usize,
head_budget: usize,
tail_budget: usize,
head: VecDeque<Vec<u8>>,
tail: VecDeque<Vec<u8>>,
head_bytes: usize,
tail_bytes: usize,
omitted_bytes: usize,
}
impl Default for HeadTailBuffer {
fn default() -> Self {
Self::new(UNIFIED_EXEC_OUTPUT_MAX_BYTES)
}
}
impl HeadTailBuffer {
/// Create a new buffer that retains at most `max_bytes` of output.
///
/// The retained output is split across a prefix ("head") and suffix ("tail")
/// budget, dropping bytes from the middle once the limit is exceeded.
pub(crate) fn new(max_bytes: usize) -> Self {
let head_budget = max_bytes / 2;
let tail_budget = max_bytes.saturating_sub(head_budget);
Self {
max_bytes,
head_budget,
tail_budget,
head: VecDeque::new(),
tail: VecDeque::new(),
head_bytes: 0,
tail_bytes: 0,
omitted_bytes: 0,
}
}
// Used for tests.
#[allow(dead_code)]
/// Total bytes currently retained by the buffer (head + tail).
pub(crate) fn retained_bytes(&self) -> usize {
self.head_bytes.saturating_add(self.tail_bytes)
}
// Used for tests.
#[allow(dead_code)]
/// Total bytes that were dropped from the middle due to the size cap.
pub(crate) fn omitted_bytes(&self) -> usize {
self.omitted_bytes
}
/// Append a chunk of bytes to the buffer.
///
/// Bytes are first added to the head until the head budget is full; any
/// remaining bytes are added to the tail, with older tail bytes being
/// dropped to preserve the tail budget.
pub(crate) fn push_chunk(&mut self, chunk: Vec<u8>) {
if self.max_bytes == 0 {
self.omitted_bytes = self.omitted_bytes.saturating_add(chunk.len());
return;
}
// Fill the head budget first, then keep a capped tail.
if self.head_bytes < self.head_budget {
let remaining_head = self.head_budget.saturating_sub(self.head_bytes);
if chunk.len() <= remaining_head {
self.head_bytes = self.head_bytes.saturating_add(chunk.len());
self.head.push_back(chunk);
return;
}
// Split the chunk: part goes to head, remainder goes to tail.
let (head_part, tail_part) = chunk.split_at(remaining_head);
if !head_part.is_empty() {
self.head_bytes = self.head_bytes.saturating_add(head_part.len());
self.head.push_back(head_part.to_vec());
}
self.push_to_tail(tail_part.to_vec());
return;
}
self.push_to_tail(chunk);
}
/// Snapshot the retained output as a list of chunks.
///
/// The returned chunks are ordered as: head chunks first, then tail chunks.
/// Omitted bytes are not represented in the snapshot.
pub(crate) fn snapshot_chunks(&self) -> Vec<Vec<u8>> {
let mut out = Vec::new();
out.extend(self.head.iter().cloned());
out.extend(self.tail.iter().cloned());
out
}
/// Return the retained output as a single byte vector.
///
/// The output is formed by concatenating head chunks, then tail chunks.
/// Omitted bytes are not represented in the returned value.
pub(crate) fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.retained_bytes());
for chunk in self.head.iter() {
out.extend_from_slice(chunk);
}
for chunk in self.tail.iter() {
out.extend_from_slice(chunk);
}
out
}
/// Drain all retained chunks from the buffer and reset its state.
///
/// The drained chunks are returned in head-then-tail order. Omitted bytes
/// are discarded along with the retained content.
pub(crate) fn drain_chunks(&mut self) -> Vec<Vec<u8>> {
let mut out: Vec<Vec<u8>> = self.head.drain(..).collect();
out.extend(self.tail.drain(..));
self.head_bytes = 0;
self.tail_bytes = 0;
self.omitted_bytes = 0;
out
}
fn push_to_tail(&mut self, chunk: Vec<u8>) {
if self.tail_budget == 0 {
self.omitted_bytes = self.omitted_bytes.saturating_add(chunk.len());
return;
}
if chunk.len() >= self.tail_budget {
// This single chunk is larger than the whole tail budget. Keep only the last
// tail_budget bytes and drop everything else.
let start = chunk.len().saturating_sub(self.tail_budget);
let kept = chunk[start..].to_vec();
let dropped = chunk.len().saturating_sub(kept.len());
self.omitted_bytes = self
.omitted_bytes
.saturating_add(self.tail_bytes)
.saturating_add(dropped);
self.tail.clear();
self.tail_bytes = kept.len();
self.tail.push_back(kept);
return;
}
self.tail_bytes = self.tail_bytes.saturating_add(chunk.len());
self.tail.push_back(chunk);
self.trim_tail_to_budget();
}
fn trim_tail_to_budget(&mut self) {
let mut excess = self.tail_bytes.saturating_sub(self.tail_budget);
while excess > 0 {
match self.tail.front_mut() {
Some(front) if excess >= front.len() => {
excess -= front.len();
self.tail_bytes = self.tail_bytes.saturating_sub(front.len());
self.omitted_bytes = self.omitted_bytes.saturating_add(front.len());
self.tail.pop_front();
}
Some(front) => {
front.drain(..excess);
self.tail_bytes = self.tail_bytes.saturating_sub(excess);
self.omitted_bytes = self.omitted_bytes.saturating_add(excess);
break;
}
None => break,
}
}
}
}
#[cfg(test)]
mod tests {
use super::HeadTailBuffer;
use pretty_assertions::assert_eq;
#[test]
fn keeps_prefix_and_suffix_when_over_budget() {
let mut buf = HeadTailBuffer::new(10);
buf.push_chunk(b"0123456789".to_vec());
assert_eq!(buf.omitted_bytes(), 0);
// Exceeds max by 2; we should keep head+tail and omit the middle.
buf.push_chunk(b"ab".to_vec());
assert!(buf.omitted_bytes() > 0);
let rendered = String::from_utf8_lossy(&buf.to_bytes()).to_string();
assert!(rendered.starts_with("01234"));
assert!(rendered.ends_with("89ab"));
}
#[test]
fn max_bytes_zero_drops_everything() {
let mut buf = HeadTailBuffer::new(0);
buf.push_chunk(b"abc".to_vec());
assert_eq!(buf.retained_bytes(), 0);
assert_eq!(buf.omitted_bytes(), 3);
assert_eq!(buf.to_bytes(), b"".to_vec());
assert_eq!(buf.snapshot_chunks(), Vec::<Vec<u8>>::new());
}
#[test]
fn head_budget_zero_keeps_only_last_byte_in_tail() {
let mut buf = HeadTailBuffer::new(1);
buf.push_chunk(b"abc".to_vec());
assert_eq!(buf.retained_bytes(), 1);
assert_eq!(buf.omitted_bytes(), 2);
assert_eq!(buf.to_bytes(), b"c".to_vec());
}
#[test]
fn draining_resets_state() {
let mut buf = HeadTailBuffer::new(10);
buf.push_chunk(b"0123456789".to_vec());
buf.push_chunk(b"ab".to_vec());
let drained = buf.drain_chunks();
assert!(!drained.is_empty());
assert_eq!(buf.retained_bytes(), 0);
assert_eq!(buf.omitted_bytes(), 0);
assert_eq!(buf.to_bytes(), b"".to_vec());
}
#[test]
fn chunk_larger_than_tail_budget_keeps_only_tail_end() {
let mut buf = HeadTailBuffer::new(10);
buf.push_chunk(b"0123456789".to_vec());
// Tail budget is 5 bytes. This chunk should replace the tail and keep only its last 5 bytes.
buf.push_chunk(b"ABCDEFGHIJK".to_vec());
let out = String::from_utf8_lossy(&buf.to_bytes()).to_string();
assert!(out.starts_with("01234"));
assert!(out.ends_with("GHIJK"));
assert!(buf.omitted_bytes() > 0);
}
#[test]
fn fills_head_then_tail_across_multiple_chunks() {
let mut buf = HeadTailBuffer::new(10);
// Fill the 5-byte head budget across multiple chunks.
buf.push_chunk(b"01".to_vec());
buf.push_chunk(b"234".to_vec());
assert_eq!(buf.to_bytes(), b"01234".to_vec());
// Then fill the 5-byte tail budget.
buf.push_chunk(b"567".to_vec());
buf.push_chunk(b"89".to_vec());
assert_eq!(buf.to_bytes(), b"0123456789".to_vec());
assert_eq!(buf.omitted_bytes(), 0);
// One more byte causes the tail to drop its oldest byte.
buf.push_chunk(b"a".to_vec());
assert_eq!(buf.to_bytes(), b"012346789a".to_vec());
assert_eq!(buf.omitted_bytes(), 1);
}
}
+60 -63
View File
@@ -1,7 +1,7 @@
//! Unified Exec: interactive PTY execution orchestrated with approvals + sandboxing.
//! Unified Exec: interactive process execution orchestrated with approvals + sandboxing.
//!
//! Responsibilities
//! - Manages interactive PTY sessions (create, reuse, buffer output with caps).
//! - Manages interactive processes (create, reuse, buffer output with caps).
//! - Uses the shared ToolOrchestrator to handle approval, sandbox selection, and
//! retry semantics in a single, descriptive flow.
//! - Spawns the PTY from a sandbox‑transformed `ExecEnv`; on sandbox denial,
@@ -9,17 +9,17 @@
//! - Uses the shared `is_likely_sandbox_denied` heuristic to keep denial messages
//! consistent with other exec paths.
//!
//! Flow at a glance (open session)
//! Flow at a glance (open process)
//! 1) Build a small request `{ command, cwd }`.
//! 2) Orchestrator: approval (bypass/cache/prompt) → select sandbox → run.
//! 3) Runtime: transform `CommandSpec` → `ExecEnv` → spawn PTY.
//! 4) If denial, orchestrator retries with `SandboxType::None`.
//! 5) Session is returned with streaming output + metadata.
//! 5) Process handle is returned with streaming output + metadata.
//!
//! This keeps policy logic and user interaction centralized while the PTY/session
//! This keeps policy logic and user interaction centralized while the PTY/process
//! concerns remain isolated here. The implementation is split between:
//! - `session.rs`: PTY session lifecycle + output buffering.
//! - `session_manager.rs`: orchestration (approvals, sandboxing, reuse) and request handling.
//! - `process.rs`: PTY process lifecycle + output buffering.
//! - `process_manager.rs`: orchestration (approvals, sandboxing, reuse) and request handling.
use std::collections::HashMap;
use std::collections::HashSet;
@@ -37,39 +37,22 @@ use crate::sandboxing::SandboxPermissions;
mod async_watcher;
mod errors;
mod session;
mod session_manager;
mod head_tail_buffer;
mod process;
mod process_manager;
pub(crate) use errors::UnifiedExecError;
pub(crate) use session::UnifiedExecSession;
pub(crate) use process::UnifiedExecProcess;
pub(crate) const MIN_YIELD_TIME_MS: u64 = 250;
pub(crate) const MAX_YIELD_TIME_MS: u64 = 30_000;
pub(crate) const DEFAULT_MAX_OUTPUT_TOKENS: usize = 10_000;
pub(crate) const UNIFIED_EXEC_OUTPUT_MAX_BYTES: usize = 1024 * 1024; // 1 MiB
pub(crate) const UNIFIED_EXEC_OUTPUT_MAX_TOKENS: usize = UNIFIED_EXEC_OUTPUT_MAX_BYTES / 4;
pub(crate) const MAX_UNIFIED_EXEC_SESSIONS: usize = 64;
pub(crate) const MAX_UNIFIED_EXEC_PROCESSES: usize = 64;
// Send a warning message to the models when it reaches this number of sessions.
pub(crate) const WARNING_UNIFIED_EXEC_SESSIONS: usize = 60;
#[derive(Debug, Default)]
pub(crate) struct CommandTranscript {
pub data: Vec<u8>,
}
impl CommandTranscript {
pub fn append(&mut self, bytes: &[u8]) {
self.data.extend_from_slice(bytes);
if self.data.len() > UNIFIED_EXEC_OUTPUT_MAX_BYTES {
let excess = self
.data
.len()
.saturating_sub(UNIFIED_EXEC_OUTPUT_MAX_BYTES);
self.data.drain(..excess);
}
}
}
// Send a warning message to the models when it reaches this number of processes.
pub(crate) const WARNING_UNIFIED_EXEC_PROCESSES: usize = 60;
pub(crate) struct UnifiedExecContext {
pub session: Arc<Session>,
@@ -121,32 +104,32 @@ pub(crate) struct UnifiedExecResponse {
}
#[derive(Default)]
pub(crate) struct SessionStore {
sessions: HashMap<String, SessionEntry>,
reserved_sessions_id: HashSet<String>,
pub(crate) struct ProcessStore {
processes: HashMap<String, ProcessEntry>,
reserved_process_ids: HashSet<String>,
}
impl SessionStore {
fn remove(&mut self, session_id: &str) -> Option<SessionEntry> {
self.reserved_sessions_id.remove(session_id);
self.sessions.remove(session_id)
impl ProcessStore {
fn remove(&mut self, process_id: &str) -> Option<ProcessEntry> {
self.reserved_process_ids.remove(process_id);
self.processes.remove(process_id)
}
}
pub(crate) struct UnifiedExecSessionManager {
session_store: Mutex<SessionStore>,
pub(crate) struct UnifiedExecProcessManager {
process_store: Mutex<ProcessStore>,
}
impl Default for UnifiedExecSessionManager {
impl Default for UnifiedExecProcessManager {
fn default() -> Self {
Self {
session_store: Mutex::new(SessionStore::default()),
process_store: Mutex::new(ProcessStore::default()),
}
}
}
struct SessionEntry {
session: Arc<UnifiedExecSession>,
struct ProcessEntry {
process: Arc<UnifiedExecProcess>,
session_ref: Arc<Session>,
turn_ref: Arc<TurnContext>,
call_id: String,
@@ -173,6 +156,7 @@ pub(crate) fn generate_chunk_id() -> String {
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::head_tail_buffer::HeadTailBuffer;
use super::*;
use crate::codex::Session;
use crate::codex::TurnContext;
@@ -185,8 +169,6 @@ mod tests {
use std::sync::Arc;
use tokio::time::Duration;
use super::session::OutputBufferState;
async fn test_session_and_turn() -> (Arc<Session>, Arc<TurnContext>) {
let (session, mut turn) = make_session_and_context().await;
turn.approval_policy = AskForApproval::Never;
@@ -245,21 +227,36 @@ mod tests {
}
#[test]
fn push_chunk_trims_only_excess_bytes() {
let mut buffer = OutputBufferState::default();
fn push_chunk_preserves_prefix_and_suffix() {
let mut buffer = HeadTailBuffer::default();
buffer.push_chunk(vec![b'a'; UNIFIED_EXEC_OUTPUT_MAX_BYTES]);
buffer.push_chunk(vec![b'b']);
buffer.push_chunk(vec![b'c']);
assert_eq!(buffer.total_bytes, UNIFIED_EXEC_OUTPUT_MAX_BYTES);
let snapshot = buffer.snapshot();
assert_eq!(snapshot.len(), 3);
assert_eq!(buffer.retained_bytes(), UNIFIED_EXEC_OUTPUT_MAX_BYTES);
let snapshot = buffer.snapshot_chunks();
let first = snapshot.first().expect("expected at least one chunk");
assert_eq!(first.first(), Some(&b'a'));
assert!(snapshot.iter().any(|chunk| chunk.as_slice() == b"b"));
assert_eq!(
snapshot.first().unwrap().len(),
UNIFIED_EXEC_OUTPUT_MAX_BYTES - 2
snapshot
.last()
.expect("expected at least one chunk")
.as_slice(),
b"c"
);
assert_eq!(snapshot.get(2).unwrap(), &vec![b'c']);
assert_eq!(snapshot.get(1).unwrap(), &vec![b'b']);
}
#[test]
fn head_tail_buffer_default_preserves_prefix_and_suffix() {
let mut buffer = HeadTailBuffer::default();
buffer.push_chunk(vec![b'a'; UNIFIED_EXEC_OUTPUT_MAX_BYTES]);
buffer.push_chunk(b"bc".to_vec());
let rendered = buffer.to_bytes();
assert_eq!(rendered.first(), Some(&b'a'));
assert!(rendered.ends_with(b"bc"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -424,10 +421,10 @@ mod tests {
session
.services
.unified_exec_manager
.session_store
.process_store
.lock()
.await
.sessions
.processes
.is_empty()
);
@@ -435,7 +432,7 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reusing_completed_session_returns_unknown_session() -> anyhow::Result<()> {
async fn reusing_completed_process_returns_unknown_process() -> anyhow::Result<()> {
skip_if_sandbox!(Ok(()));
let (session, turn) = test_session_and_turn().await;
@@ -453,23 +450,23 @@ mod tests {
let err = write_stdin(&session, process_id, "", 100)
.await
.expect_err("expected unknown session error");
.expect_err("expected unknown process error");
match err {
UnifiedExecError::UnknownSessionId { process_id: err_id } => {
UnifiedExecError::UnknownProcessId { process_id: err_id } => {
assert_eq!(err_id, process_id, "process id should match request");
}
other => panic!("expected UnknownSessionId, got {other:?}"),
other => panic!("expected UnknownProcessId, got {other:?}"),
}
assert!(
session
.services
.unified_exec_manager
.session_store
.process_store
.lock()
.await
.sessions
.processes
.is_empty()
);
@@ -1,6 +1,5 @@
#![allow(clippy::module_inception)]
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::Notify;
@@ -19,54 +18,11 @@ use crate::truncate::formatted_truncate_text;
use codex_utils_pty::ExecCommandSession;
use codex_utils_pty::SpawnedPty;
use super::UNIFIED_EXEC_OUTPUT_MAX_BYTES;
use super::UNIFIED_EXEC_OUTPUT_MAX_TOKENS;
use super::UnifiedExecError;
use super::head_tail_buffer::HeadTailBuffer;
#[derive(Debug, Default)]
pub(crate) struct OutputBufferState {
chunks: VecDeque<Vec<u8>>,
pub(crate) total_bytes: usize,
}
impl OutputBufferState {
pub(super) fn push_chunk(&mut self, chunk: Vec<u8>) {
self.total_bytes = self.total_bytes.saturating_add(chunk.len());
self.chunks.push_back(chunk);
let mut excess = self
.total_bytes
.saturating_sub(UNIFIED_EXEC_OUTPUT_MAX_BYTES);
while excess > 0 {
match self.chunks.front_mut() {
Some(front) if excess >= front.len() => {
excess -= front.len();
self.total_bytes = self.total_bytes.saturating_sub(front.len());
self.chunks.pop_front();
}
Some(front) => {
front.drain(..excess);
self.total_bytes = self.total_bytes.saturating_sub(excess);
break;
}
None => break,
}
}
}
pub(super) fn drain(&mut self) -> Vec<Vec<u8>> {
let drained: Vec<Vec<u8>> = self.chunks.drain(..).collect();
self.total_bytes = 0;
drained
}
pub(super) fn snapshot(&self) -> Vec<Vec<u8>> {
self.chunks.iter().cloned().collect()
}
}
pub(crate) type OutputBuffer = Arc<Mutex<OutputBufferState>>;
pub(crate) type OutputBuffer = Arc<Mutex<HeadTailBuffer>>;
pub(crate) struct OutputHandles {
pub(crate) output_buffer: OutputBuffer,
pub(crate) output_notify: Arc<Notify>,
@@ -74,8 +30,8 @@ pub(crate) struct OutputHandles {
}
#[derive(Debug)]
pub(crate) struct UnifiedExecSession {
session: ExecCommandSession,
pub(crate) struct UnifiedExecProcess {
process_handle: ExecCommandSession,
output_buffer: OutputBuffer,
output_notify: Arc<Notify>,
cancellation_token: CancellationToken,
@@ -84,13 +40,13 @@ pub(crate) struct UnifiedExecSession {
sandbox_type: SandboxType,
}
impl UnifiedExecSession {
impl UnifiedExecProcess {
pub(super) fn new(
session: ExecCommandSession,
process_handle: ExecCommandSession,
initial_output_rx: tokio::sync::broadcast::Receiver<Vec<u8>>,
sandbox_type: SandboxType,
) -> Self {
let output_buffer = Arc::new(Mutex::new(OutputBufferState::default()));
let output_buffer = Arc::new(Mutex::new(HeadTailBuffer::default()));
let output_notify = Arc::new(Notify::new());
let cancellation_token = CancellationToken::new();
let output_drained = Arc::new(Notify::new());
@@ -113,7 +69,7 @@ impl UnifiedExecSession {
});
Self {
session,
process_handle,
output_buffer,
output_notify,
cancellation_token,
@@ -124,7 +80,7 @@ impl UnifiedExecSession {
}
pub(super) fn writer_sender(&self) -> mpsc::Sender<Vec<u8>> {
self.session.writer_sender()
self.process_handle.writer_sender()
}
pub(super) fn output_handles(&self) -> OutputHandles {
@@ -136,7 +92,7 @@ impl UnifiedExecSession {
}
pub(super) fn output_receiver(&self) -> tokio::sync::broadcast::Receiver<Vec<u8>> {
self.session.output_receiver()
self.process_handle.output_receiver()
}
pub(super) fn cancellation_token(&self) -> CancellationToken {
@@ -148,22 +104,22 @@ impl UnifiedExecSession {
}
pub(super) fn has_exited(&self) -> bool {
self.session.has_exited()
self.process_handle.has_exited()
}
pub(super) fn exit_code(&self) -> Option<i32> {
self.session.exit_code()
self.process_handle.exit_code()
}
pub(super) fn terminate(&self) {
self.session.terminate();
self.process_handle.terminate();
self.cancellation_token.cancel();
self.output_task.abort();
}
async fn snapshot_output(&self) -> Vec<Vec<u8>> {
let guard = self.output_buffer.lock().await;
guard.snapshot()
guard.snapshot_chunks()
}
pub(crate) fn sandbox_type(&self) -> SandboxType {
@@ -208,7 +164,7 @@ impl UnifiedExecSession {
TruncationPolicy::Tokens(UNIFIED_EXEC_OUTPUT_MAX_TOKENS),
);
let message = if snippet.is_empty() {
format!("Session exited with code {exit_code}")
format!("Process exited with code {exit_code}")
} else {
snippet
};
@@ -222,11 +178,11 @@ impl UnifiedExecSession {
sandbox_type: SandboxType,
) -> Result<Self, UnifiedExecError> {
let SpawnedPty {
session,
session: process_handle,
output_rx,
mut exit_rx,
} = spawned;
let managed = Self::new(session, output_rx, sandbox_type);
let managed = Self::new(process_handle, output_rx, sandbox_type);
let exit_ready = matches!(exit_rx.try_recv(), Ok(_) | Err(TryRecvError::Closed));
@@ -261,7 +217,7 @@ impl UnifiedExecSession {
}
}
impl Drop for UnifiedExecSession {
impl Drop for UnifiedExecProcess {
fn drop(&mut self) {
self.terminate();
}
@@ -29,27 +29,26 @@ use crate::tools::sandboxing::ToolCtx;
use crate::truncate::TruncationPolicy;
use crate::truncate::approx_token_count;
use crate::truncate::formatted_truncate_text;
use super::CommandTranscript;
use super::ExecCommandRequest;
use super::MAX_UNIFIED_EXEC_SESSIONS;
use super::SessionEntry;
use super::SessionStore;
use super::UnifiedExecContext;
use super::UnifiedExecError;
use super::UnifiedExecResponse;
use super::UnifiedExecSessionManager;
use super::WARNING_UNIFIED_EXEC_SESSIONS;
use super::WriteStdinRequest;
use super::async_watcher::emit_exec_end_for_unified_exec;
use super::async_watcher::spawn_exit_watcher;
use super::async_watcher::start_streaming_output;
use super::clamp_yield_time;
use super::generate_chunk_id;
use super::resolve_max_tokens;
use super::session::OutputBuffer;
use super::session::OutputHandles;
use super::session::UnifiedExecSession;
use crate::unified_exec::ExecCommandRequest;
use crate::unified_exec::MAX_UNIFIED_EXEC_PROCESSES;
use crate::unified_exec::ProcessEntry;
use crate::unified_exec::ProcessStore;
use crate::unified_exec::UnifiedExecContext;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecProcessManager;
use crate::unified_exec::UnifiedExecResponse;
use crate::unified_exec::WARNING_UNIFIED_EXEC_PROCESSES;
use crate::unified_exec::WriteStdinRequest;
use crate::unified_exec::async_watcher::emit_exec_end_for_unified_exec;
use crate::unified_exec::async_watcher::spawn_exit_watcher;
use crate::unified_exec::async_watcher::start_streaming_output;
use crate::unified_exec::clamp_yield_time;
use crate::unified_exec::generate_chunk_id;
use crate::unified_exec::head_tail_buffer::HeadTailBuffer;
use crate::unified_exec::process::OutputBuffer;
use crate::unified_exec::process::OutputHandles;
use crate::unified_exec::process::UnifiedExecProcess;
use crate::unified_exec::resolve_max_tokens;
const UNIFIED_EXEC_ENV: [(&str, &str); 9] = [
("NO_COLOR", "1"),
@@ -70,7 +69,7 @@ fn apply_unified_exec_env(mut env: HashMap<String, String>) -> HashMap<String, S
env
}
struct PreparedSessionHandles {
struct PreparedProcessHandles {
writer_tx: mpsc::Sender<Vec<u8>>,
output_buffer: OutputBuffer,
output_notify: Arc<Notify>,
@@ -81,10 +80,10 @@ struct PreparedSessionHandles {
process_id: String,
}
impl UnifiedExecSessionManager {
impl UnifiedExecProcessManager {
pub(crate) async fn allocate_process_id(&self) -> String {
loop {
let mut store = self.session_store.lock().await;
let mut store = self.process_store.lock().await;
let process_id = if !cfg!(test) && !cfg!(feature = "deterministic_process_ids") {
// production mode → random
@@ -92,7 +91,7 @@ impl UnifiedExecSessionManager {
} else {
// test or deterministic mode
let next = store
.reserved_sessions_id
.reserved_process_ids
.iter()
.filter_map(|s| s.parse::<i32>().ok())
.max()
@@ -102,17 +101,17 @@ impl UnifiedExecSessionManager {
next.to_string()
};
if store.reserved_sessions_id.contains(&process_id) {
if store.reserved_process_ids.contains(&process_id) {
continue;
}
store.reserved_sessions_id.insert(process_id.clone());
store.reserved_process_ids.insert(process_id.clone());
return process_id;
}
}
pub(crate) async fn release_process_id(&self, process_id: &str) {
let mut store = self.session_store.lock().await;
let mut store = self.process_store.lock().await;
store.remove(process_id);
}
@@ -126,7 +125,7 @@ impl UnifiedExecSessionManager {
.clone()
.unwrap_or_else(|| context.turn.cwd.clone());
let session = self
let process = self
.open_session_with_sandbox(
&request.command,
cwd.clone(),
@@ -136,15 +135,15 @@ impl UnifiedExecSessionManager {
)
.await;
let session = match session {
Ok(session) => Arc::new(session),
let process = match process {
Ok(process) => Arc::new(process),
Err(err) => {
self.release_process_id(&request.process_id).await;
return Err(err);
}
};
let transcript = Arc::new(tokio::sync::Mutex::new(CommandTranscript::default()));
let transcript = Arc::new(tokio::sync::Mutex::new(HeadTailBuffer::default()));
let event_ctx = ToolEventCtx::new(
context.session.as_ref(),
context.turn.as_ref(),
@@ -158,7 +157,8 @@ impl UnifiedExecSessionManager {
Some(request.process_id.clone()),
);
emitter.emit(event_ctx, ToolEventStage::Begin).await;
start_streaming_output(&session, context, Arc::clone(&transcript));
start_streaming_output(&process, context, Arc::clone(&transcript));
let max_tokens = resolve_max_tokens(request.max_output_tokens);
let yield_time_ms = clamp_yield_time(request.yield_time_ms);
@@ -171,7 +171,7 @@ impl UnifiedExecSessionManager {
output_buffer,
output_notify,
cancellation_token,
} = session.output_handles();
} = process.output_handles();
let deadline = start + Duration::from_millis(yield_time_ms);
let collected = Self::collect_output_until_deadline(
&output_buffer,
@@ -184,8 +184,8 @@ impl UnifiedExecSessionManager {
let text = String::from_utf8_lossy(&collected).to_string();
let output = formatted_truncate_text(&text, TruncationPolicy::Tokens(max_tokens));
let exit_code = session.exit_code();
let has_exited = session.has_exited() || exit_code.is_some();
let exit_code = process.exit_code();
let has_exited = process.has_exited() || exit_code.is_some();
let chunk_id = generate_chunk_id();
let process_id = request.process_id.clone();
if has_exited {
@@ -208,14 +208,14 @@ impl UnifiedExecSessionManager {
.await;
self.release_process_id(&request.process_id).await;
session.check_for_sandbox_denial_with_text(&text).await?;
process.check_for_sandbox_denial_with_text(&text).await?;
} else {
// Long‑lived command: persist the session so write_stdin can reuse
// Long‑lived command: persist the process so write_stdin can reuse
// it, and register a background watcher that will emit
// ExecCommandEnd when the PTY eventually exits (even if no further
// tool calls are made).
self.store_session(
Arc::clone(&session),
self.store_process(
Arc::clone(&process),
context,
&request.command,
cwd.clone(),
@@ -254,7 +254,7 @@ impl UnifiedExecSessionManager {
) -> Result<UnifiedExecResponse, UnifiedExecError> {
let process_id = request.process_id.to_string();
let PreparedSessionHandles {
let PreparedProcessHandles {
writer_tx,
output_buffer,
output_notify,
@@ -264,7 +264,7 @@ impl UnifiedExecSessionManager {
command: session_command,
process_id,
..
} = self.prepare_session_handles(process_id.as_str()).await?;
} = self.prepare_process_handles(process_id.as_str()).await?;
if !request.input.is_empty() {
Self::send_input(&writer_tx, request.input.as_bytes()).await?;
@@ -291,23 +291,23 @@ impl UnifiedExecSessionManager {
let original_token_count = approx_token_count(&text);
let chunk_id = generate_chunk_id();
// After polling, refresh_session_state tells us whether the PTY is
// After polling, refresh_process_state tells us whether the PTY is
// still alive or has exited and been removed from the store; we thread
// that through so the handler can tag TerminalInteraction with an
// appropriate process_id and exit_code.
let status = self.refresh_session_state(process_id.as_str()).await;
let status = self.refresh_process_state(process_id.as_str()).await;
let (process_id, exit_code, event_call_id) = match status {
SessionStatus::Alive {
ProcessStatus::Alive {
exit_code,
call_id,
process_id,
} => (Some(process_id), exit_code, call_id),
SessionStatus::Exited { exit_code, entry } => {
ProcessStatus::Exited { exit_code, entry } => {
let call_id = entry.call_id.clone();
(None, exit_code, call_id)
}
SessionStatus::Unknown => {
return Err(UnifiedExecError::UnknownSessionId {
ProcessStatus::Unknown => {
return Err(UnifiedExecError::UnknownProcessId {
process_id: request.process_id.to_string(),
});
}
@@ -332,25 +332,25 @@ impl UnifiedExecSessionManager {
Ok(response)
}
async fn refresh_session_state(&self, process_id: &str) -> SessionStatus {
let mut store = self.session_store.lock().await;
let Some(entry) = store.sessions.get(process_id) else {
return SessionStatus::Unknown;
async fn refresh_process_state(&self, process_id: &str) -> ProcessStatus {
let mut store = self.process_store.lock().await;
let Some(entry) = store.processes.get(process_id) else {
return ProcessStatus::Unknown;
};
let exit_code = entry.session.exit_code();
let exit_code = entry.process.exit_code();
let process_id = entry.process_id.clone();
if entry.session.has_exited() {
if entry.process.has_exited() {
let Some(entry) = store.remove(&process_id) else {
return SessionStatus::Unknown;
return ProcessStatus::Unknown;
};
SessionStatus::Exited {
ProcessStatus::Exited {
exit_code,
entry: Box::new(entry),
}
} else {
SessionStatus::Alive {
ProcessStatus::Alive {
exit_code,
call_id: entry.call_id.clone(),
process_id,
@@ -358,16 +358,16 @@ impl UnifiedExecSessionManager {
}
}
async fn prepare_session_handles(
async fn prepare_process_handles(
&self,
process_id: &str,
) -> Result<PreparedSessionHandles, UnifiedExecError> {
let mut store = self.session_store.lock().await;
) -> Result<PreparedProcessHandles, UnifiedExecError> {
let mut store = self.process_store.lock().await;
let entry =
store
.sessions
.processes
.get_mut(process_id)
.ok_or(UnifiedExecError::UnknownSessionId {
.ok_or(UnifiedExecError::UnknownProcessId {
process_id: process_id.to_string(),
})?;
entry.last_used = Instant::now();
@@ -375,10 +375,10 @@ impl UnifiedExecSessionManager {
output_buffer,
output_notify,
cancellation_token,
} = entry.session.output_handles();
} = entry.process.output_handles();
Ok(PreparedSessionHandles {
writer_tx: entry.session.writer_sender(),
Ok(PreparedProcessHandles {
writer_tx: entry.process.writer_sender(),
output_buffer,
output_notify,
cancellation_token,
@@ -400,18 +400,18 @@ impl UnifiedExecSessionManager {
}
#[allow(clippy::too_many_arguments)]
async fn store_session(
async fn store_process(
&self,
session: Arc<UnifiedExecSession>,
process: Arc<UnifiedExecProcess>,
context: &UnifiedExecContext,
command: &[String],
cwd: PathBuf,
started_at: Instant,
process_id: String,
transcript: Arc<tokio::sync::Mutex<CommandTranscript>>,
transcript: Arc<tokio::sync::Mutex<HeadTailBuffer>>,
) {
let entry = SessionEntry {
session: Arc::clone(&session),
let entry = ProcessEntry {
process: Arc::clone(&process),
session_ref: Arc::clone(&context.session),
turn_ref: Arc::clone(&context.turn),
call_id: context.call_id.clone(),
@@ -419,25 +419,25 @@ impl UnifiedExecSessionManager {
command: command.to_vec(),
last_used: started_at,
};
let number_sessions = {
let mut store = self.session_store.lock().await;
Self::prune_sessions_if_needed(&mut store);
store.sessions.insert(process_id.clone(), entry);
store.sessions.len()
let number_processes = {
let mut store = self.process_store.lock().await;
Self::prune_processes_if_needed(&mut store);
store.processes.insert(process_id.clone(), entry);
store.processes.len()
};
if number_sessions >= WARNING_UNIFIED_EXEC_SESSIONS {
if number_processes >= WARNING_UNIFIED_EXEC_PROCESSES {
context
.session
.record_model_warning(
format!("The maximum number of unified exec sessions you can keep open is {WARNING_UNIFIED_EXEC_SESSIONS} and you currently have {number_sessions} sessions open. Reuse older sessions or close them to prevent automatic pruning of old session"),
format!("The maximum number of unified exec processes you can keep open is {WARNING_UNIFIED_EXEC_PROCESSES} and you currently have {number_processes} processes open. Reuse older processes or close them to prevent automatic pruning of old processes"),
&context.turn
)
.await;
};
spawn_exit_watcher(
Arc::clone(&session),
Arc::clone(&process),
Arc::clone(&context.session),
Arc::clone(&context.turn),
context.call_id.clone(),
@@ -471,7 +471,7 @@ impl UnifiedExecSessionManager {
pub(crate) async fn open_session_with_exec_env(
&self,
env: &ExecEnv,
) -> Result<UnifiedExecSession, UnifiedExecError> {
) -> Result<UnifiedExecProcess, UnifiedExecError> {
let (program, args) = env
.command
.split_first()
@@ -485,8 +485,8 @@ impl UnifiedExecSessionManager {
&env.arg0,
)
.await
.map_err(|err| UnifiedExecError::create_session(err.to_string()))?;
UnifiedExecSession::from_spawned(spawned, env.sandbox).await
.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
UnifiedExecProcess::from_spawned(spawned, env.sandbox).await
}
pub(super) async fn open_session_with_sandbox(
@@ -496,7 +496,7 @@ impl UnifiedExecSessionManager {
sandbox_permissions: SandboxPermissions,
justification: Option<String>,
context: &UnifiedExecContext,
) -> Result<UnifiedExecSession, UnifiedExecError> {
) -> Result<UnifiedExecProcess, UnifiedExecError> {
let env = apply_unified_exec_env(create_env(&context.turn.shell_environment_policy));
let features = context.session.features();
let mut orchestrator = ToolOrchestrator::new();
@@ -536,7 +536,7 @@ impl UnifiedExecSessionManager {
context.turn.approval_policy,
)
.await
.map_err(|e| UnifiedExecError::create_session(format!("{e:?}")))
.map_err(|e| UnifiedExecError::create_process(format!("{e:?}")))
}
pub(super) async fn collect_output_until_deadline(
@@ -550,11 +550,11 @@ impl UnifiedExecSessionManager {
let mut collected: Vec<u8> = Vec::with_capacity(4096);
let mut exit_signal_received = cancellation_token.is_cancelled();
loop {
let drained_chunks;
let drained_chunks: Vec<Vec<u8>>;
let mut wait_for_output = None;
{
let mut guard = output_buffer.lock().await;
drained_chunks = guard.drain();
drained_chunks = guard.drain_chunks();
if drained_chunks.is_empty() {
wait_for_output = Some(output_notify.notified());
}
@@ -600,20 +600,20 @@ impl UnifiedExecSessionManager {
collected
}
fn prune_sessions_if_needed(store: &mut SessionStore) -> bool {
if store.sessions.len() < MAX_UNIFIED_EXEC_SESSIONS {
fn prune_processes_if_needed(store: &mut ProcessStore) -> bool {
if store.processes.len() < MAX_UNIFIED_EXEC_PROCESSES {
return false;
}
let meta: Vec<(String, Instant, bool)> = store
.sessions
.processes
.iter()
.map(|(id, entry)| (id.clone(), entry.last_used, entry.session.has_exited()))
.map(|(id, entry)| (id.clone(), entry.last_used, entry.process.has_exited()))
.collect();
if let Some(session_id) = Self::session_id_to_prune_from_meta(&meta) {
if let Some(entry) = store.remove(&session_id) {
entry.session.terminate();
if let Some(process_id) = Self::process_id_to_prune_from_meta(&meta) {
if let Some(entry) = store.remove(&process_id) {
entry.process.terminate();
}
return true;
}
@@ -622,7 +622,7 @@ impl UnifiedExecSessionManager {
}
// Centralized pruning policy so we can easily swap strategies later.
fn session_id_to_prune_from_meta(meta: &[(String, Instant, bool)]) -> Option<String> {
fn process_id_to_prune_from_meta(meta: &[(String, Instant, bool)]) -> Option<String> {
if meta.is_empty() {
return None;
}
@@ -650,22 +650,25 @@ impl UnifiedExecSessionManager {
.map(|(process_id, _, _)| process_id)
}
pub(crate) async fn terminate_all_sessions(&self) {
let entries: Vec<SessionEntry> = {
let mut sessions = self.session_store.lock().await;
let entries: Vec<SessionEntry> =
sessions.sessions.drain().map(|(_, entry)| entry).collect();
sessions.reserved_sessions_id.clear();
pub(crate) async fn terminate_all_processes(&self) {
let entries: Vec<ProcessEntry> = {
let mut processes = self.process_store.lock().await;
let entries: Vec<ProcessEntry> = processes
.processes
.drain()
.map(|(_, entry)| entry)
.collect();
processes.reserved_process_ids.clear();
entries
};
for entry in entries {
entry.session.terminate();
entry.process.terminate();
}
}
}
enum SessionStatus {
enum ProcessStatus {
Alive {
exit_code: Option<i32>,
call_id: String,
@@ -673,7 +676,7 @@ enum SessionStatus {
},
Exited {
exit_code: Option<i32>,
entry: Box<SessionEntry>,
entry: Box<ProcessEntry>,
},
Unknown,
}
@@ -716,7 +719,7 @@ mod tests {
}
#[test]
fn pruning_prefers_exited_sessions_outside_recently_used() {
fn pruning_prefers_exited_processes_outside_recently_used() {
let now = Instant::now();
let id = |n: i32| n.to_string();
let meta = vec![
@@ -732,7 +735,7 @@ mod tests {
(id(10), now - Duration::from_secs(13), false),
];
let candidate = UnifiedExecSessionManager::session_id_to_prune_from_meta(&meta);
let candidate = UnifiedExecProcessManager::process_id_to_prune_from_meta(&meta);
assert_eq!(candidate, Some(id(2)));
}
@@ -754,13 +757,13 @@ mod tests {
(id(10), now - Duration::from_secs(13), false),
];
let candidate = UnifiedExecSessionManager::session_id_to_prune_from_meta(&meta);
let candidate = UnifiedExecProcessManager::process_id_to_prune_from_meta(&meta);
assert_eq!(candidate, Some(id(1)));
}
#[test]
fn pruning_protects_recent_sessions_even_if_exited() {
fn pruning_protects_recent_processes_even_if_exited() {
let now = Instant::now();
let id = |n: i32| n.to_string();
let meta = vec![
@@ -776,7 +779,7 @@ mod tests {
(id(10), now - Duration::from_secs(13), true),
];
let candidate = UnifiedExecSessionManager::session_id_to_prune_from_meta(&meta);
let candidate = UnifiedExecProcessManager::process_id_to_prune_from_meta(&meta);
// (10) is exited but among the last 8; we should drop the LRU outside that set.
assert_eq!(candidate, Some(id(1)));
+2 -1
View File
@@ -143,6 +143,7 @@ impl TestCodexBuilder {
config.model_provider.clone(),
config.codex_home.clone(),
);
let conversation_manager = Arc::new(conversation_manager);
let new_conversation = match resume_from {
Some(path) => {
@@ -164,7 +165,7 @@ impl TestCodexBuilder {
config,
codex: new_conversation.conversation,
session_configured: new_conversation.session_configured,
conversation_manager: Arc::new(conversation_manager),
conversation_manager,
})
}
+224 -2
View File
@@ -42,7 +42,18 @@ async fn list_models_returns_chatgpt_models() -> Result<()> {
}
fn expected_models_for_api_key() -> Vec<ModelPreset> {
vec![gpt_5_1_codex_max(), gpt_5_1_codex_mini(), gpt_5_2()]
vec![
gpt_5_1_codex_max(),
gpt_5_1_codex_mini(),
gpt_5_2(),
bengalfox(),
boomslang(),
gpt_5_codex(),
gpt_5_codex_mini(),
gpt_5_1_codex(),
gpt_5(),
gpt_5_1(),
]
}
fn expected_models_for_chatgpt() -> Vec<ModelPreset> {
@@ -53,6 +64,13 @@ fn expected_models_for_chatgpt() -> Vec<ModelPreset> {
gpt_5_1_codex_max,
gpt_5_1_codex_mini(),
gpt_5_2(),
bengalfox(),
boomslang(),
gpt_5_codex(),
gpt_5_codex_mini(),
gpt_5_1_codex(),
gpt_5(),
gpt_5_1(),
]
}
@@ -168,7 +186,7 @@ fn gpt_5_2() -> ModelPreset {
),
effort(
ReasoningEffort::XHigh,
"Extra high reasoning for complex problems",
"Extra high reasoning depth for complex problems",
),
],
is_default: false,
@@ -178,6 +196,210 @@ fn gpt_5_2() -> ModelPreset {
}
}
fn bengalfox() -> ModelPreset {
ModelPreset {
id: "bengalfox".to_string(),
model: "bengalfox".to_string(),
display_name: "bengalfox".to_string(),
description: "bengalfox".to_string(),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![
effort(
ReasoningEffort::Low,
"Fast responses with lighter reasoning",
),
effort(
ReasoningEffort::Medium,
"Balances speed and reasoning depth for everyday tasks",
),
effort(
ReasoningEffort::High,
"Greater reasoning depth for complex problems",
),
effort(
ReasoningEffort::XHigh,
"Extra high reasoning depth for complex problems",
),
],
is_default: false,
upgrade: None,
show_in_picker: false,
supported_in_api: true,
}
}
fn boomslang() -> ModelPreset {
ModelPreset {
id: "boomslang".to_string(),
model: "boomslang".to_string(),
display_name: "boomslang".to_string(),
description: "boomslang".to_string(),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![
effort(
ReasoningEffort::Low,
"Balances speed with some reasoning; useful for straightforward queries and short explanations",
),
effort(
ReasoningEffort::Medium,
"Provides a solid balance of reasoning depth and latency for general-purpose tasks",
),
effort(
ReasoningEffort::High,
"Maximizes reasoning depth for complex or ambiguous problems",
),
effort(
ReasoningEffort::XHigh,
"Extra high reasoning depth for complex problems",
),
],
is_default: false,
upgrade: None,
show_in_picker: false,
supported_in_api: true,
}
}
fn gpt_5_codex() -> ModelPreset {
ModelPreset {
id: "gpt-5-codex".to_string(),
model: "gpt-5-codex".to_string(),
display_name: "gpt-5-codex".to_string(),
description: "Optimized for codex.".to_string(),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![
effort(
ReasoningEffort::Low,
"Fastest responses with limited reasoning",
),
effort(
ReasoningEffort::Medium,
"Dynamically adjusts reasoning based on the task",
),
effort(
ReasoningEffort::High,
"Maximizes reasoning depth for complex or ambiguous problems",
),
],
is_default: false,
upgrade: Some(gpt52_codex_upgrade()),
show_in_picker: false,
supported_in_api: true,
}
}
fn gpt_5_codex_mini() -> ModelPreset {
ModelPreset {
id: "gpt-5-codex-mini".to_string(),
model: "gpt-5-codex-mini".to_string(),
display_name: "gpt-5-codex-mini".to_string(),
description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![
effort(
ReasoningEffort::Medium,
"Dynamically adjusts reasoning based on the task",
),
effort(
ReasoningEffort::High,
"Maximizes reasoning depth for complex or ambiguous problems",
),
],
is_default: false,
upgrade: Some(gpt52_codex_upgrade()),
show_in_picker: false,
supported_in_api: true,
}
}
fn gpt_5_1_codex() -> ModelPreset {
ModelPreset {
id: "gpt-5.1-codex".to_string(),
model: "gpt-5.1-codex".to_string(),
display_name: "gpt-5.1-codex".to_string(),
description: "Optimized for codex.".to_string(),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![
effort(
ReasoningEffort::Low,
"Fastest responses with limited reasoning",
),
effort(
ReasoningEffort::Medium,
"Dynamically adjusts reasoning based on the task",
),
effort(
ReasoningEffort::High,
"Maximizes reasoning depth for complex or ambiguous problems",
),
],
is_default: false,
upgrade: Some(gpt52_codex_upgrade()),
show_in_picker: false,
supported_in_api: true,
}
}
fn gpt_5() -> ModelPreset {
ModelPreset {
id: "gpt-5".to_string(),
model: "gpt-5".to_string(),
display_name: "gpt-5".to_string(),
description: "Broad world knowledge with strong general reasoning.".to_string(),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![
effort(
ReasoningEffort::Minimal,
"Fastest responses with little reasoning",
),
effort(
ReasoningEffort::Low,
"Balances speed with some reasoning; useful for straightforward queries and short explanations",
),
effort(
ReasoningEffort::Medium,
"Provides a solid balance of reasoning depth and latency for general-purpose tasks",
),
effort(
ReasoningEffort::High,
"Maximizes reasoning depth for complex or ambiguous problems",
),
],
is_default: false,
upgrade: Some(gpt52_codex_upgrade()),
show_in_picker: false,
supported_in_api: true,
}
}
fn gpt_5_1() -> ModelPreset {
ModelPreset {
id: "gpt-5.1".to_string(),
model: "gpt-5.1".to_string(),
display_name: "gpt-5.1".to_string(),
description: "Broad world knowledge with strong general reasoning.".to_string(),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![
effort(
ReasoningEffort::Low,
"Balances speed with some reasoning; useful for straightforward queries and short explanations",
),
effort(
ReasoningEffort::Medium,
"Provides a solid balance of reasoning depth and latency for general-purpose tasks",
),
effort(
ReasoningEffort::High,
"Maximizes reasoning depth for complex or ambiguous problems",
),
],
is_default: false,
upgrade: Some(gpt52_codex_upgrade()),
show_in_picker: false,
supported_in_api: true,
}
}
fn gpt52_codex_upgrade() -> codex_protocol::openai_models::ModelUpgrade {
codex_protocol::openai_models::ModelUpgrade {
id: "gpt-5.2-codex".to_string(),
+1
View File
@@ -65,3 +65,4 @@ mod unified_exec;
mod user_notification;
mod user_shell_cmd;
mod view_image;
mod web_search_cached;
+7 -7
View File
@@ -381,12 +381,11 @@ async fn remote_models_hide_picker_only_models() -> Result<()> {
assert_eq!(selected, "gpt-5.2-codex");
let available = manager.list_models(&config).await;
assert!(
available
.iter()
.all(|model| model.model != "codex-auto-balanced"),
"hidden models should not appear in the picker list"
);
let hidden = available
.iter()
.find(|model| model.model == "codex-auto-balanced")
.expect("hidden remote model should be listed");
assert!(!hidden.show_in_picker, "hidden models should remain hidden");
Ok(())
}
@@ -442,7 +441,8 @@ where
mutate_config(&mut config);
let conversation_manager = Arc::new(ConversationManager::with_models_provider(auth, provider));
let conversation_manager = ConversationManager::with_models_provider(auth, provider);
let conversation_manager = Arc::new(conversation_manager);
let new_conversation = conversation_manager
.new_conversation(config.clone())
@@ -301,8 +301,11 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an
"perl -MTime::HiRes -e 'print int(Time::HiRes::time()*1000), \"\\n\"' >> \"{}\"",
output_path.display()
);
// Use a non-login shell to avoid slow, user-specific shell init (e.g. zsh profiles)
// from making this timing-based test flaky.
let args = json!({
"command": command,
"login": false,
"timeout_ms": 1_000,
});
+5 -5
View File
@@ -1689,7 +1689,7 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> {
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "close unified exec sessions on turn end".into(),
text: "close unified exec processes on turn end".into(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
@@ -1710,7 +1710,7 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> {
let begin_process_id = begin_event
.process_id
.clone()
.expect("expected process_id for long-running unified exec session");
.expect("expected process_id for long-running unified exec process");
let pid = wait_for_pid_file(&pid_path).await?;
assert!(
@@ -2560,7 +2560,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
let prune_start = requests
.iter()
.find_map(|req| req.function_call_output_text(prune_call_id))
.expect("missing initial prune session output");
.expect("missing initial prune process output");
let prune_start_output = parse_unified_exec_output(&prune_start)?;
assert!(prune_start_output.process_id.is_some());
assert!(prune_start_output.exit_code.is_none());
@@ -2573,7 +2573,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
assert!(keep_write_output.process_id.is_some());
assert!(
keep_write_output.output.contains("still alive"),
"expected cat session to echo input, got {:?}",
"expected cat process to echo input, got {:?}",
keep_write_output.output
);
@@ -2582,7 +2582,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
.find_map(|req| req.function_call_output_text(probe_call_id))
.expect("missing probe output");
assert!(
pruned_probe.contains("UnknownSessionId") || pruned_probe.contains("Unknown process id"),
pruned_probe.contains("UnknownProcessId") || pruned_probe.contains("Unknown process id"),
"expected probe to fail after pruning, got {pruned_probe:?}"
);
@@ -0,0 +1,87 @@
#![allow(clippy::unwrap_used)]
use codex_core::features::Feature;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::responses;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
use serde_json::Value;
fn sse_completed(id: &str) -> String {
load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
}
#[allow(clippy::expect_used)]
fn find_web_search_tool(body: &Value) -> &Value {
body["tools"]
.as_array()
.expect("request body should include tools array")
.iter()
.find(|tool| tool.get("type").and_then(Value::as_str) == Some("web_search"))
.expect("tools should include a web_search tool")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn web_search_cached_sets_external_web_access_false_in_request_body() {
skip_if_no_network!();
let server = start_mock_server().await;
let sse = sse_completed("resp-1");
let resp_mock = responses::mount_sse_once(&server, sse).await;
let mut builder = test_codex()
.with_model("gpt-5-codex")
.with_config(|config| {
config.features.enable(Feature::WebSearchCached);
});
let test = builder
.build(&server)
.await
.expect("create test Codex conversation");
test.submit_turn("hello cached web search")
.await
.expect("submit turn");
let body = resp_mock.single_request().body_json();
let tool = find_web_search_tool(&body);
assert_eq!(
tool.get("external_web_access").and_then(Value::as_bool),
Some(false),
"web_search_cached should force external_web_access=false"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn web_search_cached_takes_precedence_over_web_search_request_in_request_body() {
skip_if_no_network!();
let server = start_mock_server().await;
let sse = sse_completed("resp-1");
let resp_mock = responses::mount_sse_once(&server, sse).await;
let mut builder = test_codex()
.with_model("gpt-5-codex")
.with_config(|config| {
config.features.enable(Feature::WebSearchRequest);
config.features.enable(Feature::WebSearchCached);
});
let test = builder
.build(&server)
.await
.expect("create test Codex conversation");
test.submit_turn("hello cached+live flags")
.await
.expect("submit turn");
let body = resp_mock.single_request().body_json();
let tool = find_web_search_tool(&body);
assert_eq!(
tool.get("external_web_access").and_then(Value::as_bool),
Some(false),
"web_search_cached should win over web_search_request"
);
}
@@ -595,7 +595,8 @@ impl EventProcessor for EventProcessorWithHumanOutput {
| EventMsg::ReasoningRawContentDelta(_)
| EventMsg::SkillsUpdateAvailable
| EventMsg::UndoCompleted(_)
| EventMsg::UndoStarted(_) => {}
| EventMsg::UndoStarted(_)
| EventMsg::ThreadRolledBack(_) => {}
}
CodexStatus::Running
}
+26 -26
View File
@@ -21,7 +21,7 @@ pub struct CodexToolCallParam {
/// The *initial user prompt* to start the Codex conversation.
pub prompt: String,
/// Optional override for the model name (e.g. "o3", "o4-mini").
/// Optional override for the model name (e.g. 'gpt-5.2', 'gpt-5.2-codex').
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
@@ -245,11 +245,8 @@ mod tests {
let tool = create_tool_for_codex_tool_call_param();
let tool_json = serde_json::to_value(&tool).expect("tool serializes");
let expected_tool_json = serde_json::json!({
"name": "codex",
"title": "Codex",
"description": "Run a Codex session. Accepts configuration parameters matching the Codex Config struct.",
"inputSchema": {
"type": "object",
"properties": {
"approval-policy": {
"description": "Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `on-request`, `never`.",
@@ -261,26 +258,29 @@ mod tests {
],
"type": "string"
},
"sandbox": {
"description": "Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`.",
"enum": [
"read-only",
"workspace-write",
"danger-full-access"
],
"base-instructions": {
"description": "The set of instructions to use instead of the default ones.",
"type": "string"
},
"compact-prompt": {
"description": "Prompt used when compacting the conversation.",
"type": "string"
},
"config": {
"description": "Individual config settings that will override what is in CODEX_HOME/config.toml.",
"additionalProperties": true,
"description": "Individual config settings that will override what is in CODEX_HOME/config.toml.",
"type": "object"
},
"cwd": {
"description": "Working directory for the session. If relative, it is resolved against the server process's current working directory.",
"type": "string"
},
"developer-instructions": {
"description": "Developer instructions that should be injected as a developer role message.",
"type": "string"
},
"model": {
"description": "Optional override for the model name (e.g. \"o3\", \"o4-mini\").",
"description": "Optional override for the model name (e.g. 'gpt-5.2', 'gpt-5.2-codex').",
"type": "string"
},
"profile": {
@@ -291,23 +291,23 @@ mod tests {
"description": "The *initial user prompt* to start the Codex conversation.",
"type": "string"
},
"base-instructions": {
"description": "The set of instructions to use instead of the default ones.",
"sandbox": {
"description": "Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`.",
"enum": [
"read-only",
"workspace-write",
"danger-full-access"
],
"type": "string"
},
"developer-instructions": {
"description": "Developer instructions that should be injected as a developer role message.",
"type": "string"
},
"compact-prompt": {
"description": "Prompt used when compacting the conversation.",
"type": "string"
},
}
},
"required": [
"prompt"
]
}
],
"type": "object"
},
"name": "codex",
"title": "Codex"
});
assert_eq!(expected_tool_json, tool_json);
}
@@ -311,6 +311,7 @@ async fn run_codex_tool_session_inner(
| EventMsg::UndoCompleted(_)
| EventMsg::ExitedReviewMode(_)
| EventMsg::ContextCompacted(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::DeprecationNotice(_) => {
// For now, we do not do anything extra for these
// events. Note that
+36
View File
@@ -210,6 +210,12 @@ pub enum Op {
/// Request Codex to undo a turn (turn are stacked so it is the same effect as CMD + Z).
Undo,
/// Request Codex to drop the last N user turns from in-memory context.
///
/// This does not attempt to revert local filesystem changes. Clients are
/// responsible for undoing any edits on disk.
ThreadRollback { num_turns: u32 },
/// Request a code review from the agent.
Review { review_request: ReviewRequest },
@@ -541,6 +547,9 @@ pub enum EventMsg {
/// Conversation history was compacted (either automatically or manually).
ContextCompacted(ContextCompactedEvent),
/// Conversation history was rolled back by dropping the last N user turns.
ThreadRolledBack(ThreadRolledBackEvent),
/// Agent has started a task
TaskStarted(TaskStartedEvent),
@@ -672,6 +681,26 @@ pub enum EventMsg {
ReasoningRawContentDelta(ReasoningRawContentDeltaEvent),
}
/// Agent lifecycle status, derived from emitted events.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, Default)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
pub enum AgentStatus {
/// Agent is waiting for initialization.
#[default]
PendingInit,
/// Agent is currently running.
Running,
/// Agent is done. Contains the final assistant message.
Completed(Option<String>),
/// Agent encountered an error.
Errored(String),
/// Agent has been shutdowned.
Shutdown,
/// Agent is not found.
NotFound,
}
/// Codex errors that we expose to clients.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
@@ -698,6 +727,7 @@ pub enum CodexErrorInfo {
ResponseTooManyFailedAttempts {
http_status_code: Option<u16>,
},
ThreadRollbackFailed,
Other,
}
@@ -1598,6 +1628,12 @@ pub struct UndoCompletedEvent {
pub message: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ThreadRolledBackEvent {
/// Number of user turns that were removed from context.
pub num_turns: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct StreamErrorEvent {
pub message: String,
+74
View File
@@ -1334,16 +1334,19 @@ mod tests {
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::ConversationManager;
use codex_core::config::ConfigBuilder;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::SessionConfiguredEvent;
use codex_protocol::ConversationId;
use insta::assert_snapshot;
use ratatui::prelude::Line;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tempfile::tempdir;
async fn make_test_app() -> App {
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
@@ -1427,6 +1430,24 @@ mod tests {
codex_core::models_manager::model_presets::all_model_presets().clone()
}
fn model_migration_copy_to_plain_text(
copy: &crate::model_migration::ModelMigrationCopy,
) -> String {
let mut s = String::new();
for span in &copy.heading {
s.push_str(&span.content);
}
s.push('\n');
s.push('\n');
for line in &copy.content {
for span in &line.spans {
s.push_str(&span.content);
}
s.push('\n');
}
s
}
#[tokio::test]
async fn model_migration_prompt_only_shows_for_deprecated_models() {
let seen = BTreeMap::new();
@@ -1508,6 +1529,59 @@ mod tests {
assert!(target_preset_for_upgrade(&available, "missing-target").is_none());
}
#[tokio::test]
async fn model_migration_prompt_shows_for_hidden_model() {
let codex_home = tempdir().expect("temp codex home");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config");
let available_models = all_model_presets();
let current = available_models
.iter()
.find(|preset| preset.model == "gpt-5.1-codex")
.cloned()
.expect("gpt-5.1-codex preset present");
assert!(
!current.show_in_picker,
"expected gpt-5.1-codex to be hidden from picker for this test"
);
let upgrade = current.upgrade.as_ref().expect("upgrade configured");
assert!(
should_show_model_migration_prompt(
&current.model,
&upgrade.id,
&config.notices.model_migrations,
&available_models,
),
"expected migration prompt to be eligible for hidden model"
);
let target = target_preset_for_upgrade(&available_models, &upgrade.id)
.expect("upgrade target present");
let target_description =
(!target.description.is_empty()).then(|| target.description.clone());
let can_opt_out = true;
let copy = migration_copy_for_models(
&current.model,
&upgrade.id,
upgrade.model_link.clone(),
upgrade.upgrade_copy.clone(),
target.display_name.clone(),
target_description,
can_opt_out,
);
// Snapshot the copy we would show; rendering is covered by model_migration snapshots.
assert_snapshot!(
"model_migration_prompt_shows_for_hidden_model",
model_migration_copy_to_plain_text(&copy)
);
}
#[tokio::test]
async fn update_reasoning_effort_updates_config() {
let mut app = make_test_app().await;
@@ -461,9 +461,13 @@ fn exec_options(
.chain(
proposed_execpolicy_amendment
.filter(|_| features.enabled(Feature::ExecPolicy))
.map(|prefix| {
.and_then(|prefix| {
let rendered_prefix = strip_bash_lc_and_escape(prefix.command());
ApprovalOption {
if rendered_prefix.contains('\n') || rendered_prefix.contains('\r') {
return None;
}
Some(ApprovalOption {
label: format!(
"Yes, and don't ask again for commands that start with `{rendered_prefix}`"
),
@@ -474,7 +478,7 @@ fn exec_options(
),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))],
}
})
}),
)
.chain([ApprovalOption {
@@ -73,6 +73,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
pub enum InputResult {
Submitted(String),
Command(SlashCommand),
CommandWithArgs(SlashCommand, String),
None,
}
@@ -1274,6 +1275,18 @@ impl ChatComposer {
}
}
if !input_starts_with_space
&& let Some((name, rest)) = parse_slash_name(&text)
&& !rest.is_empty()
&& !name.contains('/')
&& let Some((_n, cmd)) = built_in_slash_commands()
.into_iter()
.find(|(command_name, _)| *command_name == name)
&& cmd == SlashCommand::Review
{
return (InputResult::CommandWithArgs(cmd, rest.to_string()), true);
}
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
Ok(expanded) => expanded,
Err(err) => {
@@ -1650,6 +1663,16 @@ impl ChatComposer {
fn sync_popups(&mut self) {
let file_token = Self::current_at_token(&self.textarea);
let browsing_history = self
.history
.should_handle_navigation(self.textarea.text(), self.textarea.cursor());
// When browsing input history (shell-style Up/Down recall), skip all popup
// synchronization so nothing steals focus from continued history navigation.
if browsing_history {
self.active_popup = ActivePopup::None;
return;
}
let skill_token = self.current_skill_token();
let allow_command_popup = file_token.is_none() && skill_token.is_none();
@@ -2831,6 +2854,9 @@ mod tests {
InputResult::Command(cmd) => {
assert_eq!(cmd.command(), "init");
}
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/init'")
}
InputResult::Submitted(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
@@ -2839,6 +2865,44 @@ mod tests {
assert!(composer.textarea.is_empty(), "composer should be cleared");
}
#[test]
fn slash_review_with_args_dispatches_command_with_args() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']);
type_chars_humanlike(&mut composer, &['f', 'i', 'x', ' ', 't', 'h', 'i', 's']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::CommandWithArgs(cmd, args) => {
assert_eq!(cmd, SlashCommand::Review);
assert_eq!(args, "fix this");
}
InputResult::Command(cmd) => {
panic!("expected args for '/review', got bare command: {cmd:?}")
}
InputResult::Submitted(text) => {
panic!("expected command dispatch, got literal submit: {text}")
}
InputResult::None => panic!("expected CommandWithArgs result for '/review'"),
}
assert!(composer.textarea.is_empty(), "composer should be cleared");
}
#[test]
fn extract_args_supports_quoted_paths_single_arg() {
let args = extract_positional_args_for_prompt_line(
@@ -2904,6 +2968,9 @@ mod tests {
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"),
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/diff'")
}
InputResult::Submitted(text) => {
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
}
@@ -2937,6 +3004,9 @@ mod tests {
InputResult::Command(cmd) => {
assert_eq!(cmd.command(), "mention");
}
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/mention'")
}
InputResult::Submitted(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
@@ -4109,6 +4179,59 @@ mod tests {
assert_eq!(result, InputResult::None);
}
#[test]
fn history_navigation_takes_priority_over_popups() {
use codex_protocol::protocol::SkillScope;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use tokio::sync::mpsc::unbounded_channel;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_skill_mentions(Some(vec![SkillMetadata {
name: "codex-cli-release-notes".to_string(),
description: "example".to_string(),
short_description: None,
path: PathBuf::from("skills/codex-cli-release-notes/SKILL.md"),
scope: SkillScope::Repo,
}]));
// Seed local history; the newest entry triggers the skills popup.
composer.history.record_local_submission("older");
composer
.history
.record_local_submission("$codex-cli-release-notes");
// First Up recalls "$...", but we should not open the skills popup while browsing history.
let (result, _redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.textarea.text(), "$codex-cli-release-notes");
assert!(
matches!(composer.active_popup, ActivePopup::None),
"expected no skills popup while browsing history"
);
// Second Up should navigate history again (no popup should interfere).
let (result, _redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.textarea.text(), "older");
assert!(
matches!(composer.active_popup, ActivePopup::None),
"expected popup to be dismissed after history navigation"
);
}
#[test]
fn slash_popup_activated_for_bare_slash_and_valid_prefixes() {
// use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+2 -2
View File
@@ -416,8 +416,8 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn set_unified_exec_sessions(&mut self, sessions: Vec<String>) {
if self.unified_exec_footer.set_sessions(sessions) {
pub(crate) fn set_unified_exec_processes(&mut self, processes: Vec<String>) {
if self.unified_exec_footer.set_processes(processes) {
self.request_redraw();
}
}
@@ -8,34 +8,34 @@ use crate::live_wrap::take_prefix_by_width;
use crate::render::renderable::Renderable;
pub(crate) struct UnifiedExecFooter {
sessions: Vec<String>,
processes: Vec<String>,
}
impl UnifiedExecFooter {
pub(crate) fn new() -> Self {
Self {
sessions: Vec::new(),
processes: Vec::new(),
}
}
pub(crate) fn set_sessions(&mut self, sessions: Vec<String>) -> bool {
if self.sessions == sessions {
pub(crate) fn set_processes(&mut self, processes: Vec<String>) -> bool {
if self.processes == processes {
return false;
}
self.sessions = sessions;
self.processes = processes;
true
}
pub(crate) fn is_empty(&self) -> bool {
self.sessions.is_empty()
self.processes.is_empty()
}
fn render_lines(&self, width: u16) -> Vec<Line<'static>> {
if self.sessions.is_empty() || width < 4 {
if self.processes.is_empty() || width < 4 {
return Vec::new();
}
let count = self.sessions.len();
let count = self.processes.len();
let plural = if count == 1 { "" } else { "s" };
let message = format!(" {count} background terminal{plural} running · /ps to view");
let (truncated, _, _) = take_prefix_by_width(&message, width as usize);
@@ -72,7 +72,7 @@ mod tests {
#[test]
fn render_more_sessions() {
let mut footer = UnifiedExecFooter::new();
footer.set_sessions(vec!["rg \"foo\" src".to_string()]);
footer.set_processes(vec!["rg \"foo\" src".to_string()]);
let width = 50;
let height = footer.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
@@ -83,7 +83,7 @@ mod tests {
#[test]
fn render_many_sessions() {
let mut footer = UnifiedExecFooter::new();
footer.set_sessions((0..123).map(|idx| format!("cmd {idx}")).collect());
footer.set_processes((0..123).map(|idx| format!("cmd {idx}")).collect());
let width = 50;
let height = footer.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
+69 -28
View File
@@ -154,7 +154,7 @@ struct RunningCommand {
source: ExecCommandSource,
}
struct UnifiedExecSessionSummary {
struct UnifiedExecProcessSummary {
key: String,
command_display: String,
}
@@ -332,7 +332,7 @@ pub(crate) struct ChatWidget {
suppressed_exec_calls: HashSet<String>,
last_unified_wait: Option<UnifiedExecWaitState>,
task_complete_pending: bool,
unified_exec_sessions: Vec<UnifiedExecSessionSummary>,
unified_exec_processes: Vec<UnifiedExecProcessSummary>,
mcp_startup_status: Option<HashMap<String, McpStartupStatus>>,
// Queue of interruptive UI events deferred during an active write cycle
interrupts: InterruptManager,
@@ -801,6 +801,8 @@ impl ChatWidget {
fn on_interrupted_turn(&mut self, reason: TurnAbortReason) {
// Finalize, log a gentle prompt, and clear running state.
self.finalize_turn();
self.unified_exec_processes.clear();
self.sync_unified_exec_footer();
if reason != TurnAbortReason::ReviewEnded {
self.add_to_history(history_cell::new_error_event(
@@ -866,7 +868,7 @@ impl ChatWidget {
fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) {
self.flush_answer_stream_with_separator();
if is_unified_exec_source(ev.source) {
self.track_unified_exec_session_begin(&ev);
self.track_unified_exec_process_begin(&ev);
if !is_standard_tool_call(&ev.parsed_cmd) {
return;
}
@@ -885,10 +887,10 @@ impl ChatWidget {
fn on_terminal_interaction(&mut self, ev: TerminalInteractionEvent) {
self.flush_answer_stream_with_separator();
let command_display = self
.unified_exec_sessions
.unified_exec_processes
.iter()
.find(|session| session.key == ev.process_id)
.map(|session| session.command_display.clone());
.find(|process| process.key == ev.process_id)
.map(|process| process.command_display.clone());
if ev.stdin.is_empty() {
// Empty stdin means we are still waiting on background output; keep a live shimmer cell.
if let Some(wait_cell) = self.active_cell.as_mut().and_then(|cell| {
@@ -896,7 +898,7 @@ impl ChatWidget {
.downcast_mut::<history_cell::UnifiedExecWaitCell>()
}) && wait_cell.matches(command_display.as_deref())
{
// Same session still waiting; update command display if it shows up late.
// Same process still waiting; update command display if it shows up late.
wait_cell.update_command_display(command_display);
self.request_redraw();
return;
@@ -965,7 +967,7 @@ impl ChatWidget {
fn on_exec_command_end(&mut self, ev: ExecCommandEndEvent) {
if is_unified_exec_source(ev.source) {
self.track_unified_exec_session_end(&ev);
self.track_unified_exec_process_end(&ev);
if !self.bottom_pane.is_task_running() {
return;
}
@@ -974,20 +976,20 @@ impl ChatWidget {
self.defer_or_handle(|q| q.push_exec_end(ev), |s| s.handle_exec_end_now(ev2));
}
fn track_unified_exec_session_begin(&mut self, ev: &ExecCommandBeginEvent) {
fn track_unified_exec_process_begin(&mut self, ev: &ExecCommandBeginEvent) {
if ev.source != ExecCommandSource::UnifiedExecStartup {
return;
}
let key = ev.process_id.clone().unwrap_or(ev.call_id.to_string());
let command_display = strip_bash_lc_and_escape(&ev.command);
if let Some(existing) = self
.unified_exec_sessions
.unified_exec_processes
.iter_mut()
.find(|session| session.key == key)
.find(|process| process.key == key)
{
existing.command_display = command_display;
} else {
self.unified_exec_sessions.push(UnifiedExecSessionSummary {
self.unified_exec_processes.push(UnifiedExecProcessSummary {
key,
command_display,
});
@@ -995,23 +997,23 @@ impl ChatWidget {
self.sync_unified_exec_footer();
}
fn track_unified_exec_session_end(&mut self, ev: &ExecCommandEndEvent) {
fn track_unified_exec_process_end(&mut self, ev: &ExecCommandEndEvent) {
let key = ev.process_id.clone().unwrap_or(ev.call_id.to_string());
let before = self.unified_exec_sessions.len();
self.unified_exec_sessions
.retain(|session| session.key != key);
if self.unified_exec_sessions.len() != before {
let before = self.unified_exec_processes.len();
self.unified_exec_processes
.retain(|process| process.key != key);
if self.unified_exec_processes.len() != before {
self.sync_unified_exec_footer();
}
}
fn sync_unified_exec_footer(&mut self) {
let sessions = self
.unified_exec_sessions
let processes = self
.unified_exec_processes
.iter()
.map(|session| session.command_display.clone())
.map(|process| process.command_display.clone())
.collect();
self.bottom_pane.set_unified_exec_sessions(sessions);
self.bottom_pane.set_unified_exec_processes(processes);
}
fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) {
@@ -1457,7 +1459,7 @@ impl ChatWidget {
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
task_complete_pending: false,
unified_exec_sessions: Vec::new(),
unified_exec_processes: Vec::new(),
mcp_startup_status: None,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
@@ -1543,7 +1545,7 @@ impl ChatWidget {
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
task_complete_pending: false,
unified_exec_sessions: Vec::new(),
unified_exec_processes: Vec::new(),
mcp_startup_status: None,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
@@ -1639,6 +1641,9 @@ impl ChatWidget {
InputResult::Command(cmd) => {
self.dispatch_command(cmd);
}
InputResult::CommandWithArgs(cmd, args) => {
self.dispatch_command_with_args(cmd, args);
}
InputResult::None => {}
}
}
@@ -1835,6 +1840,33 @@ impl ChatWidget {
}
}
fn dispatch_command_with_args(&mut self, cmd: SlashCommand, args: String) {
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.add_to_history(history_cell::new_error_event(message));
self.request_redraw();
return;
}
let trimmed = args.trim();
match cmd {
SlashCommand::Review if !trimmed.is_empty() => {
self.submit_op(Op::Review {
review_request: ReviewRequest {
target: ReviewTarget::Custom {
instructions: trimmed.to_string(),
},
user_facing_hint: None,
},
});
}
_ => self.dispatch_command(cmd),
}
}
pub(crate) fn handle_paste(&mut self, text: String) {
self.bottom_pane.handle_paste(text);
}
@@ -2112,6 +2144,7 @@ impl ChatWidget {
}
EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review),
EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()),
EventMsg::ThreadRolledBack(_) => {}
EventMsg::RawResponseItem(_)
| EventMsg::ItemStarted(_)
| EventMsg::ItemCompleted(_)
@@ -2262,12 +2295,12 @@ impl ChatWidget {
}
pub(crate) fn add_ps_output(&mut self) {
let sessions = self
.unified_exec_sessions
let processes = self
.unified_exec_processes
.iter()
.map(|session| session.command_display.clone())
.map(|process| process.command_display.clone())
.collect();
self.add_to_history(history_cell::new_unified_exec_sessions_output(sessions));
self.add_to_history(history_cell::new_unified_exec_processes_output(processes));
}
fn stop_rate_limit_poller(&mut self) {
@@ -2307,7 +2340,7 @@ impl ChatWidget {
let models = self.models_manager.try_list_models(&self.config).ok()?;
models
.iter()
.find(|preset| preset.model == NUDGE_MODEL_SLUG)
.find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG)
.cloned()
}
@@ -2423,6 +2456,14 @@ impl ChatWidget {
return;
}
};
self.open_model_popup_with_presets(presets);
}
pub(crate) fn open_model_popup_with_presets(&mut self, presets: Vec<ModelPreset>) {
let presets: Vec<ModelPreset> = presets
.into_iter()
.filter(|preset| preset.show_in_picker)
.collect();
let current_label = presets
.iter()
@@ -0,0 +1,16 @@
---
source: tui/src/chatwidget/tests.rs
expression: contents
---
Would you like to run the following command?
$ python - <<'PY'
print('hello')
PY
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1989
expression: popup
---
Select Model and Effort
Access legacy models by running codex -m <model_name> or in your config.toml
› 1. test-visible-model (current) test-visible-model description
Press enter to select reasoning effort, or esc to dismiss.
+99 -1
View File
@@ -11,6 +11,7 @@ use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::Constrained;
use codex_core::config::ConstraintError;
use codex_core::features::Feature;
use codex_core::models_manager::manager::ModelsManager;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
@@ -387,7 +388,7 @@ async fn make_chatwidget_manual(
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
task_complete_pending: false,
unified_exec_sessions: Vec::new(),
unified_exec_processes: Vec::new(),
mcp_startup_status: None,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
@@ -1961,6 +1962,41 @@ async fn model_selection_popup_snapshot() {
assert_snapshot!("model_selection_popup", popup);
}
#[tokio::test]
async fn model_picker_hides_show_in_picker_false_models_from_cache() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("test-visible-model")).await;
let preset = |slug: &str, show_in_picker: bool| ModelPreset {
id: slug.to_string(),
model: slug.to_string(),
display_name: slug.to_string(),
description: format!("{slug} description"),
default_reasoning_effort: ReasoningEffortConfig::Medium,
supported_reasoning_efforts: vec![ReasoningEffortPreset {
effort: ReasoningEffortConfig::Medium,
description: "medium".to_string(),
}],
is_default: false,
upgrade: None,
show_in_picker,
supported_in_api: true,
};
chat.open_model_popup_with_presets(vec![
preset("test-visible-model", true),
preset("test-hidden-model", false),
]);
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("model_picker_filters_hidden_models", popup);
assert!(
popup.contains("test-visible-model"),
"expected visible model to appear in picker:\n{popup}"
);
assert!(
!popup.contains("test-hidden-model"),
"expected hidden model to be excluded from picker:\n{popup}"
);
}
#[tokio::test]
async fn approvals_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -2465,6 +2501,48 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
Ok(())
}
// Snapshot test: approval modal with a proposed execpolicy prefix that is multi-line;
// we should not offer adding it to execpolicy.
#[tokio::test]
async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
-> anyhow::Result<()> {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.approval_policy.set(AskForApproval::OnRequest)?;
let script = "python - <<'PY'\nprint('hello')\nPY".to_string();
let command = vec!["bash".into(), "-lc".into(), script];
let ev = ExecApprovalRequestEvent {
call_id: "call-approve-cmd-multiline-trunc".into(),
turn_id: "turn-approve-cmd-multiline-trunc".into(),
command: command.clone(),
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-approve-multiline-trunc".into(),
msg: EventMsg::ExecApprovalRequest(ev),
});
let width = 100;
let height = chat.desired_height(width);
let mut terminal =
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw approval modal (multiline prefix)");
let contents = terminal.backend().vt100().screen().contents();
assert!(!contents.contains("don't ask again"));
assert_snapshot!(
"approval_modal_exec_multiline_prefix_no_execpolicy",
contents
);
Ok(())
}
// Snapshot test: patch approval modal
#[tokio::test]
async fn approval_modal_patch_snapshot() -> anyhow::Result<()> {
@@ -2580,6 +2658,26 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() {
let _ = drain_insert_history(&mut rx);
}
#[tokio::test]
async fn interrupt_clears_unified_exec_processes() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5");
begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6");
assert_eq!(chat.unified_exec_processes.len(), 2);
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent {
reason: TurnAbortReason::Interrupted,
}),
});
assert!(chat.unified_exec_processes.is_empty());
let _ = drain_insert_history(&mut rx);
}
// Snapshot test: ChatWidget at very small heights (idle)
// Ensures overall layout behaves when terminal height is extremely constrained.
#[tokio::test]
+21 -20
View File
@@ -517,29 +517,29 @@ pub(crate) fn new_unified_exec_wait_live(
}
#[derive(Debug)]
struct UnifiedExecSessionsCell {
sessions: Vec<String>,
struct UnifiedExecProcessesCell {
processes: Vec<String>,
}
impl UnifiedExecSessionsCell {
fn new(sessions: Vec<String>) -> Self {
Self { sessions }
impl UnifiedExecProcessesCell {
fn new(processes: Vec<String>) -> Self {
Self { processes }
}
}
impl HistoryCell for UnifiedExecSessionsCell {
impl HistoryCell for UnifiedExecProcessesCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if width == 0 {
return Vec::new();
}
let wrap_width = width as usize;
let max_sessions = 16usize;
let max_processes = 16usize;
let mut out: Vec<Line<'static>> = Vec::new();
out.push(vec!["Background terminals".bold()].into());
out.push("".into());
if self.sessions.is_empty() {
if self.processes.is_empty() {
out.push(" • No background terminals running.".italic().into());
return out;
}
@@ -549,8 +549,8 @@ impl HistoryCell for UnifiedExecSessionsCell {
let truncation_suffix = " [...]";
let truncation_suffix_width = UnicodeWidthStr::width(truncation_suffix);
let mut shown = 0usize;
for command in &self.sessions {
if shown >= max_sessions {
for command in &self.processes {
if shown >= max_processes {
break;
}
let (snippet, snippet_truncated) = {
@@ -590,7 +590,7 @@ impl HistoryCell for UnifiedExecSessionsCell {
shown += 1;
}
let remaining = self.sessions.len().saturating_sub(shown);
let remaining = self.processes.len().saturating_sub(shown);
if remaining > 0 {
let more_text = format!("... and {remaining} more running");
if wrap_width <= prefix_width {
@@ -610,9 +610,9 @@ impl HistoryCell for UnifiedExecSessionsCell {
}
}
pub(crate) fn new_unified_exec_sessions_output(sessions: Vec<String>) -> CompositeHistoryCell {
pub(crate) fn new_unified_exec_processes_output(processes: Vec<String>) -> CompositeHistoryCell {
let command = PlainHistoryCell::new(vec!["/ps".magenta().into()]);
let summary = UnifiedExecSessionsCell::new(sessions);
let summary = UnifiedExecProcessesCell::new(processes);
CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)])
}
@@ -816,11 +816,11 @@ pub(crate) fn padded_emoji(emoji: &str) -> String {
#[derive(Debug)]
struct TooltipHistoryCell {
tip: &'static str,
tip: String,
}
impl TooltipHistoryCell {
fn new(tip: &'static str) -> Self {
fn new(tip: String) -> Self {
Self { tip }
}
}
@@ -1819,14 +1819,14 @@ mod tests {
#[test]
fn ps_output_empty_snapshot() {
let cell = new_unified_exec_sessions_output(Vec::new());
let cell = new_unified_exec_processes_output(Vec::new());
let rendered = render_lines(&cell.display_lines(60)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn ps_output_multiline_snapshot() {
let cell = new_unified_exec_sessions_output(vec![
let cell = new_unified_exec_processes_output(vec![
"echo hello\nand then some extra text".to_string(),
"rg \"foo\" src".to_string(),
]);
@@ -1836,7 +1836,7 @@ mod tests {
#[test]
fn ps_output_long_command_snapshot() {
let cell = new_unified_exec_sessions_output(vec![String::from(
let cell = new_unified_exec_processes_output(vec![String::from(
"rg \"foo\" src --glob '**/*.rs' --max-count 1000 --no-ignore --hidden --follow --glob '!target/**'",
)]);
let rendered = render_lines(&cell.display_lines(36)).join("\n");
@@ -1845,8 +1845,9 @@ mod tests {
#[test]
fn ps_output_many_sessions_snapshot() {
let cell =
new_unified_exec_sessions_output((0..20).map(|idx| format!("command {idx}")).collect());
let cell = new_unified_exec_processes_output(
(0..20).map(|idx| format!("command {idx}")).collect(),
);
let rendered = render_lines(&cell.display_lines(32)).join("\n");
insta::assert_snapshot!(rendered);
}
+2 -1
View File
@@ -97,7 +97,6 @@ pub use markdown_render::render_markdown_text;
pub use public_widgets::composer_input::ComposerAction;
pub use public_widgets::composer_input::ComposerInput;
use std::io::Write as _;
// (tests access modules directly within the crate)
pub async fn run_main(
@@ -344,6 +343,8 @@ async fn run_ratatui_app(
) -> color_eyre::Result<AppExitInfo> {
color_eyre::install()?;
tooltips::announcement::prewarm();
// Forward panic reports through tracing so they appear in the UI status
// line, but do not swallow the default/color-eyre panic handler.
// Chain to the previous hook so users still get a rich panic report
@@ -0,0 +1,10 @@
---
source: tui/src/app.rs
assertion_line: 1579
expression: model_migration_copy_to_plain_text(&copy)
---
Codex just got an upgrade. Introducing gpt-5.2-codex.
Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work. Learn more about gpt-5.2-codex at https://openai.com/index/introducing-gpt-5-2-codex
You can continue using gpt-5.1-codex if you prefer.
+255 -9
View File
@@ -2,15 +2,10 @@ use codex_core::features::FEATURES;
use lazy_static::lazy_static;
use rand::Rng;
const ANNOUNCEMENT_TIP_URL: &str =
"https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml";
const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt");
fn beta_tooltips() -> Vec<&'static str> {
FEATURES
.iter()
.filter_map(|spec| spec.stage.beta_announcement())
.collect()
}
lazy_static! {
static ref TOOLTIPS: Vec<&'static str> = RAW_TOOLTIPS
.lines()
@@ -25,9 +20,20 @@ lazy_static! {
};
}
pub(crate) fn random_tooltip() -> Option<&'static str> {
fn beta_tooltips() -> Vec<&'static str> {
FEATURES
.iter()
.filter_map(|spec| spec.stage.beta_announcement())
.collect()
}
/// Pick a random tooltip to show to the user when starting Codex.
pub(crate) fn random_tooltip() -> Option<String> {
if let Some(announcement) = announcement::fetch_announcement_tip() {
return Some(announcement);
}
let mut rng = rand::rng();
pick_tooltip(&mut rng)
pick_tooltip(&mut rng).map(str::to_string)
}
fn pick_tooltip<R: Rng + ?Sized>(rng: &mut R) -> Option<&'static str> {
@@ -40,9 +46,149 @@ fn pick_tooltip<R: Rng + ?Sized>(rng: &mut R) -> Option<&'static str> {
}
}
pub(crate) mod announcement {
use crate::tooltips::ANNOUNCEMENT_TIP_URL;
use crate::version::CODEX_CLI_VERSION;
use chrono::NaiveDate;
use chrono::Utc;
use regex_lite::Regex;
use serde::Deserialize;
use std::sync::OnceLock;
use std::thread;
use std::time::Duration;
static ANNOUNCEMENT_TIP: OnceLock<Option<String>> = OnceLock::new();
/// Prewarm the cache of the announcement tip.
pub(crate) fn prewarm() {
let _ = thread::spawn(|| ANNOUNCEMENT_TIP.get_or_init(init_announcement_tip_in_thread));
}
/// Fetch the announcement tip, return None if the prewarm is not done yet.
pub(crate) fn fetch_announcement_tip() -> Option<String> {
ANNOUNCEMENT_TIP
.get()
.cloned()
.flatten()
.and_then(|raw| parse_announcement_tip_toml(&raw))
}
#[derive(Debug, Deserialize)]
struct AnnouncementTipRaw {
content: String,
from_date: Option<String>,
to_date: Option<String>,
version_regex: Option<String>,
target_app: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AnnouncementTipDocument {
announcements: Vec<AnnouncementTipRaw>,
}
#[derive(Debug)]
struct AnnouncementTip {
content: String,
from_date: Option<NaiveDate>,
to_date: Option<NaiveDate>,
version_regex: Option<Regex>,
target_app: String,
}
fn init_announcement_tip_in_thread() -> Option<String> {
thread::spawn(blocking_init_announcement_tip)
.join()
.ok()
.flatten()
}
fn blocking_init_announcement_tip() -> Option<String> {
let response = reqwest::blocking::Client::new()
.get(ANNOUNCEMENT_TIP_URL)
.timeout(Duration::from_millis(2000))
.send()
.ok()?;
response.error_for_status().ok()?.text().ok()
}
pub(crate) fn parse_announcement_tip_toml(text: &str) -> Option<String> {
let announcements = toml::from_str::<AnnouncementTipDocument>(text)
.map(|doc| doc.announcements)
.or_else(|_| toml::from_str::<Vec<AnnouncementTipRaw>>(text))
.ok()?;
let mut latest_match = None;
let today = Utc::now().date_naive();
for raw in announcements {
let Some(tip) = AnnouncementTip::from_raw(raw) else {
continue;
};
if tip.version_matches(CODEX_CLI_VERSION)
&& tip.date_matches(today)
&& tip.target_app == "cli"
{
latest_match = Some(tip.content);
}
}
latest_match
}
impl AnnouncementTip {
fn from_raw(raw: AnnouncementTipRaw) -> Option<Self> {
let content = raw.content.trim();
if content.is_empty() {
return None;
}
let from_date = match raw.from_date {
Some(date) => Some(NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok()?),
None => None,
};
let to_date = match raw.to_date {
Some(date) => Some(NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok()?),
None => None,
};
let version_regex = match raw.version_regex {
Some(pattern) => Some(Regex::new(&pattern).ok()?),
None => None,
};
Some(Self {
content: content.to_string(),
from_date,
to_date,
version_regex,
target_app: raw.target_app.unwrap_or("cli".to_string()).to_lowercase(),
})
}
fn version_matches(&self, version: &str) -> bool {
self.version_regex
.as_ref()
.is_none_or(|regex| regex.is_match(version))
}
fn date_matches(&self, today: NaiveDate) -> bool {
if let Some(from) = self.from_date
&& today < from
{
return false;
}
if let Some(to) = self.to_date
&& today >= to
{
return false;
}
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tooltips::announcement::parse_announcement_tip_toml;
use rand::SeedableRng;
use rand::rngs::StdRng;
@@ -62,4 +208,104 @@ mod tests {
let mut rng = StdRng::seed_from_u64(7);
assert_eq!(expected, pick_tooltip(&mut rng));
}
#[test]
fn announcement_tip_toml_picks_last_matching() {
let toml = r#"
[[announcements]]
content = "first"
from_date = "2000-01-01"
[[announcements]]
content = "latest match"
version_regex = ".*"
target_app = "cli"
[[announcements]]
content = "should not match"
to_date = "2000-01-01"
"#;
assert_eq!(
Some("latest match".to_string()),
parse_announcement_tip_toml(toml)
);
let toml = r#"
[[announcements]]
content = "first"
from_date = "2000-01-01"
target_app = "cli"
[[announcements]]
content = "latest match"
version_regex = ".*"
[[announcements]]
content = "should not match"
to_date = "2000-01-01"
"#;
assert_eq!(
Some("latest match".to_string()),
parse_announcement_tip_toml(toml)
);
}
#[test]
fn announcement_tip_toml_picks_no_match() {
let toml = r#"
[[announcements]]
content = "first"
from_date = "2000-01-01"
to_date = "2000-01-05"
[[announcements]]
content = "latest match"
version_regex = "invalid_version_name"
[[announcements]]
content = "should not match either "
target_app = "vsce"
"#;
assert_eq!(None, parse_announcement_tip_toml(toml));
}
#[test]
fn announcement_tip_toml_bad_deserialization() {
let toml = r#"
[[announcements]]
content = 123
from_date = "2000-01-01"
"#;
assert_eq!(None, parse_announcement_tip_toml(toml));
}
#[test]
fn announcement_tip_toml_parse_comments() {
let toml = r#"
# Example announcement tips for Codex TUI.
# Each [[announcements]] entry is evaluated in order; the last matching one is shown.
# Dates are UTC, formatted as YYYY-MM-DD. The from_date is inclusive and the to_date is exclusive.
# version_regex matches against the CLI version (env!("CARGO_PKG_VERSION")); omit to apply to all versions.
# target_app specify which app should display the announcement (cli, vsce, ...).
[[announcements]]
content = "Welcome to Codex! Check out the new onboarding flow."
from_date = "2024-10-01"
to_date = "2024-10-15"
target_app = "cli"
version_regex = "^0\\.0\\.0$"
[[announcements]]
content = "This is a test announcement"
"#;
assert_eq!(
Some("This is a test announcement".to_string()),
parse_announcement_tip_toml(toml)
);
}
}
+74
View File
@@ -2088,17 +2088,20 @@ mod tests {
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::ConversationManager;
use codex_core::config::ConfigBuilder;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::SessionConfiguredEvent;
use codex_protocol::ConversationId;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::prelude::Line;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tempfile::tempdir;
async fn make_test_app() -> App {
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
@@ -2208,6 +2211,24 @@ mod tests {
codex_core::models_manager::model_presets::all_model_presets().clone()
}
fn model_migration_copy_to_plain_text(
copy: &crate::model_migration::ModelMigrationCopy,
) -> String {
let mut s = String::new();
for span in &copy.heading {
s.push_str(&span.content);
}
s.push('\n');
s.push('\n');
for line in &copy.content {
for span in &line.spans {
s.push_str(&span.content);
}
s.push('\n');
}
s
}
#[tokio::test]
async fn model_migration_prompt_only_shows_for_deprecated_models() {
let seen = BTreeMap::new();
@@ -2243,6 +2264,59 @@ mod tests {
));
}
#[tokio::test]
async fn model_migration_prompt_shows_for_hidden_model() {
let codex_home = tempdir().expect("temp codex home");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config");
let available_models = all_model_presets();
let current = available_models
.iter()
.find(|preset| preset.model == "gpt-5.1-codex")
.cloned()
.expect("gpt-5.1-codex preset present");
assert!(
!current.show_in_picker,
"expected gpt-5.1-codex to be hidden from picker for this test"
);
let upgrade = current.upgrade.as_ref().expect("upgrade configured");
assert!(
should_show_model_migration_prompt(
&current.model,
&upgrade.id,
&config.notices.model_migrations,
&available_models,
),
"expected migration prompt to be eligible for hidden model"
);
let target = available_models
.iter()
.find(|preset| preset.model == upgrade.id)
.cloned()
.expect("upgrade target present");
let target_description =
(!target.description.is_empty()).then(|| target.description.clone());
let can_opt_out = true;
let copy = migration_copy_for_models(
&current.model,
&upgrade.id,
target.display_name,
target_description,
can_opt_out,
);
assert_snapshot!(
"model_migration_prompt_shows_for_hidden_model",
model_migration_copy_to_plain_text(&copy)
);
}
#[tokio::test]
async fn transcript_selection_copy_includes_offscreen_lines() {
let mut app = make_test_app().await;
@@ -461,9 +461,13 @@ fn exec_options(
.chain(
proposed_execpolicy_amendment
.filter(|_| features.enabled(Feature::ExecPolicy))
.map(|prefix| {
.and_then(|prefix| {
let rendered_prefix = strip_bash_lc_and_escape(prefix.command());
ApprovalOption {
if rendered_prefix.contains('\n') || rendered_prefix.contains('\r') {
return None;
}
Some(ApprovalOption {
label: format!(
"Yes, and don't ask again for commands that start with `{rendered_prefix}`"
),
@@ -474,7 +478,7 @@ fn exec_options(
),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))],
}
})
}),
)
.chain([ApprovalOption {
@@ -76,6 +76,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
pub enum InputResult {
Submitted(String),
Command(SlashCommand),
CommandWithArgs(SlashCommand, String),
None,
}
@@ -1191,6 +1192,18 @@ impl ChatComposer {
}
}
if !input_starts_with_space
&& let Some((name, rest)) = parse_slash_name(&text)
&& !rest.is_empty()
&& !name.contains('/')
&& let Some((_n, cmd)) = built_in_slash_commands()
.into_iter()
.find(|(command_name, _)| *command_name == name)
&& cmd == SlashCommand::Review
{
return (InputResult::CommandWithArgs(cmd, rest.to_string()), true);
}
let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) {
Ok(expanded) => expanded,
Err(err) => {
@@ -2754,6 +2767,9 @@ mod tests {
InputResult::Command(cmd) => {
assert_eq!(cmd.command(), "init");
}
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/init'")
}
InputResult::Submitted(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
@@ -2762,6 +2778,44 @@ mod tests {
assert!(composer.textarea.is_empty(), "composer should be cleared");
}
#[test]
fn slash_review_with_args_dispatches_command_with_args() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']);
type_chars_humanlike(&mut composer, &['f', 'i', 'x', ' ', 't', 'h', 'i', 's']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::CommandWithArgs(cmd, args) => {
assert_eq!(cmd, SlashCommand::Review);
assert_eq!(args, "fix this");
}
InputResult::Command(cmd) => {
panic!("expected args for '/review', got bare command: {cmd:?}")
}
InputResult::Submitted(text) => {
panic!("expected command dispatch, got literal submit: {text}")
}
InputResult::None => panic!("expected CommandWithArgs result for '/review'"),
}
assert!(composer.textarea.is_empty(), "composer should be cleared");
}
#[test]
fn extract_args_supports_quoted_paths_single_arg() {
let args = extract_positional_args_for_prompt_line(
@@ -2827,6 +2881,9 @@ mod tests {
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"),
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/diff'")
}
InputResult::Submitted(text) => {
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
}
@@ -2860,6 +2917,9 @@ mod tests {
InputResult::Command(cmd) => {
assert_eq!(cmd.command(), "mention");
}
InputResult::CommandWithArgs(_, _) => {
panic!("expected command dispatch without args for '/mention'")
}
InputResult::Submitted(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
+40 -1
View File
@@ -1500,6 +1500,9 @@ impl ChatWidget {
InputResult::Command(cmd) => {
self.dispatch_command(cmd);
}
InputResult::CommandWithArgs(cmd, args) => {
self.dispatch_command_with_args(cmd, args);
}
InputResult::None => {}
}
}
@@ -1665,6 +1668,33 @@ impl ChatWidget {
}
}
fn dispatch_command_with_args(&mut self, cmd: SlashCommand, args: String) {
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.add_to_history(history_cell::new_error_event(message));
self.request_redraw();
return;
}
let trimmed = args.trim();
match cmd {
SlashCommand::Review if !trimmed.is_empty() => {
self.submit_op(Op::Review {
review_request: ReviewRequest {
target: ReviewTarget::Custom {
instructions: trimmed.to_string(),
},
user_facing_hint: None,
},
});
}
_ => self.dispatch_command(cmd),
}
}
pub(crate) fn handle_paste(&mut self, text: String) {
self.bottom_pane.handle_paste(text);
}
@@ -1921,6 +1951,7 @@ impl ChatWidget {
EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review),
EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()),
EventMsg::RawResponseItem(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::ItemStarted(_)
| EventMsg::ItemCompleted(_)
| EventMsg::AgentMessageContentDelta(_)
@@ -2105,7 +2136,7 @@ impl ChatWidget {
let models = self.models_manager.try_list_models(&self.config).ok()?;
models
.iter()
.find(|preset| preset.model == NUDGE_MODEL_SLUG)
.find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG)
.cloned()
}
@@ -2221,6 +2252,14 @@ impl ChatWidget {
return;
}
};
self.open_model_popup_with_presets(presets);
}
pub(crate) fn open_model_popup_with_presets(&mut self, presets: Vec<ModelPreset>) {
let presets: Vec<ModelPreset> = presets
.into_iter()
.filter(|preset| preset.show_in_picker)
.collect();
let current_label = presets
.iter()
@@ -0,0 +1,16 @@
---
source: tui2/src/chatwidget/tests.rs
expression: contents
---
Would you like to run the following command?
$ python - <<'PY'
print('hello')
PY
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
@@ -0,0 +1,11 @@
---
source: tui2/src/chatwidget/tests.rs
assertion_line: 1758
expression: popup
---
Select Model and Effort
Access legacy models by running codex -m <model_name> or in your config.toml
› 1. test-visible-model (current) test-visible-model description
Press enter to select reasoning effort, or esc to dismiss.
+74
View File
@@ -1731,6 +1731,41 @@ async fn model_selection_popup_snapshot() {
assert_snapshot!("model_selection_popup", popup);
}
#[tokio::test]
async fn model_picker_hides_show_in_picker_false_models_from_cache() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("test-visible-model")).await;
let preset = |slug: &str, show_in_picker: bool| ModelPreset {
id: slug.to_string(),
model: slug.to_string(),
display_name: slug.to_string(),
description: format!("{slug} description"),
default_reasoning_effort: ReasoningEffortConfig::Medium,
supported_reasoning_efforts: vec![ReasoningEffortPreset {
effort: ReasoningEffortConfig::Medium,
description: "medium".to_string(),
}],
is_default: false,
upgrade: None,
show_in_picker,
supported_in_api: true,
};
chat.open_model_popup_with_presets(vec![
preset("test-visible-model", true),
preset("test-hidden-model", false),
]);
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("model_picker_filters_hidden_models", popup);
assert!(
popup.contains("test-visible-model"),
"expected visible model to appear in picker:\n{popup}"
);
assert!(
!popup.contains("test-hidden-model"),
"expected hidden model to be excluded from picker:\n{popup}"
);
}
#[tokio::test]
async fn approvals_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -2123,6 +2158,45 @@ async fn approval_modal_exec_without_reason_snapshot() {
);
}
// Snapshot test: approval modal with a proposed execpolicy prefix that is multi-line;
// we should not offer adding it to execpolicy.
#[tokio::test]
async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
let script = "python - <<'PY'\nprint('hello')\nPY".to_string();
let command = vec!["bash".into(), "-lc".into(), script];
let ev = ExecApprovalRequestEvent {
call_id: "call-approve-cmd-multiline-trunc".into(),
turn_id: "turn-approve-cmd-multiline-trunc".into(),
command: command.clone(),
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-approve-multiline-trunc".into(),
msg: EventMsg::ExecApprovalRequest(ev),
});
let width = 100;
let height = chat.desired_height(width);
let mut terminal =
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw approval modal (multiline prefix)");
let contents = terminal.backend().vt100().screen().contents();
assert!(!contents.contains("don't ask again"));
assert_snapshot!(
"approval_modal_exec_multiline_prefix_no_execpolicy",
contents
);
}
// Snapshot test: patch approval modal
#[tokio::test]
async fn approval_modal_patch_snapshot() {
+2 -2
View File
@@ -817,11 +817,11 @@ pub(crate) fn padded_emoji(emoji: &str) -> String {
#[derive(Debug)]
struct TooltipHistoryCell {
tip: &'static str,
tip: String,
}
impl TooltipHistoryCell {
fn new(tip: &'static str) -> Self {
fn new(tip: String) -> Self {
Self { tip }
}
}
+2
View File
@@ -361,6 +361,8 @@ async fn run_ratatui_app(
) -> color_eyre::Result<AppExitInfo> {
color_eyre::install()?;
tooltips::announcement::prewarm();
// Forward panic reports through tracing so they appear in the UI status
// line, but do not swallow the default/color-eyre panic handler.
// Chain to the previous hook so users still get a rich panic report
@@ -0,0 +1,12 @@
---
source: tui2/src/app.rs
assertion_line: 2314
expression: model_migration_copy_to_plain_text(&copy)
---
Try gpt-5.2-codex
We recommend switching from gpt-5.1-codex to gpt-5.2-codex.
Latest frontier agentic coding model.
You can continue using gpt-5.1-codex if you prefer.
@@ -0,0 +1,7 @@
---
source: tui2/src/transcript_copy_ui.rs
expression: rendered
---
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxx ⧉ copy ctrl + shift + c xxxxx
+266 -4
View File
@@ -1,6 +1,9 @@
use codex_core::features::FEATURES;
use lazy_static::lazy_static;
use rand::Rng;
const ANNOUNCEMENT_TIP_URL: &str =
"https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml";
const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt");
lazy_static! {
@@ -9,24 +12,183 @@ lazy_static! {
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.collect();
static ref ALL_TOOLTIPS: Vec<&'static str> = {
let mut tips = Vec::new();
tips.extend(TOOLTIPS.iter().copied());
tips.extend(beta_tooltips());
tips
};
}
pub(crate) fn random_tooltip() -> Option<&'static str> {
fn beta_tooltips() -> Vec<&'static str> {
FEATURES
.iter()
.filter_map(|spec| spec.stage.beta_announcement())
.collect()
}
/// Pick a random tooltip to show to the user when starting Codex.
pub(crate) fn random_tooltip() -> Option<String> {
if let Some(announcement) = announcement::fetch_announcement_tip() {
return Some(announcement);
}
let mut rng = rand::rng();
pick_tooltip(&mut rng)
pick_tooltip(&mut rng).map(str::to_string)
}
fn pick_tooltip<R: Rng + ?Sized>(rng: &mut R) -> Option<&'static str> {
if TOOLTIPS.is_empty() {
if ALL_TOOLTIPS.is_empty() {
None
} else {
TOOLTIPS.get(rng.random_range(0..TOOLTIPS.len())).copied()
ALL_TOOLTIPS
.get(rng.random_range(0..ALL_TOOLTIPS.len()))
.copied()
}
}
pub(crate) mod announcement {
use crate::tooltips::ANNOUNCEMENT_TIP_URL;
use crate::version::CODEX_CLI_VERSION;
use chrono::NaiveDate;
use chrono::Utc;
use regex_lite::Regex;
use serde::Deserialize;
use std::sync::OnceLock;
use std::thread;
use std::time::Duration;
static ANNOUNCEMENT_TIP: OnceLock<Option<String>> = OnceLock::new();
/// Prewarm the cache of the announcement tip.
pub(crate) fn prewarm() {
let _ = thread::spawn(|| ANNOUNCEMENT_TIP.get_or_init(init_announcement_tip_in_thread));
}
/// Fetch the announcement tip, return None if the prewarm is not done yet.
pub(crate) fn fetch_announcement_tip() -> Option<String> {
ANNOUNCEMENT_TIP
.get()
.cloned()
.flatten()
.and_then(|raw| parse_announcement_tip_toml(&raw))
}
#[derive(Debug, Deserialize)]
struct AnnouncementTipRaw {
content: String,
from_date: Option<String>,
to_date: Option<String>,
version_regex: Option<String>,
target_app: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AnnouncementTipDocument {
announcements: Vec<AnnouncementTipRaw>,
}
#[derive(Debug)]
struct AnnouncementTip {
content: String,
from_date: Option<NaiveDate>,
to_date: Option<NaiveDate>,
version_regex: Option<Regex>,
target_app: String,
}
fn init_announcement_tip_in_thread() -> Option<String> {
thread::spawn(blocking_init_announcement_tip)
.join()
.ok()
.flatten()
}
fn blocking_init_announcement_tip() -> Option<String> {
let response = reqwest::blocking::Client::new()
.get(ANNOUNCEMENT_TIP_URL)
.timeout(Duration::from_millis(2000))
.send()
.ok()?;
response.error_for_status().ok()?.text().ok()
}
pub(crate) fn parse_announcement_tip_toml(text: &str) -> Option<String> {
let announcements = toml::from_str::<AnnouncementTipDocument>(text)
.map(|doc| doc.announcements)
.or_else(|_| toml::from_str::<Vec<AnnouncementTipRaw>>(text))
.ok()?;
let mut latest_match = None;
let today = Utc::now().date_naive();
for raw in announcements {
let Some(tip) = AnnouncementTip::from_raw(raw) else {
continue;
};
if tip.version_matches(CODEX_CLI_VERSION)
&& tip.date_matches(today)
&& tip.target_app == "cli"
{
latest_match = Some(tip.content);
}
}
latest_match
}
impl AnnouncementTip {
fn from_raw(raw: AnnouncementTipRaw) -> Option<Self> {
let content = raw.content.trim();
if content.is_empty() {
return None;
}
let from_date = match raw.from_date {
Some(date) => Some(NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok()?),
None => None,
};
let to_date = match raw.to_date {
Some(date) => Some(NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok()?),
None => None,
};
let version_regex = match raw.version_regex {
Some(pattern) => Some(Regex::new(&pattern).ok()?),
None => None,
};
Some(Self {
content: content.to_string(),
from_date,
to_date,
version_regex,
target_app: raw.target_app.unwrap_or("cli".to_string()).to_lowercase(),
})
}
fn version_matches(&self, version: &str) -> bool {
self.version_regex
.as_ref()
.is_none_or(|regex| regex.is_match(version))
}
fn date_matches(&self, today: NaiveDate) -> bool {
if let Some(from) = self.from_date
&& today < from
{
return false;
}
if let Some(to) = self.to_date
&& today >= to
{
return false;
}
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tooltips::announcement::parse_announcement_tip_toml;
use rand::SeedableRng;
use rand::rngs::StdRng;
@@ -46,4 +208,104 @@ mod tests {
let mut rng = StdRng::seed_from_u64(7);
assert_eq!(expected, pick_tooltip(&mut rng));
}
#[test]
fn announcement_tip_toml_picks_last_matching() {
let toml = r#"
[[announcements]]
content = "first"
from_date = "2000-01-01"
[[announcements]]
content = "latest match"
version_regex = ".*"
target_app = "cli"
[[announcements]]
content = "should not match"
to_date = "2000-01-01"
"#;
assert_eq!(
Some("latest match".to_string()),
parse_announcement_tip_toml(toml)
);
let toml = r#"
[[announcements]]
content = "first"
from_date = "2000-01-01"
target_app = "cli"
[[announcements]]
content = "latest match"
version_regex = ".*"
[[announcements]]
content = "should not match"
to_date = "2000-01-01"
"#;
assert_eq!(
Some("latest match".to_string()),
parse_announcement_tip_toml(toml)
);
}
#[test]
fn announcement_tip_toml_picks_no_match() {
let toml = r#"
[[announcements]]
content = "first"
from_date = "2000-01-01"
to_date = "2000-01-05"
[[announcements]]
content = "latest match"
version_regex = "invalid_version_name"
[[announcements]]
content = "should not match either "
target_app = "vsce"
"#;
assert_eq!(None, parse_announcement_tip_toml(toml));
}
#[test]
fn announcement_tip_toml_bad_deserialization() {
let toml = r#"
[[announcements]]
content = 123
from_date = "2000-01-01"
"#;
assert_eq!(None, parse_announcement_tip_toml(toml));
}
#[test]
fn announcement_tip_toml_parse_comments() {
let toml = r#"
# Example announcement tips for Codex TUI.
# Each [[announcements]] entry is evaluated in order; the last matching one is shown.
# Dates are UTC, formatted as YYYY-MM-DD. The from_date is inclusive and the to_date is exclusive.
# version_regex matches against the CLI version (env!("CARGO_PKG_VERSION")); omit to apply to all versions.
# target_app specify which app should display the announcement (cli, vsce, ...).
[[announcements]]
content = "Welcome to Codex! Check out the new onboarding flow."
from_date = "2024-10-01"
to_date = "2024-10-15"
target_app = "cli"
version_regex = "^0\\.0\\.0$"
[[announcements]]
content = "This is a test announcement"
"#;
assert_eq!(
Some("This is a test announcement".to_string()),
parse_announcement_tip_toml(toml)
);
}
}
+25
View File
@@ -287,6 +287,13 @@ impl TranscriptCopyUi {
let icon_style = base_style.add_modifier(Modifier::BOLD).fg(Color::LightCyan);
let bold_style = base_style.add_modifier(Modifier::BOLD);
// Clear background so underlying text doesn't bleed through the pill.
for position in pill_area.positions() {
let cell = &mut buf[position];
cell.set_symbol(" ");
cell.set_style(base_style);
}
let mut spans: Vec<Span<'static>> = vec![
Span::styled(" ", base_style),
Span::styled("⧉", icon_style),
@@ -305,6 +312,7 @@ impl TranscriptCopyUi {
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use ratatui::buffer::Buffer;
fn buf_to_string(buf: &Buffer, area: Rect) -> String {
@@ -359,4 +367,21 @@ mod tests {
assert_eq!(rect.y, 1);
assert!(ui.hit_test(rect.x, rect.y));
}
#[test]
fn copy_pill_clears_background() {
let area = Rect::new(0, 0, 40, 3);
let mut buf = Buffer::empty(area);
for y in 0..area.height {
for x in 0..area.width {
buf[(x, y)].set_symbol("x");
}
}
let mut ui = TranscriptCopyUi::new_with_shortcut(CopySelectionShortcut::CtrlShiftC);
ui.render_copy_pill(area, &mut buf, (1, 2), (1, 6), 0, 3);
let rendered = buf_to_string(&buf, area);
assert_snapshot!("copy_pill_clears_background", rendered);
}
}
+150 -18
View File
@@ -192,28 +192,130 @@ fn render_command_for_log(cwd: &Path, git_cfg: &[String], args: &[String]) -> St
/// Collect every path referenced by the diff headers inside `diff --git` sections.
pub fn extract_paths_from_patch(diff_text: &str) -> Vec<String> {
static RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?m)^diff --git a/(.*?) b/(.*)$")
.unwrap_or_else(|e| panic!("invalid regex: {e}"))
});
let mut set = std::collections::BTreeSet::new();
for caps in RE.captures_iter(diff_text) {
if let Some(a) = caps.get(1).map(|m| m.as_str())
&& a != "/dev/null"
&& !a.trim().is_empty()
{
set.insert(a.to_string());
for raw_line in diff_text.lines() {
let line = raw_line.trim();
let Some(rest) = line.strip_prefix("diff --git ") else {
continue;
};
let Some((a, b)) = parse_diff_git_paths(rest) else {
continue;
};
if let Some(a) = normalize_diff_path(&a, "a/") {
set.insert(a);
}
if let Some(b) = caps.get(2).map(|m| m.as_str())
&& b != "/dev/null"
&& !b.trim().is_empty()
{
set.insert(b.to_string());
if let Some(b) = normalize_diff_path(&b, "b/") {
set.insert(b);
}
}
set.into_iter().collect()
}
fn parse_diff_git_paths(line: &str) -> Option<(String, String)> {
let mut chars = line.chars().peekable();
let first = read_diff_git_token(&mut chars)?;
let second = read_diff_git_token(&mut chars)?;
Some((first, second))
}
fn read_diff_git_token(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Option<String> {
while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
chars.next();
}
let quote = match chars.peek().copied() {
Some('"') | Some('\'') => chars.next(),
_ => None,
};
let mut out = String::new();
while let Some(c) = chars.next() {
if let Some(q) = quote {
if c == q {
break;
}
if c == '\\' {
out.push('\\');
if let Some(next) = chars.next() {
out.push(next);
}
continue;
}
} else if c.is_whitespace() {
break;
}
out.push(c);
}
if out.is_empty() && quote.is_none() {
None
} else {
Some(match quote {
Some(_) => unescape_c_string(&out),
None => out,
})
}
}
fn normalize_diff_path(raw: &str, prefix: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if trimmed == "/dev/null" || trimmed == format!("{prefix}dev/null") {
return None;
}
let trimmed = trimmed.strip_prefix(prefix).unwrap_or(trimmed);
if trimmed.is_empty() {
return None;
}
Some(trimmed.to_string())
}
fn unescape_c_string(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
let Some(next) = chars.next() else {
out.push('\\');
break;
};
match next {
'n' => out.push('\n'),
'r' => out.push('\r'),
't' => out.push('\t'),
'b' => out.push('\u{0008}'),
'f' => out.push('\u{000C}'),
'a' => out.push('\u{0007}'),
'v' => out.push('\u{000B}'),
'\\' => out.push('\\'),
'"' => out.push('"'),
'\'' => out.push('\''),
'0'..='7' => {
let mut value = next.to_digit(8).unwrap_or(0);
for _ in 0..2 {
match chars.peek() {
Some('0'..='7') => {
if let Some(digit) = chars.next() {
value = value * 8 + digit.to_digit(8).unwrap_or(0);
} else {
break;
}
}
_ => break,
}
}
if let Some(ch) = std::char::from_u32(value) {
out.push(ch);
}
}
other => out.push(other),
}
}
out
}
/// Stage only the files that actually exist on disk for the given diff.
pub fn stage_paths(git_root: &Path, diff: &str) -> io::Result<()> {
let paths = extract_paths_from_patch(diff);
@@ -266,12 +368,12 @@ pub fn parse_git_apply_output(
let first = trimmed.chars().next().unwrap_or('\0');
let last = trimmed.chars().last().unwrap_or('\0');
let unquoted = if (first == '"' || first == '\'') && last == first && trimmed.len() >= 2 {
&trimmed[1..trimmed.len() - 1]
unescape_c_string(&trimmed[1..trimmed.len() - 1])
} else {
trimmed
trimmed.to_string()
};
if !unquoted.is_empty() {
set.insert(unquoted.to_string());
set.insert(unquoted);
}
}
@@ -531,6 +633,36 @@ mod tests {
.replace("\r\n", "\n")
}
#[test]
fn extract_paths_handles_quoted_headers() {
let diff = "diff --git \"a/hello world.txt\" \"b/hello world.txt\"\nnew file mode 100644\n--- /dev/null\n+++ b/hello world.txt\n@@ -0,0 +1 @@\n+hi\n";
let paths = extract_paths_from_patch(diff);
assert_eq!(paths, vec!["hello world.txt".to_string()]);
}
#[test]
fn extract_paths_ignores_dev_null_header() {
let diff = "diff --git a/dev/null b/ok.txt\nnew file mode 100644\n--- /dev/null\n+++ b/ok.txt\n@@ -0,0 +1 @@\n+hi\n";
let paths = extract_paths_from_patch(diff);
assert_eq!(paths, vec!["ok.txt".to_string()]);
}
#[test]
fn extract_paths_unescapes_c_style_in_quoted_headers() {
let diff = "diff --git \"a/hello\\tworld.txt\" \"b/hello\\tworld.txt\"\nnew file mode 100644\n--- /dev/null\n+++ b/hello\tworld.txt\n@@ -0,0 +1 @@\n+hi\n";
let paths = extract_paths_from_patch(diff);
assert_eq!(paths, vec!["hello\tworld.txt".to_string()]);
}
#[test]
fn parse_output_unescapes_quoted_paths() {
let stderr = "error: patch failed: \"hello\\tworld.txt\":1\n";
let (applied, skipped, conflicted) = parse_git_apply_output("", stderr);
assert_eq!(applied, Vec::<String>::new());
assert_eq!(conflicted, Vec::<String>::new());
assert_eq!(skipped, vec!["hello\tworld.txt".to_string()]);
}
#[test]
fn apply_add_success() {
let _g = env_lock().lock().unwrap();
+1 -1
View File
@@ -25,7 +25,7 @@ app-server-test-client *args:
# format code
fmt:
cargo fmt -- --config imports_granularity=Item
cargo fmt -- --config imports_granularity=Item 2>/dev/null
fix *args:
cargo clippy --fix --all-features --tests --allow-dirty "$@"