Compare commits

..

17 Commits

Author SHA1 Message Date
Ahmed Ibrahim
093406c67f feedback 2025-10-17 08:06:14 -07:00
Ahmed Ibrahim
daf762f607 tests 2025-10-16 22:23:57 -07:00
Ahmed Ibrahim
cafd08ab41 tests 2025-10-16 17:23:20 -07:00
Ahmed Ibrahim
a4e042b671 tests 2025-10-16 17:22:54 -07:00
Ahmed Ibrahim
2994b63fe5 tests 2025-10-16 17:19:36 -07:00
Ahmed Ibrahim
0cf9bd6aa7 tests 2025-10-16 17:12:14 -07:00
Ahmed Ibrahim
f2444893ca tests 2025-10-16 17:05:34 -07:00
Ahmed Ibrahim
d4e59dedd8 fix_compact 2025-10-16 16:47:42 -07:00
Ahmed Ibrahim
e0f7c32217 fix_compact 2025-10-16 16:27:15 -07:00
Ahmed Ibrahim
578a6bc9e1 fix_compact 2025-10-16 16:09:56 -07:00
joshka-oai
18d00e36b9 feat(tui): warn high effort rate use (#5035)
Highlight that selecting a high reasoning level will hit Plus plan rate
limits faster.
2025-10-15 14:57:05 -07:00
Jeremy Rose
17550fee9e add ^Y and kill-buffer to textarea (#5075)
## Summary
- add a kill buffer to the text area and wire Ctrl+Y to yank it
- capture text from Ctrl+W, Ctrl+U, and Ctrl+K operations into the kill
buffer
- add regression coverage ensuring the last kill can be yanked back

Fixes #5017


------
https://chatgpt.com/codex/tasks/task_i_68e95bf06c48832cbf3d2ba8fa2035d2
2025-10-15 14:39:55 -07:00
Michael Bolin
995f5c3614 feat: add Vec<ParsedCommand> to ExecApprovalRequestEvent (#5222)
This adds `parsed_cmd: Vec<ParsedCommand>` to `ExecApprovalRequestEvent`
in the core protocol (`protocol/src/protocol.rs`), which is also what
this field is named on `ExecCommandBeginEvent`. Honestly, I don't love
the name (it sounds like a single command, but it is actually a list of
them), but I don't want to get distracted by a naming discussion right
now.

This also adds `parsed_cmd` to `ExecCommandApprovalParams` in
`codex-rs/app-server-protocol/src/protocol.rs`, so it will be available
via `codex app-server`, as well.

For consistency, I also updated `ExecApprovalElicitRequestParams` in
`codex-rs/mcp-server/src/exec_approval.rs` to include this field under
the name `codex_parsed_cmd`, as that struct already has a number of
special `codex_*` fields. Note this is the code for when Codex is used
as an MCP _server_ and therefore has to conform to the official spec for
an MCP elicitation type.
2025-10-15 13:58:40 -07:00
Jeremy Rose
9b53a306e3 Keep backtrack Esc hint gated on empty composer (#5076)
## Summary
- only prime backtrack and show the ESC hint when the composer is empty
- keep the composer-side ESC hint unchanged when drafts or attachments
exist and cover it with a regression test

Fixes #5030

------
https://chatgpt.com/codex/tasks/task_i_68e95ba59cd8832caec8e72ae2efeb55
2025-10-15 13:57:50 -07:00
Jeremy Rose
0016346dfb tui: ^C in prompt area resets history navigation cursor (#5078)
^C resets the history navigation, similar to zsh/bash.

Fixes #4834

------
https://chatgpt.com/codex/tasks/task_i_68e9674b6ac8832c8212bff6cba75e87
2025-10-15 13:57:44 -07:00
Michael Bolin
f38ad65254 chore: standardize on ParsedCommand from codex_protocol (#5218)
Note these two types were identical, so it seems clear to standardize on the one in `codex_protocol` and eliminate the `Into` stuff.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/5218).
* #5222
* __->__ #5218
2025-10-15 13:00:22 -07:00
jif-oai
774892c6d7 feat: add auto-approval for codex exec (#5043) 2025-10-15 19:03:54 +01:00
46 changed files with 899 additions and 320 deletions

View File

@@ -9,6 +9,7 @@ use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::Verbosity;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::FileChange;
@@ -697,6 +698,7 @@ pub struct ExecCommandApprovalParams {
pub cwd: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub parsed_cmd: Vec<ParsedCommand>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
@@ -904,6 +906,9 @@ mod tests {
command: vec!["echo".to_string(), "hello".to_string()],
cwd: PathBuf::from("/tmp"),
reason: Some("because tests".to_string()),
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "echo hello".to_string(),
}],
};
let request = ServerRequest::ExecCommandApproval {
request_id: RequestId::Integer(7),
@@ -920,6 +925,12 @@ mod tests {
"command": ["echo", "hello"],
"cwd": "/tmp",
"reason": "because tests",
"parsedCmd": [
{
"type": "unknown",
"cmd": "echo hello"
}
]
}
}),
serde_json::to_value(&request)?,

View File

@@ -1050,7 +1050,6 @@ impl CodexMessageProcessor {
effort,
summary,
final_output_json_schema: None,
disabled_tools: None,
})
.await;
@@ -1285,6 +1284,7 @@ async fn apply_bespoke_event_handling(
command,
cwd,
reason,
parsed_cmd,
}) => {
let params = ExecCommandApprovalParams {
conversation_id,
@@ -1292,6 +1292,7 @@ async fn apply_bespoke_event_handling(
command,
cwd,
reason,
parsed_cmd,
};
let rx = outgoing
.send_request(ServerRequestPayload::ExecCommandApproval(params))

View File

@@ -27,6 +27,7 @@ use codex_core::protocol_config_types::ReasoningEffort;
use codex_core::protocol_config_types::ReasoningSummary;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InputMessageKind;
@@ -311,6 +312,9 @@ async fn test_send_user_turn_changes_approval_policy_behavior() {
],
cwd: working_directory.clone(),
reason: None,
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "python3 -c 'print(42)'".to_string()
}],
},
params
);

View File

@@ -43,7 +43,6 @@ use crate::model_family::ModelFamily;
use crate::model_provider_info::ModelProviderInfo;
use crate::model_provider_info::WireApi;
use crate::openai_model_info::get_model_info;
use crate::openai_tools::create_allowed_tools_json_for_responses_api;
use crate::openai_tools::create_tools_json_for_responses_api;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::RateLimitWindow;
@@ -55,7 +54,6 @@ use codex_otel::otel_event_manager::OtelEventManager;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::models::ResponseItem;
use std::collections::HashSet;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
@@ -235,39 +233,13 @@ impl ModelClient {
//
// For Azure, we send `store: true` and preserve reasoning item IDs.
let azure_workaround = self.provider.is_azure_responses_endpoint();
let tool_choice = if let Some(disabled) = &prompt.disabled_tools {
if disabled.is_empty() {
serde_json::json!("auto")
} else {
let allowed = create_allowed_tools_json_for_responses_api(&prompt.tools, disabled);
let total_unique = prompt
.tools
.iter()
.map(super::client_common::tools::ToolSpec::name)
.collect::<HashSet<_>>()
.len();
if allowed.is_empty() {
serde_json::json!("none")
} else if allowed.len() == total_unique {
serde_json::json!("auto")
} else {
serde_json::json!({
"type": "allowed_tools",
"mode": "auto",
"tools": allowed,
})
}
}
} else {
serde_json::json!("auto")
};
let payload = ResponsesApiRequest {
model: &self.config.model,
instructions: &full_instructions,
input: &input_with_instructions,
tools: &tools_json,
tool_choice,
tool_choice: "auto",
parallel_tool_calls: prompt.parallel_tool_calls,
reasoning,
store: azure_workaround,

View File

@@ -41,9 +41,6 @@ pub struct Prompt {
/// Optional the output schema for the model's response.
pub output_schema: Option<Value>,
/// Optional list of tool names to disable for this prompt.
pub disabled_tools: Option<Vec<String>>,
}
impl Prompt {
@@ -271,7 +268,7 @@ pub(crate) struct ResponsesApiRequest<'a> {
// separate enum for serialization.
pub(crate) input: &'a Vec<ResponseItem>,
pub(crate) tools: &'a [serde_json::Value],
pub(crate) tool_choice: Value,
pub(crate) tool_choice: &'static str,
pub(crate) parallel_tool_calls: bool,
pub(crate) reasoning: Option<Reasoning>,
pub(crate) store: bool,
@@ -460,7 +457,7 @@ mod tests {
instructions: "i",
input: &input,
tools: &tools,
tool_choice: serde_json::json!("auto"),
tool_choice: "auto",
parallel_tool_calls: true,
reasoning: None,
store: false,
@@ -501,7 +498,7 @@ mod tests {
instructions: "i",
input: &input,
tools: &tools,
tool_choice: serde_json::json!("auto"),
tool_choice: "auto",
parallel_tool_calls: true,
reasoning: None,
store: false,
@@ -537,7 +534,7 @@ mod tests {
instructions: "i",
input: &input,
tools: &tools,
tool_choice: serde_json::json!("auto"),
tool_choice: "auto",
parallel_tool_calls: true,
reasoning: None,
store: false,

View File

@@ -265,7 +265,6 @@ pub(crate) struct TurnContext {
pub(crate) tools_config: ToolsConfig,
pub(crate) is_review_mode: bool,
pub(crate) final_output_json_schema: Option<Value>,
pub(crate) disabled_tools: Option<Vec<String>>,
}
impl TurnContext {
@@ -489,7 +488,6 @@ impl Session {
cwd,
is_review_mode: false,
final_output_json_schema: None,
disabled_tools: None,
};
let services = SessionServices {
mcp_connection_manager,
@@ -622,6 +620,7 @@ impl Session {
warn!("Overwriting existing pending approval for sub_id: {event_id}");
}
let parsed_cmd = parse_command(&command);
let event = Event {
id: event_id,
msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
@@ -629,6 +628,7 @@ impl Session {
command,
cwd,
reason,
parsed_cmd,
}),
};
self.send_event(event).await;
@@ -884,10 +884,7 @@ impl Session {
call_id,
command: command_for_display.clone(),
cwd,
parsed_cmd: parse_command(&command_for_display)
.into_iter()
.map(Into::into)
.collect(),
parsed_cmd: parse_command(&command_for_display),
}),
};
let event = Event {
@@ -1173,7 +1170,6 @@ async fn submission_loop(
model,
effort,
summary,
disabled_tools,
} => {
// Recalculate the persistent turn context with provided overrides.
let prev = Arc::clone(&turn_context);
@@ -1222,9 +1218,6 @@ async fn submission_loop(
.clone()
.unwrap_or(prev.sandbox_policy.clone());
let new_cwd = cwd.clone().unwrap_or_else(|| prev.cwd.clone());
let new_disabled_tools = disabled_tools
.clone()
.or_else(|| prev.disabled_tools.clone());
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_family: &effective_family,
@@ -1242,7 +1235,6 @@ async fn submission_loop(
cwd: new_cwd.clone(),
is_review_mode: false,
final_output_json_schema: None,
disabled_tools: new_disabled_tools,
};
// Install the new persistent context for subsequent tasks/turns.
@@ -1281,7 +1273,6 @@ async fn submission_loop(
effort,
summary,
final_output_json_schema,
disabled_tools,
} => {
turn_context
.client
@@ -1322,9 +1313,6 @@ async fn submission_loop(
summary,
sess.conversation_id,
);
let effective_disabled_tools = disabled_tools
.clone()
.or_else(|| turn_context.disabled_tools.clone());
let fresh_turn_context = TurnContext {
client,
@@ -1340,7 +1328,6 @@ async fn submission_loop(
cwd,
is_review_mode: false,
final_output_json_schema,
disabled_tools: effective_disabled_tools.clone(),
};
// if the environment context has changed, record it in the conversation history
@@ -1620,7 +1607,6 @@ async fn spawn_review_thread(
cwd: parent_turn_context.cwd.clone(),
is_review_mode: true,
final_output_json_schema: None,
disabled_tools: parent_turn_context.disabled_tools.clone(),
};
// Seed the child task with the review prompt as the initial user message.
@@ -1993,7 +1979,6 @@ async fn run_turn(
parallel_tool_calls,
base_instructions_override: turn_context.base_instructions.clone(),
output_schema: turn_context.final_output_json_schema.clone(),
disabled_tools: turn_context.disabled_tools.clone(),
};
let mut retries = 0;
@@ -2801,7 +2786,6 @@ mod tests {
tools_config,
is_review_mode: false,
final_output_json_schema: None,
disabled_tools: None,
};
let services = SessionServices {
mcp_connection_manager: McpConnectionManager::default(),
@@ -2870,7 +2854,6 @@ mod tests {
tools_config,
is_review_mode: false,
final_output_json_schema: None,
disabled_tools: None,
});
let services = SessionServices {
mcp_connection_manager: McpConnectionManager::default(),

View File

@@ -71,13 +71,15 @@ async fn run_compact_task_inner(
input: Vec<InputItem>,
) {
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
let mut turn_input = sess
.turn_input_with_history(vec![initial_input_for_turn.clone().into()])
.await;
// Track the items we append for this compact prompt so trimming does not drop them.
let extra_items: Vec<ResponseItem> = vec![initial_input_for_turn.clone().into()];
let mut turn_input = sess.turn_input_with_history(extra_items.clone()).await;
let mut truncated_count = 0usize;
let mut trimmed_tails: Vec<Vec<ResponseItem>> = Vec::new();
let max_retries = turn_context.client.get_provider().stream_max_retries();
let mut retries = 0;
let mut context_retries = 0;
let mut stream_retries = 0;
let rollout_item = RolloutItem::TurnContext(TurnContextItem {
cwd: turn_context.cwd.clone(),
@@ -92,7 +94,6 @@ async fn run_compact_task_inner(
loop {
let prompt = Prompt {
input: turn_input.clone(),
disabled_tools: turn_context.disabled_tools.clone(),
..Default::default()
};
let attempt_result =
@@ -115,11 +116,32 @@ async fn run_compact_task_inner(
return;
}
Err(e @ CodexErr::ContextWindowExceeded) => {
if turn_input.len() > 1 {
turn_input.remove(0);
truncated_count += 1;
retries = 0;
continue;
// Drop the most recent user turn (its message plus ensuing traffic) and retry.
if turn_input.len() > extra_items.len() {
let history_len = turn_input.len() - extra_items.len();
let mut prompt_items = turn_input.split_off(history_len);
let trimmed = trim_recent_history_to_previous_user_message(&mut turn_input);
turn_input.append(&mut prompt_items);
if !trimmed.is_empty() {
truncated_count += trimmed.len();
trimmed_tails.push(trimmed);
if context_retries >= max_retries {
sess.set_total_tokens_full(&sub_id, turn_context.as_ref())
.await;
let event = Event {
id: sub_id.clone(),
msg: EventMsg::Error(ErrorEvent {
message: e.to_string(),
}),
};
sess.send_event(event).await;
return;
}
context_retries += 1;
stream_retries = 0;
// Keep stream retry budget untouched; we trimmed context successfully.
continue;
}
}
sess.set_total_tokens_full(&sub_id, turn_context.as_ref())
.await;
@@ -133,12 +155,12 @@ async fn run_compact_task_inner(
return;
}
Err(e) => {
if retries < max_retries {
retries += 1;
let delay = backoff(retries);
if stream_retries < max_retries {
stream_retries += 1;
let delay = backoff(stream_retries);
sess.notify_stream_error(
&sub_id,
format!("Re-connecting... {retries}/{max_retries}"),
format!("Re-connecting... {stream_retries}/{max_retries}"),
)
.await;
tokio::time::sleep(delay).await;
@@ -161,7 +183,10 @@ async fn run_compact_task_inner(
let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default();
let user_messages = collect_user_messages(&history_snapshot);
let initial_context = sess.build_initial_context(turn_context.as_ref());
let new_history = build_compacted_history(initial_context, &user_messages, &summary_text);
let mut new_history = build_compacted_history(initial_context, &user_messages, &summary_text);
for mut trimmed in trimmed_tails.into_iter().rev() {
new_history.append(&mut trimmed);
}
sess.replace_history(new_history).await;
let rollout_item = RolloutItem::Compacted(CompactedItem {
@@ -178,6 +203,27 @@ async fn run_compact_task_inner(
sess.send_event(event).await;
}
/// Trim conversation history back to the previous user message boundary, removing that user turn.
///
/// Returns the removed items in their original order so they can be restored later.
fn trim_recent_history_to_previous_user_message(
turn_input: &mut Vec<ResponseItem>,
) -> Vec<ResponseItem> {
if turn_input.is_empty() {
return Vec::new();
}
if let Some(last_user_index) = turn_input.iter().rposition(|item| {
matches!(
item,
ResponseItem::Message { role, .. } if role == "user"
)
}) {
turn_input.split_off(last_user_index)
} else {
std::mem::take(turn_input)
}
}
pub fn content_items_to_text(content: &[ContentItem]) -> Option<String> {
let mut pieces = Vec::new();
for item in content {

View File

@@ -1125,6 +1125,15 @@ impl Config {
.or(cfg.review_model)
.unwrap_or_else(default_review_model);
let mut approval_policy = approval_policy
.or(config_profile.approval_policy)
.or(cfg.approval_policy)
.unwrap_or_else(AskForApproval::default);
if features.enabled(Feature::ApproveAll) {
approval_policy = AskForApproval::OnRequest;
}
let config = Self {
model,
review_model,
@@ -1135,10 +1144,7 @@ impl Config {
model_provider_id,
model_provider,
cwd: resolved_cwd,
approval_policy: approval_policy
.or(config_profile.approval_policy)
.or(cfg.approval_policy)
.unwrap_or_else(AskForApproval::default),
approval_policy,
sandbox_policy,
shell_environment_policy,
notify: cfg.notify,
@@ -1432,6 +1438,26 @@ exclude_slash_tmp = true
);
}
#[test]
fn approve_all_feature_forces_on_request_policy() -> std::io::Result<()> {
let cfg = r#"
[features]
approve_all = true
"#;
let parsed = toml::from_str::<ConfigToml>(cfg)
.expect("TOML deserialization should succeed for approve_all feature");
let temp_dir = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
parsed,
ConfigOverrides::default(),
temp_dir.path().to_path_buf(),
)?;
assert!(config.features.enabled(Feature::ApproveAll));
assert_eq!(config.approval_policy, AskForApproval::OnRequest);
Ok(())
}
#[test]
fn config_defaults_to_auto_oauth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;

View File

@@ -41,6 +41,8 @@ pub enum Feature {
ViewImageTool,
/// Allow the model to request web searches.
WebSearchRequest,
/// Automatically approve all approval requests from the harness.
ApproveAll,
}
impl Feature {
@@ -247,4 +249,10 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: false,
},
FeatureSpec {
id: Feature::ApproveAll,
key: "approve_all",
stage: Stage::Experimental,
default_enabled: false,
},
];

View File

@@ -1,44 +1,9 @@
use crate::bash::try_parse_bash;
use crate::bash::try_parse_word_only_commands_sequence;
use serde::Deserialize;
use serde::Serialize;
use codex_protocol::parse_command::ParsedCommand;
use shlex::split as shlex_split;
use shlex::try_join as shlex_try_join;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub enum ParsedCommand {
Read {
cmd: String,
name: String,
},
ListFiles {
cmd: String,
path: Option<String>,
},
Search {
cmd: String,
query: Option<String>,
path: Option<String>,
},
Unknown {
cmd: String,
},
}
// Convert core's parsed command enum into the protocol's simplified type so
// events can carry the canonical representation across process boundaries.
impl From<ParsedCommand> for codex_protocol::parse_command::ParsedCommand {
fn from(v: ParsedCommand) -> Self {
use codex_protocol::parse_command::ParsedCommand as P;
match v {
ParsedCommand::Read { cmd, name } => P::Read { cmd, name },
ParsedCommand::ListFiles { cmd, path } => P::ListFiles { cmd, path },
ParsedCommand::Search { cmd, query, path } => P::Search { cmd, query, path },
ParsedCommand::Unknown { cmd } => P::Unknown { cmd },
}
}
}
fn shlex_join(tokens: &[String]) -> String {
shlex_try_join(tokens.iter().map(String::as_str))
.unwrap_or_else(|_| "<command included NUL byte>".to_string())

View File

@@ -145,22 +145,6 @@ impl ToolRouter {
let payload_outputs_custom = matches!(payload, ToolPayload::Custom { .. });
let failure_call_id = call_id.clone();
if turn
.disabled_tools
.as_ref()
.map(|tools| tools.iter().any(|name| name == &tool_name))
.unwrap_or(false)
{
let err = FunctionCallError::RespondToModel(format!(
"tool {tool_name} is disabled for this turn"
));
return Ok(Self::failure_response(
failure_call_id,
payload_outputs_custom,
err,
));
}
let invocation = ToolInvocation {
session,
turn,

View File

@@ -14,7 +14,6 @@ use serde_json::Value as JsonValue;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
#[derive(Debug, Clone)]
pub enum ConfigShellToolType {
@@ -533,40 +532,6 @@ pub fn create_tools_json_for_responses_api(
Ok(tools_json)
}
pub fn create_allowed_tools_json_for_responses_api(
tools: &[ToolSpec],
disabled_names: &[String],
) -> Vec<serde_json::Value> {
let disabled_set: HashSet<&str> = disabled_names
.iter()
.map(std::string::String::as_str)
.collect();
let mut seen = HashSet::new();
let mut allowed = Vec::new();
for spec in tools {
let name = spec.name();
if disabled_set.contains(name) || !seen.insert(name) {
continue;
}
let value = match spec {
ToolSpec::Function(tool) => json!({
"type": "function",
"name": tool.name,
}),
ToolSpec::Freeform(tool) => json!({
"type": "custom",
"name": tool.name,
}),
ToolSpec::LocalShell {} => json!({ "type": "local_shell" }),
ToolSpec::WebSearch {} => json!({ "type": "web_search" }),
};
allowed.push(value);
}
allowed
}
/// Returns JSON values that are compatible with Function Calling in the
/// Chat Completions API:
/// https://platform.openai.com/docs/guides/function-calling?api-mode=chat
@@ -1128,33 +1093,6 @@ mod tests {
);
}
#[test]
fn create_allowed_tools_excludes_disabled_entries() {
let shell = super::create_shell_tool();
let web_search = ToolSpec::WebSearch {};
let view_image = super::create_view_image_tool();
let specs = vec![shell, web_search, view_image];
let allowed = super::create_allowed_tools_json_for_responses_api(
&specs,
&[String::from("web_search")],
);
assert_eq!(allowed.len(), 2);
assert!(allowed.iter().any(|tool| {
tool.get("type") == Some(&serde_json::Value::String("function".into()))
&& tool.get("name") == Some(&serde_json::Value::String("shell".into()))
}));
assert!(allowed.iter().any(|tool| {
tool.get("type") == Some(&serde_json::Value::String("function".into()))
&& tool.get("name") == Some(&serde_json::Value::String("view_image".into()))
}));
assert!(allowed.iter().all(|tool| {
tool.get("name") != Some(&serde_json::Value::String("web_search".into()))
}));
}
#[test]
fn test_build_specs_mcp_tools_sorted_by_name() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");

View File

@@ -239,6 +239,20 @@ pub fn ev_apply_patch_function_call(call_id: &str, patch: &str) -> Value {
})
}
pub fn ev_function_call_output(call_id: &str, content: &str) -> Value {
serde_json::json!({
"type": "response.output_item.done",
"item": {
"type": "function_call_output",
"call_id": call_id,
"output": {
"content": content,
"success": true
}
}
})
}
pub fn sse_failed(id: &str, code: &str, message: &str) -> String {
sse(vec![serde_json::json!({
"type": "response.failed",

View File

@@ -19,17 +19,20 @@ use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_completed_with_tokens;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_function_call_output;
use core_test_support::responses::mount_sse_once_match;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::sse_failed;
use core_test_support::responses::start_mock_server;
use pretty_assertions::assert_eq;
use serde_json::Value;
// --- Test helpers -----------------------------------------------------------
pub(super) const FIRST_REPLY: &str = "FIRST_REPLY";
pub(super) const SUMMARY_TEXT: &str = "SUMMARY_ONLY_CONTEXT";
const THIRD_USER_MSG: &str = "next turn";
const THIRD_ASSISTANT_MSG: &str = "post compact assistant";
const AUTO_SUMMARY_TEXT: &str = "AUTO_SUMMARY";
const FIRST_AUTO_MSG: &str = "token limit start";
const SECOND_AUTO_MSG: &str = "token limit push";
@@ -644,6 +647,10 @@ async fn manual_compact_retries_after_context_window_error() {
ev_assistant_message("m2", SUMMARY_TEXT),
ev_completed("r2"),
]);
let third_turn = sse(vec![
ev_assistant_message("m3", THIRD_ASSISTANT_MSG),
ev_completed("r3"),
]);
let request_log = mount_sse_sequence(
&server,
@@ -651,6 +658,7 @@ async fn manual_compact_retries_after_context_window_error() {
user_turn.clone(),
compact_failed.clone(),
compact_succeeds.clone(),
third_turn,
],
)
.await;
@@ -688,17 +696,29 @@ async fn manual_compact_retries_after_context_window_error() {
panic!("expected background event after compact retry");
};
assert!(
event.message.contains("Trimmed 1 older conversation item"),
event
.message
.contains("Trimmed 2 older conversation item(s)"),
"background event should mention trimmed item count: {}",
event.message
);
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: THIRD_USER_MSG.into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let requests = request_log.requests();
assert_eq!(
requests.len(),
3,
"expected user turn and two compact attempts"
4,
"expected user turn, two compact attempts, and one follow-up turn"
);
let compact_attempt = requests[1].body_json();
@@ -710,42 +730,415 @@ async fn manual_compact_retries_after_context_window_error() {
let retry_input = retry_attempt["input"]
.as_array()
.unwrap_or_else(|| panic!("retry attempt missing input array: {retry_attempt}"));
assert_eq!(
compact_input
.last()
.and_then(|item| item.get("content"))
.and_then(|v| v.as_array())
fn extract_text(item: &Value) -> Option<String> {
item.get("content")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|entry| entry.get("text"))
.and_then(|text| text.as_str()),
.and_then(Value::as_str)
.map(str::to_string)
}
assert_eq!(
extract_text(compact_input.last().expect("compact input empty")).as_deref(),
Some(SUMMARIZATION_PROMPT),
"compact attempt should include summarization prompt"
"compact attempt should include summarization prompt",
);
assert_eq!(
retry_input
.last()
.and_then(|item| item.get("content"))
.and_then(|v| v.as_array())
.and_then(|items| items.first())
.and_then(|entry| entry.get("text"))
.and_then(|text| text.as_str()),
extract_text(retry_input.last().expect("retry input empty")).as_deref(),
Some(SUMMARIZATION_PROMPT),
"retry attempt should include summarization prompt"
"retry attempt should include summarization prompt",
);
let contains_text = |items: &[Value], needle: &str| {
items
.iter()
.any(|item| extract_text(item).is_some_and(|text| text == needle))
};
assert!(
contains_text(compact_input, "first turn"),
"compact attempt should include original user message",
);
assert!(
contains_text(compact_input, FIRST_REPLY),
"compact attempt should include original assistant reply",
);
assert!(
!contains_text(retry_input, "first turn"),
"retry should drop original user message",
);
assert!(
!contains_text(retry_input, FIRST_REPLY),
"retry should drop assistant reply tied to original user message",
);
assert_eq!(
retry_input.len(),
compact_input.len().saturating_sub(1),
"retry should drop exactly one history item (before {} vs after {})",
compact_input.len().saturating_sub(retry_input.len()),
2,
"retry should drop the most recent user turn (before {} vs after {})",
compact_input.len(),
retry_input.len()
);
if let (Some(first_before), Some(first_after)) = (compact_input.first(), retry_input.first()) {
assert_ne!(
first_before, first_after,
"retry should drop the oldest conversation item"
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn manual_compact_trims_last_user_turn_with_function_calls_on_context_error() {
skip_if_no_network!();
// Scenario 1: ensure the retry trims the most recent turn when function calls are involved.
const FIRST_USER_MSG: &str = "first user turn";
const SECOND_USER_MSG: &str = "second user turn";
const FIRST_CALL_A: &str = "call-first-a";
const FIRST_CALL_B: &str = "call-first-b";
const SECOND_CALL_A: &str = "call-second-a";
const SECOND_CALL_B: &str = "call-second-b";
{
let server = start_mock_server().await;
let first_turn_initial = sse(vec![ev_function_call(FIRST_CALL_A, "tool.first.a", "{}")]);
let first_turn_second_call = sse(vec![
ev_function_call_output(FIRST_CALL_A, "first-call-a output"),
ev_function_call(FIRST_CALL_B, "tool.first.b", "{}"),
]);
let first_turn_complete = sse(vec![
ev_function_call_output(FIRST_CALL_B, "first-call-b output"),
ev_assistant_message("assistant-first", "first turn complete"),
ev_completed("resp-first"),
]);
let second_turn_initial = sse(vec![ev_function_call(SECOND_CALL_A, "tool.second.a", "{}")]);
let second_turn_second_call = sse(vec![
ev_function_call_output(SECOND_CALL_A, "second-call-a output"),
ev_function_call(SECOND_CALL_B, "tool.second.b", "{}"),
]);
let second_turn_complete = sse(vec![
ev_function_call_output(SECOND_CALL_B, "second-call-b output"),
ev_assistant_message("assistant-second", "second turn complete"),
ev_completed("resp-second"),
]);
let compact_failed = sse_failed(
"resp-fail",
"context_length_exceeded",
CONTEXT_LIMIT_MESSAGE,
);
let compact_retry = sse(vec![
ev_assistant_message("assistant-summary", SUMMARY_TEXT),
ev_completed("resp-summary"),
]);
let request_log = mount_sse_sequence(
&server,
vec![
first_turn_initial,
first_turn_second_call,
first_turn_complete,
second_turn_initial,
second_turn_second_call,
second_turn_complete,
compact_failed,
compact_retry,
],
)
.await;
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home);
config.model_provider = model_provider;
config.model_auto_compact_token_limit = Some(200_000);
let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy"))
.new_conversation(config)
.await
.unwrap()
.conversation;
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: FIRST_USER_MSG.into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: SECOND_USER_MSG.into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).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 2 older conversation item(s)"),
"background event should report trimming chunked user turn: {}",
event.message
);
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let requests = request_log.requests();
assert_eq!(
requests.len(),
8,
"expected two user turns (with tool call round-trips) followed by compact attempt + retry"
);
let compact_attempt = requests[6].body_json();
let retry_attempt = requests[7].body_json();
fn extract_text(item: &Value) -> Option<String> {
item.get("content")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|entry| entry.get("text"))
.and_then(Value::as_str)
.map(str::to_string)
}
let contains_text = |items: &[Value], needle: &str| {
items
.iter()
.any(|item| extract_text(item).is_some_and(|text| text == needle))
};
assert!(
contains_text(
compact_attempt["input"].as_array().unwrap(),
SECOND_USER_MSG
),
"initial compact attempt should include most recent user message",
);
assert!(
!contains_text(retry_attempt["input"].as_array().unwrap(), SECOND_USER_MSG),
"retry should drop the most recent user message",
);
assert!(
contains_text(
compact_attempt["input"].as_array().unwrap(),
"second turn complete"
),
"initial compact attempt should include assistant reply for most recent turn",
);
assert!(
!contains_text(
retry_attempt["input"].as_array().unwrap(),
"second turn complete"
),
"retry should drop assistant reply for most recent turn",
);
assert_eq!(
compact_attempt["input"]
.as_array()
.unwrap()
.len()
.saturating_sub(retry_attempt["input"].as_array().unwrap().len()),
2,
"retry should drop the most recent user turn from the prompt",
);
let retry_call_ids: std::collections::HashSet<_> = retry_attempt["input"]
.as_array()
.unwrap()
.iter()
.filter_map(|item| item.get("call_id").and_then(|v| v.as_str()))
.collect();
assert!(
!retry_call_ids.contains(SECOND_CALL_A),
"retry should remove function call {SECOND_CALL_A}"
);
assert!(
!retry_call_ids.contains(SECOND_CALL_B),
"retry should remove function call {SECOND_CALL_B}"
);
}
// Scenario 2: after a retry succeeds, the trimmed turn is restored to history for the next user input.
{
const SIMPLE_FIRST_USER_MSG: &str = "first user turn";
const SIMPLE_FIRST_ASSISTANT_MSG: &str = "first assistant reply";
const SIMPLE_SECOND_USER_MSG: &str = "second user turn";
const SIMPLE_SECOND_ASSISTANT_MSG: &str = "second assistant reply";
const SIMPLE_THIRD_USER_MSG: &str = "post compact user";
const SIMPLE_THIRD_ASSISTANT_MSG: &str = "post compact assistant";
let server = start_mock_server().await;
let first_turn = sse(vec![
ev_assistant_message("assistant-first", SIMPLE_FIRST_ASSISTANT_MSG),
ev_completed("resp-first"),
]);
let second_turn = sse(vec![
ev_assistant_message("assistant-second", SIMPLE_SECOND_ASSISTANT_MSG),
ev_completed("resp-second"),
]);
let compact_failed = sse_failed(
"resp-fail",
"context_length_exceeded",
CONTEXT_LIMIT_MESSAGE,
);
let compact_retry = sse(vec![
ev_assistant_message("assistant-summary", SUMMARY_TEXT),
ev_completed("resp-summary"),
]);
let third_turn = sse(vec![
ev_assistant_message("assistant-third", SIMPLE_THIRD_ASSISTANT_MSG),
ev_completed("resp-third"),
]);
let request_log = mount_sse_sequence(
&server,
vec![
first_turn,
second_turn,
compact_failed,
compact_retry,
third_turn,
],
)
.await;
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home);
config.model_provider = model_provider;
config.model_auto_compact_token_limit = Some(200_000);
let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy"))
.new_conversation(config)
.await
.unwrap()
.conversation;
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: SIMPLE_FIRST_USER_MSG.into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: SIMPLE_SECOND_USER_MSG.into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
codex.submit(Op::Compact).await.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: SIMPLE_THIRD_USER_MSG.into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let requests = request_log.requests();
assert_eq!(
requests.len(),
5,
"expected two user turns, two compact attempts, and a post-compact turn",
);
let retry_request = &requests[3];
let retry_body = retry_request.body_json();
let retry_input = retry_body
.get("input")
.and_then(Value::as_array)
.expect("retry request missing input array");
assert!(
retry_input.iter().all(|item| {
item.get("content")
.and_then(Value::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("text"))
.and_then(Value::as_str)
.map(|text| {
text != SIMPLE_SECOND_USER_MSG && text != SIMPLE_SECOND_ASSISTANT_MSG
})
.unwrap_or(true)
}),
"retry compact input should omit trimmed second turn",
);
let final_request = &requests[4];
let body = final_request.body_json();
let input_items = body
.get("input")
.and_then(Value::as_array)
.expect("final request missing input array");
fn message_index(items: &[Value], needle: &str) -> Option<usize> {
items.iter().position(|item| {
item.get("type").and_then(Value::as_str) == Some("message")
&& item
.get("content")
.and_then(Value::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("text"))
.and_then(Value::as_str)
.is_some_and(|text| text == needle)
})
}
let summary_index = input_items
.iter()
.position(|item| {
item.get("content")
.and_then(Value::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("text"))
.and_then(Value::as_str)
.map(|text| text.contains(SUMMARY_TEXT))
.unwrap_or(false)
})
.expect("final request should include summary bridge");
let second_user_index = message_index(input_items, SIMPLE_SECOND_USER_MSG)
.expect("trimmed second user message should remain in history");
let second_assistant_index = message_index(input_items, SIMPLE_SECOND_ASSISTANT_MSG)
.expect("trimmed assistant reply should remain in history");
let third_user_index = message_index(input_items, SIMPLE_THIRD_USER_MSG)
.expect("post-compact user turn should be present");
assert!(
summary_index < second_user_index,
"summary bridge should precede restored user message"
);
assert!(
second_user_index < second_assistant_index,
"restored user message should precede assistant reply"
);
assert!(
second_assistant_index < third_user_index,
"restored assistant reply should precede new user turn"
);
} else {
panic!("expected non-empty compact inputs");
}
}

View File

@@ -124,7 +124,6 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
.unwrap_or_default()
.to_string();
let tool_calls = json!(requests[0]["tools"].as_array());
let tool_choice_request_1 = requests[0]["tool_choice"].clone();
let prompt_cache_key = requests[0]["prompt_cache_key"]
.as_str()
.unwrap_or_default()
@@ -133,10 +132,6 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
.as_str()
.unwrap_or_default()
.to_string();
let tool_choice_compact_1 = requests[1]["tool_choice"].clone();
let tool_choice_after_compact = requests[2]["tool_choice"].clone();
let tool_choice_after_resume = requests[3]["tool_choice"].clone();
let tool_choice_after_fork = requests[4]["tool_choice"].clone();
let expected_model = OPENAI_DEFAULT_MODEL;
let user_turn_1 = json!(
{
@@ -175,7 +170,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
}
],
"tools": tool_calls,
"tool_choice": tool_choice_request_1,
"tool_choice": "auto",
"parallel_tool_calls": false,
"reasoning": {
"summary": "auto"
@@ -244,7 +239,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
}
],
"tools": [],
"tool_choice": tool_choice_compact_1,
"tool_choice": "auto",
"parallel_tool_calls": false,
"reasoning": {
"summary": "auto"
@@ -309,7 +304,7 @@ SUMMARY_ONLY_CONTEXT"
}
],
"tools": tool_calls,
"tool_choice": tool_choice_after_compact,
"tool_choice": "auto",
"parallel_tool_calls": false,
"reasoning": {
"summary": "auto"
@@ -394,7 +389,7 @@ SUMMARY_ONLY_CONTEXT"
}
],
"tools": tool_calls,
"tool_choice": tool_choice_after_resume,
"tool_choice": "auto",
"parallel_tool_calls": false,
"reasoning": {
"summary": "auto"
@@ -479,7 +474,7 @@ SUMMARY_ONLY_CONTEXT"
}
],
"tools": tool_calls,
"tool_choice": tool_choice_after_fork,
"tool_choice": "auto",
"parallel_tool_calls": false,
"reasoning": {
"summary": "auto"

View File

@@ -159,7 +159,6 @@ async fn submit_turn(test: &TestCodex, prompt: &str) -> Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;

View File

@@ -84,7 +84,6 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> {
model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;

View File

@@ -76,7 +76,6 @@ async fn list_dir_tool_returns_entries() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;
@@ -182,7 +181,6 @@ async fn list_dir_tool_depth_one_omits_children() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;
@@ -295,7 +293,6 @@ async fn list_dir_tool_depth_two_includes_children_only() -> anyhow::Result<()>
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;
@@ -411,7 +408,6 @@ async fn list_dir_tool_depth_three_includes_grandchildren() -> anyhow::Result<()
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;

View File

@@ -38,7 +38,6 @@ async fn override_turn_context_does_not_persist_when_config_exists() {
model: Some("o3".to_string()),
effort: Some(Some(ReasoningEffort::High)),
summary: None,
disabled_tools: None,
})
.await
.expect("submit override");
@@ -79,7 +78,6 @@ async fn override_turn_context_does_not_create_config_file() {
model: Some("o3".to_string()),
effort: Some(Some(ReasoningEffort::Medium)),
summary: None,
disabled_tools: None,
})
.await
.expect("submit override");

View File

@@ -443,7 +443,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() {
model: Some("o3".to_string()),
effort: Some(Some(ReasoningEffort::High)),
summary: Some(ReasoningSummary::Detailed),
disabled_tools: None,
})
.await
.unwrap();
@@ -578,7 +577,6 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() {
effort: Some(ReasoningEffort::High),
summary: ReasoningSummary::Detailed,
final_output_json_schema: None,
disabled_tools: None,
})
.await
.unwrap();
@@ -690,7 +688,6 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() {
effort: default_effort,
summary: default_summary,
final_output_json_schema: None,
disabled_tools: None,
})
.await
.unwrap();
@@ -708,7 +705,6 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() {
effort: default_effort,
summary: default_summary,
final_output_json_schema: None,
disabled_tools: None,
})
.await
.unwrap();
@@ -806,7 +802,6 @@ async fn send_user_turn_with_changes_sends_environment_context() {
effort: default_effort,
summary: default_summary,
final_output_json_schema: None,
disabled_tools: None,
})
.await
.unwrap();
@@ -824,7 +819,6 @@ async fn send_user_turn_with_changes_sends_environment_context() {
effort: Some(ReasoningEffort::High),
summary: ReasoningSummary::Detailed,
final_output_json_schema: None,
disabled_tools: None,
})
.await
.unwrap();

View File

@@ -74,7 +74,6 @@ async fn read_file_tool_returns_requested_lines() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;

View File

@@ -110,7 +110,6 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;
@@ -260,7 +259,6 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;
@@ -442,7 +440,6 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;

View File

@@ -45,7 +45,6 @@ async fn submit_turn(test: &TestCodex, prompt: &str, sandbox_policy: SandboxPoli
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;

View File

@@ -84,7 +84,6 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()>
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;
@@ -154,7 +153,6 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;
@@ -238,7 +236,6 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;
@@ -337,7 +334,6 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<()
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;
@@ -439,7 +435,6 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;

View File

@@ -38,7 +38,6 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;

View File

@@ -48,7 +48,6 @@ async fn submit_turn(
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;

View File

@@ -128,7 +128,6 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;
@@ -265,7 +264,6 @@ PY
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;
@@ -372,7 +370,6 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;

View File

@@ -100,7 +100,6 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;
@@ -200,7 +199,6 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;
@@ -266,7 +264,6 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
disabled_tools: None,
})
.await?;

View File

@@ -17,6 +17,7 @@ use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::features::Feature;
use codex_core::git_info::get_git_repo_root;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::Event;
@@ -168,8 +169,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
model,
review_model: None,
config_profile,
// This CLI is intended to be headless and has no affordances for asking
// the user for approval.
// Default to never ask for approvals in headless mode. Feature flags can override.
approval_policy: Some(AskForApproval::Never),
sandbox_mode,
cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)),
@@ -192,6 +192,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
};
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?;
let approve_all_enabled = config.features.enabled(Feature::ApproveAll);
let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"));
@@ -348,7 +349,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
effort: default_effort,
summary: default_summary,
final_output_json_schema: output_schema,
disabled_tools: None,
})
.await?;
info!("Sent prompt with event ID: {initial_prompt_task_id}");
@@ -361,6 +361,34 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
if matches!(event.msg, EventMsg::Error(_)) {
error_seen = true;
}
// Auto-approve requests when the approve_all feature is enabled.
if approve_all_enabled {
match &event.msg {
EventMsg::ExecApprovalRequest(_) => {
if let Err(e) = conversation
.submit(Op::ExecApproval {
id: event.id.clone(),
decision: codex_core::protocol::ReviewDecision::Approved,
})
.await
{
error!("failed to auto-approve exec: {e}");
}
}
EventMsg::ApplyPatchApprovalRequest(_) => {
if let Err(e) = conversation
.submit(Op::PatchApproval {
id: event.id.clone(),
decision: codex_core::protocol::ReviewDecision::Approved,
})
.await
{
error!("failed to auto-approve patch: {e}");
}
}
_ => {}
}
}
let shutdown: CodexStatus = event_processor.process_event(event);
match shutdown {
CodexStatus::Running => continue,

View File

@@ -0,0 +1,81 @@
#![cfg(not(target_os = "windows"))]
#![allow(clippy::expect_used, clippy::unwrap_used)]
use anyhow::Result;
use core_test_support::responses;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex_exec::test_codex_exec;
use serde_json::Value;
use serde_json::json;
async fn run_exec_with_args(args: &[&str]) -> Result<String> {
let test = test_codex_exec();
let call_id = "exec-approve";
let exec_args = json!({
"command": [
if cfg!(windows) { "cmd.exe" } else { "/bin/sh" },
if cfg!(windows) { "/C" } else { "-lc" },
"echo approve-all-ok",
],
"timeout_ms": 1500,
"with_escalated_permissions": true
});
let response_streams = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell", &serde_json::to_string(&exec_args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
];
let server = responses::start_mock_server().await;
let mock = mount_sse_sequence(&server, response_streams).await;
test.cmd_with_server(&server).args(args).assert().success();
let requests = mock.requests();
assert!(requests.len() >= 2, "expected at least two responses POSTs");
let item = requests[1].function_call_output(call_id);
let output_str = item
.get("output")
.and_then(Value::as_str)
.expect("function_call_output.output should be a string");
Ok(output_str.to_string())
}
/// Setting `features.approve_all=true` should switch to auto-approvals.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn approve_all_auto_accepts_exec() -> Result<()> {
skip_if_no_network!(Ok(()));
let output = run_exec_with_args(&[
"--skip-git-repo-check",
"-c",
"features.approve_all=true",
"train",
])
.await?;
assert!(
output.contains("Exit code: 0"),
"expected Exit code: 0 in output: {output}"
);
assert!(
output.contains("approve-all-ok"),
"expected command output in response: {output}"
);
Ok(())
}

View File

@@ -1,5 +1,6 @@
// Aggregates all former standalone integration tests as modules.
mod apply_patch;
mod approve_all;
mod auth_env;
mod originator;
mod output_schema;

View File

@@ -178,6 +178,7 @@ async fn run_codex_tool_session_inner(
cwd,
call_id,
reason: _,
parsed_cmd,
}) => {
handle_exec_approval_request(
command,
@@ -188,6 +189,7 @@ async fn run_codex_tool_session_inner(
request_id_str.clone(),
event.id.clone(),
call_id,
parsed_cmd,
)
.await;
continue;

View File

@@ -4,6 +4,7 @@ use std::sync::Arc;
use codex_core::CodexConversation;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use codex_protocol::parse_command::ParsedCommand;
use mcp_types::ElicitRequest;
use mcp_types::ElicitRequestParamsRequestedSchema;
use mcp_types::JSONRPCErrorError;
@@ -35,6 +36,7 @@ pub struct ExecApprovalElicitRequestParams {
pub codex_call_id: String,
pub codex_command: Vec<String>,
pub codex_cwd: PathBuf,
pub codex_parsed_cmd: Vec<ParsedCommand>,
}
// TODO(mbolin): ExecApprovalResponse does not conform to ElicitResult. See:
@@ -56,6 +58,7 @@ pub(crate) async fn handle_exec_approval_request(
tool_call_id: String,
event_id: String,
call_id: String,
codex_parsed_cmd: Vec<ParsedCommand>,
) {
let escaped_command =
shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" "));
@@ -77,6 +80,7 @@ pub(crate) async fn handle_exec_approval_request(
codex_call_id: call_id,
codex_command: command,
codex_cwd: cwd,
codex_parsed_cmd,
};
let params_json = match serde_json::to_value(&params) {
Ok(value) => value,

View File

@@ -3,6 +3,7 @@ use std::env;
use std::path::Path;
use std::path::PathBuf;
use codex_core::parse_command;
use codex_core::protocol::FileChange;
use codex_core::protocol::ReviewDecision;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
@@ -176,6 +177,7 @@ fn create_expected_elicitation_request(
shlex::try_join(command.iter().map(std::convert::AsRef::as_ref))?,
workdir.to_string_lossy()
);
let codex_parsed_cmd = parse_command::parse_command(&command);
Ok(JSONRPCRequest {
jsonrpc: JSONRPC_VERSION.into(),
id: elicitation_request_id,
@@ -193,6 +195,7 @@ fn create_expected_elicitation_request(
codex_command: command,
codex_cwd: workdir.to_path_buf(),
codex_call_id: "call1234".to_string(),
codex_parsed_cmd,
})?),
})
}

View File

@@ -90,9 +90,6 @@ pub enum Op {
summary: ReasoningSummaryConfig,
// The JSON schema to use for the final assistant message
final_output_json_schema: Option<Value>,
/// Optional list of tool names to disable for this turn.
#[serde(skip_serializing_if = "Option::is_none")]
disabled_tools: Option<Vec<String>>,
},
/// Override parts of the persistent turn context for subsequent turns.
@@ -128,9 +125,6 @@ pub enum Op {
/// Updated reasoning summary preference (honored only for reasoning-capable models).
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<ReasoningSummaryConfig>,
/// Updated default disabled tool list for subsequent turns.
#[serde(skip_serializing_if = "Option::is_none")]
disabled_tools: Option<Vec<String>>,
},
/// Approve a command execution
@@ -1184,6 +1178,7 @@ pub struct ExecApprovalRequestEvent {
/// Optional human-readable reason for the approval (e.g. retry without sandbox).
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub parsed_cmd: Vec<ParsedCommand>,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]

View File

@@ -427,8 +427,9 @@ impl App {
tui.frame_requester().schedule_frame();
}
// Esc primes/advances backtracking only in normal (not working) mode
// with an empty composer. In any other state, forward Esc so the
// active UI (e.g. status indicator, modals, popups) handles it.
// with the composer focused and empty. In any other state, forward
// Esc so the active UI (e.g. status indicator, modals, popups)
// handles it.
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,

View File

@@ -82,15 +82,16 @@ impl App {
/// Handle global Esc presses for backtracking when no overlay is present.
pub(crate) fn handle_backtrack_esc_key(&mut self, tui: &mut tui::Tui) {
// Only handle backtracking when composer is empty to avoid clobbering edits.
if self.chat_widget.composer_is_empty() {
if !self.backtrack.primed {
self.prime_backtrack();
} else if self.overlay.is_none() {
self.open_backtrack_preview(tui);
} else if self.backtrack.overlay_preview_active {
self.step_backtrack_and_highlight(tui);
}
if !self.chat_widget.composer_is_empty() {
return;
}
if !self.backtrack.primed {
self.prime_backtrack();
} else if self.overlay.is_none() {
self.open_backtrack_preview(tui);
} else if self.backtrack.overlay_preview_active {
self.step_backtrack_and_highlight(tui);
}
}

View File

@@ -316,6 +316,11 @@ impl ChatComposer {
self.sync_file_search_popup();
}
pub(crate) fn clear_for_ctrl_c(&mut self) {
self.set_text_content(String::new());
self.history.reset_navigation();
}
/// Get the current composer text.
pub(crate) fn current_text(&self) -> String {
self.textarea.text().to_string()
@@ -852,10 +857,12 @@ impl ChatComposer {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
if self.is_empty() {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
@@ -1792,6 +1799,35 @@ mod tests {
});
}
#[test]
fn esc_hint_stays_hidden_with_draft_content() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
true,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['d']);
assert!(!composer.is_empty());
assert_eq!(composer.current_text(), "d");
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
assert!(matches!(composer.active_popup, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
assert!(!composer.esc_backtrack_hint);
}
#[test]
fn question_mark_only_toggles_on_first_char() {
use crossterm::event::KeyCode;

View File

@@ -70,6 +70,12 @@ impl ChatComposerHistory {
self.local_history.push(text.to_string());
}
/// Reset navigation tracking so the next Up key resumes from the latest entry.
pub fn reset_navigation(&mut self) {
self.history_cursor = None;
self.last_history_text = None;
}
/// Should Up/Down key presses be interpreted as history navigation given
/// the current content and cursor position of `textarea`?
pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool {
@@ -271,4 +277,24 @@ mod tests {
history.on_entry_response(1, 1, Some("older".into()))
);
}
#[test]
fn reset_navigation_resets_cursor() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut history = ChatComposerHistory::new();
history.set_metadata(1, 3);
history.fetched_history.insert(1, "command2".into());
history.fetched_history.insert(2, "command3".into());
assert_eq!(Some("command3".into()), history.navigate_up(&tx));
assert_eq!(Some("command2".into()), history.navigate_up(&tx));
history.reset_navigation();
assert!(history.history_cursor.is_none());
assert!(history.last_history_text.is_none());
assert_eq!(Some("command3".into()), history.navigate_up(&tx));
}
}

View File

@@ -37,6 +37,7 @@ pub(crate) struct SelectionItem {
pub name: String,
pub display_shortcut: Option<KeyBinding>,
pub description: Option<String>,
pub selected_description: Option<String>,
pub is_current: bool,
pub actions: Vec<SelectionAction>,
pub dismiss_on_select: bool,
@@ -193,12 +194,16 @@ impl ListSelectionView {
} else {
format!("{prefix} {n}. {name_with_marker}")
};
let description = is_selected
.then(|| item.selected_description.clone())
.flatten()
.or_else(|| item.description.clone());
GenericDisplayRow {
name: display_name,
display_shortcut: item.display_shortcut,
match_indices: None,
is_current: item.is_current,
description: item.description.clone(),
description,
}
})
})

View File

@@ -236,7 +236,7 @@ impl BottomPane {
CancellationEvent::NotHandled
} else {
self.view_stack.pop();
self.set_composer_text(String::new());
self.clear_composer_for_ctrl_c();
self.show_ctrl_c_quit_hint();
CancellationEvent::Handled
}
@@ -270,6 +270,11 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn clear_composer_for_ctrl_c(&mut self) {
self.composer.clear_for_ctrl_c();
self.request_redraw();
}
/// Get the current composer text (for tests and programmatic checks).
pub(crate) fn composer_text(&self) -> String {
self.composer.current_text()

View File

@@ -26,6 +26,7 @@ pub(crate) struct TextArea {
wrap_cache: RefCell<Option<WrapCache>>,
preferred_col: Option<usize>,
elements: Vec<TextElement>,
kill_buffer: String,
}
#[derive(Debug, Clone)]
@@ -48,6 +49,7 @@ impl TextArea {
wrap_cache: RefCell::new(None),
preferred_col: None,
elements: Vec::new(),
kill_buffer: String::new(),
}
}
@@ -57,6 +59,7 @@ impl TextArea {
self.wrap_cache.replace(None);
self.preferred_col = None;
self.elements.clear();
self.kill_buffer.clear();
}
pub fn text(&self) -> &str {
@@ -305,6 +308,13 @@ impl TextArea {
} => {
self.kill_to_end_of_line();
}
KeyEvent {
code: KeyCode::Char('y'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.yank();
}
// Cursor movement
KeyEvent {
@@ -437,7 +447,7 @@ impl TextArea {
pub fn delete_backward_word(&mut self) {
let start = self.beginning_of_previous_word();
self.replace_range(start..self.cursor_pos, "");
self.kill_range(start..self.cursor_pos);
}
/// Delete text to the right of the cursor using "word" semantics.
@@ -448,32 +458,63 @@ impl TextArea {
pub fn delete_forward_word(&mut self) {
let end = self.end_of_next_word();
if end > self.cursor_pos {
self.replace_range(self.cursor_pos..end, "");
self.kill_range(self.cursor_pos..end);
}
}
pub fn kill_to_end_of_line(&mut self) {
let eol = self.end_of_current_line();
if self.cursor_pos == eol {
let range = if self.cursor_pos == eol {
if eol < self.text.len() {
self.replace_range(self.cursor_pos..eol + 1, "");
Some(self.cursor_pos..eol + 1)
} else {
None
}
} else {
self.replace_range(self.cursor_pos..eol, "");
Some(self.cursor_pos..eol)
};
if let Some(range) = range {
self.kill_range(range);
}
}
pub fn kill_to_beginning_of_line(&mut self) {
let bol = self.beginning_of_current_line();
if self.cursor_pos == bol {
if bol > 0 {
self.replace_range(bol - 1..bol, "");
}
let range = if self.cursor_pos == bol {
if bol > 0 { Some(bol - 1..bol) } else { None }
} else {
self.replace_range(bol..self.cursor_pos, "");
Some(bol..self.cursor_pos)
};
if let Some(range) = range {
self.kill_range(range);
}
}
pub fn yank(&mut self) {
if self.kill_buffer.is_empty() {
return;
}
let text = self.kill_buffer.clone();
self.insert_str(&text);
}
fn kill_range(&mut self, range: Range<usize>) {
let range = self.expand_range_to_element_boundaries(range);
if range.start >= range.end {
return;
}
let removed = self.text[range.clone()].to_string();
if removed.is_empty() {
return;
}
self.kill_buffer = removed;
self.replace_range_raw(range, "");
}
/// Move the cursor left by a single grapheme cluster.
pub fn move_cursor_left(&mut self) {
self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos);
@@ -1198,6 +1239,39 @@ mod tests {
assert_eq!(t.cursor(), elem_range.start);
}
#[test]
fn yank_restores_last_kill() {
let mut t = ta_with("hello");
t.set_cursor(0);
t.kill_to_end_of_line();
assert_eq!(t.text(), "");
assert_eq!(t.cursor(), 0);
t.yank();
assert_eq!(t.text(), "hello");
assert_eq!(t.cursor(), 5);
let mut t = ta_with("hello world");
t.set_cursor(t.text().len());
t.delete_backward_word();
assert_eq!(t.text(), "hello ");
assert_eq!(t.cursor(), 6);
t.yank();
assert_eq!(t.text(), "hello world");
assert_eq!(t.cursor(), 11);
let mut t = ta_with("hello");
t.set_cursor(5);
t.kill_to_beginning_of_line();
assert_eq!(t.text(), "");
assert_eq!(t.cursor(), 0);
t.yank();
assert_eq!(t.text(), "hello");
assert_eq!(t.cursor(), 5);
}
#[test]
fn cursor_left_and_right_handle_graphemes() {
let mut t = ta_with("a👍b");

View File

@@ -53,6 +53,8 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use tokio::sync::mpsc::UnboundedSender;
@@ -81,6 +83,7 @@ use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::McpToolCallCell;
use crate::markdown::append_markdown;
use crate::render::renderable::ColumnRenderable;
use crate::slash_command::SlashCommand;
use crate::status::RateLimitSnapshotDisplay;
use crate::text_formatting::truncate_text;
@@ -1718,7 +1721,6 @@ impl ChatWidget {
} else {
default_choice
};
let mut items: Vec<SelectionItem> = Vec::new();
for choice in choices.iter() {
let effort = choice.display;
@@ -1741,6 +1743,14 @@ impl ChatWidget {
.map(|preset| preset.description.to_string())
});
let warning = "⚠ High reasoning effort can quickly consume Plus plan rate limits.";
let show_warning = model_slug == "gpt-5-codex" && effort == ReasoningEffortConfig::High;
let selected_description = show_warning.then(|| {
description
.as_ref()
.map_or(warning.to_string(), |d| format!("{d}\n{warning}"))
});
let model_for_action = model_slug.clone();
let effort_for_action = choice.stored;
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
@@ -1751,7 +1761,6 @@ impl ChatWidget {
model: Some(model_for_action.clone()),
effort: Some(effort_for_action),
summary: None,
disabled_tools: None,
}));
tx.send(AppEvent::UpdateModel(model_for_action.clone()));
tx.send(AppEvent::UpdateReasoningEffort(effort_for_action));
@@ -1771,6 +1780,7 @@ impl ChatWidget {
items.push(SelectionItem {
name: effort_label,
description,
selected_description,
is_current: is_current_model && choice.stored == highlight_choice,
actions,
dismiss_on_select: true,
@@ -1778,9 +1788,13 @@ impl ChatWidget {
});
}
let mut header = ColumnRenderable::new();
header.push(Line::from(
format!("Select Reasoning Level for {model_slug}").bold(),
));
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select Reasoning Level".to_string()),
subtitle: Some(format!("Reasoning for model {model_slug}")),
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
@@ -1808,7 +1822,6 @@ impl ChatWidget {
model: None,
effort: None,
summary: None,
disabled_tools: None,
}));
tx.send(AppEvent::UpdateAskForApprovalPolicy(approval));
tx.send(AppEvent::UpdateSandboxPolicy(sandbox.clone()));

View File

@@ -2,12 +2,13 @@
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Reasoning Level
Reasoning for model gpt-5-codex
Select Reasoning Level for gpt-5-codex
1. Low Fastest responses with limited reasoning
2. Medium (default) Dynamically adjusts reasoning based on the task
3. High (current) Maximizes reasoning depth for complex or ambiguous
problems
⚠ High reasoning effort can quickly consume Plus plan
rate limits.
Press enter to confirm or esc to go back

View File

@@ -392,6 +392,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-short".into(),
@@ -433,6 +434,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-multi".into(),
@@ -480,6 +482,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
command: vec!["bash".into(), "-lc".into(), long],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-long".into(),
@@ -505,10 +508,7 @@ fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) {
// Build the full command vec and parse it using core's parser,
// then convert to protocol variants for the event payload.
let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()];
let parsed_cmd: Vec<ParsedCommand> = codex_core::parse_command::parse_command(&command)
.into_iter()
.map(Into::into)
.collect();
let parsed_cmd: Vec<ParsedCommand> = codex_core::parse_command::parse_command(&command);
chat.handle_codex_event(Event {
id: call_id.to_string(),
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
@@ -1205,10 +1205,7 @@ async fn binary_size_transcript_snapshot() {
call_id: e.call_id.clone(),
command: e.command,
cwd: e.cwd,
parsed_cmd: parsed_cmd
.into_iter()
.map(std::convert::Into::into)
.collect(),
parsed_cmd,
}),
}
}
@@ -1323,6 +1320,7 @@ fn approval_modal_exec_snapshot() {
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-approve".into(),
@@ -1366,6 +1364,7 @@ fn approval_modal_exec_without_reason_snapshot() {
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-approve-noreason".into(),
@@ -1575,6 +1574,7 @@ fn status_widget_and_approval_modal_snapshot() {
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-approve-exec".into(),
@@ -2241,17 +2241,15 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
command: vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
parsed_cmd: vec![
codex_core::parse_command::ParsedCommand::Search {
ParsedCommand::Search {
query: Some("Change Approved".into()),
path: None,
cmd: "rg \"Change Approved\"".into(),
}
.into(),
codex_core::parse_command::ParsedCommand::Read {
},
ParsedCommand::Read {
name: "diff_render.rs".into(),
cmd: "cat diff_render.rs".into(),
}
.into(),
},
],
}),
});

View File

@@ -2492,7 +2492,7 @@
{"ts":"2025-08-09T15:51:59.856Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
{"ts":"2025-08-09T15:51:59.858Z","dir":"to_tui","kind":"app_event","variant":"Redraw"}
{"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] FunctionCall: {\"command\":[\"bash\",\"-lc\",\"just fix\"],\"with_escalated_permissions\":true,\"justifica"}
{"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_KOxVodT3X5ci7LJmudvcovhW","command":["bash","-lc","just fix"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run clippy with network and system permissions to apply lint fixes across workspace."}}}
{"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_KOxVodT3X5ci7LJmudvcovhW","command":["bash","-lc","just fix"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run clippy with network and system permissions to apply lint fixes across workspace.","parsed_cmd":[]}}}
{"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
{"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"insert_history","lines":5}
{"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
@@ -4172,7 +4172,7 @@
{"ts":"2025-08-09T15:53:09.375Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
{"ts":"2025-08-09T15:53:09.376Z","dir":"to_tui","kind":"app_event","variant":"Redraw"}
{"ts":"2025-08-09T15:53:09.448Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] FunctionCall: {\"command\":[\"bash\",\"-lc\",\"just fix\"],\"with_escalated_permissions\":true,\"justifica"}
{"ts":"2025-08-09T15:53:09.448Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_POl3hxI2xeszBLv9IOM7L2ir","command":["bash","-lc","just fix"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Clippy needs broader permissions; allow to run and apply lint fixes."}}}
{"ts":"2025-08-09T15:53:09.448Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_POl3hxI2xeszBLv9IOM7L2ir","command":["bash","-lc","just fix"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Clippy needs broader permissions; allow to run and apply lint fixes.","parsed_cmd":[]}}}
{"ts":"2025-08-09T15:53:09.448Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
{"ts":"2025-08-09T15:53:09.449Z","dir":"to_tui","kind":"insert_history","lines":5}
{"ts":"2025-08-09T15:53:09.449Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
@@ -7776,7 +7776,7 @@
{"ts":"2025-08-09T15:58:28.583Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
{"ts":"2025-08-09T15:58:28.590Z","dir":"to_tui","kind":"app_event","variant":"Redraw"}
{"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] FunctionCall: {\"command\":[\"bash\",\"-lc\",\"cargo test -p codex-core shell::tests::test_current_she"}
{"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_iMa8Qnw0dYLba4rVysxebmkV","command":["bash","-lc","cargo test -p codex-core shell::tests::test_current_shell_detects_zsh -- --nocapture"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run the macOS shell detection test without sandbox limits so dscl can read user shell."}}}
{"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_iMa8Qnw0dYLba4rVysxebmkV","command":["bash","-lc","cargo test -p codex-core shell::tests::test_current_shell_detects_zsh -- --nocapture"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run the macOS shell detection test without sandbox limits so dscl can read user shell.","parsed_cmd":[]}}}
{"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
{"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"insert_history","lines":5}
{"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
@@ -8730,7 +8730,7 @@
{"ts":"2025-08-09T15:59:01.983Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
{"ts":"2025-08-09T15:59:01.985Z","dir":"to_tui","kind":"app_event","variant":"Redraw"}
{"ts":"2025-08-09T15:59:02.005Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] FunctionCall: {\"command\":[\"bash\",\"-lc\",\"cargo test --all-features\"],\"with_escalated_permissions"}
{"ts":"2025-08-09T15:59:02.005Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_JDFGIuFhYCIiQO1Aq2L9lBO1","command":["bash","-lc","cargo test --all-features"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run full test suite without sandbox constraints to validate the merge."}}}
{"ts":"2025-08-09T15:59:02.005Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_JDFGIuFhYCIiQO1Aq2L9lBO1","command":["bash","-lc","cargo test --all-features"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run full test suite without sandbox constraints to validate the merge.","parsed_cmd":[]}}}
{"ts":"2025-08-09T15:59:02.006Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
{"ts":"2025-08-09T15:59:02.006Z","dir":"to_tui","kind":"insert_history","lines":5}
{"ts":"2025-08-09T15:59:02.006Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}