Files
codex/codex-rs/tui/src/app/tests.rs
T
Felipe Coury 7a26497836 feat(tui): add OSC 8 web links to rich content (#24472)
## Why

Wrapped URLs in rich TUI output, especially URLs rendered inside
Markdown tables, are split across terminal rows. In terminals that
support OSC 8 hyperlinks, treating each visible fragment as part of the
complete destination enables reliable open-link and copy-link actions
even after table layout wraps the URL.

This addresses the semantic-link portion of #12200 and the behavior
described in
https://github.com/openai/codex/issues/12200#issuecomment-4535452980. It
does not change ordinary drag-selection across bordered table rows.

## What Changed

- Added shared TUI OSC 8 support that validates `http://` and `https://`
destinations, sanitizes terminal payloads, and applies metadata
separately from visible line width/layout.
- Added semantic web-link annotations to assistant and proposed-plan
Markdown, including explicit web links and bare web URLs in prose and
table cells while excluding code and non-web Markdown destinations.
- Preserved complete URL targets through table wrapping, narrow pipe
fallback, streaming, transcript overlay rendering, history insertion,
and resize replay.
- Routed intentional Codex-owned links in notices,
status/setup/app-link, feedback, onboarding, MCP/plugin help, memories,
and update surfaces through the shared hyperlink handling.

## How to Test

1. Run Codex in a terminal with OSC 8 link support, such as Ghostty, and
request an assistant response containing a Markdown table whose last
column contains a long `https://` URL.
2. Make the terminal narrow enough for the URL to wrap across multiple
bordered table rows.
3. Use the terminal's open-link or copy-link action on more than one
wrapped URL fragment and confirm each fragment resolves to the complete
original URL.
4. Resize the terminal after the table is rendered and repeat the link
action to confirm the destination survives scrollback replay.
5. Open the transcript overlay while rich output is present and confirm
web links remain interactive there.
6. As a regression check, render inline/fenced code containing URL text
and a Markdown link such as
`[https://example.com](mailto:support@example.com)`; confirm these do
not acquire a web OSC 8 destination.

Targeted automated coverage exercised Markdown links and exclusions,
wrapped and pipe-fallback tables, streaming/transcript overlay
propagation, status-link truncation, and rendered word-wrapping cell
alignment. `just test -p codex-tui` was also run; it passed the
hyperlink coverage and reproduced two unrelated existing guardian
feature-flag test failures.
2026-05-27 20:14:55 +00:00

5559 lines
195 KiB
Rust

//! App-level orchestration tests for the TUI.
mod model_catalog;
mod session_summary;
mod startup;
use super::*;
use crate::app_backtrack::BacktrackSelection;
use crate::app_backtrack::BacktrackState;
use crate::app_backtrack::user_count;
use crate::chatwidget::ChatWidgetInit;
use crate::chatwidget::create_initial_user_message;
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::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 assert_matches::assert_matches;
use crate::app_command::AppCommand as Op;
use crate::diff_model::FileChange;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::PermissionProfileSnapshot;
use crate::legacy_core::config::TerminalResizeReflowMaxRows;
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::ConfigWarningNotification;
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::SessionSource;
use codex_app_server_protocol::Thread;
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_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::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::prelude::Line;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
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")
}
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(
ServerNotification::ThreadSettingsUpdated(notification),
) = event
&& 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(),
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 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.enqueue_primary_thread_request(approval_request).await?;
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(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() {
if let AppEvent::SubmitThreadOp {
thread_id: op_thread_id,
..
} = app_event
{
assert_eq!(op_thread_id, thread_id);
return Ok(());
}
}
panic!("expected approval action to submit a thread-scoped op");
}
#[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(&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(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 = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit {
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,
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(),
content: vec![AppServerUserInput::Text {
text: "earlier prompt".to_string(),
text_elements: Vec::new(),
}],
}],
)],
)
.await?;
let mut saw_replayed_answer = 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");
}
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, .. }) => {
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.offset, 0);
assert_eq!(event.log_id, 1);
assert!(event.entry.is_none());
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 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;
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 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(
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(
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(turn_completed_notification(
thread_id,
"turn-0",
TurnStatus::Completed,
)),
ThreadBufferedEvent::Notification(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(
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(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(
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,
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(
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 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,
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()),
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()),
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,
is_closed: false,
})
);
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 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_home = codex_home.path().to_path_buf();
// 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_home.path().to_path_buf(),
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_home = codex_home.path().to_path_buf();
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 Auto-review"));
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(config.contains("guardian_approval = true"));
assert!(config.contains("approvals_reviewer = \"guardian_subagent\""));
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 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 ="));
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 = \"guardian_subagent\""));
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(ServerNotification::Warning(
WarningNotification {
thread_id: Some(thread_id.to_string()),
message: stale_warning.to_string(),
},
)),
ThreadBufferedEvent::Request(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");
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,
);
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(), 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);
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")]),
write: Some(vec![test_absolute_path("/tmp/write")]),
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 {
available_decisions,
network_approval_context,
additional_permissions,
..
})) = app
.interactive_request_for_thread_request(thread_id, &request)
.await
else {
panic!("expected exec approval request");
};
assert_eq!(
network_approval_context,
Some(AppServerNetworkApprovalContext {
host: "example.com".to_string(),
protocol: AppServerNetworkApprovalProtocol::Socks5Tcp,
})
);
assert_eq!(
additional_permissions,
Some(AdditionalPermissionProfile {
network: Some(AdditionalNetworkPermissions {
enabled: Some(true),
}),
file_system: Some(AdditionalFileSystemPermissions {
read: Some(vec![test_absolute_path("/tmp/read-only")]),
write: Some(vec![test_absolute_path("/tmp/write")]),
glob_scan_max_depth: None,
entries: None,
}),
})
);
assert_eq!(
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 { command, .. })) = app
.interactive_request_for_thread_request(thread_id, &request)
.await
else {
panic!("expected exec approval request");
};
assert_eq!(
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("expected file change approval request");
let ThreadInteractiveRequest::Approval(ApprovalRequest::ApplyPatch {
changes, reason, ..
}) = &request
else {
panic!("expected apply-patch approval request");
};
assert_eq!(
changes,
&HashMap::from([(
PathBuf::from("README.md"),
FileChange::Add {
content: "hello\n".to_string(),
},
)])
);
assert_eq!(
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 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(),
started_at_ms: 0,
cwd: test_absolute_path("/tmp"),
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")]),
write: Some(vec![test_absolute_path("/tmp/write")]),
glob_scan_max_depth: None,
entries: None,
}),
},
},
};
let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Permissions {
permissions, ..
})) = app
.interactive_request_for_thread_request(thread_id, &request)
.await
else {
panic!("expected permissions approval request");
};
assert_eq!(
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
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
.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 {
id: agent_thread_id.to_string(),
session_id: agent_thread_id.to_string(),
forked_from_id: None,
preview: "agent thread".to_string(),
ephemeral: false,
model_provider: "agent-provider".to_string(),
created_at: 1,
updated_at: 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,
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()),
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 {
id: agent_thread_id.to_string(),
session_id: agent_thread_id.to_string(),
forked_from_id: None,
preview: "agent thread".to_string(),
ephemeral: false,
model_provider: "agent-provider".to_string(),
created_at: 1,
updated_at: 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,
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 {
id: read_thread_id.to_string(),
session_id: read_thread_id.to_string(),
forked_from_id: None,
preview: "read thread".to_string(),
ephemeral: false,
model_provider: "read-provider".to_string(),
created_at: 1,
updated_at: 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,
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_tracks_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));
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
);
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(),
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 side_thread_ignores_global_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 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.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.sync_side_thread_ui();
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "sentry".to_string(),
status: McpServerStartupState::Failed,
error: Some("sentry is not logged in".to_string()),
}),
),
)
.await;
assert!(app_event_rx.try_recv().is_err());
}
#[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)
);
}
#[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 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.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 = crate::legacy_core::test_support::get_model_offline(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,
config,
state_db: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
runtime_approval_policy_override: None,
runtime_permission_profile_override: None,
file_search,
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
initial_history_replay_buffer: None,
enhanced_keys_supported: false,
keymap: crate::keymap::RuntimeKeymap::defaults(),
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
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,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
side_threads: HashMap::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(),
pending_startup_thread_start: false,
pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(),
}
}
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 = crate::legacy_core::test_support::get_model_offline(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,
config,
state_db: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
runtime_approval_policy_override: None,
runtime_permission_profile_override: None,
file_search,
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
transcript_reflow: TranscriptReflowState::default(),
initial_history_replay_buffer: None,
enhanced_keys_supported: false,
keymap: crate::keymap::RuntimeKeymap::defaults(),
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
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,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
side_threads: HashMap::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(),
pending_startup_thread_start: false,
pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(),
},
rx,
op_rx,
)
}
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 enable_terminal_resize_reflow(app: &mut App) {
app.config
.features
.set_enabled(Feature::TerminalResizeReflow, /*enabled*/ true)
.expect("feature should be configurable");
}
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.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(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![
"cell 17".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.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
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.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
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 uncapped_resize_reflow_renders_all_cells_under_row_limit() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(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;
enable_terminal_resize_reflow(&mut app);
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(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 thread_switch_replay_buffer_uses_transcript_tail_mode_when_row_cap_present() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
enable_terminal_resize_reflow(&mut app);
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(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;
enable_terminal_resize_reflow(&mut app);
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
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;
enable_terminal_resize_reflow(&mut app);
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());
}
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,
output_tokens: 5,
reasoning_output_tokens: 0,
},
last: TokenUsageBreakdown {
total_tokens: 10,
input_tokens: 4,
cached_input_tokens: 1,
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 {
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),
reason: Some("needs approval".to_string()),
network_approval_context: None,
command: Some("echo hello".to_string()),
cwd: Some(test_path_buf("/tmp/project").abs()),
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(),
},
}
}
#[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 = crate::legacy_core::test_support::construct_model_info_offline(model, 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 {
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(),
})
);
}
#[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_with_duplicate_history_targets_unique_turn() {
let (mut app, _app_event_rx, mut 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.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 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");
assert_eq!(selection.nth_user_message, 1);
assert_eq!(selection.prefill, edited_text);
assert_eq!(selection.text_elements, edited_text_elements);
assert_eq!(selection.local_image_paths, edited_local_image_paths);
assert_eq!(
selection.remote_image_urls,
vec!["https://example.com/backtrack.png".to_string()]
);
app.apply_backtrack_rollback(selection);
assert_eq!(
app.chat_widget.remote_image_urls(),
vec!["https://example.com/backtrack.png".to_string()]
);
let mut rollback_turns = None;
while let Ok(op) = op_rx.try_recv() {
if let Op::ThreadRollback { num_turns } = op {
rollback_turns = Some(num_turns);
}
}
assert_eq!(rollback_turns, Some(1));
}
#[tokio::test]
async fn backtrack_remote_image_only_selection_clears_existing_composer_draft() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
app.transcript_cells = vec![Arc::new(UserHistoryCell {
message: "original".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>];
app.chat_widget
.set_composer_text("stale draft".to_string(), Vec::new(), Vec::new());
let remote_image_url = "https://example.com/remote-only.png".to_string();
app.apply_backtrack_rollback(BacktrackSelection {
nth_user_message: 0,
prefill: String::new(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: vec![remote_image_url.clone()],
});
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
assert_eq!(app.chat_widget.remote_image_urls(), vec![remote_image_url]);
let mut rollback_turns = None;
while let Ok(op) = op_rx.try_recv() {
if let Op::ThreadRollback { num_turns } = op {
rollback_turns = Some(num_turns);
}
}
assert_eq!(rollback_turns, Some(1));
}
#[tokio::test]
async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().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: 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()),
});
let data_image_url = "data:image/png;base64,abc123".to_string();
app.transcript_cells = vec![Arc::new(UserHistoryCell {
message: "please inspect this".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: vec![data_image_url.clone()],
}) as Arc<dyn HistoryCell>];
app.apply_backtrack_rollback(BacktrackSelection {
nth_user_message: 0,
prefill: "please inspect this".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: vec![data_image_url.clone()],
});
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let mut saw_rollback = false;
let mut submitted_items: Option<Vec<UserInput>> = None;
while let Ok(op) = op_rx.try_recv() {
match op {
Op::ThreadRollback { .. } => saw_rollback = true,
Op::UserTurn { items, .. } => submitted_items = Some(items),
_ => {}
}
}
assert!(saw_rollback);
let items = submitted_items.expect("expected user turn after backtrack resubmit");
assert!(items.iter().any(|item| {
matches!(
item,
UserInput::Image { url, .. } if url == &data_image_url
)
}));
}
#[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(),
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(),
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,
},
],
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 {
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(),
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(
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(),
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(),
},
&mut snapshot,
)
.await;
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);
}
#[tokio::test]
async fn queued_rollback_syncs_overlay_and_clears_deferred_history() {
let mut app = make_test_app().await;
app.transcript_cells = vec![
Arc::new(UserHistoryCell {
message: "first".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("after first")],
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "second".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("after second")],
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>,
];
app.overlay = Some(Overlay::new_transcript(
app.transcript_cells.clone(),
app.keymap.pager.clone(),
));
app.deferred_history_lines = vec![Line::from("stale buffered line").into()];
app.backtrack.overlay_preview_active = true;
app.backtrack.nth_user_message = 1;
let changed = app.apply_non_pending_thread_rollback(/*num_turns*/ 1);
assert!(changed);
assert!(app.backtrack_render_pending);
assert!(app.deferred_history_lines.is_empty());
assert_eq!(app.backtrack.nth_user_message, 0);
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".to_string()]);
let overlay_cell_count = match app.overlay.as_ref() {
Some(Overlay::Transcript(t)) => t.committed_cell_count(),
_ => panic!("expected transcript overlay"),
};
assert_eq!(overlay_cell_count, app.transcript_cells.len());
}
#[tokio::test]
async fn thread_rollback_response_discards_queued_active_thread_events() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let (tx, rx) = mpsc::channel(8);
app.active_thread_id = Some(thread_id);
app.active_thread_rx = Some(rx);
tx.send(ThreadBufferedEvent::Notification(
ServerNotification::ConfigWarning(ConfigWarningNotification {
summary: "stale warning".to_string(),
details: None,
path: None,
range: None,
}),
))
.await
.expect("event should queue");
app.handle_thread_rollback_response(
thread_id,
/*num_turns*/ 1,
&ThreadRollbackResponse {
thread: Thread {
id: thread_id.to_string(),
session_id: thread_id.to_string(),
forked_from_id: None,
preview: String::new(),
ephemeral: false,
model_provider: "openai".to_string(),
created_at: 0,
updated_at: 0,
status: codex_app_server_protocol::ThreadStatus::Idle,
path: None,
cwd: test_path_buf("/tmp/project").abs(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
name: None,
turns: Vec::new(),
},
},
)
.await;
let rx = app
.active_thread_rx
.as_mut()
.expect("active receiver should remain attached");
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
}
#[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");
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);
})
.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;
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(
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 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,
summary: None,
collaboration_mode: collaboration_mode.clone(),
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.pending_rollback.is_none());
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 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
}