Files
codex/codex-rs/tui/src/app/tests.rs
T
Eric Traut fdf23b4097 Allow reviewing and continuing misalignment-paused chats (#42410)
## Why

Misalignment policy failures pause the current chat. When the server provides findings and a continuation request, users should be able to inspect them and explicitly decide whether to proceed.

## What changed

- Add a `Review findings` flow that shows the detailed explanation and quoted continuation request in an overlay.
- Require explicit acknowledgement before starting a continuation turn, while preserving the active approval and sandbox settings.
- Keep ordinary input blocked and reject stale, replaced, invalid, or duplicate continuation attempts.
- Label the overlay shortcut as `q close` to match its behavior.

## Testing

- Add TUI tests covering findings review, explicit confirmation, successful and failed continuation, stale findings, duplicate events, and invalid continuation data.

GitOrigin-RevId: 36a738adf272f95db92eaee5712fb00061d0fa52
2026-09-03 01:51:14 +00:00

8676 lines
307 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! App-level orchestration tests for the TUI.
#[path = "tests/advanced_reasoning_tests.rs"]
mod advanced_reasoning_tests;
#[path = "tests/agents_navigation_tests.rs"]
mod agents_navigation_tests;
#[path = "tests/backend_banner_fallback_tests.rs"]
mod backend_banner_fallback_tests;
#[path = "tests/backend_banner_recovery_tests.rs"]
mod backend_banner_recovery_tests;
#[path = "tests/backend_banner_startup_tests.rs"]
mod backend_banner_startup_tests;
#[path = "tests/background_exit_tests.rs"]
mod background_exit_tests;
#[path = "tests/buffered_replay.rs"]
mod buffered_replay;
#[path = "tests/connector_policy.rs"]
mod connector_policy;
#[path = "tests/disconnect_tests.rs"]
mod disconnect;
#[path = "tests/key_chords.rs"]
mod key_chords;
#[path = "tests/luna_reserve_recovery_tests.rs"]
mod luna_reserve_recovery_tests;
#[path = "tests/mcp_startup.rs"]
mod mcp_startup;
#[path = "tests/misalignment_policy_tests.rs"]
mod misalignment_policy;
mod model_catalog;
#[path = "tests/patch_approval_tests.rs"]
mod patch_approval_tests;
#[path = "tests/permission_shortcuts_tests.rs"]
mod permission_shortcuts_tests;
mod plugin_catalog;
mod rate_limits;
#[path = "tests/recap_generation_tests.rs"]
mod recap_generation;
mod safety_buffering;
#[path = "tests/session_lifecycle_requests.rs"]
mod session_lifecycle_requests;
mod session_summary;
mod startup;
#[path = "tests/stream_animation_tests.rs"]
mod stream_animation_tests;
#[path = "tests/thread_usage.rs"]
mod thread_usage;
#[path = "tests/transcript_composer.rs"]
mod transcript_composer;
#[path = "tests/turn_submission.rs"]
mod turn_submission;
use super::*;
use crate::app_backtrack::BacktrackSelection;
use crate::app_backtrack::BacktrackState;
use crate::app_backtrack::user_count;
use crate::app_event::HistoryBatchEntryResponse;
use codex_utils_absolute_path::test_support::PathExt;
use crate::chatwidget::ChatWidgetInit;
use crate::chatwidget::create_initial_user_message;
use crate::chatwidget::tests::helpers::render_bottom_popup;
use crate::chatwidget::tests::helpers::set_active_cell;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
use crate::chatwidget::tests::set_chatgpt_auth;
use crate::chatwidget::tests::set_fast_mode_test_catalog;
use crate::file_search::FileSearchManager;
use crate::goal_files;
use crate::history_cell::AgentMarkdownCell;
use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::history_cell::new_session_info;
use crate::multi_agents::AgentPickerThreadEntry;
use crate::multi_agents::SubAgentActivityDisplay;
use assert_matches::assert_matches;
use crate::app_command::AppCommand as Op;
use crate::app_event::ConsolidationScrollbackReflow;
use crate::diff_model::FileChange;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::PermissionProfileSnapshot;
use codex_app_server_client::AppServerPath;
use codex_app_server_protocol::AdditionalFileSystemPermissions;
use codex_app_server_protocol::AdditionalNetworkPermissions;
use codex_app_server_protocol::AdditionalPermissionProfile;
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::FileUpdateChange;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_app_server_protocol::McpServerStartupState;
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext;
use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol;
use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyAmendment;
use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction;
use codex_app_server_protocol::NonSteerableTurnKind as AppServerNonSteerableTurnKind;
use codex_app_server_protocol::PatchChangeKind;
use codex_app_server_protocol::PermissionsRequestApprovalParams;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadArchivedNotification;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadSettings;
use codex_app_server_protocol::ThreadSettingsUpdatedNotification;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
use codex_app_server_protocol::TokenUsageBreakdown;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnError as AppServerTurnError;
use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_app_server_protocol::UserInput as AppServerUserInput;
use codex_app_server_protocol::WarningNotification;
use codex_history::RolloutItem;
use codex_models_manager::test_support::construct_model_info_offline_for_tests;
use codex_models_manager::test_support::get_model_offline_for_tests;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Settings;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionSource as RolloutSessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_protocol::user_input::TextElement;
use codex_utils_absolute_path::AbsolutePathBuf;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::prelude::Line;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use tempfile::tempdir;
use tokio::time;
macro_rules! assert_app_snapshot {
($name:expr, $value:expr $(,)?) => {
insta::with_settings!({snapshot_path => "../snapshots"}, {
assert_snapshot!($name, $value);
});
};
}
fn test_absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path")
}
#[tokio::test]
async fn pasted_text_normalizes_mixed_line_endings_at_app_boundary() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = start_config_write_test_app_server(&app).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Paste("line1\r\nline2\rline3\nline4".to_string()),
)
.await?;
assert_snapshot!(app.chat_widget.composer_text_with_pending(), @r"
line1
line2
line3
line4
");
Ok(())
}
#[tokio::test]
async fn chat_widget_frame_reuses_active_cell_height_across_frame_passes() {
#[derive(Debug)]
struct CountingHistoryCell {
desired_height_calls: Arc<AtomicUsize>,
}
impl HistoryCell for CountingHistoryCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
vec![Line::from("active cell")]
}
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![Line::from("active cell")]
}
fn desired_height(&self, _width: u16) -> u16 {
self.desired_height_calls.fetch_add(1, Ordering::Relaxed);
1
}
}
let mut app = make_test_app().await;
let desired_height_calls = Arc::new(AtomicUsize::new(0));
set_active_cell(
&mut app.chat_widget,
Box::new(CountingHistoryCell {
desired_height_calls: Arc::clone(&desired_height_calls),
}),
);
let width = 80;
app.with_chat_widget_frame(width, |desired_height, chat_widget| {
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, desired_height);
let mut buffer = Buffer::empty(area);
chat_widget.render(area, &mut buffer);
assert!(chat_widget.cursor_pos(area).is_some());
let _ = chat_widget.cursor_style(area);
});
assert_eq!(desired_height_calls.load(Ordering::Relaxed), 1);
}
async fn next_thread_settings_updated(
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> ThreadSettingsUpdatedNotification {
for _ in 0..20 {
let event = time::timeout(
std::time::Duration::from_secs(/*secs*/ 2),
app_server.next_event(),
)
.await
.expect("app-server should emit an event")
.expect("app-server event stream should remain open");
if let codex_app_server_client::AppServerEvent::ServerNotification(notification) = event
&& let ServerNotification::ThreadSettingsUpdated(notification) = *notification
&& notification.thread_id == thread_id.to_string()
{
return notification;
}
}
panic!("expected ThreadSettingsUpdated for thread {thread_id}");
}
#[tokio::test]
async fn handle_mcp_inventory_result_respects_origin_thread() {
let mut app = make_test_app().await;
app.transcript_cells
.push(Arc::new(history_cell::new_mcp_inventory_loading(
/*animations_enabled*/ false,
)));
app.handle_mcp_inventory_result(
Ok(vec![McpServerStatus {
name: "docs".to_string(),
runtime_status: None,
plugin_id: None,
server_info: None,
tools: HashMap::new(),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
}]),
McpServerStatusDetail::ToolsAndAuthOnly,
/*thread_id*/ None,
);
assert_eq!(app.transcript_cells.len(), 0);
app.active_thread_id = Some(ThreadId::new());
app.transcript_cells
.push(Arc::new(history_cell::new_mcp_inventory_loading(
/*animations_enabled*/ false,
)));
app.handle_mcp_inventory_result(
Ok(Vec::new()),
McpServerStatusDetail::ToolsAndAuthOnly,
Some(ThreadId::new()),
);
assert_eq!(app.transcript_cells.len(), 1);
}
#[test]
fn bypass_hook_trust_startup_warning_snapshot() {
let rendered = lines_to_single_string(
&history_cell::new_warning_event(
"`--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation."
.to_string(),
)
.display_lines(/*width*/ 80),
);
assert_app_snapshot!("bypass_hook_trust_startup_warning", rendered);
}
#[tokio::test]
async fn cyber_model_auto_review_notice_snapshot() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
app.handle_event(
&mut tui,
&mut app_server,
AppEvent::CyberModelAutoReviewNotice,
)
.await?;
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
assert_app_snapshot!("cyber_model_auto_review_notice", rendered);
Ok(())
}
#[tokio::test]
#[serial_test::serial]
async fn external_editor_writable_directory_rejected_snapshot() -> Result<()> {
struct RestoreVisual(Option<std::ffi::OsString>);
impl Drop for RestoreVisual {
fn drop(&mut self) {
match self.0.take() {
Some(value) => unsafe { std::env::set_var("VISUAL", value) },
None => unsafe { std::env::remove_var("VISUAL") },
}
}
}
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let codex_home = app.chat_widget.config_ref().codex_home.clone();
let fallback_home = dirs::home_dir()
.expect("home directory")
.join(".codex")
.abs();
let workspace_codex_home = app.chat_widget.config_ref().cwd.join(".codex");
let permission_profile = PermissionProfile::workspace_write_with(
&[codex_home, fallback_home, workspace_codex_home],
codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
/*exclude_tmpdir_env_var*/ true,
/*exclude_slash_tmp*/ true,
);
app.chat_widget
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy(
permission_profile,
))?;
let mut tui = crate::tui::test_support::make_test_tui()?;
let restore_visual = RestoreVisual(std::env::var_os("VISUAL"));
unsafe { std::env::set_var("VISUAL", "editor") };
app.launch_external_editor(&mut tui).await;
drop(restore_visual);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
assert_app_snapshot!("external_editor_writable_directory_rejected", rendered);
Ok(())
}
#[tokio::test]
async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let approval_request =
exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None);
assert_eq!(
app.pending_app_server_requests
.note_server_request(&approval_request),
None
);
app.agents_overview
.dispatched_requests
.insert(thread_id, vec![approval_request.clone()]);
app.enqueue_primary_thread_request(approval_request.clone())
.await?;
let (mut app_server, requests, proxy) = session_lifecycle_requests::start_recording_app_server(
&app.config,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
)
.await?;
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
ServerNotification::ServerRequestResolved(
codex_app_server_protocol::ServerRequestResolvedNotification {
thread_id: ThreadId::new().to_string(),
request_id: AppServerRequestId::Integer(1),
},
),
)),
)
.await;
assert_eq!(app.agents_overview.dispatched_requests[&thread_id].len(), 1);
app.enqueue_primary_thread_session(
test_thread_session(thread_id, test_path_buf("/tmp/project")),
Vec::new(),
)
.await?;
let rx = app
.active_thread_rx
.as_mut()
.expect("primary thread receiver should be active");
let event = time::timeout(Duration::from_millis(50), rx.recv())
.await
.expect("timed out waiting for buffered approval event")
.expect("channel closed unexpectedly");
assert!(matches!(
&event,
ThreadBufferedEvent::Request(request)
if matches!(
request.as_ref(),
ServerRequest::CommandExecutionRequestApproval { params, .. }
if params.turn_id == "turn-1"
)
));
app.handle_thread_event_now(event);
let background_thread_id = ThreadId::new();
let mut background_request = approval_request;
let ServerRequest::CommandExecutionRequestApproval { request_id, params } =
&mut background_request
else {
unreachable!("expected a command approval request");
};
*request_id = AppServerRequestId::Integer(2);
params.thread_id = background_thread_id.to_string();
app.pending_app_server_requests
.note_server_request(&background_request);
app.enqueue_thread_request(background_thread_id, background_request)
.await?;
for expected_thread_id in [thread_id, background_thread_id] {
assert!(app.chat_widget.has_active_view());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let (actual_thread_id, op) = std::iter::from_fn(|| app_event_rx.try_recv().ok())
.find_map(|event| match event {
AppEvent::SubmitThreadOp { thread_id, op } => Some((thread_id, op)),
_ => None,
})
.expect("approval should submit a thread-scoped operation");
assert_eq!(actual_thread_id, expected_thread_id);
app.submit_thread_op(&mut app_server, expected_thread_id, op)
.await?;
}
assert!(!app.chat_widget.has_active_view());
app_server.shutdown().await?;
proxy.await??;
assert_eq!(
requests
.lock()
.expect("request recorder lock")
.iter()
.filter(|request| request.method == "server/request/response")
.map(|request| request.id.clone())
.collect::<Vec<_>>(),
(1..=2).map(AppServerRequestId::Integer).collect::<Vec<_>>()
);
Ok(())
}
#[tokio::test]
async fn resolved_buffered_approval_does_not_become_actionable_after_drain() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let approval_request =
exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None);
app.enqueue_primary_thread_session(
test_thread_session(thread_id, test_path_buf("/tmp/project")),
Vec::new(),
)
.await?;
while app_event_rx.try_recv().is_ok() {}
assert_eq!(
app.pending_app_server_requests
.note_server_request(&approval_request),
None
);
app.enqueue_thread_request(thread_id, approval_request)
.await?;
let resolved = app
.pending_app_server_requests
.resolve_notification(&thread_id.to_string(), &AppServerRequestId::Integer(1))
.expect("matching app-server request should resolve");
app.chat_widget.dismiss_app_server_request(&resolved);
while app_event_rx.try_recv().is_ok() {}
let rx = app
.active_thread_rx
.as_mut()
.expect("primary thread receiver should be active");
let event = time::timeout(Duration::from_millis(50), rx.recv())
.await
.expect("timed out waiting for buffered approval event")
.expect("channel closed unexpectedly");
assert!(matches!(
&event,
ThreadBufferedEvent::Request(request)
if matches!(
request.as_ref(),
ServerRequest::CommandExecutionRequestApproval { params, .. }
if params.turn_id == "turn-1"
)
));
app.handle_thread_event_now(event);
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
while let Ok(app_event) = app_event_rx.try_recv() {
assert!(
!matches!(app_event, AppEvent::SubmitThreadOp { .. }),
"resolved buffered approval should not become actionable"
);
}
Ok(())
}
#[tokio::test]
async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_submit() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let initial_prompt = "follow-up after replay".to_string();
let config = app.config.clone();
let model = get_model_offline_for_tests(config.model.as_deref());
app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit {
requires_openai_auth: true,
local_settings: crate::local_settings::LocalSettings::from(&config),
config,
frame_requester: crate::tui::FrameRequester::test_dummy(),
app_event_tx: app.app_event_tx.clone(),
workspace_command_runner: None,
initial_user_message: create_initial_user_message(
Some(initial_prompt.clone()),
Vec::new(),
Vec::new(),
),
enhanced_keys_supported: false,
has_chatgpt_account: false,
has_codex_backend_auth: false,
model_catalog: app.model_catalog.clone(),
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: false,
status_account_display: None,
runtime_model_provider_base_url: None,
initial_plan_type: None,
model: Some(model),
startup_tooltip_override: None,
status_line_invalid_items_warned: app.status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: app.terminal_title_invalid_items_warned.clone(),
session_telemetry: app.session_telemetry.clone(),
});
app.enqueue_primary_thread_session(
test_thread_session(thread_id, test_path_buf("/tmp/project")),
vec![test_turn(
"turn-1",
TurnStatus::Completed,
vec![ThreadItem::UserMessage {
id: "user-1".to_string(),
client_id: None,
content: vec![AppServerUserInput::Text {
text: "earlier prompt".to_string(),
text_elements: Vec::new(),
}],
}],
)],
)
.await?;
let mut saw_replayed_answer = false;
let mut saw_optimistic_prompt = false;
let mut submitted_items = None;
while let Ok(event) = app_event_rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(cell) => {
let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80));
saw_replayed_answer |= transcript.contains("earlier prompt");
saw_optimistic_prompt |= transcript.contains(&initial_prompt);
}
AppEvent::SubmitThreadOp {
thread_id: op_thread_id,
op: Op::UserTurn { items, .. },
} => {
assert_eq!(op_thread_id, thread_id);
submitted_items = Some(items);
}
AppEvent::CodexOp(Op::UserTurn { items, .. }) => {
assert!(
saw_optimistic_prompt,
"expected optimistic prompt before turn submission"
);
submitted_items = Some(items);
}
_ => {}
}
}
assert!(
saw_replayed_answer,
"expected replayed history before initial prompt submit"
);
assert_eq!(
submitted_items,
Some(vec![UserInput::Text {
text: initial_prompt,
text_elements: Vec::new(),
}])
);
Ok(())
}
#[tokio::test]
async fn reset_thread_event_state_aborts_listener_tasks() {
struct NotifyOnDrop(Option<tokio::sync::oneshot::Sender<()>>);
impl Drop for NotifyOnDrop {
fn drop(&mut self) {
if let Some(tx) = self.0.take() {
let _ = tx.send(());
}
}
}
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
let handle = tokio::spawn(async move {
let _notify_on_drop = NotifyOnDrop(Some(dropped_tx));
let _ = started_tx.send(());
std::future::pending::<()>().await;
});
app.thread_event_listener_tasks.insert(thread_id, handle);
started_rx
.await
.expect("listener task should report it started");
app.reset_thread_event_state();
assert_eq!(app.thread_event_listener_tasks.is_empty(), true);
time::timeout(Duration::from_millis(50), dropped_rx)
.await
.expect("timed out waiting for listener task abort")
.expect("listener task drop notification should succeed");
}
#[tokio::test]
async fn history_lookup_response_is_routed_to_requesting_thread() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.lookup_message_history_entry(thread_id, /*offset*/ 0, /*log_id*/ 1)
.await?;
let app_event = tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv())
.await
.expect("history lookup should emit an app event")
.expect("app event channel should stay open");
let AppEvent::ThreadHistoryEntryResponse {
thread_id: routed_thread_id,
event,
} = app_event
else {
panic!("expected thread-routed history response");
};
assert_eq!(routed_thread_id, thread_id);
assert_eq!(
event,
HistoryLookupResponse::Entry {
offset: 0,
log_id: 1,
entry: None,
}
);
let cursor = codex_message_history::HistoryBatchCursor::new(/*end_offset*/ 10);
app.lookup_message_history_batch(thread_id, cursor, /*log_id*/ 1)
.await?;
let app_event = tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv())
.await
.expect("history batch lookup should emit an app event")
.expect("app event channel should stay open");
let AppEvent::ThreadHistoryEntryResponse {
thread_id: routed_thread_id,
event,
} = app_event
else {
panic!("expected thread-routed history batch response");
};
assert_eq!(routed_thread_id, thread_id);
assert_eq!(
event,
HistoryLookupResponse::BatchError { cursor, log_id: 1 }
);
Ok(())
}
#[tokio::test]
async fn enqueue_thread_event_does_not_block_when_channel_full() -> Result<()> {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.set_thread_active(thread_id, /*active*/ true).await;
let event = thread_closed_notification(thread_id);
app.enqueue_thread_notification(thread_id, event.clone())
.await?;
time::timeout(
Duration::from_millis(50),
app.enqueue_thread_notification(thread_id, event),
)
.await
.expect("enqueue_thread_notification blocked on a full channel")?;
let mut rx = app
.thread_event_channels
.get_mut(&thread_id)
.expect("missing thread channel")
.receiver
.take()
.expect("missing receiver");
time::timeout(Duration::from_millis(50), rx.recv())
.await
.expect("timed out waiting for first event")
.expect("channel closed unexpectedly");
time::timeout(Duration::from_millis(50), rx.recv())
.await
.expect("timed out waiting for second event")
.expect("channel closed unexpectedly");
Ok(())
}
#[tokio::test]
async fn active_thread_drain_yields_after_frame_deadline_without_dropping_events() -> Result<()> {
let mut app = make_test_app().await;
app.startup_protected_input_boundary = true;
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 2));
app.activate_thread_channel(thread_id).await;
let first_event = token_usage_notification(thread_id, "turn-1", Some(100));
let mut second_event = token_usage_notification(thread_id, "turn-1", Some(100));
if let ServerNotification::ThreadTokenUsageUpdated(notification) = &mut second_event {
notification.token_usage.total.output_tokens = 10;
notification.token_usage.total.total_tokens = 15;
}
for event in [first_event, second_event] {
app.enqueue_thread_notification(thread_id, event).await?;
}
let mut tui = crate::tui::test_support::make_test_tui()?;
app.drain_active_thread_events_until(&mut tui, Instant::now())
.await?;
assert_eq!(
app.chat_widget.token_usage(),
crate::token_usage::TokenUsage {
input_tokens: 4,
cached_input_tokens: 1,
output_tokens: 5,
reasoning_output_tokens: 0,
total_tokens: 10,
}
);
assert!(
app.active_thread_rx
.as_ref()
.is_some_and(|receiver| !receiver.is_empty()),
"an expired frame deadline should preserve queued notifications"
);
assert!(
!app.has_queued_startup_protected_request(),
"ordinary notifications left by the frame deadline must not block terminal input"
);
app.drain_active_thread_events(&mut tui).await?;
assert!(
app.active_thread_rx
.as_ref()
.is_some_and(tokio::sync::mpsc::Receiver::is_empty),
"the next foreground frame should deliver the preserved notification"
);
assert_eq!(
app.chat_widget.token_usage(),
crate::token_usage::TokenUsage {
input_tokens: 4,
cached_input_tokens: 1,
output_tokens: 10,
reasoning_output_tokens: 0,
total_tokens: 15,
}
);
Ok(())
}
#[tokio::test]
async fn selected_side_thread_close_is_handled_by_foreground_event_owner() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let primary = app_server.start_thread(&app.config).await?;
let primary_thread_id = primary.session.thread_id;
app.enqueue_primary_thread_session(primary.session, primary.turns)
.await?;
let side_thread_id = ThreadId::new();
let side_channel = ThreadEventChannel::new(/*capacity*/ 1);
side_channel.store.lock().await.set_session(
test_thread_session(side_thread_id, app.config.cwd.to_path_buf()),
Vec::new(),
);
side_channel
.sender
.try_send(ThreadBufferedEvent::Notification(Box::new(
thread_closed_notification(side_thread_id),
)))
.expect("closed side-thread notification should fit in its saved receiver");
app.thread_event_channels
.insert(side_thread_id, side_channel);
app.side_threads
.insert(side_thread_id, SideThreadState::new(primary_thread_id));
let mut tui = crate::tui::test_support::make_test_tui()?;
app.select_agent_thread(&mut tui, &mut app_server, side_thread_id)
.await?;
assert_eq!(app.active_thread_id, Some(side_thread_id));
let event = app
.active_thread_rx
.as_mut()
.expect("selected side thread should retain its receiver")
.try_recv()
.expect("thread closure should remain queued for the foreground loop");
app.handle_active_thread_event(&mut tui, &mut app_server, event)
.await?;
assert_eq!(app.active_thread_id, Some(primary_thread_id));
assert!(!app.side_threads.contains_key(&side_thread_id));
assert!(!app.thread_event_channels.contains_key(&side_thread_id));
assert!(app.active_thread_rx.is_some());
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn active_history_batch_is_delivered_without_replay_buffering() -> Result<()> {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 4));
app.set_thread_active(thread_id, /*active*/ true).await;
let cursor = codex_message_history::HistoryBatchCursor::new(/*end_offset*/ 1);
let event = HistoryLookupResponse::Batch {
cursor,
log_id: 1,
entries: vec![HistoryBatchEntryResponse {
offset: 1,
entry: Some("history entry".to_string()),
}],
next_older_cursor: Some(codex_message_history::HistoryBatchCursor::new(
/*end_offset*/ 0,
)),
};
app.enqueue_thread_history_entry_response(thread_id, event.clone())
.await?;
let channel = app
.thread_event_channels
.get_mut(&thread_id)
.expect("missing thread channel");
assert!(channel.store.lock().await.buffer.is_empty());
let mut receiver = channel.receiver.take().expect("missing receiver");
let delivered = time::timeout(Duration::from_millis(50), receiver.recv())
.await
.expect("timed out waiting for history batch")
.expect("missing history batch");
let ThreadBufferedEvent::HistoryEntryResponse(delivered) = delivered else {
panic!("expected history batch response");
};
assert_eq!(delivered, event);
Ok(())
}
#[tokio::test]
async fn replay_thread_snapshot_renders_only_retained_agent_message_deltas() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.thread_event_channels.insert(
thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
session.clone(),
Vec::new(),
),
);
app.activate_thread_channel(thread_id).await;
app.chat_widget.handle_thread_session(session);
{
let channel = app
.thread_event_channels
.get(&thread_id)
.expect("thread channel should exist");
let mut store = channel.store.lock().await;
for chunk in 0..65 {
let marker = format!("chunk {chunk:02}");
let text = format!("{marker}\n{}\n", "x".repeat(4096 - marker.len() - 2));
store.push_notification(agent_message_delta_notification(
thread_id,
"turn-budget",
"item-budget",
&text,
));
}
}
app.store_active_thread_receiver().await;
let (_receiver, snapshot) = app
.activate_thread_for_replay(thread_id)
.await
.expect("detached thread should reactivate for replay");
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);
let mut rendered = Vec::new();
loop {
app.chat_widget.on_commit_tick();
let mut inserted = false;
while let Ok(event) = app_event_rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
rendered.push(lines_to_single_string(&cell.display_lines(/*width*/ 80)));
inserted = true;
}
}
if !inserted {
break;
}
}
if let Some(lines) = app.chat_widget.active_cell_transcript_lines(/*width*/ 80) {
rendered.push(lines_to_single_string(&lines));
}
let markers = rendered
.iter()
.flat_map(|text| text.lines())
.filter_map(|line| line.split_once("chunk "))
.map(|(_, marker)| format!("chunk {}", marker.trim()))
.collect::<Vec<_>>();
assert_eq!(markers.len(), 64);
assert_snapshot!(
format!("{}\n{}", markers.first().expect("first marker"), markers.last().expect("last marker")),
@r"
chunk 01
chunk 64
"
);
}
#[tokio::test]
async fn replay_thread_snapshot_restores_draft_and_queued_input() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.thread_event_channels.insert(
thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
session.clone(),
Vec::new(),
),
);
app.activate_thread_channel(thread_id).await;
app.chat_widget.handle_thread_session(session.clone());
app.chat_widget
.apply_external_edit("draft prompt".to_string());
app.chat_widget.submit_user_message_with_mode(
"queued follow-up".to_string(),
CollaborationModeMask {
name: "Default".to_string(),
mode: None,
model: None,
reasoning_effort: None,
developer_instructions: None,
},
);
let expected_input_state = app
.chat_widget
.capture_thread_input_state()
.expect("expected thread input state");
app.store_active_thread_receiver().await;
let snapshot = {
let channel = app
.thread_event_channels
.get(&thread_id)
.expect("thread channel should exist");
let store = channel.store.lock().await;
assert_eq!(store.input_state, Some(expected_input_state));
store.snapshot()
};
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
make_chatwidget_manual_with_sender().await;
app.chat_widget = chat_widget;
let outgoing_thread_id = ThreadId::new();
app.chat_widget.handle_thread_session(test_thread_session(
outgoing_thread_id,
test_path_buf("/tmp/outgoing-project"),
));
app.chat_widget.handle_server_notification(
turn_started_notification(outgoing_thread_id, "outgoing-turn"),
/*replay_kind*/ None,
);
app.chat_widget
.apply_external_edit("outgoing queued input".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
app.chat_widget
.set_queue_autosend_suppressed(/*suppressed*/ true);
app.chat_widget.handle_server_notification(
turn_completed_notification(outgoing_thread_id, "outgoing-turn", TurnStatus::Completed),
/*replay_kind*/ None,
);
app.chat_widget
.set_queue_autosend_suppressed(/*suppressed*/ false);
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ true);
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt");
assert!(app.chat_widget.queued_user_message_texts().is_empty());
while let Ok(op) = new_op_rx.try_recv() {
assert!(
!matches!(op, Op::UserTurn { .. }),
"draft-only replay should not auto-submit queued input"
);
}
}
#[tokio::test]
async fn replay_thread_snapshot_restores_the_matching_safety_buffer_prompt() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.thread_event_channels.insert(
thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
session.clone(),
Vec::new(),
),
);
app.activate_thread_channel(thread_id).await;
app.chat_widget.handle_thread_session(session);
let default_mode = CollaborationModeMask {
name: "Default".to_string(),
mode: None,
model: None,
reasoning_effort: None,
developer_instructions: None,
};
app.chat_widget
.submit_user_message_with_mode("buffered prompt A".to_string(), default_mode.clone());
let expected_input_state = app
.chat_widget
.capture_thread_input_state()
.expect("expected thread input state");
app.store_active_thread_receiver().await;
let snapshot = {
let channel = app
.thread_event_channels
.get(&thread_id)
.expect("thread channel should exist");
let store = channel.store.lock().await;
assert_eq!(store.input_state, Some(expected_input_state.clone()));
store.snapshot()
};
let (mut chat_widget, _app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
chat_widget.handle_thread_session(test_thread_session(
ThreadId::new(),
test_path_buf("/tmp/other-project"),
));
chat_widget.submit_user_message_with_mode("buffered prompt B".to_string(), default_mode);
app.chat_widget = chat_widget;
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);
assert_eq!(
app.chat_widget.capture_thread_input_state(),
Some(expected_input_state)
);
}
#[tokio::test]
async fn active_turn_id_for_thread_uses_snapshot_turns() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.thread_event_channels.insert(
thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
session,
vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())],
),
);
assert_eq!(
app.active_turn_id_for_thread(thread_id).await,
Some("turn-1".to_string())
);
}
#[tokio::test]
async fn replayed_turn_complete_submits_restored_queued_follow_up() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session.clone());
app.chat_widget.handle_server_notification(
turn_started_notification(thread_id, "turn-1"),
/*replay_kind*/ None,
);
app.chat_widget.handle_server_notification(
agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"),
/*replay_kind*/ None,
);
app.chat_widget
.apply_external_edit("queued follow-up".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let input_state = app
.chat_widget
.capture_thread_input_state()
.expect("expected queued follow-up state");
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
make_chatwidget_manual_with_sender().await;
app.chat_widget = chat_widget;
app.chat_widget.handle_thread_session(session.clone());
while new_op_rx.try_recv().is_ok() {}
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: None,
turns: Vec::new(),
events: vec![ThreadBufferedEvent::Notification(Box::new(
turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed),
))],
input_state: Some(input_state),
},
/*resume_restored_queue*/ true,
);
match next_user_turn_op(&mut new_op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "queued follow-up".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued follow-up submission, got {other:?}"),
}
}
#[tokio::test]
async fn replay_only_thread_keeps_restored_queue_visible() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session.clone());
app.chat_widget.handle_server_notification(
turn_started_notification(thread_id, "turn-1"),
/*replay_kind*/ None,
);
app.chat_widget.handle_server_notification(
agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"),
/*replay_kind*/ None,
);
app.chat_widget
.apply_external_edit("queued follow-up".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let input_state = app
.chat_widget
.capture_thread_input_state()
.expect("expected queued follow-up state");
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
make_chatwidget_manual_with_sender().await;
app.chat_widget = chat_widget;
app.chat_widget.handle_thread_session(session.clone());
while new_op_rx.try_recv().is_ok() {}
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: None,
turns: Vec::new(),
events: vec![ThreadBufferedEvent::Notification(Box::new(
turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed),
))],
input_state: Some(input_state),
},
/*resume_restored_queue*/ false,
);
assert_eq!(
app.chat_widget.queued_user_message_texts(),
vec!["queued follow-up".to_string()]
);
assert!(
new_op_rx.try_recv().is_err(),
"replay-only threads should not auto-submit restored queue"
);
}
#[tokio::test]
async fn replay_thread_snapshot_keeps_queue_when_running_state_only_comes_from_snapshot() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session.clone());
app.chat_widget.handle_server_notification(
turn_started_notification(thread_id, "turn-1"),
/*replay_kind*/ None,
);
app.chat_widget.handle_server_notification(
agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"),
/*replay_kind*/ None,
);
app.chat_widget
.apply_external_edit("queued follow-up".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let input_state = app
.chat_widget
.capture_thread_input_state()
.expect("expected queued follow-up state");
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
make_chatwidget_manual_with_sender().await;
app.chat_widget = chat_widget;
app.chat_widget.handle_thread_session(session.clone());
while new_op_rx.try_recv().is_ok() {}
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: None,
turns: Vec::new(),
events: vec![],
input_state: Some(input_state),
},
/*resume_restored_queue*/ true,
);
assert_eq!(
app.chat_widget.queued_user_message_texts(),
vec!["queued follow-up".to_string()]
);
assert!(
new_op_rx.try_recv().is_err(),
"restored queue should stay queued when replay did not prove the turn finished"
);
}
#[tokio::test]
async fn replay_thread_snapshot_in_progress_turn_restores_running_queue_state() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session.clone());
app.chat_widget.handle_server_notification(
turn_started_notification(thread_id, "turn-1"),
/*replay_kind*/ None,
);
app.chat_widget.handle_server_notification(
agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"),
/*replay_kind*/ None,
);
app.chat_widget
.apply_external_edit("queued follow-up".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let input_state = app
.chat_widget
.capture_thread_input_state()
.expect("expected queued follow-up state");
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
make_chatwidget_manual_with_sender().await;
app.chat_widget = chat_widget;
app.chat_widget.handle_thread_session(session.clone());
while new_op_rx.try_recv().is_ok() {}
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: None,
turns: vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())],
events: Vec::new(),
input_state: Some(input_state),
},
/*resume_restored_queue*/ true,
);
assert_eq!(
app.chat_widget.queued_user_message_texts(),
vec!["queued follow-up".to_string()]
);
assert!(
new_op_rx.try_recv().is_err(),
"restored queue should stay queued while replayed turn is still running"
);
}
#[tokio::test]
async fn replay_thread_snapshot_in_progress_turn_restores_running_state_without_input_state() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
let (chat_widget, _app_event_tx, _rx, _new_op_rx) = make_chatwidget_manual_with_sender().await;
app.chat_widget = chat_widget;
app.chat_widget.handle_thread_session(session);
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: None,
turns: vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())],
events: Vec::new(),
input_state: None,
},
/*resume_restored_queue*/ false,
);
assert!(app.chat_widget.is_task_running_for_test());
}
#[tokio::test]
async fn replay_thread_snapshot_does_not_submit_queue_before_replay_catches_up() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session.clone());
app.chat_widget.handle_server_notification(
turn_started_notification(thread_id, "turn-1"),
/*replay_kind*/ None,
);
app.chat_widget.handle_server_notification(
agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"),
/*replay_kind*/ None,
);
app.chat_widget
.apply_external_edit("queued follow-up".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
let input_state = app
.chat_widget
.capture_thread_input_state()
.expect("expected queued follow-up state");
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
make_chatwidget_manual_with_sender().await;
app.chat_widget = chat_widget;
app.chat_widget.handle_thread_session(session.clone());
while new_op_rx.try_recv().is_ok() {}
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: None,
turns: Vec::new(),
events: vec![
ThreadBufferedEvent::Notification(Box::new(turn_completed_notification(
thread_id,
"turn-0",
TurnStatus::Completed,
))),
ThreadBufferedEvent::Notification(Box::new(turn_started_notification(
thread_id, "turn-1",
))),
],
input_state: Some(input_state),
},
/*resume_restored_queue*/ true,
);
assert!(
new_op_rx.try_recv().is_err(),
"queued follow-up should stay queued until the latest turn completes"
);
assert_eq!(
app.chat_widget.queued_user_message_texts(),
vec!["queued follow-up".to_string()]
);
app.chat_widget.handle_server_notification(
turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed),
/*replay_kind*/ None,
);
match next_user_turn_op(&mut new_op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "queued follow-up".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued follow-up submission, got {other:?}"),
}
}
#[tokio::test]
async fn replay_thread_snapshot_restores_pending_pastes_for_submit() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.thread_event_channels.insert(
thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
session.clone(),
Vec::new(),
),
);
app.activate_thread_channel(thread_id).await;
app.chat_widget.handle_thread_session(session);
let large = "x".repeat(1005);
app.chat_widget.handle_paste(large.clone());
let expected_input_state = app
.chat_widget
.capture_thread_input_state()
.expect("expected thread input state");
app.store_active_thread_receiver().await;
let snapshot = {
let channel = app
.thread_event_channels
.get(&thread_id)
.expect("thread channel should exist");
let store = channel.store.lock().await;
assert_eq!(store.input_state, Some(expected_input_state));
store.snapshot()
};
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
make_chatwidget_manual_with_sender().await;
app.chat_widget = chat_widget;
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ true);
assert_eq!(app.chat_widget.composer_text_with_pending(), large);
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match next_user_turn_op(&mut new_op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: large,
text_elements: Vec::new(),
}]
),
other => panic!("expected restored paste submission, got {other:?}"),
}
}
#[tokio::test]
async fn replay_thread_snapshot_restores_collaboration_mode_for_draft_submit() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session.clone());
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
app.chat_widget
.set_collaboration_mask(CollaborationModeMask {
name: "Plan".to_string(),
mode: Some(ModeKind::Plan),
model: Some("gpt-restored".to_string()),
reasoning_effort: Some(Some(ReasoningEffortConfig::High)),
developer_instructions: None,
});
app.chat_widget
.apply_external_edit("draft prompt".to_string());
let input_state = app
.chat_widget
.capture_thread_input_state()
.expect("expected draft input state");
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
make_chatwidget_manual_with_sender().await;
app.chat_widget = chat_widget;
app.chat_widget.handle_thread_session(session.clone());
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::Low));
app.chat_widget
.set_collaboration_mask(CollaborationModeMask {
name: "Default".to_string(),
mode: Some(ModeKind::Default),
model: Some("gpt-replacement".to_string()),
reasoning_effort: Some(Some(ReasoningEffortConfig::Low)),
developer_instructions: None,
});
while new_op_rx.try_recv().is_ok() {}
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: None,
turns: Vec::new(),
events: vec![],
input_state: Some(input_state),
},
/*resume_restored_queue*/ true,
);
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match next_user_turn_op(&mut new_op_rx) {
Op::UserTurn {
items,
model,
effort,
collaboration_mode,
..
} => {
assert_eq!(
items,
vec![UserInput::Text {
text: "draft prompt".to_string(),
text_elements: Vec::new(),
}]
);
assert_eq!(model, "gpt-restored".to_string());
assert_eq!(effort, Some(ReasoningEffortConfig::High));
assert_eq!(
collaboration_mode,
Some(CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: "gpt-restored".to_string(),
reasoning_effort: Some(ReasoningEffortConfig::High),
developer_instructions: None,
},
})
);
}
other => panic!("expected restored draft submission, got {other:?}"),
}
}
#[tokio::test]
async fn replay_thread_snapshot_restores_collaboration_mode_without_input() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session.clone());
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
app.chat_widget
.set_collaboration_mask(CollaborationModeMask {
name: "Plan".to_string(),
mode: Some(ModeKind::Plan),
model: Some("gpt-restored".to_string()),
reasoning_effort: Some(Some(ReasoningEffortConfig::High)),
developer_instructions: None,
});
let input_state = app
.chat_widget
.capture_thread_input_state()
.expect("expected collaboration-only input state");
let (chat_widget, _app_event_tx, _rx, _new_op_rx) = make_chatwidget_manual_with_sender().await;
app.chat_widget = chat_widget;
app.chat_widget.handle_thread_session(session.clone());
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::Low));
app.chat_widget
.set_collaboration_mask(CollaborationModeMask {
name: "Default".to_string(),
mode: Some(ModeKind::Default),
model: Some("gpt-replacement".to_string()),
reasoning_effort: Some(Some(ReasoningEffortConfig::Low)),
developer_instructions: None,
});
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: None,
turns: Vec::new(),
events: vec![],
input_state: Some(input_state),
},
/*resume_restored_queue*/ true,
);
assert_eq!(
app.chat_widget.active_collaboration_mode_kind(),
ModeKind::Plan
);
assert_eq!(app.chat_widget.current_model(), "gpt-restored");
assert_eq!(
app.chat_widget.current_reasoning_effort(),
Some(ReasoningEffortConfig::High)
);
}
#[tokio::test]
async fn replayed_interrupted_turn_restores_queued_input_to_composer() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session.clone());
app.chat_widget.handle_server_notification(
turn_started_notification(thread_id, "turn-1"),
/*replay_kind*/ None,
);
app.chat_widget.handle_server_notification(
agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"),
/*replay_kind*/ None,
);
app.chat_widget
.apply_external_edit("queued follow-up".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let input_state = app
.chat_widget
.capture_thread_input_state()
.expect("expected queued follow-up state");
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
make_chatwidget_manual_with_sender().await;
app.chat_widget = chat_widget;
app.chat_widget.handle_thread_session(session.clone());
while new_op_rx.try_recv().is_ok() {}
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: None,
turns: Vec::new(),
events: vec![ThreadBufferedEvent::Notification(Box::new(
turn_completed_notification(thread_id, "turn-1", TurnStatus::Interrupted),
))],
input_state: Some(input_state),
},
/*resume_restored_queue*/ true,
);
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"queued follow-up"
);
assert!(app.chat_widget.queued_user_message_texts().is_empty());
assert!(
new_op_rx.try_recv().is_err(),
"replayed interrupted turns should restore queued input for editing, not submit it"
);
}
#[tokio::test]
async fn token_usage_update_refreshes_status_line_with_runtime_context_window() {
let mut app = make_test_app().await;
app.chat_widget.setup_status_line(
vec![crate::bottom_pane::StatusLineItem::ContextWindowSize],
/*use_theme_colors*/ true,
);
assert_eq!(app.chat_widget.status_line_text(), None);
app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
token_usage_notification(ThreadId::new(), "turn-1", Some(950_000)),
)));
assert_eq!(
app.chat_widget.status_line_text(),
Some("950K window".into())
);
}
#[tokio::test]
async fn collab_receiver_notification_caches_thread_without_app_server_read() {
let mut app = make_test_app().await;
let receiver_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: ThreadId::new().to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
item: ThreadItem::CollabAgentToolCall {
id: "wait-1".to_string(),
tool: codex_app_server_protocol::CollabAgentTool::Wait,
status: codex_app_server_protocol::CollabAgentToolCallStatus::InProgress,
sender_thread_id: ThreadId::new().to_string(),
receiver_thread_ids: vec![receiver_thread_id.to_string()],
prompt: None,
model: None,
reasoning_effort: None,
agents_states: HashMap::new(),
},
}),
)));
assert_eq!(
app.agent_navigation.get(&receiver_thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: None,
agent_role: None,
agent_path: None,
is_running: false,
is_closed: false,
})
);
}
#[tokio::test]
async fn collab_receiver_notification_does_not_cache_not_found_thread() {
let mut app = make_test_app().await;
let receiver_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000124").expect("valid thread id");
app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification {
thread_id: ThreadId::new().to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
item: ThreadItem::CollabAgentToolCall {
id: "send-1".to_string(),
tool: codex_app_server_protocol::CollabAgentTool::SendInput,
status: codex_app_server_protocol::CollabAgentToolCallStatus::Failed,
sender_thread_id: ThreadId::new().to_string(),
receiver_thread_ids: vec![receiver_thread_id.to_string()],
prompt: Some("hello".to_string()),
model: None,
reasoning_effort: None,
agents_states: HashMap::from([(
receiver_thread_id.to_string(),
codex_app_server_protocol::CollabAgentState {
status: codex_app_server_protocol::CollabAgentStatus::NotFound,
message: None,
},
)]),
},
}),
)));
assert_eq!(app.agent_navigation.get(&receiver_thread_id), None);
}
#[tokio::test]
async fn archived_untracked_threads_do_not_appear_in_agent_picker() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await?;
let primary_thread_id = ThreadId::new();
app.enqueue_primary_thread_session(
test_thread_session(primary_thread_id, test_path_buf("/tmp/project")),
Vec::new(),
)
.await?;
let archived_thread_id = ThreadId::new();
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
ServerNotification::ThreadArchived(ThreadArchivedNotification {
thread_id: archived_thread_id.to_string(),
}),
)),
)
.await;
assert!(!app.thread_event_channels.contains_key(&archived_thread_id));
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_eq!(
app.agent_navigation.ordered_thread_ids(),
vec![primary_thread_id]
);
assert_eq!(app.active_thread_id, Some(primary_thread_id));
Ok(())
}
#[tokio::test]
async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
assert_eq!(
app.agent_navigation.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: None,
agent_role: None,
agent_path: None,
is_running: false,
is_closed: true,
})
);
assert_eq!(app.agent_navigation.ordered_thread_ids(), vec![thread_id]);
Ok(())
}
#[tokio::test]
async fn open_agent_picker_preserves_cached_metadata_for_replay_threads() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.agent_navigation.upsert(
thread_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
/*is_closed*/ true,
);
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
assert_eq!(
app.agent_navigation.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
agent_path: None,
is_running: false,
is_closed: true,
})
);
Ok(())
}
#[tokio::test]
async fn open_agent_picker_preserves_running_hints_until_observed_completion() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 4));
app.agent_navigation
.record_sub_agent_activity(SubAgentActivityDisplay {
thread_id,
agent_path: "/root/child".to_string(),
is_running_hint: true,
});
Box::pin(app.open_agent_picker(&mut app_server)).await;
let mut expected_entry = AgentPickerThreadEntry {
agent_nickname: None,
agent_role: None,
agent_path: Some("/root/child".to_string()),
is_running: true,
is_closed: false,
};
assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry));
let status = loop {
let event = app_event_rx.try_recv().expect("agent status history cell");
if let AppEvent::InsertHistoryCell(cell) = event {
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
if rendered.contains("/subagents") {
break rendered;
}
}
};
assert_snapshot!(status, @r###"
/subagents
Sub-agents running
• `/root/child`
No recent activity yet.
"###);
app.enqueue_thread_notification(
thread_id,
turn_completed_notification(thread_id, "turn-older", TurnStatus::Completed),
)
.await?;
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry));
app.enqueue_thread_notification(thread_id, turn_started_notification(thread_id, "turn-1"))
.await?;
app.enqueue_thread_notification(
thread_id,
turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed),
)
.await?;
Box::pin(app.open_agent_picker(&mut app_server)).await;
expected_entry.is_running = false;
assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry));
app.agent_navigation
.record_sub_agent_activity(SubAgentActivityDisplay {
thread_id,
agent_path: "/root/child".to_string(),
is_running_hint: true,
});
Box::pin(app.open_agent_picker(&mut app_server)).await;
expected_entry.is_running = false;
assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry));
app.enqueue_thread_notification(thread_id, turn_started_notification(thread_id, "turn-2"))
.await?;
Box::pin(app.open_agent_picker(&mut app_server)).await;
expected_entry.is_running = true;
assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry));
Ok(())
}
#[tokio::test]
async fn open_agent_picker_clears_running_hint_from_completed_snapshot() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels.insert(
thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
test_thread_session(thread_id, test_path_buf("/tmp/project")),
vec![test_turn("turn-1", TurnStatus::Completed, Vec::new())],
),
);
app.agent_navigation
.record_sub_agent_activity(SubAgentActivityDisplay {
thread_id,
agent_path: "/root/child".to_string(),
is_running_hint: true,
});
assert!(!app.agent_navigation.is_parent_owned(thread_id));
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_eq!(
app.agent_navigation.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: None,
agent_role: None,
agent_path: Some("/root/child".to_string()),
is_running: false,
is_closed: false,
})
);
Ok(())
}
#[tokio::test]
async fn open_agent_picker_selects_path_backed_agent() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.agent_navigation
.record_sub_agent_activity(SubAgentActivityDisplay {
thread_id,
agent_path: "/root/worker".to_string(),
is_running_hint: true,
});
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_app_snapshot!(
"path_backed_agent_picker",
render_bottom_popup(&app.chat_widget, /*width*/ 80)
);
while app_event_rx.try_recv().is_ok() {}
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id
);
Ok(())
}
#[tokio::test]
async fn open_agent_picker_refreshes_replay_only_path_backed_liveness() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
let mut channel = ThreadEventChannel::new(/*capacity*/ 4);
channel.mark_replay_only();
{
let mut store = channel.store.lock().await;
store.push_notification(turn_started_notification(thread_id, "turn-1"));
}
app.thread_event_channels.insert(thread_id, channel);
app.agent_navigation
.record_sub_agent_activity(SubAgentActivityDisplay {
thread_id,
agent_path: "/root/child".to_string(),
is_running_hint: true,
});
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_eq!(
app.agent_navigation.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: None,
agent_role: None,
agent_path: Some("/root/child".to_string()),
is_running: false,
is_closed: true,
})
);
Ok(())
}
#[tokio::test]
async fn open_agent_picker_prunes_terminal_metadata_only_threads() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.agent_navigation.upsert(
thread_id,
Some("Ghost".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_eq!(app.agent_navigation.get(&thread_id), None);
assert!(app.agent_navigation.is_empty());
Ok(())
}
#[tokio::test]
async fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.agent_navigation.upsert(
thread_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
/*is_closed*/ false,
);
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_eq!(
app.agent_navigation.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
agent_path: None,
is_running: false,
is_closed: true,
})
);
Ok(())
}
#[test]
fn open_agent_picker_marks_loaded_threads_open() -> Result<()> {
const WORKER_THREADS: usize = 1;
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(WORKER_THREADS)
.thread_stack_size(TEST_STACK_SIZE_BYTES)
.enable_all()
.build()?;
runtime.block_on(async {
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await?;
let thread_id = started.session.thread_id;
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_eq!(
app.agent_navigation.get(&thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: None,
agent_role: None,
agent_path: None,
is_running: false,
is_closed: false,
})
);
Ok(())
})
}
#[test]
fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -> Result<()> {
const WORKER_THREADS: usize = 1;
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(WORKER_THREADS)
.thread_stack_size(TEST_STACK_SIZE_BYTES)
.enable_all()
.build()?;
runtime.block_on(async {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let root_thread_id = ThreadId::new();
let rollout_dir = app
.config
.codex_home
.join("sessions")
.join("2026")
.join("01")
.join("01");
std::fs::create_dir_all(&rollout_dir)?;
let root_session_meta = SessionMeta {
session_id: root_thread_id.into(),
id: root_thread_id,
timestamp: "2026-01-01T00:00:00Z".to_string(),
cwd: app.config.cwd.to_path_buf(),
originator: "codex-tui-test".to_string(),
cli_version: "0.0.0".to_string(),
source: RolloutSessionSource::Cli,
model_provider: Some(app.config.model_provider_id.clone()),
multi_agent_version: Some(MultiAgentVersion::V2),
..SessionMeta::default()
};
let root_session_meta_line = serde_json::json!({
"timestamp": "2026-01-01T00:00:00Z",
"type": "session_meta",
"payload": serde_json::to_value(root_session_meta)?,
});
std::fs::write(
rollout_dir.join(format!(
"rollout-2026-01-01T00-00-00-{root_thread_id}.jsonl"
)),
format!("{root_session_meta_line}\n"),
)?;
let mut child_thread_ids = Vec::new();
for (index, multi_agent_version) in [MultiAgentVersion::V1, MultiAgentVersion::V2]
.into_iter()
.enumerate()
{
let child_thread_id = ThreadId::new();
let timestamp = format!("2026-01-01T00:00:0{index}Z");
let session_meta = SessionMeta {
session_id: root_thread_id.into(),
id: child_thread_id,
parent_thread_id: Some(root_thread_id),
timestamp: timestamp.clone(),
cwd: app.config.cwd.to_path_buf(),
originator: "codex-tui-test".to_string(),
cli_version: "0.0.0".to_string(),
source: RolloutSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root_thread_id,
depth: 1,
agent_path: None,
agent_nickname: Some(format!("child-{index}")),
agent_role: Some("worker".to_string()),
}),
model_provider: Some(app.config.model_provider_id.clone()),
multi_agent_version: Some(multi_agent_version),
..SessionMeta::default()
};
let rollout_path = rollout_dir.join(format!(
"rollout-2026-01-01T00-00-0{index}-{child_thread_id}.jsonl"
));
let session_meta_line = serde_json::json!({
"timestamp": timestamp,
"type": "session_meta",
"payload": serde_json::to_value(session_meta)?,
});
std::fs::write(rollout_path, format!("{session_meta_line}\n"))?;
child_thread_ids.push(child_thread_id);
}
// Cold-resume the persisted V2 root so its children are registered before selection.
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let root = app_server
.resume_thread(
&app.local_settings,
app.config.clone(),
root_thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
)
.await?;
app.enqueue_primary_thread_session(root.session, root.turns)
.await?;
for (child_thread_id, multi_agent_version) in child_thread_ids
.iter()
.copied()
.zip([MultiAgentVersion::V1, MultiAgentVersion::V2])
{
assert!(
app.attach_live_thread_for_selection(&mut app_server, child_thread_id)
.await?
);
assert_eq!(
app.agent_navigation.is_parent_owned(child_thread_id),
multi_agent_version == MultiAgentVersion::V2
);
}
app.agent_navigation
.record_sub_agent_activity(SubAgentActivityDisplay {
thread_id: child_thread_ids[0],
agent_path: "/root/child-0".to_string(),
is_running_hint: true,
});
app.thread_event_channels.remove(&child_thread_ids[1]);
let backfill = app.backfill_loaded_subagent_threads(&mut app_server).await;
assert!(backfill.completed);
assert_eq!(
backfill.refreshed_thread_ids,
[child_thread_ids[1]].into_iter().collect()
);
assert_eq!(
app.agent_navigation.get(&child_thread_ids[0]),
Some(&AgentPickerThreadEntry {
agent_nickname: Some("child-0".to_string()),
agent_role: Some("worker".to_string()),
agent_path: Some("/root/child-0".to_string()),
is_running: true,
is_closed: false,
})
);
assert!(!app.agent_navigation.is_parent_owned(child_thread_ids[0]));
assert!(app.agent_navigation.is_parent_owned(child_thread_ids[1]));
let mut tui = crate::tui::test_support::make_test_tui()?;
app.select_agent_thread(&mut tui, &mut app_server, child_thread_ids[0])
.await?;
while app_event_rx.try_recv().is_ok() {}
app.chat_widget
.restore_user_message_to_composer("v1 remains writable".into());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
std::iter::from_fn(|| app_event_rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. })))
);
app.select_agent_thread(&mut tui, &mut app_server, child_thread_ids[1])
.await?;
while app_event_rx.try_recv().is_ok() {}
app.chat_widget
.restore_user_message_to_composer("v2 stays view-only".into());
let draft = app.chat_widget.composer_text_with_pending();
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.chat_widget.composer_text_with_pending(), draft);
assert!(
!std::iter::from_fn(|| app_event_rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. })))
);
let resumed = app_server
.resume_thread(
&app.local_settings,
app.config.clone(),
child_thread_ids[1],
app.resume_model_settings(),
)
.await?;
assert!(resumed.blocks_direct_input);
app.replace_chat_widget_with_app_server_thread(
&mut tui,
resumed,
crate::app::session_lifecycle::ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
)
.await?;
while app_event_rx.try_recv().is_ok() {}
app.chat_widget
.restore_user_message_to_composer("direct resume stays view-only".into());
let draft = app.chat_widget.composer_text_with_pending();
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.chat_widget.composer_text_with_pending(), draft);
assert!(
!std::iter::from_fn(|| app_event_rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. })))
);
Ok(())
})
}
#[test]
fn attach_live_thread_for_selection_rejects_empty_non_ephemeral_fallback_threads() -> Result<()> {
const WORKER_THREADS: usize = 1;
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(WORKER_THREADS)
.thread_stack_size(TEST_STACK_SIZE_BYTES)
.enable_all()
.build()?;
runtime.block_on(async {
let config = {
let app = make_test_app().await;
app.chat_widget.config_ref().clone()
};
let mut app_server = crate::start_embedded_app_server_for_picker(&config)
.await
.expect("embedded app server");
let started = app_server.start_thread(&config).await?;
let thread_id = started.session.thread_id;
let mut app = make_test_app().await;
app.agent_navigation.upsert(
thread_id,
Some("Scout".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
let err = app
.attach_live_thread_for_selection(&mut app_server, thread_id)
.await
.expect_err("empty fallback should not attach as a blank replay-only thread");
assert_eq!(
err.to_string(),
format!("Agent thread {thread_id} is not yet available for replay or live attach.")
);
assert!(!app.thread_event_channels.contains_key(&thread_id));
Ok(())
})
}
#[test]
fn attach_live_thread_for_selection_rejects_unmaterialized_fallback_threads() -> Result<()> {
const WORKER_THREADS: usize = 1;
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(WORKER_THREADS)
.thread_stack_size(TEST_STACK_SIZE_BYTES)
.enable_all()
.build()?;
runtime.block_on(async {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let mut ephemeral_config = app.chat_widget.config_ref().clone();
ephemeral_config.ephemeral = true;
let started = app_server.start_thread(&ephemeral_config).await?;
let thread_id = started.session.thread_id;
app.agent_navigation.upsert(
thread_id,
Some("Scout".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
let err = app
.attach_live_thread_for_selection(&mut app_server, thread_id)
.await
.expect_err("ephemeral fallback should not attach as a blank live thread");
assert_eq!(
err.to_string(),
format!("Agent thread {thread_id} is not yet available for replay or live attach.")
);
assert!(!app.thread_event_channels.contains_key(&thread_id));
Ok(())
})
}
#[tokio::test]
async fn should_attach_live_thread_for_selection_skips_closed_metadata_only_threads() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.agent_navigation.upsert(
thread_id,
Some("Ghost".to_string()),
Some("worker".to_string()),
/*is_closed*/ true,
);
assert!(!app.should_attach_live_thread_for_selection(thread_id));
app.agent_navigation.upsert(
thread_id,
Some("Ghost".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
assert!(app.should_attach_live_thread_for_selection(thread_id));
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
assert!(!app.should_attach_live_thread_for_selection(thread_id));
}
#[tokio::test]
async fn refresh_agent_picker_thread_liveness_prunes_closed_metadata_only_threads() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.agent_navigation.upsert(
thread_id,
Some("Ghost".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
let is_available =
Box::pin(app.refresh_agent_picker_thread_liveness(&mut app_server, thread_id)).await;
assert!(!is_available);
assert_eq!(app.agent_navigation.get(&thread_id), None);
assert!(!app.thread_event_channels.contains_key(&thread_id));
Ok(())
}
#[tokio::test]
async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let config = app.chat_widget.config_ref().clone();
let parent_thread_id = ThreadId::from_string(
&app_test_support::create_fake_rollout(
config.codex_home.as_path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Some(config.model_provider_id.as_str()),
/*git_info*/ None,
)
.expect("create source rollout"),
)?;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
let started = app_server
.resume_thread(
&crate::local_settings::LocalSettings::from(&config),
config,
parent_thread_id,
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
)
.await?;
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
while app_event_rx.try_recv().is_ok() {}
let mut tui = crate::tui::test_support::make_test_tui()?;
let control = Box::pin(app.handle_start_side(
&mut tui,
&mut app_server,
parent_thread_id,
/*user_message*/ None,
))
.await?;
let side_thread_id = app
.active_thread_id
.expect("side conversation should become active");
assert!(matches!(control, AppRunControl::Continue));
assert_ne!(side_thread_id, parent_thread_id);
assert!(app.side_threads.contains_key(&side_thread_id));
assert!(app.thread_event_channels.contains_key(&side_thread_id));
assert!(
!app.agent_navigation
.get(&side_thread_id)
.expect("side start should seed navigation before thread/started")
.is_closed
);
let mut saw_thread_started = false;
for _ in 0..20 {
let event = time::timeout(
std::time::Duration::from_secs(/*secs*/ 2),
app_server.next_event(),
)
.await
.expect("app-server should emit an event")
.expect("app-server event stream should remain open");
if let codex_app_server_client::AppServerEvent::ServerNotification(notification) = event
&& let ServerNotification::ThreadStarted(notification) = notification.as_ref()
&& notification.thread.id == side_thread_id.to_string()
{
saw_thread_started = true;
break;
}
}
assert!(saw_thread_started);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn select_uncached_agent_thread_still_refreshes_liveness() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await?;
let thread_id = ThreadId::new();
app.agent_navigation.upsert(
thread_id,
Some("Ghost".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
let mut tui = crate::tui::test_support::make_test_tui()?;
Box::pin(app.select_agent_thread(&mut tui, &mut app_server, thread_id)).await?;
assert_eq!(app.active_thread_id, None);
assert_eq!(app.agent_navigation.get(&thread_id), None);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let _ = app.config.features.disable(Feature::Collab);
Box::pin(app.open_agent_picker(&mut app_server)).await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)]
);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = cell
.display_lines(/*width*/ 120)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Subagents will be enabled in the next session."));
Ok(())
}
#[tokio::test]
async fn update_memory_settings_persists_and_updates_widget_config() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
Box::pin(app.update_memory_settings_with_app_server(
&mut app_server,
/*use_memories*/ false,
/*generate_memories*/ false,
))
.await;
assert!(!app.config.memories.use_memories);
assert!(!app.config.memories.generate_memories);
assert!(!app.chat_widget.config_ref().memories.use_memories);
assert!(!app.chat_widget.config_ref().memories.generate_memories);
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
let config_value = toml::from_str::<TomlValue>(&config)?;
let memories = config_value
.as_table()
.and_then(|table| table.get("memories"))
.and_then(TomlValue::as_table)
.expect("memories table should exist");
assert_eq!(
memories.get("use_memories"),
Some(&TomlValue::Boolean(false))
);
assert_eq!(
memories.get("generate_memories"),
Some(&TomlValue::Boolean(false))
);
assert!(
!memories.contains_key("disable_on_external_context")
&& !memories.contains_key("no_memories_if_mcp_or_web_search"),
"the TUI menu should not write the external-context memory setting"
);
app_server.shutdown().await?;
Ok(())
}
#[test]
fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> {
const WORKER_THREADS: usize = 1;
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(WORKER_THREADS)
.thread_stack_size(TEST_STACK_SIZE_BYTES)
.enable_all()
.build()?;
runtime.block_on(async {
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs());
// Seed the previous setting so this test exercises the thread-mode update path.
app.config.memories.generate_memories = true;
let mut app_server =
Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let started = app_server.start_thread(&app.config).await?;
let thread_id = started.session.thread_id;
app.active_thread_id = Some(thread_id);
Box::pin(app.update_memory_settings_with_app_server(
&mut app_server,
/*use_memories*/ true,
/*generate_memories*/ false,
))
.await;
let state_db = codex_state::StateRuntime::init(
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
app.config.model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let memory_mode = state_db
.get_thread_memory_mode(thread_id)
.await
.expect("thread memory mode should be readable");
assert_eq!(memory_mode.as_deref(), Some("disabled"));
app_server.shutdown().await?;
Ok(())
})
}
#[tokio::test]
async fn reset_memories_clears_local_memory_directories() -> Result<()> {
Box::pin(async {
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs());
let memory_root = codex_home.path().join("memories");
let extensions_root = memory_root.join("extensions");
std::fs::create_dir_all(memory_root.join("rollout_summaries"))?;
std::fs::create_dir_all(&extensions_root)?;
std::fs::write(memory_root.join("MEMORY.md"), "stale memory\n")?;
std::fs::write(
memory_root.join("rollout_summaries").join("stale.md"),
"stale summary\n",
)?;
std::fs::write(extensions_root.join("stale.txt"), "stale extension\n")?;
let mut app_server =
Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
Box::pin(app.reset_memories_with_app_server(&mut app_server)).await;
assert_eq!(std::fs::read_dir(&memory_root)?.count(), 0);
app_server.shutdown().await?;
Ok(())
})
.await
}
#[tokio::test]
async fn apply_permission_profile_selection_preserves_loader_overrides() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let codex_home = tempdir()?;
let selected_config = codex_home.path().join("work.config.toml");
std::fs::write(
&selected_config,
r#"
default_permissions = "locked-down"
[permissions.locked-down.filesystem]
":minimal" = "read"
"#,
)?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
app.loader_overrides.user_config_path = Some(selected_config.abs());
app.harness_overrides.sandbox_mode = Some(SandboxMode::WorkspaceWrite);
app.harness_overrides.permission_profile = Some(PermissionProfile::workspace_write());
assert!(
app.apply_permission_profile_selection(PermissionProfileSelection {
profile_id: "locked-down".to_string(),
approval_policy: None,
approvals_reviewer: None,
display_label: "locked-down".to_string(),
})
.await
);
assert_eq!(
app.config
.permissions
.active_permission_profile()
.as_ref()
.map(|profile| profile.id.as_str()),
Some("locked-down")
);
assert_eq!(
app.chat_widget
.config_ref()
.permissions
.active_permission_profile()
.as_ref()
.map(|profile| profile.id.as_str()),
Some("locked-down")
);
assert_eq!(
app.runtime_permission_profile_override,
Some(RuntimePermissionProfileOverride::from_config(&app.config))
);
let op = match app_event_rx.try_recv() {
Ok(AppEvent::CodexOp(op)) => op,
other => panic!("expected CodexOp event, got {other:?}"),
};
assert_eq!(
op,
Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
approvals_reviewer: None,
permission_profile: Some(app.config.permissions.permission_profile().clone()),
active_permission_profile: app.config.permissions.active_permission_profile(),
windows_sandbox_level: None,
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
}
);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = cell
.display_lines(/*width*/ 120)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Permissions updated to locked-down"));
Ok(())
}
#[tokio::test]
async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
let auto_review = auto_review_mode();
let mut app_server = start_config_write_test_app_server(&app).await?;
app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, true)])
.await;
assert!(app.config.features.enabled(Feature::GuardianApproval));
assert!(
app.chat_widget
.config_ref()
.features
.enabled(Feature::GuardianApproval)
);
assert_eq!(
app.config.approvals_reviewer,
auto_review.approvals_reviewer
);
assert_eq!(
AskForApproval::from(app.config.permissions.approval_policy.value()),
auto_review.approval_policy
);
assert_eq!(
AskForApproval::from(
app.chat_widget
.config_ref()
.permissions
.approval_policy
.value(),
),
auto_review.approval_policy
);
assert_eq!(
app.chat_widget
.config_ref()
.permissions
.permission_profile(),
&auto_review.permission_profile()
);
assert_eq!(
app.config.permissions.active_permission_profile(),
Some(auto_review.active_permission_profile.clone())
);
assert_eq!(
app.chat_widget
.config_ref()
.permissions
.active_permission_profile(),
Some(auto_review.active_permission_profile.clone())
);
assert_eq!(
app.chat_widget.config_ref().approvals_reviewer,
auto_review.approvals_reviewer
);
assert_eq!(app.runtime_approval_policy_override, None);
assert_eq!(
app.runtime_permission_profile_override,
Some(RuntimePermissionProfileOverride::from_config(&app.config))
);
assert_eq!(
op_rx.try_recv(),
Ok(Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(auto_review.approval_policy),
approvals_reviewer: Some(auto_review.approvals_reviewer),
permission_profile: Some(auto_review.permission_profile()),
active_permission_profile: Some(auto_review.active_permission_profile.clone()),
windows_sandbox_level: None,
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = cell
.display_lines(/*width*/ 120)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Permissions updated to Approve for me"));
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(config.contains("guardian_approval = true"));
assert!(config.contains("approvals_reviewer = \"auto_review\""));
assert!(config.contains("approval_policy = \"on-request\""));
assert!(config.contains("sandbox_mode = \"workspace-write\""));
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default()
-> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
let config_toml_path = codex_home.path().join("config.toml").abs();
let config_toml = "approvals_reviewer = \"guardian_subagent\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n";
std::fs::write(config_toml_path.as_path(), config_toml)?;
let user_config = toml::from_str::<TomlValue>(config_toml)?;
app.config.config_layer_stack = app
.config
.config_layer_stack
.with_user_config(&config_toml_path, user_config)?;
app.config
.features
.set_enabled(Feature::GuardianApproval, /*enabled*/ true)?;
app.chat_widget
.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true);
app.config.approvals_reviewer = ApprovalsReviewer::AutoReview;
app.chat_widget
.set_approvals_reviewer(ApprovalsReviewer::AutoReview);
app.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest.to_core())?;
app.config
.permissions
.set_permission_profile(PermissionProfile::workspace_write())?;
app.chat_widget
.set_approval_policy(AskForApproval::OnRequest);
app.chat_widget
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy(
PermissionProfile::workspace_write(),
))?;
let mut app_server = start_config_write_test_app_server(&app).await?;
app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, false)])
.await;
assert!(!app.config.features.enabled(Feature::GuardianApproval));
assert!(
!app.chat_widget
.config_ref()
.features
.enabled(Feature::GuardianApproval)
);
assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User);
assert_eq!(
AskForApproval::from(app.config.permissions.approval_policy.value()),
AskForApproval::OnRequest
);
assert_eq!(
app.chat_widget.config_ref().approvals_reviewer,
ApprovalsReviewer::User
);
assert_eq!(app.runtime_approval_policy_override, None);
assert_eq!(
op_rx.try_recv(),
Ok(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
approvals_reviewer: Some(ApprovalsReviewer::User),
permission_profile: None,
active_permission_profile: None,
windows_sandbox_level: None,
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = cell
.display_lines(/*width*/ 120)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Permissions updated to Ask for approval"));
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(!config.contains("guardian_approval = true"));
assert!(!config.contains("approvals_reviewer ="));
assert!(config.contains("approval_policy = \"on-request\""));
assert!(config.contains("sandbox_mode = \"workspace-write\""));
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review_policy()
-> Result<()> {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
let auto_review = auto_review_mode();
let config_toml_path = codex_home.path().join("config.toml").abs();
let config_toml = "approvals_reviewer = \"user\"\n";
std::fs::write(config_toml_path.as_path(), config_toml)?;
let user_config = toml::from_str::<TomlValue>(config_toml)?;
app.config.config_layer_stack = app
.config
.config_layer_stack
.with_user_config(&config_toml_path, user_config)?;
app.config.approvals_reviewer = ApprovalsReviewer::User;
app.chat_widget
.set_approvals_reviewer(ApprovalsReviewer::User);
let mut app_server = start_config_write_test_app_server(&app).await?;
app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, true)])
.await;
assert!(app.config.features.enabled(Feature::GuardianApproval));
assert_eq!(
app.config.approvals_reviewer,
auto_review.approvals_reviewer
);
assert_eq!(
app.chat_widget.config_ref().approvals_reviewer,
auto_review.approvals_reviewer
);
assert_eq!(
AskForApproval::from(app.config.permissions.approval_policy.value()),
auto_review.approval_policy
);
assert_eq!(
app.chat_widget
.config_ref()
.permissions
.permission_profile(),
&auto_review.permission_profile()
);
assert_eq!(
op_rx.try_recv(),
Ok(Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(auto_review.approval_policy),
approvals_reviewer: Some(auto_review.approvals_reviewer),
permission_profile: Some(auto_review.permission_profile()),
active_permission_profile: Some(auto_review.active_permission_profile.clone()),
windows_sandbox_level: None,
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
);
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(config.contains("approvals_reviewer = \"auto_review\""));
assert!(config.contains("guardian_approval = true"));
assert!(config.contains("approval_policy = \"on-request\""));
assert!(config.contains("sandbox_mode = \"workspace-write\""));
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history()
-> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
let config_toml_path = codex_home.path().join("config.toml").abs();
let config_toml = "approvals_reviewer = \"user\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n";
std::fs::write(config_toml_path.as_path(), config_toml)?;
let user_config = toml::from_str::<TomlValue>(config_toml)?;
app.config.config_layer_stack = app
.config
.config_layer_stack
.with_user_config(&config_toml_path, user_config)?;
app.config
.features
.set_enabled(Feature::GuardianApproval, /*enabled*/ true)?;
app.chat_widget
.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true);
app.config.approvals_reviewer = ApprovalsReviewer::User;
app.chat_widget
.set_approvals_reviewer(ApprovalsReviewer::User);
let mut app_server = start_config_write_test_app_server(&app).await?;
app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, false)])
.await;
assert!(!app.config.features.enabled(Feature::GuardianApproval));
assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User);
assert_eq!(
app.chat_widget.config_ref().approvals_reviewer,
ApprovalsReviewer::User
);
assert_eq!(
op_rx.try_recv(),
Ok(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
approvals_reviewer: Some(ApprovalsReviewer::User),
permission_profile: None,
active_permission_profile: None,
windows_sandbox_level: None,
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
);
assert!(
app_event_rx.try_recv().is_err(),
"manual review should not emit a permissions history update when the effective state stays default"
);
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(!config.contains("guardian_approval = true"));
assert!(!config.contains("approvals_reviewer ="));
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
Box::pin(app.open_agent_picker(&mut app_server)).await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id
);
Ok(())
}
#[tokio::test]
async fn refresh_pending_thread_approvals_only_lists_inactive_threads() {
let mut app = make_test_app().await;
let main_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread");
let agent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread");
app.primary_thread_id = Some(main_thread_id);
app.active_thread_id = Some(main_thread_id);
app.thread_event_channels
.insert(main_thread_id, ThreadEventChannel::new(/*capacity*/ 1));
let agent_channel = ThreadEventChannel::new(/*capacity*/ 1);
{
let mut store = agent_channel.store.lock().await;
store.push_request(exec_approval_request(
agent_thread_id,
"turn-1",
"call-1",
/*approval_id*/ None,
));
}
app.thread_event_channels
.insert(agent_thread_id, agent_channel);
app.agent_navigation.upsert(
agent_thread_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
/*is_closed*/ false,
);
app.refresh_pending_thread_approvals().await;
assert_eq!(
app.chat_widget.pending_thread_approvals(),
&["Robie [explorer]".to_string()]
);
app.active_thread_id = Some(agent_thread_id);
app.refresh_pending_thread_approvals().await;
assert!(app.chat_widget.pending_thread_approvals().is_empty());
}
#[tokio::test]
async fn inactive_thread_approval_bubbles_into_active_view() -> Result<()> {
let mut app = make_test_app().await;
let main_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000011").expect("valid thread");
let agent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000022").expect("valid thread");
app.primary_thread_id = Some(main_thread_id);
app.active_thread_id = Some(main_thread_id);
app.thread_event_channels
.insert(main_thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.thread_event_channels.insert(
agent_thread_id,
ThreadEventChannel::new_with_session(
/*capacity*/ 1,
ThreadSessionState {
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::workspace_write(),
rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")),
..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent"))
},
Vec::new(),
),
);
app.agent_navigation.upsert(
agent_thread_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
/*is_closed*/ false,
);
app.enqueue_thread_request(
agent_thread_id,
exec_approval_request(
agent_thread_id,
"turn-approval",
"call-approval",
/*approval_id*/ None,
),
)
.await?;
assert_eq!(app.chat_widget.has_active_view(), true);
assert_eq!(
app.chat_widget.pending_thread_approvals(),
&["Robie [explorer]".to_string()]
);
Ok(())
}
#[tokio::test]
async fn side_defers_parent_approval_overlay_until_parent_replay() -> Result<()> {
let mut app = make_test_app().await;
let parent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000011").expect("valid thread");
let side_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000022").expect("valid thread");
app.primary_thread_id = Some(parent_thread_id);
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.thread_event_channels.insert(
parent_thread_id,
ThreadEventChannel::new_with_session(
/*capacity*/ 4,
test_thread_session(parent_thread_id, test_path_buf("/tmp/main")),
Vec::new(),
),
);
app.enqueue_thread_request(
parent_thread_id,
exec_approval_request(
parent_thread_id,
"turn-approval",
"call-approval",
/*approval_id*/ None,
),
)
.await?;
assert_eq!(app.chat_widget.has_active_view(), false);
assert!(app.chat_widget.pending_thread_approvals().is_empty());
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
Some(SideParentStatus::NeedsApproval)
);
let snapshot = {
let channel = app
.thread_event_channels
.get(&parent_thread_id)
.expect("parent thread channel");
let store = channel.store.lock().await;
store.snapshot()
};
app.side_threads.remove(&side_thread_id);
app.active_thread_id = Some(parent_thread_id);
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);
assert_eq!(app.chat_widget.has_active_view(), true);
Ok(())
}
#[tokio::test]
async fn replay_snapshot_with_pending_request_suppresses_replay_notices() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000011").expect("valid thread");
let stale_warning = "stale startup warning that should not cover the approval";
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: Some(test_thread_session(thread_id, test_path_buf("/tmp/main"))),
turns: Vec::new(),
events: vec![
ThreadBufferedEvent::Notification(Box::new(ServerNotification::Warning(
WarningNotification {
thread_id: Some(thread_id.to_string()),
message: stale_warning.to_string(),
},
))),
ThreadBufferedEvent::Request(Box::new(exec_approval_request(
thread_id,
"turn-approval",
"call-approval",
/*approval_id*/ None,
))),
],
input_state: None,
},
/*resume_restored_queue*/ false,
);
assert_eq!(app.chat_widget.has_active_view(), true);
let mut replayed_history = String::new();
while let Ok(event) = app_event_rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
replayed_history.push_str(&lines_to_single_string(
&cell.transcript_lines(/*width*/ 80),
));
}
}
assert!(
replayed_history.is_empty(),
"expected pending approval replay to suppress session notices, got {replayed_history:?}"
);
}
#[tokio::test]
async fn side_defers_subagent_approval_overlay_until_side_exits() -> Result<()> {
let mut app = make_test_app().await;
let main_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000011").expect("valid thread");
let side_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000022").expect("valid thread");
let agent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000033").expect("valid thread");
let quiet_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000044").expect("valid thread");
app.primary_thread_id = Some(main_thread_id);
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(main_thread_id));
app.thread_event_channels.insert(
agent_thread_id,
ThreadEventChannel::new_with_session(
/*capacity*/ 4,
ThreadSessionState {
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::workspace_write(),
rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")),
..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent"))
},
Vec::new(),
),
);
app.agent_navigation.upsert(
agent_thread_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
/*is_closed*/ false,
);
let pending_approval = exec_approval_request(
agent_thread_id,
"turn-approval",
"call-approval",
/*approval_id*/ None,
);
app.enqueue_thread_request(agent_thread_id, pending_approval.clone())
.await?;
app.enqueue_thread_request(
quiet_thread_id,
ServerRequest::DynamicToolCall {
request_id: AppServerRequestId::Integer(99),
params: codex_app_server_protocol::DynamicToolCallParams {
thread_id: quiet_thread_id.to_string(),
turn_id: "turn-quiet".to_string(),
call_id: "call-quiet".to_string(),
namespace: None,
tool: "ignored-tool".to_string(),
arguments: serde_json::json!({}),
},
},
)
.await?;
assert_eq!(app.chat_widget.has_active_view(), false);
assert_eq!(
app.chat_widget.pending_thread_approvals(),
&["Robie [explorer]".to_string()]
);
app.side_threads.remove(&side_thread_id);
app.active_thread_id = Some(main_thread_id);
assert_eq!(
app.pending_inactive_thread_requests().await,
vec![(agent_thread_id, pending_approval)]
);
app.surface_pending_inactive_thread_interactive_requests()
.await?;
assert_eq!(app.chat_widget.has_active_view(), true);
Ok(())
}
#[tokio::test]
async fn inactive_thread_exec_approval_preserves_context() {
let app = make_test_app().await;
let thread_id = ThreadId::new();
let mut request = exec_approval_request(
thread_id,
"turn-approval",
"call-approval",
/*approval_id*/ None,
);
let ServerRequest::CommandExecutionRequestApproval { params, .. } = &mut request else {
panic!("expected exec approval request");
};
params.network_approval_context = Some(AppServerNetworkApprovalContext {
host: "example.com".to_string(),
protocol: AppServerNetworkApprovalProtocol::Socks5Tcp,
});
params.additional_permissions = Some(AdditionalPermissionProfile {
network: Some(AdditionalNetworkPermissions {
enabled: Some(true),
}),
file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![test_absolute_path("/tmp/read-only").into()]),
write: Some(vec![test_absolute_path("/tmp/write").into()]),
glob_scan_max_depth: None,
entries: None,
}),
});
params.proposed_network_policy_amendments = Some(vec![AppServerNetworkPolicyAmendment {
host: "example.com".to_string(),
action: AppServerNetworkPolicyRuleAction::Allow,
}]);
let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec(approval))) = app
.interactive_request_for_thread_request(thread_id, &request)
.await
.expect("valid localized paths")
else {
panic!("expected exec approval request");
};
assert_eq!(
approval.network_approval_context,
Some(AppServerNetworkApprovalContext {
host: "example.com".to_string(),
protocol: AppServerNetworkApprovalProtocol::Socks5Tcp,
})
);
assert_eq!(
approval.additional_permissions,
Some(AdditionalPermissionProfile {
network: Some(AdditionalNetworkPermissions {
enabled: Some(true),
}),
file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![test_absolute_path("/tmp/read-only").into()]),
write: Some(vec![test_absolute_path("/tmp/write").into()]),
glob_scan_max_depth: None,
entries: None,
}),
})
);
assert_eq!(
approval.available_decisions,
vec![
codex_app_server_protocol::CommandExecutionApprovalDecision::Accept,
codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession,
codex_app_server_protocol::CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
network_policy_amendment: AppServerNetworkPolicyAmendment {
host: "example.com".to_string(),
action: AppServerNetworkPolicyRuleAction::Allow,
},
},
codex_app_server_protocol::CommandExecutionApprovalDecision::Cancel,
]
);
}
#[tokio::test]
async fn inactive_thread_exec_approval_splits_shell_wrapped_command() {
let app = make_test_app().await;
let thread_id = ThreadId::new();
let script = r#"python3 -c 'print("Hello, world!")'"#;
let mut request = exec_approval_request(
thread_id,
"turn-approval",
"call-approval",
/*approval_id*/ None,
);
let ServerRequest::CommandExecutionRequestApproval { params, .. } = &mut request else {
panic!("expected exec approval request");
};
params.command =
Some(shlex::try_join(["/bin/zsh", "-lc", script]).expect("round-trippable shell wrapper"));
let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec(approval))) = app
.interactive_request_for_thread_request(thread_id, &request)
.await
.expect("valid localized paths")
else {
panic!("expected exec approval request");
};
assert_eq!(
approval.command,
vec![
"/bin/zsh".to_string(),
"-lc".to_string(),
script.to_string(),
]
);
}
#[tokio::test]
async fn inactive_thread_file_change_approval_recovers_buffered_changes() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.enqueue_thread_notification(
thread_id,
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id.to_string(),
turn_id: "turn-approval".to_string(),
started_at_ms: 0,
item: ThreadItem::FileChange {
id: "patch-approval".to_string(),
changes: vec![FileUpdateChange {
path: "README.md".to_string(),
kind: PatchChangeKind::Add,
diff: "hello\n".to_string(),
}],
status: codex_app_server_protocol::PatchApplyStatus::InProgress,
},
}),
)
.await
.expect("enqueue file change item");
let request = ServerRequest::FileChangeRequestApproval {
request_id: AppServerRequestId::Integer(9),
params: FileChangeRequestApprovalParams {
thread_id: thread_id.to_string(),
turn_id: "turn-approval".to_string(),
item_id: "patch-approval".to_string(),
started_at_ms: 0,
reason: Some("command failed; retry without sandbox?".to_string()),
grant_root: None,
},
};
let request = app
.interactive_request_for_thread_request(thread_id, &request)
.await
.expect("valid localized paths")
.expect("expected file change approval request");
let ThreadInteractiveRequest::Approval(ApprovalRequest::ApplyPatch(approval)) = &request else {
panic!("expected apply-patch approval request");
};
assert_eq!(
&approval.changes,
&HashMap::from([(
PathBuf::from("README.md"),
FileChange::Add {
content: "hello\n".to_string(),
},
)])
);
assert_eq!(
&approval.reason,
&Some("command failed; retry without sandbox?".to_string())
);
app.push_thread_interactive_request(request);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected patch preview history cell, saw {other:?}"),
};
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
assert!(rendered.contains("• Added README.md (+1 -0)"));
assert!(rendered.contains("1 +hello"));
}
#[tokio::test]
async fn active_thread_file_change_approval_recovers_buffered_changes() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
app.startup_protected_input_boundary = true;
app.enqueue_thread_notification(
thread_id,
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id.to_string(),
turn_id: "turn-active-approval".to_string(),
started_at_ms: 0,
item: ThreadItem::FileChange {
id: "patch-active-approval".to_string(),
changes: vec![FileUpdateChange {
path: "visible-target.md".to_string(),
kind: PatchChangeKind::Add,
diff: "hello\n".to_string(),
}],
status: codex_app_server_protocol::PatchApplyStatus::InProgress,
},
}),
)
.await
.expect("enqueue file change item");
let request = ServerRequest::FileChangeRequestApproval {
request_id: AppServerRequestId::Integer(10),
params: FileChangeRequestApprovalParams {
thread_id: thread_id.to_string(),
turn_id: "turn-active-approval".to_string(),
item_id: "patch-active-approval".to_string(),
started_at_ms: 0,
reason: None,
grant_root: None,
},
};
assert_eq!(
app.pending_app_server_requests
.note_server_request(&request),
None
);
app.chat_widget.handle_server_notification(
agent_message_delta_notification(thread_id, "turn-active-approval", "agent-1", "streaming"),
/*replay_kind*/ None,
);
let event = ThreadBufferedEvent::Request(Box::new(request));
app.handle_thread_event_now_recovering_file_changes(event)
.await;
assert!(!app.chat_widget.has_active_view());
assert!(app.startup_pending_protected_request);
app.chat_widget.handle_server_notification(
ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification {
thread_id: thread_id.to_string(),
turn_id: "turn-active-approval".to_string(),
completed_at_ms: 0,
item: ThreadItem::AgentMessage {
id: "agent-1".to_string(),
text: "streaming".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
}),
/*replay_kind*/ None,
);
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 100);
let destination = app.chat_widget.config_ref().cwd.join("visible-target.md");
assert!(rendered.contains("Description: Apply proposed file edits"));
assert!(rendered.contains(&format!("Destination: {}", destination.display())));
}
#[tokio::test]
async fn replayed_file_change_approval_recovers_snapshot_changes() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let cwd = test_path_buf("/tmp/project").abs();
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: Some(test_thread_session(thread_id, cwd.clone().into_path_buf())),
turns: vec![test_turn(
"turn-replayed-approval",
TurnStatus::InProgress,
vec![ThreadItem::FileChange {
id: "patch-replayed-approval".to_string(),
changes: vec![FileUpdateChange {
path: "visible-target.md".to_string(),
kind: PatchChangeKind::Add,
diff: "hello\n".to_string(),
}],
status: codex_app_server_protocol::PatchApplyStatus::InProgress,
}],
)],
events: vec![ThreadBufferedEvent::Request(Box::new(
ServerRequest::FileChangeRequestApproval {
request_id: AppServerRequestId::Integer(11),
params: FileChangeRequestApprovalParams {
thread_id: thread_id.to_string(),
turn_id: "turn-replayed-approval".to_string(),
item_id: "patch-replayed-approval".to_string(),
started_at_ms: 0,
reason: None,
grant_root: None,
},
},
))],
input_state: None,
},
/*resume_restored_queue*/ false,
);
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 100).replace(
&cwd.join("visible-target.md").display().to_string(),
"/tmp/project/visible-target.md",
);
assert_app_snapshot!("replayed_file_change_approval", rendered);
}
#[tokio::test]
async fn inactive_thread_permissions_approval_preserves_file_system_permissions() {
let app = make_test_app().await;
let thread_id = ThreadId::new();
let request = ServerRequest::PermissionsRequestApproval {
request_id: AppServerRequestId::Integer(7),
params: PermissionsRequestApprovalParams {
thread_id: thread_id.to_string(),
turn_id: "turn-approval".to_string(),
item_id: "call-approval".to_string(),
environment_id: Some("remote".to_string()),
started_at_ms: 0,
cwd: test_absolute_path("/tmp").into(),
reason: Some("Need access to .git".to_string()),
permissions: codex_app_server_protocol::RequestPermissionProfile {
network: Some(AdditionalNetworkPermissions {
enabled: Some(true),
}),
file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![test_absolute_path("/tmp/read-only").into()]),
write: Some(vec![test_absolute_path("/tmp/write").into()]),
glob_scan_max_depth: None,
entries: None,
}),
},
},
};
let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Permissions(approval))) = app
.interactive_request_for_thread_request(thread_id, &request)
.await
.expect("valid localized paths")
else {
panic!("expected permissions approval request");
};
assert_eq!(approval.environment_id.as_deref(), Some("remote"));
assert_eq!(
approval.permissions,
RequestPermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![test_absolute_path("/tmp/read-only")]),
Some(vec![test_absolute_path("/tmp/write")]),
)),
}
);
}
#[tokio::test]
async fn inactive_thread_url_elicitation_routes_to_app_link() {
let app = make_test_app().await;
let thread_id = ThreadId::new();
let request = ServerRequest::McpServerElicitationRequest {
request_id: AppServerRequestId::Integer(9),
params: McpServerElicitationRequestParams {
thread_id: thread_id.to_string(),
turn_id: Some("turn-auth".to_string()),
server_name: "payments".to_string(),
request: McpServerElicitationRequest::Url {
meta: None,
message: "Review the payment details to continue.".to_string(),
url: "https://payments.example/checkout/123".to_string(),
elicitation_id: "payment-123".to_string(),
},
},
};
let Some(ThreadInteractiveRequest::AppLink(params)) = app
.interactive_request_for_thread_request(thread_id, &request)
.await
.expect("valid localized paths")
else {
panic!("expected app link request");
};
assert_eq!(params.title, "Action required");
assert_eq!(params.description, Some("Server: payments".to_string()));
assert_eq!(params.url, "https://payments.example/checkout/123");
assert_eq!(
params.elicitation_target,
Some(crate::bottom_pane::AppLinkElicitationTarget {
thread_id,
server_name: "payments".to_string(),
request_id: AppServerRequestId::Integer(9),
})
);
}
#[tokio::test]
async fn inactive_thread_invalid_url_elicitation_is_declined() {
let (app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let request = ServerRequest::McpServerElicitationRequest {
request_id: AppServerRequestId::Integer(10),
params: McpServerElicitationRequestParams {
thread_id: thread_id.to_string(),
turn_id: Some("turn-auth".to_string()),
server_name: "payments".to_string(),
request: McpServerElicitationRequest::Url {
meta: None,
message: "Review the payment details to continue.".to_string(),
url: "http://payments.example/checkout/123".to_string(),
elicitation_id: "payment-123".to_string(),
},
},
};
assert!(
app.interactive_request_for_thread_request(thread_id, &request)
.await
.expect("valid localized paths")
.is_none()
);
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::SubmitThreadOp {
thread_id: op_thread_id,
op: Op::ResolveElicitation {
server_name,
request_id: AppServerRequestId::Integer(10),
decision: codex_app_server_protocol::McpServerElicitationAction::Decline,
content: None,
meta: None,
},
}) if op_thread_id == thread_id && server_name == "payments"
);
}
#[tokio::test]
async fn inactive_thread_approval_badge_clears_after_turn_completion_notification() -> Result<()> {
let mut app = make_test_app().await;
let main_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread");
let agent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000202").expect("valid thread");
app.primary_thread_id = Some(main_thread_id);
app.active_thread_id = Some(main_thread_id);
app.thread_event_channels
.insert(main_thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.thread_event_channels.insert(
agent_thread_id,
ThreadEventChannel::new_with_session(
/*capacity*/ 4,
ThreadSessionState {
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::workspace_write(),
rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")),
..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent"))
},
Vec::new(),
),
);
app.agent_navigation.upsert(
agent_thread_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
/*is_closed*/ false,
);
app.enqueue_thread_request(
agent_thread_id,
exec_approval_request(
agent_thread_id,
"turn-approval",
"call-approval",
/*approval_id*/ None,
),
)
.await?;
assert_eq!(
app.chat_widget.pending_thread_approvals(),
&["Robie [explorer]".to_string()]
);
app.enqueue_thread_notification(
agent_thread_id,
turn_completed_notification(agent_thread_id, "turn-approval", TurnStatus::Completed),
)
.await?;
assert!(
app.chat_widget.pending_thread_approvals().is_empty(),
"turn completion should clear inactive-thread approval badge immediately"
);
Ok(())
}
#[tokio::test]
async fn inactive_thread_started_notification_initializes_replay_session() -> Result<()> {
let mut app = make_test_app().await;
let temp_dir = tempdir()?;
let main_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread");
let agent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000202").expect("valid thread");
let primary_cwd = test_path_buf("/tmp/main").abs();
let shared_root = test_path_buf("/tmp/shared").abs();
let primary_session = ThreadSessionState {
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::workspace_write(),
runtime_workspace_roots: vec![primary_cwd.clone(), shared_root.clone()],
..test_thread_session(main_thread_id, primary_cwd.to_path_buf())
};
app.primary_thread_id = Some(main_thread_id);
app.active_thread_id = Some(main_thread_id);
app.primary_session_configured = Some(primary_session.clone());
app.thread_event_channels.insert(
main_thread_id,
ThreadEventChannel::new_with_session(
/*capacity*/ 4,
primary_session.clone(),
Vec::new(),
),
);
let rollout_path = temp_dir.path().join("agent-rollout.jsonl");
let rollout = serde_json::json!({
"timestamp": "t0",
"type": "turn_context",
"payload": {
"cwd": test_path_buf("/tmp/agent"),
"model": "gpt-agent",
},
});
std::fs::write(
&rollout_path,
format!("{}\n", serde_json::to_string(&rollout)?),
)?;
app.enqueue_thread_notification(
agent_thread_id,
ServerNotification::ThreadStarted(ThreadStartedNotification {
thread: Thread {
environments: None,
id: agent_thread_id.to_string(),
extra: None,
session_id: agent_thread_id.to_string(),
forked_from_id: None,
parent_thread_id: None,
preview: "agent thread".to_string(),
ephemeral: false,
section: None,
section_entered_at: None,
project_id: None,
history_mode: Default::default(),
model_provider: "agent-provider".to_string(),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),
status: codex_app_server_protocol::ThreadStatus::Idle,
path: Some(rollout_path.clone()),
cwd: test_path_buf("/tmp/agent").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
git_info: None,
name: Some("agent thread".to_string()),
turns: Vec::new(),
},
}),
)
.await?;
let store = app
.thread_event_channels
.get(&agent_thread_id)
.expect("agent thread channel")
.store
.lock()
.await;
let session = store.session.clone().expect("inferred session");
drop(store);
assert_eq!(session.thread_id, agent_thread_id);
assert_eq!(session.thread_name, Some("agent thread".to_string()));
assert_eq!(session.model, "gpt-agent");
assert_eq!(session.model_provider_id, "agent-provider");
assert_eq!(session.approval_policy, primary_session.approval_policy);
assert_eq!(session.cwd.as_path(), test_path_buf("/tmp/agent").as_path());
assert_eq!(
session.runtime_workspace_roots,
vec![test_path_buf("/tmp/agent").abs(), shared_root]
);
assert_eq!(session.rollout_path, Some(rollout_path));
assert_eq!(
app.agent_navigation.get(&agent_thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
agent_path: None,
is_running: false,
is_closed: false,
})
);
Ok(())
}
#[tokio::test]
async fn inactive_thread_started_notification_preserves_primary_model_when_path_missing()
-> Result<()> {
let mut app = make_test_app().await;
let main_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000301").expect("valid thread");
let agent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000302").expect("valid thread");
let primary_cwd = test_path_buf("/tmp/main").abs();
let primary_session = ThreadSessionState {
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::workspace_write(),
runtime_workspace_roots: vec![primary_cwd.clone()],
..test_thread_session(main_thread_id, primary_cwd.to_path_buf())
};
app.primary_thread_id = Some(main_thread_id);
app.active_thread_id = Some(main_thread_id);
app.primary_session_configured = Some(primary_session.clone());
app.thread_event_channels.insert(
main_thread_id,
ThreadEventChannel::new_with_session(
/*capacity*/ 4,
primary_session.clone(),
Vec::new(),
),
);
app.enqueue_thread_notification(
agent_thread_id,
ServerNotification::ThreadStarted(ThreadStartedNotification {
thread: Thread {
environments: None,
id: agent_thread_id.to_string(),
extra: None,
session_id: agent_thread_id.to_string(),
forked_from_id: None,
parent_thread_id: None,
preview: "agent thread".to_string(),
ephemeral: false,
section: None,
section_entered_at: None,
project_id: None,
history_mode: Default::default(),
model_provider: "agent-provider".to_string(),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),
status: codex_app_server_protocol::ThreadStatus::Idle,
path: None,
cwd: test_path_buf("/tmp/agent").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
git_info: None,
name: Some("agent thread".to_string()),
turns: Vec::new(),
},
}),
)
.await?;
let store = app
.thread_event_channels
.get(&agent_thread_id)
.expect("agent thread channel")
.store
.lock()
.await;
let session = store.session.clone().expect("inferred session");
assert_eq!(session.model, primary_session.model);
Ok(())
}
/// `thread/read` is metadata/replay hydration and does not return a fresh
/// server-authored `PermissionProfile`, so it must not reuse the cached primary
/// session profile after swapping in the read thread's cwd.
#[tokio::test]
async fn thread_read_session_state_does_not_reuse_primary_permission_profile() {
let mut app = make_test_app().await;
let main_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000401").expect("valid thread");
let read_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000402").expect("valid thread");
let primary_cwd = test_path_buf("/tmp/main").abs();
let primary_session = ThreadSessionState {
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::workspace_write(),
runtime_workspace_roots: vec![primary_cwd.clone()],
..test_thread_session(main_thread_id, primary_cwd.to_path_buf())
};
app.primary_session_configured = Some(primary_session);
let thread = Thread {
environments: None,
id: read_thread_id.to_string(),
extra: None,
session_id: read_thread_id.to_string(),
forked_from_id: None,
parent_thread_id: None,
preview: "read thread".to_string(),
ephemeral: false,
section: None,
section_entered_at: None,
project_id: None,
history_mode: Default::default(),
model_provider: "read-provider".to_string(),
model: None,
reasoning_effort: None,
created_at: 1,
updated_at: 2,
recency_at: Some(2),
status: codex_app_server_protocol::ThreadStatus::Idle,
path: None,
cwd: test_path_buf("/tmp/read").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
name: Some("read thread".to_string()),
turns: Vec::new(),
};
let session = app
.session_state_for_thread_read(read_thread_id, &thread)
.await;
assert_eq!(session.thread_id, read_thread_id);
assert_eq!(session.cwd.as_path(), test_path_buf("/tmp/read").as_path());
assert_eq!(
session.runtime_workspace_roots,
vec![test_path_buf("/tmp/read").abs()]
);
let expected_permission_profile = app
.chat_widget
.config_ref()
.permissions
.permission_profile()
.clone();
assert_eq!(
session.permission_profile, expected_permission_profile,
"thread/read does not return fresh server permissions; the fallback profile must use the \
active widget permissions rather than reusing the cached primary session profile"
);
}
#[test]
fn agent_picker_item_name_snapshot() {
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
let snapshot = [
format!(
"{} | {}",
format_agent_picker_item_name(
Some("Robie"),
Some("explorer"),
/*is_primary*/ true
),
thread_id
),
format!(
"{} | {}",
format_agent_picker_item_name(
Some("Robie"),
Some("explorer"),
/*is_primary*/ false
),
thread_id
),
format!(
"{} | {}",
format_agent_picker_item_name(
Some("Robie"),
/*agent_role*/ None,
/*is_primary*/ false
),
thread_id
),
format!(
"{} | {}",
format_agent_picker_item_name(
/*agent_nickname*/ None,
Some("explorer"),
/*is_primary*/ false
),
thread_id
),
format!(
"{} | {}",
format_agent_picker_item_name(
/*agent_nickname*/ None, /*agent_role*/ None, /*is_primary*/ false
),
thread_id
),
]
.join("\n");
assert_app_snapshot!("agent_picker_item_name", snapshot);
}
#[tokio::test]
async fn side_fork_config_is_ephemeral_and_appends_developer_guardrails() {
let app = make_test_app().await;
let original_approval_policy = app.config.permissions.approval_policy.value();
let original_sandbox_policy = app.config.legacy_sandbox_policy();
let fork_config = app.side_fork_config();
assert!(fork_config.ephemeral);
assert_eq!(
fork_config.permissions.approval_policy.value(),
original_approval_policy
);
assert_eq!(fork_config.legacy_sandbox_policy(), original_sandbox_policy);
let developer_instructions = fork_config
.developer_instructions
.as_deref()
.expect("side developer instructions");
assert!(
developer_instructions.contains("You are in a side conversation, not the main thread.")
);
assert!(
developer_instructions
.contains("inherited fork history is provided only as reference context")
);
assert!(
developer_instructions.contains(
"Only instructions submitted after the side-conversation boundary are active"
)
);
assert!(developer_instructions.contains("Do not continue, execute, or complete any task"));
assert!(
developer_instructions
.contains("External tools may be available according to this thread's current")
);
assert!(
developer_instructions
.contains("Any MCP or external tool calls or outputs visible in the inherited")
);
assert!(developer_instructions.contains("non-mutating inspection"));
assert!(developer_instructions.contains("Do not modify files"));
assert!(developer_instructions.contains("Do not request escalated permissions"));
assert!(app.transcript_cells.is_empty());
}
#[tokio::test]
async fn side_fork_config_inherits_parent_thread_runtime_settings() {
let mut app = make_test_app().await;
app.config.model = Some("persisted-default-model".to_string());
app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Low);
let parent_service_tier = ServiceTier::Fast.request_value();
let parent_permission_profile = PermissionProfile::workspace_write();
app.chat_widget.set_model("parent-thread-model");
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
app.chat_widget
.set_service_tier(Some(parent_service_tier.to_string()));
app.chat_widget
.set_approval_policy(AskForApproval::OnRequest);
app.chat_widget
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy(
parent_permission_profile.clone(),
))
.expect("test permission profile should be accepted");
app.chat_widget
.set_approvals_reviewer(ApprovalsReviewer::AutoReview);
let fork_config = app.side_fork_config();
assert_eq!(
(
fork_config.model.as_deref(),
fork_config.model_reasoning_effort,
fork_config.service_tier.as_deref(),
fork_config.permissions.approval_policy.value(),
fork_config.permissions.permission_profile(),
fork_config.approvals_reviewer,
),
(
Some("parent-thread-model"),
Some(ReasoningEffortConfig::High),
Some(parent_service_tier),
AskForApproval::OnRequest.to_core(),
&parent_permission_profile,
ApprovalsReviewer::AutoReview,
)
);
}
#[tokio::test]
async fn side_start_block_message_allows_replacing_open_side_conversation() {
let mut app = make_test_app().await;
assert_eq!(
app.side_start_block_message(),
Some("'/side' is unavailable until the main thread is ready.")
);
app.primary_thread_id = Some(ThreadId::new());
assert_eq!(app.side_start_block_message(), None);
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.active_thread_id = Some(parent_thread_id);
assert_eq!(app.side_start_block_message(), None);
app.active_thread_id = Some(side_thread_id);
assert_eq!(
app.side_start_block_message(),
Some(
"A side conversation is already open. Press ctrl + c to return before starting another."
)
);
app.side_threads.remove(&side_thread_id);
assert_eq!(app.side_start_block_message(), None);
}
#[tokio::test]
async fn side_parent_status_tracks_parent_turn_lifecycle() -> Result<()> {
let mut app = make_test_app().await;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.primary_thread_id = Some(parent_thread_id);
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.enqueue_thread_notification(
parent_thread_id,
turn_completed_notification(parent_thread_id, "turn-1", TurnStatus::Completed),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
Some(SideParentStatus::Finished)
);
app.enqueue_thread_notification(
parent_thread_id,
turn_started_notification(parent_thread_id, "turn-2"),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
None
);
app.enqueue_thread_notification(
parent_thread_id,
turn_completed_notification(parent_thread_id, "turn-2", TurnStatus::Failed),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
Some(SideParentStatus::Failed)
);
Ok(())
}
#[tokio::test]
async fn side_parent_status_prioritizes_input_over_approval() -> Result<()> {
let mut app = make_test_app().await;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.primary_thread_id = Some(parent_thread_id);
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.enqueue_thread_request(
parent_thread_id,
exec_approval_request(
parent_thread_id,
"turn-approval",
"call-approval",
/*approval_id*/ None,
),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
Some(SideParentStatus::NeedsApproval)
);
app.enqueue_thread_request(
parent_thread_id,
request_user_input_request(parent_thread_id, "turn-input", "call-input"),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
Some(SideParentStatus::NeedsInput)
);
app.enqueue_thread_notification(
parent_thread_id,
ServerNotification::ServerRequestResolved(
codex_app_server_protocol::ServerRequestResolvedNotification {
thread_id: parent_thread_id.to_string(),
request_id: AppServerRequestId::Integer(2),
},
),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
Some(SideParentStatus::NeedsApproval)
);
app.enqueue_thread_notification(
parent_thread_id,
ServerNotification::ServerRequestResolved(
codex_app_server_protocol::ServerRequestResolvedNotification {
thread_id: parent_thread_id.to_string(),
request_id: AppServerRequestId::Integer(1),
},
),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
None
);
app.enqueue_thread_request(
parent_thread_id,
request_user_input_request(parent_thread_id, "turn-eviction", "input-eviction"),
)
.await?;
let chunk = "x".repeat(4 * 1024);
app.enqueue_thread_notification(
parent_thread_id,
agent_message_delta_notification(
parent_thread_id,
"turn-eviction",
"item-eviction",
&chunk,
),
)
.await?;
app.enqueue_thread_request(
parent_thread_id,
exec_approval_request(
parent_thread_id,
"turn-eviction",
"approval-eviction",
/*approval_id*/ None,
),
)
.await?;
let side_footer = |app: &App| {
render_bottom_popup(&app.chat_widget, /*width*/ 120)
.lines()
.find_map(|line| {
line.find("Side from main thread")
.map(|start| line[start..].trim().to_string())
})
.expect("side conversation footer should be rendered")
};
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
Some(SideParentStatus::NeedsInput)
);
let input_footer = side_footer(&app);
for _ in 0..64 {
app.enqueue_thread_notification(
parent_thread_id,
agent_message_delta_notification(
parent_thread_id,
"turn-eviction",
"item-eviction",
&chunk,
),
)
.await?;
}
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
Some(SideParentStatus::NeedsApproval)
);
let approval_footer = side_footer(&app);
assert!(
app.thread_event_channels
.get(&parent_thread_id)
.expect("parent thread channel should exist")
.store
.lock()
.await
.has_pending_thread_approvals()
);
app.enqueue_thread_notification(
parent_thread_id,
agent_message_delta_notification(
parent_thread_id,
"turn-eviction",
"item-eviction",
&chunk,
),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
None
);
let cleared_footer = side_footer(&app);
assert!(
!app.thread_event_channels
.get(&parent_thread_id)
.expect("parent thread channel should exist")
.store
.lock()
.await
.has_pending_thread_approvals()
);
assert_snapshot!(
format!("{input_footer}\n{approval_footer}\n{cleared_footer}"),
@r"
Side from main thread · main needs input · ctrl + / to switch · ctrl + c to close
Side from main thread · main needs approval · ctrl + / to switch · ctrl + c to close
Side from main thread · ctrl + / to switch · ctrl + c to close
"
);
Ok(())
}
#[tokio::test]
async fn side_thread_snapshot_hides_forked_parent_transcript() {
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
let mut store = ThreadEventStore::new(/*capacity*/ 4);
let session = ThreadSessionState {
forked_from_id: Some(parent_thread_id),
fork_parent_title: None,
..test_thread_session(side_thread_id, test_path_buf("/tmp/side"))
};
let parent_turn = test_turn(
"parent-turn",
TurnStatus::Completed,
vec![ThreadItem::UserMessage {
id: "parent-user".to_string(),
client_id: None,
content: vec![AppServerUserInput::Text {
text: "parent prompt should stay hidden".to_string(),
text_elements: Vec::new(),
}],
}],
);
App::install_side_thread_snapshot(&mut store, session, vec![parent_turn]);
let stored_session = store.session.as_ref().expect("side session");
assert_eq!(stored_session.thread_id, side_thread_id);
assert_eq!(stored_session.forked_from_id, None);
assert_eq!(store.turns, Vec::<Turn>::new());
assert_eq!(store.active_turn_id(), None);
}
#[tokio::test]
async fn side_thread_snapshot_does_not_refresh_from_fork_history() {
let mut app = make_test_app().await;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
let snapshot = ThreadEventSnapshot {
session: Some(ThreadSessionState {
rollout_path: None,
..test_thread_session(side_thread_id, test_path_buf("/tmp/side"))
}),
turns: Vec::new(),
events: Vec::new(),
input_state: None,
};
assert!(!app.should_refresh_snapshot_session(
side_thread_id,
/*is_replay_only*/ false,
&snapshot
));
}
#[tokio::test]
async fn side_thread_snapshot_skips_session_header_preamble() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
while app_event_rx.try_recv().is_ok() {}
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.primary_thread_id = Some(parent_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
let snapshot = ThreadEventSnapshot {
session: Some(ThreadSessionState {
forked_from_id: Some(parent_thread_id),
fork_parent_title: None,
..test_thread_session(side_thread_id, test_path_buf("/tmp/side"))
}),
turns: Vec::new(),
events: Vec::new(),
input_state: None,
};
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);
let mut rendered_cells = Vec::new();
while let Ok(event) = app_event_rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
rendered_cells.push(lines_to_single_string(&cell.display_lines(/*width*/ 120)));
}
}
assert_eq!(app.chat_widget.thread_id(), Some(side_thread_id));
assert_eq!(rendered_cells, Vec::<String>::new());
assert_eq!(
app.chat_widget.active_cell_transcript_lines(/*width*/ 120),
None
);
}
#[tokio::test]
async fn primary_thread_ignores_child_mcp_startup_notifications() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
while app_event_rx.try_recv().is_ok() {}
let sentry_config = toml::from_str::<toml::Value>("command = 'true'")
.expect("test MCP config should parse")
.try_into()
.expect("test MCP config should deserialize");
app.config
.mcp_servers
.set(std::collections::HashMap::from([(
"sentry".to_string(),
sentry_config,
)]))
.expect("test MCP servers should accept any configuration");
let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let parent_thread_id = ThreadId::new();
let child_thread_id = ThreadId::new();
app.primary_thread_id = Some(parent_thread_id);
app.active_thread_id = Some(parent_thread_id);
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
thread_id: Some(child_thread_id.to_string()),
name: "sentry".to_string(),
status: McpServerStartupState::Failed,
error: Some("sentry is not logged in".to_string()),
failure_reason: None,
}),
)),
)
.await;
assert!(app_event_rx.try_recv().is_err());
let mut child_snapshot = app
.thread_event_channels
.get(&child_thread_id)
.expect("child thread channel should be created")
.store
.lock()
.await
.snapshot();
assert!(
matches!(
child_snapshot.events.as_slice(),
[ThreadBufferedEvent::Notification(notification)]
if matches!(
notification.as_ref(),
ServerNotification::McpServerStatusUpdated(_)
)
),
"child MCP startup notification should be buffered for the child thread"
);
app.apply_refreshed_snapshot_thread(
child_thread_id,
AppServerStartedThread {
session: test_thread_session(child_thread_id, test_path_buf("/tmp/child")),
turns: Vec::new(),
blocks_direct_input: false,
task_tools_available: false,
},
&mut child_snapshot,
)
.await;
app.replay_thread_snapshot(child_snapshot, /*resume_restored_queue*/ false);
let mut rendered_cells = Vec::new();
while let Ok(event) = app_event_rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
rendered_cells.push(lines_to_single_string(&cell.display_lines(/*width*/ 120)));
}
}
let rendered = rendered_cells.join("\n");
assert_eq!(app.chat_widget.thread_id(), Some(child_thread_id));
assert_eq!(rendered.matches("sentry is not logged in").count(), 1);
assert_eq!(
rendered
.matches("MCP startup incomplete (failed: sentry)")
.count(),
1
);
}
#[tokio::test]
async fn app_scoped_mcp_startup_notifications_do_not_render_in_active_thread() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
while app_event_rx.try_recv().is_ok() {}
let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.primary_thread_id = Some(thread_id);
app.active_thread_id = Some(thread_id);
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
thread_id: None,
name: "sentry".to_string(),
status: McpServerStartupState::Failed,
error: Some("sentry is not logged in".to_string()),
failure_reason: None,
}),
)),
)
.await;
assert!(app_event_rx.try_recv().is_err());
assert_eq!(
app.chat_widget.active_cell_transcript_lines(/*width*/ 120),
None
);
}
#[tokio::test]
async fn active_side_thread_renders_live_mcp_startup_notifications() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
while app_event_rx.try_recv().is_ok() {}
let sentry_config = toml::from_str::<toml::Value>("command = 'true'")
.expect("test MCP config should parse")
.try_into()
.expect("test MCP config should deserialize");
app.config
.mcp_servers
.set(std::collections::HashMap::from([(
"sentry".to_string(),
sentry_config,
)]))
.expect("test MCP servers should accept any configuration");
let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.primary_thread_id = Some(parent_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.ensure_thread_channel(side_thread_id);
app.activate_thread_channel(side_thread_id).await;
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: Some(test_thread_session(
side_thread_id,
test_path_buf("/tmp/side"),
)),
turns: Vec::new(),
events: Vec::new(),
input_state: None,
},
/*resume_restored_queue*/ false,
);
app.sync_side_thread_ui();
for status in [
McpServerStartupState::Starting,
McpServerStartupState::Failed,
] {
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
thread_id: Some(side_thread_id.to_string()),
name: "sentry".to_string(),
status,
error: matches!(status, McpServerStartupState::Failed)
.then(|| "sentry is not logged in".to_string()),
failure_reason: None,
}),
)),
)
.await;
}
let mut active_thread_events = Vec::new();
let active_thread_rx = app
.active_thread_rx
.as_mut()
.expect("side thread receiver should be active");
while let Ok(event) = active_thread_rx.try_recv() {
active_thread_events.push(event);
}
for event in active_thread_events {
app.handle_thread_event_now(event);
}
let mut rendered_cells = Vec::new();
while let Ok(event) = app_event_rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
rendered_cells.push(lines_to_single_string(&cell.display_lines(/*width*/ 120)));
}
}
let rendered = rendered_cells.join("\n");
assert!(app.chat_widget.side_conversation_active());
assert_eq!(rendered.matches("sentry is not logged in").count(), 1);
assert_eq!(
rendered
.matches("MCP startup incomplete (failed: sentry)")
.count(),
1
);
}
#[tokio::test]
async fn side_restore_user_message_puts_inline_question_back_in_composer() {
let mut app = make_test_app().await;
let user_message = crate::chatwidget::UserMessage::from("side question");
app.restore_side_user_message(Some(user_message));
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"side question"
);
}
#[tokio::test]
async fn side_discard_selection_keeps_current_side_thread() {
let mut app = make_test_app().await;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
assert_eq!(
app.side_thread_to_discard_after_switch(side_thread_id),
None
);
assert_eq!(
app.side_thread_to_discard_after_switch(parent_thread_id),
Some(side_thread_id)
);
app.active_thread_id = Some(parent_thread_id);
assert_eq!(
app.side_thread_to_discard_after_switch(ThreadId::new()),
Some(side_thread_id)
);
assert_eq!(
app.side_thread_to_discard_after_switch(side_thread_id),
None
);
}
#[tokio::test]
async fn discard_side_thread_removes_agent_navigation_entry() -> Result<()> {
Box::pin(async {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let mut side_config = app.chat_widget.config_ref().clone();
side_config.ephemeral = true;
let started = app_server.start_thread(&side_config).await?;
let side_thread_id = started.session.thread_id;
app.side_threads
.insert(side_thread_id, SideThreadState::new(ThreadId::new()));
app.agent_navigation.upsert(
side_thread_id,
Some("Side".to_string()),
Some("side".to_string()),
/*is_closed*/ false,
);
assert!(
app.discard_side_thread(&mut app_server, side_thread_id)
.await
);
assert_eq!(app.agent_navigation.get(&side_thread_id), None);
assert!(!app.side_threads.contains_key(&side_thread_id));
Ok(())
})
.await
}
#[tokio::test]
async fn discard_side_thread_keeps_local_state_when_server_close_fails() -> Result<()> {
Box::pin(async {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.agent_navigation.upsert(
side_thread_id,
Some("Side".to_string()),
Some("side".to_string()),
/*is_closed*/ false,
);
assert!(
!app.discard_side_thread(&mut app_server, side_thread_id)
.await
);
assert_eq!(app.active_thread_id, Some(side_thread_id));
assert_eq!(
app.side_threads
.get(&side_thread_id)
.map(|state| state.parent_thread_id),
Some(parent_thread_id)
);
assert!(app.agent_navigation.get(&side_thread_id).is_some());
Ok(())
})
.await
}
#[tokio::test]
async fn background_side_cleanup_removes_local_state_and_ignores_late_events() -> Result<()> {
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.active_thread_id = Some(parent_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.thread_event_channels
.insert(side_thread_id, ThreadEventChannel::new(/*capacity*/ 4));
app.agent_navigation.upsert(
side_thread_id,
Some("Side".to_string()),
Some("side".to_string()),
/*is_closed*/ false,
);
app.dynamic_tool_tasks.insert(
AppServerRequestId::Integer(123),
(
side_thread_id.to_string(),
tokio::spawn(std::future::pending::<()>()),
),
);
app.discard_side_thread_in_background(&mut app_server, side_thread_id)
.await;
assert_matches!(
events.try_recv(),
Ok(AppEvent::DynamicToolCallCompleted { response, .. }) if !response.success
);
assert!(app.dynamic_tool_tasks.is_empty());
assert_eq!(app.active_thread_id, Some(parent_thread_id));
assert!(!app.side_threads.contains_key(&side_thread_id));
assert!(!app.thread_event_channels.contains_key(&side_thread_id));
assert_eq!(app.agent_navigation.get(&side_thread_id), None);
assert!(app.abandoned_side_threads.contains(&side_thread_id));
app.enqueue_thread_notification(
side_thread_id,
agent_message_delta_notification(side_thread_id, "turn-1", "item-1", "late"),
)
.await?;
assert!(!app.thread_event_channels.contains_key(&side_thread_id));
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerRequest(Box::new(exec_approval_request(
side_thread_id,
"turn-1",
"item-1",
Some("approval-1"),
))),
)
.await;
let resolution = app
.pending_app_server_requests
.take_resolution(
&side_thread_id.to_string(),
Op::ExecApproval {
id: "approval-1".to_string(),
turn_id: None,
decision: codex_app_server_protocol::CommandExecutionApprovalDecision::Accept,
},
)
.expect("approval resolution should serialize");
assert_eq!(resolution, None);
Ok(())
}
#[tokio::test]
async fn discard_closed_side_thread_removes_local_state_without_server_rpc() {
let mut app = make_test_app().await;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.thread_event_channels
.insert(side_thread_id, ThreadEventChannel::new(/*capacity*/ 4));
app.agent_navigation.upsert(
side_thread_id,
Some("Side".to_string()),
Some("side".to_string()),
/*is_closed*/ false,
);
app.discard_closed_side_thread(side_thread_id).await;
assert_eq!(app.active_thread_id, None);
assert!(!app.side_threads.contains_key(&side_thread_id));
assert!(!app.thread_event_channels.contains_key(&side_thread_id));
assert_eq!(app.agent_navigation.get(&side_thread_id), None);
}
#[tokio::test]
async fn active_non_primary_shutdown_target_returns_none_for_non_shutdown_event() -> Result<()> {
let mut app = make_test_app().await;
app.active_thread_id = Some(ThreadId::new());
app.primary_thread_id = Some(ThreadId::new());
assert_eq!(
app.active_non_primary_shutdown_target(&ServerNotification::SkillsChanged(
codex_app_server_protocol::SkillsChangedNotification {},
)),
None
);
Ok(())
}
#[tokio::test]
async fn active_non_primary_shutdown_target_returns_none_for_primary_thread_shutdown() -> Result<()>
{
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
app.primary_thread_id = Some(thread_id);
assert_eq!(
app.active_non_primary_shutdown_target(&thread_closed_notification(thread_id)),
None
);
Ok(())
}
#[tokio::test]
async fn active_non_primary_shutdown_target_returns_ids_for_non_primary_shutdown() -> Result<()> {
let mut app = make_test_app().await;
let active_thread_id = ThreadId::new();
let primary_thread_id = ThreadId::new();
app.active_thread_id = Some(active_thread_id);
app.primary_thread_id = Some(primary_thread_id);
assert_eq!(
app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)),
Some((active_thread_id, primary_thread_id))
);
Ok(())
}
#[tokio::test]
async fn active_non_primary_shutdown_target_returns_none_when_shutdown_exit_is_pending()
-> Result<()> {
let mut app = make_test_app().await;
let active_thread_id = ThreadId::new();
let primary_thread_id = ThreadId::new();
app.active_thread_id = Some(active_thread_id);
app.primary_thread_id = Some(primary_thread_id);
app.pending_shutdown_exit_thread_id = Some(active_thread_id);
assert_eq!(
app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)),
None
);
Ok(())
}
#[tokio::test]
async fn active_non_primary_shutdown_target_still_switches_for_other_pending_exit_thread()
-> Result<()> {
let mut app = make_test_app().await;
let active_thread_id = ThreadId::new();
let primary_thread_id = ThreadId::new();
app.active_thread_id = Some(active_thread_id);
app.primary_thread_id = Some(primary_thread_id);
app.pending_shutdown_exit_thread_id = Some(ThreadId::new());
assert_eq!(
app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)),
Some((active_thread_id, primary_thread_id))
);
Ok(())
}
async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String {
let mut app = make_test_app().await;
app.config.cwd = test_path_buf("/tmp/project").abs();
app.chat_widget.set_model("gpt-test");
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
let story_part_one = "In the cliffside town of Bracken Ferry, the lighthouse had been dark for \
nineteen years, and the children were told it was because the sea no longer wanted a \
guide. Mara, who repaired clocks for a living, found that hard to believe. Every dawn she \
heard the gulls circling the empty tower, and every dusk she watched ships hesitate at the \
mouth of the bay as if listening for a signal that never came. When an old brass key fell \
out of a cracked parcel in her workshop, tagged only with the words 'for the lamp room,' \
she decided to climb the hill and see what the town had forgotten.";
let story_part_two = "Inside the lighthouse she found gears wrapped in oilcloth, logbooks filled \
with weather notes, and a lens shrouded beneath salt-stiff canvas. The mechanism was not \
broken, only unfinished. Someone had removed the governor spring and hidden it in a false \
drawer, along with a letter from the last keeper admitting he had darkened the light on \
purpose after smugglers threatened his family. Mara spent the night rebuilding the clockwork \
from spare watch parts, her fingers blackened with soot and grease, while a storm gathered \
over the water and the harbor bells began to ring.";
let story_part_three = "At midnight the first squall hit, and the fishing boats returned early, \
blind in sheets of rain. Mara wound the mechanism, set the teeth by hand, and watched the \
great lens begin to turn in slow, certain arcs. The beam swept across the bay, caught the \
whitecaps, and reached the boats just as they were drifting toward the rocks below the \
eastern cliffs. In the morning the town square was crowded with wet sailors, angry elders, \
and wide-eyed children, but when the oldest captain placed the keeper's log on the fountain \
and thanked Mara for relighting the coast, nobody argued. By sunset, Bracken Ferry had a \
lighthouse again, and Mara had more clocks to mend than ever because everyone wanted \
something in town to keep better time.";
let user_cell = |text: &str| -> Arc<dyn HistoryCell> {
Arc::new(UserHistoryCell {
message: text.to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>
};
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
Arc::new(AgentMessageCell::new(
vec![Line::from(text.to_string())],
/*is_first_line*/ true,
)) as Arc<dyn HistoryCell>
};
let make_header = |is_first| -> Arc<dyn HistoryCell> {
let session = ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/tmp/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::High),
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
Arc::new(new_session_info(
app.chat_widget.config_ref(),
&app.local_settings,
app.chat_widget.current_model(),
&session,
is_first,
/*tooltip_override*/ None,
/*auth_plan*/ None,
/*show_fast_status*/ false,
)) as Arc<dyn HistoryCell>
};
app.transcript_cells = vec![
make_header(true),
Arc::new(crate::history_cell::new_info_event(
"startup tip that used to replay".to_string(),
/*hint*/ None,
)) as Arc<dyn HistoryCell>,
user_cell("Tell me a long story about a town with a dark lighthouse."),
agent_cell(story_part_one),
user_cell("Continue the story and reveal why the light went out."),
agent_cell(story_part_two),
user_cell("Finish the story with a storm and a resolution."),
agent_cell(story_part_three),
];
app.has_emitted_history_lines = true;
let rendered = app
.clear_ui_header_lines_with_version(/*width*/ 80, "<VERSION>")
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
!rendered.contains("startup tip that used to replay"),
"clear header should not replay startup notices"
);
assert!(
!rendered.contains("Bracken Ferry"),
"clear header should not replay prior conversation turns"
);
rendered
}
#[tokio::test]
#[cfg_attr(
target_os = "windows",
ignore = "snapshot path rendering differs on Windows"
)]
async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() {
let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await;
assert_app_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered);
}
#[tokio::test]
#[cfg_attr(
target_os = "windows",
ignore = "snapshot path rendering differs on Windows"
)]
async fn ctrl_l_clear_ui_after_long_transcript_reuses_clear_header_snapshot() {
let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await;
assert_app_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered);
}
#[tokio::test]
#[cfg_attr(
target_os = "windows",
ignore = "snapshot path rendering differs on Windows"
)]
async fn clear_ui_header_shows_fast_status_for_fast_capable_models() {
let mut app = make_test_app().await;
app.config.cwd = test_path_buf("/tmp/project").abs();
app.chat_widget.set_model("gpt-5.4");
set_fast_mode_test_catalog(&mut app.chat_widget);
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
app.chat_widget.set_service_tier(Some(
codex_protocol::config_types::ServiceTier::Fast
.request_value()
.to_string(),
));
set_chatgpt_auth(&mut app.chat_widget);
set_fast_mode_test_catalog(&mut app.chat_widget);
let rendered = app
.clear_ui_header_lines_with_version(/*width*/ 80, "<VERSION>")
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert_app_snapshot!("clear_ui_header_fast_status_fast_capable_models", rendered);
}
async fn make_test_app() -> App {
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
let model = get_model_offline_for_tests(config.model.as_deref());
let session_telemetry = test_session_telemetry(&config, model.as_str());
App {
model_catalog: chat_widget.model_catalog(),
session_telemetry,
app_event_tx,
chat_widget,
workspace_command_runner: None,
launch_cwd: config.cwd.to_path_buf(),
runtime_working_directory_override: None,
local_settings: crate::local_settings::LocalSettings::from(&config),
config,
state_db: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
cloud_config_bundle: CloudConfigBundleLoader::default(),
runtime_approval_policy_override: None,
runtime_permission_profile_override: None,
file_search,
transcript_cells: Vec::new(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
initial_history_replay_buffer: None,
scrollback_has_older_history: false,
enhanced_keys_supported: false,
keymap: crate::keymap::RuntimeKeymap::defaults(),
key_chord_matcher: crate::keymap::KeyChordMatcher::default(),
commit_animation: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
skill_load_warnings: SkillLoadWarningState::default(),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
app_server_target: crate::AppServerTarget::Embedded,
reconnect: Default::default(),
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
temporary_structured_requests: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
agents_overview: Default::default(),
side_threads: HashMap::new(),
abandoned_side_threads: HashSet::new(),
active_thread_id: None,
active_thread_rx: None,
primary_thread_id: None,
last_subagent_backfill_attempt: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
dynamic_tool_status_updates: tokio::sync::broadcast::channel(/*capacity*/ 64).0,
dynamic_tool_tasks: HashMap::new(),
pending_startup_thread_start: false,
startup_protected_input_boundary: false,
startup_pending_protected_request: false,
rate_limit_hard_stop_generation: 0,
rate_limit_refresh_state: Default::default(),
pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(),
recap: recap::RecapState::default(),
}
}
pub(super) async fn make_test_app_with_channels() -> (
App,
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
tokio::sync::mpsc::UnboundedReceiver<Op>,
) {
let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
let model = get_model_offline_for_tests(config.model.as_deref());
let session_telemetry = test_session_telemetry(&config, model.as_str());
(
App {
model_catalog: chat_widget.model_catalog(),
session_telemetry,
app_event_tx,
chat_widget,
workspace_command_runner: None,
launch_cwd: config.cwd.to_path_buf(),
runtime_working_directory_override: None,
local_settings: crate::local_settings::LocalSettings::from(&config),
config,
state_db: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
cloud_config_bundle: CloudConfigBundleLoader::default(),
runtime_approval_policy_override: None,
runtime_permission_profile_override: None,
file_search,
transcript_cells: Vec::new(),
last_rendered_history_tail: None,
last_thread_usage_status_cell: None,
pending_thread_usage_history_refresh: false,
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
initial_history_replay_buffer: None,
scrollback_has_older_history: false,
enhanced_keys_supported: false,
keymap: crate::keymap::RuntimeKeymap::defaults(),
key_chord_matcher: crate::keymap::KeyChordMatcher::default(),
commit_animation: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
skill_load_warnings: SkillLoadWarningState::default(),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
app_server_target: crate::AppServerTarget::Embedded,
reconnect: Default::default(),
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
temporary_structured_requests: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
agents_overview: Default::default(),
side_threads: HashMap::new(),
abandoned_side_threads: HashSet::new(),
active_thread_id: None,
active_thread_rx: None,
primary_thread_id: None,
last_subagent_backfill_attempt: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
dynamic_tool_status_updates: tokio::sync::broadcast::channel(/*capacity*/ 64).0,
dynamic_tool_tasks: HashMap::new(),
pending_startup_thread_start: false,
startup_protected_input_boundary: false,
startup_pending_protected_request: false,
rate_limit_hard_stop_generation: 0,
rate_limit_refresh_state: Default::default(),
pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(),
recap: recap::RecapState::default(),
},
rx,
op_rx,
)
}
#[tokio::test]
async fn set_thread_goal_draft_materializes_long_objective_and_confirms_before_paste() -> Result<()>
{
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await?;
let thread_id = started.session.thread_id;
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1);
app.set_thread_goal_draft(
&mut app_server,
thread_id,
crate::goal_files::GoalDraft {
objective: objective.clone(),
..Default::default()
},
crate::app_event::ThreadGoalSetMode::ConfirmIfExists,
)
.await;
let response = app_server.thread_goal_get(thread_id).await?;
let goal = response.goal.expect("goal should be set");
let saved_objective = goal.objective.clone();
let codex_home = app_server
.codex_home_path(&app.chat_widget.config_ref().codex_home)
.expect("codex home");
assert!(goal_files::objective_file_path(&goal.objective, Some(&codex_home)).is_some());
assert_eq!(
goal_files::objective_text_for_edit(&mut app_server, Some(&codex_home), &goal.objective)
.await
.expect("managed goal file should be readable"),
objective
);
let is_managed = |home: &AppServerPath, path: &str| {
let reference = goal_files::objective_file_reference(&AppServerPath::from_app_server(path))
.expect("goal objective reference");
goal_files::objective_file_path(&reference, Some(home)).is_some()
};
let suffix = "attachments/00000000-0000-4000-8000-000000000000/goal-objective.md";
for path in [
format!("/tmp/{suffix}"),
format!("{codex_home}/../other/{suffix}"),
format!("{codex_home}/other/{suffix}"),
] {
assert!(!is_managed(&codex_home, &path));
}
assert!(!is_managed(
&AppServerPath::from_app_server("/tmp/codex\\home"),
&format!("/tmp/codex/home/{suffix}")
));
let unix_path = AppServerPath::from_app_server("/tmp/codex\\").join("a");
assert_eq!(unix_path.as_str(), "/tmp/codex\\/a");
let attachments_dir = app.chat_widget.config_ref().codex_home.join("attachments");
let attachment_count = std::fs::read_dir(&attachments_dir)?.count();
let placeholder = "[Pasted Content 5 chars]";
let paste_draft = crate::goal_files::GoalDraft {
objective: format!("Use {placeholder}"),
text_elements: vec![TextElement::new(
(4..4 + placeholder.len()).into(),
Some(placeholder.to_string()),
)],
pending_pastes: vec![(placeholder.to_string(), "hello".to_string())],
..Default::default()
};
app.set_thread_goal_draft(
&mut app_server,
thread_id,
paste_draft.clone(),
crate::app_event::ThreadGoalSetMode::ConfirmIfExists,
)
.await;
assert_eq!(
std::fs::read_dir(&attachments_dir)?.count(),
attachment_count
);
assert_eq!(
app_server
.thread_goal_get(thread_id)
.await?
.goal
.expect("goal should still be set")
.objective,
saved_objective
);
app.set_thread_goal_draft(
&mut app_server,
thread_id,
paste_draft,
crate::app_event::ThreadGoalSetMode::ReplaceExisting,
)
.await;
let goal = app_server
.thread_goal_get(thread_id)
.await?
.goal
.expect("replacement goal should be set");
let paste_path = goal
.objective
.strip_prefix("Use pasted text file: ")
.and_then(|text| text.strip_suffix(". Read this file before continuing."))
.expect("paste file reference");
assert_eq!(std::fs::read_to_string(paste_path)?, "hello");
let attachment_count = std::fs::read_dir(&attachments_dir)?.count();
let stale_paste = (placeholder.to_string(), "hello".to_string());
app.set_thread_goal_draft(
&mut app_server,
thread_id,
crate::goal_files::GoalDraft {
objective: "small goal".to_string(),
pending_pastes: vec![stale_paste],
..Default::default()
},
crate::app_event::ThreadGoalSetMode::ReplaceExisting,
)
.await;
assert_eq!(
std::fs::read_dir(&attachments_dir)?.count(),
attachment_count
);
let whitespace_placeholder = "[Pasted Content 3 chars]";
app.set_thread_goal_draft(
&mut app_server,
thread_id,
crate::goal_files::GoalDraft {
objective: whitespace_placeholder.to_string(),
text_elements: vec![TextElement::new(
(0..whitespace_placeholder.len()).into(),
Some(whitespace_placeholder.to_string()),
)],
pending_pastes: vec![(whitespace_placeholder.to_string(), " \n\t".to_string())],
..Default::default()
},
crate::app_event::ThreadGoalSetMode::ReplaceExisting,
)
.await;
assert_eq!(
std::fs::read_dir(&attachments_dir)?.count(),
attachment_count
);
assert_eq!(
app_server
.thread_goal_get(thread_id)
.await?
.goal
.expect("small goal should remain set")
.objective,
"small goal"
);
let image_dir = tempfile::tempdir()?;
let image_path = image_dir.path().join("local-image.png");
std::fs::write(&image_path, b"png bytes")?;
let image_placeholder = "[Image #3]";
app.set_thread_goal_draft(
&mut app_server,
thread_id,
crate::goal_files::GoalDraft {
objective: format!("Describe {image_placeholder}"),
text_elements: vec![TextElement::new(
(9..9 + image_placeholder.len()).into(),
Some(image_placeholder.to_string()),
)],
local_images: vec![crate::bottom_pane::LocalImageAttachment {
placeholder: image_placeholder.to_string(),
path: image_path,
}],
remote_image_urls: vec![
"https://example.com/first.png".to_string(),
"https://example.com/second.png".to_string(),
],
..Default::default()
},
crate::app_event::ThreadGoalSetMode::ReplaceExisting,
)
.await;
let objective = app_server
.thread_goal_get(thread_id)
.await?
.goal
.expect("image goal should be set")
.objective;
let copied_image = objective
.strip_prefix("Describe image file: ")
.and_then(|text| text.split_once("\n\n"))
.map(|(path, _)| path)
.expect("copied image path");
assert_eq!(std::fs::read(copied_image)?, b"png bytes");
assert!(objective.contains(
"Referenced image URLs:\n- [Image #1]: https://example.com/first.png\n- [Image #2]: https://example.com/second.png"
));
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn replace_goal_confirmation_snapshot() {
let mut app = make_test_app().await;
app.show_replace_thread_goal_confirmation(
ThreadId::new(),
goal_files::GoalDraft {
objective: "New goal".to_string(),
..Default::default()
},
);
assert_app_snapshot!(
"replace_goal_confirmation",
render_bottom_popup(&app.chat_widget, /*width*/ 80)
);
}
fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState {
ThreadSessionState {
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: cwd.abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}
}
fn plain_line_cell(text: impl Into<String>) -> Arc<dyn HistoryCell> {
Arc::new(PlainHistoryCell::new(vec![Line::from(text.into())])) as Arc<dyn HistoryCell>
}
fn rendered_line_text(line: &crate::terminal_hyperlinks::HyperlinkLine) -> String {
line.line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
#[tokio::test]
async fn capped_resize_reflow_renders_recent_suffix_only() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(5);
app.transcript_cells = (0..20)
.map(|i| plain_line_cell(format!("cell {i}")))
.collect();
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
assert_eq!(rendered.lines.len(), 5);
assert_eq!(
rendered
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>(),
vec![
"Earlier messages are available — press ctrl + t to view the full transcript"
.to_string(),
String::new(),
"cell 18".to_string(),
String::new(),
"cell 19".to_string(),
]
);
}
#[tokio::test]
async fn uncapped_resize_reflow_renders_all_cells_when_row_cap_absent() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(0);
app.transcript_cells = (0..20)
.map(|i| plain_line_cell(format!("cell {i}")))
.collect();
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
assert_eq!(rendered.lines.len(), 39);
assert_eq!(rendered_line_text(&rendered.lines[0]), "cell 0");
assert_eq!(rendered_line_text(&rendered.lines[38]), "cell 19");
}
#[tokio::test]
async fn resize_reflow_wraps_transcript_early_when_pet_is_enabled() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(0);
app.transcript_cells = vec![Arc::new(AgentMarkdownCell::new(
"alpha beta gamma delta epsilon zeta eta theta iota kappa lambda".to_string(),
Path::new("/tmp"),
))];
let without_pet = app.render_transcript_lines_for_reflow(/*width*/ 40);
app.chat_widget
.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported(
crate::pets::ImageProtocol::Kitty,
));
app.chat_widget
.install_test_ambient_pet_for_tests(/*animations_enabled*/ false);
let width = app.chat_widget.history_wrap_width(/*width*/ 40);
assert!(width < 40);
let with_pet = app.render_transcript_lines_for_reflow(width);
assert!(
with_pet.lines.len() > without_pet.lines.len(),
"expected pet-enabled transcript reflow to wrap earlier"
);
}
#[tokio::test]
async fn copy_picker_opening_preserves_terminal_scrollback_without_reflow() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let response = "Existing response\n\n```rust\nkeep_scrollback();\n```";
app.chat_widget.handle_server_notification(
ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification {
thread_id: String::new(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
item: serde_json::from_value(serde_json::json!({
"type": "agentMessage",
"id": "message-1",
"text": response,
}))
.expect("valid completed agent message"),
}),
/*replay_kind*/ None,
);
while app_event_rx.try_recv().is_ok() {}
app.transcript_cells = vec![
plain_line_cell("Older terminal scrollback"),
Arc::new(AgentMarkdownCell::new(
response.to_string(),
Path::new("/tmp"),
)),
];
app.deferred_history_lines = vec![Line::from("Buffered scrollback").into()];
app.has_emitted_history_lines = true;
app.scrollback_has_older_history = true;
app.transcript_reflow.note_width(/*width*/ 80);
let before = app
.render_transcript_lines_for_reflow(/*width*/ 80)
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>();
app.chat_widget.apply_external_edit("/copy".to_string());
assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Whole response"));
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Whole response"));
assert_eq!(
app.render_transcript_lines_for_reflow(/*width*/ 80)
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>(),
before
);
assert_eq!(app.transcript_cells.len(), 2);
assert_eq!(app.deferred_history_lines.len(), 1);
assert!(app.has_emitted_history_lines);
assert!(app.scrollback_has_older_history);
assert!(!app.transcript_reflow.has_pending_reflow());
while let Ok(event) = app_event_rx.try_recv() {
assert!(
!matches!(
event,
AppEvent::ClearUi { .. }
| AppEvent::ClearUiAndSubmitUserMessage { .. }
| AppEvent::InsertHistoryCell(_)
| AppEvent::ConsolidateAgentMessage { .. }
),
"opening a picker must not modify terminal scrollback: {event:?}"
);
}
}
#[tokio::test]
async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(100);
app.transcript_cells = (0..3)
.map(|i| plain_line_cell(format!("cell {i}")))
.collect();
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
assert_eq!(
rendered
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>(),
vec![
"cell 0".to_string(),
String::new(),
"cell 1".to_string(),
String::new(),
"cell 2".to_string(),
]
);
}
#[tokio::test]
async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(3);
app.begin_initial_history_replay_buffer();
for index in 0..5 {
App::buffer_initial_history_replay_display_lines(
app.initial_history_replay_buffer
.as_mut()
.expect("initial replay buffer active"),
vec![Line::from(format!("line {index}")).into()],
/*max_rows*/ 3,
);
}
let buffer = app
.initial_history_replay_buffer
.as_ref()
.expect("initial replay buffer should remain active");
assert_eq!(
buffer
.retained_lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>(),
vec![
"line 2".to_string(),
"line 3".to_string(),
"line 4".to_string(),
]
);
}
#[tokio::test]
async fn required_stream_reflow_during_capped_initial_replay_uses_transcript_tail() -> Result<()> {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(20);
app.transcript_cells = vec![
plain_line_cell("latest user question"),
Arc::new(AgentMarkdownCell::new(
"Final answer:\n\n| Pattern | Outcome |\n| --- | --- |\n| Table tail | Preserved |"
.to_string(),
Path::new("/tmp"),
)),
];
app.begin_initial_history_replay_buffer();
App::buffer_initial_history_replay_display_lines(
app.initial_history_replay_buffer
.as_mut()
.expect("initial replay buffer active"),
vec![Line::from("latest user question").into()],
/*max_rows*/ 20,
);
let mut tui = crate::tui::test_support::make_test_tui()?;
app.finish_required_stream_reflow(&mut tui)?;
let buffer = app
.initial_history_replay_buffer
.as_ref()
.expect("initial replay buffer should remain active");
assert_eq!(
(
buffer.retained_lines.len(),
buffer.render_from_transcript_tail
),
(0, true),
);
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
assert_snapshot!(
"required_stream_reflow_during_capped_initial_replay",
rendered
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>()
.join("\n")
);
app.finish_initial_history_replay_buffer(&mut tui);
assert!(app.initial_history_replay_buffer.is_none());
assert!(app.transcript_reflow.has_pending_reflow());
Ok(())
}
#[tokio::test]
async fn directive_only_completion_removes_streamed_directive() -> Result<()> {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(20);
app.begin_initial_history_replay_buffer();
app.transcript_cells = vec![
plain_line_cell("before directive"),
Arc::new(AgentMessageCell::new(
vec![Line::from(r#"::git-stage{cwd="/tmp"}"#)],
/*is_first_line*/ true,
)),
];
let mut tui = crate::tui::test_support::make_test_tui()?;
app.handle_consolidate_agent_message(
&mut tui,
String::new(),
PathBuf::from("/tmp"),
/*inline_visualization_context*/ None,
ConsolidationScrollbackReflow::Required,
/*deferred_history_cell*/ None,
)?;
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
assert_snapshot!(
"directive_only_completion_removes_streamed_directive",
rendered
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>()
.join("\n")
);
Ok(())
}
#[tokio::test]
async fn required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay()
-> Result<()> {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(7);
app.transcript_cells = vec![
plain_line_cell("latest user question"),
Arc::new(AgentMessageCell::new(
vec![Line::from("stale streamed table tail")],
/*is_first_line*/ true,
)),
];
app.begin_initial_history_replay_buffer();
App::buffer_initial_history_replay_display_lines(
app.initial_history_replay_buffer
.as_mut()
.expect("initial replay buffer active"),
vec![Line::from("stale streamed table tail").into()],
/*max_rows*/ 7,
);
let mut tui = crate::tui::test_support::make_test_tui()?;
app.handle_consolidate_agent_message(
&mut tui,
"Final answer:\n\n| Pattern | Outcome |\n| --- | --- |\n| Table tail | Preserved |"
.to_string(),
PathBuf::from("/tmp"),
/*inline_visualization_context*/ None,
ConsolidationScrollbackReflow::Required,
/*deferred_history_cell*/ None,
)?;
app.open_transcript_overlay(&mut tui);
assert!(tui.is_alt_screen_active());
app.finish_initial_history_replay_buffer(&mut tui);
assert!(app.initial_history_replay_buffer.is_none());
assert!(app.transcript_reflow.has_pending_reflow());
let screen_size = tui.terminal.last_known_screen_size;
app.maybe_run_resize_reflow(&mut tui, screen_size)?;
assert!(app.transcript_reflow.has_pending_reflow());
app.close_transcript_overlay(&mut tui);
assert!(!tui.is_alt_screen_active());
assert!(app.transcript_reflow.has_pending_reflow());
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
assert_eq!(rendered.lines.len(), 7);
assert_snapshot!(
"required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay",
rendered
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>()
.join("\n")
);
Ok(())
}
#[tokio::test]
async fn thread_switch_replay_buffer_uses_transcript_tail_mode_when_row_cap_present() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(3);
app.begin_thread_switch_history_replay_buffer();
let buffer = app
.initial_history_replay_buffer
.as_ref()
.expect("thread switch replay buffer should be active");
assert!(buffer.render_from_transcript_tail);
assert!(buffer.retained_lines.is_empty());
}
#[tokio::test]
async fn thread_switch_replay_buffer_is_disabled_without_row_cap() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(0);
app.begin_thread_switch_history_replay_buffer();
assert!(app.initial_history_replay_buffer.is_none());
}
#[tokio::test]
async fn height_shrink_schedules_resize_reflow() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
let frame_requester = crate::tui::FrameRequester::test_dummy();
assert!(!app.handle_draw_size_change(
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
&frame_requester,
));
assert!(app.handle_draw_size_change(
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 24),
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
&frame_requester,
));
assert!(app.transcript_reflow.has_pending_reflow());
}
#[tokio::test]
async fn resizing_empty_transcript_schedules_settled_size_recheck() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
let mut tui = crate::tui::test_support::make_test_tui().expect("test tui");
let frame_requester = crate::tui::FrameRequester::test_dummy();
let initial_size = ratatui::layout::Size::new(/*width*/ 80, /*height*/ 24);
let resized_size = ratatui::layout::Size::new(/*width*/ 100, /*height*/ 24);
assert!(!app.handle_draw_size_change(initial_size, initial_size, &frame_requester));
tui.screen_size_for_event(&TuiEvent::Resize(resized_size))
.expect("resolve resize event");
tui.terminal.resize(resized_size).expect("apply event size");
assert!(app.handle_draw_size_change(resized_size, initial_size, &frame_requester));
tokio::time::sleep(crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE).await;
assert_eq!(
tui.screen_size_for_event(&TuiEvent::Draw)
.expect("resolve settled size"),
initial_size
);
}
fn test_turn(turn_id: &str, status: TurnStatus, items: Vec<ThreadItem>) -> Turn {
Turn {
id: turn_id.to_string(),
items_view: codex_app_server_protocol::TurnItemsView::Full,
items,
status,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
}
}
fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification {
ServerNotification::TurnStarted(TurnStartedNotification {
thread_id: thread_id.to_string(),
turn: Turn {
started_at: Some(0),
..test_turn(turn_id, TurnStatus::InProgress, Vec::new())
},
})
}
fn turn_completed_notification(
thread_id: ThreadId,
turn_id: &str,
status: TurnStatus,
) -> ServerNotification {
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: thread_id.to_string(),
turn: Turn {
completed_at: Some(0),
duration_ms: Some(1),
..test_turn(turn_id, status, Vec::new())
},
})
}
fn thread_closed_notification(thread_id: ThreadId) -> ServerNotification {
ServerNotification::ThreadClosed(ThreadClosedNotification {
thread_id: thread_id.to_string(),
})
}
fn token_usage_notification(
thread_id: ThreadId,
turn_id: &str,
model_context_window: Option<i64>,
) -> ServerNotification {
ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
token_usage: ThreadTokenUsage {
total: TokenUsageBreakdown {
total_tokens: 10,
input_tokens: 4,
cached_input_tokens: 1,
cache_write_input_tokens: 0,
output_tokens: 5,
reasoning_output_tokens: 0,
},
last: TokenUsageBreakdown {
total_tokens: 10,
input_tokens: 4,
cached_input_tokens: 1,
cache_write_input_tokens: 0,
output_tokens: 5,
reasoning_output_tokens: 0,
},
model_context_window,
},
})
}
fn agent_message_delta_notification(
thread_id: ThreadId,
turn_id: &str,
item_id: &str,
delta: &str,
) -> ServerNotification {
ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification {
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
item_id: item_id.to_string(),
delta: delta.to_string(),
})
}
fn exec_approval_request(
thread_id: ThreadId,
turn_id: &str,
item_id: &str,
approval_id: Option<&str>,
) -> ServerRequest {
ServerRequest::CommandExecutionRequestApproval {
request_id: AppServerRequestId::Integer(1),
params: CommandExecutionRequestApprovalParams {
kind: Default::default(),
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
item_id: item_id.to_string(),
started_at_ms: 0,
approval_id: approval_id.map(str::to_string),
environment_id: None,
reason: Some("needs approval".to_string()),
network_approval_context: None,
command: Some("echo hello".to_string()),
cwd: Some(test_path_buf("/tmp/project").abs().into()),
command_actions: None,
additional_permissions: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
available_decisions: None,
},
}
}
fn request_user_input_request(thread_id: ThreadId, turn_id: &str, item_id: &str) -> ServerRequest {
ServerRequest::ToolRequestUserInput {
request_id: AppServerRequestId::Integer(2),
params: ToolRequestUserInputParams {
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
item_id: item_id.to_string(),
questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None,
},
}
}
#[tokio::test]
async fn feedback_submission_without_thread_emits_error_history_cell() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.handle_feedback_submitted(
/*origin_thread_id*/ None,
FeedbackCategory::Bug,
/*include_logs*/ true,
Err("boom".to_string()),
)
.await;
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected feedback error history cell, saw {other:?}"),
};
assert_eq!(
lines_to_single_string(&cell.display_lines(/*width*/ 120)),
"■ Failed to upload feedback: boom"
);
}
#[tokio::test]
async fn feedback_submission_for_inactive_thread_replays_into_origin_thread() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let origin_thread_id = ThreadId::new();
let active_thread_id = ThreadId::new();
let origin_session = test_thread_session(origin_thread_id, test_path_buf("/tmp/origin"));
let active_session = test_thread_session(active_thread_id, test_path_buf("/tmp/active"));
app.thread_event_channels.insert(
origin_thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
origin_session.clone(),
Vec::new(),
),
);
app.thread_event_channels.insert(
active_thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
active_session.clone(),
Vec::new(),
),
);
app.activate_thread_channel(active_thread_id).await;
app.chat_widget.handle_thread_session(active_session);
while app_event_rx.try_recv().is_ok() {}
app.handle_feedback_submitted(
Some(origin_thread_id),
FeedbackCategory::Bug,
/*include_logs*/ true,
Ok("uploaded-thread".to_string()),
)
.await;
assert_matches!(
app_event_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
);
let snapshot = {
let channel = app
.thread_event_channels
.get(&origin_thread_id)
.expect("origin thread channel should exist");
let store = channel.store.lock().await;
assert!(matches!(
store.buffer.back(),
Some(ThreadBufferedEvent::FeedbackSubmission(_))
));
store.snapshot()
};
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);
let mut rendered_cells = Vec::new();
while let Ok(event) = app_event_rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
rendered_cells.push(lines_to_single_string(&cell.display_lines(/*width*/ 120)));
}
}
assert!(rendered_cells.iter().any(|cell| {
cell.contains("• Feedback uploaded. Please open an issue using the following URL:")
&& cell.contains("uploaded-thread")
}));
}
fn next_user_turn_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Op {
let mut seen = Vec::new();
while let Ok(op) = op_rx.try_recv() {
if matches!(op, Op::UserTurn { .. }) {
return op;
}
seen.push(format!("{op:?}"));
}
panic!("expected UserTurn op, saw: {seen:?}");
}
fn lines_to_single_string(lines: &[Line<'_>]) -> String {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry {
let model_info =
construct_model_info_offline_for_tests(model, &config.to_models_manager_config());
SessionTelemetry::new(
ThreadId::new(),
model,
model_info.slug.as_str(),
/*account_id*/ None,
/*account_email*/ None,
/*auth_mode*/ None,
"test_originator".to_string(),
/*log_user_prompts*/ false,
"test".to_string(),
crate::test_support::session_source_cli(),
)
}
#[test]
fn active_turn_not_steerable_turn_error_extracts_structured_server_error() {
let turn_error = AppServerTurnError {
misalignment: None,
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: AppServerNonSteerableTurnKind::Review,
}),
additional_details: None,
};
let error = TypedRequestError::Server {
method: "turn/steer".to_string(),
source: JSONRPCErrorError {
code: -32602,
message: turn_error.message.clone(),
data: Some(serde_json::to_value(&turn_error).expect("turn error should serialize")),
},
};
assert_eq!(
active_turn_not_steerable_turn_error(&error),
Some(turn_error)
);
}
#[test]
fn active_turn_steer_race_detects_missing_active_turn() {
let error = TypedRequestError::Server {
method: "turn/steer".to_string(),
source: JSONRPCErrorError {
code: -32602,
message: "no active turn to steer".to_string(),
data: None,
},
};
assert_eq!(
active_turn_steer_race(&error),
Some(ActiveTurnSteerRace::Missing)
);
assert_eq!(active_turn_not_steerable_turn_error(&error), None);
}
#[test]
fn active_turn_steer_race_extracts_actual_turn_id_from_mismatch() {
let error = TypedRequestError::Server {
method: "turn/steer".to_string(),
source: JSONRPCErrorError {
code: -32602,
message: "expected active turn id `turn-expected` but found `turn-actual`".to_string(),
data: None,
},
};
assert_eq!(
active_turn_steer_race(&error),
Some(ActiveTurnSteerRace::ExpectedTurnMismatch {
actual_turn_id: "turn-actual".to_string(),
})
);
}
#[test]
fn active_turn_interrupt_race_extracts_actual_turn_id_from_mismatch() {
let error = TypedRequestError::Server {
method: "turn/interrupt".to_string(),
source: JSONRPCErrorError {
code: -32602,
message: "expected active turn id turn-expected but found turn-actual".to_string(),
data: None,
},
};
assert_eq!(
active_turn_interrupt_race(&error),
Some("turn-actual".to_string())
);
}
#[tokio::test]
async fn fresh_session_config_uses_current_service_tier() {
let mut app = make_test_app().await;
app.chat_widget.set_service_tier(Some(
codex_protocol::config_types::ServiceTier::Fast
.request_value()
.to_string(),
));
let config = app.fresh_session_config();
assert_eq!(
config.service_tier,
Some(
codex_protocol::config_types::ServiceTier::Fast
.request_value()
.to_string()
)
);
}
#[tokio::test]
async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let user_cell = |text: &str,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
remote_image_urls: Vec<String>|
-> Arc<dyn HistoryCell> {
Arc::new(UserHistoryCell {
message: text.to_string(),
text_elements,
local_image_paths,
remote_image_urls,
}) as Arc<dyn HistoryCell>
};
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
Arc::new(AgentMessageCell::new(
vec![Line::from(text.to_string())],
/*is_first_line*/ true,
)) as Arc<dyn HistoryCell>
};
let make_header = |is_first| {
let session = ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
Arc::new(new_session_info(
app.chat_widget.config_ref(),
&app.local_settings,
app.chat_widget.current_model(),
&session,
is_first,
/*tooltip_override*/ None,
/*auth_plan*/ None,
/*show_fast_status*/ false,
)) as Arc<dyn HistoryCell>
};
let placeholder = "[Image #1]";
let edited_text = format!("follow-up (edited) {placeholder}");
let edited_range = edited_text.len().saturating_sub(placeholder.len())..edited_text.len();
let edited_text_elements = vec![TextElement::new(
edited_range.into(),
/*placeholder*/ None,
)];
let edited_local_image_paths = vec![PathBuf::from("/tmp/fake-image.png")];
// Simulate a transcript with duplicated history (e.g., from prior backtracks)
// and an edited turn appended after a session header boundary.
app.transcript_cells = vec![
make_header(true),
user_cell("first question", Vec::new(), Vec::new(), Vec::new()),
agent_cell("answer first"),
user_cell("follow-up", Vec::new(), Vec::new(), Vec::new()),
agent_cell("answer follow-up"),
make_header(false),
user_cell("first question", Vec::new(), Vec::new(), Vec::new()),
agent_cell("answer first"),
user_cell(
&edited_text,
edited_text_elements.clone(),
edited_local_image_paths.clone(),
vec!["https://example.com/backtrack.png".to_string()],
),
agent_cell("answer edited"),
];
assert_eq!(user_count(&app.transcript_cells), 2);
let transcript_before: Vec<String> = app
.transcript_cells
.iter()
.map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 80)))
.collect();
let base_id = ThreadId::new();
app.chat_widget
.handle_thread_session(crate::session_state::ThreadSessionState {
thread_id: base_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
});
app.backtrack.base_id = Some(base_id);
app.backtrack.primed = true;
app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1);
let selection = app
.confirm_backtrack_from_main()
.expect("backtrack selection");
let expected = BacktrackSelection {
thread_id: base_id,
nth_user_message: 1,
prompt: crate::chatwidget::UserMessage {
text: edited_text,
local_images: vec![crate::bottom_pane::LocalImageAttachment {
placeholder: placeholder.to_string(),
path: edited_local_image_paths[0].clone(),
}],
remote_image_urls: vec!["https://example.com/backtrack.png".to_string()],
text_elements: edited_text_elements,
mention_bindings: Vec::new(),
},
};
assert_eq!(selection, expected);
app.apply_backtrack_selection(selection);
let event = std::iter::from_fn(|| app_event_rx.try_recv().ok())
.find(|event| matches!(event, AppEvent::ForkSessionForPromptEdit { .. }))
.expect("prompt edit fork should be requested");
assert_matches!(
event,
AppEvent::ForkSessionForPromptEdit {
thread_id,
nth_user_message,
prompt,
} if thread_id == expected.thread_id
&& nth_user_message == expected.nth_user_message
&& prompt == expected.prompt
);
let transcript_after: Vec<String> = app
.transcript_cells
.iter()
.map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 80)))
.collect();
assert_eq!(transcript_after, transcript_before);
}
#[tokio::test]
async fn backtrack_branch_failure_restores_selected_prompt_snapshot() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.restore_backtrack_prompt_after_branch_error(
crate::chatwidget::UserMessage::from("edit this prompt"),
"branch unavailable",
);
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"edit this prompt"
);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
assert_app_snapshot!(
"backtrack_branch_failure_restores_selected_prompt",
rendered
);
}
#[tokio::test]
async fn remote_resume_current_cwd_rejection_snapshot() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"current\"\n",
)?;
app.app_server_target = crate::AppServerTarget::Remote {
endpoint: crate::RemoteAppServerEndpoint::WebSocket {
websocket_url: "ws://127.0.0.1:4500".to_string(),
auth_token: None,
},
};
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
let control = app
.resume_target_session(
&mut tui,
&mut app_server,
crate::resume_picker::SessionTarget {
path: None,
thread_id: ThreadId::new(),
history_mode: None,
},
)
.await?;
assert!(matches!(control, AppRunControl::Continue));
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
assert_app_snapshot!("remote_resume_current_cwd_rejected", rendered);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn remote_exec_resume_current_cwd_is_rejected() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"current\"\n",
)?;
app.environment_manager = Arc::new(
EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
Some(codex_exec_server::ExecServerRuntimePaths::new(
std::env::current_exe()?,
/*codex_linux_sandbox_exe*/ None,
)?),
)
.await,
);
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
let control = app
.resume_target_session(
&mut tui,
&mut app_server,
crate::resume_picker::SessionTarget {
path: None,
thread_id: ThreadId::new(),
history_mode: None,
},
)
.await?;
assert!(matches!(control, AppRunControl::Continue));
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
assert_eq!(
rendered,
"■ `tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace"
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn in_app_resume_session_cwd_without_metadata_is_non_fatal() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"session\"\n",
)?;
app.state_db = None;
let active_thread_id = app.chat_widget.thread_id();
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
let control = app
.resume_target_session(
&mut tui,
&mut app_server,
crate::resume_picker::SessionTarget {
path: None,
thread_id: ThreadId::new(),
history_mode: None,
},
)
.await?;
assert!(matches!(control, AppRunControl::Continue));
assert_eq!(app.chat_widget.thread_id(), active_thread_id);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 100));
assert_app_snapshot!("in_app_resume_session_cwd_without_metadata", rendered);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn remote_resume_keeps_server_only_cwd_out_of_local_config() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let local_cwd = app.config.cwd.to_path_buf();
let local_workspace_roots = app
.rebuild_config_for_cwd(local_cwd.clone())
.await?
.workspace_roots;
let remote_cwd = if cfg!(windows) {
PathBuf::from("/srv/remote/project")
} else {
PathBuf::from(r"C:\remote\project")
};
let filename_timestamp = "2025-01-05T12-00-00";
let thread_id = app_test_support::create_fake_rollout(
app.config.codex_home.as_path(),
filename_timestamp,
"2025-01-05T12:00:00Z",
"Saved user message",
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("materialized rollout should be created");
let rollout_path = app_test_support::rollout_path(
app.config.codex_home.as_path(),
filename_timestamp,
&thread_id,
);
app.app_server_target = crate::AppServerTarget::Remote {
endpoint: crate::RemoteAppServerEndpoint::WebSocket {
websocket_url: "ws://127.0.0.1:4500".to_string(),
auth_token: None,
},
};
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config))
.await?
.with_remote_cwd_override(Some(remote_cwd.clone()));
let mut tui = crate::tui::test_support::make_test_tui()?;
let control = app
.resume_target_session(
&mut tui,
&mut app_server,
crate::resume_picker::SessionTarget {
path: Some(rollout_path),
thread_id: ThreadId::from_string(&thread_id)?,
history_mode: None,
},
)
.await?;
assert!(matches!(control, AppRunControl::Continue));
assert_eq!(app_server.remote_cwd_override(), Some(remote_cwd.as_path()));
assert!(!crate::session_resume::cwds_differ(
app.config.cwd.as_path(),
&local_cwd,
));
assert_eq!(app.config.workspace_roots, local_workspace_roots);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn in_app_resume_uses_configured_or_explicit_cwd() -> Result<()> {
let temp_dir = tempdir()?;
let codex_home = temp_dir.path().join("codex-home");
let launch_cwd = temp_dir.path().join("launch");
let active_cwd = temp_dir.path().join("active");
let session_cwd = temp_dir.path().join("session");
let explicit_cwd = temp_dir.path().join("explicit");
let runtime_cwd = temp_dir.path().join("runtime");
for directory in [
&codex_home,
&launch_cwd,
&active_cwd,
&session_cwd,
&explicit_cwd,
&runtime_cwd,
] {
std::fs::create_dir_all(directory)?;
}
let mut local_app_server = None;
let mut remote_app_server = None;
for (configured_mode, has_explicit_cwd, has_remote_exec, has_runtime_cwd, expected_directory) in [
("current", false, false, false, "launch"),
("session", false, false, false, "session"),
("session", true, false, false, "explicit"),
("session", false, true, false, "session"),
("session", true, true, false, "explicit"),
("current", false, false, true, "runtime"),
("current", true, false, true, "runtime"),
("session", false, false, true, "runtime"),
("session", true, false, true, "runtime"),
] {
std::fs::write(
codex_home.join("config.toml"),
format!("[tui]\nresume_cwd = \"{configured_mode}\"\n"),
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.clone())
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.harness_overrides(ConfigOverrides {
cwd: Some(active_cwd.clone()),
..Default::default()
})
.build()
.await?;
let filename_timestamp = "2025-01-05T12-00-00";
let thread_id = app_test_support::create_fake_rollout(
&codex_home,
filename_timestamp,
"2025-01-05T12:00:00Z",
"Saved user message",
Some(&config.model_provider_id),
/*git_info*/ None,
)
.expect("materialized rollout should be created");
let rollout_path =
app_test_support::rollout_path(&codex_home, filename_timestamp, &thread_id);
let mut rollout_lines = std::fs::read_to_string(&rollout_path)?
.lines()
.map(serde_json::from_str::<serde_json::Value>)
.collect::<std::result::Result<Vec<_>, _>>()?;
rollout_lines[0]["payload"]["cwd"] = serde_json::to_value(&session_cwd)?;
std::fs::write(
&rollout_path,
format!(
"{}\n",
rollout_lines
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n")
),
)?;
let thread_id = ThreadId::from_string(&thread_id)?;
let environment_manager = if has_remote_exec {
Arc::new(
EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
Some(codex_exec_server::ExecServerRuntimePaths::new(
std::env::current_exe()?,
/*codex_linux_sandbox_exe*/ None,
)?),
)
.await,
)
} else {
Arc::new(EnvironmentManager::default_for_tests())
};
let server = if has_remote_exec {
&mut remote_app_server
} else {
&mut local_app_server
};
let app_server = match server {
Some(server) => server,
slot @ None => slot.insert(
crate::start_app_server_for_picker(
&config,
&crate::AppServerTarget::Embedded,
/*state_db*/ None,
Arc::clone(&environment_manager),
)
.await?,
),
};
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.config = config;
app.launch_cwd = launch_cwd.clone();
app.environment_manager = environment_manager;
app.harness_overrides.cwd = has_explicit_cwd.then_some(explicit_cwd.clone());
app.runtime_working_directory_override = has_runtime_cwd.then_some(runtime_cwd.clone());
app.chat_widget
.handle_thread_session_quiet(test_thread_session(ThreadId::new(), active_cwd.clone()));
let mut tui = crate::tui::test_support::make_test_tui()?;
let control = app
.resume_target_session(
&mut tui,
app_server,
crate::resume_picker::SessionTarget {
path: Some(rollout_path),
thread_id,
history_mode: None,
},
)
.await?;
assert!(matches!(control, AppRunControl::Continue));
let expected_cwd = temp_dir.path().join(expected_directory);
assert!(!crate::session_resume::cwds_differ(
app.config.cwd.as_path(),
&expected_cwd,
));
assert!(!crate::session_resume::cwds_differ(
app.chat_widget.config_ref().cwd.as_path(),
&expected_cwd,
));
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
}
if let Some(app_server) = local_app_server {
app_server.shutdown().await?;
}
if let Some(app_server) = remote_app_server {
app_server.shutdown().await?;
}
Ok(())
}
#[tokio::test]
async fn remembered_current_cwd_stays_at_launch_across_in_app_resumes() -> Result<()> {
let temp_dir = tempdir()?;
let codex_home = temp_dir.path().join("codex-home");
let launch_cwd = temp_dir.path().join("launch");
let active_cwd = temp_dir.path().join("active");
let first_session_cwd = temp_dir.path().join("first-session");
let second_session_cwd = temp_dir.path().join("second-session");
std::fs::create_dir_all(&codex_home)?;
std::fs::create_dir_all(&launch_cwd)?;
std::fs::create_dir_all(&active_cwd)?;
std::fs::create_dir_all(&first_session_cwd)?;
std::fs::create_dir_all(&second_session_cwd)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.clone())
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.harness_overrides(ConfigOverrides {
cwd: Some(active_cwd.clone()),
..Default::default()
})
.build()
.await?;
let immediately_selected_cwd = crate::cwd_prompt::CwdSelection::CurrentAndRemember
.selected_cwd(&active_cwd, &first_session_cwd, &launch_cwd);
assert!(!crate::session_resume::cwds_differ(
immediately_selected_cwd,
&launch_cwd,
));
crate::legacy_core::config::edit::ConfigEditsBuilder::for_config(&config)
.set_resume_cwd(codex_config::types::ResumeCwdMode::Current)
.apply()
.await
.map_err(std::io::Error::other)?;
let mut targets = Vec::new();
for (filename_timestamp, metadata_timestamp, session_cwd) in [
(
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
first_session_cwd,
),
(
"2025-01-05T12-01-00",
"2025-01-05T12:01:00Z",
second_session_cwd,
),
] {
let thread_id = app_test_support::create_fake_rollout(
&codex_home,
filename_timestamp,
metadata_timestamp,
"Saved user message",
Some(&config.model_provider_id),
/*git_info*/ None,
)
.expect("materialized rollout should be created");
let rollout_path =
app_test_support::rollout_path(&codex_home, filename_timestamp, &thread_id);
let mut rollout_lines = std::fs::read_to_string(&rollout_path)?
.lines()
.map(serde_json::from_str::<serde_json::Value>)
.collect::<std::result::Result<Vec<_>, _>>()?;
rollout_lines[0]["payload"]["cwd"] = serde_json::to_value(&session_cwd)?;
std::fs::write(
&rollout_path,
format!(
"{}\n",
rollout_lines
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n")
),
)?;
targets.push(crate::resume_picker::SessionTarget {
path: Some(rollout_path),
thread_id: ThreadId::from_string(&thread_id)?,
history_mode: None,
});
}
let state_db =
crate::init_state_db_for_app_server_target(&config, &crate::AppServerTarget::Embedded)
.await?;
let mut app_server = crate::start_app_server_for_picker(
&config,
&crate::AppServerTarget::Embedded,
state_db.clone(),
Arc::new(EnvironmentManager::default_for_tests()),
)
.await?;
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.config = config;
app.launch_cwd = launch_cwd.clone();
app.state_db = state_db;
app.chat_widget
.handle_thread_session_quiet(test_thread_session(ThreadId::new(), active_cwd));
let mut tui = crate::tui::test_support::make_test_tui()?;
for target_session in targets {
let control = app
.resume_target_session(&mut tui, &mut app_server, target_session)
.await?;
assert!(matches!(control, AppRunControl::Continue));
assert!(!crate::session_resume::cwds_differ(
app.config.cwd.as_path(),
&launch_cwd,
));
assert!(!crate::session_resume::cwds_differ(
app.chat_widget.config_ref().cwd.as_path(),
&launch_cwd,
));
}
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let config = app.chat_widget.config_ref().clone();
let filename_ts = "2025-01-05T12-00-00";
let source_thread_id = app_test_support::create_fake_rollout(
config.codex_home.as_path(),
filename_ts,
"2025-01-05T12:00:00Z",
"unused preview",
Some("test-provider"),
/*git_info*/ None,
)
.expect("materialized rollout should be created");
let source_path =
app_test_support::rollout_path(config.codex_home.as_path(), filename_ts, &source_thread_id);
let session_meta = std::fs::read_to_string(&source_path)?
.lines()
.next()
.expect("fake rollout should have session metadata")
.to_string();
std::fs::write(&source_path, format!("{session_meta}\n"))?;
for (turn_id, message, images, local_images) in [
("turn-1", "retained prompt", None, Vec::new()),
(
"turn-2",
"selected prompt [Image #1]",
Some(vec!["https://example.com/backtrack.png".to_string()]),
vec![PathBuf::from("/tmp/fake-image.png")],
),
] {
for item in [
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::default(),
})),
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
message: message.to_string(),
images,
local_images,
..Default::default()
})),
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_id.to_string(),
last_agent_message: None,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
})),
] {
codex_rollout::append_rollout_item_to_path(&source_path, &item).await?;
}
}
let source_thread_id = ThreadId::from_string(&source_thread_id)?;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
let started = app_server
.resume_thread(
&crate::local_settings::LocalSettings::from(&config),
config.clone(),
source_thread_id,
crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig,
)
.await?;
let selected_turn = started.turns[1].clone();
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
{
let mut store = app
.thread_event_channels
.get(&source_thread_id)
.expect("source thread event channel")
.store
.lock()
.await;
store.turns.pop();
store.push_notification(turn_started_notification(
source_thread_id,
&selected_turn.id,
));
for item in selected_turn.items {
store.push_notification(ServerNotification::ItemCompleted(
codex_app_server_protocol::ItemCompletedNotification {
thread_id: source_thread_id.to_string(),
turn_id: selected_turn.id.clone(),
completed_at_ms: 0,
item,
},
));
}
store.push_notification(turn_completed_notification(
source_thread_id,
&selected_turn.id,
TurnStatus::Interrupted,
));
}
while app_event_rx.try_recv().is_ok() {}
let source_before = std::fs::read_to_string(&source_path)?;
let mut tui = crate::tui::test_support::make_test_tui()?;
let prompt = crate::chatwidget::UserMessage {
text: "selected prompt [Image #1]".to_string(),
local_images: vec![crate::bottom_pane::LocalImageAttachment {
placeholder: "[Image #1]".to_string(),
path: PathBuf::from("/tmp/fake-image.png"),
}],
remote_image_urls: vec!["https://example.com/backtrack.png".to_string()],
text_elements: Vec::new(),
mention_bindings: Vec::new(),
};
let control = Box::pin(app.handle_event(
&mut tui,
&mut app_server,
AppEvent::ForkSessionForPromptEdit {
thread_id: source_thread_id,
nth_user_message: 1,
prompt: prompt.clone(),
},
))
.await?;
assert!(matches!(control, AppRunControl::Continue));
let forked_thread_id = app
.chat_widget
.thread_id()
.expect("prompt edit should switch to a forked thread");
assert_ne!(forked_thread_id, source_thread_id);
assert_eq!(app.chat_widget.composer_text_with_pending(), prompt.text);
assert_eq!(
app.chat_widget.remote_image_urls(),
prompt.remote_image_urls
);
assert_eq!(std::fs::read_to_string(&source_path)?, source_before);
assert_eq!(
app_server
.thread_read(source_thread_id, /*include_turns*/ true)
.await?
.turns
.iter()
.map(|turn| turn.id.as_str())
.collect::<Vec<_>>(),
vec!["turn-1", "turn-2"]
);
assert_eq!(
app_server
.thread_read(forked_thread_id, /*include_turns*/ true)
.await?
.turns
.iter()
.map(|turn| turn.id.as_str())
.collect::<Vec<_>>(),
vec!["turn-1"]
);
let history = std::iter::from_fn(|| app_event_rx.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => {
Some(lines_to_single_string(&cell.display_lines(/*width*/ 120)))
}
_ => None,
})
.collect::<Vec<_>>();
let retained_index = history
.iter()
.position(|line| line.contains("retained prompt"))
.expect("forked history should replay the retained prompt");
let notice_index = history
.iter()
.position(|line| line == "• You’re continuing from this point in a new conversation")
.expect("prompt edit should emit the branch notice");
assert!(retained_index < notice_index);
assert!(
!history
.iter()
.any(|line| line.contains("Thread forked from"))
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn prompt_edit_before_first_prompt_starts_fresh_thread() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let config = app.chat_widget.config_ref().clone();
let source_thread_id = app_test_support::create_fake_rollout(
config.codex_home.as_path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"first prompt",
Some("test-provider"),
/*git_info*/ None,
)
.expect("materialized rollout should be created");
let source_thread_id = ThreadId::from_string(&source_thread_id)?;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
let started = app_server
.resume_thread(
&crate::local_settings::LocalSettings::from(&config),
config.clone(),
source_thread_id,
crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig,
)
.await?;
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
while app_event_rx.try_recv().is_ok() {}
let mut tui = crate::tui::test_support::make_test_tui()?;
let control = Box::pin(app.handle_event(
&mut tui,
&mut app_server,
AppEvent::ForkSessionForPromptEdit {
thread_id: source_thread_id,
nth_user_message: 0,
prompt: crate::chatwidget::UserMessage::from("first prompt"),
},
))
.await?;
assert!(matches!(control, AppRunControl::Continue));
let fresh_thread_id = app
.chat_widget
.thread_id()
.expect("first prompt edit should start a fresh thread");
assert_ne!(fresh_thread_id, source_thread_id);
assert_eq!(app.chat_widget.composer_text_with_pending(), "first prompt");
let history = std::iter::from_fn(|| app_event_rx.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => {
Some(lines_to_single_string(&cell.display_lines(/*width*/ 120)))
}
_ => None,
})
.collect::<Vec<_>>();
assert!(
history.iter().any(|line| {
line == "• You’re continuing from this point in a new conversation"
})
);
assert!(
!history
.iter()
.any(|line| line.contains("Thread forked from"))
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn replay_thread_snapshot_replays_turn_history_in_order() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: Some(test_thread_session(
thread_id,
test_path_buf("/home/user/project"),
)),
turns: vec![
Turn {
id: "turn-1".to_string(),
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::UserMessage {
id: "user-1".to_string(),
client_id: None,
content: vec![AppServerUserInput::Text {
text: "first prompt".to_string(),
text_elements: Vec::new(),
}],
}],
status: TurnStatus::Completed,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
},
Turn {
id: "turn-2".to_string(),
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![
ThreadItem::UserMessage {
id: "user-2".to_string(),
client_id: None,
content: vec![AppServerUserInput::Text {
text: "third prompt".to_string(),
text_elements: Vec::new(),
}],
},
ThreadItem::AgentMessage {
id: "assistant-2".to_string(),
text: "done".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
],
status: TurnStatus::Completed,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
},
],
events: Vec::new(),
input_state: None,
},
/*resume_restored_queue*/ false,
);
while let Ok(event) = app_event_rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
let cell: Arc<dyn HistoryCell> = cell.into();
app.transcript_cells.push(cell);
}
}
let user_messages: Vec<String> = app
.transcript_cells
.iter()
.filter_map(|cell| {
cell.as_any()
.downcast_ref::<UserHistoryCell>()
.map(|cell| cell.message.clone())
})
.collect();
assert_eq!(
user_messages,
vec!["first prompt".to_string(), "third prompt".to_string()]
);
}
#[tokio::test]
async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let receiver_thread_id =
ThreadId::from_string("019cff70-2599-75e2-af72-b958ce5dc1cc").expect("valid thread");
app.agent_navigation.upsert(
receiver_thread_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
/*is_closed*/ false,
);
let replacement = ChatWidget::new_with_app_event(ChatWidgetInit {
requires_openai_auth: true,
local_settings: crate::local_settings::LocalSettings::from(&app.config),
config: app.config.clone(),
frame_requester: crate::tui::FrameRequester::test_dummy(),
app_event_tx: app.app_event_tx.clone(),
workspace_command_runner: None,
initial_user_message: None,
enhanced_keys_supported: app.enhanced_keys_supported,
has_chatgpt_account: app.chat_widget.has_chatgpt_account(),
has_codex_backend_auth: app.chat_widget.has_codex_backend_auth(),
model_catalog: app.model_catalog.clone(),
feedback: app.feedback.clone(),
is_first_run: false,
status_account_display: app.chat_widget.status_account_display().cloned(),
runtime_model_provider_base_url: app
.chat_widget
.runtime_model_provider_base_url()
.map(str::to_string),
initial_plan_type: app.chat_widget.current_plan_type(),
model: Some(app.chat_widget.current_model().to_string()),
startup_tooltip_override: None,
status_line_invalid_items_warned: app.status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: app.terminal_title_invalid_items_warned.clone(),
session_telemetry: app.session_telemetry.clone(),
});
app.replace_chat_widget(replacement);
app.replay_thread_snapshot(
ThreadEventSnapshot {
session: None,
turns: Vec::new(),
events: vec![ThreadBufferedEvent::Notification(Box::new(
ServerNotification::ItemStarted(
codex_app_server_protocol::ItemStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
item: ThreadItem::CollabAgentToolCall {
id: "wait-1".to_string(),
tool: codex_app_server_protocol::CollabAgentTool::Wait,
status:
codex_app_server_protocol::CollabAgentToolCallStatus::InProgress,
sender_thread_id: ThreadId::new().to_string(),
receiver_thread_ids: vec![receiver_thread_id.to_string()],
prompt: None,
model: None,
reasoning_effort: None,
agents_states: HashMap::new(),
},
},
),
))],
input_state: None,
},
/*resume_restored_queue*/ false,
);
let mut saw_named_wait = false;
while let Ok(event) = app_event_rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80));
saw_named_wait |= transcript.contains("Robie [explorer]");
}
}
assert!(
saw_named_wait,
"expected replayed wait item to keep agent name"
);
}
#[tokio::test]
async fn refreshed_snapshot_session_persists_resumed_turns() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let initial_session = test_thread_session(thread_id, test_path_buf("/tmp/original"));
app.thread_event_channels.insert(
thread_id,
ThreadEventChannel::new_with_session(
/*capacity*/ 4,
initial_session.clone(),
Vec::new(),
),
);
let resumed_turns = vec![test_turn(
"turn-1",
TurnStatus::Completed,
vec![ThreadItem::UserMessage {
id: "user-1".to_string(),
client_id: None,
content: vec![AppServerUserInput::Text {
text: "restored prompt".to_string(),
text_elements: Vec::new(),
}],
}],
)];
let resumed_session = ThreadSessionState {
cwd: test_path_buf("/tmp/refreshed").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
..initial_session.clone()
};
let mut snapshot = ThreadEventSnapshot {
session: Some(initial_session),
turns: Vec::new(),
events: Vec::new(),
input_state: None,
};
app.apply_refreshed_snapshot_thread(
thread_id,
AppServerStartedThread {
session: resumed_session.clone(),
turns: resumed_turns.clone(),
blocks_direct_input: true,
task_tools_available: false,
},
&mut snapshot,
)
.await;
assert!(app.agent_navigation.is_parent_owned(thread_id));
assert_eq!(snapshot.session, Some(resumed_session.clone()));
assert_eq!(snapshot.turns, resumed_turns);
let store = app
.thread_event_channels
.get(&thread_id)
.expect("thread channel")
.store
.lock()
.await;
let store_snapshot = store.snapshot();
assert_eq!(store_snapshot.session, Some(resumed_session));
assert_eq!(store_snapshot.turns, snapshot.turns);
assert_eq!(
store.recap_progress(),
recap::RecapProgress {
completed_turns: 1,
last_recapped_turn_count: None,
}
);
}
#[tokio::test]
async fn late_usage_result_can_follow_finalized_plan() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.chat_widget
.add_token_activity_output(crate::chatwidget::TokenActivityView::Daily);
let request_id = match app_event_rx.try_recv() {
Ok(AppEvent::RefreshTokenActivity { request_id }) => request_id,
other => panic!("expected token activity refresh request, got {other:?}"),
};
app.chat_widget.note_stream_consolidation_queued();
app.transcript_cells
.push(Arc::new(history_cell::new_proposed_plan_stream(
vec![Line::from("finalized plan")],
/*is_stream_continuation*/ false,
)));
app.chat_widget.note_stream_consolidation_completed();
assert!(
app.chat_widget.finish_token_activity_refresh(
request_id,
Err("token activity unavailable".to_string()),
)
);
assert!(!app.pending_usage_output_insertion_blocked());
assert!(
app.chat_widget
.take_completed_token_activity_output()
.is_some()
);
}
#[tokio::test]
async fn new_session_requests_shutdown_for_previous_conversation() {
Box::pin(async {
let (mut app, mut app_event_rx, mut op_rx) = Box::pin(make_test_app_with_channels()).await;
let thread_id = ThreadId::new();
let event = crate::session_state::ThreadSessionState {
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
app.chat_widget.handle_thread_session(event);
while app_event_rx.try_recv().is_ok() {}
while op_rx.try_recv().is_ok() {}
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
Box::pin(app.shutdown_current_thread(&mut app_server)).await;
assert!(
op_rx.try_recv().is_err(),
"shutdown should not submit Op::Shutdown"
);
})
.await;
}
#[tokio::test]
async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let control = Box::pin(app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst)).await;
assert_eq!(app.pending_shutdown_exit_thread_id, None);
assert!(matches!(
control,
AppRunControl::Exit(ExitReason::UserRequested)
));
}
#[tokio::test]
async fn shutdown_first_exit_uses_app_server_shutdown_without_submitting_op() {
let (mut app, _app_event_rx, mut op_rx) = Box::pin(make_test_app_with_channels()).await;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let control = Box::pin(app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst)).await;
assert_eq!(app.pending_shutdown_exit_thread_id, None);
assert!(matches!(
control,
AppRunControl::Exit(ExitReason::UserRequested)
));
assert!(
op_rx.try_recv().is_err(),
"shutdown should not submit Op::Shutdown"
);
}
#[tokio::test]
async fn interrupt_without_active_turn_is_treated_as_handled() {
Box::pin(async {
let mut app = make_test_app().await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await
.expect("thread/start should succeed");
let thread_id = started.session.thread_id;
app.enqueue_primary_thread_session(started.session, started.turns)
.await
.expect("primary thread should be registered");
app.backtrack.primed = true;
let op = AppCommand::interrupt();
let handled = Box::pin(app.try_submit_active_thread_op_via_app_server(
&mut app_server,
thread_id,
&op,
))
.await
.expect("interrupt submission should not fail");
assert_eq!(handled, true);
assert!(!app.backtrack.primed);
})
.await;
}
#[tokio::test]
async fn override_turn_context_sends_thread_settings_update() {
Box::pin(async {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await
.expect("thread/start should succeed");
let thread_id = started.session.thread_id;
let initial_model = started.session.model.clone();
let initial_effort = started.session.reasoning_effort.clone();
app.enqueue_primary_thread_session(started.session, started.turns)
.await
.expect("primary thread should be registered");
let service_tier = ServiceTier::Fast.request_value().to_string();
let collaboration_mode = CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: "gpt-5.4".to_string(),
reasoning_effort: Some(ReasoningEffortConfig::High),
developer_instructions: None,
},
};
let op = AppCommand::override_turn_context(
/*cwd*/ None,
Some(AskForApproval::OnRequest),
Some(ApprovalsReviewer::AutoReview),
/*permission_profile*/ None,
Some(ActivePermissionProfile::new(
codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
)),
/*windows_sandbox_level*/ None,
Some("gpt-5.4".to_string()),
Some(Some(ReasoningEffortConfig::High)),
/*summary*/ None,
Some(Some(service_tier.clone())),
Some(collaboration_mode.clone()),
Some(Personality::Pragmatic),
);
let handled = app
.try_submit_active_thread_op_via_app_server(&mut app_server, thread_id, &op)
.await
.expect("settings update submission should not fail");
assert_eq!(handled, true);
assert_eq!(
app.primary_session_configured
.as_ref()
.expect("primary session")
.model,
initial_model,
"thread/settings/update response is only an ack; cached state changes on notification"
);
let notification = next_thread_settings_updated(&mut app_server, thread_id).await;
assert_eq!(notification.thread_settings.model, "gpt-5.4");
assert_eq!(
notification.thread_settings.effort,
Some(ReasoningEffortConfig::High)
);
assert_eq!(
notification.thread_settings.service_tier,
Some(service_tier.clone())
);
assert_eq!(
notification.thread_settings.approval_policy,
AskForApproval::OnRequest
);
assert_eq!(
notification.thread_settings.approvals_reviewer.to_core(),
ApprovalsReviewer::AutoReview
);
let notified_mode = &notification.thread_settings.collaboration_mode;
assert_eq!(notified_mode.mode, collaboration_mode.mode);
assert_eq!(
notified_mode.settings.model,
collaboration_mode.settings.model
);
assert_eq!(
notified_mode.settings.reasoning_effort,
collaboration_mode.settings.reasoning_effort
);
assert_eq!(
notification.thread_settings.personality,
Some(Personality::Pragmatic)
);
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
ServerNotification::ThreadSettingsUpdated(notification),
)),
)
.await;
let updated_session = app
.primary_session_configured
.as_ref()
.expect("primary session should be updated from notification");
assert_eq!(updated_session.model, initial_model);
assert_eq!(updated_session.reasoning_effort, initial_effort);
let updated_mode = updated_session
.collaboration_mode
.as_deref()
.expect("collaboration mode should be cached");
assert_eq!(updated_mode.mode, collaboration_mode.mode);
assert_eq!(
updated_mode.settings.model,
collaboration_mode.settings.model
);
assert_eq!(
updated_mode.settings.reasoning_effort,
collaboration_mode.settings.reasoning_effort
);
assert_eq!(updated_session.personality, Some(Personality::Pragmatic));
assert_eq!(updated_session.service_tier, Some(service_tier));
assert_eq!(updated_session.approval_policy, AskForApproval::OnRequest);
assert_eq!(
updated_session.approvals_reviewer,
ApprovalsReviewer::AutoReview
);
assert_eq!(
updated_session
.active_permission_profile
.as_ref()
.expect("active profile")
.id,
codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE
);
})
.await;
}
#[tokio::test]
async fn selecting_cyber_model_defaults_active_thread_to_auto_review() {
Box::pin(async {
let mut app = make_test_app().await;
app.config
.permissions
.approval_policy
.set(AskForApproval::UnlessTrusted.to_core())
.expect("set approval policy");
app.chat_widget
.set_approval_policy(AskForApproval::UnlessTrusted);
let mut model = app
.model_catalog
.try_list_models()
.expect("model catalog")
.into_iter()
.find(|model| model.model == "gpt-5.4")
.expect("gpt-5.4 model");
model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string());
app.model_catalog = Arc::new(ModelCatalog::new(vec![model]));
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await
.expect("thread/start should succeed");
assert_eq!(
started.session.approval_policy,
AskForApproval::UnlessTrusted
);
assert_eq!(started.session.approvals_reviewer, ApprovalsReviewer::User);
let thread_id = started.session.thread_id;
app.enqueue_primary_thread_session(started.session, started.turns)
.await
.expect("primary thread should be registered");
let mut tui = crate::tui::test_support::make_test_tui().expect("test tui");
app.handle_event(
&mut tui,
&mut app_server,
AppEvent::UpdateModel("gpt-5.4".to_string()),
)
.await
.expect("model selection should succeed");
let notification = next_thread_settings_updated(&mut app_server, thread_id).await;
assert_eq!(
notification.thread_settings.approval_policy,
AskForApproval::OnRequest
);
assert_eq!(
notification.thread_settings.approvals_reviewer.to_core(),
ApprovalsReviewer::AutoReview
);
assert_eq!(
notification
.thread_settings
.active_permission_profile
.expect("active permission profile")
.id,
codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE
);
})
.await;
}
#[tokio::test]
async fn changing_cyber_model_reasoning_preserves_selected_permissions() {
Box::pin(async {
let mut app = make_test_app().await;
let model_name = app.chat_widget.current_model().to_string();
let mut model = app
.model_catalog
.try_list_models()
.expect("model catalog")
.into_iter()
.find(|model| model.model == model_name)
.expect("current model");
model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string());
app.model_catalog = Arc::new(ModelCatalog::new(vec![model]));
assert!(
app.apply_permission_profile_selection(PermissionProfileSelection {
profile_id: codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY
.to_string(),
approval_policy: Some(AskForApproval::OnRequest),
approvals_reviewer: Some(ApprovalsReviewer::User),
display_label: "Read Only".to_string(),
})
.await
);
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await
.expect("thread/start should succeed");
let thread_id = started.session.thread_id;
app.enqueue_primary_thread_session(started.session, started.turns)
.await
.expect("primary thread should be registered");
let mut tui = crate::tui::test_support::make_test_tui().expect("test tui");
for effort in [ReasoningEffortConfig::High, ReasoningEffortConfig::Ultra] {
if effort == ReasoningEffortConfig::Ultra {
app.chat_widget
.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
app.chat_widget
.set_collaboration_mask(CollaborationModeMask {
name: "Plan".to_string(),
mode: Some(ModeKind::Plan),
model: Some(model_name.clone()),
reasoning_effort: Some(Some(effort.clone())),
developer_instructions: None,
});
app.handle_event(
&mut tui,
&mut app_server,
AppEvent::ApplyAdvancedReasoning {
model: model_name.clone(),
effort: effort.clone(),
},
)
.await
.expect("advanced reasoning selection should succeed");
} else {
app.handle_event(
&mut tui,
&mut app_server,
AppEvent::UpdateModel(model_name.clone()),
)
.await
.expect("same-model selection should succeed");
app.handle_event(
&mut tui,
&mut app_server,
AppEvent::UpdateReasoningEffort(Some(effort.clone())),
)
.await
.expect("reasoning selection should succeed");
}
let settings = next_thread_settings_updated(&mut app_server, thread_id)
.await
.thread_settings;
assert_eq!(settings.effort, Some(effort));
assert_eq!(settings.approval_policy, AskForApproval::OnRequest);
assert_eq!(
settings.approvals_reviewer.to_core(),
ApprovalsReviewer::User
);
assert_eq!(
settings
.active_permission_profile
.expect("active permission profile")
.id,
codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY
);
assert_eq!(
settings.collaboration_mode.mode,
app.chat_widget.effective_collaboration_mode().mode
);
assert_eq!(settings.collaboration_mode.settings.model, model_name);
}
})
.await;
}
#[tokio::test]
async fn selecting_cyber_model_falls_back_to_user_when_auto_review_is_unavailable() {
let mut app = make_test_app().await;
let mut model = app
.model_catalog
.try_list_models()
.expect("model catalog")
.into_iter()
.find(|model| model.model == "gpt-5.4")
.expect("gpt-5.4 model");
model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string());
app.model_catalog = Arc::new(ModelCatalog::new(vec![model]));
let _ = app.config.features.disable(Feature::GuardianApproval);
app.chat_widget
.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false);
app.active_thread_id = Some(ThreadId::new());
let params = app
.active_thread_model_setting_update_params("gpt-5.4".to_string())
.expect("active thread should produce update params");
assert_eq!(
params.approval_policy,
Some(codex_app_server_protocol::AskForApproval::OnRequest)
);
assert_eq!(
params.approvals_reviewer,
Some(codex_app_server_protocol::ApprovalsReviewer::User)
);
}
#[tokio::test]
async fn selecting_cyber_model_respects_auto_review_requirements() {
Box::pin(async {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let requirements_toml = codex_config::ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::UnlessTrusted.to_core()]),
..Default::default()
};
let mut requirements_with_sources = codex_config::ConfigRequirementsWithSources::default();
requirements_with_sources.merge_unset_fields(
codex_config::RequirementSource::Unknown,
requirements_toml.clone(),
);
let requirements = codex_config::ConfigRequirements::try_from(requirements_with_sources)
.expect("reviewer requirements");
app.config.config_layer_stack =
codex_config::ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)
.expect("auto-review requirements stack");
app.config
.permissions
.approval_policy
.set(AskForApproval::UnlessTrusted.to_core())
.expect("set approval policy");
app.chat_widget
.set_approval_policy(AskForApproval::UnlessTrusted);
app.chat_widget.sync_plugin_mentions_config(&app.config);
let mut model = app
.model_catalog
.try_list_models()
.expect("model catalog")
.into_iter()
.find(|model| model.model == "gpt-5.4")
.expect("gpt-5.4 model");
model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string());
app.model_catalog = Arc::new(ModelCatalog::new(vec![model]));
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await
.expect("thread/start should succeed");
assert_eq!(
started.session.approval_policy,
AskForApproval::UnlessTrusted
);
let thread_id = started.session.thread_id;
app.enqueue_primary_thread_session(started.session, started.turns)
.await
.expect("primary thread should be registered");
let mut tui = crate::tui::test_support::make_test_tui().expect("test tui");
app.handle_event(
&mut tui,
&mut app_server,
AppEvent::UpdateModel("gpt-5.4".to_string()),
)
.await
.expect("model selection should succeed");
let notification = next_thread_settings_updated(&mut app_server, thread_id).await;
assert_eq!(
notification.thread_settings.approval_policy,
AskForApproval::UnlessTrusted
);
assert_eq!(
notification.thread_settings.approvals_reviewer.to_core(),
ApprovalsReviewer::User
);
assert!(
std::iter::from_fn(|| app_event_rx.try_recv().ok())
.all(|event| !matches!(event, AppEvent::CyberModelAutoReviewNotice))
);
})
.await;
}
#[tokio::test]
async fn thread_setting_update_params_sync_model_and_default_reasoning() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
app.chat_widget.set_model("gpt-5.4");
let params = app
.active_thread_model_setting_update_params("gpt-5.4".to_string())
.expect("active thread should produce update params");
assert_eq!(params.thread_id, thread_id.to_string());
assert_eq!(params.model, Some("gpt-5.4".to_string()));
assert_eq!(
params
.collaboration_mode
.as_ref()
.expect("collaboration mode should sync with model")
.settings
.model,
"gpt-5.4"
);
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::Low));
app.chat_widget
.set_collaboration_mask(CollaborationModeMask {
name: "Plan".to_string(),
mode: Some(ModeKind::Plan),
model: Some("gpt-plan".to_string()),
reasoning_effort: Some(Some(ReasoningEffortConfig::Medium)),
developer_instructions: None,
});
app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High));
let params = app
.active_thread_reasoning_setting_update_params(Some(ReasoningEffortConfig::High))
.expect("active thread should produce update params");
assert_eq!(params.thread_id, thread_id.to_string());
assert_eq!(params.effort, Some(ReasoningEffortConfig::High));
let collaboration_mode = params
.collaboration_mode
.expect("collaboration mode should sync with reasoning");
assert_eq!(collaboration_mode.mode, ModeKind::Default);
assert_eq!(
collaboration_mode.settings.reasoning_effort,
Some(ReasoningEffortConfig::High)
);
}
#[tokio::test]
async fn inactive_thread_settings_notification_updates_cached_collaboration_mode() {
let mut app = make_test_app().await;
let primary_thread_id = ThreadId::new();
let inactive_thread_id = ThreadId::new();
let primary_session = test_thread_session(primary_thread_id, test_path_buf("/tmp/main"));
let inactive_session = test_thread_session(inactive_thread_id, test_path_buf("/tmp/inactive"));
let collaboration_mode = CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: "gpt-plan".to_string(),
reasoning_effort: Some(ReasoningEffortConfig::High),
developer_instructions: Some("draft a plan first".to_string()),
},
};
app.primary_thread_id = Some(primary_thread_id);
app.active_thread_id = Some(primary_thread_id);
app.primary_session_configured = Some(primary_session.clone());
app.thread_event_channels.insert(
primary_thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
primary_session,
Vec::new(),
),
);
app.thread_event_channels.insert(
inactive_thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
inactive_session,
Vec::new(),
),
);
let notification = ThreadSettingsUpdatedNotification {
thread_id: inactive_thread_id.to_string(),
thread_settings: ThreadSettings {
cwd: test_absolute_path("/tmp/thread-settings"),
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview,
sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly {
network_access: false,
},
active_permission_profile: Some(
codex_app_server_protocol::ActivePermissionProfile::read_only(),
),
model: "gpt-plan".to_string(),
model_provider: "openai".to_string(),
service_tier: None,
effort: collaboration_mode.settings.reasoning_effort.clone(),
summary: None,
collaboration_mode: collaboration_mode.clone(),
multi_agent_mode: Default::default(),
personality: Some(Personality::Pragmatic),
},
};
app.enqueue_thread_notification(
inactive_thread_id,
ServerNotification::ThreadSettingsUpdated(notification),
)
.await
.expect("settings notification should be cached");
let cached_session = app
.thread_event_channels
.get(&inactive_thread_id)
.expect("inactive thread channel")
.store
.lock()
.await
.session
.clone()
.expect("inactive session should remain cached");
assert_eq!(cached_session.model, "gpt-test");
assert_eq!(cached_session.personality, Some(Personality::Pragmatic));
assert_eq!(
cached_session.collaboration_mode.as_deref(),
Some(&collaboration_mode)
);
app.chat_widget.handle_thread_session(cached_session);
assert_eq!(
app.chat_widget.active_collaboration_mode_kind(),
ModeKind::Plan
);
assert_eq!(app.chat_widget.current_model(), "gpt-plan");
assert_eq!(
app.chat_widget.current_collaboration_mode().model(),
"gpt-test"
);
assert_eq!(
app.chat_widget.current_reasoning_effort(),
Some(ReasoningEffortConfig::High)
);
assert_eq!(
app.chat_widget.config_ref().personality,
Some(Personality::Pragmatic)
);
}
#[tokio::test]
async fn clear_only_ui_reset_preserves_chat_session_state() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.chat_widget
.handle_thread_session(crate::session_state::ThreadSessionState {
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: Some("keep me".to_string()),
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/tmp/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
});
app.chat_widget
.apply_external_edit("draft prompt".to_string());
app.transcript_cells = vec![Arc::new(UserHistoryCell {
message: "old message".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>];
app.overlay = Some(Overlay::new_transcript(
app.transcript_cells.clone(),
crate::keymap::RuntimeKeymap::defaults().pager,
));
app.deferred_history_lines = vec![Line::from("stale buffered line").into()];
app.has_emitted_history_lines = true;
app.backtrack.primed = true;
app.backtrack.overlay_preview_active = true;
app.backtrack.nth_user_message = 0;
app.backtrack_render_pending = true;
app.reset_app_ui_state_after_clear();
assert!(app.overlay.is_none());
assert!(app.transcript_cells.is_empty());
assert!(app.deferred_history_lines.is_empty());
assert!(!app.has_emitted_history_lines);
assert!(!app.backtrack.primed);
assert!(!app.backtrack.overlay_preview_active);
assert!(!app.backtrack_render_pending);
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt");
}
#[tokio::test]
async fn clear_only_ui_reset_allows_active_skill_warning_to_render_again() {
let mut app = make_test_app().await;
let error = SkillErrorInfo {
path: test_path_buf("/tmp/project/.codex/skills/abc/SKILL.md"),
message: "invalid description".to_string(),
};
assert_eq!(
app.skill_load_warnings
.newly_active_errors(std::slice::from_ref(&error)),
vec![error.clone()]
);
assert_eq!(
app.skill_load_warnings
.newly_active_errors(std::slice::from_ref(&error)),
Vec::<SkillErrorInfo>::new()
);
app.reset_app_ui_state_after_clear();
assert_eq!(
app.skill_load_warnings
.newly_active_errors(std::slice::from_ref(&error)),
vec![error]
);
}
#[tokio::test]
async fn backtrack_esc_does_not_steal_empty_vim_insert_escape() {
let mut app = make_test_app().await;
let esc = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Esc, KeyModifiers::NONE);
assert!(app.chat_widget.composer_is_empty());
assert!(app.should_handle_backtrack_esc(esc));
app.chat_widget.toggle_vim_mode_and_notify();
assert!(app.should_handle_backtrack_esc(esc));
app.chat_widget
.handle_key_event(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('i'),
KeyModifiers::NONE,
));
assert!(app.chat_widget.should_handle_vim_insert_escape(esc));
assert!(!app.should_handle_backtrack_esc(esc));
app.chat_widget.handle_key_event(esc);
assert!(!app.backtrack.primed);
assert!(!app.chat_widget.should_handle_vim_insert_escape(esc));
assert!(app.should_handle_backtrack_esc(esc));
}
#[tokio::test]
async fn side_conversations_reject_backtrack_esc_without_stealing_vim_insert_escape() {
let mut app = make_test_app().await;
let esc = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Esc, KeyModifiers::NONE);
app.chat_widget
.set_side_conversation_active(/*active*/ true);
assert!(app.chat_widget.composer_is_empty());
assert!(!app.should_handle_backtrack_esc(esc));
assert!(app.should_reject_side_backtrack_esc(esc));
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget
.handle_key_event(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('i'),
KeyModifiers::NONE,
));
assert!(app.chat_widget.should_handle_vim_insert_escape(esc));
assert!(!app.should_handle_backtrack_esc(esc));
assert!(!app.should_reject_side_backtrack_esc(esc));
}
#[tokio::test]
async fn side_backtrack_rejection_reports_unavailable_message_snapshot() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.backtrack.primed = true;
app.reject_side_backtrack_esc();
assert!(!app.backtrack.primed);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = cell
.display_lines(/*width*/ 80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert_app_snapshot!(
"side_backtrack_rejection_reports_unavailable_message",
rendered
);
}
async fn start_config_write_test_app_server(app: &App) -> Result<AppServerSession> {
Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await
}
#[path = "tests/active_reconnect_tests.rs"]
mod active_reconnect;
#[cfg(unix)]
#[path = "tests/navigation_reconnect_tests.rs"]
mod navigation_reconnect;