Merge origin/main into jif/add-session-id

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
jif-oai
2026-05-01 09:18:58 +01:00
co-authored by Codex
377 changed files with 24865 additions and 16204 deletions
+410 -2
View File
@@ -373,6 +373,9 @@
"browser_use": {
"type": "boolean"
},
"browser_use_external": {
"type": "boolean"
},
"child_agents_md": {
"type": "boolean"
},
@@ -445,6 +448,9 @@
"guardian_approval": {
"type": "boolean"
},
"hooks": {
"type": "boolean"
},
"image_detail_original": {
"type": "boolean"
},
@@ -2411,7 +2417,8 @@
"open_transcript": null,
"queue": null,
"submit": null,
"toggle_shortcuts": null
"toggle_shortcuts": null,
"toggle_vim_mode": null
},
"list": {
"accept": null,
@@ -2430,6 +2437,44 @@
"page_up": null,
"scroll_down": null,
"scroll_up": null
},
"vim_normal": {
"append_after_cursor": null,
"append_line_end": null,
"cancel_operator": null,
"delete_char": null,
"delete_to_line_end": null,
"enter_insert": null,
"insert_line_start": null,
"move_down": null,
"move_left": null,
"move_line_end": null,
"move_line_start": null,
"move_right": null,
"move_up": null,
"move_word_backward": null,
"move_word_end": null,
"move_word_forward": null,
"open_line_above": null,
"open_line_below": null,
"paste_after": null,
"start_delete_operator": null,
"start_yank_operator": null,
"yank_line": null
},
"vim_operator": {
"cancel": null,
"delete_line": null,
"motion_down": null,
"motion_left": null,
"motion_line_end": null,
"motion_line_start": null,
"motion_right": null,
"motion_up": null,
"motion_word_backward": null,
"motion_word_end": null,
"motion_word_forward": null,
"yank_line": null
}
},
"description": "Keybinding overrides for the TUI.\n\nThis supports rebinding selected actions globally and by context. Context bindings take precedence over `global` bindings."
@@ -2483,6 +2528,11 @@
},
"type": "array"
},
"status_line_use_colors": {
"default": true,
"description": "Color status line items with colors derived from the active syntax theme. Defaults to `true`.",
"type": "boolean"
},
"terminal_resize_reflow_max_rows": {
"default": null,
"description": "Trim terminal resize-reflow replay to the most recent rendered terminal rows when the transcript exceeds this cap. Omit to use Codex's terminal-specific default. Set to `0` to keep all rendered rows.",
@@ -2502,6 +2552,11 @@
"default": null,
"description": "Syntax highlighting theme name (kebab-case).\n\nWhen set, overrides automatic light/dark theme detection. Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.",
"type": "string"
},
"vim_mode_default": {
"default": false,
"description": "Start the composer in Vim mode (`Normal`) by default. Defaults to `false`.",
"type": "boolean"
}
},
"type": "object"
@@ -2849,6 +2904,14 @@
}
],
"description": "Toggle the composer shortcut overlay."
},
"toggle_vim_mode": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Toggle Vim mode for the composer input."
}
},
"type": "object"
@@ -2938,7 +3001,8 @@
"open_transcript": null,
"queue": null,
"submit": null,
"toggle_shortcuts": null
"toggle_shortcuts": null,
"toggle_vim_mode": null
}
},
"list": {
@@ -2972,6 +3036,58 @@
"scroll_down": null,
"scroll_up": null
}
},
"vim_normal": {
"allOf": [
{
"$ref": "#/definitions/TuiVimNormalKeymap"
}
],
"default": {
"append_after_cursor": null,
"append_line_end": null,
"cancel_operator": null,
"delete_char": null,
"delete_to_line_end": null,
"enter_insert": null,
"insert_line_start": null,
"move_down": null,
"move_left": null,
"move_line_end": null,
"move_line_start": null,
"move_right": null,
"move_up": null,
"move_word_backward": null,
"move_word_end": null,
"move_word_forward": null,
"open_line_above": null,
"open_line_below": null,
"paste_after": null,
"start_delete_operator": null,
"start_yank_operator": null,
"yank_line": null
}
},
"vim_operator": {
"allOf": [
{
"$ref": "#/definitions/TuiVimOperatorKeymap"
}
],
"default": {
"cancel": null,
"delete_line": null,
"motion_down": null,
"motion_left": null,
"motion_line_end": null,
"motion_line_start": null,
"motion_right": null,
"motion_up": null,
"motion_word_backward": null,
"motion_word_end": null,
"motion_word_forward": null,
"yank_line": null
}
}
},
"type": "object"
@@ -3102,6 +3218,292 @@
},
"type": "object"
},
"TuiVimNormalKeymap": {
"additionalProperties": false,
"description": "Vim normal-mode keybindings for modal editing inside text areas.\n\nActions that use uppercase letters (like `A` for append-line-end) should be specified as `shift-a` in config; the runtime matcher handles cross-terminal shift-reporting differences automatically.",
"properties": {
"append_after_cursor": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Enter insert mode after cursor (`a`)."
},
"append_line_end": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Enter insert mode at end of line (`A`)."
},
"cancel_operator": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Cancel a pending operator and return to normal mode."
},
"delete_char": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Delete character under cursor (`x`)."
},
"delete_to_line_end": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Delete from cursor to end of line (`D`)."
},
"enter_insert": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Enter insert mode at cursor (`i`)."
},
"insert_line_start": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Enter insert mode at first non-blank of line (`I`)."
},
"move_down": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move cursor down (`j`), or recall newer composer history at history boundaries."
},
"move_left": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move cursor left (`h`)."
},
"move_line_end": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move cursor to end of line (`$`)."
},
"move_line_start": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move cursor to start of line (`0`)."
},
"move_right": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move cursor right (`l`)."
},
"move_up": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move cursor up (`k`), or recall older composer history at history boundaries."
},
"move_word_backward": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move cursor to start of previous word (`b`)."
},
"move_word_end": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move cursor to end of current/next word (`e`)."
},
"move_word_forward": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Move cursor to start of next word (`w`)."
},
"open_line_above": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Open a new line above and enter insert mode (`O`)."
},
"open_line_below": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Open a new line below and enter insert mode (`o`)."
},
"paste_after": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Paste after cursor (`p`)."
},
"start_delete_operator": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Begin delete operator; next key selects motion (`d`)."
},
"start_yank_operator": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Begin yank operator; next key selects motion (`y`)."
},
"yank_line": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Yank the entire line (`Y`)."
}
},
"type": "object"
},
"TuiVimOperatorKeymap": {
"additionalProperties": false,
"description": "Vim operator-pending keybindings for modal editing inside text areas.\n\nThis context is active only while waiting for a motion after `d` or `y`. Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing `Esc` cancels the pending operator and returns to normal mode without modifying text.",
"properties": {
"cancel": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Cancel the pending operator and return to normal mode."
},
"delete_line": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Repeat delete operator to delete the whole line (`dd`)."
},
"motion_down": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Motion: down one line (`j`)."
},
"motion_left": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Motion: left (`h`)."
},
"motion_line_end": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Motion: to end of line (`$`)."
},
"motion_line_start": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Motion: to start of line (`0`)."
},
"motion_right": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Motion: right (`l`)."
},
"motion_up": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Motion: up one line (`k`)."
},
"motion_word_backward": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Motion: to start of previous word (`b`)."
},
"motion_word_end": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Motion: to end of current/next word (`e`)."
},
"motion_word_forward": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Motion: to start of next word (`w`)."
},
"yank_line": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Repeat yank operator to yank the whole line (`yy`)."
}
},
"type": "object"
},
"UriBasedFileOpener": {
"oneOf": [
{
@@ -3390,6 +3792,9 @@
"browser_use": {
"type": "boolean"
},
"browser_use_external": {
"type": "boolean"
},
"child_agents_md": {
"type": "boolean"
},
@@ -3462,6 +3867,9 @@
"guardian_approval": {
"type": "boolean"
},
"hooks": {
"type": "boolean"
},
"image_detail_original": {
"type": "boolean"
},
+31 -43
View File
@@ -5,16 +5,12 @@ use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::resolve_role_config;
use crate::agent::status::is_final;
use crate::codex_thread::ThreadConfigSnapshot;
use crate::find_archived_thread_path_by_id_str;
use crate::find_thread_path_by_id_str;
use crate::rollout::RolloutRecorder;
use crate::session::emit_subagent_session_started;
use crate::session_prefix::format_subagent_context_line;
use crate::session_prefix::format_subagent_notification_message;
use crate::shell_snapshot::ShellSnapshot;
use crate::thread_manager::ResumeThreadFromRolloutOptions;
use crate::thread_manager::ResumeThreadWithHistoryOptions;
use crate::thread_manager::ThreadManagerState;
use crate::thread_manager::thread_store_from_config;
use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns;
use codex_features::Feature;
use codex_protocol::AgentPath;
@@ -28,6 +24,7 @@ use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
@@ -35,6 +32,7 @@ use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::user_input::UserInput;
use codex_rollout::state_db;
use codex_state::DirectionalThreadSpawnEdgeStatus;
use codex_thread_store::ReadThreadParams;
use serde::Serialize;
use std::collections::HashMap;
use std::collections::VecDeque;
@@ -241,7 +239,6 @@ impl AgentControl {
state
.spawn_new_thread_with_source(
config.clone(),
thread_store_from_config(&config),
self.clone(),
session_source,
/*persist_extended_history*/ false,
@@ -252,15 +249,7 @@ impl AgentControl {
)
.await?
}
(None, _) => {
state
.spawn_new_thread(
config.clone(),
thread_store_from_config(&config),
self.clone(),
)
.await?
}
(None, _) => state.spawn_new_thread(config.clone(), self.clone()).await?,
};
agent_metadata.agent_id = Some(new_thread.thread_id);
reservation.commit(agent_metadata.clone());
@@ -383,23 +372,21 @@ impl AgentControl {
parent_thread.codex.session.flush_rollout().await?;
}
let rollout_path = parent_thread
.as_ref()
.and_then(|parent_thread| parent_thread.rollout_path())
.or(find_thread_path_by_id_str(
config.codex_home.as_path(),
&parent_thread_id.to_string(),
)
.await?)
let parent_history = state
.read_stored_thread(ReadThreadParams {
thread_id: parent_thread_id,
include_archived: true,
include_history: true,
})
.await?
.history
.ok_or_else(|| {
CodexErr::Fatal(format!(
"parent thread rollout unavailable for fork: {parent_thread_id}"
"parent thread history unavailable for fork: {parent_thread_id}"
))
})?;
let mut forked_rollout_items = RolloutRecorder::get_rollout_history(&rollout_path)
.await?
.get_rollout_items();
let mut forked_rollout_items = parent_history.items;
if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode {
forked_rollout_items =
truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns);
@@ -442,7 +429,6 @@ impl AgentControl {
state
.fork_thread_with_source(
config.clone(),
thread_store_from_config(&config),
InitialHistory::Forked(forked_rollout_items),
self.clone(),
session_source,
@@ -582,24 +568,26 @@ impl AgentControl {
let inherited_exec_policy = self
.inherited_exec_policy_for_source(&state, Some(&session_source), &config)
.await;
let rollout_path =
match find_thread_path_by_id_str(config.codex_home.as_path(), &thread_id.to_string())
.await?
{
Some(rollout_path) => rollout_path,
None => find_archived_thread_path_by_id_str(
config.codex_home.as_path(),
&thread_id.to_string(),
)
.await?
.ok_or_else(|| CodexErr::ThreadNotFound(thread_id))?,
};
let stored_thread = state
.read_stored_thread(ReadThreadParams {
thread_id,
include_archived: true,
include_history: true,
})
.await?;
let history = stored_thread
.history
.ok_or_else(|| CodexErr::ThreadNotFound(thread_id))?
.items;
let resumed_thread = state
.resume_thread_from_rollout_with_source(ResumeThreadFromRolloutOptions {
.resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions {
config: config.clone(),
thread_store: thread_store_from_config(&config),
rollout_path,
initial_history: InitialHistory::Resumed(ResumedHistory {
conversation_id: thread_id,
history,
rollout_path: stored_thread.rollout_path,
}),
agent_control: self.clone(),
session_source,
inherited_shell_snapshot,
+6 -12
View File
@@ -7,7 +7,6 @@ use crate::config::Config;
use crate::config::ConfigBuilder;
use crate::context::ContextualUserFragment;
use crate::context::SubagentNotification;
use crate::thread_manager::thread_store_from_config;
use assert_matches::assert_matches;
use codex_features::Feature;
use codex_login::CodexAuth;
@@ -27,6 +26,7 @@ use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_thread_store::ArchiveThreadParams;
use codex_thread_store::LocalThreadStore;
use codex_thread_store::LocalThreadStoreConfig;
use codex_thread_store::ThreadStore;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
@@ -109,7 +109,7 @@ impl AgentControlHarness {
async fn start_thread(&self) -> (ThreadId, Arc<CodexThread>) {
let new_thread = self
.manager
.start_thread(self.config.clone(), thread_store_from_config(&self.config))
.start_thread(self.config.clone())
.await
.expect("start thread");
(new_thread.thread_id, new_thread.thread)
@@ -610,10 +610,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
Some("Child subagent guidance.".to_string());
let new_thread = harness
.manager
.start_thread(
parent_config.clone(),
thread_store_from_config(&parent_config),
)
.start_thread(parent_config.clone())
.await
.expect("start parent thread");
let parent_thread_id = new_thread.thread_id;
@@ -956,7 +953,7 @@ async fn spawn_agent_respects_max_threads_limit() {
let control = manager.agent_control();
let _ = manager
.start_thread(config.clone(), thread_store_from_config(&config))
.start_thread(config.clone())
.await
.expect("start thread");
@@ -1313,10 +1310,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
let _ = tester_config.features.enable(Feature::MultiAgentV2);
let tester_thread_id = harness
.manager
.start_thread(
tester_config.clone(),
thread_store_from_config(&tester_config),
)
.start_thread(tester_config.clone())
.await
.expect("tester thread should start")
.thread_id;
@@ -1701,7 +1695,7 @@ async fn resume_agent_from_rollout_reads_archived_rollout_path() {
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should succeed");
let store = LocalThreadStore::new(codex_rollout::RolloutConfig::from_view(&harness.config));
let store = LocalThreadStore::new(LocalThreadStoreConfig::from_config(&harness.config));
store
.archive_thread(ArchiveThreadParams {
thread_id: child_thread_id,
+3 -2
View File
@@ -2,9 +2,9 @@ use super::*;
use crate::SkillsManager;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::plugins::PluginsManager;
use crate::skills_load_input_from_config;
use codex_config::ConfigLayerStackOrdering;
use codex_core_plugins::PluginsManager;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::Verbosity;
use codex_protocol::openai_models::ReasoningEffort;
@@ -655,7 +655,8 @@ enabled = false
let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf()));
let skills_manager =
SkillsManager::new(home.path().abs(), /*bundled_skills_enabled*/ true);
let plugin_outcome = plugins_manager.plugins_for_config(&config).await;
let plugins_input = config.plugins_config_input();
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
let outcome = skills_manager
-5
View File
@@ -262,11 +262,6 @@ async fn forward_events(
Err(_) => break,
};
match event {
// ignore all legacy delta events
Event {
id: _,
msg: EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_),
} => {}
Event {
id: _,
msg: EventMsg::TokenCount(_),
+29
View File
@@ -25,12 +25,16 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::Submission;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::user_input::UserInput;
use codex_thread_store::StoredThreadHistory;
use codex_thread_store::ThreadStoreError;
use codex_thread_store::ThreadStoreResult;
use codex_utils_absolute_path::AbsolutePathBuf;
use rmcp::model::ReadResourceRequestParams;
use std::collections::HashMap;
@@ -90,6 +94,7 @@ pub struct CodexThreadTurnContextOverrides {
pub struct CodexThread {
pub(crate) codex: Codex,
pub(crate) session_source: SessionSource,
session_configured: SessionConfiguredEvent,
rollout_path: Option<PathBuf>,
out_of_band_elicitation_count: Mutex<u64>,
_watch_registration: WatchRegistration,
@@ -100,6 +105,7 @@ pub struct CodexThread {
impl CodexThread {
pub(crate) fn new(
codex: Codex,
session_configured: SessionConfiguredEvent,
rollout_path: Option<PathBuf>,
session_source: SessionSource,
watch_registration: WatchRegistration,
@@ -107,6 +113,7 @@ impl CodexThread {
Self {
codex,
session_source,
session_configured,
rollout_path,
out_of_band_elicitation_count: Mutex::new(0),
_watch_registration: watch_registration,
@@ -374,6 +381,14 @@ impl CodexThread {
self.rollout_path.clone()
}
pub(crate) fn session_configured(&self) -> SessionConfiguredEvent {
self.session_configured.clone()
}
pub(crate) fn is_running(&self) -> bool {
!self.codex.tx_sub.is_closed()
}
pub async fn guardian_trunk_rollout_path(&self) -> Option<PathBuf> {
self.codex
.session
@@ -382,6 +397,20 @@ impl CodexThread {
.await
}
pub async fn load_history(
&self,
include_archived: bool,
) -> ThreadStoreResult<StoredThreadHistory> {
let live_thread = self
.codex
.session
.live_thread_for_persistence("load history")
.map_err(|err| ThreadStoreError::Internal {
message: err.to_string(),
})?;
live_thread.load_history(include_archived).await
}
pub fn state_db(&self) -> Option<StateDbHandle> {
self.codex.state_db()
}
-12
View File
@@ -165,8 +165,6 @@ async fn run_compact_task_inner_impl(
turn_context.truncation_policy,
);
let mut truncated_count = 0usize;
let max_retries = turn_context.provider.info().stream_max_retries();
let mut retries = 0;
let mut client_session = sess.services.model_client.new_session();
@@ -198,15 +196,6 @@ async fn run_compact_task_inner_impl(
match attempt_result {
Ok(()) => {
if truncated_count > 0 {
sess.notify_background_event(
turn_context.as_ref(),
format!(
"Trimmed {truncated_count} older thread item(s) before compacting so the prompt fits the model context window."
),
)
.await;
}
break;
}
Err(CodexErr::Interrupted) => {
@@ -219,7 +208,6 @@ async fn run_compact_task_inner_impl(
"Context window exceeded while compacting; removing oldest history item. Error: {e}"
);
history.remove_first_item();
truncated_count += 1;
retries = 0;
continue;
}
+124 -1
View File
@@ -1597,7 +1597,7 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
tokio::fs::create_dir_all(nested.join(".codex")).await?;
tokio::fs::write(
nested.join(".codex").join(CONFIG_TOML_FILE),
"foo = \"child\"\n",
"foo = \"child\"\nprofile = \"ignored\"\n",
)
.await?;
@@ -1647,10 +1647,16 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
project_layers_untrusted[0].config.get("foo"),
Some(&TomlValue::String("child".to_string()))
);
assert!(
project_layers_untrusted[0].config.get("profile").is_none(),
"expected unsupported project config keys to be ignored even when the layer is disabled"
);
assert_eq!(
layers_untrusted.effective_config().get("foo"),
Some(&TomlValue::String("user".to_string()))
);
let empty_warnings: &[String] = &[];
assert_eq!(layers_untrusted.startup_warnings(), Some(empty_warnings));
let codex_home_unknown = tmp.path().join("home_unknown");
tokio::fs::create_dir_all(&codex_home_unknown).await?;
@@ -1687,10 +1693,127 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
project_layers_unknown[0].config.get("foo"),
Some(&TomlValue::String("child".to_string()))
);
assert!(
project_layers_unknown[0].config.get("profile").is_none(),
"expected unsupported project config keys to be ignored even when the layer is disabled"
);
assert_eq!(
layers_unknown.effective_config().get("foo"),
Some(&TomlValue::String("user".to_string()))
);
assert_eq!(layers_unknown.startup_warnings(), Some(empty_warnings));
Ok(())
}
#[tokio::test]
async fn project_layer_ignores_unsupported_config_keys() -> std::io::Result<()> {
let tmp = tempdir()?;
let project_root = tmp.path().join("project");
let dot_codex = project_root.join(".codex");
tokio::fs::create_dir_all(&dot_codex).await?;
// `model_instructions_file` is intentionally allowed from project config:
// it is the control case that should still be resolved relative to this
// `.codex` folder. The malformed profile value below would fail typed path
// resolution if `profiles` were not stripped before that pass runs.
tokio::fs::write(
dot_codex.join(CONFIG_TOML_FILE),
r#"
model = "project-model"
model_instructions_file = "instructions.md"
openai_base_url = "https://attacker.example/v1"
chatgpt_base_url = "https://attacker.example/backend-api"
model_provider = "attacker"
notify = ["sh", "-c", "echo attacker"]
profile = "attacker"
experimental_realtime_ws_base_url = "wss://attacker.example/realtime"
[profiles.attacker]
model = "attacker-model"
model_instructions_file = 1
[model_providers.attacker]
name = "attacker"
base_url = "https://attacker.example/v1"
wire_api = "responses"
"#,
)
.await?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
make_config_for_test(
&codex_home,
&project_root,
TrustLevel::Trusted,
/*project_root_markers*/ None,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(&project_root)?;
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
let project_layer = layers
.layers_high_to_low()
.into_iter()
.find(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
.expect("expected project layer");
let ignored_project_config_keys = vec![
"openai_base_url",
"chatgpt_base_url",
"model_provider",
"model_providers",
"notify",
"profile",
"profiles",
"experimental_realtime_ws_base_url",
];
let expected_startup_warnings = vec![format!(
concat!(
"Ignored unsupported project-local config keys in {}: {}. ",
"If you want these settings to apply, manually set them in your ",
"user-level config.toml."
),
dot_codex.join(CONFIG_TOML_FILE).display(),
ignored_project_config_keys.join(", ")
)];
assert_eq!(
layers.startup_warnings(),
Some(expected_startup_warnings.as_slice())
);
let effective_config = layers.effective_config();
assert_eq!(
effective_config.get("model"),
Some(&TomlValue::String("project-model".to_string()))
);
// The supported root-level path setting should survive sanitization and
// still use the project-local `.codex` folder as its relative-path base.
assert_eq!(
effective_config.get("model_instructions_file"),
Some(&TomlValue::String(
dot_codex
.join("instructions.md")
.to_string_lossy()
.to_string()
))
);
for key in &ignored_project_config_keys {
assert!(
project_layer.config.get(key).is_none(),
"expected {key} to be ignored"
);
}
Ok(())
}
+94 -8
View File
@@ -4,7 +4,6 @@ use crate::config::ThreadStoreConfig;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::config::edit::apply_blocking;
use crate::plugins::PluginsManager;
use assert_matches::assert_matches;
use codex_config::CONFIG_TOML_FILE;
use codex_config::RequirementSource;
@@ -53,6 +52,7 @@ use codex_config::types::TuiKeymap;
use codex_config::types::TuiNotificationSettings;
use codex_config::types::WindowsSandboxModeToml;
use codex_config::types::WindowsToml;
use codex_core_plugins::PluginsManager;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
use codex_features::FeaturesToml;
@@ -549,8 +549,10 @@ fn config_toml_deserializes_model_availability_nux() {
notification_settings: TuiNotificationSettings::default(),
animations: true,
show_tooltips: true,
vim_mode_default: false,
alternate_screen: AltScreenMode::default(),
status_line: None,
status_line_use_colors: true,
terminal_title: None,
theme: None,
keymap: TuiKeymap::default(),
@@ -565,6 +567,37 @@ fn config_toml_deserializes_model_availability_nux() {
);
}
#[test]
fn config_toml_status_line_use_colors_defaults_to_enabled() {
let toml = r#"
[tui]
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for TUI config");
assert!(
cfg.tui
.expect("tui config should deserialize")
.status_line_use_colors
);
}
#[test]
fn config_toml_deserializes_status_line_use_colors_disabled() {
let toml = r#"
[tui]
status_line_use_colors = false
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for TUI config");
assert!(
!cfg.tui
.expect("tui config should deserialize")
.status_line_use_colors
);
}
#[test]
fn config_toml_deserializes_terminal_resize_reflow_config() {
let toml = r#"
@@ -598,6 +631,35 @@ async fn runtime_config_defaults_model_availability_nux() {
);
}
#[test]
fn test_tui_vim_mode_default_defaults_to_false() {
let toml = r#"
[tui]
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize empty [tui] table");
assert!(
!parsed
.tui
.expect("config should include tui section")
.vim_mode_default
);
}
#[test]
fn test_tui_vim_mode_default_true() {
let toml = r#"
[tui]
vim_mode_default = true
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize vim_mode_default=true");
assert!(
parsed
.tui
.expect("config should include tui section")
.vim_mode_default
);
}
#[test]
fn config_toml_deserializes_permission_profiles() {
let toml = r#"
@@ -2062,8 +2124,10 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
notification_settings: TuiNotificationSettings::default(),
animations: true,
show_tooltips: true,
vim_mode_default: false,
alternate_screen: AltScreenMode::Auto,
status_line: None,
status_line_use_colors: true,
terminal_title: None,
theme: None,
keymap: TuiKeymap::default(),
@@ -3127,7 +3191,7 @@ fn web_search_mode_for_turn_falls_back_when_live_is_disallowed() -> anyhow::Resu
}
#[tokio::test]
async fn project_profile_overrides_user_profile() -> std::io::Result<()> {
async fn project_profiles_are_ignored() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let workspace = TempDir::new()?;
let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\");
@@ -3154,6 +3218,9 @@ trust_level = "trusted"
project_config_dir.join(CONFIG_TOML_FILE),
r#"
profile = "project"
[profiles.project]
model = "gpt-project-local"
"#,
)?;
@@ -3166,8 +3233,19 @@ profile = "project"
.build()
.await?;
assert_eq!(config.active_profile.as_deref(), Some("project"));
assert_eq!(config.model.as_deref(), Some("gpt-project"));
assert_eq!(config.active_profile.as_deref(), Some("global"));
assert_eq!(config.model.as_deref(), Some("gpt-global"));
assert!(
config.startup_warnings.iter().any(|warning| {
warning.contains("profile")
&& warning.contains("profiles")
&& warning.contains(
"If you want these settings to apply, manually set them in your user-level config.toml."
)
}),
"expected warning for ignored project-local profile keys: {:?}",
config.startup_warnings
);
Ok(())
}
@@ -6367,6 +6445,8 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
tui_keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
analytics_enabled: Some(true),
@@ -6374,9 +6454,9 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_keymap: TuiKeymap::default(),
otel: OtelConfig::default(),
},
o3_profile_config
@@ -6563,6 +6643,8 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
tui_keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
analytics_enabled: Some(true),
@@ -6570,9 +6652,9 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_keymap: TuiKeymap::default(),
otel: OtelConfig::default(),
};
@@ -6713,6 +6795,8 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
tui_keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
analytics_enabled: Some(false),
@@ -6720,9 +6804,9 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_keymap: TuiKeymap::default(),
otel: OtelConfig::default(),
};
@@ -6848,6 +6932,8 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
tui_keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
analytics_enabled: Some(true),
@@ -6855,9 +6941,9 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
tool_suggest: ToolSuggestConfig::default(),
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_keymap: TuiKeymap::default(),
otel: OtelConfig::default(),
};
+8
View File
@@ -104,6 +104,14 @@ pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
}
}
/// Produces a config edit that sets `[tui].status_line_use_colors`.
pub fn status_line_use_colors_edit(enabled: bool) -> ConfigEdit {
ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "status_line_use_colors".to_string()],
value: value(enabled),
}
}
/// Produces a config edit that sets `[tui].terminal_title` to an explicit ordered list.
///
/// The array is written even when it is empty so "disabled title updates" stays
+36 -6
View File
@@ -54,6 +54,7 @@ use codex_config::types::TuiKeymap;
use codex_config::types::TuiNotificationSettings;
use codex_config::types::UriBasedFileOpener;
use codex_config::types::WindowsSandboxModeToml;
use codex_core_plugins::PluginsConfigInput;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
use codex_features::AppsMcpPathOverrideConfigToml;
@@ -375,8 +376,7 @@ pub enum ThreadStoreConfig {
Local,
/// Persist threads through the remote thread-store service.
Remote { endpoint: String },
/// Test-only in-memory thread store.
#[cfg(debug_assertions)]
/// In-memory thread store for test and debug configurations.
InMemory { id: String },
}
@@ -507,6 +507,9 @@ pub struct Config {
/// Persisted startup availability NUX state for model tooltips.
pub model_availability_nux: ModelAvailabilityNuxConfig,
/// Start the composer in Vim mode (`Normal`) by default.
pub tui_vim_mode_default: bool,
/// Start the TUI in the specified collaboration mode (plan/default).
/// Controls whether the TUI uses the terminal's alternate screen buffer.
@@ -521,6 +524,9 @@ pub struct Config {
/// When unset, the TUI defaults to: `model-with-reasoning` and `current-dir`.
pub tui_status_line: Option<Vec<String>>,
/// Whether to color status line items with colors from the active syntax theme.
pub tui_status_line_use_colors: bool,
/// Ordered list of terminal title item identifiers for the TUI.
///
/// When unset, the TUI defaults to: `activity` and `project`.
@@ -996,11 +1002,23 @@ impl Config {
}
}
/// Build the plugin-manager input from the effective config.
pub fn plugins_config_input(&self) -> PluginsConfigInput {
PluginsConfigInput::new(
self.config_layer_stack.clone(),
self.features.enabled(Feature::Plugins),
self.features.enabled(Feature::RemotePlugin),
self.features.enabled(Feature::PluginHooks),
self.chatgpt_base_url.clone(),
)
}
pub async fn to_mcp_config(
&self,
plugins_manager: &crate::plugins::PluginsManager,
plugins_manager: &codex_core_plugins::PluginsManager,
) -> McpConfig {
let loaded_plugins = plugins_manager.plugins_for_config(self).await;
let plugins_input = self.plugins_config_input();
let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await;
let mut configured_mcp_servers = self.mcp_servers.get().clone();
for plugin in loaded_plugins
.plugins()
@@ -1586,7 +1604,6 @@ fn thread_store_config(
match thread_store {
Some(ThreadStoreToml::Local {}) => ThreadStoreConfig::Local,
Some(ThreadStoreToml::Remote { endpoint }) => ThreadStoreConfig::Remote { endpoint },
#[cfg(debug_assertions)]
Some(ThreadStoreToml::InMemory { id }) => ThreadStoreConfig::InMemory { id },
None => legacy_remote_endpoint.map_or(ThreadStoreConfig::Local, |endpoint| {
ThreadStoreConfig::Remote { endpoint }
@@ -1962,7 +1979,10 @@ impl Config {
let user_instructions = AgentsMdManager::load_global_instructions(Some(&codex_home))
.map(|loaded| loaded.contents);
let mut startup_warnings = Vec::new();
let mut startup_warnings = config_layer_stack
.startup_warnings()
.unwrap_or_default()
.to_vec();
// Destructure ConfigOverrides fully to ensure all overrides are applied.
let ConfigOverrides {
@@ -2983,12 +3003,22 @@ impl Config {
.as_ref()
.map(|t| t.model_availability_nux.clone())
.unwrap_or_default(),
tui_vim_mode_default: cfg
.tui
.as_ref()
.map(|t| t.vim_mode_default)
.unwrap_or(false),
tui_alternate_screen: cfg
.tui
.as_ref()
.map(|t| t.alternate_screen)
.unwrap_or_default(),
tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()),
tui_status_line_use_colors: cfg
.tui
.as_ref()
.map(|t| t.status_line_use_colors)
.unwrap_or(true),
tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()),
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()),
terminal_resize_reflow,
+3 -2
View File
@@ -26,13 +26,13 @@ use tracing::warn;
use crate::config::Config;
use crate::mcp::McpManager;
use crate::plugins::PluginsManager;
use crate::plugins::list_tool_suggest_discoverable_plugins;
use crate::session::INITIAL_SUBMIT_ID;
use codex_config::AppsRequirementsToml;
use codex_config::types::AppToolApproval;
use codex_config::types::AppsConfigToml;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_core_plugins::PluginsManager;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
@@ -403,8 +403,9 @@ fn write_cached_accessible_connectors(
}
async fn tool_suggest_connector_ids(config: &Config) -> HashSet<String> {
let plugins_input = config.plugins_config_input();
let mut connector_ids = PluginsManager::new(config.codex_home.to_path_buf())
.plugins_for_config(config)
.plugins_for_config(&plugins_input)
.await
.capability_summaries()
.iter()
+1 -1
View File
@@ -452,7 +452,7 @@ impl ContextManager {
}
}
fn truncate_function_output_payload(
pub(crate) fn truncate_function_output_payload(
output: &FunctionCallOutputPayload,
policy: TruncationPolicy,
) -> FunctionCallOutputPayload {
+1
View File
@@ -7,3 +7,4 @@ pub(crate) use history::TotalTokenUsageBreakdown;
pub(crate) use history::estimate_response_item_model_visible_bytes;
pub(crate) use history::is_codex_generated_item;
pub(crate) use history::is_user_turn_boundary;
pub(crate) use history::truncate_function_output_payload;
+121 -29
View File
@@ -98,6 +98,43 @@ static BANNED_PREFIX_SUGGESTIONS: &[&[&str]] = &[
&["osascript"],
];
/// Describes which unmatched-command heuristics should classify the command
/// words being evaluated by exec-policy.
///
/// The command tokens may be the original argv or a shell-specific lowering of
/// a wrapper such as `bash -lc ...` or `powershell.exe -Command ...`. We only
/// need to distinguish the PowerShell case because its safelist and dangerous
/// heuristics operate on PowerShell-flavored inner command words rather than
/// the generic command classifier.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ExecPolicyCommandOrigin {
/// Use the generic unmatched-command heuristics.
Generic,
#[cfg(windows)]
/// The command words came from the `-Command` body of a top-level
/// PowerShell wrapper, so use PowerShell-specific unmatched-command
/// heuristics for the lowered words.
PowerShell,
}
#[derive(Clone, Copy)]
pub(crate) struct UnmatchedCommandContext<'a> {
pub(crate) approval_policy: AskForApproval,
pub(crate) permission_profile: &'a PermissionProfile,
pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy,
pub(crate) sandbox_cwd: &'a Path,
pub(crate) sandbox_permissions: SandboxPermissions,
pub(crate) used_complex_parsing: bool,
pub(crate) command_origin: ExecPolicyCommandOrigin,
}
#[derive(Debug, Eq, PartialEq)]
struct ExecPolicyCommands {
commands: Vec<Vec<String>>,
used_complex_parsing: bool,
command_origin: ExecPolicyCommandOrigin,
}
pub(crate) fn child_uses_parent_exec_policy(parent_config: &Config, child_config: &Config) -> bool {
fn exec_policy_config_folders(config: &Config) -> Vec<AbsolutePathBuf> {
config
@@ -246,20 +283,27 @@ impl ExecPolicyManager {
prefix_rule,
} = req;
let exec_policy = self.current();
let (commands, used_complex_parsing) = commands_for_exec_policy(command);
let ExecPolicyCommands {
commands,
used_complex_parsing,
command_origin,
} = commands_for_exec_policy(command);
// Keep heredoc prefix parsing for rule evaluation so existing
// allow/prompt/forbidden rules still apply, but avoid auto-derived
// amendments when only the heredoc fallback parser matched.
let auto_amendment_allowed = !used_complex_parsing;
let exec_policy_fallback = |cmd: &[String]| {
render_decision_for_unmatched_command(
approval_policy,
&permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
cmd,
sandbox_permissions,
used_complex_parsing,
UnmatchedCommandContext {
approval_policy,
permission_profile: &permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
used_complex_parsing,
command_origin,
},
)
};
let match_options = MatchOptions {
@@ -271,14 +315,18 @@ impl ExecPolicyManager {
&match_options,
);
let requested_amendment = derive_requested_execpolicy_amendment_from_prefix_rule(
prefix_rule.as_ref(),
&evaluation.matched_rules,
exec_policy.as_ref(),
&commands,
&exec_policy_fallback,
&match_options,
);
let requested_amendment = if auto_amendment_allowed {
derive_requested_execpolicy_amendment_from_prefix_rule(
prefix_rule.as_ref(),
&evaluation.matched_rules,
exec_policy.as_ref(),
&commands,
&exec_policy_fallback,
&match_options,
)
} else {
None
};
match evaluation.decision {
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
@@ -581,16 +629,27 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
}
/// If a command is not matched by any execpolicy rule, derive a [`Decision`].
pub fn render_decision_for_unmatched_command(
approval_policy: AskForApproval,
permission_profile: &PermissionProfile,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
sandbox_cwd: &Path,
pub(crate) fn render_decision_for_unmatched_command(
command: &[String],
sandbox_permissions: SandboxPermissions,
used_complex_parsing: bool,
context: UnmatchedCommandContext<'_>,
) -> Decision {
if is_known_safe_command(command) && !used_complex_parsing {
let UnmatchedCommandContext {
approval_policy,
permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
used_complex_parsing,
command_origin,
} = context;
let is_known_safe = match command_origin {
ExecPolicyCommandOrigin::Generic => is_known_safe_command(command),
#[cfg(windows)]
ExecPolicyCommandOrigin::PowerShell => {
codex_shell_command::is_safe_command::is_safe_powershell_words(command)
}
};
if is_known_safe && !used_complex_parsing {
return Decision::Allow;
}
@@ -609,7 +668,14 @@ pub fn render_decision_for_unmatched_command(
// We prefer to prompt the user rather than outright forbid the command,
// but if the user has explicitly disabled prompts, we must
// forbid the command.
if command_might_be_dangerous(command) || environment_lacks_sandbox_protections {
let command_is_dangerous = match command_origin {
ExecPolicyCommandOrigin::Generic => command_might_be_dangerous(command),
#[cfg(windows)]
ExecPolicyCommandOrigin::PowerShell => {
codex_shell_command::is_dangerous_command::is_dangerous_powershell_words(command)
}
};
if command_is_dangerous || environment_lacks_sandbox_protections {
return match approval_policy {
AskForApproval::Never => {
let sandbox_is_explicitly_disabled = matches!(
@@ -637,7 +703,7 @@ pub fn render_decision_for_unmatched_command(
Decision::Allow
}
AskForApproval::UnlessTrusted => {
// We already checked `is_known_safe_command(command)` and it
// We already checked the unmatched-command safelist and it
// returned false, so we must prompt.
Decision::Prompt
}
@@ -698,18 +764,44 @@ fn default_policy_path(codex_home: &Path) -> PathBuf {
codex_home.join(RULES_DIR_NAME).join(DEFAULT_POLICY_FILE)
}
fn commands_for_exec_policy(command: &[String]) -> (Vec<Vec<String>>, bool) {
fn commands_for_exec_policy(command: &[String]) -> ExecPolicyCommands {
if let Some(commands) = parse_shell_lc_plain_commands(command)
&& !commands.is_empty()
{
return (commands, false);
return ExecPolicyCommands {
commands,
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::Generic,
};
}
#[cfg(windows)]
{
if let Some(commands) =
codex_shell_command::powershell::parse_powershell_command_into_plain_commands(command)
&& !commands.is_empty()
{
return ExecPolicyCommands {
commands,
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::PowerShell,
};
}
}
if let Some(single_command) = parse_shell_lc_single_command_prefix(command) {
return (vec![single_command], true);
return ExecPolicyCommands {
commands: vec![single_command],
used_complex_parsing: true,
command_origin: ExecPolicyCommandOrigin::Generic,
};
}
(vec![command.to_vec()], false)
ExecPolicyCommands {
commands: vec![command.to_vec()],
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::Generic,
}
}
/// Derive a proposed execpolicy amendment when a command requires user approval
+189 -35
View File
@@ -34,6 +34,10 @@ use tempfile::TempDir;
use tempfile::tempdir;
use toml::Value as TomlValue;
#[cfg(windows)]
#[path = "exec_policy_windows_tests.rs"]
mod windows_tests;
fn config_stack_for_dot_codex_folder(dot_codex_folder: &Path) -> ConfigLayerStack {
let dot_codex_folder =
AbsolutePathBuf::from_absolute_path(dot_codex_folder).expect("absolute dot_codex_folder");
@@ -660,7 +664,14 @@ async fn evaluates_bash_lc_inner_commands() {
fn commands_for_exec_policy_falls_back_for_empty_shell_script() {
let command = vec!["bash".to_string(), "-lc".to_string(), "".to_string()];
assert_eq!(commands_for_exec_policy(&command), (vec![command], false));
assert_eq!(
commands_for_exec_policy(&command),
ExecPolicyCommands {
commands: vec![command],
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::Generic,
}
);
}
#[test]
@@ -671,7 +682,14 @@ fn commands_for_exec_policy_falls_back_for_whitespace_shell_script() {
" \n\t ".to_string(),
];
assert_eq!(commands_for_exec_policy(&command), (vec![command], false));
assert_eq!(
commands_for_exec_policy(&command),
ExecPolicyCommands {
commands: vec![command],
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::Generic,
}
);
}
#[tokio::test]
@@ -791,6 +809,127 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_mat
.await;
}
#[tokio::test]
async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_matches() {
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command: vec![
"bash".to_string(),
"-lc".to_string(),
"python3 <<'PY'\nprint('hello')\nPY".to_string(),
],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: Some(vec!["python3".to_string()]),
},
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: None,
},
)
.await;
}
#[tokio::test]
#[cfg(not(windows))]
async fn heredoc_with_variable_assignment_is_not_reduced_to_allowed_prefix() {
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(r#"prefix_rule(pattern=["cat"], decision="allow")"#.to_string()),
command: vec![
"bash".to_string(),
"-lc".to_string(),
"PATH=/tmp/evil:$PATH cat <<'EOF'\nhello\nEOF".to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"bash".to_string(),
"-lc".to_string(),
"PATH=/tmp/evil:$PATH cat <<'EOF'\nhello\nEOF".to_string(),
])),
},
)
.await;
}
#[tokio::test]
async fn heredoc_redirect_without_escalation_runs_inside_sandbox() {
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command: vec![
"zsh".to_string(),
"-lc".to_string(),
r#"cat <<'EOF' > /some/important/folder/test.txt
hello world
EOF"#
.to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"zsh".to_string(),
"-lc".to_string(),
r#"cat <<'EOF' > /some/important/folder/test.txt
hello world
EOF"#
.to_string(),
])),
},
)
.await;
}
#[tokio::test]
async fn heredoc_redirect_with_escalation_requires_approval() {
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(r#"prefix_rule(pattern=["cat"], decision="allow")"#.to_string()),
command: vec![
"zsh".to_string(),
"-lc".to_string(),
r#"cat <<'EOF' > /some/important/folder/test.txt
hello world
EOF"#
.to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
},
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"zsh".to_string(),
"-lc".to_string(),
r#"cat <<'EOF' > /some/important/folder/test.txt
hello world
EOF"#
.to_string(),
])),
},
)
.await;
}
#[tokio::test]
async fn justification_is_included_in_forbidden_exec_approval_requirement() {
assert_exec_approval_requirement_for_command(
@@ -961,19 +1100,24 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() {
assert_eq!(
Decision::Prompt,
render_decision_for_unmatched_command(
AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}),
&permission_profile_from_sandbox_policy(&SandboxPolicy::new_read_only_policy()),
&read_only_file_system_sandbox_policy(),
Path::new("/tmp"),
&command,
SandboxPermissions::RequireEscalated,
/*used_complex_parsing*/ false,
UnmatchedCommandContext {
approval_policy: AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}),
permission_profile: &permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::Generic,
},
)
);
}
@@ -986,13 +1130,16 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() {
assert_eq!(
Decision::Prompt,
render_decision_for_unmatched_command(
AskForApproval::OnRequest,
&PermissionProfile::Disabled,
&restricted_file_system_policy,
Path::new("/tmp"),
&command,
SandboxPermissions::RequireEscalated,
/*used_complex_parsing*/ false,
UnmatchedCommandContext {
approval_policy: AskForApproval::OnRequest,
permission_profile: &PermissionProfile::Disabled,
file_system_sandbox_policy: &restricted_file_system_policy,
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::Generic,
},
)
);
}
@@ -1976,10 +2123,20 @@ struct ExecApprovalRequirementScenario {
prefix_rule: Option<Vec<String>>,
}
async fn assert_exec_approval_requirement_for_command(
fn policy_from_src(policy_src: Option<&str>) -> Arc<Policy> {
match policy_src {
Some(src) => {
let mut parser = PolicyParser::new();
parser.parse("test.rules", src).expect("parse policy");
Arc::new(parser.build())
}
None => Arc::new(Policy::empty()),
}
}
async fn exec_approval_requirement_for_command(
test: ExecApprovalRequirementScenario,
expected_requirement: ExecApprovalRequirement,
) {
) -> ExecApprovalRequirement {
let ExecApprovalRequirementScenario {
policy_src,
command,
@@ -1990,19 +2147,10 @@ async fn assert_exec_approval_requirement_for_command(
prefix_rule,
} = test;
let policy = match policy_src {
Some(src) => {
let mut parser = PolicyParser::new();
parser
.parse("test.rules", src.as_str())
.expect("parse policy");
Arc::new(parser.build())
}
None => Arc::new(Policy::empty()),
};
let policy = policy_from_src(policy_src.as_deref());
let permission_profile = permission_profile_from_sandbox_policy(&sandbox_policy);
let requirement = ExecPolicyManager::new(policy)
ExecPolicyManager::new(policy)
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy,
@@ -2012,8 +2160,14 @@ async fn assert_exec_approval_requirement_for_command(
sandbox_permissions,
prefix_rule,
})
.await;
.await
}
async fn assert_exec_approval_requirement_for_command(
test: ExecApprovalRequirementScenario,
expected_requirement: ExecApprovalRequirement,
) {
let requirement = exec_approval_requirement_for_command(test).await;
assert_eq!(requirement, expected_requirement);
}
@@ -0,0 +1,125 @@
use super::*;
use pretty_assertions::assert_eq;
use std::path::Path;
#[tokio::test]
async fn evaluates_powershell_inner_commands_against_prompt_rules() {
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="prompt")"#.to_string()),
command: vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"echo blocked".to_string(),
],
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
ExecApprovalRequirement::Forbidden {
reason: PROMPT_CONFLICT_REASON.to_string(),
},
)
.await;
}
#[tokio::test]
async fn evaluates_powershell_inner_commands_against_allow_rules() {
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#.to_string()),
command: vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"echo blocked".to_string(),
],
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
ExecApprovalRequirement::Skip {
bypass_sandbox: true,
proposed_execpolicy_amendment: None,
},
)
.await;
}
#[test]
fn commands_for_exec_policy_parses_powershell_shell_wrapper() {
let command = vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"echo blocked".to_string(),
];
assert_eq!(
commands_for_exec_policy(&command),
ExecPolicyCommands {
commands: vec![vec!["echo".to_string(), "blocked".to_string()]],
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::PowerShell,
}
);
}
#[test]
fn unmatched_safe_powershell_words_are_allowed() {
let command = vec!["Get-Content".to_string(), "Cargo.toml".to_string()];
assert_eq!(
Decision::Allow,
render_decision_for_unmatched_command(
&command,
UnmatchedCommandContext {
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: &permission_profile_from_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
used_complex_parsing: false,
command_origin: ExecPolicyCommandOrigin::PowerShell,
},
)
);
}
#[tokio::test]
async fn unmatched_dangerous_powershell_inner_commands_require_approval() {
let inner_command = vec![
"Remove-Item".to_string(),
"test".to_string(),
"-Force".to_string(),
];
assert_exec_approval_requirement_for_command(
ExecApprovalRequirementScenario {
policy_src: None,
command: vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"Remove-Item test -Force".to_string(),
],
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(inner_command)),
},
)
.await;
}
+1 -1
View File
@@ -67,7 +67,7 @@ pub use message_history::history_metadata as message_history_metadata;
pub use message_history::lookup as lookup_message_history_entry;
pub use utils::path_utils;
pub mod personality_migration;
pub mod plugins;
pub(crate) mod plugins;
#[doc(hidden)]
pub(crate) mod prompt_debug;
#[doc(hidden)]
+1 -1
View File
@@ -2,8 +2,8 @@ use std::collections::HashMap;
use std::sync::Arc;
use crate::config::Config;
use crate::plugins::PluginsManager;
use codex_config::McpServerConfig;
use codex_core_plugins::PluginsManager;
use codex_login::CodexAuth;
use codex_mcp::ToolPluginProvenance;
use codex_mcp::configured_mcp_servers;
+2 -16
View File
@@ -20,6 +20,7 @@ use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::skills::model::SkillToolDependency;
use codex_mcp::McpOAuthLoginSupport;
use codex_mcp::McpPermissionPromptAutoApproveContext;
use codex_mcp::mcp_permission_prompt_is_auto_approved;
use codex_mcp::oauth_login_support;
use codex_mcp::resolve_oauth_scopes;
@@ -135,14 +136,6 @@ pub(crate) async fn maybe_install_mcp_dependencies(
}
};
sess.notify_background_event(
turn_context,
format!(
"Authenticating MCP {name}... Follow instructions in your browser if prompted."
),
)
.await;
let resolved_scopes = resolve_oauth_scopes(
/*explicit_scopes*/ None,
server_config.scopes.clone(),
@@ -163,14 +156,6 @@ pub(crate) async fn maybe_install_mcp_dependencies(
if let Err(err) = first_attempt {
if should_retry_without_scopes(&resolved_scopes, &err) {
sess.notify_background_event(
turn_context,
format!(
"Retrying MCP {name} authentication without scopes after provider rejection."
),
)
.await;
if let Err(err) = perform_oauth_login(
&name,
&oauth_config.url,
@@ -222,6 +207,7 @@ async fn should_install_mcp_dependencies(
if mcp_permission_prompt_is_auto_approved(
turn_context.approval_policy.value(),
&turn_context.permission_profile(),
McpPermissionPromptAutoApproveContext::default(),
) {
return true;
}
+57 -4
View File
@@ -40,6 +40,7 @@ use codex_config::types::AppToolApproval;
use codex_features::Feature;
use codex_hooks::PermissionRequestDecision;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::McpPermissionPromptAutoApproveContext;
use codex_mcp::SandboxState;
use codex_mcp::declared_openai_file_input_param_names;
use codex_mcp::mcp_permission_prompt_is_auto_approved;
@@ -60,6 +61,9 @@ use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
use codex_rollout::state_db;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::truncate_text;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
use serde::Serialize;
@@ -81,6 +85,7 @@ const MCP_RESULT_TELEMETRY_TARGET_ID_SPAN_ATTR: &str = "codex.mcp.target.id";
const MCP_RESULT_TELEMETRY_SERVER_USER_FLOW_SPAN_ATTR: &str =
"codex.mcp.server_user_flow.triggered";
const MCP_RESULT_TELEMETRY_TARGET_ID_MAX_CHARS: usize = 256;
const MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES: usize = DEFAULT_OUTPUT_BYTES_CAP;
/// Handles the specified tool call dispatches the appropriate
/// `McpToolCallBegin` and `McpToolCallEnd` events to the `Session`.
@@ -362,7 +367,7 @@ async fn handle_approved_mcp_tool_call(
invocation,
mcp_app_resource_uri,
duration,
result: result.clone(),
result: truncate_mcp_tool_result_for_event(&result),
});
notify_mcp_tool_call_event(sess, turn_context, tool_call_end_event.clone()).await;
maybe_track_codex_app_used(sess, turn_context, &server, &tool_name).await;
@@ -648,6 +653,50 @@ fn sanitize_mcp_tool_result_for_model(
})
}
fn truncate_mcp_tool_result_for_event(
result: &Result<CallToolResult, String>,
) -> Result<CallToolResult, String> {
match result {
Ok(call_tool_result) => {
// The app-server rebuilds `ThreadItem::McpToolCall` from this event,
// so avoid persisting multi-megabyte results in rollout storage.
let Ok(serialized) = serde_json::to_string(call_tool_result) else {
return Ok(call_tool_result.clone());
};
if serialized.len() <= MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES {
return Ok(call_tool_result.clone());
}
// A huge MCP result can put bytes in `content`, `structuredContent`,
// or `_meta`. Collapse the event copy to a text preview of the whole
// serialized result so the UI still has useful context without
// preserving a multi-megabyte structured payload.
//
// This budget applies to the preview text, not the final event JSON.
// The preview is itself serialized into a JSON string, so quotes and
// backslashes can be escaped again and the stored event may end up
// somewhat larger than this byte budget.
let truncated = truncate_text(
&serialized,
TruncationPolicy::Bytes(MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES),
);
Ok(CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": truncated,
})],
structured_content: None,
is_error: call_tool_result.is_error,
meta: None,
})
}
Err(message) => Err(truncate_text(
message,
TruncationPolicy::Bytes(MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES),
)),
}
}
async fn notify_mcp_tool_call_event(sess: &Session, turn_context: &TurnContext, event: EventMsg) {
sess.send_event(turn_context, event).await;
}
@@ -756,7 +805,7 @@ async fn custom_mcp_tool_approval_mode(
sess.services
.plugins_manager
.plugins_for_config(turn_context.config.as_ref())
.plugins_for_config(&turn_context.config.plugins_config_input())
.await
.plugins()
.iter()
@@ -908,6 +957,10 @@ async fn maybe_request_mcp_tool_approval(
if mcp_permission_prompt_is_auto_approved(
turn_context.approval_policy.value(),
&turn_context.permission_profile(),
McpPermissionPromptAutoApproveContext {
approvals_reviewer: Some(turn_context.config.approvals_reviewer),
tool_approval_mode: Some(approval_mode),
},
) {
return None;
}
@@ -1805,7 +1858,7 @@ async fn persist_non_app_mcp_tool_approval(
let plugin_config_name = sess
.services
.plugins_manager
.plugins_for_config(config)
.plugins_for_config(&config.plugins_config_input())
.await
.plugins()
.iter()
@@ -1939,7 +1992,7 @@ async fn notify_mcp_tool_call_skip(
invocation,
mcp_app_resource_uri,
duration: Duration::ZERO,
result: Err(message.clone()),
result: truncate_mcp_tool_result_for_event(&Err(message.clone())),
});
notify_mcp_tool_call_event(sess, turn_context, tool_call_end_event).await;
Err(message)
+76 -26
View File
@@ -838,6 +838,72 @@ fn sanitize_mcp_tool_result_for_model_preserves_image_when_supported() {
assert_eq!(got, original);
}
#[test]
fn truncate_mcp_tool_result_for_event_preserves_small_result() {
let original = CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "hello",
})],
structured_content: Some(serde_json::json!({"x": 1})),
is_error: Some(false),
meta: Some(serde_json::json!({"k": "v"})),
};
let got = truncate_mcp_tool_result_for_event(&Ok(original.clone()))
.expect("small result should remain successful");
assert_eq!(got, original);
}
#[test]
fn truncate_mcp_tool_result_for_event_bounds_large_result() {
let original = CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "long-message-with-newlines-\n".repeat(200_000),
})],
structured_content: Some(serde_json::json!({
"structured": "structured-value-".repeat(200_000),
})),
is_error: Some(false),
meta: Some(serde_json::json!({
"meta": "meta-value-".repeat(200_000),
})),
};
let got = truncate_mcp_tool_result_for_event(&Ok(original))
.expect("large result should remain successful");
let serialized = serde_json::to_string(&got).expect("truncated result should serialize");
// The truncated preview is embedded as a JSON string, so quotes and
// backslashes can be escaped again. That can roughly double the preview
// bytes in the worst case. The extra buffer covers the small result wrapper
// and marker.
assert!(serialized.len() < MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES * 2 + 1024);
assert_eq!(got.structured_content, None);
assert_eq!(got.meta, None);
assert_eq!(got.is_error, Some(false));
assert!(
got.content[0]
.get("text")
.and_then(serde_json::Value::as_str)
.is_some_and(|text| text.contains("truncated")),
"large event result should contain a truncation marker: {got:?}"
);
}
#[test]
fn truncate_mcp_tool_result_for_event_bounds_large_error() {
let got = truncate_mcp_tool_result_for_event(&Err("error-message-".repeat(200_000)))
.expect_err("large error should remain an error");
// `truncate_text` includes its own marker, so allow a small amount of
// overhead beyond the requested byte budget.
assert!(got.len() < MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES + 1024);
assert!(got.contains("truncated"));
}
#[tokio::test]
async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() {
let (_, turn_context) = make_session_and_context().await;
@@ -1594,7 +1660,7 @@ enabled = true
session
.services
.plugins_manager
.plugins_for_config(&initial_config)
.plugins_for_config(&initial_config.plugins_config_input())
.await;
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
@@ -2556,31 +2622,19 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
}
#[tokio::test]
async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_enabled() {
async fn approve_mode_skips_arc_and_guardian_when_guardian_reviewer_is_enabled() {
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
let server = start_mock_server().await;
let guardian_request_log = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-guardian"),
ev_assistant_message(
"msg-guardian",
&serde_json::json!({
"risk_level": "low",
"user_authorization": "high",
"outcome": "allow",
"rationale": "The user already configured guardian to review escalated approvals for this session.",
})
.to_string(),
),
ev_completed("resp-guardian"),
]),
)
.await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
@@ -2594,7 +2648,7 @@ async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_
"why": "requires review",
}],
})))
.expect(1)
.expect(0)
.mount(&server)
.await;
@@ -2653,9 +2707,5 @@ async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_
)
.await;
assert_eq!(decision, Some(McpToolApprovalDecision::Accept));
assert_eq!(
guardian_request_log.single_request().path(),
"/v1/responses"
);
assert_eq!(decision, None);
}
+3 -4
View File
@@ -3,6 +3,7 @@ use codex_config::config_toml::ConfigToml;
use codex_protocol::config_types::Personality;
use codex_thread_store::ListThreadsParams;
use codex_thread_store::LocalThreadStore;
use codex_thread_store::LocalThreadStoreConfig;
use codex_thread_store::ThreadSortKey;
use codex_thread_store::ThreadStore;
use std::io;
@@ -60,12 +61,10 @@ pub async fn maybe_migrate_personality(
}
async fn has_recorded_sessions(codex_home: &Path, default_provider: &str) -> io::Result<bool> {
let store = LocalThreadStore::new(codex_rollout::RolloutConfig {
let store = LocalThreadStore::new(LocalThreadStoreConfig {
codex_home: codex_home.to_path_buf(),
sqlite_home: codex_home.to_path_buf(),
cwd: codex_home.to_path_buf(),
model_provider_id: default_provider.to_string(),
generate_memories: false,
default_model_provider_id: default_provider.to_string(),
});
if has_threads(&store, /*archived*/ false).await? {
return Ok(true);
+8 -3
View File
@@ -3,11 +3,11 @@ use std::collections::HashSet;
use tracing::warn;
use super::PluginCapabilitySummary;
use super::PluginsManager;
use crate::config::Config;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST;
use codex_features::Feature;
use codex_tools::DiscoverablePluginInfo;
@@ -25,6 +25,7 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
}
let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf());
let plugins_input = config.plugins_config_input();
let configured_plugin_ids = config
.tool_suggest
.discoverables
@@ -40,7 +41,7 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
.map(|disabled_tool| disabled_tool.id.as_str())
.collect::<HashSet<_>>();
let marketplaces = plugins_manager
.list_marketplaces_for_config(config, &[])
.list_marketplaces_for_config(&plugins_input, &[])
.context("failed to list plugin marketplaces for tool suggestions")?
.marketplaces;
let mut discoverable_plugins = Vec::<DiscoverablePluginInfo>::new();
@@ -62,7 +63,11 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
let plugin_id = plugin.id.clone();
match plugins_manager
.read_plugin_detail_for_marketplace_plugin(config, &marketplace_name, plugin)
.read_plugin_detail_for_marketplace_plugin(
&plugins_input,
&marketplace_name,
plugin,
)
.await
{
Ok(plugin) => {
@@ -1,11 +1,11 @@
use super::*;
use crate::plugins::PluginInstallRequest;
use crate::plugins::test_support::load_plugins_config;
use crate::plugins::test_support::write_curated_plugin;
use crate::plugins::test_support::write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use crate::plugins::test_support::write_plugins_feature_config;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_tools::DiscoverablePluginInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -30
View File
@@ -1,43 +1,14 @@
use codex_config::types::McpServerConfig;
mod discoverable;
mod injection;
mod manager;
mod mentions;
mod render;
mod startup_sync;
#[cfg(test)]
pub(crate) mod test_support;
pub use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
pub use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;
pub use codex_plugin::AppConnectorId;
pub use codex_plugin::EffectiveSkillRoots;
pub use codex_plugin::PluginCapabilitySummary;
pub use codex_plugin::PluginId;
pub use codex_plugin::PluginIdError;
pub use codex_plugin::PluginTelemetryMetadata;
pub use codex_plugin::validate_plugin_segment;
pub type LoadedPlugin = codex_plugin::LoadedPlugin<McpServerConfig>;
pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome<McpServerConfig>;
pub(crate) use codex_plugin::PluginCapabilitySummary;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use injection::build_plugin_injections;
pub use manager::ConfiguredMarketplace;
pub use manager::ConfiguredMarketplaceListOutcome;
pub use manager::ConfiguredMarketplacePlugin;
pub use manager::PluginDetail;
pub use manager::PluginDetailsUnavailableReason;
pub use manager::PluginInstallError;
pub use manager::PluginInstallOutcome;
pub use manager::PluginInstallRequest;
pub use manager::PluginReadOutcome;
pub use manager::PluginReadRequest;
pub use manager::PluginRemoteSyncError;
pub use manager::PluginUninstallError;
pub use manager::PluginsManager;
pub use manager::RemotePluginSyncResult;
pub(crate) use render::render_explicit_plugin_instructions;
pub(crate) use mentions::build_connector_slug_counts;
-100
View File
@@ -1,100 +0,0 @@
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use crate::config::Config;
use crate::plugins::PluginsManager;
use codex_core_plugins::startup_sync::has_local_curated_plugins_snapshot;
use codex_login::AuthManager;
use tracing::info;
use tracing::warn;
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) fn start_startup_remote_plugin_sync_once(
manager: Arc<PluginsManager>,
codex_home: PathBuf,
config: Config,
auth_manager: Arc<AuthManager>,
) {
let marker_path = startup_remote_plugin_sync_marker_path(codex_home.as_path());
if marker_path.is_file() {
return;
}
tokio::spawn(async move {
if marker_path.is_file() {
return;
}
if !wait_for_startup_remote_plugin_sync_prerequisites(codex_home.as_path()).await {
warn!(
codex_home = %codex_home.display(),
"skipping startup remote plugin sync because curated marketplace is not ready"
);
return;
}
let auth = auth_manager.auth().await;
match manager
.sync_plugins_from_remote(&config, auth.as_ref(), /*additive_only*/ true)
.await
{
Ok(sync_result) => {
info!(
installed_plugin_ids = ?sync_result.installed_plugin_ids,
enabled_plugin_ids = ?sync_result.enabled_plugin_ids,
disabled_plugin_ids = ?sync_result.disabled_plugin_ids,
uninstalled_plugin_ids = ?sync_result.uninstalled_plugin_ids,
"completed startup remote plugin sync"
);
if let Err(err) =
write_startup_remote_plugin_sync_marker(codex_home.as_path()).await
{
warn!(
error = %err,
path = %marker_path.display(),
"failed to persist startup remote plugin sync marker"
);
}
}
Err(err) => {
warn!(
error = %err,
"startup remote plugin sync failed; will retry on next app-server start"
);
}
}
});
}
fn startup_remote_plugin_sync_marker_path(codex_home: &Path) -> PathBuf {
codex_home.join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE)
}
async fn wait_for_startup_remote_plugin_sync_prerequisites(codex_home: &Path) -> bool {
let deadline = tokio::time::Instant::now() + STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT;
loop {
if has_local_curated_plugins_snapshot(codex_home) {
return true;
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::Result<()> {
let marker_path = startup_remote_plugin_sync_marker_path(codex_home);
if let Some(parent) = marker_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(marker_path, b"ok\n").await
}
#[cfg(test)]
#[path = "startup_sync_tests.rs"]
mod tests;
@@ -1,90 +0,0 @@
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::plugins::PluginsManager;
use crate::plugins::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION;
use crate::plugins::test_support::write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use std::time::Duration;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test]
async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() {
let tmp = tempdir().expect("tempdir");
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear"]);
write_curated_plugin_sha(tmp.path());
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated"]
enabled = false
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}
]"#,
))
.mount(&server)
.await;
let mut config = crate::plugins::test_support::load_plugins_config(tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf()));
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
start_startup_remote_plugin_sync_once(
Arc::clone(&manager),
tmp.path().to_path_buf(),
config,
auth_manager,
);
let marker_path = tmp.path().join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE);
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if marker_path.is_file() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("marker should be written");
assert!(
tmp.path()
.join(format!(
"plugins/cache/openai-curated/linear/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
))
.is_dir()
);
let config =
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("config should exist");
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
assert!(config.contains("enabled = true"));
let marker_contents = std::fs::read_to_string(marker_path).expect("marker should be readable");
assert_eq!(marker_contents, "ok\n");
}
@@ -6,7 +6,6 @@ use std::path::Path;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
pub(crate) const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
pub(crate) const TEST_CURATED_PLUGIN_CACHE_VERSION: &str = "01234567";
pub(crate) fn write_file(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
+2 -2
View File
@@ -41,9 +41,9 @@ pub async fn build_prompt_input(
SessionSource::Exec,
Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
);
let thread_store = thread_store_from_config(&config);
let thread = thread_manager.start_thread(config, thread_store).await?;
let thread = thread_manager.start_thread(config).await?;
let output = build_prompt_input_from_session(thread.thread.codex.session.as_ref(), input).await;
let shutdown = thread.thread.shutdown_and_wait().await;
+2 -12
View File
@@ -27,7 +27,6 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use crate::review_prompts::resolve_review_request;
use crate::tasks::CompactTask;
use crate::tasks::UndoTask;
use crate::tasks::UserShellCommandMode;
use crate::tasks::UserShellCommandTask;
use crate::tasks::execute_user_shell_command;
@@ -620,8 +619,9 @@ pub async fn list_skills(sess: &Session, sub_id: String, cwds: Vec<PathBuf>, for
continue;
}
};
let plugins_input = config.plugins_config_input();
let effective_skill_roots = plugins_manager
.effective_skill_roots_for_layer_stack(&config_layer_stack, &config)
.effective_skill_roots_for_layer_stack(&config_layer_stack, &plugins_input)
.await;
let skills_input = crate::SkillsLoadInput::new(
cwd_abs.clone(),
@@ -648,12 +648,6 @@ pub async fn list_skills(sess: &Session, sub_id: String, cwds: Vec<PathBuf>, for
sess.send_event_raw(event).await;
}
pub async fn undo(sess: &Arc<Session>, sub_id: String) {
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
sess.spawn_task(turn_context, Vec::new(), UndoTask::new())
.await;
}
pub async fn compact(sess: &Arc<Session>, sub_id: String) {
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
@@ -1117,10 +1111,6 @@ pub(super) async fn submission_loop(
list_skills(&sess, sub.id.clone(), cwds, force_reload).await;
false
}
Op::Undo => {
undo(&sess, sub.id.clone()).await;
false
}
Op::Compact => {
compact(&sess, sub.id.clone()).await;
false
+8 -16
View File
@@ -136,6 +136,7 @@ use codex_thread_store::LiveThreadInitGuard;
use codex_thread_store::LocalThreadStore;
use codex_thread_store::ResumeThreadParams;
use codex_thread_store::ThreadEventPersistenceMode;
use codex_thread_store::ThreadPersistenceMetadata;
use codex_thread_store::ThreadStore;
use codex_utils_output_truncation::TruncationPolicy;
use futures::future::BoxFuture;
@@ -274,7 +275,6 @@ use crate::exec_policy::ExecPolicyUpdateError;
use crate::guardian::GuardianReviewSessionManager;
use crate::mcp::McpManager;
use crate::network_policy_decision::execpolicy_network_rule_amendment;
use crate::plugins::PluginsManager;
use crate::rollout::map_session_init_error;
use crate::session_startup_prewarm::SessionStartupPrewarmHandle;
use crate::shell;
@@ -301,6 +301,7 @@ use crate::turn_timing::TurnTimingState;
use crate::turn_timing::record_turn_ttfm_metric;
use crate::unified_exec::UnifiedExecProcessManager;
use crate::windows_sandbox::WindowsSandboxLevelExt;
use codex_core_plugins::PluginsManager;
use codex_git_utils::get_git_repo_root;
use codex_mcp::compute_auth_statuses;
use codex_mcp::with_codex_apps_mcp;
@@ -318,7 +319,6 @@ use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::BackgroundEventEvent;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::DeprecationNoticeEvent;
@@ -348,6 +348,7 @@ use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
use codex_protocol::protocol::SkillToolDependency as ProtocolSkillToolDependency;
use codex_protocol::protocol::StreamErrorEvent;
use codex_protocol::protocol::Submission;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
@@ -475,7 +476,8 @@ impl Codex {
let fs = environment
.as_ref()
.map(|environment| environment.get_filesystem());
let plugin_outcome = plugins_manager.plugins_for_config(&config).await;
let plugins_input = config.plugins_config_input();
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
let loaded_skills = skills_manager.skills_for_config(&skills_input, fs).await;
@@ -2666,7 +2668,7 @@ impl Session {
let loaded_plugins = self
.services
.plugins_manager
.plugins_for_config(&turn_context.config)
.plugins_for_config(&turn_context.config.plugins_config_input())
.await;
if let Some(plugin_instructions) =
AvailablePluginsInstructions::from_plugins(loaded_plugins.capability_summaries())
@@ -2937,17 +2939,6 @@ impl Session {
self.ensure_rollout_materialized().await;
}
pub(crate) async fn notify_background_event(
&self,
turn_context: &TurnContext,
message: impl Into<String>,
) {
let event = EventMsg::BackgroundEvent(BackgroundEventEvent {
message: message.into(),
});
self.send_event(turn_context, event).await;
}
pub(crate) async fn notify_stream_error(
&self,
turn_context: &TurnContext,
@@ -3362,7 +3353,8 @@ async fn build_hooks_for_config(
let _ = hook_shell_argv.pop();
let plugin_hooks_enabled = config.features.enabled(Feature::PluginHooks);
let (plugin_hook_sources, plugin_hook_load_warnings) = if plugin_hooks_enabled {
let plugin_outcome = plugins_manager.plugins_for_config(config).await;
let plugins_input = config.plugins_config_input();
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
(
plugin_outcome.effective_plugin_hook_sources(),
plugin_outcome.effective_plugin_hook_warnings(),
+18
View File
@@ -400,6 +400,15 @@ impl Session {
text: session_configuration.base_instructions.clone(),
},
dynamic_tools: session_configuration.dynamic_tools.clone(),
metadata: ThreadPersistenceMetadata {
cwd: Some(config.cwd.to_path_buf()),
model_provider: config.model_provider_id.clone(),
memory_mode: if config.memories.generate_memories {
ThreadMemoryMode::Enabled
} else {
ThreadMemoryMode::Disabled
},
},
event_persistence_mode,
},
)
@@ -413,6 +422,15 @@ impl Session {
rollout_path: resumed_history.rollout_path.clone(),
history: Some(resumed_history.history.clone()),
include_archived: true,
metadata: ThreadPersistenceMetadata {
cwd: Some(config.cwd.to_path_buf()),
model_provider: config.model_provider_id.clone(),
memory_mode: if config.memories.generate_memories {
ThreadMemoryMode::Enabled
} else {
ThreadMemoryMode::Disabled
},
},
event_persistence_mode,
},
)
+26 -9
View File
@@ -1681,9 +1681,6 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
.fork_thread(
usize::MAX,
fork_config.clone(),
std::sync::Arc::new(codex_thread_store::LocalThreadStore::new(
codex_rollout::RolloutConfig::from_view(&fork_config),
)),
rollout_path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
@@ -2722,6 +2719,7 @@ async fn wait_for_thread_rollback_failed(rx: &async_channel::Receiver<Event>) ->
}
async fn attach_thread_persistence(session: &mut Session) -> PathBuf {
let config = session.get_config().await;
let live_thread = LiveThread::create(
Arc::clone(&session.services.thread_store),
CreateThreadParams {
@@ -2730,6 +2728,15 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf {
source: SessionSource::Exec,
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
metadata: ThreadPersistenceMetadata {
cwd: Some(config.cwd.to_path_buf()),
model_provider: config.model_provider_id.clone(),
memory_mode: if config.memories.generate_memories {
ThreadMemoryMode::Enabled
} else {
ThreadMemoryMode::Disabled
},
},
event_persistence_mode: ThreadEventPersistenceMode::Limited,
},
)
@@ -3394,7 +3401,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
Arc::new(codex_thread_store::LocalThreadStore::new(
codex_rollout::RolloutConfig::from_view(config.as_ref()),
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
)),
codex_rollout_trace::ThreadTraceContext::disabled(),
)
@@ -3541,7 +3548,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
state_db: None,
live_thread: None,
thread_store: Arc::new(codex_thread_store::LocalThreadStore::new(
codex_rollout::RolloutConfig::from_view(config.as_ref()),
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
)),
model_client: ModelClient::new(
Some(auth_manager.clone()),
@@ -3561,7 +3568,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let plugin_outcome = services
.plugins_manager
.plugins_for_config(&per_turn_config)
.plugins_for_config(&per_turn_config.plugins_config_input())
.await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input =
@@ -3714,7 +3721,7 @@ async fn make_session_with_config_and_rx(
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
Arc::new(codex_thread_store::LocalThreadStore::new(
codex_rollout::RolloutConfig::from_view(config.as_ref()),
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
)),
codex_rollout_trace::ThreadTraceContext::disabled(),
)
@@ -4577,6 +4584,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() {
let (mut session, _turn_context) = make_session_and_context().await;
let store = Arc::new(codex_thread_store::InMemoryThreadStore::default());
let thread_store: Arc<dyn codex_thread_store::ThreadStore> = store.clone();
let config = session.get_config().await;
let live_thread = LiveThread::create(
Arc::clone(&thread_store),
CreateThreadParams {
@@ -4585,6 +4593,15 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() {
source: SessionSource::Exec,
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
metadata: ThreadPersistenceMetadata {
cwd: Some(config.cwd.to_path_buf()),
model_provider: config.model_provider_id.clone(),
memory_mode: if config.memories.generate_memories {
ThreadMemoryMode::Enabled
} else {
ThreadMemoryMode::Disabled
},
},
event_persistence_mode: ThreadEventPersistenceMode::Limited,
},
)
@@ -4971,7 +4988,7 @@ where
state_db: None,
live_thread: None,
thread_store: Arc::new(codex_thread_store::LocalThreadStore::new(
codex_rollout::RolloutConfig::from_view(config.as_ref()),
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
)),
model_client: ModelClient::new(
Some(Arc::clone(&auth_manager)),
@@ -4991,7 +5008,7 @@ where
let plugin_outcome = services
.plugins_manager
.plugins_for_config(&per_turn_config)
.plugins_for_config(&per_turn_config.plugins_config_input())
.await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input =
@@ -729,7 +729,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_watcher = Arc::new(SkillsWatcher::noop());
let thread_store = Arc::new(codex_thread_store::LocalThreadStore::new(
codex_rollout::RolloutConfig::from_view(&config),
codex_thread_store::LocalThreadStoreConfig::from_config(&config),
));
let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
+4 -10
View File
@@ -170,7 +170,7 @@ pub(crate) async fn run_turn(
let loaded_plugins = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config)
.plugins_for_config(&turn_context.config.plugins_config_input())
.await;
// Structured plugin:// mentions are resolved from the current session's
// enabled plugins, then converted into turn-scoped guidance below.
@@ -336,7 +336,7 @@ pub(crate) async fn run_turn(
record_additional_contexts(&sess, &turn_context, additional_contexts).await;
if !input.is_empty() {
// Track the previous-turn baseline from the regular user-turn path only so
// standalone tasks (compact/shell/review/undo) cannot suppress future
// standalone tasks (compact/shell/review) cannot suppress future
// model/realtime injections.
sess.set_previous_turn_settings(Some(PreviousTurnSettings {
model: turn_context.model_info.slug.clone(),
@@ -1124,7 +1124,7 @@ pub(crate) async fn built_tools(
let loaded_plugins = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config)
.plugins_for_config(&turn_context.config.plugins_config_input())
.await;
let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone();
@@ -1443,11 +1443,8 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::TurnComplete(_)
| EventMsg::TokenCount(_)
| EventMsg::UserMessage(_)
| EventMsg::AgentMessageDelta(_)
| EventMsg::AgentReasoning(_)
| EventMsg::AgentReasoningDelta(_)
| EventMsg::AgentReasoningRawContent(_)
| EventMsg::AgentReasoningRawContentDelta(_)
| EventMsg::AgentReasoningSectionBreak(_)
| EventMsg::SessionConfigured(_)
| EventMsg::ThreadNameUpdated(_)
@@ -1477,9 +1474,6 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::ElicitationRequest(_)
| EventMsg::ApplyPatchApprovalRequest(_)
| EventMsg::DeprecationNotice(_)
| EventMsg::BackgroundEvent(_)
| EventMsg::UndoStarted(_)
| EventMsg::UndoCompleted(_)
| EventMsg::StreamError(_)
| EventMsg::TurnDiff(_)
| EventMsg::GetHistoryEntryResponse(_)
@@ -1902,7 +1896,7 @@ async fn try_run_sampling_request(
ResponseEvent::Created => {}
ResponseEvent::OutputItemDone(item) => {
if let Some((_, mut consumer)) = active_tool_argument_diff_consumer.take()
&& let Some(event) = consumer.flush_on_complete()
&& let Ok(Some(event)) = consumer.finish()
{
sess.send_event(&turn_context, event).await;
}
+1 -1
View File
@@ -696,7 +696,7 @@ impl Session {
let plugin_outcome = self
.services
.plugins_manager
.plugins_for_config(&per_turn_config)
.plugins_for_config(&per_turn_config.plugins_config_input())
.await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots);
+3 -2
View File
@@ -16,8 +16,8 @@ use crate::file_watcher::Receiver;
use crate::file_watcher::ThrottledWatchReceiver;
use crate::file_watcher::WatchPath;
use crate::file_watcher::WatchRegistration;
use crate::plugins::PluginsManager;
use crate::skills_load_input_from_config;
use codex_core_plugins::PluginsManager;
#[cfg(not(test))]
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10);
@@ -61,7 +61,8 @@ impl SkillsWatcher {
plugins_manager: &PluginsManager,
fs: Option<Arc<dyn codex_exec_server::ExecutorFileSystem>>,
) -> WatchRegistration {
let plugin_outcome = plugins_manager.plugins_for_config(config).await;
let plugins_input = config.plugins_config_input();
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input = skills_load_input_from_config(config, effective_skill_roots);
let roots = skills_manager
+1 -1
View File
@@ -9,7 +9,6 @@ use crate::exec_policy::ExecPolicyManager;
use crate::guardian::GuardianRejection;
use crate::guardian::GuardianRejectionCircuitBreaker;
use crate::mcp::McpManager;
use crate::plugins::PluginsManager;
use crate::skills_watcher::SkillsWatcher;
use crate::tools::code_mode::CodeModeService;
use crate::tools::network_approval::NetworkApprovalService;
@@ -17,6 +16,7 @@ use crate::tools::sandboxing::ApprovalStore;
use crate::unified_exec::UnifiedExecProcessManager;
use arc_swap::ArcSwap;
use codex_analytics::AnalyticsEventsClient;
use codex_core_plugins::PluginsManager;
use codex_exec_server::EnvironmentManager;
use codex_hooks::Hooks;
use codex_login::AuthManager;
-2
View File
@@ -1,7 +1,6 @@
mod compact;
mod regular;
mod review;
mod undo;
mod user_shell;
use std::sync::Arc;
@@ -57,7 +56,6 @@ use codex_protocol::models::ContentItem;
pub(crate) use compact::CompactTask;
pub(crate) use regular::RegularTask;
pub(crate) use review::ReviewTask;
pub(crate) use undo::UndoTask;
pub(crate) use user_shell::UserShellCommandMode;
pub(crate) use user_shell::UserShellCommandTask;
pub(crate) use user_shell::execute_user_shell_command;
-2
View File
@@ -6,7 +6,6 @@ use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AgentMessageContentDeltaEvent;
use codex_protocol::protocol::AgentMessageDeltaEvent;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
@@ -162,7 +161,6 @@ async fn process_review_events(
item: TurnItem::AgentMessage(_),
..
})
| EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { .. })
| EventMsg::AgentMessageContentDelta(AgentMessageContentDeltaEvent { .. }) => {}
EventMsg::TurnComplete(task_complete) => {
// Parse review output from the last agent message (if present).
-72
View File
@@ -1,72 +0,0 @@
use std::sync::Arc;
use crate::session::turn_context::TurnContext;
use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::UndoCompletedEvent;
use codex_protocol::protocol::UndoStartedEvent;
use codex_protocol::user_input::UserInput;
use tokio_util::sync::CancellationToken;
pub(crate) struct UndoTask;
impl UndoTask {
pub(crate) fn new() -> Self {
Self
}
}
impl SessionTask for UndoTask {
fn kind(&self) -> TaskKind {
TaskKind::Regular
}
fn span_name(&self) -> &'static str {
"session_task.undo"
}
async fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
_input: Vec<UserInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
session
.session
.services
.session_telemetry
.counter("codex.task.undo", /*inc*/ 1, &[]);
let sess = session.clone_session();
sess.send_event(
ctx.as_ref(),
EventMsg::UndoStarted(UndoStartedEvent {
message: Some("Undo in progress...".to_string()),
}),
)
.await;
if cancellation_token.is_cancelled() {
sess.send_event(
ctx.as_ref(),
EventMsg::UndoCompleted(UndoCompletedEvent {
success: false,
message: Some("Undo cancelled.".to_string()),
}),
)
.await;
return None;
}
let completed = UndoCompletedEvent {
success: false,
message: Some("Undo is no longer available.".to_string()),
};
sess.send_event(ctx.as_ref(), EventMsg::UndoCompleted(completed))
.await;
None
}
}
+1 -5
View File
@@ -20,7 +20,6 @@ use codex_models_manager::test_support::get_model_offline_for_tests;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
use codex_thread_store::ThreadStore;
use once_cell::sync::Lazy;
use crate::ThreadManager;
@@ -77,18 +76,16 @@ pub fn thread_manager_with_models_provider_and_home(
pub async fn start_thread_with_user_shell_override(
thread_manager: &ThreadManager,
config: Config,
thread_store: Arc<dyn ThreadStore>,
user_shell_override: crate::shell::Shell,
) -> codex_protocol::error::Result<crate::NewThread> {
thread_manager
.start_thread_with_user_shell_override_for_tests(config, thread_store, user_shell_override)
.start_thread_with_user_shell_override_for_tests(config, user_shell_override)
.await
}
pub async fn resume_thread_from_rollout_with_user_shell_override(
thread_manager: &ThreadManager,
config: Config,
thread_store: Arc<dyn ThreadStore>,
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
user_shell_override: crate::shell::Shell,
@@ -96,7 +93,6 @@ pub async fn resume_thread_from_rollout_with_user_shell_override(
thread_manager
.resume_thread_from_rollout_with_user_shell_override_for_tests(
config,
thread_store,
rollout_path,
auth_manager,
user_shell_override,
+99 -64
View File
@@ -8,7 +8,6 @@ use crate::environment_selection::selected_primary_environment;
use crate::environment_selection::validate_environment_selections;
use crate::file_watcher::FileWatcher;
use crate::mcp::McpManager;
use crate::plugins::PluginsManager;
use crate::rollout::RolloutRecorder;
use crate::rollout::truncation;
use crate::session::Codex;
@@ -23,11 +22,13 @@ use crate::tasks::interrupted_turn_history_marker;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::TurnStatus;
use codex_core_plugins::PluginsManager;
use codex_exec_server::EnvironmentManager;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::OPENAI_PROVIDER_ID;
use codex_models_manager::manager::RefreshStrategy;
use codex_models_manager::manager::SharedModelsManager;
use codex_protocol::ThreadId;
@@ -50,13 +51,15 @@ use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::W3cTraceContext;
use codex_rollout::RolloutConfig;
use codex_state::DirectionalThreadSpawnEdgeStatus;
#[cfg(debug_assertions)]
use codex_thread_store::InMemoryThreadStore;
use codex_thread_store::LocalThreadStore;
use codex_thread_store::LocalThreadStoreConfig;
use codex_thread_store::ReadThreadParams;
use codex_thread_store::RemoteThreadStore;
use codex_thread_store::StoredThread;
use codex_thread_store::ThreadStore;
use codex_thread_store::ThreadStoreError;
use codex_utils_absolute_path::AbsolutePathBuf;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
@@ -212,7 +215,6 @@ pub struct ThreadManager {
pub struct StartThreadOptions {
pub config: Config,
pub thread_store: Arc<dyn ThreadStore>,
pub initial_history: InitialHistory,
pub session_source: Option<SessionSource>,
pub dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
@@ -222,10 +224,9 @@ pub struct StartThreadOptions {
pub environments: Vec<TurnEnvironmentSelection>,
}
pub(crate) struct ResumeThreadFromRolloutOptions {
pub(crate) struct ResumeThreadWithHistoryOptions {
pub(crate) config: Config,
pub(crate) thread_store: Arc<dyn ThreadStore>,
pub(crate) rollout_path: PathBuf,
pub(crate) initial_history: InitialHistory,
pub(crate) agent_control: AgentControl,
pub(crate) session_source: SessionSource,
pub(crate) inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
@@ -245,6 +246,7 @@ pub(crate) struct ThreadManagerState {
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
skills_watcher: Arc<SkillsWatcher>,
thread_store: Arc<dyn ThreadStore>,
session_source: SessionSource,
analytics_events_client: Option<AnalyticsEventsClient>,
// Captures submitted ops for testing purpose when test mode is enabled.
@@ -264,11 +266,10 @@ pub fn build_models_manager(
pub fn thread_store_from_config(config: &Config) -> Arc<dyn ThreadStore> {
match &config.experimental_thread_store {
ThreadStoreConfig::Local => {
Arc::new(LocalThreadStore::new(RolloutConfig::from_view(config)))
}
ThreadStoreConfig::Local => Arc::new(LocalThreadStore::new(
LocalThreadStoreConfig::from_config(config),
)),
ThreadStoreConfig::Remote { endpoint } => Arc::new(RemoteThreadStore::new(endpoint)),
#[cfg(debug_assertions)]
ThreadStoreConfig::InMemory { id } => InMemoryThreadStore::for_id(id),
}
}
@@ -280,6 +281,7 @@ impl ThreadManager {
session_source: SessionSource,
environment_manager: Arc<EnvironmentManager>,
analytics_events_client: Option<AnalyticsEventsClient>,
thread_store: Arc<dyn ThreadStore>,
) -> Self {
let codex_home = config.codex_home.clone();
let restriction_product = session_source.restriction_product();
@@ -305,6 +307,7 @@ impl ThreadManager {
plugins_manager,
mcp_manager,
skills_watcher,
thread_store,
auth_manager,
session_source,
analytics_events_client,
@@ -365,6 +368,14 @@ impl ThreadManager {
restriction_product,
));
let skills_watcher = build_skills_watcher(Arc::clone(&skills_manager));
// This test constructor has no Config input. Tests that need a non-local
// process store should construct ThreadManager::new with an explicit store.
let thread_store: Arc<dyn ThreadStore> =
Arc::new(LocalThreadStore::new(LocalThreadStoreConfig {
codex_home: codex_home.clone(),
sqlite_home: codex_home.clone(),
default_model_provider_id: OPENAI_PROVIDER_ID.to_string(),
}));
Self {
state: Arc::new(ThreadManagerState {
threads: Arc::new(RwLock::new(HashMap::new())),
@@ -376,6 +387,7 @@ impl ThreadManager {
plugins_manager,
mcp_manager,
skills_watcher,
thread_store,
auth_manager,
session_source: SessionSource::Exec,
analytics_events_client: None,
@@ -519,16 +531,11 @@ impl ThreadManager {
Ok(subtree_thread_ids)
}
pub async fn start_thread(
&self,
config: Config,
thread_store: Arc<dyn ThreadStore>,
) -> CodexResult<NewThread> {
pub async fn start_thread(&self, config: Config) -> CodexResult<NewThread> {
// Box delegated thread-spawn futures so these convenience wrappers do
// not inline the full spawn path into every caller's async state.
Box::pin(self.start_thread_with_tools(
config,
thread_store,
Vec::new(),
/*persist_extended_history*/ false,
))
@@ -538,7 +545,6 @@ impl ThreadManager {
pub async fn start_thread_with_tools(
&self,
config: Config,
thread_store: Arc<dyn ThreadStore>,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
persist_extended_history: bool,
) -> CodexResult<NewThread> {
@@ -548,7 +554,6 @@ impl ThreadManager {
);
Box::pin(self.start_thread_with_options(StartThreadOptions {
config,
thread_store,
initial_history: InitialHistory::New,
session_source: None,
dynamic_tools,
@@ -569,7 +574,6 @@ impl ThreadManager {
.unwrap_or_else(|| self.state.session_source.clone());
Box::pin(self.state.spawn_thread_with_source(
options.config,
options.thread_store,
options.initial_history,
Arc::clone(&self.state.auth_manager),
self.agent_control(),
@@ -589,7 +593,6 @@ impl ThreadManager {
pub async fn resume_thread_from_rollout(
&self,
config: Config,
thread_store: Arc<dyn ThreadStore>,
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
parent_trace: Option<W3cTraceContext>,
@@ -597,7 +600,6 @@ impl ThreadManager {
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
Box::pin(self.resume_thread_with_history(
config,
thread_store,
initial_history,
auth_manager,
/*persist_extended_history*/ false,
@@ -609,7 +611,6 @@ impl ThreadManager {
pub async fn resume_thread_with_history(
&self,
config: Config,
thread_store: Arc<dyn ThreadStore>,
initial_history: InitialHistory,
auth_manager: Arc<AuthManager>,
persist_extended_history: bool,
@@ -621,7 +622,6 @@ impl ThreadManager {
);
Box::pin(self.state.spawn_thread(
config,
thread_store,
initial_history,
auth_manager,
self.agent_control(),
@@ -638,7 +638,6 @@ impl ThreadManager {
pub(crate) async fn start_thread_with_user_shell_override_for_tests(
&self,
config: Config,
thread_store: Arc<dyn ThreadStore>,
user_shell_override: crate::shell::Shell,
) -> CodexResult<NewThread> {
let environments = default_thread_environment_selections(
@@ -647,7 +646,6 @@ impl ThreadManager {
);
Box::pin(self.state.spawn_thread(
config,
thread_store,
InitialHistory::New,
Arc::clone(&self.state.auth_manager),
self.agent_control(),
@@ -664,7 +662,6 @@ impl ThreadManager {
pub(crate) async fn resume_thread_from_rollout_with_user_shell_override_for_tests(
&self,
config: Config,
thread_store: Arc<dyn ThreadStore>,
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
user_shell_override: crate::shell::Shell,
@@ -676,7 +673,6 @@ impl ThreadManager {
);
Box::pin(self.state.spawn_thread(
config,
thread_store,
initial_history,
auth_manager,
self.agent_control(),
@@ -756,7 +752,6 @@ impl ThreadManager {
&self,
snapshot: S,
config: Config,
thread_store: Arc<dyn ThreadStore>,
path: PathBuf,
persist_extended_history: bool,
parent_trace: Option<W3cTraceContext>,
@@ -769,7 +764,6 @@ impl ThreadManager {
self.fork_thread_from_history(
snapshot,
config,
thread_store,
history,
persist_extended_history,
parent_trace,
@@ -782,7 +776,6 @@ impl ThreadManager {
&self,
snapshot: S,
config: Config,
thread_store: Arc<dyn ThreadStore>,
history: InitialHistory,
persist_extended_history: bool,
parent_trace: Option<W3cTraceContext>,
@@ -793,7 +786,6 @@ impl ThreadManager {
self.fork_thread_with_initial_history(
snapshot.into(),
config,
thread_store,
history,
persist_extended_history,
parent_trace,
@@ -805,7 +797,6 @@ impl ThreadManager {
&self,
snapshot: ForkSnapshot,
config: Config,
thread_store: Arc<dyn ThreadStore>,
history: InitialHistory,
persist_extended_history: bool,
parent_trace: Option<W3cTraceContext>,
@@ -818,7 +809,6 @@ impl ThreadManager {
);
Box::pin(self.state.spawn_thread(
config,
thread_store,
history,
Arc::clone(&self.state.auth_manager),
self.agent_control(),
@@ -867,6 +857,31 @@ impl ThreadManagerState {
}
}
pub(crate) async fn read_stored_thread(
&self,
params: ReadThreadParams,
) -> CodexResult<StoredThread> {
let thread_id = params.thread_id;
self.thread_store
.read_thread(params)
.await
.map_err(|err| match err {
ThreadStoreError::ThreadNotFound { thread_id } => {
CodexErr::ThreadNotFound(thread_id)
}
ThreadStoreError::InvalidRequest { message } => {
if message.starts_with("no rollout found for thread id ") {
CodexErr::ThreadNotFound(thread_id)
} else {
CodexErr::Fatal(format!(
"failed to read stored thread {thread_id}: invalid thread-store request: {message}"
))
}
}
err => CodexErr::Fatal(format!("failed to read stored thread {thread_id}: {err}")),
})
}
/// Send an operation to a thread by ID.
pub(crate) async fn send_op(&self, thread_id: ThreadId, op: Op) -> CodexResult<String> {
let thread = self.get_thread(thread_id).await?;
@@ -898,12 +913,10 @@ impl ThreadManagerState {
pub(crate) async fn spawn_new_thread(
&self,
config: Config,
thread_store: Arc<dyn ThreadStore>,
agent_control: AgentControl,
) -> CodexResult<NewThread> {
Box::pin(self.spawn_new_thread_with_source(
config,
thread_store,
agent_control,
self.session_source.clone(),
/*persist_extended_history*/ false,
@@ -919,7 +932,6 @@ impl ThreadManagerState {
pub(crate) async fn spawn_new_thread_with_source(
&self,
config: Config,
thread_store: Arc<dyn ThreadStore>,
agent_control: AgentControl,
session_source: SessionSource,
persist_extended_history: bool,
@@ -933,7 +945,6 @@ impl ThreadManagerState {
});
Box::pin(self.spawn_thread_with_source(
config,
thread_store,
InitialHistory::New,
Arc::clone(&self.auth_manager),
agent_control,
@@ -950,25 +961,22 @@ impl ThreadManagerState {
.await
}
pub(crate) async fn resume_thread_from_rollout_with_source(
pub(crate) async fn resume_thread_with_history_with_source(
&self,
options: ResumeThreadFromRolloutOptions,
options: ResumeThreadWithHistoryOptions,
) -> CodexResult<NewThread> {
let ResumeThreadFromRolloutOptions {
let ResumeThreadWithHistoryOptions {
config,
thread_store,
rollout_path,
initial_history,
agent_control,
session_source,
inherited_shell_snapshot,
inherited_exec_policy,
} = options;
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
let environments =
default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd);
Box::pin(self.spawn_thread_with_source(
config,
thread_store,
initial_history,
Arc::clone(&self.auth_manager),
agent_control,
@@ -989,7 +997,6 @@ impl ThreadManagerState {
pub(crate) async fn fork_thread_with_source(
&self,
config: Config,
thread_store: Arc<dyn ThreadStore>,
initial_history: InitialHistory,
agent_control: AgentControl,
session_source: SessionSource,
@@ -1003,7 +1010,6 @@ impl ThreadManagerState {
});
Box::pin(self.spawn_thread_with_source(
config,
thread_store,
initial_history,
Arc::clone(&self.auth_manager),
agent_control,
@@ -1025,7 +1031,6 @@ impl ThreadManagerState {
pub(crate) async fn spawn_thread(
&self,
config: Config,
thread_store: Arc<dyn ThreadStore>,
initial_history: InitialHistory,
auth_manager: Arc<AuthManager>,
agent_control: AgentControl,
@@ -1038,7 +1043,6 @@ impl ThreadManagerState {
) -> CodexResult<NewThread> {
Box::pin(self.spawn_thread_with_source(
config,
thread_store,
initial_history,
auth_manager,
agent_control,
@@ -1059,7 +1063,6 @@ impl ThreadManagerState {
pub(crate) async fn spawn_thread_with_source(
&self,
config: Config,
thread_store: Arc<dyn ThreadStore>,
initial_history: InitialHistory,
auth_manager: Arc<AuthManager>,
agent_control: AgentControl,
@@ -1074,6 +1077,27 @@ impl ThreadManagerState {
user_shell_override: Option<crate::shell::Shell>,
) -> CodexResult<NewThread> {
let is_resumed_thread = matches!(&initial_history, InitialHistory::Resumed(_));
if let InitialHistory::Resumed(resumed) = &initial_history {
let mut threads = self.threads.write().await;
if let Some(thread) = threads.get(&resumed.conversation_id).cloned() {
if thread.is_running() {
if let Some(requested_rollout_path) = resumed.rollout_path.as_deref()
&& thread.rollout_path().as_deref() != Some(requested_rollout_path)
{
return Err(CodexErr::InvalidRequest(format!(
"thread {} is already running with a different rollout path",
resumed.conversation_id
)));
}
return Ok(NewThread {
thread_id: resumed.conversation_id,
session_configured: thread.session_configured(),
thread,
});
}
threads.remove(&resumed.conversation_id);
}
}
let environment =
selected_primary_environment(self.environment_manager.as_ref(), &environments)?;
let watch_registration = match environment.as_ref() {
@@ -1117,7 +1141,7 @@ impl ThreadManagerState {
parent_trace,
environments,
analytics_events_client: self.analytics_events_client.clone(),
thread_store,
thread_store: Arc::clone(&self.thread_store),
})
.await?;
let new_thread = self
@@ -1149,20 +1173,31 @@ impl ThreadManagerState {
}
};
let thread = Arc::new(CodexThread::new(
codex,
session_configured.rollout_path.clone(),
session_source,
watch_registration,
));
let mut threads = self.threads.write().await;
threads.insert(thread_id, thread.clone());
{
let mut threads = self.threads.write().await;
if let std::collections::hash_map::Entry::Vacant(e) = threads.entry(thread_id) {
let thread = Arc::new(CodexThread::new(
codex,
session_configured.clone(),
session_configured.rollout_path.clone(),
session_source,
watch_registration,
));
e.insert(thread.clone());
return Ok(NewThread {
thread_id,
thread,
session_configured,
});
}
}
Ok(NewThread {
thread_id,
thread,
session_configured,
})
if let Err(err) = codex.shutdown_and_wait().await {
warn!("failed to shut down duplicate thread {thread_id}: {err}");
}
Err(CodexErr::InvalidRequest(format!(
"thread {thread_id} is already running"
)))
}
pub(crate) fn notify_thread_created(&self, thread_id: ThreadId) {
+121 -21
View File
@@ -161,8 +161,7 @@ fn fork_thread_accepts_legacy_usize_snapshot_argument() {
) {
let _future = manager.fork_thread(
usize::MAX,
config.clone(),
thread_store_from_config(&config),
config,
path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
@@ -263,12 +262,12 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() {
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
let thread_1 = manager
.start_thread(config.clone(), thread_store_from_config(&config))
.start_thread(config.clone())
.await
.expect("start first thread")
.thread_id;
let thread_2 = manager
.start_thread(config.clone(), thread_store_from_config(&config))
.start_thread(config.clone())
.await
.expect("start second thread")
.thread_id;
@@ -314,7 +313,6 @@ async fn start_thread_accepts_explicit_environment_when_default_environment_is_d
let thread = manager
.start_thread_with_options(StartThreadOptions {
thread_store: thread_store_from_config(&config),
config: config.clone(),
initial_history: InitialHistory::New,
session_source: None,
@@ -347,10 +345,8 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() {
config.codex_home.to_path_buf(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
let thread_store = thread_store_from_config(&config);
let thread = manager
.start_thread_with_options(StartThreadOptions {
thread_store,
config,
initial_history: InitialHistory::New,
session_source: Some(SessionSource::Internal(
@@ -393,6 +389,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
);
let selected_cwd =
AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path");
@@ -401,11 +398,8 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
cwd: selected_cwd.clone(),
}];
let default_cwd = config.cwd.clone();
let thread_store = thread_store_from_config(&config);
let source = manager
.start_thread_with_options(StartThreadOptions {
thread_store: Arc::clone(&thread_store),
config: config.clone(),
initial_history: InitialHistory::New,
session_source: None,
@@ -437,7 +431,6 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
let resumed = manager
.resume_thread_from_rollout(
config.clone(),
Arc::clone(&thread_store),
rollout_path.clone(),
auth_manager,
/*parent_trace*/ None,
@@ -459,7 +452,6 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
.fork_thread(
ForkSnapshot::Interrupted,
config,
thread_store,
rollout_path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
@@ -478,6 +470,117 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
assert_ne!(forked_turn.environments[0].cwd, selected_cwd);
}
#[tokio::test]
async fn resume_active_thread_from_rollout_returns_running_thread() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
);
let source = manager
.start_thread(config.clone())
.await
.expect("start source thread");
source.thread.ensure_rollout_materialized().await;
source
.thread
.flush_rollout()
.await
.expect("flush source rollout");
let rollout_path = source
.thread
.rollout_path()
.expect("source rollout path should exist");
let resumed = manager
.resume_thread_from_rollout(
config,
rollout_path,
auth_manager,
/*parent_trace*/ None,
)
.await
.expect("resume active source thread");
assert_eq!(resumed.thread_id, source.thread_id);
assert!(Arc::ptr_eq(&resumed.thread, &source.thread));
source
.thread
.shutdown_and_wait()
.await
.expect("shutdown source thread");
}
#[tokio::test]
async fn resume_stopped_thread_from_rollout_spawns_new_thread() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
);
let source = manager
.start_thread(config.clone())
.await
.expect("start source thread");
source.thread.ensure_rollout_materialized().await;
source
.thread
.flush_rollout()
.await
.expect("flush source rollout");
let rollout_path = source
.thread
.rollout_path()
.expect("source rollout path should exist");
source
.thread
.shutdown_and_wait()
.await
.expect("shutdown source thread");
let resumed = manager
.resume_thread_from_rollout(
config,
rollout_path,
auth_manager,
/*parent_trace*/ None,
)
.await
.expect("resume stopped source thread");
assert_eq!(resumed.thread_id, source.thread_id);
assert!(!Arc::ptr_eq(&resumed.thread, &source.thread));
resumed
.thread
.shutdown_and_wait()
.await
.expect("shutdown resumed thread");
}
#[tokio::test]
async fn new_uses_active_provider_for_model_refresh() {
let server = MockServer::start().await;
@@ -499,6 +602,7 @@ async fn new_uses_active_provider_for_model_refresh() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
);
let _ = manager.list_models(RefreshStrategy::Online).await;
@@ -709,12 +813,12 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
);
let source = manager
.resume_thread_with_history(
config.clone(),
thread_store_from_config(&config),
InitialHistory::Forked(vec![
RolloutItem::ResponseItem(user_msg("hello")),
RolloutItem::ResponseItem(assistant_msg("partial")),
@@ -741,7 +845,6 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
.fork_thread(
ForkSnapshot::Interrupted,
config.clone(),
thread_store_from_config(&config),
source_path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
@@ -812,12 +915,12 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
);
let source = manager
.resume_thread_with_history(
config.clone(),
thread_store_from_config(&config),
InitialHistory::Forked(vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-explicit".to_string(),
@@ -855,7 +958,6 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
.fork_thread(
ForkSnapshot::Interrupted,
config.clone(),
thread_store_from_config(&config),
source_path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
@@ -904,12 +1006,12 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
);
let source = manager
.resume_thread_with_history(
config.clone(),
thread_store_from_config(&config),
InitialHistory::Forked(vec![
RolloutItem::ResponseItem(user_msg("hello")),
RolloutItem::ResponseItem(assistant_msg("partial")),
@@ -934,7 +1036,6 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
.fork_thread(
ForkSnapshot::Interrupted,
config.clone(),
thread_store_from_config(&config),
source_path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
@@ -975,7 +1076,6 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
.fork_thread(
ForkSnapshot::Interrupted,
config.clone(),
thread_store_from_config(&config),
forked_path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
@@ -1042,12 +1142,12 @@ async fn resumed_thread_activates_paused_goal_and_continues_on_request() -> anyh
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
);
let source = manager
.resume_thread_with_history(
config.clone(),
thread_store_from_config(&config),
InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("keep working"))]),
auth_manager.clone(),
/*persist_extended_history*/ false,
@@ -1072,12 +1172,12 @@ async fn resumed_thread_activates_paused_goal_and_continues_on_request() -> anyh
/*token_budget*/ None,
)
.await?;
source.thread.shutdown_and_wait().await?;
manager.remove_thread(&source.thread_id).await;
let resumed = manager
.resume_thread_from_rollout(
config.clone(),
thread_store_from_config(&config),
source_path,
auth_manager,
/*parent_trace*/ None,
+9 -1
View File
@@ -1,3 +1,4 @@
use crate::context_manager::truncate_function_output_payload;
use crate::original_image_detail::sanitize_original_image_detail;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
@@ -142,6 +143,7 @@ pub struct McpToolOutput {
pub tool_input: JsonValue,
pub wall_time: Duration,
pub original_image_detail_supported: bool,
pub truncation_policy: TruncationPolicy,
}
impl ToolOutput for McpToolOutput {
@@ -199,7 +201,13 @@ impl McpToolOutput {
}
}
payload
// This is the context-injection form, so keep it aligned with the
// function-call output truncation that conversation history already
// applies. Code-mode consumers still get the raw `CallToolResult`.
//
// The text is serialized again inside the Responses payload, so allow
// a small buffer for JSON escaping and wrapper overhead.
truncate_function_output_payload(&payload, self.truncation_policy * 1.2)
}
}
+51 -2
View File
@@ -101,6 +101,7 @@ fn mcp_tool_output_response_item_includes_wall_time() {
tool_input: json!({}),
wall_time: std::time::Duration::from_millis(1250),
original_image_detail_supported: false,
truncation_policy: TruncationPolicy::Bytes(1024),
};
let response = output.to_response_item(
@@ -137,6 +138,51 @@ fn mcp_tool_output_response_item_includes_wall_time() {
}
}
#[test]
fn mcp_tool_output_response_item_truncates_large_structured_content() {
let output = McpToolOutput {
result: CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "ignored when structured content is present",
})],
structured_content: Some(serde_json::json!({
"items": "large structured value ".repeat(1_000),
})),
is_error: Some(false),
meta: None,
},
tool_input: json!({}),
wall_time: std::time::Duration::from_millis(1250),
original_image_detail_supported: false,
truncation_policy: TruncationPolicy::Bytes(128),
};
let response = output.to_response_item(
"mcp-call-large",
&ToolPayload::Mcp {
server: "server".to_string(),
tool: "tool".to_string(),
raw_arguments: "{}".to_string(),
},
);
match response {
ResponseInputItem::FunctionCallOutput { call_id, output } => {
assert_eq!(call_id, "mcp-call-large");
assert_eq!(output.success, Some(true));
let text = output
.body
.to_text()
.expect("MCP output should serialize as text");
assert!(text.starts_with("Wall time: 1.2500 seconds\nOutput:\n"));
assert!(text.contains("chars truncated"));
assert!(!text.contains("ignored when structured content is present"));
}
other => panic!("expected FunctionCallOutput, got {other:?}"),
}
}
#[test]
fn mcp_tool_output_response_item_preserves_content_items() {
let image_url = "data:image/png;base64,AAA";
@@ -154,6 +200,7 @@ fn mcp_tool_output_response_item_preserves_content_items() {
tool_input: json!({}),
wall_time: std::time::Duration::from_millis(500),
original_image_detail_supported: false,
truncation_policy: TruncationPolicy::Bytes(1024),
};
let response = output.to_response_item(
@@ -193,6 +240,7 @@ fn mcp_tool_output_response_item_preserves_content_items() {
#[test]
fn mcp_tool_output_code_mode_result_stays_raw_call_tool_result() {
let large_content = "large structured value ".repeat(1_000);
let output = McpToolOutput {
result: CallToolResult {
content: vec![serde_json::json!({
@@ -200,7 +248,7 @@ fn mcp_tool_output_code_mode_result_stays_raw_call_tool_result() {
"text": "ignored",
})],
structured_content: Some(serde_json::json!({
"content": "done",
"content": large_content,
})),
is_error: Some(false),
meta: None,
@@ -208,6 +256,7 @@ fn mcp_tool_output_code_mode_result_stays_raw_call_tool_result() {
tool_input: json!({}),
wall_time: std::time::Duration::from_millis(1250),
original_image_detail_supported: false,
truncation_policy: TruncationPolicy::Bytes(64),
};
let result = output.code_mode_result(&ToolPayload::Mcp {
@@ -224,7 +273,7 @@ fn mcp_tool_output_code_mode_result_stays_raw_call_tool_result() {
"text": "ignored",
}],
"structuredContent": {
"content": "done",
"content": "large structured value ".repeat(1_000),
},
"isError": false,
})
@@ -41,7 +41,6 @@ pub struct BatchJobHandler;
const DEFAULT_AGENT_JOB_CONCURRENCY: usize = 16;
const MAX_AGENT_JOB_CONCURRENCY: usize = 64;
const STATUS_POLL_INTERVAL: Duration = Duration::from_millis(250);
const PROGRESS_EMIT_INTERVAL: Duration = Duration::from_secs(1);
const DEFAULT_AGENT_JOB_ITEM_TIMEOUT: Duration = Duration::from_secs(60 * 30);
#[derive(Debug, Deserialize)]
@@ -83,17 +82,6 @@ struct AgentJobFailureSummary {
last_error: String,
}
#[derive(Debug, Serialize)]
struct AgentJobProgressUpdate {
job_id: String,
total_items: usize,
pending_items: usize,
running_items: usize,
completed_items: usize,
failed_items: usize,
eta_seconds: Option<u64>,
}
#[derive(Debug, Serialize)]
struct ReportAgentJobResultToolResult {
accepted: bool,
@@ -112,73 +100,6 @@ struct ActiveJobItem {
status_rx: Option<Receiver<AgentStatus>>,
}
struct JobProgressEmitter {
started_at: Instant,
last_emit_at: Instant,
last_processed: usize,
last_failed: usize,
}
impl JobProgressEmitter {
fn new() -> Self {
let now = Instant::now();
let last_emit_at = now.checked_sub(PROGRESS_EMIT_INTERVAL).unwrap_or(now);
Self {
started_at: now,
last_emit_at,
last_processed: 0,
last_failed: 0,
}
}
async fn maybe_emit(
&mut self,
session: &Session,
turn: &TurnContext,
job_id: &str,
progress: &codex_state::AgentJobProgress,
force: bool,
) -> anyhow::Result<()> {
let processed = progress.completed_items + progress.failed_items;
let should_emit = force
|| processed != self.last_processed
|| progress.failed_items != self.last_failed
|| self.last_emit_at.elapsed() >= PROGRESS_EMIT_INTERVAL;
if !should_emit {
return Ok(());
}
let elapsed = self.started_at.elapsed().as_secs_f64();
let eta_seconds = if processed > 0 && elapsed > 0.0 {
let remaining = progress.total_items.saturating_sub(processed) as f64;
let rate = processed as f64 / elapsed;
if rate > 0.0 {
Some((remaining / rate).round() as u64)
} else {
None
}
} else {
None
};
let update = AgentJobProgressUpdate {
job_id: job_id.to_string(),
total_items: progress.total_items,
pending_items: progress.pending_items,
running_items: progress.running_items,
completed_items: progress.completed_items,
failed_items: progress.failed_items,
eta_seconds,
};
let payload = serde_json::to_string(&update)?;
session
.notify_background_event(turn, format!("agent_job_progress:{payload}"))
.await;
self.last_emit_at = Instant::now();
self.last_processed = processed;
self.last_failed = progress.failed_items;
Ok(())
}
}
impl ToolHandler for BatchJobHandler {
type Output = FunctionToolOutput;
@@ -358,12 +279,6 @@ mod spawn_agents_on_csv {
"failed to transition agent job {job_id} to running: {err}"
))
})?;
let max_threads = turn.config.agent_max_threads;
let effective_concurrency = options.max_concurrency;
let message = format!(
"agent job concurrency: job_id={job_id} requested={requested_concurrency:?} max_threads={max_threads:?} effective={effective_concurrency}"
);
let _ = session.notify_background_event(&turn, message).await;
if let Err(err) = run_agent_job_loop(
session.clone(),
turn.clone(),
@@ -584,7 +499,6 @@ async fn run_agent_job_loop(
.ok_or_else(|| anyhow::anyhow!("agent job {job_id} was not found"))?;
let runtime_timeout = job_runtime_timeout(&job);
let mut active_items: HashMap<ThreadId, ActiveJobItem> = HashMap::new();
let mut progress_emitter = JobProgressEmitter::new();
recover_running_items(
session.clone(),
db.clone(),
@@ -593,16 +507,6 @@ async fn run_agent_job_loop(
runtime_timeout,
)
.await?;
let initial_progress = db.get_agent_job_progress(job_id.as_str()).await?;
progress_emitter
.maybe_emit(
&session,
&turn,
job_id.as_str(),
&initial_progress,
/*force*/ true,
)
.await?;
let mut cancel_requested = db.is_agent_job_cancelled(job_id.as_str()).await?;
loop {
@@ -610,12 +514,6 @@ async fn run_agent_job_loop(
if !cancel_requested && db.is_agent_job_cancelled(job_id.as_str()).await? {
cancel_requested = true;
let _ = session
.notify_background_event(
&turn,
format!("agent job {job_id} cancellation requested; stopping new workers"),
)
.await;
}
if !cancel_requested && active_items.len() < options.max_concurrency {
@@ -749,20 +647,9 @@ async fn run_agent_job_loop(
)
.await?;
active_items.remove(&thread_id);
let progress = db.get_agent_job_progress(job_id.as_str()).await?;
progress_emitter
.maybe_emit(
&session,
&turn,
job_id.as_str(),
&progress,
/*force*/ false,
)
.await?;
}
}
let progress = db.get_agent_job_progress(job_id.as_str()).await?;
if let Err(err) = export_job_csv_snapshot(db.clone(), &job).await {
let message = format!("auto-export failed: {err}");
db.mark_agent_job_failed(job_id.as_str(), message.as_str())
@@ -771,37 +658,9 @@ async fn run_agent_job_loop(
}
let cancelled = cancel_requested || db.is_agent_job_cancelled(job_id.as_str()).await?;
if cancelled {
let pending_items = progress.pending_items;
let message =
format!("agent job {job_id} cancelled with {pending_items} unprocessed items");
let _ = session.notify_background_event(&turn, message).await;
progress_emitter
.maybe_emit(
&session,
&turn,
job_id.as_str(),
&progress,
/*force*/ true,
)
.await?;
return Ok(());
}
if progress.failed_items > 0 {
let failed_items = progress.failed_items;
let message = format!("agent job completed with {failed_items} failed items");
let _ = session.notify_background_event(&turn, message).await;
}
db.mark_agent_job_completed(job_id.as_str()).await?;
let progress = db.get_agent_job_progress(job_id.as_str()).await?;
progress_emitter
.maybe_emit(
&session,
&turn,
job_id.as_str(),
&progress,
/*force*/ true,
)
.await?;
Ok(())
}
+14 -17
View File
@@ -33,10 +33,9 @@ use crate::tools::runtimes::apply_patch::ApplyPatchRequest;
use crate::tools::runtimes::apply_patch::ApplyPatchRuntime;
use crate::tools::sandboxing::ToolCtx;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchArgs;
use codex_apply_patch::ApplyPatchFileChange;
use codex_apply_patch::Hunk;
use codex_apply_patch::parse_patch_streaming;
use codex_apply_patch::StreamingPatchParser;
use codex_exec_server::ExecutorFileSystem;
use codex_features::Feature;
use codex_protocol::models::AdditionalPermissionProfile;
@@ -56,8 +55,7 @@ pub struct ApplyPatchHandler;
#[derive(Default)]
struct ApplyPatchArgumentDiffConsumer {
input: String,
last_progress: Option<Vec<Hunk>>,
parser: StreamingPatchParser,
last_sent_at: Option<Instant>,
pending: Option<PatchApplyUpdatedEvent>,
}
@@ -77,26 +75,19 @@ impl ToolArgumentDiffConsumer for ApplyPatchArgumentDiffConsumer {
.map(EventMsg::PatchApplyUpdated)
}
fn flush_on_complete(&mut self) -> Option<EventMsg> {
self.flush_update_on_complete()
.map(EventMsg::PatchApplyUpdated)
fn finish(&mut self) -> Result<Option<EventMsg>, FunctionCallError> {
self.finish_update_on_complete()
.map(|event| event.map(EventMsg::PatchApplyUpdated))
}
}
impl ApplyPatchArgumentDiffConsumer {
fn push_delta(&mut self, call_id: String, delta: &str) -> Option<PatchApplyUpdatedEvent> {
self.input.push_str(delta);
let ApplyPatchArgs { hunks, .. } = parse_patch_streaming(&self.input).ok()?;
let hunks = self.parser.push_delta(delta).ok()?;
if hunks.is_empty() {
return None;
}
if self.last_progress.as_ref() == Some(&hunks) {
return None;
}
let changes = convert_apply_patch_hunks_to_protocol(&hunks);
self.last_progress = Some(hunks);
let event = PatchApplyUpdatedEvent { call_id, changes };
let now = Instant::now();
match self.last_sent_at {
@@ -114,12 +105,18 @@ impl ApplyPatchArgumentDiffConsumer {
}
}
fn flush_update_on_complete(&mut self) -> Option<PatchApplyUpdatedEvent> {
fn finish_update_on_complete(
&mut self,
) -> Result<Option<PatchApplyUpdatedEvent>, FunctionCallError> {
self.parser.finish().map_err(|err| {
FunctionCallError::RespondToModel(format!("failed to parse apply_patch: {err}"))
})?;
let event = self.pending.take();
if event.is_some() {
self.last_sent_at = Some(Instant::now());
}
event
Ok(event)
}
}
@@ -136,7 +136,7 @@ fn diff_consumer_streams_apply_patch_changes() {
HashMap::from([(
PathBuf::from("hello.txt"),
FileChange::Add {
content: "hello\n".to_string(),
content: String::new(),
},
)]),
)
@@ -147,8 +147,16 @@ fn diff_consumer_streams_apply_patch_changes() {
.push_delta("call-1".to_string(), "\n+world")
.is_none()
);
assert!(
consumer
.push_delta("call-1".to_string(), "\n*** End Patch")
.is_none()
);
let event = consumer.flush_update_on_complete().expect("progress event");
let event = consumer
.finish_update_on_complete()
.expect("finish parser")
.expect("progress event");
assert_eq!(
(event.call_id, event.changes),
(
@@ -175,7 +183,7 @@ fn diff_consumer_sends_next_update_after_buffer_interval() {
HashMap::from([(
PathBuf::from("hello.txt"),
FileChange::Add {
content: "hello\n".to_string(),
content: String::new(),
},
)])
);
@@ -190,7 +198,7 @@ fn diff_consumer_sends_next_update_after_buffer_interval() {
HashMap::from([(
PathBuf::from("hello.txt"),
FileChange::Add {
content: "hello\nworld\n".to_string(),
content: "hello\n".to_string(),
},
)])
);
+2
View File
@@ -96,6 +96,7 @@ impl ToolHandler for McpHandler {
tool_input: result.tool_input,
wall_time: started.elapsed(),
original_image_detail_supported: can_request_original_image_detail(&turn.model_info),
truncation_policy: turn.truncation_policy,
})
}
}
@@ -181,6 +182,7 @@ mod tests {
}),
wall_time: Duration::from_millis(42),
original_image_detail_supported: true,
truncation_policy: codex_utils_output_truncation::TruncationPolicy::Bytes(1024),
};
let (session, turn) = make_session_and_context().await;
let invocation = ToolInvocation {
@@ -5,7 +5,6 @@ use crate::config::DEFAULT_AGENT_MAX_DEPTH;
use crate::function_tool::FunctionCallError;
use crate::session::tests::make_session_and_context;
use crate::session_prefix::format_subagent_notification_message;
use crate::thread_manager::thread_store_from_config;
use crate::tools::context::ToolOutput;
use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2;
@@ -297,10 +296,7 @@ async fn spawn_agent_fork_context_rejects_agent_type_override() {
let role_name = install_role_with_model_override(&mut turn).await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -332,10 +328,7 @@ async fn spawn_agent_fork_context_rejects_child_model_overrides() {
let (mut session, turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -370,10 +363,7 @@ async fn multi_agent_v2_spawn_fork_turns_all_rejects_agent_type_override() {
let role_name = install_role_with_model_override(&mut turn).await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -416,10 +406,7 @@ async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_over
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -460,10 +447,7 @@ async fn multi_agent_v2_spawn_partial_fork_turns_allows_agent_type_override() {
let role_name = install_role_with_model_override(&mut turn).await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -546,10 +530,7 @@ async fn multi_agent_v2_spawn_requires_task_name() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -583,10 +564,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_items_field() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -646,10 +624,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -746,10 +721,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_fork_context() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -788,10 +760,7 @@ async fn multi_agent_v2_spawn_rejects_invalid_fork_turns_string() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -830,10 +799,7 @@ async fn multi_agent_v2_spawn_rejects_zero_fork_turns() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -872,10 +838,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -951,10 +914,7 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -1035,10 +995,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -1132,10 +1089,7 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -1222,10 +1176,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -1289,10 +1240,7 @@ async fn multi_agent_v2_send_message_rejects_legacy_items_field() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -1348,10 +1296,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -1424,10 +1369,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn()
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -1562,10 +1504,7 @@ async fn multi_agent_v2_followup_task_rejects_legacy_items_field() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -1618,10 +1557,7 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -1698,10 +1634,7 @@ async fn multi_agent_v2_spawn_omits_agent_id_when_named() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -1740,10 +1673,7 @@ async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -1957,7 +1887,7 @@ async fn multi_agent_v2_spawn_agent_ignores_configured_max_depth() {
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
let root = manager
.start_thread(config.clone(), thread_store_from_config(&config))
.start_thread(config.clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -2082,7 +2012,7 @@ async fn send_input_interrupts_before_prompt() {
session.services.agent_control = manager.agent_control();
let config = turn.config.as_ref().clone();
let thread = manager
.start_thread(config.clone(), thread_store_from_config(&config))
.start_thread(config.clone())
.await
.expect("start thread");
let agent_id = thread.thread_id;
@@ -2124,7 +2054,7 @@ async fn send_input_accepts_structured_items() {
session.services.agent_control = manager.agent_control();
let config = turn.config.as_ref().clone();
let thread = manager
.start_thread(config.clone(), thread_store_from_config(&config))
.start_thread(config.clone())
.await
.expect("start thread");
let agent_id = thread.thread_id;
@@ -2219,7 +2149,7 @@ async fn resume_agent_noops_for_active_agent() {
session.services.agent_control = manager.agent_control();
let config = turn.config.as_ref().clone();
let thread = manager
.start_thread(config.clone(), thread_store_from_config(&config))
.start_thread(config.clone())
.await
.expect("start thread");
let agent_id = thread.thread_id;
@@ -2260,7 +2190,6 @@ async fn resume_agent_restores_closed_agent_and_accepts_send_input() {
let thread = manager
.resume_thread_with_history(
config.clone(),
thread_store_from_config(&config),
InitialHistory::Forked(vec![RolloutItem::ResponseItem(ResponseItem::Message {
id: None,
role: "user".to_string(),
@@ -2425,10 +2354,7 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -2605,7 +2531,7 @@ async fn wait_agent_times_out_when_status_is_not_final() {
session.services.agent_control = manager.agent_control();
let config = turn.config.as_ref().clone();
let thread = manager
.start_thread(config.clone(), thread_store_from_config(&config))
.start_thread(config.clone())
.await
.expect("start thread");
let agent_id = thread.thread_id;
@@ -2648,7 +2574,7 @@ async fn wait_agent_clamps_short_timeouts_to_minimum() {
session.services.agent_control = manager.agent_control();
let config = turn.config.as_ref().clone();
let thread = manager
.start_thread(config.clone(), thread_store_from_config(&config))
.start_thread(config.clone())
.await
.expect("start thread");
let agent_id = thread.thread_id;
@@ -2686,7 +2612,7 @@ async fn wait_agent_returns_final_status_without_timeout() {
session.services.agent_control = manager.agent_control();
let config = turn.config.as_ref().clone();
let thread = manager
.start_thread(config.clone(), thread_store_from_config(&config))
.start_thread(config.clone())
.await
.expect("start thread");
let agent_id = thread.thread_id;
@@ -2736,10 +2662,7 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -2830,10 +2753,7 @@ async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -2911,10 +2831,7 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -3002,10 +2919,7 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -3091,10 +3005,7 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -3153,10 +3064,7 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(
(*turn.config).clone(),
thread_store_from_config(turn.config.as_ref()),
)
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
@@ -3206,7 +3114,7 @@ async fn close_agent_submits_shutdown_and_returns_previous_status() {
session.services.agent_control = manager.agent_control();
let config = turn.config.as_ref().clone();
let thread = manager
.start_thread(config.clone(), thread_store_from_config(&config))
.start_thread(config.clone())
.await
.expect("start thread");
let agent_id = thread.thread_id;
@@ -3250,7 +3158,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr
.expect("test config should allow sqlite");
let parent = manager
.start_thread(config.clone(), thread_store_from_config(&config))
.start_thread(config.clone())
.await
.expect("parent thread should start");
let parent_thread_id = parent.thread_id;
@@ -3381,7 +3289,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr
);
let operator = manager
.start_thread(config.clone(), thread_store_from_config(&config))
.start_thread(config.clone())
.await
.expect("operator thread should start");
let operator_session = operator.thread.codex.session.clone();
@@ -314,10 +314,11 @@ async fn refresh_missing_suggested_connectors(
fn verified_plugin_suggestion_completed(
tool_id: &str,
config: &crate::config::Config,
plugins_manager: &crate::plugins::PluginsManager,
plugins_manager: &codex_core_plugins::PluginsManager,
) -> bool {
let plugins_input = config.plugins_config_input();
plugins_manager
.list_marketplaces_for_config(config, &[])
.list_marketplaces_for_config(&plugins_input, &[])
.ok()
.into_iter()
.flat_map(|outcome| outcome.marketplaces)
@@ -1,6 +1,4 @@
use super::*;
use crate::plugins::PluginInstallRequest;
use crate::plugins::PluginsManager;
use crate::plugins::test_support::load_plugins_config;
use crate::plugins::test_support::write_curated_plugin_sha;
use crate::plugins::test_support::write_openai_curated_marketplace;
@@ -11,6 +9,8 @@ use codex_config::types::ToolSuggestConfig;
use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverable;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_rmcp_client::ElicitationResponse;
use codex_tools::DiscoverablePluginInfo;
+3 -3
View File
@@ -98,9 +98,9 @@ pub(crate) trait ToolArgumentDiffConsumer: Send {
fn consume_diff(&mut self, turn: &TurnContext, call_id: String, diff: &str)
-> Option<EventMsg>;
/// Flush any buffered event before the tool call completes.
fn flush_on_complete(&mut self) -> Option<EventMsg> {
None
/// Finish consuming argument diffs before the tool call completes.
fn finish(&mut self) -> Result<Option<EventMsg>, FunctionCallError> {
Ok(None)
}
}
@@ -25,10 +25,6 @@ use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::exec_output::StreamOutput;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandOutputDeltaEvent;
use codex_protocol::protocol::ExecOutputStream;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxType;
@@ -87,22 +83,6 @@ impl ApplyPatchRuntime {
use_legacy_landlock: attempt.use_legacy_landlock,
})
}
async fn emit_output_delta(ctx: &ToolCtx, stream: ExecOutputStream, chunk: &[u8]) {
if chunk.is_empty() {
return;
}
let event = Event {
id: ctx.turn.sub_id.clone(),
msg: EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
call_id: ctx.call_id.clone(),
stream,
chunk: chunk.to_vec(),
}),
};
let _ = ctx.session.get_tx_event().send(event).await;
}
}
impl Sandboxable for ApplyPatchRuntime {
@@ -230,8 +210,6 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
.await;
let stdout = String::from_utf8_lossy(&stdout).into_owned();
let stderr = String::from_utf8_lossy(&stderr).into_owned();
Self::emit_output_delta(ctx, ExecOutputStream::Stdout, stdout.as_bytes()).await;
Self::emit_output_delta(ctx, ExecOutputStream::Stderr, stderr.as_bytes()).await;
let exit_code = if result.is_ok() { 0 } else { 1 };
let output = ExecToolCallOutput {
exit_code,
@@ -674,13 +674,16 @@ fn evaluate_intercepted_exec_policy(
let fallback = |cmd: &[String]| {
crate::exec_policy::render_decision_for_unmatched_command(
approval_policy,
&permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
cmd,
sandbox_permissions,
used_complex_parsing,
crate::exec_policy::UnmatchedCommandContext {
approval_policy,
permission_profile: &permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
used_complex_parsing,
command_origin: crate::exec_policy::ExecPolicyCommandOrigin::Generic,
},
)
};
+2 -9
View File
@@ -430,6 +430,7 @@ impl TestCodexBuilder {
SessionSource::Exec,
Arc::clone(&environment_manager),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
)
} else {
codex_core::test_support::thread_manager_with_models_provider_and_home(
@@ -449,7 +450,6 @@ impl TestCodexBuilder {
codex_core::test_support::resume_thread_from_rollout_with_user_shell_override(
thread_manager.as_ref(),
config.clone(),
thread_store_from_config(&config),
path,
auth_manager,
user_shell_override,
@@ -461,7 +461,6 @@ impl TestCodexBuilder {
let auth_manager = codex_core::test_support::auth_manager_from_auth(auth);
Box::pin(thread_manager.resume_thread_from_rollout(
config.clone(),
thread_store_from_config(&config),
path,
auth_manager,
/*parent_trace*/ None,
@@ -473,18 +472,12 @@ impl TestCodexBuilder {
codex_core::test_support::start_thread_with_user_shell_override(
thread_manager.as_ref(),
config.clone(),
thread_store_from_config(&config),
user_shell_override,
),
)
.await?
}
(None, None) => {
Box::pin(
thread_manager.start_thread(config.clone(), thread_store_from_config(&config)),
)
.await?
}
(None, None) => Box::pin(thread_manager.start_thread(config.clone())).await?,
};
Ok(TestCodex {
+1 -1
View File
@@ -1027,7 +1027,7 @@ async fn apply_patch_custom_tool_streaming_emits_updated_changes() -> Result<()>
.changes
.get(&std::path::PathBuf::from("streamed.txt")),
Some(&codex_protocol::protocol::FileChange::Add {
content: "hello\n".to_string(),
content: String::new(),
})
);
assert_eq!(
+161 -1
View File
@@ -96,6 +96,14 @@ enum ActionKind {
RunCommand {
command: &'static str,
},
RunCommandWithPolicy {
command: &'static str,
policy_src: &'static str,
},
RunCommandWithPrefixRule {
command: &'static str,
prefix_rule: &'static [&'static str],
},
RunUnifiedExecCommand {
command: &'static str,
justification: Option<&'static str>,
@@ -114,6 +122,20 @@ const DEFAULT_UNIFIED_EXEC_JUSTIFICATION: &str =
"Requires escalated permissions to bypass the sandbox in tests.";
impl ActionKind {
fn policy_src(&self) -> Option<&'static str> {
match self {
ActionKind::RunCommandWithPolicy { policy_src, .. } => Some(*policy_src),
ActionKind::WriteFile { .. }
| ActionKind::FetchUrlNoProxy { .. }
| ActionKind::FetchUrl { .. }
| ActionKind::RunCommand { .. }
| ActionKind::RunCommandWithPrefixRule { .. }
| ActionKind::RunUnifiedExecCommand { .. }
| ActionKind::ApplyPatchFunction { .. }
| ActionKind::ApplyPatchShell { .. } => None,
}
}
async fn prepare(
&self,
test: &TestCodex,
@@ -204,6 +226,31 @@ impl ActionKind {
)?;
Ok((event, Some(command.to_string())))
}
ActionKind::RunCommandWithPolicy { command, .. } => {
// Bazel Linux runners can be heavily oversubscribed while this
// matrix runs, so avoid making scheduling latency look like an
// approval behavior failure.
let event = shell_event(
call_id,
command,
/*timeout_ms*/ 30_000,
sandbox_permissions,
)?;
Ok((event, Some(command.to_string())))
}
ActionKind::RunCommandWithPrefixRule {
command,
prefix_rule,
} => {
let event = shell_event_with_prefix_rule(
call_id,
command,
/*timeout_ms*/ 30_000,
sandbox_permissions,
Some(prefix_rule.iter().map(|part| (*part).to_string()).collect()),
)?;
Ok((event, Some(command.to_string())))
}
ActionKind::RunUnifiedExecCommand {
command,
justification,
@@ -549,6 +596,11 @@ enum Outcome {
decision: ReviewDecision,
expected_reason: Option<&'static str>,
},
ExecApprovalWithAmendment {
decision: ReviewDecision,
expected_reason: Option<&'static str>,
expected_execpolicy_amendment: Option<&'static [&'static str]>,
},
PatchApproval {
decision: ReviewDecision,
expected_reason: Option<&'static str>,
@@ -913,6 +965,70 @@ fn scenarios() -> Vec<ScenarioSpec> {
output_contains: "rejected by user",
},
},
ScenarioSpec {
name: "cat_heredoc_file_redirect_prefix_rule_requires_escalation_approval",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommandWithPrefixRule {
command: r#"cat <<'EOF' > /tmp/out.txt
hello
EOF"#,
prefix_rule: &["cat"],
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
model_override: Some("gpt-5.2"),
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Denied,
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
ScenarioSpec {
name: "cat_heredoc_variable_assignment_policy_requires_escalation_approval",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommandWithPolicy {
command: r#"PATH=/tmp/evil:$PATH cat <<'EOF'
hello
EOF"#,
policy_src: r#"prefix_rule(pattern=["cat"], decision="allow")"#,
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
model_override: Some("gpt-5.2"),
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Denied,
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
ScenarioSpec {
name: "python_heredoc_requested_prefix_rule_omits_amendment",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::RunCommandWithPrefixRule {
command: r#"python3 <<'PY'
print('hello')
PY"#,
prefix_rule: &["python3"],
},
sandbox_permissions: SandboxPermissions::RequireEscalated,
features: vec![],
model_override: Some("gpt-5.2"),
outcome: Outcome::ExecApprovalWithAmendment {
decision: ReviewDecision::Denied,
expected_reason: None,
expected_execpolicy_amendment: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
ScenarioSpec {
name: "danger_full_access_on_failure_allows_outside_write",
approval_policy: OnFailure,
@@ -1703,7 +1819,9 @@ fn scenario_group(scenario: &ScenarioSpec) -> ScenarioGroup {
ActionKind::WriteFile { .. }
| ActionKind::FetchUrlNoProxy { .. }
| ActionKind::FetchUrl { .. }
| ActionKind::RunCommand { .. } => match &scenario.sandbox_policy {
| ActionKind::RunCommand { .. }
| ActionKind::RunCommandWithPolicy { .. }
| ActionKind::RunCommandWithPrefixRule { .. } => match &scenario.sandbox_policy {
SandboxPolicy::DangerFullAccess => ScenarioGroup::DangerFullAccess,
SandboxPolicy::ReadOnly { .. } => ScenarioGroup::ReadOnly,
SandboxPolicy::WorkspaceWrite { .. } => ScenarioGroup::WorkspaceWrite,
@@ -1720,6 +1838,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
let features = scenario.features.clone();
let model_override = scenario.model_override;
let model = model_override.unwrap_or("gpt-5.4");
let policy_src = scenario.action.policy_src();
let mut builder = test_codex().with_model(model).with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
@@ -1733,6 +1852,13 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
.expect("test config should allow feature update");
}
});
if let Some(policy_src) = policy_src {
builder = builder.with_pre_build_hook(move |home| {
let rules_dir = home.join("rules");
fs::create_dir_all(&rules_dir).expect("create rules dir");
fs::write(rules_dir.join("default.rules"), policy_src).expect("write policy");
});
}
let test = builder.build(&server).await?;
let call_id = scenario.name;
@@ -1799,6 +1925,40 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
.await?;
wait_for_completion(&test).await;
}
Outcome::ExecApprovalWithAmendment {
decision,
expected_reason,
expected_execpolicy_amendment,
} => {
let command = expected_command
.as_deref()
.expect("exec approval requires shell command");
let approval = expect_exec_approval(&test, command).await;
if let Some(expected_reason) = expected_reason {
assert_eq!(
approval.reason.as_deref(),
Some(*expected_reason),
"unexpected approval reason for {}",
scenario.name
);
}
let expected_execpolicy_amendment = expected_execpolicy_amendment.map(|command| {
ExecPolicyAmendment::new(command.iter().map(|part| (*part).to_string()).collect())
});
assert_eq!(
approval.proposed_execpolicy_amendment, expected_execpolicy_amendment,
"unexpected execpolicy amendment for {}",
scenario.name
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: decision.clone(),
})
.await?;
wait_for_completion(&test).await;
}
Outcome::PatchApproval {
decision,
expected_reason,
+2 -1
View File
@@ -1108,9 +1108,10 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
);
let NewThread { thread: codex, .. } = thread_manager
.start_thread(config.clone(), thread_store_from_config(&config))
.start_thread(config.clone())
.await
.expect("create new conversation");
-1
View File
@@ -2556,7 +2556,6 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
.thread_manager
.start_thread_with_tools(
base_test.config.clone(),
codex_core::thread_store_from_config(&base_test.config),
vec![DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "hidden_dynamic_tool".to_string(),
@@ -229,21 +229,15 @@ async fn codex_delegate_ignores_legacy_deltas() {
.expect("submit review");
let mut reasoning_delta_count = 0;
let mut legacy_reasoning_delta_count = 0;
loop {
let ev = wait_for_event(&test.codex, |_| true).await;
match ev {
EventMsg::ReasoningContentDelta(_) => reasoning_delta_count += 1,
EventMsg::AgentReasoningDelta(_) => legacy_reasoning_delta_count += 1,
EventMsg::TurnComplete(_) => break,
_ => {}
}
}
assert_eq!(reasoning_delta_count, 1, "expected one new reasoning delta");
assert_eq!(
legacy_reasoning_delta_count, 1,
"expected one legacy reasoning delta"
);
}
-10
View File
@@ -2149,16 +2149,6 @@ async fn manual_compact_retries_after_context_window_error() {
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
codex.submit(Op::Compact).await.unwrap();
let EventMsg::BackgroundEvent(event) =
wait_for_event(&codex, |ev| matches!(ev, EventMsg::BackgroundEvent(_))).await
else {
panic!("expected background event after compact retry");
};
assert!(
event.message.contains("Trimmed 1 older thread item"),
"background event should mention trimmed item count: {}",
event.message
);
let warning_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Warning(_))).await;
let EventMsg::Warning(WarningEvent { message }) = warning_event else {
panic!("expected warning event after compact retry");
@@ -445,7 +445,6 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> {
.thread_manager
.start_thread_with_tools(
test.config.clone(),
codex_core::thread_store_from_config(&test.config),
dynamic_tools,
/*persist_extended_history*/ false,
)
@@ -149,6 +149,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
"compact+resume test expects base path {base_path:?} to exist",
);
shutdown_conversation(&base).await;
let resumed = resume_conversation(&manager, &config, base_path).await;
user_turn(&resumed, "AFTER_RESUME").await;
let resumed_path = fetch_conversation_path(&resumed);
@@ -304,6 +305,7 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
"second compact test expects base path {base_path:?} to exist",
);
shutdown_conversation(&base).await;
let resumed = resume_conversation(&manager, &config, base_path).await;
user_turn(&resumed, "AFTER_RESUME").await;
let resumed_path = fetch_conversation_path(&resumed);
@@ -323,6 +325,7 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<()
"second compact test expects forked path {forked_path:?} to exist",
);
shutdown_conversation(&forked).await;
let resumed_again = resume_conversation(&manager, &config, forked_path).await;
user_turn(&resumed_again, AFTER_SECOND_RESUME).await;
@@ -815,6 +818,13 @@ fn fetch_conversation_path(conversation: &Arc<CodexThread>) -> std::path::PathBu
conversation.rollout_path().expect("rollout path")
}
async fn shutdown_conversation(conversation: &Arc<CodexThread>) {
conversation
.shutdown_and_wait()
.await
.expect("shutdown conversation");
}
async fn resume_conversation(
manager: &ThreadManager,
config: &Config,
@@ -825,7 +835,6 @@ async fn resume_conversation(
);
Box::pin(manager.resume_thread_from_rollout(
config.clone(),
codex_core::thread_store_from_config(config),
path,
auth_manager,
/*parent_trace*/ None,
@@ -845,7 +854,6 @@ async fn fork_thread(
Box::pin(manager.fork_thread(
nth_user_message,
config.clone(),
codex_core::thread_store_from_config(config),
path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
-5
View File
@@ -81,11 +81,6 @@ fn assert_no_matched_rules_invariant(output_item: &Value) {
#[tokio::test]
async fn execpolicy_blocks_shell_invocation() -> Result<()> {
// TODO execpolicy doesn't parse powershell commands yet
if cfg!(windows) {
return Ok(());
}
let mut builder = test_codex().with_config(|config| {
let policy_path = config.codex_home.join("rules").join("policy.rules");
fs::create_dir_all(
-3
View File
@@ -100,7 +100,6 @@ async fn fork_thread_twice_drops_to_first_message() {
.fork_thread(
ForkSnapshot::TruncateBeforeNthUserMessage(1),
config_for_fork.clone(),
codex_core::thread_store_from_config(&config_for_fork),
base_path.clone(),
/*persist_extended_history*/ false,
/*parent_trace*/ None,
@@ -125,7 +124,6 @@ async fn fork_thread_twice_drops_to_first_message() {
.fork_thread(
ForkSnapshot::TruncateBeforeNthUserMessage(0),
config_for_fork.clone(),
codex_core::thread_store_from_config(&config_for_fork),
fork1_path.clone(),
/*persist_extended_history*/ false,
/*parent_trace*/ None,
@@ -194,7 +192,6 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() {
.fork_thread_from_history(
ForkSnapshot::Interrupted,
test.config.clone(),
codex_core::thread_store_from_config(&test.config),
InitialHistory::Resumed(ResumedHistory {
conversation_id: test.session_configured.thread_id,
history: source_items.clone(),
+1 -1
View File
@@ -315,7 +315,7 @@ elif mode == "exit_2":
.unwrap_or_default();
let config_toml = format!(
r#"[features]
codex_hooks = true
hooks = true
[hooks]
-20
View File
@@ -503,11 +503,6 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> {
_ => None,
})
.await;
let legacy_delta = wait_for_event_match(&codex, |ev| match ev {
EventMsg::AgentMessageDelta(event) => Some(event.clone()),
_ => None,
})
.await;
let completed_item = wait_for_event_match(&codex, |ev| match ev {
EventMsg::ItemCompleted(ItemCompletedEvent {
item: TurnItem::AgentMessage(item),
@@ -522,7 +517,6 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> {
assert_eq!(delta_event.turn_id, started_turn_id);
assert_eq!(delta_event.item_id, started_item.id);
assert_eq!(delta_event.delta, "streamed response");
assert_eq!(legacy_delta.delta, "streamed response");
assert_eq!(completed_item.id, started_item.id);
Ok(())
@@ -1091,15 +1085,8 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> {
_ => None,
})
.await;
let legacy_delta = wait_for_event_match(&codex, |ev| match ev {
EventMsg::AgentReasoningDelta(event) => Some(event.clone()),
_ => None,
})
.await;
assert_eq!(delta_event.item_id, reasoning_item.id);
assert_eq!(delta_event.delta, "step one");
assert_eq!(legacy_delta.delta, "step one");
Ok(())
}
@@ -1152,15 +1139,8 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> {
_ => None,
})
.await;
let legacy_delta = wait_for_event_match(&codex, |ev| match ev {
EventMsg::AgentReasoningRawContentDelta(event) => Some(event.clone()),
_ => None,
})
.await;
assert_eq!(delta_event.item_id, reasoning_item.id);
assert_eq!(delta_event.delta, "raw detail");
assert_eq!(legacy_delta.delta, "raw detail");
Ok(())
}
-1
View File
@@ -102,7 +102,6 @@ mod tool_suggest;
mod tools;
mod truncation;
mod turn_state;
mod undo;
mod unified_exec;
mod unstable_features_warning;
mod user_notification;
@@ -496,7 +496,6 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
.fork_thread(
ForkSnapshot::Interrupted,
fork_config.clone(),
codex_core::thread_store_from_config(&fork_config),
rollout_path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
@@ -1671,7 +1671,6 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge
.thread_manager
.resume_thread_with_history(
test.config.clone(),
codex_core::thread_store_from_config(&test.config),
InitialHistory::Forked(history),
auth_manager_from_auth(CodexAuth::from_api_key("dummy")),
/*persist_extended_history*/ false,
@@ -106,7 +106,6 @@ async fn emits_warning_when_resumed_model_differs() {
} = thread_manager
.resume_thread_with_history(
config.clone(),
codex_core::thread_store_from_config(&config),
initial_history,
auth_manager,
/*persist_extended_history*/ false,
-4
View File
@@ -233,7 +233,6 @@ async fn review_op_with_plain_text_emits_review_fallback() {
/// Ensure review flow suppresses assistant-specific streaming/completion events:
/// - AgentMessageContentDelta
/// - AgentMessageDelta (legacy)
/// - ItemCompleted for TurnItem::AgentMessage
// Windows CI only: bump to 4 workers to prevent SSE/event starvation and test timeouts.
#[cfg_attr(windows, tokio::test(flavor = "multi_thread", worker_threads = 4))]
@@ -290,9 +289,6 @@ async fn review_filters_agent_message_related_events() {
EventMsg::AgentMessageContentDelta(_) => {
panic!("unexpected AgentMessageContentDelta surfaced during review")
}
EventMsg::AgentMessageDelta(_) => {
panic!("unexpected AgentMessageDelta surfaced during review")
}
_ => false,
})
.await;
-1
View File
@@ -793,7 +793,6 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
.thread_manager
.start_thread_with_tools(
base_test.config.clone(),
codex_core::thread_store_from_config(&base_test.config),
vec![dynamic_tool],
/*persist_extended_history*/ false,
)
+2 -3
View File
@@ -246,10 +246,9 @@ async fn list_skills_skips_cwd_roots_when_environment_disabled() -> Result<()> {
)?,
)),
/*analytics_events_client*/ None,
thread_store_from_config(&config),
);
let new_thread = thread_manager
.start_thread(config.clone(), thread_store_from_config(&config))
.await?;
let new_thread = thread_manager.start_thread(config.clone()).await?;
let cwd = config.cwd.to_path_buf();
new_thread
-43
View File
@@ -1,43 +0,0 @@
#![cfg(not(target_os = "windows"))]
use std::sync::Arc;
use anyhow::Result;
use codex_core::CodexThread;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::UndoCompletedEvent;
use core_test_support::test_codex::TestCodexHarness;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event_match;
use pretty_assertions::assert_eq;
async fn undo_harness() -> Result<TestCodexHarness> {
TestCodexHarness::with_builder(test_codex().with_model("gpt-5.4")).await
}
async fn invoke_undo(codex: &Arc<CodexThread>) -> Result<UndoCompletedEvent> {
codex.submit(Op::Undo).await?;
let event = wait_for_event_match(codex, |msg| match msg {
EventMsg::UndoCompleted(done) => Some(done.clone()),
_ => None,
})
.await;
Ok(event)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn undo_reports_feature_removal() -> Result<()> {
let harness = undo_harness().await?;
let codex = Arc::clone(&harness.test().codex);
let event = invoke_undo(&codex).await?;
assert!(!event.success, "expected undo to fail");
assert_eq!(
event.message.as_deref(),
Some("Undo is no longer available.")
);
Ok(())
}
@@ -44,7 +44,6 @@ async fn emits_warning_when_unstable_features_enabled_via_config() {
} = thread_manager
.resume_thread_with_history(
config.clone(),
codex_core::thread_store_from_config(&config),
InitialHistory::New,
auth_manager,
/*persist_extended_history*/ false,
@@ -92,7 +91,6 @@ async fn suppresses_warning_when_configured() {
} = thread_manager
.resume_thread_with_history(
config.clone(),
codex_core::thread_store_from_config(&config),
InitialHistory::New,
auth_manager,
/*persist_extended_history*/ false,
@@ -71,7 +71,6 @@ async fn window_id_advances_after_compact_persists_on_resume_and_resets_on_fork(
.fork_thread(
/*snapshot*/ 0usize,
resumed.config.clone(),
codex_core::thread_store_from_config(&resumed.config),
rollout_path,
/*persist_extended_history*/ false,
/*parent_trace*/ None,