mirror of
https://github.com/openai/codex.git
synced 2026-02-04 07:53:43 +00:00
Compare commits
17 Commits
search
...
fix_compac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
093406c67f | ||
|
|
daf762f607 | ||
|
|
cafd08ab41 | ||
|
|
a4e042b671 | ||
|
|
2994b63fe5 | ||
|
|
0cf9bd6aa7 | ||
|
|
f2444893ca | ||
|
|
d4e59dedd8 | ||
|
|
e0f7c32217 | ||
|
|
578a6bc9e1 | ||
|
|
18d00e36b9 | ||
|
|
17550fee9e | ||
|
|
995f5c3614 | ||
|
|
9b53a306e3 | ||
|
|
0016346dfb | ||
|
|
f38ad65254 | ||
|
|
774892c6d7 |
@@ -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)?,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -84,7 +84,6 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> {
|
||||
model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
disabled_tools: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ async fn submit_turn(
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
disabled_tools: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
81
codex-rs/exec/tests/suite/approve_all.rs
Normal file
81
codex-rs/exec/tests/suite/approve_all.rs
Normal 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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¶ms) {
|
||||
Ok(value) => value,
|
||||
|
||||
@@ -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,
|
||||
})?),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user