mirror of
https://github.com/openai/codex.git
synced 2026-05-14 00:02:33 +00:00
Compare commits
5 Commits
cc/remove-
...
mifan-test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06a0c6b131 | ||
|
|
d807d44ae7 | ||
|
|
5e3793def2 | ||
|
|
85065ea1b8 | ||
|
|
e830000e41 |
@@ -1,6 +1,6 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::guards::AgentMetadata;
|
||||
use crate::agent::guards::Guards;
|
||||
use crate::agent::registry::AgentMetadata;
|
||||
use crate::agent::registry::AgentRegistry;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::resolve_role_config;
|
||||
use crate::agent::status::is_final;
|
||||
@@ -80,14 +80,14 @@ fn agent_nickname_candidates(
|
||||
/// spawn new agents and the inter-agent communication layer.
|
||||
/// An `AgentControl` instance is intended to be created at most once per root thread/session
|
||||
/// tree. That same `AgentControl` is then shared with every sub-agent spawned from that root,
|
||||
/// which keeps the guards scoped to that root thread rather than the entire `ThreadManager`.
|
||||
/// which keeps the registry scoped to that root thread rather than the entire `ThreadManager`.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct AgentControl {
|
||||
/// Weak handle back to the global thread registry/state.
|
||||
/// This is `Weak` to avoid reference cycles and shadow persistence of the form
|
||||
/// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`.
|
||||
manager: Weak<ThreadManagerState>,
|
||||
state: Arc<Guards>,
|
||||
state: Arc<AgentRegistry>,
|
||||
}
|
||||
|
||||
impl AgentControl {
|
||||
@@ -686,7 +686,7 @@ impl AgentControl {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn prepare_thread_spawn(
|
||||
&self,
|
||||
reservation: &mut crate::agent::guards::SpawnReservation,
|
||||
reservation: &mut crate::agent::registry::SpawnReservation,
|
||||
config: &crate::config::Config,
|
||||
parent_thread_id: ThreadId,
|
||||
depth: i32,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
pub(crate) mod agent_resolver;
|
||||
pub(crate) mod control;
|
||||
mod guards;
|
||||
mod registry;
|
||||
pub(crate) mod role;
|
||||
pub(crate) mod status;
|
||||
|
||||
pub(crate) use codex_protocol::protocol::AgentStatus;
|
||||
pub(crate) use control::AgentControl;
|
||||
pub(crate) use guards::exceeds_thread_spawn_depth_limit;
|
||||
pub(crate) use guards::next_thread_spawn_depth;
|
||||
pub(crate) use registry::exceeds_thread_spawn_depth_limit;
|
||||
pub(crate) use registry::next_thread_spawn_depth;
|
||||
pub(crate) use status::agent_status_from_event;
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::sync::atomic::Ordering;
|
||||
/// This structure is shared by all agents in the same user session (because the `AgentControl`
|
||||
/// is).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Guards {
|
||||
pub(crate) struct AgentRegistry {
|
||||
active_agents: Mutex<ActiveAgents>,
|
||||
total_count: AtomicUsize,
|
||||
}
|
||||
@@ -75,7 +75,7 @@ pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bo
|
||||
depth > max_depth
|
||||
}
|
||||
|
||||
impl Guards {
|
||||
impl AgentRegistry {
|
||||
pub(crate) fn reserve_spawn_slot(
|
||||
self: &Arc<Self>,
|
||||
max_threads: Option<usize>,
|
||||
@@ -263,7 +263,7 @@ impl Guards {
|
||||
}
|
||||
|
||||
pub(crate) struct SpawnReservation {
|
||||
state: Arc<Guards>,
|
||||
state: Arc<AgentRegistry>,
|
||||
active: bool,
|
||||
reserved_agent_nickname: Option<String>,
|
||||
reserved_agent_path: Option<AgentPath>,
|
||||
@@ -311,5 +311,5 @@ impl Drop for SpawnReservation {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "guards_tests.rs"]
|
||||
#[path = "registry_tests.rs"]
|
||||
mod tests;
|
||||
@@ -52,22 +52,22 @@ fn non_thread_spawn_subagents_default_to_depth_zero() {
|
||||
|
||||
#[test]
|
||||
fn reservation_drop_releases_slot() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
drop(reservation);
|
||||
|
||||
let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot released");
|
||||
let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot released");
|
||||
drop(reservation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_holds_slot_until_release() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
let thread_id = ThreadId::new();
|
||||
reservation.commit(agent_metadata(thread_id));
|
||||
|
||||
let err = match guards.reserve_spawn_slot(Some(1)) {
|
||||
let err = match registry.reserve_spawn_slot(Some(1)) {
|
||||
Ok(_) => panic!("limit should be enforced"),
|
||||
Err(err) => err,
|
||||
};
|
||||
@@ -76,8 +76,8 @@ fn commit_holds_slot_until_release() {
|
||||
};
|
||||
assert_eq!(max_threads, 1);
|
||||
|
||||
guards.release_spawned_thread(thread_id);
|
||||
let reservation = guards
|
||||
registry.release_spawned_thread(thread_id);
|
||||
let reservation = registry
|
||||
.reserve_spawn_slot(Some(1))
|
||||
.expect("slot released after thread removal");
|
||||
drop(reservation);
|
||||
@@ -85,14 +85,14 @@ fn commit_holds_slot_until_release() {
|
||||
|
||||
#[test]
|
||||
fn release_ignores_unknown_thread_id() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
let thread_id = ThreadId::new();
|
||||
reservation.commit(agent_metadata(thread_id));
|
||||
|
||||
guards.release_spawned_thread(ThreadId::new());
|
||||
registry.release_spawned_thread(ThreadId::new());
|
||||
|
||||
let err = match guards.reserve_spawn_slot(Some(1)) {
|
||||
let err = match registry.reserve_spawn_slot(Some(1)) {
|
||||
Ok(_) => panic!("limit should still be enforced"),
|
||||
Err(err) => err,
|
||||
};
|
||||
@@ -101,8 +101,8 @@ fn release_ignores_unknown_thread_id() {
|
||||
};
|
||||
assert_eq!(max_threads, 1);
|
||||
|
||||
guards.release_spawned_thread(thread_id);
|
||||
let reservation = guards
|
||||
registry.release_spawned_thread(thread_id);
|
||||
let reservation = registry
|
||||
.reserve_spawn_slot(Some(1))
|
||||
.expect("slot released after real thread removal");
|
||||
drop(reservation);
|
||||
@@ -110,20 +110,20 @@ fn release_ignores_unknown_thread_id() {
|
||||
|
||||
#[test]
|
||||
fn release_is_idempotent_for_registered_threads() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot");
|
||||
let first_id = ThreadId::new();
|
||||
reservation.commit(agent_metadata(first_id));
|
||||
|
||||
guards.release_spawned_thread(first_id);
|
||||
registry.release_spawned_thread(first_id);
|
||||
|
||||
let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot reused");
|
||||
let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot reused");
|
||||
let second_id = ThreadId::new();
|
||||
reservation.commit(agent_metadata(second_id));
|
||||
|
||||
guards.release_spawned_thread(first_id);
|
||||
registry.release_spawned_thread(first_id);
|
||||
|
||||
let err = match guards.reserve_spawn_slot(Some(1)) {
|
||||
let err = match registry.reserve_spawn_slot(Some(1)) {
|
||||
Ok(_) => panic!("limit should still be enforced"),
|
||||
Err(err) => err,
|
||||
};
|
||||
@@ -132,8 +132,8 @@ fn release_is_idempotent_for_registered_threads() {
|
||||
};
|
||||
assert_eq!(max_threads, 1);
|
||||
|
||||
guards.release_spawned_thread(second_id);
|
||||
let reservation = guards
|
||||
registry.release_spawned_thread(second_id);
|
||||
let reservation = registry
|
||||
.reserve_spawn_slot(Some(1))
|
||||
.expect("slot released after second thread removal");
|
||||
drop(reservation);
|
||||
@@ -141,15 +141,15 @@ fn release_is_idempotent_for_registered_threads() {
|
||||
|
||||
#[test]
|
||||
fn failed_spawn_keeps_nickname_marked_used() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot");
|
||||
let agent_nickname = reservation
|
||||
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
|
||||
.expect("reserve agent name");
|
||||
assert_eq!(agent_nickname, "alpha");
|
||||
drop(reservation);
|
||||
|
||||
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
|
||||
let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot");
|
||||
let agent_nickname = reservation
|
||||
.reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None)
|
||||
.expect("unused name should still be preferred");
|
||||
@@ -158,8 +158,10 @@ fn failed_spawn_keeps_nickname_marked_used() {
|
||||
|
||||
#[test]
|
||||
fn agent_nickname_resets_used_pool_when_exhausted() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let mut first = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve first slot");
|
||||
let first_name = first
|
||||
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
|
||||
.expect("reserve first agent name");
|
||||
@@ -167,14 +169,14 @@ fn agent_nickname_resets_used_pool_when_exhausted() {
|
||||
first.commit(agent_metadata(first_id));
|
||||
assert_eq!(first_name, "alpha");
|
||||
|
||||
let mut second = guards
|
||||
let mut second = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve second slot");
|
||||
let second_name = second
|
||||
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
|
||||
.expect("name should be reused after pool reset");
|
||||
assert_eq!(second_name, "alpha the 2nd");
|
||||
let active_agents = guards
|
||||
let active_agents = registry
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
@@ -183,9 +185,11 @@ fn agent_nickname_resets_used_pool_when_exhausted() {
|
||||
|
||||
#[test]
|
||||
fn released_nickname_stays_used_until_pool_reset() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
|
||||
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
|
||||
let mut first = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve first slot");
|
||||
let first_name = first
|
||||
.reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None)
|
||||
.expect("reserve first agent name");
|
||||
@@ -193,9 +197,9 @@ fn released_nickname_stays_used_until_pool_reset() {
|
||||
first.commit(agent_metadata(first_id));
|
||||
assert_eq!(first_name, "alpha");
|
||||
|
||||
guards.release_spawned_thread(first_id);
|
||||
registry.release_spawned_thread(first_id);
|
||||
|
||||
let mut second = guards
|
||||
let mut second = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve second slot");
|
||||
let second_name = second
|
||||
@@ -204,15 +208,17 @@ fn released_nickname_stays_used_until_pool_reset() {
|
||||
assert_eq!(second_name, "beta");
|
||||
let second_id = ThreadId::new();
|
||||
second.commit(agent_metadata(second_id));
|
||||
guards.release_spawned_thread(second_id);
|
||||
registry.release_spawned_thread(second_id);
|
||||
|
||||
let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot");
|
||||
let mut third = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve third slot");
|
||||
let third_name = third
|
||||
.reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None)
|
||||
.expect("pool reset should permit a duplicate");
|
||||
let expected_names = HashSet::from(["alpha the 2nd".to_string(), "beta the 2nd".to_string()]);
|
||||
assert!(expected_names.contains(&third_name));
|
||||
let active_agents = guards
|
||||
let active_agents = registry
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
@@ -221,18 +227,20 @@ fn released_nickname_stays_used_until_pool_reset() {
|
||||
|
||||
#[test]
|
||||
fn repeated_resets_advance_the_ordinal_suffix() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
|
||||
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
|
||||
let mut first = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve first slot");
|
||||
let first_name = first
|
||||
.reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None)
|
||||
.expect("reserve first agent name");
|
||||
let first_id = ThreadId::new();
|
||||
first.commit(agent_metadata(first_id));
|
||||
assert_eq!(first_name, "Plato");
|
||||
guards.release_spawned_thread(first_id);
|
||||
registry.release_spawned_thread(first_id);
|
||||
|
||||
let mut second = guards
|
||||
let mut second = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve second slot");
|
||||
let second_name = second
|
||||
@@ -241,14 +249,16 @@ fn repeated_resets_advance_the_ordinal_suffix() {
|
||||
let second_id = ThreadId::new();
|
||||
second.commit(agent_metadata(second_id));
|
||||
assert_eq!(second_name, "Plato the 2nd");
|
||||
guards.release_spawned_thread(second_id);
|
||||
registry.release_spawned_thread(second_id);
|
||||
|
||||
let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot");
|
||||
let mut third = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve third slot");
|
||||
let third_name = third
|
||||
.reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None)
|
||||
.expect("reserve third agent name");
|
||||
assert_eq!(third_name, "Plato the 3rd");
|
||||
let active_agents = guards
|
||||
let active_agents = registry
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
@@ -257,27 +267,29 @@ fn repeated_resets_advance_the_ordinal_suffix() {
|
||||
|
||||
#[test]
|
||||
fn register_root_thread_indexes_root_path() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let root_thread_id = ThreadId::new();
|
||||
|
||||
guards.register_root_thread(root_thread_id);
|
||||
registry.register_root_thread(root_thread_id);
|
||||
|
||||
assert_eq!(
|
||||
guards.agent_id_for_path(&AgentPath::root()),
|
||||
registry.agent_id_for_path(&AgentPath::root()),
|
||||
Some(root_thread_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_agent_path_is_released_when_spawn_fails() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let mut first = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve first slot");
|
||||
first
|
||||
.reserve_agent_path(&agent_path("/root/researcher"))
|
||||
.expect("reserve first path");
|
||||
drop(first);
|
||||
|
||||
let mut second = guards
|
||||
let mut second = registry
|
||||
.reserve_spawn_slot(None)
|
||||
.expect("reserve second slot");
|
||||
second
|
||||
@@ -287,9 +299,9 @@ fn reserved_agent_path_is_released_when_spawn_fails() {
|
||||
|
||||
#[test]
|
||||
fn committed_agent_path_is_indexed_until_release() {
|
||||
let guards = Arc::new(Guards::default());
|
||||
let registry = Arc::new(AgentRegistry::default());
|
||||
let thread_id = ThreadId::new();
|
||||
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
|
||||
let mut reservation = registry.reserve_spawn_slot(None).expect("reserve slot");
|
||||
reservation
|
||||
.reserve_agent_path(&agent_path("/root/researcher"))
|
||||
.expect("reserve path");
|
||||
@@ -300,13 +312,13 @@ fn committed_agent_path_is_indexed_until_release() {
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
guards.agent_id_for_path(&agent_path("/root/researcher")),
|
||||
registry.agent_id_for_path(&agent_path("/root/researcher")),
|
||||
Some(thread_id)
|
||||
);
|
||||
|
||||
guards.release_spawned_thread(thread_id);
|
||||
registry.release_spawned_thread(thread_id);
|
||||
assert_eq!(
|
||||
guards.agent_id_for_path(&agent_path("/root/researcher")),
|
||||
registry.agent_id_for_path(&agent_path("/root/researcher")),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -64,17 +64,31 @@ use codex_execpolicy::NetworkRuleProtocol;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_otel::TelemetryAuthMode;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ConversationAudioParams;
|
||||
use codex_protocol::protocol::RealtimeAudioFrame;
|
||||
use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use core_test_support::context_snapshot;
|
||||
use core_test_support::context_snapshot::ContextSnapshotOptions;
|
||||
use core_test_support::context_snapshot::ContextSnapshotRenderMode;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::tracing::install_test_tracing;
|
||||
use core_test_support::wait_for_event;
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
use opentelemetry::trace::TraceId;
|
||||
use std::path::Path;
|
||||
@@ -1115,6 +1129,111 @@ async fn record_initial_history_reconstructs_forked_transcript() {
|
||||
assert_eq!(expected, history.raw_items());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
)
|
||||
.await;
|
||||
let first_forked_request = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.permissions.approval_policy =
|
||||
codex_config::Constrained::allow_any(AskForApproval::OnRequest);
|
||||
});
|
||||
let initial = builder.build(&server).await?;
|
||||
let rollout_path = initial
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("rollout path");
|
||||
|
||||
initial
|
||||
.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "fork seed".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let mut fork_config = initial.config.clone();
|
||||
fork_config.permissions.approval_policy =
|
||||
codex_config::Constrained::allow_any(AskForApproval::UnlessTrusted);
|
||||
let forked = initial
|
||||
.thread_manager
|
||||
.fork_thread(usize::MAX, fork_config, rollout_path, false, None)
|
||||
.await?;
|
||||
|
||||
let collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Plan,
|
||||
settings: Settings {
|
||||
model: forked.session_configured.model.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: Some("Fork turn collaboration instructions.".to_string()),
|
||||
},
|
||||
};
|
||||
forked
|
||||
.thread
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: None,
|
||||
windows_sandbox_level: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
service_tier: None,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
personality: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
forked
|
||||
.thread
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "after fork".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let request = first_forked_request.single_request();
|
||||
let snapshot = context_snapshot::format_labeled_requests_snapshot(
|
||||
"First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.",
|
||||
&[("First Forked Turn Request", &request)],
|
||||
&ContextSnapshotOptions::default()
|
||||
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 })
|
||||
.strip_capability_instructions()
|
||||
.strip_agents_md_user_context(),
|
||||
);
|
||||
|
||||
let mut settings = insta::Settings::clone_current();
|
||||
settings.set_snapshot_path("snapshots");
|
||||
settings.set_prepend_module_to_snapshot(false);
|
||||
settings.bind(|| {
|
||||
insta::assert_snapshot!(
|
||||
"codex_core__codex_tests__fork_startup_context_then_first_turn_diff",
|
||||
snapshot
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
|
||||
@@ -5677,7 +5677,7 @@ approvals_reviewer = "guardian_subagent"
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smart_approvals_alias_is_migrated_to_guardian_approval() -> std::io::Result<()> {
|
||||
async fn smart_approvals_alias_is_ignored() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
@@ -5692,23 +5692,19 @@ smart_approvals = true
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert!(config.features.enabled(Feature::GuardianApproval));
|
||||
assert_eq!(config.features.legacy_feature_usages().count(), 0);
|
||||
assert_eq!(
|
||||
config.approvals_reviewer,
|
||||
ApprovalsReviewer::GuardianSubagent
|
||||
);
|
||||
assert!(!config.features.enabled(Feature::GuardianApproval));
|
||||
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
|
||||
|
||||
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
|
||||
assert!(serialized.contains("guardian_approval = true"));
|
||||
assert!(serialized.contains("approvals_reviewer = \"guardian_subagent\""));
|
||||
assert!(!serialized.contains("smart_approvals"));
|
||||
assert!(serialized.contains("smart_approvals = true"));
|
||||
assert!(!serialized.contains("guardian_approval"));
|
||||
assert!(!serialized.contains("approvals_reviewer"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smart_approvals_alias_is_migrated_in_profiles() -> std::io::Result<()> {
|
||||
async fn smart_approvals_alias_is_ignored_in_profiles() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
@@ -5719,106 +5715,6 @@ smart_approvals = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert!(config.features.enabled(Feature::GuardianApproval));
|
||||
assert_eq!(config.features.legacy_feature_usages().count(), 0);
|
||||
assert_eq!(
|
||||
config.approvals_reviewer,
|
||||
ApprovalsReviewer::GuardianSubagent
|
||||
);
|
||||
|
||||
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
|
||||
assert!(serialized.contains("[profiles.guardian.features]"));
|
||||
assert!(serialized.contains("guardian_approval = true"));
|
||||
assert!(serialized.contains("approvals_reviewer = \"guardian_subagent\""));
|
||||
assert!(!serialized.contains("smart_approvals"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smart_approvals_alias_migration_preserves_disabled_profile_override() -> std::io::Result<()>
|
||||
{
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
guardian_approval = true
|
||||
|
||||
[profiles.guardian.features]
|
||||
smart_approvals = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.harness_overrides(ConfigOverrides {
|
||||
config_profile: Some("guardian".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert!(!config.features.enabled(Feature::GuardianApproval));
|
||||
assert_eq!(config.features.legacy_feature_usages().count(), 0);
|
||||
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
|
||||
|
||||
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
|
||||
assert!(serialized.contains("[profiles.guardian.features]"));
|
||||
assert!(serialized.contains("guardian_approval = false"));
|
||||
assert!(!serialized.contains("smart_approvals"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smart_approvals_alias_migration_preserves_existing_approvals_reviewer()
|
||||
-> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"approvals_reviewer = "user"
|
||||
|
||||
[features]
|
||||
smart_approvals = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert!(config.features.enabled(Feature::GuardianApproval));
|
||||
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
|
||||
|
||||
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
|
||||
assert!(serialized.contains("guardian_approval = true"));
|
||||
assert!(serialized.contains("approvals_reviewer = \"user\""));
|
||||
assert!(!serialized.contains("smart_approvals"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smart_approvals_alias_migration_does_not_override_canonical_disabled_flag()
|
||||
-> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
guardian_approval = false
|
||||
smart_approvals = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
@@ -5829,9 +5725,10 @@ smart_approvals = true
|
||||
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
|
||||
|
||||
let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?;
|
||||
assert!(serialized.contains("guardian_approval = false"));
|
||||
assert!(!serialized.contains("approvals_reviewer = \"guardian_subagent\""));
|
||||
assert!(!serialized.contains("smart_approvals"));
|
||||
assert!(serialized.contains("[profiles.guardian.features]"));
|
||||
assert!(serialized.contains("smart_approvals = true"));
|
||||
assert!(!serialized.contains("guardian_approval"));
|
||||
assert!(!serialized.contains("approvals_reviewer"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -104,7 +104,6 @@ use crate::config::profile::ConfigProfile;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use toml::Value as TomlValue;
|
||||
use toml_edit::DocumentMut;
|
||||
use toml_edit::value;
|
||||
|
||||
pub(crate) mod agent_roles;
|
||||
pub mod edit;
|
||||
@@ -652,9 +651,6 @@ impl ConfigBuilder {
|
||||
fallback_cwd,
|
||||
} = self;
|
||||
let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?;
|
||||
if let Err(err) = maybe_migrate_smart_approvals_alias(&codex_home).await {
|
||||
tracing::warn!(error = %err, "failed to migrate smart_approvals feature alias");
|
||||
}
|
||||
let cli_overrides = cli_overrides.unwrap_or_default();
|
||||
let mut harness_overrides = harness_overrides.unwrap_or_default();
|
||||
let loader_overrides = loader_overrides.unwrap_or_default();
|
||||
@@ -702,111 +698,6 @@ impl ConfigBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
fn config_scope_segments(scope: &[String], key: &str) -> Vec<String> {
|
||||
let mut segments = scope.to_vec();
|
||||
segments.push(key.to_string());
|
||||
segments
|
||||
}
|
||||
|
||||
fn feature_scope_segments(scope: &[String], feature_key: &str) -> Vec<String> {
|
||||
let mut segments = scope.to_vec();
|
||||
segments.push("features".to_string());
|
||||
segments.push(feature_key.to_string());
|
||||
segments
|
||||
}
|
||||
|
||||
fn push_smart_approvals_alias_migration_edits(
|
||||
edits: &mut Vec<ConfigEdit>,
|
||||
scope: &[String],
|
||||
features: &FeaturesToml,
|
||||
approvals_reviewer_missing: bool,
|
||||
) {
|
||||
let Some(alias_enabled) = features.entries.get("smart_approvals").copied() else {
|
||||
return;
|
||||
};
|
||||
let canonical_enabled = features
|
||||
.entries
|
||||
.get("guardian_approval")
|
||||
.copied()
|
||||
.unwrap_or(alias_enabled);
|
||||
|
||||
if !features.entries.contains_key("guardian_approval") {
|
||||
edits.push(ConfigEdit::SetPath {
|
||||
segments: feature_scope_segments(scope, "guardian_approval"),
|
||||
value: value(alias_enabled),
|
||||
});
|
||||
}
|
||||
if canonical_enabled && approvals_reviewer_missing {
|
||||
edits.push(ConfigEdit::SetPath {
|
||||
segments: config_scope_segments(scope, "approvals_reviewer"),
|
||||
value: value(ApprovalsReviewer::GuardianSubagent.to_string()),
|
||||
});
|
||||
}
|
||||
edits.push(ConfigEdit::ClearPath {
|
||||
segments: feature_scope_segments(scope, "smart_approvals"),
|
||||
});
|
||||
}
|
||||
|
||||
/// Rewrites the legacy `smart_approvals` feature flag to
|
||||
/// `guardian_approval` in `config.toml` before normal config loading.
|
||||
///
|
||||
/// If the old key is present, this preserves its value by setting
|
||||
/// `guardian_approval = <alias value>` when the new key is not already present.
|
||||
/// Because the deprecated flag historically meant "turn guardian review on",
|
||||
/// this migration also backfills `approvals_reviewer = "guardian_subagent"`
|
||||
/// in the same scope when that reviewer is not already configured there and the
|
||||
/// migrated feature value is `true`.
|
||||
/// In all cases it removes the deprecated `smart_approvals` entry so future
|
||||
/// loads only see the canonical feature flag name.
|
||||
async fn maybe_migrate_smart_approvals_alias(codex_home: &Path) -> std::io::Result<bool> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
if !tokio::fs::try_exists(&config_path).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let config_contents = tokio::fs::read_to_string(&config_path).await?;
|
||||
let Ok(config_toml) = toml::from_str::<ConfigToml>(&config_contents) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let mut edits = Vec::new();
|
||||
|
||||
let root_scope = Vec::new();
|
||||
if let Some(features) = config_toml.features.as_ref() {
|
||||
push_smart_approvals_alias_migration_edits(
|
||||
&mut edits,
|
||||
&root_scope,
|
||||
features,
|
||||
config_toml.approvals_reviewer.is_none(),
|
||||
);
|
||||
}
|
||||
|
||||
for (profile_name, profile) in &config_toml.profiles {
|
||||
if let Some(features) = profile.features.as_ref() {
|
||||
let scope = vec!["profiles".to_string(), profile_name.clone()];
|
||||
push_smart_approvals_alias_migration_edits(
|
||||
&mut edits,
|
||||
&scope,
|
||||
features,
|
||||
profile.approvals_reviewer.is_none(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if edits.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
ConfigEditsBuilder::new(codex_home)
|
||||
.with_edits(edits)
|
||||
.apply()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
std::io::Error::other(format!("failed to migrate guardian_approval alias: {err}"))
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// This is the preferred way to create an instance of [Config].
|
||||
pub async fn load_with_cli_overrides(
|
||||
@@ -868,9 +759,6 @@ pub async fn load_config_as_toml_with_cli_overrides(
|
||||
cwd: &AbsolutePathBuf,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
) -> std::io::Result<ConfigToml> {
|
||||
if let Err(err) = maybe_migrate_smart_approvals_alias(codex_home).await {
|
||||
tracing::warn!(error = %err, "failed to migrate smart_approvals feature alias");
|
||||
}
|
||||
let config_layer_stack = load_config_layers_state(
|
||||
codex_home,
|
||||
Some(cwd.clone()),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: core/src/codex_tests.rs
|
||||
assertion_line: 1282
|
||||
expression: snapshot
|
||||
---
|
||||
Scenario: First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.
|
||||
|
||||
## First Forked Turn Request
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:fork seed
|
||||
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
04:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
05:message/developer[2]:
|
||||
[01] <PERMISSIONS_INSTRUCTIONS>
|
||||
[02] <collaboration_mode>Fork turn collaboration instructions.</collaboration_mode>
|
||||
06:message/user:after fork
|
||||
@@ -176,6 +176,21 @@ mod tests {
|
||||
assert_snapshot!("render_one_message", format!("{buf:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_one_message_with_shift_left_binding() {
|
||||
let mut queue = PendingInputPreview::new();
|
||||
queue.queued_messages.push("Hello, world!".to_string());
|
||||
queue.set_edit_binding(key_hint::shift(KeyCode::Left));
|
||||
let width = 40;
|
||||
let height = queue.desired_height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
||||
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
||||
assert_snapshot!(
|
||||
"render_one_message_with_shift_left_binding",
|
||||
format!("{buf:?}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_two_messages() {
|
||||
let mut queue = PendingInputPreview::new();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/pending_input_preview.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 40, height: 3 },
|
||||
content: [
|
||||
"• Queued follow-up messages ",
|
||||
" ↳ Hello, world! ",
|
||||
" shift + ← edit last queued message ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 38, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -155,6 +155,8 @@ use codex_protocol::request_permissions::RequestPermissionsEvent;
|
||||
use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_terminal_detection::Multiplexer;
|
||||
use codex_terminal_detection::TerminalInfo;
|
||||
use codex_terminal_detection::TerminalName;
|
||||
use codex_terminal_detection::terminal_info;
|
||||
use codex_utils_sleep_inhibitor::SleepInhibitor;
|
||||
@@ -195,14 +197,21 @@ const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection";
|
||||
/// Choose the keybinding used to edit the most-recently queued message.
|
||||
///
|
||||
/// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently
|
||||
/// swallow Alt+Up, so users in those environments would never be able to trigger
|
||||
/// the edit action. We fall back to Shift+Left for those terminals while
|
||||
/// keeping the more discoverable Alt+Up everywhere else.
|
||||
/// swallow Alt+Up, and tmux does not reliably pass that chord through. We fall
|
||||
/// back to Shift+Left for those environments while keeping the more discoverable
|
||||
/// Alt+Up everywhere else.
|
||||
///
|
||||
/// The match is exhaustive so that adding a new `TerminalName` variant forces
|
||||
/// an explicit decision about which binding that terminal should use.
|
||||
fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyBinding {
|
||||
match terminal_name {
|
||||
fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyBinding {
|
||||
if matches!(
|
||||
terminal_info.multiplexer.as_ref(),
|
||||
Some(Multiplexer::Tmux { .. })
|
||||
) {
|
||||
return key_hint::shift(KeyCode::Left);
|
||||
}
|
||||
|
||||
match terminal_info.name {
|
||||
TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => {
|
||||
key_hint::shift(KeyCode::Left)
|
||||
}
|
||||
@@ -3617,8 +3626,7 @@ impl ChatWidget {
|
||||
let active_cell = Some(Self::placeholder_session_header_cell(&config));
|
||||
|
||||
let current_cwd = Some(config.cwd.clone());
|
||||
let queued_message_edit_binding =
|
||||
queued_message_edit_binding_for_terminal(terminal_info().name);
|
||||
let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info());
|
||||
let mut widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
@@ -3819,8 +3827,7 @@ impl ChatWidget {
|
||||
let active_cell = Some(Self::placeholder_session_header_cell(&config));
|
||||
let current_cwd = Some(config.cwd.clone());
|
||||
|
||||
let queued_message_edit_binding =
|
||||
queued_message_edit_binding_for_terminal(terminal_info().name);
|
||||
let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info());
|
||||
let mut widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
@@ -4013,8 +4020,7 @@ impl ChatWidget {
|
||||
settings: fallback_default,
|
||||
};
|
||||
|
||||
let queued_message_edit_binding =
|
||||
queued_message_edit_binding_for_terminal(terminal_info().name);
|
||||
let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info());
|
||||
let mut widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
|
||||
@@ -120,6 +120,8 @@ use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestionOption;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_terminal_detection::Multiplexer;
|
||||
use codex_terminal_detection::TerminalInfo;
|
||||
use codex_terminal_detection::TerminalName;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_approval_presets::builtin_approval_presets;
|
||||
@@ -3756,10 +3758,10 @@ async fn alt_up_edits_most_recent_queued_message() {
|
||||
}
|
||||
|
||||
async fn assert_shift_left_edits_most_recent_queued_message_for_terminal(
|
||||
terminal_name: TerminalName,
|
||||
terminal_info: TerminalInfo,
|
||||
) {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_name);
|
||||
chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info);
|
||||
chat.bottom_pane
|
||||
.set_queued_message_edit_binding(chat.queued_message_edit_binding);
|
||||
|
||||
@@ -3791,37 +3793,102 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal(
|
||||
|
||||
#[tokio::test]
|
||||
async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() {
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::AppleTerminal)
|
||||
.await;
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo {
|
||||
name: TerminalName::AppleTerminal,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shift_left_edits_most_recent_queued_message_in_warp_terminal() {
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::WarpTerminal)
|
||||
.await;
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo {
|
||||
name: TerminalName::WarpTerminal,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shift_left_edits_most_recent_queued_message_in_vscode_terminal() {
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::VsCode).await;
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo {
|
||||
name: TerminalName::VsCode,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shift_left_edits_most_recent_queued_message_in_tmux() {
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo {
|
||||
name: TerminalName::Iterm2,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: Some(Multiplexer::Tmux { version: None }),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_message_edit_binding_mapping_covers_special_terminals() {
|
||||
fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() {
|
||||
assert_eq!(
|
||||
queued_message_edit_binding_for_terminal(TerminalName::AppleTerminal),
|
||||
queued_message_edit_binding_for_terminal(TerminalInfo {
|
||||
name: TerminalName::AppleTerminal,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
}),
|
||||
crate::key_hint::shift(KeyCode::Left)
|
||||
);
|
||||
assert_eq!(
|
||||
queued_message_edit_binding_for_terminal(TerminalName::WarpTerminal),
|
||||
queued_message_edit_binding_for_terminal(TerminalInfo {
|
||||
name: TerminalName::WarpTerminal,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
}),
|
||||
crate::key_hint::shift(KeyCode::Left)
|
||||
);
|
||||
assert_eq!(
|
||||
queued_message_edit_binding_for_terminal(TerminalName::VsCode),
|
||||
queued_message_edit_binding_for_terminal(TerminalInfo {
|
||||
name: TerminalName::VsCode,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
}),
|
||||
crate::key_hint::shift(KeyCode::Left)
|
||||
);
|
||||
assert_eq!(
|
||||
queued_message_edit_binding_for_terminal(TerminalName::Iterm2),
|
||||
queued_message_edit_binding_for_terminal(TerminalInfo {
|
||||
name: TerminalName::Iterm2,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: Some(Multiplexer::Tmux { version: None }),
|
||||
}),
|
||||
crate::key_hint::shift(KeyCode::Left)
|
||||
);
|
||||
assert_eq!(
|
||||
queued_message_edit_binding_for_terminal(TerminalInfo {
|
||||
name: TerminalName::Iterm2,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
}),
|
||||
crate::key_hint::alt(KeyCode::Up)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,6 +176,21 @@ mod tests {
|
||||
assert_snapshot!("render_one_message", format!("{buf:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_one_message_with_shift_left_binding() {
|
||||
let mut queue = PendingInputPreview::new();
|
||||
queue.queued_messages.push("Hello, world!".to_string());
|
||||
queue.set_edit_binding(key_hint::shift(KeyCode::Left));
|
||||
let width = 40;
|
||||
let height = queue.desired_height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
||||
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
||||
assert_snapshot!(
|
||||
"render_one_message_with_shift_left_binding",
|
||||
format!("{buf:?}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_two_messages() {
|
||||
let mut queue = PendingInputPreview::new();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
source: tui_app_server/src/bottom_pane/pending_input_preview.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 40, height: 3 },
|
||||
content: [
|
||||
"• Queued follow-up messages ",
|
||||
" ↳ Hello, world! ",
|
||||
" shift + ← edit last queued message ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC,
|
||||
x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 38, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -199,6 +199,8 @@ use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestionOption;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_terminal_detection::Multiplexer;
|
||||
use codex_terminal_detection::TerminalInfo;
|
||||
use codex_terminal_detection::TerminalName;
|
||||
use codex_terminal_detection::terminal_info;
|
||||
use codex_utils_sleep_inhibitor::SleepInhibitor;
|
||||
@@ -238,14 +240,21 @@ const APP_SERVER_TUI_STUB_MESSAGE: &str = "Not available in app-server TUI yet."
|
||||
/// Choose the keybinding used to edit the most-recently queued message.
|
||||
///
|
||||
/// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently
|
||||
/// swallow Alt+Up, so users in those environments would never be able to trigger
|
||||
/// the edit action. We fall back to Shift+Left for those terminals while
|
||||
/// keeping the more discoverable Alt+Up everywhere else.
|
||||
/// swallow Alt+Up, and tmux does not reliably pass that chord through. We fall
|
||||
/// back to Shift+Left for those environments while keeping the more discoverable
|
||||
/// Alt+Up everywhere else.
|
||||
///
|
||||
/// The match is exhaustive so that adding a new `TerminalName` variant forces
|
||||
/// an explicit decision about which binding that terminal should use.
|
||||
fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyBinding {
|
||||
match terminal_name {
|
||||
fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyBinding {
|
||||
if matches!(
|
||||
terminal_info.multiplexer.as_ref(),
|
||||
Some(Multiplexer::Tmux { .. })
|
||||
) {
|
||||
return key_hint::shift(KeyCode::Left);
|
||||
}
|
||||
|
||||
match terminal_info.name {
|
||||
TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => {
|
||||
key_hint::shift(KeyCode::Left)
|
||||
}
|
||||
@@ -4170,8 +4179,7 @@ impl ChatWidget {
|
||||
let active_cell = Some(Self::placeholder_session_header_cell(&config));
|
||||
|
||||
let current_cwd = Some(config.cwd.clone());
|
||||
let queued_message_edit_binding =
|
||||
queued_message_edit_binding_for_terminal(terminal_info().name);
|
||||
let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info());
|
||||
let mut widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
|
||||
@@ -143,6 +143,8 @@ use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestionOption;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_terminal_detection::Multiplexer;
|
||||
use codex_terminal_detection::TerminalInfo;
|
||||
use codex_terminal_detection::TerminalName;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_approval_presets::builtin_approval_presets;
|
||||
@@ -3765,10 +3767,10 @@ async fn alt_up_edits_most_recent_queued_message() {
|
||||
}
|
||||
|
||||
async fn assert_shift_left_edits_most_recent_queued_message_for_terminal(
|
||||
terminal_name: TerminalName,
|
||||
terminal_info: TerminalInfo,
|
||||
) {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_name);
|
||||
chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info);
|
||||
chat.bottom_pane
|
||||
.set_queued_message_edit_binding(chat.queued_message_edit_binding);
|
||||
|
||||
@@ -3800,37 +3802,102 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal(
|
||||
|
||||
#[tokio::test]
|
||||
async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() {
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::AppleTerminal)
|
||||
.await;
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo {
|
||||
name: TerminalName::AppleTerminal,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shift_left_edits_most_recent_queued_message_in_warp_terminal() {
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::WarpTerminal)
|
||||
.await;
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo {
|
||||
name: TerminalName::WarpTerminal,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shift_left_edits_most_recent_queued_message_in_vscode_terminal() {
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalName::VsCode).await;
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo {
|
||||
name: TerminalName::VsCode,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shift_left_edits_most_recent_queued_message_in_tmux() {
|
||||
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo {
|
||||
name: TerminalName::Iterm2,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: Some(Multiplexer::Tmux { version: None }),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_message_edit_binding_mapping_covers_special_terminals() {
|
||||
fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() {
|
||||
assert_eq!(
|
||||
queued_message_edit_binding_for_terminal(TerminalName::AppleTerminal),
|
||||
queued_message_edit_binding_for_terminal(TerminalInfo {
|
||||
name: TerminalName::AppleTerminal,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
}),
|
||||
crate::key_hint::shift(KeyCode::Left)
|
||||
);
|
||||
assert_eq!(
|
||||
queued_message_edit_binding_for_terminal(TerminalName::WarpTerminal),
|
||||
queued_message_edit_binding_for_terminal(TerminalInfo {
|
||||
name: TerminalName::WarpTerminal,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
}),
|
||||
crate::key_hint::shift(KeyCode::Left)
|
||||
);
|
||||
assert_eq!(
|
||||
queued_message_edit_binding_for_terminal(TerminalName::VsCode),
|
||||
queued_message_edit_binding_for_terminal(TerminalInfo {
|
||||
name: TerminalName::VsCode,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
}),
|
||||
crate::key_hint::shift(KeyCode::Left)
|
||||
);
|
||||
assert_eq!(
|
||||
queued_message_edit_binding_for_terminal(TerminalName::Iterm2),
|
||||
queued_message_edit_binding_for_terminal(TerminalInfo {
|
||||
name: TerminalName::Iterm2,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: Some(Multiplexer::Tmux { version: None }),
|
||||
}),
|
||||
crate::key_hint::shift(KeyCode::Left)
|
||||
);
|
||||
assert_eq!(
|
||||
queued_message_edit_binding_for_terminal(TerminalInfo {
|
||||
name: TerminalName::Iterm2,
|
||||
term_program: None,
|
||||
version: None,
|
||||
term: None,
|
||||
multiplexer: None,
|
||||
}),
|
||||
crate::key_hint::alt(KeyCode::Up)
|
||||
);
|
||||
}
|
||||
|
||||
0
test_push.md
Normal file
0
test_push.md
Normal file
Reference in New Issue
Block a user