mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
Restore Frodex fork command and debug hooks
This commit is contained in:
@@ -1704,15 +1704,17 @@ async fn parent_mcp_tool_snapshot_for_source(
|
||||
}
|
||||
|
||||
fn fork_previous_response_id_enabled() -> bool {
|
||||
std::env::var(CODEX_EXPERIMENTAL_FORK_PREVIOUS_RESPONSE_ID_ENV)
|
||||
.is_ok_and(|value| fork_previous_response_id_value_enabled(&value))
|
||||
let value = std::env::var(CODEX_EXPERIMENTAL_FORK_PREVIOUS_RESPONSE_ID_ENV).ok();
|
||||
fork_previous_response_id_value_enabled(value.as_deref())
|
||||
}
|
||||
|
||||
fn fork_previous_response_id_value_enabled(value: &str) -> bool {
|
||||
matches!(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
fn fork_previous_response_id_value_enabled(value: Option<&str>) -> bool {
|
||||
value.is_none_or(|value| {
|
||||
matches!(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn parent_response_continuation_for_source(
|
||||
|
||||
@@ -108,19 +108,24 @@ fn assistant_message(text: &str, phase: Option<MessagePhase>) -> ResponseItem {
|
||||
fn fork_previous_response_id_env_value_parses_truthy_values() {
|
||||
for value in ["1", "true", "TRUE", "yes", "on"] {
|
||||
assert!(
|
||||
fork_previous_response_id_value_enabled(value),
|
||||
fork_previous_response_id_value_enabled(Some(value)),
|
||||
"{value} should enable previous response forking"
|
||||
);
|
||||
}
|
||||
|
||||
for value in ["", "0", "false", "off", "no", "enabled"] {
|
||||
assert!(
|
||||
!fork_previous_response_id_value_enabled(value),
|
||||
!fork_previous_response_id_value_enabled(Some(value)),
|
||||
"{value} should not enable previous response forking"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_previous_response_id_is_enabled_by_default() {
|
||||
assert!(fork_previous_response_id_value_enabled(/*value*/ None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn previous_response_fork_rollout_items_preserve_latest_turn_context() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
|
||||
@@ -6,6 +6,15 @@ use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
const CODEX_MATERIALIZE_EPHEMERAL_ROLLOUTS_ENV: &str = "CODEX_MATERIALIZE_EPHEMERAL_ROLLOUTS";
|
||||
|
||||
fn materialize_ephemeral_rollouts_for_debug() -> bool {
|
||||
std::env::var(CODEX_MATERIALIZE_EPHEMERAL_ROLLOUTS_ENV).is_ok_and(|value| {
|
||||
let value = value.trim();
|
||||
!value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
|
||||
})
|
||||
}
|
||||
|
||||
/// Context for an initialized model agent
|
||||
///
|
||||
/// A session has at most 1 running task at a time, and can be interrupted by user input.
|
||||
@@ -382,8 +391,9 @@ impl Session {
|
||||
// - initialize thread persistence with new or resumed session info
|
||||
// - perform default shell discovery
|
||||
// - load history metadata (skipped for subagents)
|
||||
let materialize_ephemeral_rollout = materialize_ephemeral_rollouts_for_debug();
|
||||
let thread_persistence_fut = async {
|
||||
if config.ephemeral {
|
||||
if config.ephemeral && !materialize_ephemeral_rollout {
|
||||
Ok::<_, anyhow::Error>(None)
|
||||
} else {
|
||||
let live_thread = match &initial_history {
|
||||
@@ -442,6 +452,7 @@ impl Session {
|
||||
"session_init.thread_persistence",
|
||||
otel.name = "session_init.thread_persistence",
|
||||
session_init.ephemeral = config.ephemeral,
|
||||
session_init.materialize_ephemeral_rollout = materialize_ephemeral_rollout,
|
||||
));
|
||||
let state_db_fut = async {
|
||||
if config.ephemeral {
|
||||
|
||||
@@ -64,6 +64,25 @@ impl ToolHandler for Handler {
|
||||
)
|
||||
.await;
|
||||
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.finish_watchdog_helper(helper_thread_id)
|
||||
.await;
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.finish_watchdog_helper_thread(helper_thread_id)
|
||||
.await
|
||||
.map_err(|err| collab_agent_error(helper_thread_id, err))?;
|
||||
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.close_agent(target_thread_id)
|
||||
.await
|
||||
.map_err(|err| collab_agent_error(target_thread_id, err))?;
|
||||
|
||||
if let Some(message) = args.message
|
||||
&& !message.trim().is_empty()
|
||||
{
|
||||
@@ -74,13 +93,6 @@ impl ToolHandler for Handler {
|
||||
.await;
|
||||
}
|
||||
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.close_agent(target_thread_id)
|
||||
.await
|
||||
.map_err(|err| collab_agent_error(target_thread_id, err))?;
|
||||
|
||||
Ok(WatchdogSelfCloseResult {
|
||||
previous_status: status,
|
||||
})
|
||||
|
||||
@@ -29,6 +29,16 @@ impl ToolHandler for Handler {
|
||||
"watchdog.snooze is only available in watchdog check-in threads.".to_string(),
|
||||
));
|
||||
};
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.finish_watchdog_helper_thread(session.conversation_id)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"failed to finish watchdog helper after snooze: {err}"
|
||||
))
|
||||
})?;
|
||||
let _ = args.reason;
|
||||
Ok(WatchdogSnoozeResult {
|
||||
target_thread_id: result.target_thread_id.to_string(),
|
||||
|
||||
@@ -730,7 +730,12 @@ async fn watchdog_snooze_suppresses_helper_and_clears_active_helper() {
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("watchdog handle should start");
|
||||
let helper_thread_id = session.conversation_id;
|
||||
let helper = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("watchdog helper thread should start");
|
||||
let helper_thread_id = helper.thread_id;
|
||||
session.conversation_id = helper_thread_id;
|
||||
session.services.agent_control = agent_control.clone();
|
||||
let mut config = (*turn.config).clone();
|
||||
config
|
||||
@@ -999,7 +1004,6 @@ async fn watchdog_close_self_notifies_owner_and_unregisters_handle() {
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("watchdog handle should start");
|
||||
let helper_thread_id = session.conversation_id;
|
||||
session.services.agent_control = agent_control.clone();
|
||||
let mut config = (*turn.config).clone();
|
||||
config
|
||||
@@ -1015,10 +1019,29 @@ async fn watchdog_close_self_notifies_owner_and_unregisters_handle() {
|
||||
child_depth: 0,
|
||||
interval_s: 60,
|
||||
prompt: "check in".to_string(),
|
||||
config,
|
||||
config: config.clone(),
|
||||
})
|
||||
.await
|
||||
.expect("watchdog registration should succeed");
|
||||
let helper_thread_id = agent_control
|
||||
.spawn_agent(
|
||||
config,
|
||||
vec![UserInput::Text {
|
||||
text: "watchdog helper implementation detail".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: owner.thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("watchdog".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("watchdog helper should start");
|
||||
session.conversation_id = helper_thread_id;
|
||||
agent_control
|
||||
.set_watchdog_active_helper_for_tests(target.thread_id, helper_thread_id)
|
||||
.await;
|
||||
@@ -1043,6 +1066,19 @@ async fn watchdog_close_self_notifies_owner_and_unregisters_handle() {
|
||||
agent_control.get_status(target.thread_id).await,
|
||||
AgentStatus::NotFound
|
||||
);
|
||||
assert_eq!(
|
||||
agent_control.get_status(helper_thread_id).await,
|
||||
AgentStatus::NotFound
|
||||
);
|
||||
let listed_agents = agent_control
|
||||
.list_agents(&SessionSource::Cli, /*path_prefix*/ None)
|
||||
.await
|
||||
.expect("list_agents should succeed after self-close");
|
||||
assert!(
|
||||
!listed_agents
|
||||
.iter()
|
||||
.any(|agent| agent.agent_name == target.thread_id.to_string())
|
||||
);
|
||||
let expected = InterAgentCommunication::new(
|
||||
AgentPath::try_from("/root/watchdog").expect("watchdog path"),
|
||||
AgentPath::root(),
|
||||
@@ -1073,6 +1109,97 @@ async fn watchdog_close_self_notifies_owner_and_unregisters_handle() {
|
||||
assert_eq!(close_event.status, AgentStatus::Running);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watchdog_close_self_removes_watchdog_handle_from_list_agents() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
let agent_control = manager.agent_control();
|
||||
session.services.agent_control = agent_control.clone();
|
||||
session.conversation_id = root.thread_id;
|
||||
let mut config = (*turn.config).clone();
|
||||
config
|
||||
.features
|
||||
.enable(Feature::AgentWatchdog)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("test config should allow feature update");
|
||||
let enabled_config = config.clone();
|
||||
turn.config = Arc::new(config);
|
||||
let root_session = Arc::new(session);
|
||||
let root_turn = Arc::new(turn);
|
||||
|
||||
let spawn_output = SpawnAgentHandlerV2
|
||||
.handle(invocation(
|
||||
root_session,
|
||||
root_turn,
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "check this branch periodically",
|
||||
"task_name": "ping_watchdog",
|
||||
"agent_type": "watchdog"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("watchdog spawn should succeed");
|
||||
let (_, spawn_success) = expect_text_output(spawn_output);
|
||||
let watchdog_id = agent_control
|
||||
.resolve_agent_reference(root.thread_id, &SessionSource::Cli, "ping_watchdog")
|
||||
.await
|
||||
.expect("watchdog path should resolve");
|
||||
assert_eq!(spawn_success, Some(true));
|
||||
|
||||
let helper_id = agent_control
|
||||
.spawn_agent(
|
||||
enabled_config,
|
||||
vec![UserInput::Text {
|
||||
text: "watchdog helper implementation detail".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
.into(),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: Some("watchdog".to_string()),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("watchdog helper should start");
|
||||
agent_control
|
||||
.set_watchdog_active_helper_for_tests(watchdog_id, helper_id)
|
||||
.await;
|
||||
let (mut helper_session, helper_turn) = make_session_and_context().await;
|
||||
helper_session.services.agent_control = agent_control.clone();
|
||||
helper_session.conversation_id = helper_id;
|
||||
|
||||
WatchdogSelfCloseHandler
|
||||
.handle(invocation(
|
||||
Arc::new(helper_session),
|
||||
Arc::new(helper_turn),
|
||||
"close_self",
|
||||
function_payload(json!({"message": "watchdog done"})),
|
||||
))
|
||||
.await
|
||||
.expect("watchdog helper should self-close");
|
||||
|
||||
let listed_agents = agent_control
|
||||
.list_agents(&SessionSource::Cli, /*path_prefix*/ None)
|
||||
.await
|
||||
.expect("list_agents should succeed after self-close");
|
||||
assert!(
|
||||
!listed_agents
|
||||
.iter()
|
||||
.any(|agent| agent.agent_name == "/root/ping_watchdog")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_spawn_requires_task_name() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
|
||||
@@ -51,3 +51,21 @@ fn does_not_persist_rollout_file_in_ephemeral_mode() -> anyhow::Result<()> {
|
||||
assert_eq!(session_rollout_count(test.home_path()), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materializes_rollout_file_for_ephemeral_mode_when_debug_env_is_enabled() -> anyhow::Result<()> {
|
||||
let test = test_codex_exec();
|
||||
let fixture = find_resource!("tests/fixtures/cli_responses_fixture.sse")?;
|
||||
|
||||
test.cmd()
|
||||
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
.env("CODEX_MATERIALIZE_EPHEMERAL_ROLLOUTS", "1")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("--ephemeral")
|
||||
.arg("ephemeral debug behavior")
|
||||
.assert()
|
||||
.code(0);
|
||||
|
||||
assert_eq!(session_rollout_count(test.home_path()), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
use super::resize_reflow::trailing_run_start;
|
||||
use super::*;
|
||||
use crate::terminal_multiplexer::FORK_PLACEMENT_REQUIRES_MULTIPLEXER_MESSAGE;
|
||||
use crate::terminal_multiplexer::ForkPaneSpawnResult;
|
||||
use crate::terminal_multiplexer::spawn_fork_in_new_pane;
|
||||
|
||||
const SHUTDOWN_FIRST_EXIT_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 2);
|
||||
|
||||
@@ -115,7 +118,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::ForkCurrentSession => {
|
||||
AppEvent::ForkCurrentSession { placement } => {
|
||||
self.session_telemetry.counter(
|
||||
"codex.thread.fork",
|
||||
/*inc*/ 1,
|
||||
@@ -127,11 +130,46 @@ impl App {
|
||||
self.chat_widget.thread_name(),
|
||||
self.chat_widget.rollout_path().as_deref(),
|
||||
);
|
||||
self.chat_widget
|
||||
.add_plain_history_lines(vec!["/fork".magenta().into()]);
|
||||
let terminal_info = codex_terminal_detection::terminal_info();
|
||||
if terminal_info.multiplexer.is_none() {
|
||||
self.chat_widget
|
||||
.add_plain_history_lines(vec!["/fork".magenta().into()]);
|
||||
}
|
||||
if let Some(thread_id) = self.chat_widget.thread_id() {
|
||||
self.refresh_in_memory_config_from_disk_best_effort("forking the thread")
|
||||
.await;
|
||||
if let Some(multiplexer) = terminal_info.multiplexer.as_ref() {
|
||||
match spawn_fork_in_new_pane(
|
||||
multiplexer,
|
||||
&thread_id,
|
||||
&self.config,
|
||||
&self.harness_overrides.additional_writable_roots,
|
||||
placement,
|
||||
)
|
||||
.await
|
||||
{
|
||||
ForkPaneSpawnResult::Spawned => {
|
||||
tui.frame_requester().schedule_frame();
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
ForkPaneSpawnResult::InvalidPlacement(message) => {
|
||||
self.chat_widget.add_error_message(message);
|
||||
tui.frame_requester().schedule_frame();
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
ForkPaneSpawnResult::Failed(err) => {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to open a new pane for /fork: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if placement.is_some() {
|
||||
self.chat_widget.add_error_message(
|
||||
FORK_PLACEMENT_REQUIRES_MULTIPLEXER_MESSAGE.to_string(),
|
||||
);
|
||||
tui.frame_requester().schedule_frame();
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
match app_server.fork_thread(self.config.clone(), thread_id).await {
|
||||
Ok(forked) => {
|
||||
self.shutdown_current_thread(app_server).await;
|
||||
|
||||
@@ -172,7 +172,9 @@ pub(crate) enum AppEvent {
|
||||
ResumeSessionByIdOrName(String),
|
||||
|
||||
/// Fork the current session into a new thread.
|
||||
ForkCurrentSession,
|
||||
ForkCurrentSession {
|
||||
placement: Option<ForkPanePlacement>,
|
||||
},
|
||||
|
||||
/// Request to exit the application.
|
||||
///
|
||||
@@ -890,6 +892,15 @@ pub(crate) struct RealtimeWebrtcOffer {
|
||||
pub(crate) handle: RealtimeWebrtcSessionHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ForkPanePlacement {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
Float,
|
||||
}
|
||||
|
||||
/// The exit strategy requested by the UI layer.
|
||||
///
|
||||
/// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only
|
||||
|
||||
@@ -9,6 +9,9 @@ use super::*;
|
||||
use crate::app_event::ThreadGoalSetMode;
|
||||
use crate::bottom_pane::prompt_args::parse_slash_name;
|
||||
use crate::bottom_pane::slash_commands;
|
||||
use crate::terminal_multiplexer::fork_command_usage;
|
||||
use crate::terminal_multiplexer::fork_pane_options;
|
||||
use crate::terminal_multiplexer::parse_fork_pane_placement;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SlashCommandDispatchSource {
|
||||
@@ -145,7 +148,8 @@ impl ChatWidget {
|
||||
self.app_event_tx.send(AppEvent::OpenResumePicker);
|
||||
}
|
||||
SlashCommand::Fork => {
|
||||
self.app_event_tx.send(AppEvent::ForkCurrentSession);
|
||||
let terminal_info = terminal_info();
|
||||
self.dispatch_fork_command(terminal_info.multiplexer.as_ref());
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME);
|
||||
@@ -722,6 +726,18 @@ impl ChatWidget {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ResumeSessionByIdOrName(args));
|
||||
}
|
||||
SlashCommand::Fork => {
|
||||
let mut parts = trimmed.split_whitespace();
|
||||
let placement = parts.next().and_then(parse_fork_pane_placement);
|
||||
if placement.is_none() || parts.next().is_some() {
|
||||
self.add_error_message(fork_command_usage(
|
||||
terminal_info().multiplexer.as_ref(),
|
||||
));
|
||||
} else {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ForkCurrentSession { placement });
|
||||
}
|
||||
}
|
||||
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args });
|
||||
@@ -812,6 +828,43 @@ impl ChatWidget {
|
||||
self.queued_command_drain_result(cmd)
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_fork_command(&mut self, multiplexer: Option<&Multiplexer>) {
|
||||
if let Some(multiplexer) = multiplexer {
|
||||
self.open_fork_popup(multiplexer);
|
||||
} else {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ForkCurrentSession { placement: None });
|
||||
}
|
||||
}
|
||||
|
||||
fn open_fork_popup(&mut self, multiplexer: &Multiplexer) {
|
||||
let items = fork_pane_options(multiplexer)
|
||||
.iter()
|
||||
.map(|option| {
|
||||
let placement = option.placement;
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::ForkCurrentSession {
|
||||
placement: Some(placement),
|
||||
});
|
||||
})];
|
||||
SelectionItem {
|
||||
name: format!("/fork {}", option.name),
|
||||
description: Some(option.description.to_string()),
|
||||
actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Fork into a new pane".to_string()),
|
||||
subtitle: Some("Choose where to open the fork.".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
fn builtin_command_flags(&self) -> slash_commands::BuiltinCommandFlags {
|
||||
#[cfg(target_os = "windows")]
|
||||
let allow_elevate_sandbox = {
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Fork into a new pane
|
||||
Choose where to open the fork.
|
||||
|
||||
› 1. /fork right Open the fork in a pane to the right.
|
||||
2. /fork left Open the fork in a pane to the left.
|
||||
3. /fork up Open the fork in a pane above.
|
||||
4. /fork down Open the fork in a pane below.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Fork into a new pane
|
||||
Choose where to open the fork.
|
||||
|
||||
› 1. /fork float Open the fork in a floating pane.
|
||||
2. /fork right Open the fork in a pane to the right.
|
||||
3. /fork down Open the fork in a pane below.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use codex_terminal_detection::Multiplexer;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) {
|
||||
@@ -1648,9 +1649,42 @@ async fn slash_resume_with_arg_requests_named_session() {
|
||||
async fn slash_fork_requests_current_fork() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.dispatch_command(SlashCommand::Fork);
|
||||
chat.dispatch_fork_command(/*multiplexer*/ None);
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession));
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::ForkCurrentSession { placement: None })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_fork_opens_tmux_popup() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.dispatch_fork_command(Some(&Multiplexer::Tmux { version: None }));
|
||||
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"expected /fork in tmux to open a popup instead of dispatching immediately"
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert_chatwidget_snapshot!("fork_selection_popup_tmux", popup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_fork_opens_zellij_popup() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
chat.dispatch_fork_command(Some(&Multiplexer::Zellij {}));
|
||||
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"expected /fork in zellij to open a popup instead of dispatching immediately"
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert_chatwidget_snapshot!("fork_selection_popup_zellij", popup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -166,6 +166,7 @@ mod status_indicator_widget;
|
||||
mod streaming;
|
||||
mod style;
|
||||
mod subagent_panel;
|
||||
mod terminal_multiplexer;
|
||||
mod terminal_palette;
|
||||
mod terminal_probe;
|
||||
mod terminal_title;
|
||||
|
||||
@@ -149,6 +149,7 @@ impl SlashCommand {
|
||||
self,
|
||||
SlashCommand::Review
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Fork
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Goal
|
||||
| SlashCommand::Fast
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/terminal_multiplexer.rs
|
||||
assertion_line: 367
|
||||
expression: fork_command_usage(None)
|
||||
---
|
||||
Usage: /fork
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/terminal_multiplexer.rs
|
||||
expression: "fork_command_usage(Some(&Multiplexer::Tmux { version: None }))"
|
||||
---
|
||||
Usage: /fork [right|left|up|down]
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/terminal_multiplexer.rs
|
||||
expression: "fork_command_usage(Some(&Multiplexer::Zellij {}))"
|
||||
---
|
||||
Usage: /fork [float|right|down]
|
||||
@@ -0,0 +1,538 @@
|
||||
use crate::app_event::ForkPanePlacement;
|
||||
use crate::legacy_core::config::Config;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_terminal_detection::Multiplexer;
|
||||
use shlex::try_join;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
|
||||
pub(crate) struct MultiplexerSpawnConfig {
|
||||
pub(crate) program: PathBuf,
|
||||
pub(crate) args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ForkPaneSpawnResult {
|
||||
Spawned,
|
||||
InvalidPlacement(String),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ForkPaneOption {
|
||||
pub(crate) placement: ForkPanePlacement,
|
||||
pub(crate) name: &'static str,
|
||||
pub(crate) description: &'static str,
|
||||
}
|
||||
|
||||
const TMUX_FORK_PANE_OPTIONS: &[ForkPaneOption] = &[
|
||||
ForkPaneOption {
|
||||
placement: ForkPanePlacement::Right,
|
||||
name: "right",
|
||||
description: "Open the fork in a pane to the right.",
|
||||
},
|
||||
ForkPaneOption {
|
||||
placement: ForkPanePlacement::Left,
|
||||
name: "left",
|
||||
description: "Open the fork in a pane to the left.",
|
||||
},
|
||||
ForkPaneOption {
|
||||
placement: ForkPanePlacement::Up,
|
||||
name: "up",
|
||||
description: "Open the fork in a pane above.",
|
||||
},
|
||||
ForkPaneOption {
|
||||
placement: ForkPanePlacement::Down,
|
||||
name: "down",
|
||||
description: "Open the fork in a pane below.",
|
||||
},
|
||||
];
|
||||
|
||||
const ZELLIJ_FORK_PANE_OPTIONS: &[ForkPaneOption] = &[
|
||||
ForkPaneOption {
|
||||
placement: ForkPanePlacement::Float,
|
||||
name: "float",
|
||||
description: "Open the fork in a floating pane.",
|
||||
},
|
||||
ForkPaneOption {
|
||||
placement: ForkPanePlacement::Right,
|
||||
name: "right",
|
||||
description: "Open the fork in a pane to the right.",
|
||||
},
|
||||
ForkPaneOption {
|
||||
placement: ForkPanePlacement::Down,
|
||||
name: "down",
|
||||
description: "Open the fork in a pane below.",
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) fn fork_pane_options(multiplexer: &Multiplexer) -> &'static [ForkPaneOption] {
|
||||
match multiplexer {
|
||||
Multiplexer::Zellij {} => ZELLIJ_FORK_PANE_OPTIONS,
|
||||
Multiplexer::Tmux { .. } => TMUX_FORK_PANE_OPTIONS,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_fork_pane_placement(arg: &str) -> Option<ForkPanePlacement> {
|
||||
match arg.to_ascii_lowercase().as_str() {
|
||||
"left" => Some(ForkPanePlacement::Left),
|
||||
"right" => Some(ForkPanePlacement::Right),
|
||||
"up" => Some(ForkPanePlacement::Up),
|
||||
"down" => Some(ForkPanePlacement::Down),
|
||||
"float" => Some(ForkPanePlacement::Float),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_executable() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.map(|path| resolve_codex_executable(&path))
|
||||
.unwrap_or_else(|_| PathBuf::from("codex"))
|
||||
}
|
||||
|
||||
fn resolve_codex_executable(current_exe: &Path) -> PathBuf {
|
||||
let Some(file_name) = current_exe.file_name().and_then(|name| name.to_str()) else {
|
||||
return PathBuf::from("codex");
|
||||
};
|
||||
let Some(base_name) = file_name
|
||||
.strip_suffix(".exe")
|
||||
.unwrap_or(file_name)
|
||||
.strip_prefix("codex-tui")
|
||||
else {
|
||||
return current_exe.to_path_buf();
|
||||
};
|
||||
if !base_name.is_empty() {
|
||||
return current_exe.to_path_buf();
|
||||
}
|
||||
|
||||
let sibling = if file_name.ends_with(".exe") {
|
||||
current_exe.with_file_name("codex.exe")
|
||||
} else {
|
||||
current_exe.with_file_name("codex")
|
||||
};
|
||||
|
||||
if sibling.is_file() {
|
||||
sibling
|
||||
} else {
|
||||
PathBuf::from("codex")
|
||||
}
|
||||
}
|
||||
|
||||
fn fork_command_parts(
|
||||
exe: &Path,
|
||||
thread_id: &ThreadId,
|
||||
config: &Config,
|
||||
additional_writable_roots: &[PathBuf],
|
||||
) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
exe.display().to_string(),
|
||||
"fork".to_string(),
|
||||
"-C".to_string(),
|
||||
config.cwd.display().to_string(),
|
||||
];
|
||||
|
||||
match config.permissions.approval_policy.value() {
|
||||
AskForApproval::UnlessTrusted => {
|
||||
args.push("-a".to_string());
|
||||
args.push("untrusted".to_string());
|
||||
}
|
||||
AskForApproval::OnFailure => {
|
||||
args.push("-a".to_string());
|
||||
args.push("on-failure".to_string());
|
||||
}
|
||||
AskForApproval::OnRequest => {
|
||||
args.push("-a".to_string());
|
||||
args.push("on-request".to_string());
|
||||
}
|
||||
AskForApproval::Never => {
|
||||
args.push("-a".to_string());
|
||||
args.push("never".to_string());
|
||||
}
|
||||
AskForApproval::Granular(granular_config) => {
|
||||
let sandbox_approval = granular_config.sandbox_approval;
|
||||
let rules = granular_config.rules;
|
||||
let skill_approval = granular_config.skill_approval;
|
||||
let request_permissions = granular_config.request_permissions;
|
||||
let mcp_elicitations = granular_config.mcp_elicitations;
|
||||
args.push("-c".to_string());
|
||||
args.push(format!(
|
||||
"approval_policy={{ granular = {{ sandbox_approval = {sandbox_approval}, rules = {rules}, skill_approval = {skill_approval}, request_permissions = {request_permissions}, mcp_elicitations = {mcp_elicitations} }} }}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(profile) = config.active_profile.as_deref() {
|
||||
args.push("-p".to_string());
|
||||
args.push(profile.to_string());
|
||||
}
|
||||
if let Some(model) = config.model.as_deref() {
|
||||
args.push("-m".to_string());
|
||||
args.push(model.to_string());
|
||||
}
|
||||
if let Some(sandbox_mode) = sandbox_mode_arg(&config.legacy_sandbox_policy()) {
|
||||
args.push("-s".to_string());
|
||||
args.push(sandbox_mode.to_string());
|
||||
}
|
||||
if config.web_search_mode.value() == WebSearchMode::Live {
|
||||
args.push("--search".to_string());
|
||||
}
|
||||
for root in additional_writable_roots {
|
||||
args.push("--add-dir".to_string());
|
||||
args.push(root.display().to_string());
|
||||
}
|
||||
args.push(thread_id.to_string());
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
fn sandbox_mode_arg(policy: &SandboxPolicy) -> Option<&'static str> {
|
||||
match policy {
|
||||
SandboxPolicy::DangerFullAccess => Some("danger-full-access"),
|
||||
SandboxPolicy::ReadOnly { .. } => Some("read-only"),
|
||||
SandboxPolicy::WorkspaceWrite { .. } => Some("workspace-write"),
|
||||
SandboxPolicy::ExternalSandbox { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn zellij_direction(placement: ForkPanePlacement) -> Option<&'static str> {
|
||||
match placement {
|
||||
ForkPanePlacement::Right => Some("right"),
|
||||
ForkPanePlacement::Down => Some("down"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_zellij_new_pane_args(
|
||||
command: &[String],
|
||||
thread_id: &ThreadId,
|
||||
placement: Option<ForkPanePlacement>,
|
||||
) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"action".to_string(),
|
||||
"new-pane".to_string(),
|
||||
"--close-on-exit".to_string(),
|
||||
];
|
||||
args.push("--name".to_string());
|
||||
args.push(format!("Fork of {thread_id}"));
|
||||
if let Some(placement) = placement {
|
||||
if placement == ForkPanePlacement::Float {
|
||||
args.push("--floating".to_string());
|
||||
} else if let Some(direction) = zellij_direction(placement) {
|
||||
args.push("--direction".to_string());
|
||||
args.push(direction.to_string());
|
||||
} else {
|
||||
unreachable!("invalid zellij placement");
|
||||
}
|
||||
}
|
||||
args.push("--".to_string());
|
||||
args.extend(command.iter().cloned());
|
||||
args
|
||||
}
|
||||
|
||||
fn tmux_split_flags(placement: Option<ForkPanePlacement>) -> [&'static str; 2] {
|
||||
match placement {
|
||||
None | Some(ForkPanePlacement::Right) => ["-h", ""],
|
||||
Some(ForkPanePlacement::Left) => ["-h", "-b"],
|
||||
Some(ForkPanePlacement::Down) => ["-v", ""],
|
||||
Some(ForkPanePlacement::Up) => ["-v", "-b"],
|
||||
_ => unreachable!("invalid tmux placement"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tmux_new_pane_args(
|
||||
command: &[String],
|
||||
placement: Option<ForkPanePlacement>,
|
||||
) -> Vec<String> {
|
||||
let command =
|
||||
try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" "));
|
||||
let flags = tmux_split_flags(placement);
|
||||
let mut args = vec!["split-window".to_string(), flags[0].to_string()];
|
||||
if !flags[1].is_empty() {
|
||||
args.push(flags[1].to_string());
|
||||
}
|
||||
args.push(command);
|
||||
args
|
||||
}
|
||||
|
||||
fn fork_spawn_config(
|
||||
multiplexer: &Multiplexer,
|
||||
exe: &Path,
|
||||
thread_id: &ThreadId,
|
||||
config: &Config,
|
||||
additional_writable_roots: &[PathBuf],
|
||||
placement: Option<ForkPanePlacement>,
|
||||
) -> MultiplexerSpawnConfig {
|
||||
let command = fork_command_parts(exe, thread_id, config, additional_writable_roots);
|
||||
match multiplexer {
|
||||
Multiplexer::Zellij {} => MultiplexerSpawnConfig {
|
||||
program: PathBuf::from("zellij"),
|
||||
args: build_zellij_new_pane_args(&command, thread_id, placement),
|
||||
},
|
||||
Multiplexer::Tmux { .. } => MultiplexerSpawnConfig {
|
||||
program: PathBuf::from("tmux"),
|
||||
args: build_tmux_new_pane_args(&command, placement),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const TMUX_FLOAT_UNSUPPORTED_MESSAGE: &str = "tmux does not support /fork float.";
|
||||
const ZELLIJ_UNSUPPORTED_MESSAGE: &str = "Zellij only supports /fork [float|right|down].";
|
||||
pub(crate) const FORK_PLACEMENT_REQUIRES_MULTIPLEXER_MESSAGE: &str =
|
||||
"Fork pane placement requires a terminal multiplexer.";
|
||||
|
||||
pub(crate) fn fork_command_usage(multiplexer: Option<&Multiplexer>) -> String {
|
||||
let Some(multiplexer) = multiplexer else {
|
||||
return "Usage: /fork".to_string();
|
||||
};
|
||||
let options = fork_pane_options(multiplexer);
|
||||
if options.is_empty() {
|
||||
return "Usage: /fork".to_string();
|
||||
}
|
||||
|
||||
let options = options
|
||||
.iter()
|
||||
.map(|option| option.name)
|
||||
.collect::<Vec<_>>()
|
||||
.join("|");
|
||||
format!("Usage: /fork [{options}]")
|
||||
}
|
||||
|
||||
fn validate_fork_placement_for_multiplexer(
|
||||
multiplexer: &Multiplexer,
|
||||
placement: Option<ForkPanePlacement>,
|
||||
) -> Result<(), String> {
|
||||
match multiplexer {
|
||||
Multiplexer::Zellij {} => {
|
||||
if placement.is_none_or(|placement| {
|
||||
ZELLIJ_FORK_PANE_OPTIONS
|
||||
.iter()
|
||||
.any(|option| option.placement == placement)
|
||||
}) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ZELLIJ_UNSUPPORTED_MESSAGE.to_string())
|
||||
}
|
||||
}
|
||||
Multiplexer::Tmux { .. } => {
|
||||
if placement.is_none_or(|placement| {
|
||||
TMUX_FORK_PANE_OPTIONS
|
||||
.iter()
|
||||
.any(|option| option.placement == placement)
|
||||
}) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TMUX_FLOAT_UNSUPPORTED_MESSAGE.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn spawn_fork_in_new_pane(
|
||||
multiplexer: &Multiplexer,
|
||||
thread_id: &ThreadId,
|
||||
config: &Config,
|
||||
additional_writable_roots: &[PathBuf],
|
||||
placement: Option<ForkPanePlacement>,
|
||||
) -> ForkPaneSpawnResult {
|
||||
if let Err(err) = validate_fork_placement_for_multiplexer(multiplexer, placement) {
|
||||
return ForkPaneSpawnResult::InvalidPlacement(err);
|
||||
}
|
||||
|
||||
let exe = codex_executable();
|
||||
let spawn_config = fork_spawn_config(
|
||||
multiplexer,
|
||||
&exe,
|
||||
thread_id,
|
||||
config,
|
||||
additional_writable_roots,
|
||||
placement,
|
||||
);
|
||||
let MultiplexerSpawnConfig { program, args } = spawn_config;
|
||||
let program_display = program.display().to_string();
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
Command::new(&program)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => ForkPaneSpawnResult::Spawned,
|
||||
Ok(Err(err)) => {
|
||||
ForkPaneSpawnResult::Failed(format!("failed to run {program_display}: {err}"))
|
||||
}
|
||||
Err(err) => {
|
||||
ForkPaneSpawnResult::Failed(format!("failed to spawn {program_display} pane: {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::legacy_core::config::ConfigBuilder;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn resolve_codex_executable_prefers_sibling_codex_for_codex_tui() {
|
||||
let tempdir = tempdir().expect("tempdir");
|
||||
let current_exe = tempdir.path().join("codex-tui");
|
||||
let sibling = tempdir.path().join("codex");
|
||||
std::fs::write(&sibling, b"").expect("create sibling codex");
|
||||
|
||||
assert_eq!(resolve_codex_executable(¤t_exe), sibling);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_codex_executable_keeps_non_tui_binary() {
|
||||
let current_exe = PathBuf::from("/tmp/codex");
|
||||
|
||||
assert_eq!(resolve_codex_executable(¤t_exe), current_exe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_zellij_fork_placement_rejects_left() {
|
||||
assert_eq!(
|
||||
validate_fork_placement_for_multiplexer(
|
||||
&Multiplexer::Zellij {},
|
||||
Some(ForkPanePlacement::Left),
|
||||
),
|
||||
Err(ZELLIJ_UNSUPPORTED_MESSAGE.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_tmux_fork_placement_rejects_float() {
|
||||
assert_eq!(
|
||||
validate_fork_placement_for_multiplexer(
|
||||
&Multiplexer::Tmux { version: None },
|
||||
Some(ForkPanePlacement::Float),
|
||||
),
|
||||
Err(TMUX_FLOAT_UNSUPPORTED_MESSAGE.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_command_usage_is_contextual() {
|
||||
assert_snapshot!(
|
||||
"fork_command_usage_default",
|
||||
fork_command_usage(/*multiplexer*/ None)
|
||||
);
|
||||
assert_snapshot!(
|
||||
"fork_command_usage_tmux",
|
||||
fork_command_usage(Some(&Multiplexer::Tmux { version: None }))
|
||||
);
|
||||
assert_snapshot!(
|
||||
"fork_command_usage_zellij",
|
||||
fork_command_usage(Some(&Multiplexer::Zellij {}))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fork_command_parts_include_current_session_overrides() {
|
||||
let codex_home = tempdir().expect("temp codex home");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("config");
|
||||
config.active_profile = Some("work".to_string());
|
||||
config.model = Some("gpt-5".to_string());
|
||||
config.cwd =
|
||||
AbsolutePathBuf::from_absolute_path(PathBuf::from("/repo")).expect("absolute repo cwd");
|
||||
config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::OnRequest)
|
||||
.expect("approval policy");
|
||||
config
|
||||
.set_legacy_sandbox_policy(SandboxPolicy::new_workspace_write_policy())
|
||||
.expect("sandbox policy");
|
||||
config
|
||||
.web_search_mode
|
||||
.set(WebSearchMode::Live)
|
||||
.expect("web search mode");
|
||||
|
||||
let command = fork_command_parts(
|
||||
Path::new("/bin/codex"),
|
||||
&ThreadId::new(),
|
||||
&config,
|
||||
&[PathBuf::from("/extra")],
|
||||
);
|
||||
let thread_id = command.last().expect("thread id").clone();
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
vec![
|
||||
"/bin/codex".to_string(),
|
||||
"fork".to_string(),
|
||||
"-C".to_string(),
|
||||
"/repo".to_string(),
|
||||
"-a".to_string(),
|
||||
"on-request".to_string(),
|
||||
"-p".to_string(),
|
||||
"work".to_string(),
|
||||
"-m".to_string(),
|
||||
"gpt-5".to_string(),
|
||||
"-s".to_string(),
|
||||
"workspace-write".to_string(),
|
||||
"--search".to_string(),
|
||||
"--add-dir".to_string(),
|
||||
"/extra".to_string(),
|
||||
thread_id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fork_command_parts_preserve_granular_approval_policy() {
|
||||
let codex_home = tempdir().expect("temp codex home");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("config");
|
||||
config.cwd =
|
||||
AbsolutePathBuf::from_absolute_path(PathBuf::from("/repo")).expect("absolute repo cwd");
|
||||
config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: false,
|
||||
skill_approval: true,
|
||||
request_permissions: false,
|
||||
mcp_elicitations: true,
|
||||
}))
|
||||
.expect("approval policy");
|
||||
|
||||
let command = fork_command_parts(Path::new("/bin/codex"), &ThreadId::new(), &config, &[]);
|
||||
let thread_id = command.last().expect("thread id").clone();
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
vec![
|
||||
"/bin/codex".to_string(),
|
||||
"fork".to_string(),
|
||||
"-C".to_string(),
|
||||
"/repo".to_string(),
|
||||
"-c".to_string(),
|
||||
"approval_policy={ granular = { sandbox_approval = true, rules = false, skill_approval = true, request_permissions = false, mcp_elicitations = true } }".to_string(),
|
||||
"-s".to_string(),
|
||||
"read-only".to_string(),
|
||||
thread_id,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user