mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
## What changed Add `tui.keymap.global.hide_pane`, defaulting to `leader -` in workspace mode. Hiding a focused side conversation returns to its parent while retaining the conversation, drafts, and view state for reopening. Expose the action in the workspace keymap picker, validate binding conflicts, and suppress held shortcut keys after switching focus. ## Testing Add coverage for hiding and reopening at split-pane and narrow widths, preserving conversation state, shortcut availability, key capture, and repeat/release handling. GitOrigin-RevId: 6897c34b208041e6f90ae0e18c781eebd62566bd
10978 lines
399 KiB
Rust
10978 lines
399 KiB
Rust
//! App-level orchestration tests for the TUI.
|
||
|
||
#[path = "tests/terminal_size_telemetry_tests.rs"]
|
||
mod terminal_size_telemetry_tests;
|
||
|
||
#[path = "tests/copy_mode_tests.rs"]
|
||
mod copy_mode_tests;
|
||
#[path = "tests/mcp_login_tests.rs"]
|
||
mod mcp_login_tests;
|
||
|
||
#[path = "tests/math_interruption_tests.rs"]
|
||
mod math_interruption_tests;
|
||
#[path = "tests/security_setup_tests.rs"]
|
||
mod security_setup_tests;
|
||
|
||
#[path = "tests/advanced_reasoning_tests.rs"]
|
||
mod advanced_reasoning_tests;
|
||
#[path = "tests/agents_navigation_tests.rs"]
|
||
mod agents_navigation_tests;
|
||
#[path = "tests/approvals_reviewer_error_tests.rs"]
|
||
mod approvals_reviewer_error_tests;
|
||
#[path = "tests/backend_banner_fallback_tests.rs"]
|
||
mod backend_banner_fallback_tests;
|
||
#[path = "tests/backend_banner_recovery_tests.rs"]
|
||
mod backend_banner_recovery_tests;
|
||
#[path = "tests/backend_banner_startup_tests.rs"]
|
||
mod backend_banner_startup_tests;
|
||
#[path = "tests/background_exit_tests.rs"]
|
||
mod background_exit_tests;
|
||
#[path = "tests/background_task_defaults_tests.rs"]
|
||
mod background_task_defaults_tests;
|
||
#[path = "tests/browsing_pagination_tests.rs"]
|
||
mod browsing_pagination_tests;
|
||
#[path = "tests/buffered_replay.rs"]
|
||
mod buffered_replay;
|
||
#[path = "tests/connector_policy.rs"]
|
||
mod connector_policy;
|
||
#[path = "tests/disconnect_tests.rs"]
|
||
mod disconnect;
|
||
#[path = "tests/external_writer_fork_tests.rs"]
|
||
mod external_writer_fork_tests;
|
||
#[path = "tests/fork_workspace_roots_tests.rs"]
|
||
mod fork_workspace_roots_tests;
|
||
#[path = "tests/fresh_sparkle_tests.rs"]
|
||
mod fresh_sparkle_tests;
|
||
#[path = "tests/home_cleanup_tests.rs"]
|
||
mod home_cleanup_tests;
|
||
#[path = "tests/key_chords.rs"]
|
||
mod key_chords;
|
||
#[path = "tests/local_command_scroll_tests.rs"]
|
||
mod local_command_scroll_tests;
|
||
#[path = "tests/luna_reserve_recovery_tests.rs"]
|
||
mod luna_reserve_recovery_tests;
|
||
#[path = "tests/marketplace_upgrade_tests.rs"]
|
||
mod marketplace_upgrade_tests;
|
||
#[path = "tests/mcp_startup.rs"]
|
||
mod mcp_startup;
|
||
#[path = "tests/misalignment_policy_tests.rs"]
|
||
mod misalignment_policy;
|
||
mod model_catalog;
|
||
#[path = "tests/model_defaults_tests.rs"]
|
||
mod model_defaults;
|
||
#[path = "tests/pagination_completion_tests.rs"]
|
||
mod pagination_completion_tests;
|
||
#[path = "tests/patch_approval_tests.rs"]
|
||
mod patch_approval_tests;
|
||
#[path = "tests/permission_selection_tests.rs"]
|
||
mod permission_selection_tests;
|
||
#[path = "tests/projectless_tests.rs"]
|
||
mod projectless_tests;
|
||
#[path = "tests/quick_dials_tests.rs"]
|
||
mod quick_dials_tests;
|
||
#[path = "tests/unavailable_commands_tests.rs"]
|
||
mod unavailable_commands;
|
||
|
||
#[path = "tests/backend_sessions_tests.rs"]
|
||
mod backend_sessions;
|
||
#[path = "tests/history_hydration_tests.rs"]
|
||
mod history_hydration_tests;
|
||
#[path = "tests/permission_shortcuts_tests.rs"]
|
||
mod permission_shortcuts_tests;
|
||
mod plugin_catalog;
|
||
mod rate_limits;
|
||
#[path = "tests/realtime_handoff_e2e.rs"]
|
||
mod realtime_handoff_e2e;
|
||
#[path = "tests/realtime_requests.rs"]
|
||
mod realtime_requests;
|
||
#[path = "tests/realtime_start.rs"]
|
||
mod realtime_start;
|
||
#[path = "tests/reasoning_resume_tests.rs"]
|
||
mod reasoning_resume_tests;
|
||
#[path = "tests/recap_generation_tests.rs"]
|
||
mod recap_generation;
|
||
#[path = "tests/resume_shutdown_tests.rs"]
|
||
mod resume_shutdown_tests;
|
||
mod safety_buffering;
|
||
#[path = "tests/session_lifecycle_requests.rs"]
|
||
mod session_lifecycle_requests;
|
||
mod startup;
|
||
#[path = "tests/startup_frame_tests.rs"]
|
||
mod startup_frame_tests;
|
||
#[path = "tests/startup_warnings_tests.rs"]
|
||
mod startup_warnings_tests;
|
||
#[path = "tests/stream_animation_tests.rs"]
|
||
mod stream_animation_tests;
|
||
#[path = "tests/streaming_interleave_e2e.rs"]
|
||
mod streaming_interleave_e2e;
|
||
#[path = "tests/thread_usage.rs"]
|
||
mod thread_usage;
|
||
#[path = "tests/transcript_composer.rs"]
|
||
mod transcript_composer;
|
||
#[path = "tests/transcript_selection.rs"]
|
||
mod transcript_selection;
|
||
#[path = "tests/turn_submission.rs"]
|
||
mod turn_submission;
|
||
#[path = "tests/user_verification_routes_tests.rs"]
|
||
mod user_verification_routes;
|
||
#[path = "tests/worktree_background_terminals_tests.rs"]
|
||
mod worktree_background_terminals_tests;
|
||
|
||
use super::agent_navigation::AgentPickerThreadVisibility;
|
||
use super::owned_transcript::tests::buffer_text;
|
||
use super::test_support::enable_side_retention;
|
||
use super::test_support::side_frame;
|
||
use super::*;
|
||
use crate::app_backtrack::BacktrackSelection;
|
||
use crate::app_backtrack::BacktrackState;
|
||
use crate::app_backtrack::nth_user_position;
|
||
use crate::app_backtrack::user_count;
|
||
use crate::app_event::HistoryBatchEntryResponse;
|
||
use crate::test_support::boxed_future;
|
||
use codex_app_server_protocol::ThreadItem;
|
||
|
||
async fn drain_managed_worktree_start(app: &mut App, server: &mut AppServerSession) {
|
||
if let Some((mode, name)) = app.pending_start_managed_worktree.take() {
|
||
app.start_managed_worktree(server, mode, name).await;
|
||
}
|
||
}
|
||
use codex_utils_absolute_path::test_support::PathExt;
|
||
|
||
use crate::chatwidget::ChatWidgetInit;
|
||
use crate::chatwidget::create_initial_user_message;
|
||
use crate::chatwidget::tests::helpers::normalize_snapshot_paths;
|
||
use crate::chatwidget::tests::helpers::render_bottom_popup;
|
||
use crate::chatwidget::tests::helpers::set_active_cell;
|
||
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
|
||
use crate::chatwidget::tests::set_chatgpt_auth;
|
||
use crate::chatwidget::tests::set_fast_mode_test_catalog;
|
||
use crate::file_search::FileSearchManager;
|
||
use crate::goal_files;
|
||
use crate::history_cell::AgentMarkdownCell;
|
||
use crate::history_cell::AgentMessageCell;
|
||
use crate::history_cell::HistoryCell;
|
||
use crate::history_cell::PlainHistoryCell;
|
||
use crate::history_cell::UserHistoryCell;
|
||
use crate::history_cell::new_session_info;
|
||
use crate::multi_agents::AgentPickerThreadEntry;
|
||
use crate::multi_agents::SubAgentActivityDisplay;
|
||
use assert_matches::assert_matches;
|
||
|
||
use crate::app_command::AppCommand as Op;
|
||
use crate::app_event::ConsolidationScrollbackReflow;
|
||
use crate::diff_model::FileChange;
|
||
use crate::legacy_core::config::ConfigBuilder;
|
||
use crate::legacy_core::config::ConfigOverrides;
|
||
use crate::legacy_core::config::PermissionProfileSnapshot;
|
||
use codex_app_server_client::AppServerPath;
|
||
use codex_app_server_protocol::AdditionalFileSystemPermissions;
|
||
use codex_app_server_protocol::AdditionalNetworkPermissions;
|
||
use codex_app_server_protocol::AdditionalPermissionProfile;
|
||
use codex_app_server_protocol::AgentMessageDeltaNotification;
|
||
use codex_app_server_protocol::AskForApproval;
|
||
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
|
||
use codex_app_server_protocol::FileChangeRequestApprovalParams;
|
||
use codex_app_server_protocol::FileUpdateChange;
|
||
use codex_app_server_protocol::ItemStartedNotification;
|
||
use codex_app_server_protocol::JSONRPCErrorError;
|
||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||
use codex_app_server_protocol::McpServerStartupState;
|
||
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
|
||
use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext;
|
||
use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol;
|
||
use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyAmendment;
|
||
use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction;
|
||
use codex_app_server_protocol::NonSteerableTurnKind as AppServerNonSteerableTurnKind;
|
||
use codex_app_server_protocol::PatchChangeKind;
|
||
use codex_app_server_protocol::PermissionsRequestApprovalParams;
|
||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||
use codex_app_server_protocol::ServerNotification;
|
||
use codex_app_server_protocol::ServerRequest;
|
||
use codex_app_server_protocol::Thread;
|
||
use codex_app_server_protocol::ThreadArchivedNotification;
|
||
use codex_app_server_protocol::ThreadAttachmentOperation;
|
||
use codex_app_server_protocol::ThreadAttachmentUpdatedNotification;
|
||
use codex_app_server_protocol::ThreadClosedNotification;
|
||
use codex_app_server_protocol::ThreadItemPayload;
|
||
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::ThreadUnarchivedNotification;
|
||
use codex_app_server_protocol::TokenUsageBreakdown;
|
||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||
use codex_app_server_protocol::Turn;
|
||
use codex_app_server_protocol::TurnCompletedNotification;
|
||
use codex_app_server_protocol::TurnError as AppServerTurnError;
|
||
use codex_app_server_protocol::TurnStartedNotification;
|
||
use codex_app_server_protocol::TurnStatus;
|
||
use codex_app_server_protocol::UserInput;
|
||
use codex_app_server_protocol::UserInput as AppServerUserInput;
|
||
use codex_app_server_protocol::WarningNotification;
|
||
use codex_history::RolloutItem;
|
||
use codex_models_manager::test_support::construct_model_info_offline_for_tests;
|
||
use codex_models_manager::test_support::get_model_offline_for_tests;
|
||
use codex_otel::SessionTelemetry;
|
||
use codex_protocol::ThreadId;
|
||
use codex_protocol::config_types::CollaborationMode;
|
||
use codex_protocol::config_types::CollaborationModeMask;
|
||
use codex_protocol::config_types::ModeKind;
|
||
use codex_protocol::config_types::SandboxMode;
|
||
use codex_protocol::config_types::ServiceTier;
|
||
use codex_protocol::config_types::Settings;
|
||
use codex_protocol::models::ActivePermissionProfile;
|
||
use codex_protocol::models::FileSystemPermissions;
|
||
use codex_protocol::models::NetworkPermissions;
|
||
use codex_protocol::models::PermissionProfile;
|
||
use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER;
|
||
use codex_protocol::protocol::EventMsg;
|
||
use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS;
|
||
use codex_protocol::protocol::MultiAgentVersion;
|
||
use codex_protocol::protocol::SessionMeta;
|
||
use codex_protocol::protocol::SessionSource as RolloutSessionSource;
|
||
use codex_protocol::protocol::SubAgentSource;
|
||
use codex_protocol::protocol::TurnCompleteEvent;
|
||
use codex_protocol::protocol::TurnStartedEvent;
|
||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||
use codex_protocol::user_input::TextElement;
|
||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||
use crossterm::event::KeyModifiers;
|
||
use insta::assert_snapshot;
|
||
use pretty_assertions::assert_eq;
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::prelude::Line;
|
||
use std::path::Path;
|
||
use std::path::PathBuf;
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::AtomicBool;
|
||
use std::sync::atomic::AtomicUsize;
|
||
use std::sync::atomic::Ordering;
|
||
use tempfile::tempdir;
|
||
use tokio::time;
|
||
|
||
macro_rules! assert_app_snapshot {
|
||
($name:expr, $value:expr $(,)?) => {
|
||
insta::with_settings!({snapshot_path => "../snapshots"}, {
|
||
assert_snapshot!($name, $value);
|
||
});
|
||
};
|
||
}
|
||
|
||
fn test_absolute_path(path: &str) -> AbsolutePathBuf {
|
||
AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path")
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn pasted_text_normalizes_mixed_line_endings_at_app_boundary() -> Result<()> {
|
||
let mut app = make_test_app().await;
|
||
let mut app_server = start_config_write_test_app_server(&app).await?;
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
TuiEvent::Paste("line1\r\nline2\rline3\nline4".to_string()),
|
||
)
|
||
.await?;
|
||
|
||
assert_snapshot!(app.chat_widget.composer_text_with_pending(), @r"
|
||
line1
|
||
line2
|
||
line3
|
||
line4
|
||
");
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn program_status_acknowledges_deliberate_mouse_events() -> Result<()> {
|
||
use crate::terminal_program_status::ProgramStatus;
|
||
use crossterm::event::MouseButton;
|
||
use crossterm::event::MouseEvent;
|
||
use crossterm::event::MouseEventKind;
|
||
|
||
let mut app = make_test_app().await;
|
||
let mut app_server = start_config_write_test_app_server(&app).await?;
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
let thread_id = ThreadId::new();
|
||
for (turn_status, expected) in [
|
||
(TurnStatus::Completed, ProgramStatus::Done),
|
||
(TurnStatus::Failed, ProgramStatus::Error),
|
||
] {
|
||
for kind in [
|
||
MouseEventKind::Down(MouseButton::Left),
|
||
MouseEventKind::Drag(MouseButton::Left),
|
||
MouseEventKind::Up(MouseButton::Left),
|
||
MouseEventKind::ScrollUp,
|
||
MouseEventKind::ScrollDown,
|
||
] {
|
||
app.chat_widget.handle_server_notification(
|
||
turn_started_notification(thread_id, "result"),
|
||
/*replay_kind*/ None,
|
||
);
|
||
app.chat_widget.handle_server_notification(
|
||
turn_completed_notification(thread_id, "result", turn_status.clone()),
|
||
/*replay_kind*/ None,
|
||
);
|
||
let before = app.chat_widget.desired_program_status();
|
||
let mut mouse = MouseEvent {
|
||
kind: MouseEventKind::Moved,
|
||
column: 1,
|
||
row: 1,
|
||
modifiers: KeyModifiers::NONE,
|
||
};
|
||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Mouse(mouse))
|
||
.await?;
|
||
let after_hover = app.chat_widget.desired_program_status();
|
||
mouse.kind = kind;
|
||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Mouse(mouse))
|
||
.await?;
|
||
assert_eq!(
|
||
[
|
||
before,
|
||
after_hover,
|
||
app.chat_widget.desired_program_status()
|
||
],
|
||
[expected, expected, ProgramStatus::Idle],
|
||
"after {turn_status:?}, {kind:?}",
|
||
);
|
||
}
|
||
}
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn duplicate_named_resume_renders_error_without_replacing_current_session() -> Result<()> {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let label = format!("duplicate-{}", ThreadId::new());
|
||
let runtime = codex_state::StateRuntime::init(
|
||
app.config.sqlite.clone(),
|
||
app.config.model_provider_id.clone(),
|
||
)
|
||
.await
|
||
.map_err(std::io::Error::other)?;
|
||
runtime
|
||
.mark_backfill_complete(/*last_watermark*/ None)
|
||
.await
|
||
.map_err(std::io::Error::other)?;
|
||
let mut thread_ids = Vec::new();
|
||
for _ in 0..2 {
|
||
let filename_ts = "2025-02-01T10-00-00";
|
||
let timestamp = "2025-02-01T10:00:00Z";
|
||
let thread_id = app_test_support::create_fake_rollout(
|
||
app.config.codex_home.as_path(),
|
||
filename_ts,
|
||
timestamp,
|
||
&label,
|
||
Some(&app.config.model_provider_id),
|
||
/*git_info*/ None,
|
||
)
|
||
.expect("materialized rollout should be created");
|
||
let path = app_test_support::rollout_path(
|
||
app.config.codex_home.as_path(),
|
||
filename_ts,
|
||
&thread_id,
|
||
);
|
||
let thread_id = ThreadId::from_string(&thread_id)?;
|
||
thread_ids.push(thread_id);
|
||
let created_at =
|
||
chrono::DateTime::parse_from_rfc3339(timestamp)?.with_timezone(&chrono::Utc);
|
||
let mut metadata = codex_state::ThreadMetadataBuilder::new(
|
||
thread_id,
|
||
path.to_path_buf(),
|
||
created_at,
|
||
serde_json::from_value(serde_json::json!("cli"))?,
|
||
)
|
||
.build(&app.config.model_provider_id);
|
||
metadata.title = label.clone();
|
||
metadata.first_user_message = Some(label.clone());
|
||
metadata.preview = Some(label.clone());
|
||
runtime
|
||
.upsert_thread(&metadata)
|
||
.await
|
||
.map_err(std::io::Error::other)?;
|
||
}
|
||
|
||
let current = ThreadId::new();
|
||
app.active_thread_id = Some(current);
|
||
app.transcript_cells = vec![plain_line_cell("Existing conversation")];
|
||
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
app.handle_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
AppEvent::ResumeSessionByIdOrName(label.clone()),
|
||
)
|
||
.await?;
|
||
while let Ok(event) = app_event_rx.try_recv() {
|
||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||
app.transcript_cells.push(Arc::from(cell));
|
||
}
|
||
}
|
||
let rendered = app
|
||
.render_transcript_lines_for_reflow(/*width*/ 80)
|
||
.lines
|
||
.iter()
|
||
.map(rendered_line_text)
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(
|
||
thread_ids
|
||
.iter()
|
||
.all(|id| rendered.contains(&id.to_string()))
|
||
);
|
||
let rendered = thread_ids
|
||
.iter()
|
||
.fold(rendered.replace(&label, "duplicate-label"), |text, id| {
|
||
text.replace(&id.to_string(), "<session UUID>")
|
||
});
|
||
assert_app_snapshot!("duplicate_named_resume_error", rendered);
|
||
assert_eq!(app.active_thread_id, Some(current));
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn chat_widget_frame_reuses_active_cell_height_across_frame_passes() {
|
||
#[derive(Debug)]
|
||
struct CountingHistoryCell {
|
||
desired_height_calls: Arc<AtomicUsize>,
|
||
}
|
||
|
||
impl HistoryCell for CountingHistoryCell {
|
||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||
vec![Line::from("active cell")]
|
||
}
|
||
|
||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||
vec![Line::from("active cell")]
|
||
}
|
||
|
||
fn desired_height(&self, _width: u16) -> u16 {
|
||
self.desired_height_calls.fetch_add(1, Ordering::Relaxed);
|
||
1
|
||
}
|
||
}
|
||
|
||
let mut app = make_test_app().await;
|
||
let desired_height_calls = Arc::new(AtomicUsize::new(0));
|
||
set_active_cell(
|
||
&mut app.chat_widget,
|
||
Box::new(CountingHistoryCell {
|
||
desired_height_calls: Arc::clone(&desired_height_calls),
|
||
}),
|
||
);
|
||
let width = 80;
|
||
app.with_chat_widget_frame(width, |desired_height, chat_widget| {
|
||
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, desired_height);
|
||
let mut buffer = Buffer::empty(area);
|
||
|
||
chat_widget.render(area, &mut buffer);
|
||
assert!(chat_widget.cursor_pos(area).is_some());
|
||
let _ = chat_widget.cursor_style(area);
|
||
});
|
||
|
||
assert_eq!(desired_height_calls.load(Ordering::Relaxed), 1);
|
||
}
|
||
|
||
async fn next_thread_settings_updated(
|
||
app_server: &mut AppServerSession,
|
||
thread_id: ThreadId,
|
||
) -> ThreadSettingsUpdatedNotification {
|
||
for _ in 0..20 {
|
||
let event = time::timeout(
|
||
std::time::Duration::from_secs(/*secs*/ 2),
|
||
app_server.next_event(),
|
||
)
|
||
.await
|
||
.expect("app-server should emit an event")
|
||
.expect("app-server event stream should remain open");
|
||
if let codex_app_server_client::AppServerEvent::ServerNotification(notification) =
|
||
event.event
|
||
&& let ServerNotification::ThreadSettingsUpdated(notification) = *notification
|
||
&& notification.thread_id == thread_id.to_string()
|
||
{
|
||
return notification;
|
||
}
|
||
}
|
||
panic!("expected ThreadSettingsUpdated for thread {thread_id}");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn handle_mcp_inventory_result_respects_origin_thread() {
|
||
let mut app = make_test_app().await;
|
||
app.transcript_cells
|
||
.push(Arc::new(history_cell::new_mcp_inventory_loading(
|
||
/*animations_enabled*/ false,
|
||
)));
|
||
|
||
app.handle_mcp_inventory_result(
|
||
Ok(vec![McpServerStatus {
|
||
server_capabilities: None,
|
||
tool_list_meta: None,
|
||
tools_error: None,
|
||
name: "docs".to_string(),
|
||
runtime_status: None,
|
||
plugin_id: None,
|
||
http_origin: None,
|
||
server_info: None,
|
||
tools: HashMap::new(),
|
||
resources: Vec::new(),
|
||
resource_templates: Vec::new(),
|
||
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
|
||
}]),
|
||
McpServerStatusDetail::ToolsAndAuthOnly,
|
||
/*thread_id*/ None,
|
||
);
|
||
|
||
assert_eq!(app.transcript_cells.len(), 0);
|
||
|
||
app.active_thread_id = Some(ThreadId::new());
|
||
app.transcript_cells
|
||
.push(Arc::new(history_cell::new_mcp_inventory_loading(
|
||
/*animations_enabled*/ false,
|
||
)));
|
||
|
||
app.handle_mcp_inventory_result(
|
||
Ok(Vec::new()),
|
||
McpServerStatusDetail::ToolsAndAuthOnly,
|
||
Some(ThreadId::new()),
|
||
);
|
||
|
||
assert_eq!(app.transcript_cells.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn bypass_hook_trust_startup_warning_snapshot() {
|
||
let rendered = lines_to_single_string(
|
||
&history_cell::new_warning_event(
|
||
"`--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation."
|
||
.to_string(),
|
||
)
|
||
.transcript_lines(/*width*/ 80),
|
||
);
|
||
|
||
assert_app_snapshot!("bypass_hook_trust_startup_warning", rendered);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn cyber_model_auto_review_notice_snapshot() -> Result<()> {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let mut app_server =
|
||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
|
||
app.handle_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
AppEvent::CyberModelAutoReviewNotice,
|
||
)
|
||
.await?;
|
||
|
||
let cell = match app_event_rx.try_recv() {
|
||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
||
};
|
||
let rendered = lines_to_single_string(&cell.transcript_lines(/*width*/ 80));
|
||
assert_app_snapshot!("cyber_model_auto_review_notice", rendered);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[serial_test::serial]
|
||
async fn external_editor_writable_directory_rejected_snapshot() -> Result<()> {
|
||
struct RestoreVisual(Option<std::ffi::OsString>);
|
||
|
||
impl Drop for RestoreVisual {
|
||
fn drop(&mut self) {
|
||
match self.0.take() {
|
||
Some(value) => unsafe { std::env::set_var("VISUAL", value) },
|
||
None => unsafe { std::env::remove_var("VISUAL") },
|
||
}
|
||
}
|
||
}
|
||
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let codex_home = app.chat_widget.config_ref().codex_home.clone();
|
||
let fallback_home = dirs::home_dir()
|
||
.expect("home directory")
|
||
.join(".codex")
|
||
.abs();
|
||
let workspace_codex_home = app.chat_widget.config_ref().cwd.join(".codex");
|
||
let permission_profile = PermissionProfile::workspace_write_with(
|
||
&[codex_home, fallback_home, workspace_codex_home],
|
||
codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
|
||
/*exclude_tmpdir_env_var*/ true,
|
||
/*exclude_slash_tmp*/ true,
|
||
);
|
||
app.chat_widget
|
||
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy(
|
||
permission_profile,
|
||
))?;
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
let restore_visual = RestoreVisual(std::env::var_os("VISUAL"));
|
||
unsafe { std::env::set_var("VISUAL", "editor") };
|
||
|
||
app.launch_external_editor(&mut tui).await;
|
||
drop(restore_visual);
|
||
|
||
let cell = match app_event_rx.try_recv() {
|
||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
||
};
|
||
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
|
||
assert_app_snapshot!("external_editor_writable_directory_rejected", rendered);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let (mut app_server, requests, proxy) = session_lifecycle_requests::start_recording_app_server(
|
||
&app.config,
|
||
/*blocked_thread_list*/ None,
|
||
/*failed_thread_name*/ None,
|
||
)
|
||
.await?;
|
||
let thread_id = ThreadId::new();
|
||
let approval_request =
|
||
exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None);
|
||
|
||
let buffered = super::app_server_requests::BufferedServerRequest::new(
|
||
approval_request.clone(),
|
||
&app_server.request_handle(),
|
||
);
|
||
assert_eq!(
|
||
app.pending_app_server_requests
|
||
.note_buffered_request(&buffered),
|
||
None
|
||
);
|
||
app.agents_overview.dispatched_requests.insert(
|
||
thread_id,
|
||
vec![super::app_server_requests::BufferedServerRequest::new(
|
||
approval_request.clone(),
|
||
&app_server.request_handle(),
|
||
)],
|
||
);
|
||
app.enqueue_primary_thread_request(approval_request.clone())
|
||
.await?;
|
||
app.handle_app_server_event(
|
||
&app_server,
|
||
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
|
||
ServerNotification::ServerRequestResolved(
|
||
codex_app_server_protocol::ServerRequestResolvedNotification {
|
||
thread_id: ThreadId::new().to_string(),
|
||
request_id: AppServerRequestId::Integer(1),
|
||
},
|
||
),
|
||
)),
|
||
)
|
||
.await;
|
||
assert_eq!(app.agents_overview.dispatched_requests[&thread_id].len(), 1);
|
||
app.enqueue_primary_thread_session(
|
||
test_thread_session(thread_id, test_path_buf("/tmp/project")),
|
||
Vec::new(),
|
||
)
|
||
.await?;
|
||
|
||
let rx = app
|
||
.active_thread_rx
|
||
.as_mut()
|
||
.expect("primary thread receiver should be active");
|
||
let event = time::timeout(Duration::from_millis(50), rx.recv())
|
||
.await
|
||
.expect("timed out waiting for buffered approval event")
|
||
.expect("channel closed unexpectedly");
|
||
|
||
assert!(matches!(
|
||
&event,
|
||
ThreadBufferedEvent::Request(request)
|
||
if matches!(
|
||
request.as_ref(),
|
||
ServerRequest::CommandExecutionRequestApproval { params, .. }
|
||
if params.turn_id == "turn-1"
|
||
)
|
||
));
|
||
|
||
app.handle_thread_event_now(event);
|
||
let background_thread_id = ThreadId::new();
|
||
let mut background_request = approval_request;
|
||
let ServerRequest::CommandExecutionRequestApproval { request_id, params } =
|
||
&mut background_request
|
||
else {
|
||
unreachable!("expected a command approval request");
|
||
};
|
||
*request_id = AppServerRequestId::Integer(2);
|
||
params.thread_id = background_thread_id.to_string();
|
||
let buffered = super::app_server_requests::BufferedServerRequest::new(
|
||
background_request.clone(),
|
||
&app_server.request_handle(),
|
||
);
|
||
app.pending_app_server_requests
|
||
.note_buffered_request(&buffered);
|
||
app.enqueue_thread_request(background_thread_id, background_request.clone())
|
||
.await?;
|
||
|
||
crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id);
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
app.replace_chat_widget_with_app_server_thread(
|
||
&mut tui,
|
||
AppServerStartedThread {
|
||
session: test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()),
|
||
turns: Vec::new(),
|
||
blocks_direct_input: false,
|
||
task_tools_available: false,
|
||
},
|
||
session_lifecycle::ThreadAttachPresentation::SessionLineage,
|
||
/*initial_user_message*/ None,
|
||
)
|
||
.await?;
|
||
Box::pin(app.select_agent_thread(&mut tui, &mut app_server, thread_id)).await?;
|
||
|
||
assert!(
|
||
!app.pending_app_server_requests
|
||
.contains_server_request(&background_request)
|
||
);
|
||
let buffered = super::app_server_requests::BufferedServerRequest::new(
|
||
background_request.clone(),
|
||
&app_server.request_handle(),
|
||
);
|
||
app.pending_app_server_requests
|
||
.note_buffered_request(&buffered);
|
||
app.enqueue_thread_request(background_thread_id, background_request)
|
||
.await?;
|
||
|
||
for expected_thread_id in [thread_id, background_thread_id] {
|
||
assert!(app.chat_widget.has_active_view());
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||
let (actual_thread_id, op) = std::iter::from_fn(|| app_event_rx.try_recv().ok())
|
||
.find_map(|event| match event {
|
||
AppEvent::SubmitThreadOp { thread_id, op } => Some((thread_id, op)),
|
||
_ => None,
|
||
})
|
||
.expect("approval should submit a thread-scoped operation");
|
||
assert_eq!(actual_thread_id, expected_thread_id);
|
||
app.submit_thread_op(&mut app_server, expected_thread_id, op)
|
||
.await?;
|
||
}
|
||
assert!(!app.chat_widget.has_active_view());
|
||
|
||
app_server.shutdown().await?;
|
||
proxy.await??;
|
||
assert_eq!(
|
||
requests
|
||
.lock()
|
||
.expect("request recorder lock")
|
||
.iter()
|
||
.filter(|request| request.method == "server/request/response")
|
||
.map(|request| request.id.clone())
|
||
.collect::<Vec<_>>(),
|
||
(1..=2).map(AppServerRequestId::Integer).collect::<Vec<_>>()
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn resolved_buffered_approval_does_not_become_actionable_after_drain() -> Result<()> {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let thread_id = ThreadId::new();
|
||
let approval_request =
|
||
exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None);
|
||
|
||
app.enqueue_primary_thread_session(
|
||
test_thread_session(thread_id, test_path_buf("/tmp/project")),
|
||
Vec::new(),
|
||
)
|
||
.await?;
|
||
while app_event_rx.try_recv().is_ok() {}
|
||
|
||
assert_eq!(
|
||
app.pending_app_server_requests
|
||
.note_server_request(&approval_request),
|
||
None
|
||
);
|
||
app.enqueue_thread_request(thread_id, approval_request)
|
||
.await?;
|
||
|
||
let resolved = app
|
||
.pending_app_server_requests
|
||
.resolve_notification(&thread_id.to_string(), &AppServerRequestId::Integer(1))
|
||
.expect("matching app-server request should resolve");
|
||
app.chat_widget
|
||
.dismiss_app_server_request(&resolved.request);
|
||
while app_event_rx.try_recv().is_ok() {}
|
||
|
||
let rx = app
|
||
.active_thread_rx
|
||
.as_mut()
|
||
.expect("primary thread receiver should be active");
|
||
let event = time::timeout(Duration::from_millis(50), rx.recv())
|
||
.await
|
||
.expect("timed out waiting for buffered approval event")
|
||
.expect("channel closed unexpectedly");
|
||
|
||
assert!(matches!(
|
||
&event,
|
||
ThreadBufferedEvent::Request(request)
|
||
if matches!(
|
||
request.as_ref(),
|
||
ServerRequest::CommandExecutionRequestApproval { params, .. }
|
||
if params.turn_id == "turn-1"
|
||
)
|
||
));
|
||
|
||
app.handle_thread_event_now(event);
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||
|
||
while let Ok(app_event) = app_event_rx.try_recv() {
|
||
assert!(
|
||
!matches!(app_event, AppEvent::SubmitThreadOp { .. }),
|
||
"resolved buffered approval should not become actionable"
|
||
);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_resolution_keeps_matching_foreground_user_input_visible() -> Result<()> {
|
||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||
let background_thread = ThreadId::new();
|
||
let foreground_thread = ThreadId::new();
|
||
let background_request = request_user_input_request(background_thread, "turn-1", "question");
|
||
let foreground_request = request_user_input_request(foreground_thread, "turn-1", "question");
|
||
app.enqueue_primary_thread_session(
|
||
test_thread_session(foreground_thread, test_path_buf("/tmp/project")),
|
||
Vec::new(),
|
||
)
|
||
.await?;
|
||
app.pending_app_server_requests
|
||
.note_server_request(&background_request);
|
||
app.pending_app_server_requests
|
||
.note_server_request(&foreground_request);
|
||
app.handle_thread_event_now(ThreadBufferedEvent::Request(Box::new(foreground_request)));
|
||
assert!(app.chat_widget.has_active_view());
|
||
|
||
app.handle_app_server_event(
|
||
&app_server,
|
||
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
|
||
ServerNotification::ServerRequestResolved(
|
||
codex_app_server_protocol::ServerRequestResolvedNotification {
|
||
thread_id: background_thread.to_string(),
|
||
request_id: AppServerRequestId::Integer(2),
|
||
},
|
||
),
|
||
)),
|
||
)
|
||
.await;
|
||
assert!(app.chat_widget.has_active_view());
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
fn set_test_initial_prompt(app: &mut App, initial_prompt: String) {
|
||
let config = app.config.clone();
|
||
let model = get_model_offline_for_tests(config.model.as_deref());
|
||
app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit {
|
||
requires_openai_auth: true,
|
||
local_settings: crate::local_settings::LocalSettings::from(&config),
|
||
config,
|
||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||
app_event_tx: app.app_event_tx.clone(),
|
||
workspace_command_runner: None,
|
||
initial_user_message: create_initial_user_message(
|
||
Some(initial_prompt),
|
||
Vec::new(),
|
||
Vec::new(),
|
||
),
|
||
enhanced_keys_supported: false,
|
||
has_chatgpt_account: false,
|
||
has_codex_backend_auth: false,
|
||
model_catalog: app.model_catalog.clone(),
|
||
feedback: codex_feedback::CodexFeedback::new(),
|
||
is_first_run: false,
|
||
status_account_display: None,
|
||
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(),
|
||
});
|
||
}
|
||
|
||
#[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();
|
||
set_test_initial_prompt(&mut app, initial_prompt.clone());
|
||
|
||
app.enqueue_primary_thread_session(
|
||
test_thread_session(thread_id, test_path_buf("/tmp/project")),
|
||
vec![test_turn(
|
||
"turn-1",
|
||
TurnStatus::Completed,
|
||
vec![ThreadItemPayload::UserMessage {
|
||
id: "user-1".to_string(),
|
||
client_id: None,
|
||
content: vec![AppServerUserInput::Text {
|
||
text: "earlier prompt".to_string(),
|
||
text_elements: Vec::new(),
|
||
}],
|
||
}],
|
||
)],
|
||
)
|
||
.await?;
|
||
|
||
let mut saw_replayed_answer = false;
|
||
let mut saw_optimistic_prompt = false;
|
||
let mut submitted_items = None;
|
||
while let Ok(event) = app_event_rx.try_recv() {
|
||
match event {
|
||
AppEvent::InsertHistoryCell(cell) => {
|
||
let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80));
|
||
saw_replayed_answer |= transcript.contains("earlier prompt");
|
||
saw_optimistic_prompt |= transcript.contains(&initial_prompt);
|
||
}
|
||
AppEvent::SubmitThreadOp {
|
||
thread_id: op_thread_id,
|
||
op: Op::UserTurn { items, .. },
|
||
} => {
|
||
assert_eq!(op_thread_id, thread_id);
|
||
submitted_items = Some(items);
|
||
}
|
||
AppEvent::CodexOp(Op::UserTurn { items, .. }) => {
|
||
assert!(
|
||
saw_optimistic_prompt,
|
||
"expected optimistic prompt before turn submission"
|
||
);
|
||
submitted_items = Some(items);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
assert!(
|
||
saw_replayed_answer,
|
||
"expected replayed history before initial prompt submit"
|
||
);
|
||
assert_eq!(
|
||
submitted_items,
|
||
Some(vec![UserInput::Text {
|
||
text: initial_prompt,
|
||
text_elements: Vec::new(),
|
||
}])
|
||
);
|
||
|
||
for expected in ["follow-up after replay", "earlier prompt"] {
|
||
app.chat_widget.handle_key_event(KeyCode::Up.into());
|
||
assert_eq!(app.chat_widget.composer_text_with_pending(), expected);
|
||
}
|
||
|
||
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 app_server = crate::start_embedded_app_server_for_picker(&app.config)
|
||
.await
|
||
.expect("embedded app server");
|
||
let thread_id = ThreadId::new();
|
||
let side_thread_id = ThreadId::new();
|
||
let side_request = exec_approval_request(
|
||
side_thread_id,
|
||
"turn-1",
|
||
"call-1",
|
||
/*approval_id*/ None,
|
||
);
|
||
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);
|
||
app.thread_event_listener_tasks
|
||
.insert(side_thread_id, tokio::spawn(std::future::pending()));
|
||
app.thread_event_channels.insert(
|
||
side_thread_id,
|
||
ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY),
|
||
);
|
||
app.thread_event_channels[&side_thread_id]
|
||
.store
|
||
.lock()
|
||
.await
|
||
.push_request(side_request.clone());
|
||
app.side_threads
|
||
.insert(side_thread_id, SideThreadState::new(thread_id));
|
||
let buffered = super::app_server_requests::BufferedServerRequest::new(
|
||
side_request.clone(),
|
||
&app_server.request_handle(),
|
||
);
|
||
app.pending_app_server_requests
|
||
.note_buffered_request(&buffered);
|
||
app.pending_server_profiles.insert(
|
||
thread_id,
|
||
PermissionProfileSelection {
|
||
profile_id: "server-only".into(),
|
||
approval_policy: None,
|
||
approvals_reviewer: None,
|
||
display_label: "server-only".into(),
|
||
},
|
||
);
|
||
started_rx
|
||
.await
|
||
.expect("listener task should report it started");
|
||
|
||
app.reset_thread_event_state().await;
|
||
|
||
assert_eq!(
|
||
app.thread_event_listener_tasks
|
||
.keys()
|
||
.copied()
|
||
.collect::<Vec<_>>(),
|
||
vec![side_thread_id]
|
||
);
|
||
assert!(app.thread_event_channels.contains_key(&side_thread_id));
|
||
assert!(app.side_threads.contains_key(&side_thread_id));
|
||
assert!(
|
||
app.pending_app_server_requests
|
||
.contains_server_request(&side_request)
|
||
);
|
||
assert!(app.pending_server_profiles.is_empty());
|
||
time::timeout(Duration::from_millis(50), dropped_rx)
|
||
.await
|
||
.expect("timed out waiting for listener task abort")
|
||
.expect("listener task drop notification should succeed");
|
||
app_server.shutdown().await.expect("app server shutdown");
|
||
}
|
||
|
||
#[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();
|
||
|
||
let local_home = tempfile::tempdir()?;
|
||
app.local_settings.codex_home = AbsolutePathBuf::from_absolute_path(local_home.path())?;
|
||
let history_config = codex_message_history::HistoryConfig::new(
|
||
app.local_settings.codex_home.clone(),
|
||
&app.local_settings.history,
|
||
);
|
||
codex_message_history::append_entry("local prompt", thread_id, &history_config).await?;
|
||
let (log_id, _) = codex_message_history::history_metadata(&history_config).await;
|
||
|
||
app.lookup_message_history_entry(thread_id, /*offset*/ 0, log_id)
|
||
.await?;
|
||
|
||
let app_event = tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv())
|
||
.await
|
||
.expect("history lookup should emit an app event")
|
||
.expect("app event channel should stay open");
|
||
|
||
let AppEvent::ThreadHistoryEntryResponse {
|
||
thread_id: routed_thread_id,
|
||
event,
|
||
} = app_event
|
||
else {
|
||
panic!("expected thread-routed history response");
|
||
};
|
||
assert_eq!(routed_thread_id, thread_id);
|
||
assert_eq!(
|
||
event,
|
||
HistoryLookupResponse::Entry {
|
||
offset: 0,
|
||
log_id,
|
||
entry: Some("local prompt".to_string()),
|
||
}
|
||
);
|
||
|
||
let cursor = codex_message_history::HistoryBatchCursor::new(/*end_offset*/ 1);
|
||
app.lookup_message_history_batch(thread_id, cursor, log_id)
|
||
.await?;
|
||
let app_event = tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv())
|
||
.await
|
||
.expect("history batch lookup should emit an app event")
|
||
.expect("app event channel should stay open");
|
||
let AppEvent::ThreadHistoryEntryResponse {
|
||
thread_id: routed_thread_id,
|
||
event,
|
||
} = app_event
|
||
else {
|
||
panic!("expected thread-routed history batch response");
|
||
};
|
||
assert_eq!(routed_thread_id, thread_id);
|
||
assert_eq!(
|
||
event,
|
||
HistoryLookupResponse::Batch {
|
||
cursor,
|
||
log_id,
|
||
entries: vec![HistoryBatchEntryResponse {
|
||
offset: 0,
|
||
entry: Some("local prompt".to_string()),
|
||
}],
|
||
next_older_cursor: 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 active_thread_drain_yields_after_frame_deadline_without_dropping_events() -> Result<()> {
|
||
let mut app = make_test_app().await;
|
||
app.startup_protected_input_boundary = true;
|
||
let thread_id = ThreadId::new();
|
||
app.thread_event_channels
|
||
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 2));
|
||
app.activate_thread_channel(thread_id).await;
|
||
|
||
let first_event = token_usage_notification(thread_id, "turn-1", Some(100));
|
||
let mut second_event = token_usage_notification(thread_id, "turn-1", Some(100));
|
||
if let ServerNotification::ThreadTokenUsageUpdated(notification) = &mut second_event {
|
||
notification.token_usage.total.output_tokens = 10;
|
||
notification.token_usage.total.total_tokens = 15;
|
||
}
|
||
for event in [first_event, second_event] {
|
||
app.enqueue_thread_notification(thread_id, event).await?;
|
||
}
|
||
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
app.drain_active_thread_events_until(&mut tui, Instant::now())
|
||
.await?;
|
||
assert_eq!(
|
||
app.chat_widget.token_usage(),
|
||
crate::token_usage::TokenUsage {
|
||
input_tokens: 4,
|
||
cached_input_tokens: 1,
|
||
output_tokens: 5,
|
||
reasoning_output_tokens: 0,
|
||
total_tokens: 10,
|
||
}
|
||
);
|
||
assert!(
|
||
app.active_thread_rx
|
||
.as_ref()
|
||
.is_some_and(|receiver| !receiver.is_empty()),
|
||
"an expired frame deadline should preserve queued notifications"
|
||
);
|
||
assert!(
|
||
!app.has_queued_startup_protected_request(),
|
||
"ordinary notifications left by the frame deadline must not block terminal input"
|
||
);
|
||
|
||
app.drain_active_thread_events(&mut tui).await?;
|
||
assert!(
|
||
app.active_thread_rx
|
||
.as_ref()
|
||
.is_some_and(tokio::sync::mpsc::Receiver::is_empty),
|
||
"the next foreground frame should deliver the preserved notification"
|
||
);
|
||
assert_eq!(
|
||
app.chat_widget.token_usage(),
|
||
crate::token_usage::TokenUsage {
|
||
input_tokens: 4,
|
||
cached_input_tokens: 1,
|
||
output_tokens: 10,
|
||
reasoning_output_tokens: 0,
|
||
total_tokens: 15,
|
||
}
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
async fn drain_app_events(
|
||
app: &mut App,
|
||
tui: &mut tui::Tui,
|
||
server: &mut AppServerSession,
|
||
events: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||
) -> Result<()> {
|
||
while let Ok(event) = events.try_recv() {
|
||
boxed_future!(app.handle_event(tui, server, event)).await?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn read_side_history(app: &mut App, tui: &mut tui::Tui, label: &str) -> Result<()> {
|
||
app.transcript_cells = (0..64)
|
||
.map(|row| plain_line_cell(format!("{label}-{row:02}")))
|
||
.collect();
|
||
app.refresh_owned_transcript(tui)?;
|
||
app.transcript_view
|
||
.jump_to_entry(&app.transcript_cells, /*index*/ 24);
|
||
app.refresh_owned_transcript(tui)
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn selected_side_thread_close_is_handled_by_foreground_event_owner() -> Result<()> {
|
||
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
|
||
app.config.features.enable(Feature::TuiWorkspace)?;
|
||
app.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Owned;
|
||
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||
let primary = app_server.start_thread(&app.config).await?;
|
||
let primary_thread_id = primary.session.thread_id;
|
||
app.enqueue_primary_thread_session(primary.session, primary.turns)
|
||
.await?;
|
||
|
||
let side_thread_id = ThreadId::new();
|
||
let side_channel = ThreadEventChannel::new(/*capacity*/ 1);
|
||
side_channel.store.lock().await.set_session(
|
||
test_thread_session(side_thread_id, app.config.cwd.to_path_buf()),
|
||
Vec::new(),
|
||
);
|
||
side_channel
|
||
.sender
|
||
.try_send(ThreadBufferedEvent::Notification(Box::new(
|
||
thread_closed_notification(side_thread_id),
|
||
)))
|
||
.expect("closed side-thread notification should fit in its saved receiver");
|
||
app.thread_event_channels
|
||
.insert(side_thread_id, side_channel);
|
||
app.side_threads
|
||
.insert(side_thread_id, SideThreadState::new(primary_thread_id));
|
||
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
tui.set_owned_screen(/*owned*/ true)?;
|
||
read_side_history(&mut app, &mut tui, "parent")?;
|
||
app.select_agent_thread(&mut tui, &mut app_server, side_thread_id)
|
||
.await?;
|
||
assert_eq!(app.active_thread_id, Some(side_thread_id));
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut events).await?;
|
||
let event = app
|
||
.active_thread_rx
|
||
.as_mut()
|
||
.expect("selected side thread should retain its receiver")
|
||
.try_recv()
|
||
.expect("thread closure should remain queued for the foreground loop");
|
||
app.handle_active_thread_event(&mut tui, &mut app_server, event)
|
||
.await?;
|
||
|
||
assert_eq!(app.active_thread_id, Some(primary_thread_id));
|
||
assert!(!app.side_threads.contains_key(&side_thread_id));
|
||
assert!(!app.thread_event_channels.contains_key(&side_thread_id));
|
||
assert!(app.active_thread_rx.is_some());
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut events).await?;
|
||
app.refresh_owned_transcript(&mut tui)?;
|
||
assert!(buffer_text(tui.terminal.previous_buffer()).contains("parent-24"));
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn active_history_batch_is_delivered_without_replay_buffering() -> Result<()> {
|
||
let mut app = make_test_app().await;
|
||
let thread_id = ThreadId::new();
|
||
app.thread_event_channels
|
||
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 4));
|
||
app.set_thread_active(thread_id, /*active*/ true).await;
|
||
|
||
let cursor = codex_message_history::HistoryBatchCursor::new(/*end_offset*/ 1);
|
||
let event = HistoryLookupResponse::Batch {
|
||
cursor,
|
||
log_id: 1,
|
||
entries: vec![HistoryBatchEntryResponse {
|
||
offset: 1,
|
||
entry: Some("history entry".to_string()),
|
||
}],
|
||
next_older_cursor: Some(codex_message_history::HistoryBatchCursor::new(
|
||
/*end_offset*/ 0,
|
||
)),
|
||
};
|
||
app.enqueue_thread_history_entry_response(thread_id, event.clone())
|
||
.await?;
|
||
|
||
let channel = app
|
||
.thread_event_channels
|
||
.get_mut(&thread_id)
|
||
.expect("missing thread channel");
|
||
assert!(channel.store.lock().await.buffer.is_empty());
|
||
let mut receiver = channel.receiver.take().expect("missing receiver");
|
||
let delivered = time::timeout(Duration::from_millis(50), receiver.recv())
|
||
.await
|
||
.expect("timed out waiting for history batch")
|
||
.expect("missing history batch");
|
||
let ThreadBufferedEvent::HistoryEntryResponse(delivered) = delivered else {
|
||
panic!("expected history batch response");
|
||
};
|
||
assert_eq!(delivered, event);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn replay_thread_snapshot_renders_only_retained_agent_message_deltas() {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let thread_id = ThreadId::new();
|
||
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
||
app.thread_event_channels.insert(
|
||
thread_id,
|
||
ThreadEventChannel::new_with_session(
|
||
THREAD_EVENT_CHANNEL_CAPACITY,
|
||
session.clone(),
|
||
Vec::new(),
|
||
),
|
||
);
|
||
app.activate_thread_channel(thread_id).await;
|
||
app.chat_widget.handle_thread_session(session);
|
||
|
||
{
|
||
let channel = app
|
||
.thread_event_channels
|
||
.get(&thread_id)
|
||
.expect("thread channel should exist");
|
||
let mut store = channel.store.lock().await;
|
||
for chunk in 0..65 {
|
||
let marker = format!("chunk {chunk:02}");
|
||
let text = format!("{marker}\n{}\n", "x".repeat(4096 - marker.len() - 2));
|
||
store.push_notification(agent_message_delta_notification(
|
||
thread_id,
|
||
"turn-budget",
|
||
"item-budget",
|
||
&text,
|
||
));
|
||
}
|
||
}
|
||
|
||
app.store_active_thread_receiver().await;
|
||
let (_receiver, snapshot) = app
|
||
.activate_thread_for_replay(thread_id)
|
||
.await
|
||
.expect("detached thread should reactivate for replay");
|
||
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);
|
||
|
||
let mut rendered = Vec::new();
|
||
loop {
|
||
app.chat_widget.on_commit_tick();
|
||
let mut inserted = false;
|
||
while let Ok(event) = app_event_rx.try_recv() {
|
||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||
rendered.push(lines_to_single_string(&cell.display_lines(/*width*/ 80)));
|
||
inserted = true;
|
||
}
|
||
}
|
||
if !inserted {
|
||
break;
|
||
}
|
||
}
|
||
if let Some(lines) = app.chat_widget.active_cell_transcript_lines(/*width*/ 80) {
|
||
rendered.push(lines_to_single_string(&lines));
|
||
}
|
||
let markers = rendered
|
||
.iter()
|
||
.flat_map(|text| text.lines())
|
||
.filter_map(|line| line.split_once("chunk "))
|
||
.map(|(_, marker)| format!("chunk {}", marker.trim()))
|
||
.collect::<Vec<_>>();
|
||
|
||
assert_eq!(markers.len(), 64);
|
||
assert_snapshot!(
|
||
format!("{}\n{}", markers.first().expect("first marker"), markers.last().expect("last marker")),
|
||
@r"
|
||
chunk 01
|
||
chunk 64
|
||
"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn replay_thread_snapshot_restores_draft_and_queued_input() {
|
||
let mut app = make_test_app().await;
|
||
let thread_id = ThreadId::new();
|
||
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
||
app.thread_event_channels.insert(
|
||
thread_id,
|
||
ThreadEventChannel::new_with_session(
|
||
THREAD_EVENT_CHANNEL_CAPACITY,
|
||
session.clone(),
|
||
Vec::new(),
|
||
),
|
||
);
|
||
app.activate_thread_channel(thread_id).await;
|
||
app.chat_widget.handle_thread_session(session.clone());
|
||
|
||
app.chat_widget
|
||
.apply_external_edit("draft prompt".to_string());
|
||
app.chat_widget.submit_user_message_with_mode(
|
||
"queued follow-up".to_string(),
|
||
CollaborationModeMask {
|
||
name: "Default".to_string(),
|
||
mode: None,
|
||
model: None,
|
||
reasoning_effort: None,
|
||
developer_instructions: None,
|
||
},
|
||
);
|
||
let expected_input_state = app
|
||
.chat_widget
|
||
.capture_thread_input_state()
|
||
.expect("expected thread input state");
|
||
|
||
app.store_active_thread_receiver().await;
|
||
|
||
let snapshot = {
|
||
let channel = app
|
||
.thread_event_channels
|
||
.get(&thread_id)
|
||
.expect("thread channel should exist");
|
||
let store = channel.store.lock().await;
|
||
assert_eq!(store.input_state, Some(expected_input_state));
|
||
store.snapshot()
|
||
};
|
||
|
||
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
||
make_chatwidget_manual_with_sender().await;
|
||
app.chat_widget = chat_widget;
|
||
let outgoing_thread_id = ThreadId::new();
|
||
app.chat_widget.handle_thread_session(test_thread_session(
|
||
outgoing_thread_id,
|
||
test_path_buf("/tmp/outgoing-project"),
|
||
));
|
||
app.chat_widget.handle_server_notification(
|
||
turn_started_notification(outgoing_thread_id, "outgoing-turn"),
|
||
/*replay_kind*/ None,
|
||
);
|
||
app.chat_widget
|
||
.apply_external_edit("outgoing queued input".to_string());
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||
app.chat_widget
|
||
.set_queue_autosend_suppressed(/*suppressed*/ true);
|
||
app.chat_widget.handle_server_notification(
|
||
turn_completed_notification(outgoing_thread_id, "outgoing-turn", TurnStatus::Completed),
|
||
/*replay_kind*/ None,
|
||
);
|
||
app.chat_widget
|
||
.set_queue_autosend_suppressed(/*suppressed*/ false);
|
||
|
||
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ true);
|
||
|
||
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt");
|
||
assert!(app.chat_widget.queued_user_message_texts().is_empty());
|
||
while let Ok(op) = new_op_rx.try_recv() {
|
||
assert!(
|
||
!matches!(op, Op::UserTurn { .. }),
|
||
"draft-only replay should not auto-submit queued input"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn replay_thread_snapshot_restores_the_matching_safety_buffer_prompt() {
|
||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let thread_id = ThreadId::new();
|
||
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
||
app.thread_event_channels.insert(
|
||
thread_id,
|
||
ThreadEventChannel::new_with_session(
|
||
THREAD_EVENT_CHANNEL_CAPACITY,
|
||
session.clone(),
|
||
Vec::new(),
|
||
),
|
||
);
|
||
app.activate_thread_channel(thread_id).await;
|
||
app.chat_widget.handle_thread_session(session);
|
||
let default_mode = CollaborationModeMask {
|
||
name: "Default".to_string(),
|
||
mode: None,
|
||
model: None,
|
||
reasoning_effort: None,
|
||
developer_instructions: None,
|
||
};
|
||
app.chat_widget
|
||
.submit_user_message_with_mode("buffered prompt A".to_string(), default_mode.clone());
|
||
let expected_input_state = app
|
||
.chat_widget
|
||
.capture_thread_input_state()
|
||
.expect("expected thread input state");
|
||
|
||
app.store_active_thread_receiver().await;
|
||
let snapshot = {
|
||
let channel = app
|
||
.thread_event_channels
|
||
.get(&thread_id)
|
||
.expect("thread channel should exist");
|
||
let store = channel.store.lock().await;
|
||
assert_eq!(store.input_state, Some(expected_input_state.clone()));
|
||
store.snapshot()
|
||
};
|
||
|
||
let (mut chat_widget, _app_event_tx, _rx, _op_rx) =
|
||
boxed_future!(make_chatwidget_manual_with_sender()).await;
|
||
chat_widget.handle_thread_session(test_thread_session(
|
||
ThreadId::new(),
|
||
test_path_buf("/tmp/other-project"),
|
||
));
|
||
chat_widget.submit_user_message_with_mode("buffered prompt B".to_string(), default_mode);
|
||
app.chat_widget = chat_widget;
|
||
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);
|
||
|
||
assert_eq!(
|
||
app.chat_widget.capture_thread_input_state(),
|
||
Some(expected_input_state)
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn active_turn_id_for_thread_uses_snapshot_turns() {
|
||
let mut app = make_test_app().await;
|
||
let thread_id = ThreadId::new();
|
||
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
||
app.thread_event_channels.insert(
|
||
thread_id,
|
||
ThreadEventChannel::new_with_session(
|
||
THREAD_EVENT_CHANNEL_CAPACITY,
|
||
session,
|
||
vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())],
|
||
),
|
||
);
|
||
|
||
assert_eq!(
|
||
app.active_turn_id_for_thread(thread_id).await,
|
||
Some("turn-1".to_string())
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn replayed_turn_complete_submits_restored_queued_follow_up() {
|
||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let thread_id = ThreadId::new();
|
||
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
||
app.chat_widget.handle_thread_session(session.clone());
|
||
app.chat_widget.handle_server_notification(
|
||
turn_started_notification(thread_id, "turn-1"),
|
||
/*replay_kind*/ None,
|
||
);
|
||
app.chat_widget.handle_server_notification(
|
||
agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"),
|
||
/*replay_kind*/ None,
|
||
);
|
||
app.chat_widget
|
||
.apply_external_edit("queued follow-up".to_string());
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||
let input_state = app
|
||
.chat_widget
|
||
.capture_thread_input_state()
|
||
.expect("expected queued follow-up state");
|
||
|
||
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
||
make_chatwidget_manual_with_sender().await;
|
||
app.chat_widget = chat_widget;
|
||
app.chat_widget.handle_thread_session(session.clone());
|
||
while new_op_rx.try_recv().is_ok() {}
|
||
app.replay_thread_snapshot(
|
||
ThreadEventSnapshot {
|
||
delegated_turns: Vec::new(),
|
||
session: None,
|
||
turns: Vec::new(),
|
||
events: vec![ThreadBufferedEvent::Notification(Box::new(
|
||
turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed),
|
||
))],
|
||
active_reasoning_item: None,
|
||
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.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.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 mut input_state = app
|
||
.chat_widget
|
||
.capture_thread_input_state()
|
||
.expect("expected queued follow-up state");
|
||
input_state.reconnect_pending = true;
|
||
|
||
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 {
|
||
delegated_turns: Vec::new(),
|
||
session: None,
|
||
turns: Vec::new(),
|
||
events: vec![ThreadBufferedEvent::Notification(Box::new(
|
||
turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed),
|
||
))],
|
||
active_reasoning_item: None,
|
||
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 {
|
||
delegated_turns: Vec::new(),
|
||
session: None,
|
||
turns: Vec::new(),
|
||
events: vec![],
|
||
active_reasoning_item: None,
|
||
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 {
|
||
delegated_turns: Vec::new(),
|
||
session: None,
|
||
turns: vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())],
|
||
events: Vec::new(),
|
||
active_reasoning_item: None,
|
||
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) =
|
||
boxed_future!(make_chatwidget_manual_with_sender()).await;
|
||
app.chat_widget = chat_widget;
|
||
app.chat_widget.handle_thread_session(session);
|
||
|
||
app.replay_thread_snapshot(
|
||
ThreadEventSnapshot {
|
||
delegated_turns: Vec::new(),
|
||
session: None,
|
||
turns: vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())],
|
||
events: Vec::new(),
|
||
active_reasoning_item: None,
|
||
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("accepted steer".to_string());
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||
app.chat_widget
|
||
.apply_external_edit("queued follow-up".to_string());
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||
let mut input_state = app
|
||
.chat_widget
|
||
.capture_thread_input_state()
|
||
.expect("expected queued follow-up state");
|
||
let client_id = input_state
|
||
.pending_steers
|
||
.front()
|
||
.unwrap()
|
||
.client_id
|
||
.clone();
|
||
input_state.reconnect_pending = true;
|
||
|
||
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 {
|
||
delegated_turns: Vec::new(),
|
||
session: None,
|
||
turns: Vec::new(),
|
||
events: vec![
|
||
ThreadBufferedEvent::Notification(Box::new(ServerNotification::ItemCompleted(
|
||
codex_app_server_protocol::ItemCompletedNotification::new(
|
||
thread_id.to_string(),
|
||
"turn-1".into(),
|
||
ThreadItemPayload::UserMessage {
|
||
id: "accepted".into(),
|
||
client_id: Some(client_id),
|
||
content: vec![UserInput::Text {
|
||
text: "accepted steer".into(),
|
||
text_elements: Vec::new(),
|
||
}],
|
||
},
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
),
|
||
))),
|
||
ThreadBufferedEvent::Notification(Box::new(turn_completed_notification(
|
||
thread_id,
|
||
"turn-0",
|
||
TurnStatus::Completed,
|
||
))),
|
||
ThreadBufferedEvent::Notification(Box::new(turn_started_notification(
|
||
thread_id, "turn-1",
|
||
))),
|
||
],
|
||
active_reasoning_item: None,
|
||
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());
|
||
app.chat_widget.handle_key_event(KeyCode::Char('!').into());
|
||
let expected = format!("{large}!");
|
||
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(), expected);
|
||
|
||
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: expected,
|
||
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 {
|
||
delegated_turns: Vec::new(),
|
||
session: None,
|
||
turns: Vec::new(),
|
||
events: vec![],
|
||
active_reasoning_item: None,
|
||
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,
|
||
});
|
||
app.chat_widget
|
||
.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High));
|
||
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) =
|
||
boxed_future!(make_chatwidget_manual_with_sender()).await;
|
||
app.chat_widget = chat_widget;
|
||
app.chat_widget
|
||
.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Low));
|
||
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 {
|
||
delegated_turns: Vec::new(),
|
||
session: None,
|
||
turns: Vec::new(),
|
||
events: vec![],
|
||
active_reasoning_item: None,
|
||
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)
|
||
);
|
||
for _ in 0..2 {
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||
}
|
||
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 {
|
||
delegated_turns: Vec::new(),
|
||
session: None,
|
||
turns: Vec::new(),
|
||
events: vec![ThreadBufferedEvent::Notification(Box::new(
|
||
turn_completed_notification(thread_id, "turn-1", TurnStatus::Interrupted),
|
||
))],
|
||
active_reasoning_item: None,
|
||
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() {
|
||
use crate::bottom_pane::StatusLineItem;
|
||
use crate::bottom_pane::footer_content::FooterContent;
|
||
|
||
let mut app = make_test_app().await;
|
||
app.chat_widget.setup_status_line(
|
||
vec![
|
||
StatusLineItem::ContextWindowSize,
|
||
StatusLineItem::UsedTokens,
|
||
],
|
||
/*use_theme_colors*/ true,
|
||
);
|
||
assert_eq!(
|
||
app.chat_widget.config_panel_tui.status_line,
|
||
app.chat_widget.local_settings.tui.status_line
|
||
);
|
||
assert!(app.chat_widget.config_panel_tui.status_line_use_colors);
|
||
assert_eq!(app.chat_widget.status_line_text(), None);
|
||
|
||
let thread_id = ThreadId::new();
|
||
for (extensions, window, expected) in [
|
||
(false, 950_000, "950K window · 8 used"),
|
||
(true, 800_000, "800K window · 8 used"),
|
||
(false, 900_000, "900K window · 8 used"),
|
||
] {
|
||
let before = render_bottom_popup(&app.chat_widget, /*width*/ 60);
|
||
app.chat_widget
|
||
.set_feature_enabled(Feature::TuiExtensionsStatusLine, extensions);
|
||
app.refresh_status_line();
|
||
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 60), before);
|
||
app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
|
||
token_usage_notification(thread_id, "turn-1", Some(window)),
|
||
)));
|
||
assert!(matches!(
|
||
(extensions, app.chat_widget.status_line_content()),
|
||
(false, Some(FooterContent::HyperlinkLine(_)))
|
||
| (true, Some(FooterContent::Extension { .. }))
|
||
));
|
||
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 60);
|
||
assert!(
|
||
rendered.lines().any(|row| row.trim() == expected),
|
||
"{rendered}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn collab_receiver_notification_caches_thread_without_app_server_read() {
|
||
let mut app = make_test_app().await;
|
||
let receiver_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
|
||
|
||
app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
|
||
ServerNotification::ItemStarted(ItemStartedNotification::new(
|
||
ThreadId::new().to_string(),
|
||
"turn-1".to_string(),
|
||
ThreadItemPayload::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(),
|
||
},
|
||
/*started_at_ms*/ 0,
|
||
)),
|
||
)));
|
||
|
||
assert_eq!(
|
||
app.agent_navigation.get(&receiver_thread_id),
|
||
Some(&AgentPickerThreadEntry {
|
||
agent_nickname: None,
|
||
agent_role: None,
|
||
agent_path: None,
|
||
is_running: false,
|
||
is_closed: false,
|
||
})
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn collab_receiver_notification_does_not_cache_not_found_thread() {
|
||
let mut app = make_test_app().await;
|
||
let receiver_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000124").expect("valid thread id");
|
||
|
||
app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
|
||
ServerNotification::ItemCompleted(
|
||
codex_app_server_protocol::ItemCompletedNotification::new(
|
||
ThreadId::new().to_string(),
|
||
"turn-1".to_string(),
|
||
ThreadItemPayload::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,
|
||
},
|
||
)]),
|
||
},
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
),
|
||
),
|
||
)));
|
||
|
||
assert_eq!(app.agent_navigation.get(&receiver_thread_id), None);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn archived_untracked_threads_do_not_appear_in_agent_picker() -> Result<()> {
|
||
let mut app = Box::pin(make_test_app()).await;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await?;
|
||
let primary_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
|
||
app.enqueue_primary_thread_session(
|
||
test_thread_session(primary_thread_id, test_path_buf("/tmp/project")),
|
||
Vec::new(),
|
||
)
|
||
.await?;
|
||
|
||
let archived_thread_id = ThreadId::new();
|
||
app.handle_app_server_event(
|
||
&app_server,
|
||
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
|
||
ServerNotification::ThreadArchived(ThreadArchivedNotification {
|
||
thread_id: archived_thread_id.to_string(),
|
||
}),
|
||
)),
|
||
)
|
||
.await;
|
||
|
||
assert!(!app.thread_event_channels.contains_key(&archived_thread_id));
|
||
|
||
let attachment_thread_id = ThreadId::new();
|
||
app.handle_app_server_event(
|
||
&app_server,
|
||
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
|
||
ServerNotification::ThreadAttachmentUpdated(ThreadAttachmentUpdatedNotification {
|
||
thread_id: attachment_thread_id.to_string(),
|
||
attachment_type: "pull_request".to_string(),
|
||
identity_key: r#"["github.com","openai","codex",123]"#.to_string(),
|
||
attachment_id: "attachment-1".to_string(),
|
||
operation: ThreadAttachmentOperation::Deleted,
|
||
}),
|
||
)),
|
||
)
|
||
.await;
|
||
|
||
assert!(
|
||
!app.thread_event_channels
|
||
.contains_key(&attachment_thread_id)
|
||
);
|
||
|
||
app.upsert_agent_picker_thread(
|
||
archived_thread_id,
|
||
Some("Missed archive".to_string()),
|
||
Some("worker".to_string()),
|
||
/*is_closed*/ false,
|
||
);
|
||
app.thread_event_channels
|
||
.insert(archived_thread_id, ThreadEventChannel::new(/*capacity*/ 1));
|
||
let request_id = app
|
||
.agent_navigation
|
||
.begin_picker_refresh(primary_thread_id)
|
||
.expect("picker refresh after missed archive");
|
||
app.apply_agent_picker_thread_refresh(
|
||
&app_server,
|
||
primary_thread_id,
|
||
request_id,
|
||
Ok(crate::app_event::AgentPickerThreadRefresh {
|
||
threads: Vec::new(),
|
||
archived_thread_ids: std::collections::HashSet::from([archived_thread_id]),
|
||
}),
|
||
);
|
||
assert_eq!(
|
||
app.thread_event_channels[&archived_thread_id].attachment(),
|
||
ThreadEventAttachment::ReplayOnly
|
||
);
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
let archived_picker = render_bottom_popup(&app.chat_widget, /*width*/ 80);
|
||
app.handle_app_server_event(
|
||
&app_server,
|
||
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
|
||
ServerNotification::ThreadUnarchived(ThreadUnarchivedNotification {
|
||
thread_id: archived_thread_id.to_string(),
|
||
}),
|
||
)),
|
||
)
|
||
.await;
|
||
|
||
assert_app_snapshot!(
|
||
"untracked_thread_notifications_agent_picker",
|
||
format!(
|
||
"Archived:\n{archived_picker}\nUnarchived:\n{}",
|
||
render_bottom_popup(&app.chat_widget, /*width*/ 80)
|
||
)
|
||
.replace(&archived_thread_id.to_string(), "[archived]")
|
||
);
|
||
assert_eq!(
|
||
app.agent_navigation.ordered_thread_ids(),
|
||
vec![primary_thread_id, archived_thread_id]
|
||
);
|
||
assert_eq!(app.active_thread_id, Some(primary_thread_id));
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> {
|
||
let mut app = Box::pin(make_test_app()).await;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await
|
||
.expect("embedded app server");
|
||
let thread_id = ThreadId::new();
|
||
app.thread_event_channels
|
||
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
|
||
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
|
||
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
|
||
assert_eq!(
|
||
app.agent_navigation.get(&thread_id),
|
||
Some(&AgentPickerThreadEntry {
|
||
agent_nickname: None,
|
||
agent_role: None,
|
||
agent_path: None,
|
||
is_running: false,
|
||
is_closed: true,
|
||
})
|
||
);
|
||
assert_eq!(app.agent_navigation.ordered_thread_ids(), vec![thread_id]);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn open_agent_picker_preserves_cached_metadata_for_replay_threads() -> Result<()> {
|
||
let mut app = Box::pin(make_test_app()).await;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await
|
||
.expect("embedded app server");
|
||
let thread_id = ThreadId::new();
|
||
app.thread_event_channels
|
||
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
|
||
app.agent_navigation.upsert(
|
||
thread_id,
|
||
Some("Robie".to_string()),
|
||
Some("explorer".to_string()),
|
||
/*is_closed*/ true,
|
||
);
|
||
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
|
||
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
|
||
assert_eq!(
|
||
app.agent_navigation.get(&thread_id),
|
||
Some(&AgentPickerThreadEntry {
|
||
agent_nickname: Some("Robie".to_string()),
|
||
agent_role: Some("explorer".to_string()),
|
||
agent_path: None,
|
||
is_running: false,
|
||
is_closed: true,
|
||
})
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn open_agent_picker_preserves_running_hints_until_observed_completion() -> Result<()> {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await
|
||
.expect("embedded app server");
|
||
let thread_id = ThreadId::new();
|
||
app.thread_event_channels
|
||
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 4));
|
||
app.agent_navigation
|
||
.record_sub_agent_activity(SubAgentActivityDisplay {
|
||
thread_id,
|
||
agent_path: "/root/child".to_string(),
|
||
is_running_hint: true,
|
||
});
|
||
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
|
||
let mut expected_entry = AgentPickerThreadEntry {
|
||
agent_nickname: None,
|
||
agent_role: None,
|
||
agent_path: Some("/root/child".to_string()),
|
||
is_running: true,
|
||
is_closed: false,
|
||
};
|
||
assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry));
|
||
let status = loop {
|
||
let event = app_event_rx.try_recv().expect("agent status history cell");
|
||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
|
||
if rendered.contains("/subagents") {
|
||
break rendered;
|
||
}
|
||
}
|
||
};
|
||
assert_snapshot!(status, @r###"
|
||
/subagents
|
||
Sub-agents running
|
||
|
||
• `/root/child`
|
||
No recent activity yet.
|
||
"###);
|
||
|
||
app.enqueue_thread_notification(
|
||
thread_id,
|
||
turn_completed_notification(thread_id, "turn-older", TurnStatus::Completed),
|
||
)
|
||
.await?;
|
||
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
|
||
assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry));
|
||
app.enqueue_thread_notification(thread_id, turn_started_notification(thread_id, "turn-1"))
|
||
.await?;
|
||
app.enqueue_thread_notification(
|
||
thread_id,
|
||
turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed),
|
||
)
|
||
.await?;
|
||
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
|
||
expected_entry.is_running = false;
|
||
assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry));
|
||
app.agent_navigation
|
||
.record_sub_agent_activity(SubAgentActivityDisplay {
|
||
thread_id,
|
||
agent_path: "/root/child".to_string(),
|
||
is_running_hint: true,
|
||
});
|
||
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
|
||
expected_entry.is_running = false;
|
||
assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry));
|
||
app.enqueue_thread_notification(thread_id, turn_started_notification(thread_id, "turn-2"))
|
||
.await?;
|
||
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
|
||
expected_entry.is_running = true;
|
||
assert_eq!(app.agent_navigation.get(&thread_id), Some(&expected_entry));
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn open_agent_picker_clears_running_hint_from_completed_snapshot() -> Result<()> {
|
||
let mut app = Box::pin(make_test_app()).await;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await
|
||
.expect("embedded app server");
|
||
let thread_id = ThreadId::new();
|
||
app.thread_event_channels.insert(
|
||
thread_id,
|
||
ThreadEventChannel::new_with_session(
|
||
THREAD_EVENT_CHANNEL_CAPACITY,
|
||
test_thread_session(thread_id, test_path_buf("/tmp/project")),
|
||
vec![test_turn("turn-1", TurnStatus::Completed, Vec::new())],
|
||
),
|
||
);
|
||
app.agent_navigation
|
||
.record_sub_agent_activity(SubAgentActivityDisplay {
|
||
thread_id,
|
||
agent_path: "/root/child".to_string(),
|
||
is_running_hint: true,
|
||
});
|
||
assert!(!app.agent_navigation.is_parent_owned(thread_id));
|
||
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
|
||
assert_eq!(
|
||
app.agent_navigation.get(&thread_id),
|
||
Some(&AgentPickerThreadEntry {
|
||
agent_nickname: None,
|
||
agent_role: None,
|
||
agent_path: Some("/root/child".to_string()),
|
||
is_running: false,
|
||
is_closed: false,
|
||
})
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn open_agent_picker_selects_path_backed_agent() -> Result<()> {
|
||
let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await
|
||
.expect("embedded app server");
|
||
let thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
|
||
let main = ThreadId::from_u128(/*value*/ 1);
|
||
let waiting = ThreadId::from_u128(/*value*/ 2);
|
||
app.enqueue_primary_thread_session(
|
||
test_thread_session(main, test_path_buf("/tmp/project")),
|
||
Vec::new(),
|
||
)
|
||
.await?;
|
||
app.enqueue_thread_request(
|
||
waiting,
|
||
exec_approval_request(waiting, "turn", "call", /*approval_id*/ None),
|
||
)
|
||
.await?;
|
||
app.thread_event_channels
|
||
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
|
||
app.agent_navigation
|
||
.record_sub_agent_activity(SubAgentActivityDisplay {
|
||
thread_id,
|
||
agent_path: "/root/worker".to_string(),
|
||
is_running_hint: true,
|
||
});
|
||
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
|
||
// The second request arrives while the picker is open.
|
||
let request =
|
||
exec_approval_request(thread_id, "turn", "worker-call", /*approval_id*/ None);
|
||
app.thread_event_channels[&thread_id]
|
||
.store
|
||
.lock()
|
||
.await
|
||
.push_request(request);
|
||
app.refresh_pending_thread_approvals().await;
|
||
assert_eq!(
|
||
app.chat_widget.pending_thread_approvals(),
|
||
&["Agent (000000000002)", "/root/worker"]
|
||
);
|
||
|
||
// The first request gains its path without another approval event.
|
||
app.cache_collab_receiver_threads_for_notification(&ServerNotification::ItemStarted(
|
||
ItemStartedNotification::new(
|
||
main.to_string(),
|
||
"turn-1".to_string(),
|
||
ThreadItemPayload::SubAgentActivity {
|
||
id: "activity".to_string(),
|
||
kind: codex_app_server_protocol::SubAgentActivityKind::Started,
|
||
agent_thread_id: waiting.to_string(),
|
||
agent_path: "/root/review".to_string(),
|
||
model: None,
|
||
reasoning_effort: None,
|
||
},
|
||
/*started_at_ms*/ 0,
|
||
),
|
||
));
|
||
assert_eq!(
|
||
app.chat_widget.pending_thread_approvals(),
|
||
&["/root/review", "/root/worker"]
|
||
);
|
||
|
||
assert_app_snapshot!(
|
||
"path_backed_agent_picker",
|
||
render_bottom_popup(&app.chat_widget, /*width*/ 80)
|
||
);
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||
app.enqueue_thread_notification(
|
||
thread_id,
|
||
turn_completed_notification(thread_id, "turn", TurnStatus::Completed),
|
||
)
|
||
.await?;
|
||
assert_eq!(
|
||
app.chat_widget
|
||
.selected_index_for_present_view(agent_picker::AGENT_PICKER_VIEW_ID),
|
||
Some(2)
|
||
);
|
||
while app_event_rx.try_recv().is_ok() {}
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
assert_matches!(
|
||
app_event_rx.try_recv(),
|
||
Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn open_agent_picker_refreshes_replay_only_path_backed_liveness() -> Result<()> {
|
||
let mut app = Box::pin(make_test_app()).await;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await
|
||
.expect("embedded app server");
|
||
let thread_id = ThreadId::new();
|
||
let mut channel = ThreadEventChannel::new(/*capacity*/ 4);
|
||
channel.mark_replay_only();
|
||
{
|
||
let mut store = channel.store.lock().await;
|
||
store.push_notification(turn_started_notification(thread_id, "turn-1"));
|
||
}
|
||
app.thread_event_channels.insert(thread_id, channel);
|
||
app.agent_navigation
|
||
.record_sub_agent_activity(SubAgentActivityDisplay {
|
||
thread_id,
|
||
agent_path: "/root/child".to_string(),
|
||
is_running_hint: true,
|
||
});
|
||
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
|
||
assert_eq!(
|
||
app.agent_navigation.get(&thread_id),
|
||
Some(&AgentPickerThreadEntry {
|
||
agent_nickname: None,
|
||
agent_role: None,
|
||
agent_path: Some("/root/child".to_string()),
|
||
is_running: false,
|
||
is_closed: true,
|
||
})
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn open_agent_picker_prunes_terminal_metadata_only_threads() -> Result<()> {
|
||
let mut app = Box::pin(make_test_app()).await;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await
|
||
.expect("embedded app server");
|
||
let thread_id = ThreadId::new();
|
||
app.agent_navigation.upsert(
|
||
thread_id,
|
||
Some("Ghost".to_string()),
|
||
Some("worker".to_string()),
|
||
/*is_closed*/ false,
|
||
);
|
||
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
|
||
assert_eq!(app.agent_navigation.get(&thread_id), None);
|
||
assert!(app.agent_navigation.is_empty());
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> {
|
||
let mut app = Box::pin(make_test_app()).await;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await
|
||
.expect("embedded app server");
|
||
let thread_id = ThreadId::new();
|
||
app.thread_event_channels
|
||
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
|
||
app.agent_navigation.upsert(
|
||
thread_id,
|
||
Some("Robie".to_string()),
|
||
Some("explorer".to_string()),
|
||
/*is_closed*/ false,
|
||
);
|
||
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
|
||
assert_eq!(
|
||
app.agent_navigation.get(&thread_id),
|
||
Some(&AgentPickerThreadEntry {
|
||
agent_nickname: Some("Robie".to_string()),
|
||
agent_role: Some("explorer".to_string()),
|
||
agent_path: None,
|
||
is_running: false,
|
||
is_closed: true,
|
||
})
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn open_agent_picker_marks_loaded_threads_open() -> Result<()> {
|
||
const WORKER_THREADS: usize = 1;
|
||
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
|
||
|
||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||
.worker_threads(WORKER_THREADS)
|
||
.thread_stack_size(TEST_STACK_SIZE_BYTES)
|
||
.enable_all()
|
||
.build()?;
|
||
|
||
runtime.block_on(async {
|
||
let mut app = Box::pin(make_test_app()).await;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await
|
||
.expect("embedded app server");
|
||
let started = app_server
|
||
.start_thread(app.chat_widget.config_ref())
|
||
.await?;
|
||
let thread_id = started.session.thread_id;
|
||
app.thread_event_channels
|
||
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
|
||
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
|
||
assert_eq!(
|
||
app.agent_navigation.get(&thread_id),
|
||
Some(&AgentPickerThreadEntry {
|
||
agent_nickname: None,
|
||
agent_role: None,
|
||
agent_path: None,
|
||
is_running: false,
|
||
is_closed: false,
|
||
})
|
||
);
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -> Result<()> {
|
||
const WORKER_THREADS: usize = 1;
|
||
const TEST_STACK_SIZE_BYTES: usize = 12 * 1024 * 1024;
|
||
|
||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||
.worker_threads(WORKER_THREADS)
|
||
.thread_stack_size(TEST_STACK_SIZE_BYTES)
|
||
.enable_all()
|
||
.build()?;
|
||
|
||
// Keep the scenario and its large setup/resume futures off the test thread's stack.
|
||
runtime.block_on(Box::pin(async {
|
||
let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||
let root_thread_id = ThreadId::new();
|
||
let rollout_dir = app
|
||
.config
|
||
.codex_home
|
||
.join("sessions")
|
||
.join("2026")
|
||
.join("01")
|
||
.join("01");
|
||
std::fs::create_dir_all(&rollout_dir)?;
|
||
let root_session_meta = SessionMeta {
|
||
session_id: root_thread_id.into(),
|
||
id: root_thread_id,
|
||
timestamp: "2026-01-01T00:00:00Z".to_string(),
|
||
cwd: app.config.cwd.to_path_buf(),
|
||
originator: "codex-tui-test".to_string(),
|
||
cli_version: "0.0.0".to_string(),
|
||
source: RolloutSessionSource::Cli,
|
||
model_provider: Some(app.config.model_provider_id.clone()),
|
||
multi_agent_version: Some(MultiAgentVersion::V2),
|
||
..SessionMeta::default()
|
||
};
|
||
let root_session_meta_line = serde_json::json!({
|
||
"timestamp": "2026-01-01T00:00:00Z",
|
||
"type": "session_meta",
|
||
"payload": serde_json::to_value(root_session_meta)?,
|
||
});
|
||
std::fs::write(
|
||
rollout_dir.join(format!(
|
||
"rollout-2026-01-01T00-00-00-{root_thread_id}.jsonl"
|
||
)),
|
||
format!("{root_session_meta_line}\n"),
|
||
)?;
|
||
let mut child_thread_ids = Vec::new();
|
||
for (index, multi_agent_version) in [MultiAgentVersion::V1, MultiAgentVersion::V2]
|
||
.into_iter()
|
||
.enumerate()
|
||
{
|
||
let child_thread_id = ThreadId::new();
|
||
let timestamp = format!("2026-01-01T00:00:0{index}Z");
|
||
let session_meta = SessionMeta {
|
||
session_id: root_thread_id.into(),
|
||
id: child_thread_id,
|
||
parent_thread_id: Some(root_thread_id),
|
||
timestamp: timestamp.clone(),
|
||
cwd: app.config.cwd.to_path_buf(),
|
||
originator: "codex-tui-test".to_string(),
|
||
cli_version: "0.0.0".to_string(),
|
||
source: RolloutSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||
parent_thread_id: root_thread_id,
|
||
depth: 1,
|
||
agent_path: None,
|
||
agent_nickname: Some(format!("child-{index}")),
|
||
agent_role: Some("worker".to_string()),
|
||
}),
|
||
model_provider: Some(app.config.model_provider_id.clone()),
|
||
multi_agent_version: Some(multi_agent_version),
|
||
..SessionMeta::default()
|
||
};
|
||
let rollout_path = rollout_dir.join(format!(
|
||
"rollout-2026-01-01T00-00-0{index}-{child_thread_id}.jsonl"
|
||
));
|
||
let session_meta_line = serde_json::json!({
|
||
"timestamp": timestamp,
|
||
"type": "session_meta",
|
||
"payload": serde_json::to_value(session_meta)?,
|
||
});
|
||
let saved_message = format!("Saved work from child-{index}");
|
||
let response_item = serde_json::json!({
|
||
"timestamp": timestamp,
|
||
"type": "response_item",
|
||
"payload": {
|
||
"type": "message", "role": "user",
|
||
"content": [{"type": "input_text", "text": saved_message}]
|
||
}
|
||
});
|
||
let user_message = serde_json::json!({
|
||
"timestamp": timestamp,
|
||
"type": "event_msg",
|
||
"payload": {"type": "user_message", "message": saved_message, "kind": "plain"}
|
||
});
|
||
std::fs::write(
|
||
rollout_path,
|
||
format!("{session_meta_line}\n{response_item}\n{user_message}\n"),
|
||
)?;
|
||
child_thread_ids.push(child_thread_id);
|
||
}
|
||
|
||
// Cold-resume the persisted V2 root so its children are registered before selection.
|
||
std::fs::write(
|
||
app.config.codex_home.join("config.toml"),
|
||
"thread_unload_delay_secs = 0\n",
|
||
)?;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
&app.config,
|
||
))
|
||
.await?;
|
||
let root = Box::pin(app_server.resume_thread(
|
||
&app.local_settings,
|
||
app.config.clone(),
|
||
root_thread_id,
|
||
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
|
||
))
|
||
.await?;
|
||
app.enqueue_primary_thread_session(root.session, root.turns)
|
||
.await?;
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
for (index, (child_thread_id, multi_agent_version)) in child_thread_ids
|
||
.iter()
|
||
.copied()
|
||
.zip([MultiAgentVersion::V1, MultiAgentVersion::V2])
|
||
.enumerate()
|
||
{
|
||
while app_event_rx.try_recv().is_ok() {}
|
||
assert_eq!(
|
||
app_server.thread_read(child_thread_id, /*include_turns*/ false).await?.status,
|
||
codex_app_server_protocol::ThreadStatus::NotLoaded
|
||
);
|
||
app.select_agent_thread(&mut tui, &mut app_server, child_thread_id)
|
||
.await?;
|
||
assert_eq!(app.active_thread_id, Some(child_thread_id));
|
||
assert_eq!(
|
||
app.agent_navigation.is_parent_owned(child_thread_id),
|
||
multi_agent_version == MultiAgentVersion::V2
|
||
);
|
||
assert_app_snapshot!(
|
||
format!("selected_unloaded_subagent_{multi_agent_version:?}"),
|
||
normalize_snapshot_paths(render_bottom_popup(&app.chat_widget, /*width*/ 80))
|
||
);
|
||
let saved_message = format!("Saved work from child-{index}");
|
||
while let Ok(event) = app_event_rx.try_recv() {
|
||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||
app.transcript_cells.push(Arc::from(cell));
|
||
}
|
||
}
|
||
assert!(
|
||
app.render_transcript_lines_for_reflow(/*width*/ 80)
|
||
.lines
|
||
.iter()
|
||
.any(|line| rendered_line_text(line).contains(&saved_message))
|
||
);
|
||
app.select_agent_thread(&mut tui, &mut app_server, root_thread_id)
|
||
.await?;
|
||
app_server.thread_unsubscribe(child_thread_id).await?;
|
||
let closed = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async {
|
||
while let Some(event) = app_server.next_event().await {
|
||
if matches!(&event.event,
|
||
codex_app_server_client::AppServerEvent::ServerNotification(notification)
|
||
if matches!(notification.as_ref(), ServerNotification::ThreadClosed(closed)
|
||
if closed.thread_id == child_thread_id.to_string()))
|
||
{
|
||
return event;
|
||
}
|
||
}
|
||
panic!("server event stream ended before the child closed");
|
||
})
|
||
.await?;
|
||
while app_event_rx.try_recv().is_ok() {}
|
||
app.select_agent_thread(&mut tui, &mut app_server, child_thread_id)
|
||
.await?;
|
||
// A queued close from the old runtime must not undo the new attachment.
|
||
app.handle_backend_event(&app_server, closed).await;
|
||
app.drain_active_thread_events(&mut tui).await?;
|
||
assert_eq!(app.active_thread_id, Some(child_thread_id));
|
||
assert!(!matches!(
|
||
app_server.thread_read(child_thread_id, /*include_turns*/ false).await?.status,
|
||
codex_app_server_protocol::ThreadStatus::NotLoaded
|
||
));
|
||
while let Ok(event) = app_event_rx.try_recv() {
|
||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||
app.transcript_cells.push(Arc::from(cell));
|
||
}
|
||
}
|
||
assert!(
|
||
app.render_transcript_lines_for_reflow(/*width*/ 80)
|
||
.lines
|
||
.iter()
|
||
.any(|line| rendered_line_text(line).contains(&saved_message))
|
||
);
|
||
}
|
||
app.select_agent_thread(&mut tui, &mut app_server, root_thread_id)
|
||
.await?;
|
||
|
||
app.agent_navigation
|
||
.record_sub_agent_activity(SubAgentActivityDisplay {
|
||
thread_id: child_thread_ids[0],
|
||
agent_path: "/root/child-0".to_string(),
|
||
is_running_hint: true,
|
||
});
|
||
app.thread_event_channels.remove(&child_thread_ids[1]);
|
||
let backfill = app.backfill_loaded_subagent_threads(&mut app_server).await;
|
||
assert!(backfill.completed);
|
||
assert_eq!(
|
||
backfill.refreshed_thread_ids,
|
||
[child_thread_ids[1]].into_iter().collect()
|
||
);
|
||
assert_eq!(
|
||
app.agent_navigation.get(&child_thread_ids[0]),
|
||
Some(&AgentPickerThreadEntry {
|
||
agent_nickname: Some("child-0".to_string()),
|
||
agent_role: Some("worker".to_string()),
|
||
agent_path: Some("/root/child-0".to_string()),
|
||
is_running: true,
|
||
is_closed: false,
|
||
})
|
||
);
|
||
assert!(!app.agent_navigation.is_parent_owned(child_thread_ids[0]));
|
||
assert!(app.agent_navigation.is_parent_owned(child_thread_ids[1]));
|
||
|
||
app.select_agent_thread(&mut tui, &mut app_server, child_thread_ids[0])
|
||
.await?;
|
||
while app_event_rx.try_recv().is_ok() {}
|
||
app.chat_widget
|
||
.restore_user_message_to_composer("v1 remains writable".into());
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
assert!(
|
||
std::iter::from_fn(|| app_event_rx.try_recv().ok())
|
||
.any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. })))
|
||
);
|
||
|
||
app.select_agent_thread(&mut tui, &mut app_server, child_thread_ids[1])
|
||
.await?;
|
||
while app_event_rx.try_recv().is_ok() {}
|
||
app.chat_widget
|
||
.restore_user_message_to_composer("v2 stays view-only".into());
|
||
let draft = app.chat_widget.composer_text_with_pending();
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert_eq!(app.chat_widget.composer_text_with_pending(), draft);
|
||
assert!(
|
||
!std::iter::from_fn(|| app_event_rx.try_recv().ok())
|
||
.any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. })))
|
||
);
|
||
|
||
let resumed = Box::pin(app_server.resume_thread(
|
||
&app.local_settings,
|
||
app.config.clone(),
|
||
child_thread_ids[1],
|
||
app.resume_model_settings(),
|
||
))
|
||
.await?;
|
||
assert!(resumed.blocks_direct_input);
|
||
app.replace_chat_widget_with_app_server_thread(
|
||
&mut tui,
|
||
resumed,
|
||
crate::app::session_lifecycle::ThreadAttachPresentation::SessionLineage,
|
||
/*initial_user_message*/ None,
|
||
)
|
||
.await?;
|
||
while app_event_rx.try_recv().is_ok() {}
|
||
app.chat_widget
|
||
.restore_user_message_to_composer("direct resume stays view-only".into());
|
||
let draft = app.chat_widget.composer_text_with_pending();
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert_eq!(app.chat_widget.composer_text_with_pending(), draft);
|
||
assert!(
|
||
!std::iter::from_fn(|| app_event_rx.try_recv().ok())
|
||
.any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. })))
|
||
);
|
||
Ok(())
|
||
}))
|
||
}
|
||
|
||
#[test]
|
||
fn attach_live_thread_for_selection_rejects_empty_non_ephemeral_fallback_threads() -> Result<()> {
|
||
const WORKER_THREADS: usize = 1;
|
||
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
|
||
|
||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||
.worker_threads(WORKER_THREADS)
|
||
.thread_stack_size(TEST_STACK_SIZE_BYTES)
|
||
.enable_all()
|
||
.build()?;
|
||
|
||
runtime.block_on(async {
|
||
let config_owner = make_test_app().await;
|
||
let config = config_owner.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 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 docked_replay_finishes_while_skills_refresh_is_pending() -> Result<()> {
|
||
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
|
||
let parent = ThreadId::new();
|
||
let side = ThreadId::new();
|
||
app.side_threads
|
||
.insert(side, side::SideThreadState::new(parent));
|
||
enable_side_retention(&mut app)?;
|
||
let _ = app.config.features.disable(Feature::Plugins);
|
||
app.chat_widget.sync_plugin_mentions_config(&app.config);
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
tui.set_owned_screen(/*owned*/ true)?;
|
||
let cwd = app.chat_widget.config_ref().cwd.to_path_buf();
|
||
|
||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||
let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?;
|
||
let (pending_tx, mut pending_rx) = tokio::sync::mpsc::unbounded_channel();
|
||
let proxy = tokio::spawn(async move {
|
||
let (stream, _) = listener.accept().await?;
|
||
let socket = tokio_tungstenite::accept_async(stream).await?;
|
||
disconnect::serve_reconnect_requests(socket, |request| {
|
||
assert_eq!(request.method, "skills/list");
|
||
let (release, wait) = tokio::sync::oneshot::channel();
|
||
pending_tx
|
||
.send((request.params, release))
|
||
.expect("test is running");
|
||
async move {
|
||
wait.await.expect("release the pending skills refresh");
|
||
Some(serde_json::json!({"result": {"data": []}}))
|
||
}
|
||
})
|
||
.await
|
||
});
|
||
let mut app_server = AppServerSession::new(
|
||
crate::backend_id::BackendId::new(),
|
||
crate::connect_remote_app_server(endpoint).await?,
|
||
crate::app_server_session::ThreadParamsMode::Embedded,
|
||
);
|
||
let deadline = std::time::Duration::from_secs(/*secs*/ 5);
|
||
while events.try_recv().is_ok() {}
|
||
|
||
for thread_id in [side, parent] {
|
||
app.active_thread_id = Some(thread_id);
|
||
app.pending_thread_switch_resets = 1;
|
||
app.app_event_tx
|
||
.send(AppEvent::CodexOp(AppCommand::list_skills(
|
||
vec![cwd.clone()],
|
||
/*force_reload*/ true,
|
||
)));
|
||
app.app_event_tx
|
||
.send(AppEvent::SideReplayFinished(thread_id));
|
||
|
||
time::timeout(
|
||
deadline,
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut events),
|
||
)
|
||
.await??;
|
||
let (params, release) = time::timeout(deadline, pending_rx.recv())
|
||
.await?
|
||
.expect("skills/list should be requested");
|
||
assert_eq!(
|
||
params,
|
||
Some(serde_json::json!({"cwds": [cwd], "forceReload": true}))
|
||
);
|
||
assert!(!app.awaiting_side_replay(&tui));
|
||
|
||
release
|
||
.send(())
|
||
.expect("skills/list should still be pending");
|
||
let event = time::timeout(deadline, events.recv())
|
||
.await?
|
||
.expect("receive the skills refresh");
|
||
assert!(matches!(
|
||
&event,
|
||
AppEvent::SideReplaySkillsLoaded { thread_id: loaded_thread_id, result: Ok(_), .. }
|
||
if *loaded_thread_id == thread_id
|
||
));
|
||
boxed_future!(app.handle_event(&mut tui, &mut app_server, event)).await?;
|
||
assert_eq!(app_server.pending_side_skills_refresh, None);
|
||
}
|
||
|
||
app_server.shutdown().await?;
|
||
proxy.await??;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()> {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let config = app.chat_widget.config_ref().clone();
|
||
let parent_thread_id = ThreadId::from_string(
|
||
&app_test_support::create_fake_rollout(
|
||
config.codex_home.as_path(),
|
||
"2025-01-05T12-00-00",
|
||
"2025-01-05T12:00:00Z",
|
||
"Saved user message",
|
||
Some(config.model_provider_id.as_str()),
|
||
/*git_info*/ None,
|
||
)
|
||
.expect("create source rollout"),
|
||
)?;
|
||
let other_thread_id = ThreadId::from_string(
|
||
&app_test_support::create_fake_rollout(
|
||
config.codex_home.as_path(),
|
||
"2025-01-06T12-00-00",
|
||
"2025-01-06T12:00:00Z",
|
||
"Other task",
|
||
Some(config.model_provider_id.as_str()),
|
||
/*git_info*/ None,
|
||
)
|
||
.expect("create other rollout"),
|
||
)?;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
|
||
let started = app_server
|
||
.resume_thread(
|
||
&crate::local_settings::LocalSettings::from(&config),
|
||
config,
|
||
parent_thread_id,
|
||
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
|
||
)
|
||
.await?;
|
||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||
.await?;
|
||
while app_event_rx.try_recv().is_ok() {}
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
|
||
app.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(
|
||
app.chat_widget.config_ref(),
|
||
));
|
||
let read_only = PermissionProfileSelection {
|
||
profile_id: ":read-only".into(),
|
||
approval_policy: Some(AskForApproval::OnRequest),
|
||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||
display_label: "Read Only".into(),
|
||
};
|
||
app.select_permission_profile(&mut app_server, read_only.clone())
|
||
.await;
|
||
|
||
let control = Box::pin(app.handle_start_side(
|
||
&mut tui,
|
||
&mut app_server,
|
||
parent_thread_id,
|
||
/*user_message*/ None,
|
||
))
|
||
.await?;
|
||
|
||
let side_thread_id = app
|
||
.active_thread_id
|
||
.expect("side conversation should become active");
|
||
assert!(matches!(control, AppRunControl::Continue));
|
||
assert_ne!(side_thread_id, parent_thread_id);
|
||
assert!(app.side_threads.contains_key(&side_thread_id));
|
||
assert!(app.thread_event_channels.contains_key(&side_thread_id));
|
||
assert!(
|
||
!app.agent_navigation
|
||
.get(&side_thread_id)
|
||
.expect("side start should seed navigation before thread/started")
|
||
.is_closed
|
||
);
|
||
|
||
let mut saw_thread_started = false;
|
||
for _ in 0..20 {
|
||
let event = time::timeout(
|
||
std::time::Duration::from_secs(/*secs*/ 2),
|
||
app_server.next_event(),
|
||
)
|
||
.await
|
||
.expect("app-server should emit an event")
|
||
.expect("app-server event stream should remain open");
|
||
if let codex_app_server_client::AppServerEvent::ServerNotification(notification) =
|
||
event.event
|
||
&& let ServerNotification::ThreadStarted(notification) = notification.as_ref()
|
||
&& notification.thread.id == side_thread_id.to_string()
|
||
{
|
||
saw_thread_started = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
assert!(saw_thread_started);
|
||
assert_eq!(
|
||
app.config.permissions.permission_profile(),
|
||
&PermissionProfile::read_only()
|
||
);
|
||
assert_eq!(
|
||
app.runtime_permission_profile_override,
|
||
Some(RuntimePermissionProfileOverride::from_config(
|
||
app.chat_widget.config_ref()
|
||
))
|
||
);
|
||
app.select_agents_overview_thread(&mut tui, &mut app_server, other_thread_id)
|
||
.await?;
|
||
app.select_agents_overview_thread(&mut tui, &mut app_server, parent_thread_id)
|
||
.await?;
|
||
Box::pin(app.handle_start_side(
|
||
&mut tui,
|
||
&mut app_server,
|
||
parent_thread_id,
|
||
/*user_message*/ None,
|
||
))
|
||
.await?;
|
||
assert_eq!(app.active_thread_id, Some(side_thread_id));
|
||
assert_eq!(app.side_threads.len(), 1);
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||
enable_side_retention(&mut app)?;
|
||
tui.set_owned_screen(/*owned*/ true)?;
|
||
read_side_history(&mut app, &mut tui, "child")?;
|
||
app.chat_widget.insert_str("child draft");
|
||
app.chat_widget.handle_key_event(KeyCode::Char('!').into());
|
||
app.select_agent_thread(&mut tui, &mut app_server, parent_thread_id)
|
||
.await?;
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||
app.select_permission_profile(
|
||
&mut app_server,
|
||
PermissionProfileSelection {
|
||
profile_id: ":workspace".into(),
|
||
approval_policy: Some(AskForApproval::OnRequest),
|
||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||
display_label: "Workspace".into(),
|
||
},
|
||
)
|
||
.await;
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||
read_side_history(&mut app, &mut tui, "parent")?;
|
||
app.chat_widget.insert_str("parent draft");
|
||
app.select_agent_thread(&mut tui, &mut app_server, side_thread_id)
|
||
.await?;
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||
let settings = next_thread_settings_updated(&mut app_server, parent_thread_id).await;
|
||
app.enqueue_thread_notification(
|
||
parent_thread_id,
|
||
ServerNotification::ThreadSettingsUpdated(settings),
|
||
)
|
||
.await?;
|
||
app.enqueue_thread_notification(
|
||
parent_thread_id,
|
||
ServerNotification::ThreadProjectUpdated(
|
||
codex_app_server_protocol::ThreadProjectUpdatedNotification {
|
||
thread_id: parent_thread_id.to_string(),
|
||
project_id: Some("other-project".into()),
|
||
},
|
||
),
|
||
)
|
||
.await?;
|
||
app.select_agent_thread(&mut tui, &mut app_server, parent_thread_id)
|
||
.await?;
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||
app.refresh_owned_transcript(&mut tui)?;
|
||
let frame = buffer_text(tui.terminal.previous_buffer());
|
||
assert!(
|
||
frame.contains("parent-24") && !frame.contains("New activity"),
|
||
"{frame}"
|
||
);
|
||
|
||
app.select_agent_thread(&mut tui, &mut app_server, side_thread_id)
|
||
.await?;
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||
app.enqueue_thread_notification(
|
||
parent_thread_id,
|
||
agent_message_delta_notification(
|
||
parent_thread_id,
|
||
"background",
|
||
"reply",
|
||
"still working\n\n",
|
||
),
|
||
)
|
||
.await?;
|
||
app.select_agent_thread(&mut tui, &mut app_server, parent_thread_id)
|
||
.await?;
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||
app.refresh_owned_transcript(&mut tui)?;
|
||
let frame = buffer_text(tui.terminal.previous_buffer());
|
||
let parent_row = "parent-24";
|
||
let child_row = "child-24";
|
||
assert!(
|
||
frame.contains(parent_row) && frame.contains("New activity"),
|
||
"{frame}"
|
||
);
|
||
assert_eq!(app.chat_widget.composer_text_with_pending(), "parent draft");
|
||
assert_eq!(
|
||
app.config
|
||
.permissions
|
||
.active_permission_profile()
|
||
.unwrap()
|
||
.id,
|
||
":workspace"
|
||
);
|
||
assert_eq!(
|
||
app.runtime_permission_profile_override,
|
||
Some(RuntimePermissionProfileOverride::from_config(
|
||
app.chat_widget.config_ref()
|
||
))
|
||
);
|
||
|
||
macro_rules! keys {
|
||
($($key:expr),+) => {{
|
||
for key in [$($key),+] {
|
||
boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key))).await?;
|
||
}
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||
}};
|
||
}
|
||
let leader = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
|
||
let [minus, repeat, release] = [
|
||
KeyEventKind::Press,
|
||
KeyEventKind::Repeat,
|
||
KeyEventKind::Release,
|
||
]
|
||
.map(|kind| KeyEvent::new_with_kind(KeyCode::Char('-'), KeyModifiers::NONE, kind));
|
||
assert!(app.side_pane_shortcuts_available(&tui));
|
||
// Hide is consumed on the parent without closing the visible pane or typing its completion.
|
||
keys!(leader, minus, repeat, release);
|
||
assert!(!app.chat_widget.is_in_paste_burst());
|
||
assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id));
|
||
assert_eq!(app.chat_widget.composer_text_with_pending(), "parent draft");
|
||
|
||
for width in [100, 99, 100] {
|
||
let frame = side_frame(&mut app, &mut tui, Size::new(width, /*height*/ 18))?;
|
||
assert!(
|
||
frame.contains(parent_row) && frame.contains("parent draft"),
|
||
"{frame}"
|
||
);
|
||
for marker in [child_row, "child draft!", "│"] {
|
||
assert_eq!(frame.contains(marker), width == 100, "{frame}");
|
||
}
|
||
assert!(tui.terminal.last_known_cursor_pos.x < 62);
|
||
assert!(frame.contains("New activity"), "{frame}");
|
||
}
|
||
let wide = buffer_text(tui.terminal.previous_buffer());
|
||
let (panel, _) = wide.rsplit_once('\n').expect("split frame has a footer");
|
||
assert_app_snapshot!("single_side_chat", panel);
|
||
|
||
let size = Size::new(/*width*/ 100, /*height*/ 18);
|
||
let composer_gutter = |app: &mut App, tui: &mut crate::tui::Tui| -> Result<String> {
|
||
crate::terminal_palette::with_test_default_colors(
|
||
crate::terminal_probe::DefaultColors {
|
||
fg: (220, 220, 220),
|
||
bg: (20, 20, 20),
|
||
},
|
||
|| {
|
||
side_frame(app, tui, size)?;
|
||
let buffer = tui.terminal.previous_buffer();
|
||
let divider = 62;
|
||
let row = (0..size.height)
|
||
.find(|&y| buffer[(divider + 2, y)].symbol() == "›")
|
||
.expect("right composer prompt");
|
||
let snapshot_row = |y| {
|
||
(divider..=divider + 2)
|
||
.map(|x| {
|
||
let cell = &buffer[(x, y)];
|
||
format!("{:?} {:?}", cell.symbol(), cell.bg)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(" | ")
|
||
};
|
||
Ok(format!(
|
||
"{}\n{}",
|
||
snapshot_row(row),
|
||
snapshot_row(size.height - 1)
|
||
))
|
||
},
|
||
)
|
||
};
|
||
let inactive = composer_gutter(&mut app, &mut tui)?;
|
||
|
||
let toggle = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL);
|
||
keys!(toggle);
|
||
assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id));
|
||
let wide = side_frame(&mut app, &mut tui, size)?;
|
||
assert!(
|
||
wide.contains(parent_row) && wide.contains(child_row),
|
||
"{wide}"
|
||
);
|
||
assert_eq!(tui.terminal.last_known_cursor_pos.x, 78);
|
||
let focused = composer_gutter(&mut app, &mut tui)?;
|
||
assert_app_snapshot!(
|
||
"side_composer_gutter",
|
||
format!("Inactive\n{inactive}\n\nFocused\n{focused}")
|
||
);
|
||
|
||
// The pending permission change refuses a parent switch even though selection returns Ok.
|
||
app.pending_server_profiles
|
||
.insert(side_thread_id, read_only);
|
||
keys!(leader, minus, repeat, release);
|
||
assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id));
|
||
assert_eq!(app.chat_widget.composer_text_with_pending(), "child draft!");
|
||
assert!(!app.chat_widget.is_in_paste_burst());
|
||
let refused = side_frame(&mut app, &mut tui, size)?;
|
||
assert!(
|
||
refused.contains(parent_row) && refused.contains(child_row) && refused.contains('│'),
|
||
"{refused}"
|
||
);
|
||
app.pending_server_profiles.remove(&side_thread_id);
|
||
|
||
let channel = Arc::clone(&app.thread_event_channels[&side_thread_id].store);
|
||
let mut frames = Vec::new();
|
||
for width in [100, 99] {
|
||
let size = Size::new(width, /*height*/ 18);
|
||
side_frame(&mut app, &mut tui, size)?;
|
||
let prefix = if width == 100 {
|
||
leader
|
||
} else {
|
||
app.local_settings.tui.keymap = toml::from_str("[global]\nleader = 'f12'")?;
|
||
enable_side_retention(&mut app)?;
|
||
KeyCode::F(12).into()
|
||
};
|
||
assert!(app.transcript_view.begin_select_mode(&app.transcript_cells));
|
||
keys!(prefix, minus);
|
||
assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id));
|
||
keys!(repeat, release);
|
||
assert!(!app.chat_widget.is_in_paste_burst());
|
||
let hidden = side_frame(&mut app, &mut tui, size)?;
|
||
assert!(
|
||
hidden.contains(parent_row) && !hidden.contains(child_row) && !hidden.contains('│'),
|
||
"{hidden}"
|
||
);
|
||
if width == 100 {
|
||
frames.push(format!("{width} columns, hidden\n{hidden}"));
|
||
assert_eq!(app.chat_widget.composer_text_with_pending(), "parent draft");
|
||
// A new physical press after release belongs to the parent, including its final raw key.
|
||
keys!(minus, toggle);
|
||
} else {
|
||
assert_eq!(
|
||
app.chat_widget.composer_text_with_pending(),
|
||
"parent draft-"
|
||
);
|
||
boxed_future!(app.handle_start_side(
|
||
&mut tui,
|
||
&mut app_server,
|
||
parent_thread_id,
|
||
/*user_message*/ None
|
||
))
|
||
.await?;
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||
}
|
||
assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id));
|
||
assert!(Arc::ptr_eq(
|
||
&channel,
|
||
&app.thread_event_channels[&side_thread_id].store
|
||
));
|
||
assert!(app.transcript_view.is_select_mode());
|
||
app.transcript_view.end_select_mode();
|
||
let reopened = side_frame(&mut app, &mut tui, size)?;
|
||
assert!(
|
||
reopened.contains(child_row) && reopened.contains("child draft!"),
|
||
"{reopened}"
|
||
);
|
||
assert_eq!(
|
||
reopened.contains("parent draft-"),
|
||
width == 100,
|
||
"{reopened}"
|
||
);
|
||
frames.push(format!("{width} columns, reopened\n{reopened}"));
|
||
}
|
||
assert_app_snapshot!("hide_and_reopen_side_conversation", frames.join("\n\n"));
|
||
|
||
read_side_history(&mut app, &mut tui, "child")?;
|
||
// The upload finishes before the app event loop can park the now-inactive child.
|
||
app.app_event_tx.send(AppEvent::FeedbackSubmitted {
|
||
origin_thread_id: Some(side_thread_id),
|
||
category: FeedbackCategory::Bug,
|
||
include_logs: false,
|
||
result: Ok("uploaded-thread".to_string()),
|
||
});
|
||
app.select_agent_thread(&mut tui, &mut app_server, parent_thread_id)
|
||
.await?;
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||
app.select_agent_thread(&mut tui, &mut app_server, side_thread_id)
|
||
.await?;
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||
let frame = side_frame(&mut app, &mut tui, size)?;
|
||
assert!(
|
||
frame.contains(child_row) && frame.contains("New activity"),
|
||
"{frame}"
|
||
);
|
||
assert!(app.transcript_cells.iter().any(|cell| {
|
||
lines_to_single_string(&cell.display_lines(/*width*/ 120))
|
||
.contains("Feedback recorded (no logs).")
|
||
}));
|
||
|
||
// A delta queued just before switching must appear once in the inactive pane.
|
||
app.transcript_view.jump_to_latest();
|
||
app.enqueue_thread_notification(
|
||
side_thread_id,
|
||
turn_started_notification(side_thread_id, "live"),
|
||
)
|
||
.await?;
|
||
app.enqueue_thread_notification(
|
||
side_thread_id,
|
||
agent_message_delta_notification(side_thread_id, "live", "reply", "unfinished answer"),
|
||
)
|
||
.await?;
|
||
app.select_agent_thread(&mut tui, &mut app_server, parent_thread_id)
|
||
.await?;
|
||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||
let frame = side_frame(&mut app, &mut tui, size)?;
|
||
assert_eq!(frame.matches("unfinished answer").count(), 1, "{frame}");
|
||
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn select_uncached_agent_thread_still_refreshes_liveness() -> Result<()> {
|
||
let mut app = Box::pin(make_test_app()).await;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await?;
|
||
let thread_id = ThreadId::new();
|
||
app.agent_navigation.upsert(
|
||
thread_id,
|
||
Some("Ghost".to_string()),
|
||
Some("worker".to_string()),
|
||
/*is_closed*/ false,
|
||
);
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
|
||
Box::pin(app.select_agent_thread(&mut tui, &mut app_server, thread_id)).await?;
|
||
|
||
assert_eq!(app.active_thread_id, None);
|
||
assert_eq!(app.agent_navigation.get(&thread_id), None);
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn open_agent_picker_prompts_when_subagents_disabled() -> Result<()> {
|
||
let (mut app, _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);
|
||
let primary_thread_id = ThreadId::new();
|
||
app.enqueue_primary_thread_session(
|
||
test_thread_session(primary_thread_id, test_path_buf("/tmp/project")),
|
||
Vec::new(),
|
||
)
|
||
.await?;
|
||
let archived_thread_id = ThreadId::new();
|
||
app.upsert_agent_picker_thread(
|
||
archived_thread_id,
|
||
Some("Archived".to_string()),
|
||
Some("worker".to_string()),
|
||
/*is_closed*/ false,
|
||
);
|
||
app.set_agent_picker_thread_visibility(archived_thread_id, AgentPickerThreadVisibility::Hidden);
|
||
|
||
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
||
assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80), @r###"
|
||
Enable subagents?
|
||
› As Subagents are disabled in this TUI session.
|
||
|
||
gp
|
||
› 1. Yes, enable Save on the server for new threads without changing
|
||
this thread
|
||
2. Not now Keep subagents disabled
|
||
|
||
enter select · esc back
|
||
"###);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn update_memory_settings_persists_and_updates_widget_config() -> Result<()> {
|
||
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||
let codex_home = tempdir()?;
|
||
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||
|
||
Box::pin(app.update_memory_settings_with_app_server(
|
||
&mut app_server,
|
||
/*use_memories*/ false,
|
||
/*generate_memories*/ false,
|
||
))
|
||
.await;
|
||
|
||
assert!(!app.config.memories.use_memories);
|
||
assert!(!app.config.memories.generate_memories);
|
||
assert!(!app.chat_widget.config_ref().memories.use_memories);
|
||
assert!(!app.chat_widget.config_ref().memories.generate_memories);
|
||
|
||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||
let config_value = toml::from_str::<TomlValue>(&config)?;
|
||
let memories = config_value
|
||
.as_table()
|
||
.and_then(|table| table.get("memories"))
|
||
.and_then(TomlValue::as_table)
|
||
.expect("memories table should exist");
|
||
assert_eq!(
|
||
memories.get("use_memories"),
|
||
Some(&TomlValue::Boolean(false))
|
||
);
|
||
assert_eq!(
|
||
memories.get("generate_memories"),
|
||
Some(&TomlValue::Boolean(false))
|
||
);
|
||
assert!(
|
||
!memories.contains_key("disable_on_external_context")
|
||
&& !memories.contains_key("no_memories_if_mcp_or_web_search"),
|
||
"the TUI menu should not write the external-context memory setting"
|
||
);
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> {
|
||
const WORKER_THREADS: usize = 1;
|
||
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
|
||
|
||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||
.worker_threads(WORKER_THREADS)
|
||
.thread_stack_size(TEST_STACK_SIZE_BYTES)
|
||
.enable_all()
|
||
.build()?;
|
||
|
||
runtime.block_on(async {
|
||
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||
let codex_home = tempdir()?;
|
||
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||
app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs());
|
||
// Seed the previous setting so this test exercises the thread-mode update path.
|
||
app.config.memories.generate_memories = true;
|
||
|
||
let mut app_server =
|
||
Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||
let started = app_server.start_thread(&app.config).await?;
|
||
let thread_id = started.session.thread_id;
|
||
app.active_thread_id = Some(thread_id);
|
||
|
||
Box::pin(app.update_memory_settings_with_app_server(
|
||
&mut app_server,
|
||
/*use_memories*/ true,
|
||
/*generate_memories*/ false,
|
||
))
|
||
.await;
|
||
|
||
let state_db = codex_state::StateRuntime::init(
|
||
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
|
||
app.config.model_provider_id.clone(),
|
||
)
|
||
.await
|
||
.expect("state db should initialize");
|
||
let memory_mode = state_db
|
||
.get_thread_memory_mode(thread_id)
|
||
.await
|
||
.expect("thread memory mode should be readable");
|
||
assert_eq!(memory_mode.as_deref(), Some("disabled"));
|
||
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn reset_memories_clears_local_memory_directories() -> Result<()> {
|
||
Box::pin(async {
|
||
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||
let codex_home = tempdir()?;
|
||
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||
app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs());
|
||
|
||
let memory_root = codex_home.path().join("memories");
|
||
let extensions_root = memory_root.join("extensions");
|
||
std::fs::create_dir_all(memory_root.join("rollout_summaries"))?;
|
||
std::fs::create_dir_all(&extensions_root)?;
|
||
std::fs::write(memory_root.join("MEMORY.md"), "stale memory\n")?;
|
||
std::fs::write(
|
||
memory_root.join("rollout_summaries").join("stale.md"),
|
||
"stale summary\n",
|
||
)?;
|
||
std::fs::write(extensions_root.join("stale.txt"), "stale extension\n")?;
|
||
|
||
let mut app_server =
|
||
Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||
|
||
Box::pin(app.reset_memories_with_app_server(&mut app_server)).await;
|
||
|
||
assert_eq!(std::fs::read_dir(&memory_root)?.count(), 0);
|
||
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
})
|
||
.await
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> Result<()> {
|
||
let (mut app, mut events, mut ops) = make_test_app_with_channels().await;
|
||
app.config.ephemeral = false;
|
||
let home = tempdir()?;
|
||
std::fs::write(
|
||
home.path().join("config.toml"),
|
||
"default_permissions = \":workspace\"\n[permissions.server-only]\nextends = \":read-only\"\n",
|
||
)?;
|
||
let server_config = ConfigBuilder::default()
|
||
.codex_home(home.path().into())
|
||
.build()
|
||
.await?;
|
||
let mut server = crate::start_embedded_app_server_for_picker(&server_config).await?;
|
||
let selection = PermissionProfileSelection {
|
||
profile_id: "server-only".into(),
|
||
approval_policy: None,
|
||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||
display_label: "server-only".into(),
|
||
};
|
||
app.select_permission_profile(&mut server, selection.clone())
|
||
.await;
|
||
insta::assert_snapshot!(
|
||
next_history_message(&mut events),
|
||
@"■ Wait for the task to connect before selecting permissions."
|
||
);
|
||
let started = server.start_thread(&app.config).await?;
|
||
let thread_id = started.session.thread_id;
|
||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||
.await?;
|
||
app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::AutoReview);
|
||
app.runtime_permission_profile_override =
|
||
Some(RuntimePermissionProfileOverride::from_config(&app.config));
|
||
while events.try_recv().is_ok() {}
|
||
app.select_permission_profile(&mut server, selection.clone())
|
||
.await;
|
||
insta::assert_snapshot!(
|
||
next_history_message(&mut events),
|
||
@"• Permission selection requested: server-only"
|
||
);
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
app.chat_widget
|
||
.restore_user_message_to_composer("use the selected profile".into());
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
let turn = next_user_turn_op(&mut ops);
|
||
app.submit_thread_op(&mut server, thread_id, turn).await?;
|
||
server.startup_interrupt(thread_id).await?;
|
||
let settings = next_thread_settings_updated(&mut server, thread_id).await;
|
||
app.enqueue_thread_notification(
|
||
thread_id,
|
||
ServerNotification::ThreadSettingsUpdated(settings),
|
||
)
|
||
.await?;
|
||
assert!(!app.pending_server_profiles.contains_key(&thread_id));
|
||
assert_eq!(
|
||
(
|
||
app.runtime_approvals_reviewer_override,
|
||
app.chat_widget.config_ref().approvals_reviewer
|
||
),
|
||
(None, ApprovalsReviewer::User),
|
||
);
|
||
app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::User);
|
||
app.select_permission_profile(
|
||
&mut server,
|
||
PermissionProfileSelection {
|
||
approvals_reviewer: None,
|
||
..selection.clone()
|
||
},
|
||
)
|
||
.await;
|
||
let settings = next_thread_settings_updated(&mut server, thread_id).await;
|
||
app.enqueue_thread_notification(
|
||
thread_id,
|
||
ServerNotification::ThreadSettingsUpdated(settings),
|
||
)
|
||
.await?;
|
||
assert_eq!(
|
||
app.runtime_approvals_reviewer_override,
|
||
Some(ApprovalsReviewer::User),
|
||
);
|
||
let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:8765")?;
|
||
app.app_server_target = crate::AppServerTarget::Remote { endpoint };
|
||
assert!(app.selected_server_profile(thread_id).is_none());
|
||
while events.try_recv().is_ok() {}
|
||
app.change_working_directory(&mut tui, &mut server, home.path().abs())
|
||
.await;
|
||
insta::assert_snapshot!(
|
||
next_history_message(&mut events),
|
||
@"■ Changing directories with a named profile is not supported."
|
||
);
|
||
app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
|
||
turn_completed_notification(thread_id, "first", TurnStatus::Interrupted),
|
||
)));
|
||
while events.try_recv().is_ok() {}
|
||
app.chat_widget
|
||
.restore_user_message_to_composer("busy".into());
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
let mut turn = next_user_turn_op(&mut ops);
|
||
if let Op::UserTurn {
|
||
active_permission_profile,
|
||
..
|
||
} = &mut turn
|
||
{
|
||
*active_permission_profile = Some(ActivePermissionProfile::new("stale-profile"));
|
||
}
|
||
app.submit_thread_op(&mut server, thread_id, turn).await?;
|
||
let thread = server
|
||
.thread_read(thread_id, /*include_turns*/ true)
|
||
.await?;
|
||
assert_eq!(thread.turns.len(), 2);
|
||
while events.try_recv().is_ok() {}
|
||
app.chat_widget
|
||
.set_queue_autosend_suppressed(/*suppressed*/ true);
|
||
app.chat_widget
|
||
.restore_user_message_to_composer("queued follow-up".into());
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
let selection = PermissionProfileSelection {
|
||
profile_id: "server-only".into(),
|
||
approval_policy: None,
|
||
approvals_reviewer: None,
|
||
display_label: "server-only".into(),
|
||
};
|
||
app.select_permission_profile(&mut server, selection).await;
|
||
insta::assert_snapshot!(
|
||
next_history_message(&mut events),
|
||
@"• Permission selection requested: server-only"
|
||
);
|
||
app.handle_event(&mut tui, &mut server, AppEvent::SettingsSelectionSettled)
|
||
.await?;
|
||
app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
|
||
turn_completed_notification(thread_id, "second", TurnStatus::Completed),
|
||
)));
|
||
assert!(!app.chat_widget.has_queued_follow_up_messages());
|
||
let _ = next_user_turn_op(&mut ops);
|
||
server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn remote_resume_rejects_explicit_permission_override() -> Result<()> {
|
||
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
|
||
let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:8765")?;
|
||
|
||
let home = tempdir()?;
|
||
std::fs::write(
|
||
home.path().join("config.toml"),
|
||
"default_permissions = \":workspace\"\n",
|
||
)?;
|
||
app.config.codex_home = home.path().to_path_buf().abs();
|
||
let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||
let backend_id = server.backend_id();
|
||
app.cli_kv_overrides
|
||
.push(("default_permissions".into(), ":workspace".into()));
|
||
assert!(
|
||
!config_persistence::has_explicit_resume_permission_override(
|
||
&app.config,
|
||
&app.harness_overrides
|
||
)
|
||
);
|
||
let refreshed = app
|
||
.rebuild_config_for_cwd(app.config.cwd.to_path_buf())
|
||
.await?;
|
||
assert!(config_persistence::has_explicit_resume_permission_override(
|
||
&refreshed,
|
||
&app.harness_overrides
|
||
));
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
let mut settings = app.backend_settings();
|
||
settings.target = crate::AppServerTarget::Remote { endpoint };
|
||
let result = app
|
||
.prepare_target_session(
|
||
&mut tui,
|
||
&mut server,
|
||
&SessionTarget {
|
||
backend_id,
|
||
thread_id: ThreadId::new(),
|
||
path: None,
|
||
cwd: None,
|
||
history_mode: None,
|
||
},
|
||
super::resume_preparation::ResumeSource::Backend(&settings),
|
||
)
|
||
.await;
|
||
assert!(matches!(result, Err(AppRunControl::Continue)));
|
||
assert_eq!(app.active_thread_id, None);
|
||
let cell = std::iter::from_fn(|| events.try_recv().ok())
|
||
.find_map(|event| match event {
|
||
AppEvent::InsertHistoryCell(cell) => Some(cell),
|
||
_ => None,
|
||
})
|
||
.expect("permission override error");
|
||
insta::assert_snapshot!(
|
||
lines_to_single_string(&cell.display_lines(/*width*/ 80)),
|
||
@"■ Permission overrides are not supported when resuming a remote task."
|
||
);
|
||
server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[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());
|
||
|
||
app.set_approvals_reviewer_in_app_and_widget(ApprovalsReviewer::AutoReview);
|
||
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 mut destination = app.config.clone();
|
||
destination.approvals_reviewer = ApprovalsReviewer::User;
|
||
app.apply_runtime_policy_overrides(&mut destination, RuntimePolicyOverrideScope::ExplicitOnly)?;
|
||
assert_eq!(destination.approvals_reviewer, ApprovalsReviewer::User);
|
||
assert!(
|
||
!app.resume_permission_overrides(&destination)
|
||
.approvals_reviewer
|
||
);
|
||
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(),
|
||
model: None,
|
||
effort: None,
|
||
summary: None,
|
||
service_tier: None,
|
||
collaboration_mode: 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()),
|
||
model: None,
|
||
effort: None,
|
||
summary: None,
|
||
service_tier: None,
|
||
collaboration_mode: None,
|
||
})
|
||
);
|
||
let cell = match app_event_rx.try_recv() {
|
||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
||
};
|
||
let rendered = cell
|
||
.display_lines(/*width*/ 120)
|
||
.into_iter()
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(rendered.contains("Permissions updated to Approve for me"));
|
||
|
||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||
assert!(config.contains("guardian_approval = true"));
|
||
assert!(config.contains("approvals_reviewer = \"auto_review\""));
|
||
assert!(config.contains("approval_policy = \"on-request\""));
|
||
assert!(config.contains("sandbox_mode = \"workspace-write\""));
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default()
|
||
-> Result<()> {
|
||
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||
let codex_home = tempdir()?;
|
||
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||
let config_toml_path = codex_home.path().join("config.toml").abs();
|
||
let config_toml = "approvals_reviewer = \"guardian_subagent\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n";
|
||
std::fs::write(config_toml_path.as_path(), config_toml)?;
|
||
let user_config = toml::from_str::<TomlValue>(config_toml)?;
|
||
app.config.config_layer_stack = app
|
||
.config
|
||
.config_layer_stack
|
||
.with_user_config(&config_toml_path, user_config)?;
|
||
app.config
|
||
.features
|
||
.set_enabled(Feature::GuardianApproval, /*enabled*/ true)?;
|
||
app.chat_widget
|
||
.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true);
|
||
app.config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||
app.chat_widget
|
||
.set_approvals_reviewer(ApprovalsReviewer::AutoReview);
|
||
app.config
|
||
.permissions
|
||
.approval_policy
|
||
.set(AskForApproval::OnRequest.to_core())?;
|
||
app.config
|
||
.permissions
|
||
.set_permission_profile(PermissionProfile::workspace_write())?;
|
||
app.chat_widget
|
||
.set_approval_policy(AskForApproval::OnRequest);
|
||
app.chat_widget
|
||
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy(
|
||
PermissionProfile::workspace_write(),
|
||
))?;
|
||
let mut app_server = start_config_write_test_app_server(&app).await?;
|
||
|
||
app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, false)])
|
||
.await;
|
||
|
||
assert!(!app.config.features.enabled(Feature::GuardianApproval));
|
||
assert!(
|
||
!app.chat_widget
|
||
.config_ref()
|
||
.features
|
||
.enabled(Feature::GuardianApproval)
|
||
);
|
||
assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User);
|
||
assert_eq!(
|
||
AskForApproval::from(app.config.permissions.approval_policy.value()),
|
||
AskForApproval::OnRequest
|
||
);
|
||
assert_eq!(
|
||
app.chat_widget.config_ref().approvals_reviewer,
|
||
ApprovalsReviewer::User
|
||
);
|
||
assert_eq!(app.runtime_approval_policy_override, None);
|
||
assert_eq!(
|
||
op_rx.try_recv(),
|
||
Ok(Op::OverrideTurnContext {
|
||
cwd: None,
|
||
approval_policy: None,
|
||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||
permission_profile: None,
|
||
active_permission_profile: None,
|
||
model: None,
|
||
effort: None,
|
||
summary: None,
|
||
service_tier: None,
|
||
collaboration_mode: None,
|
||
})
|
||
);
|
||
let cell = match app_event_rx.try_recv() {
|
||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
||
};
|
||
let rendered = cell
|
||
.display_lines(/*width*/ 120)
|
||
.into_iter()
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(rendered.contains("Permissions updated to Ask for approval"));
|
||
|
||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||
assert!(!config.contains("guardian_approval = true"));
|
||
assert!(!config.contains("approvals_reviewer ="));
|
||
assert!(config.contains("approval_policy = \"on-request\""));
|
||
assert!(config.contains("sandbox_mode = \"workspace-write\""));
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review_policy()
|
||
-> Result<()> {
|
||
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||
let codex_home = tempdir()?;
|
||
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||
let auto_review = auto_review_mode();
|
||
let config_toml_path = codex_home.path().join("config.toml").abs();
|
||
let config_toml = "approvals_reviewer = \"user\"\n";
|
||
std::fs::write(config_toml_path.as_path(), config_toml)?;
|
||
let user_config = toml::from_str::<TomlValue>(config_toml)?;
|
||
app.config.config_layer_stack = app
|
||
.config
|
||
.config_layer_stack
|
||
.with_user_config(&config_toml_path, user_config)?;
|
||
app.config.approvals_reviewer = ApprovalsReviewer::User;
|
||
app.chat_widget
|
||
.set_approvals_reviewer(ApprovalsReviewer::User);
|
||
let mut app_server = start_config_write_test_app_server(&app).await?;
|
||
|
||
app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, true)])
|
||
.await;
|
||
|
||
assert!(app.config.features.enabled(Feature::GuardianApproval));
|
||
assert_eq!(
|
||
app.config.approvals_reviewer,
|
||
auto_review.approvals_reviewer
|
||
);
|
||
assert_eq!(
|
||
app.chat_widget.config_ref().approvals_reviewer,
|
||
auto_review.approvals_reviewer
|
||
);
|
||
assert_eq!(
|
||
AskForApproval::from(app.config.permissions.approval_policy.value()),
|
||
auto_review.approval_policy
|
||
);
|
||
assert_eq!(
|
||
app.chat_widget
|
||
.config_ref()
|
||
.permissions
|
||
.permission_profile(),
|
||
&auto_review.permission_profile()
|
||
);
|
||
assert_eq!(
|
||
op_rx.try_recv(),
|
||
Ok(Op::OverrideTurnContext {
|
||
cwd: None,
|
||
approval_policy: Some(auto_review.approval_policy),
|
||
approvals_reviewer: Some(auto_review.approvals_reviewer),
|
||
permission_profile: Some(auto_review.permission_profile()),
|
||
active_permission_profile: Some(auto_review.active_permission_profile.clone()),
|
||
model: None,
|
||
effort: None,
|
||
summary: None,
|
||
service_tier: None,
|
||
collaboration_mode: None,
|
||
})
|
||
);
|
||
|
||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||
assert!(config.contains("approvals_reviewer = \"auto_review\""));
|
||
assert!(config.contains("guardian_approval = true"));
|
||
assert!(config.contains("approval_policy = \"on-request\""));
|
||
assert!(config.contains("sandbox_mode = \"workspace-write\""));
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history()
|
||
-> Result<()> {
|
||
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||
let codex_home = tempdir()?;
|
||
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
||
let config_toml_path = codex_home.path().join("config.toml").abs();
|
||
let config_toml = "approvals_reviewer = \"user\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n";
|
||
std::fs::write(config_toml_path.as_path(), config_toml)?;
|
||
let user_config = toml::from_str::<TomlValue>(config_toml)?;
|
||
app.config.config_layer_stack = app
|
||
.config
|
||
.config_layer_stack
|
||
.with_user_config(&config_toml_path, user_config)?;
|
||
app.config
|
||
.features
|
||
.set_enabled(Feature::GuardianApproval, /*enabled*/ true)?;
|
||
app.chat_widget
|
||
.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true);
|
||
app.config.approvals_reviewer = ApprovalsReviewer::User;
|
||
app.chat_widget
|
||
.set_approvals_reviewer(ApprovalsReviewer::User);
|
||
let mut app_server = start_config_write_test_app_server(&app).await?;
|
||
|
||
app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, false)])
|
||
.await;
|
||
|
||
assert!(!app.config.features.enabled(Feature::GuardianApproval));
|
||
assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User);
|
||
assert_eq!(
|
||
app.chat_widget.config_ref().approvals_reviewer,
|
||
ApprovalsReviewer::User
|
||
);
|
||
assert_eq!(
|
||
op_rx.try_recv(),
|
||
Ok(Op::OverrideTurnContext {
|
||
cwd: None,
|
||
approval_policy: None,
|
||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||
permission_profile: None,
|
||
active_permission_profile: None,
|
||
model: None,
|
||
effort: None,
|
||
summary: None,
|
||
service_tier: None,
|
||
collaboration_mode: 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_retains_warnings_without_covering_approval() {
|
||
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 {
|
||
delegated_turns: Vec::new(),
|
||
session: Some(test_thread_session(thread_id, test_path_buf("/tmp/main"))),
|
||
turns: Vec::new(),
|
||
events: vec![
|
||
ThreadBufferedEvent::Notification(Box::new(ServerNotification::Warning(
|
||
WarningNotification {
|
||
thread_id: Some(thread_id.to_string()),
|
||
message: stale_warning.to_string(),
|
||
},
|
||
))),
|
||
ThreadBufferedEvent::Request(Box::new(exec_approval_request(
|
||
thread_id,
|
||
"turn-approval",
|
||
"call-approval",
|
||
/*approval_id*/ None,
|
||
))),
|
||
],
|
||
active_reasoning_item: None,
|
||
input_state: None,
|
||
},
|
||
/*resume_restored_queue*/ false,
|
||
);
|
||
|
||
assert_eq!(app.chat_widget.has_active_view(), true);
|
||
|
||
let mut replayed_history = String::new();
|
||
let mut warnings = Vec::new();
|
||
while let Ok(event) = app_event_rx.try_recv() {
|
||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||
warnings.extend(
|
||
cell.warning_entries()
|
||
.into_iter()
|
||
.map(|entry| entry.details),
|
||
);
|
||
replayed_history.push_str(&lines_to_single_string(&cell.display_lines(/*width*/ 80)));
|
||
}
|
||
}
|
||
|
||
assert_eq!(warnings, vec![stale_warning]);
|
||
assert!(
|
||
replayed_history.is_empty(),
|
||
"expected pending approval replay to suppress session notices, got {replayed_history:?}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn side_defers_subagent_approval_overlay_until_side_exits() -> Result<()> {
|
||
let mut app = make_test_app().await;
|
||
let main_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000011").expect("valid thread");
|
||
let side_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000022").expect("valid thread");
|
||
let agent_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000033").expect("valid thread");
|
||
let quiet_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000044").expect("valid thread");
|
||
|
||
app.primary_thread_id = Some(main_thread_id);
|
||
app.active_thread_id = Some(side_thread_id);
|
||
app.side_threads
|
||
.insert(side_thread_id, SideThreadState::new(main_thread_id));
|
||
app.thread_event_channels.insert(
|
||
agent_thread_id,
|
||
ThreadEventChannel::new_with_session(
|
||
/*capacity*/ 4,
|
||
ThreadSessionState {
|
||
approval_policy: AskForApproval::OnRequest,
|
||
permission_profile: PermissionProfile::workspace_write(),
|
||
rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")),
|
||
..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent"))
|
||
},
|
||
Vec::new(),
|
||
),
|
||
);
|
||
app.agent_navigation.upsert(
|
||
agent_thread_id,
|
||
Some("Robie".to_string()),
|
||
Some("explorer".to_string()),
|
||
/*is_closed*/ false,
|
||
);
|
||
|
||
let pending_approval = exec_approval_request(
|
||
agent_thread_id,
|
||
"turn-approval",
|
||
"call-approval",
|
||
/*approval_id*/ None,
|
||
);
|
||
app.enqueue_thread_request(agent_thread_id, pending_approval.clone())
|
||
.await?;
|
||
app.enqueue_thread_request(
|
||
quiet_thread_id,
|
||
ServerRequest::DynamicToolCall {
|
||
request_id: AppServerRequestId::Integer(99),
|
||
params: codex_app_server_protocol::DynamicToolCallParams {
|
||
thread_id: quiet_thread_id.to_string(),
|
||
turn_id: "turn-quiet".to_string(),
|
||
call_id: "call-quiet".to_string(),
|
||
namespace: None,
|
||
tool: "ignored-tool".to_string(),
|
||
arguments: serde_json::json!({}),
|
||
},
|
||
},
|
||
)
|
||
.await?;
|
||
|
||
assert_eq!(app.chat_widget.has_active_view(), false);
|
||
assert_eq!(
|
||
app.chat_widget.pending_thread_approvals(),
|
||
&["Robie [explorer]".to_string()]
|
||
);
|
||
|
||
app.side_threads.remove(&side_thread_id);
|
||
app.active_thread_id = Some(main_thread_id);
|
||
assert_eq!(
|
||
app.pending_inactive_thread_requests().await,
|
||
vec![(agent_thread_id, pending_approval)]
|
||
);
|
||
app.surface_pending_inactive_thread_interactive_requests()
|
||
.await?;
|
||
|
||
assert_eq!(app.chat_widget.has_active_view(), true);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn inactive_thread_exec_approval_preserves_context() {
|
||
let app = make_test_app().await;
|
||
let thread_id = ThreadId::new();
|
||
let mut request = exec_approval_request(
|
||
thread_id,
|
||
"turn-approval",
|
||
"call-approval",
|
||
/*approval_id*/ None,
|
||
);
|
||
let ServerRequest::CommandExecutionRequestApproval { params, .. } = &mut request else {
|
||
panic!("expected exec approval request");
|
||
};
|
||
params.network_approval_context = Some(AppServerNetworkApprovalContext {
|
||
host: "example.com".to_string(),
|
||
protocol: AppServerNetworkApprovalProtocol::Socks5Tcp,
|
||
});
|
||
params.additional_permissions = Some(AdditionalPermissionProfile {
|
||
network: Some(AdditionalNetworkPermissions {
|
||
enabled: Some(true),
|
||
}),
|
||
file_system: Some(AdditionalFileSystemPermissions {
|
||
read: Some(vec![test_absolute_path("/tmp/read-only").into()]),
|
||
write: Some(vec![test_absolute_path("/tmp/write").into()]),
|
||
glob_scan_max_depth: None,
|
||
entries: None,
|
||
}),
|
||
});
|
||
params.proposed_network_policy_amendments = Some(vec![AppServerNetworkPolicyAmendment {
|
||
host: "example.com".to_string(),
|
||
action: AppServerNetworkPolicyRuleAction::Allow,
|
||
}]);
|
||
|
||
let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec(approval))) = app
|
||
.interactive_request_for_thread_request(thread_id, &request)
|
||
.await
|
||
.expect("valid localized paths")
|
||
else {
|
||
panic!("expected exec approval request");
|
||
};
|
||
|
||
assert_eq!(
|
||
approval.network_approval_context,
|
||
Some(AppServerNetworkApprovalContext {
|
||
host: "example.com".to_string(),
|
||
protocol: AppServerNetworkApprovalProtocol::Socks5Tcp,
|
||
})
|
||
);
|
||
assert_eq!(
|
||
approval.additional_permissions,
|
||
Some(AdditionalPermissionProfile {
|
||
network: Some(AdditionalNetworkPermissions {
|
||
enabled: Some(true),
|
||
}),
|
||
file_system: Some(AdditionalFileSystemPermissions {
|
||
read: Some(vec![test_absolute_path("/tmp/read-only").into()]),
|
||
write: Some(vec![test_absolute_path("/tmp/write").into()]),
|
||
glob_scan_max_depth: None,
|
||
entries: None,
|
||
}),
|
||
})
|
||
);
|
||
assert_eq!(
|
||
approval.available_decisions,
|
||
vec![
|
||
codex_app_server_protocol::CommandExecutionApprovalDecision::Accept,
|
||
codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession,
|
||
codex_app_server_protocol::CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
|
||
network_policy_amendment: AppServerNetworkPolicyAmendment {
|
||
host: "example.com".to_string(),
|
||
action: AppServerNetworkPolicyRuleAction::Allow,
|
||
},
|
||
},
|
||
codex_app_server_protocol::CommandExecutionApprovalDecision::Cancel,
|
||
]
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn inactive_thread_exec_approval_splits_shell_wrapped_command() {
|
||
let app = make_test_app().await;
|
||
let thread_id = ThreadId::new();
|
||
let script = r#"python3 -c 'print("Hello, world!")'"#;
|
||
let mut request = exec_approval_request(
|
||
thread_id,
|
||
"turn-approval",
|
||
"call-approval",
|
||
/*approval_id*/ None,
|
||
);
|
||
let ServerRequest::CommandExecutionRequestApproval { params, .. } = &mut request else {
|
||
panic!("expected exec approval request");
|
||
};
|
||
params.command =
|
||
Some(shlex::try_join(["/bin/zsh", "-lc", script]).expect("round-trippable shell wrapper"));
|
||
|
||
let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec(approval))) = app
|
||
.interactive_request_for_thread_request(thread_id, &request)
|
||
.await
|
||
.expect("valid localized paths")
|
||
else {
|
||
panic!("expected exec approval request");
|
||
};
|
||
|
||
assert_eq!(
|
||
approval.command,
|
||
vec![
|
||
"/bin/zsh".to_string(),
|
||
"-lc".to_string(),
|
||
script.to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn inactive_thread_file_change_approval_recovers_buffered_changes() {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let thread_id = ThreadId::new();
|
||
app.enqueue_thread_notification(
|
||
thread_id,
|
||
ServerNotification::ItemStarted(ItemStartedNotification::new(
|
||
thread_id.to_string(),
|
||
"turn-approval".to_string(),
|
||
ThreadItemPayload::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,
|
||
},
|
||
/*started_at_ms*/ 0,
|
||
)),
|
||
)
|
||
.await
|
||
.expect("enqueue file change item");
|
||
|
||
let request = ServerRequest::FileChangeRequestApproval {
|
||
request_id: AppServerRequestId::Integer(9),
|
||
params: FileChangeRequestApprovalParams {
|
||
thread_id: thread_id.to_string(),
|
||
turn_id: "turn-approval".to_string(),
|
||
item_id: "patch-approval".to_string(),
|
||
started_at_ms: 0,
|
||
reason: Some("command failed; retry without sandbox?".to_string()),
|
||
grant_root: None,
|
||
},
|
||
};
|
||
|
||
let request = app
|
||
.interactive_request_for_thread_request(thread_id, &request)
|
||
.await
|
||
.expect("valid localized paths")
|
||
.expect("expected file change approval request");
|
||
|
||
let ThreadInteractiveRequest::Approval(ApprovalRequest::ApplyPatch(approval)) = &request else {
|
||
panic!("expected apply-patch approval request");
|
||
};
|
||
assert_eq!(
|
||
&approval.changes,
|
||
&HashMap::from([(
|
||
PathBuf::from("README.md"),
|
||
FileChange::Add {
|
||
content: "hello\n".to_string(),
|
||
},
|
||
)])
|
||
);
|
||
assert_eq!(
|
||
&approval.reason,
|
||
&Some("command failed; retry without sandbox?".to_string())
|
||
);
|
||
|
||
app.push_thread_interactive_request(request);
|
||
let cell = match app_event_rx.try_recv() {
|
||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||
other => panic!("expected patch preview history cell, saw {other:?}"),
|
||
};
|
||
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
|
||
assert!(rendered.contains("• Added README.md (+1 -0)"));
|
||
assert!(rendered.contains("1 +hello"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn active_thread_file_change_approval_recovers_buffered_changes() {
|
||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let app_server = crate::start_embedded_app_server_for_picker(&app.config)
|
||
.await
|
||
.expect("embedded app server");
|
||
let thread_id = ThreadId::new();
|
||
app.active_thread_id = Some(thread_id);
|
||
app.primary_thread_id = Some(ThreadId::new());
|
||
app.agents_overview
|
||
.dispatched_requests
|
||
.insert(thread_id, Vec::new());
|
||
app.startup_protected_input_boundary = true;
|
||
app.enqueue_thread_notification(
|
||
thread_id,
|
||
ServerNotification::ItemStarted(ItemStartedNotification::new(
|
||
thread_id.to_string(),
|
||
"turn-active-approval".to_string(),
|
||
ThreadItemPayload::FileChange {
|
||
id: "patch-active-approval".to_string(),
|
||
changes: vec![FileUpdateChange {
|
||
path: "visible-target.md".to_string(),
|
||
kind: PatchChangeKind::Add,
|
||
diff: "hello\n".to_string(),
|
||
}],
|
||
status: codex_app_server_protocol::PatchApplyStatus::InProgress,
|
||
},
|
||
/*started_at_ms*/ 0,
|
||
)),
|
||
)
|
||
.await
|
||
.expect("enqueue file change item");
|
||
|
||
let request = ServerRequest::FileChangeRequestApproval {
|
||
request_id: AppServerRequestId::Integer(10),
|
||
params: FileChangeRequestApprovalParams {
|
||
thread_id: thread_id.to_string(),
|
||
turn_id: "turn-active-approval".to_string(),
|
||
item_id: "patch-active-approval".to_string(),
|
||
started_at_ms: 0,
|
||
reason: None,
|
||
grant_root: None,
|
||
},
|
||
};
|
||
app.handle_app_server_event(
|
||
&app_server,
|
||
codex_app_server_client::AppServerEvent::ServerRequest(Box::new(request.clone())),
|
||
)
|
||
.await;
|
||
assert!(app.agents_overview.dispatched_requests[&thread_id].is_empty());
|
||
assert!(
|
||
app.pending_app_server_requests
|
||
.contains_server_request(&request)
|
||
);
|
||
app.chat_widget.handle_server_notification(
|
||
agent_message_delta_notification(thread_id, "turn-active-approval", "agent-1", "streaming"),
|
||
/*replay_kind*/ None,
|
||
);
|
||
let event = ThreadBufferedEvent::Request(Box::new(request));
|
||
app.handle_thread_event_now_recovering_file_changes(event)
|
||
.await;
|
||
|
||
assert!(!app.chat_widget.has_active_view());
|
||
assert!(app.startup_pending_protected_request);
|
||
app.chat_widget.handle_server_notification(
|
||
ServerNotification::ItemCompleted(
|
||
codex_app_server_protocol::ItemCompletedNotification::new(
|
||
thread_id.to_string(),
|
||
"turn-active-approval".to_string(),
|
||
ThreadItemPayload::AgentMessage {
|
||
id: "agent-1".to_string(),
|
||
text: "streaming".to_string(),
|
||
phase: None,
|
||
memory_citation: None,
|
||
delivery: None,
|
||
questions: None,
|
||
},
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
),
|
||
),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 100);
|
||
let destination = app.chat_widget.config_ref().cwd.join("visible-target.md");
|
||
assert!(rendered.contains("Description: Apply proposed file edits"));
|
||
assert!(rendered.contains(&format!("Destination: {}", destination.display())));
|
||
app_server.shutdown().await.expect("shutdown app server");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn replayed_file_change_approval_recovers_snapshot_changes() {
|
||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let thread_id = ThreadId::new();
|
||
let cwd = test_path_buf("/tmp/project").abs();
|
||
app.replay_thread_snapshot(
|
||
ThreadEventSnapshot {
|
||
delegated_turns: Vec::new(),
|
||
session: Some(test_thread_session(thread_id, cwd.clone().into_path_buf())),
|
||
turns: vec![test_turn(
|
||
"turn-replayed-approval",
|
||
TurnStatus::InProgress,
|
||
vec![ThreadItemPayload::FileChange {
|
||
id: "patch-replayed-approval".to_string(),
|
||
changes: vec![FileUpdateChange {
|
||
path: "visible-target.md".to_string(),
|
||
kind: PatchChangeKind::Add,
|
||
diff: "hello\n".to_string(),
|
||
}],
|
||
status: codex_app_server_protocol::PatchApplyStatus::InProgress,
|
||
}],
|
||
)],
|
||
events: vec![ThreadBufferedEvent::Request(Box::new(
|
||
ServerRequest::FileChangeRequestApproval {
|
||
request_id: AppServerRequestId::Integer(11),
|
||
params: FileChangeRequestApprovalParams {
|
||
thread_id: thread_id.to_string(),
|
||
turn_id: "turn-replayed-approval".to_string(),
|
||
item_id: "patch-replayed-approval".to_string(),
|
||
started_at_ms: 0,
|
||
reason: None,
|
||
grant_root: None,
|
||
},
|
||
},
|
||
))],
|
||
active_reasoning_item: None,
|
||
input_state: None,
|
||
},
|
||
/*resume_restored_queue*/ false,
|
||
);
|
||
|
||
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 100).replace(
|
||
&cwd.join("visible-target.md").display().to_string(),
|
||
"/tmp/project/visible-target.md",
|
||
);
|
||
assert_app_snapshot!("replayed_file_change_approval", rendered);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn inactive_thread_permissions_approval_preserves_file_system_permissions() {
|
||
let app = make_test_app().await;
|
||
let thread_id = ThreadId::new();
|
||
let request = ServerRequest::PermissionsRequestApproval {
|
||
request_id: AppServerRequestId::Integer(7),
|
||
params: PermissionsRequestApprovalParams {
|
||
thread_id: thread_id.to_string(),
|
||
turn_id: "turn-approval".to_string(),
|
||
item_id: "call-approval".to_string(),
|
||
environment_id: Some("remote".to_string()),
|
||
started_at_ms: 0,
|
||
cwd: test_absolute_path("/tmp").into(),
|
||
reason: Some("Need access to .git".to_string()),
|
||
permissions: codex_app_server_protocol::RequestPermissionProfile {
|
||
network: Some(AdditionalNetworkPermissions {
|
||
enabled: Some(true),
|
||
}),
|
||
file_system: Some(AdditionalFileSystemPermissions {
|
||
read: Some(vec![test_absolute_path("/tmp/read-only").into()]),
|
||
write: Some(vec![test_absolute_path("/tmp/write").into()]),
|
||
glob_scan_max_depth: None,
|
||
entries: None,
|
||
}),
|
||
},
|
||
},
|
||
};
|
||
|
||
let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Permissions(approval))) = app
|
||
.interactive_request_for_thread_request(thread_id, &request)
|
||
.await
|
||
.expect("valid localized paths")
|
||
else {
|
||
panic!("expected permissions approval request");
|
||
};
|
||
|
||
assert_eq!(approval.environment_id.as_deref(), Some("remote"));
|
||
assert_eq!(
|
||
approval.permissions,
|
||
RequestPermissionProfile {
|
||
network: Some(NetworkPermissions {
|
||
enabled: Some(true),
|
||
}),
|
||
file_system: Some(FileSystemPermissions::from_read_write_roots(
|
||
Some(vec![test_absolute_path("/tmp/read-only")]),
|
||
Some(vec![test_absolute_path("/tmp/write")]),
|
||
)),
|
||
}
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn inactive_thread_url_elicitation_routes_to_app_link() {
|
||
let app = make_test_app().await;
|
||
let thread_id = ThreadId::new();
|
||
let request = ServerRequest::McpServerElicitationRequest {
|
||
request_id: AppServerRequestId::Integer(9),
|
||
params: McpServerElicitationRequestParams {
|
||
thread_id: thread_id.to_string(),
|
||
turn_id: Some("turn-auth".to_string()),
|
||
server_name: "payments".to_string(),
|
||
request: McpServerElicitationRequest::Url {
|
||
meta: None,
|
||
message: "Review the payment details to continue.".to_string(),
|
||
url: "https://payments.example/checkout/123".to_string(),
|
||
elicitation_id: "payment-123".to_string(),
|
||
},
|
||
},
|
||
};
|
||
|
||
let Some(ThreadInteractiveRequest::AppLink(params)) = app
|
||
.interactive_request_for_thread_request(thread_id, &request)
|
||
.await
|
||
.expect("valid localized paths")
|
||
else {
|
||
panic!("expected app link request");
|
||
};
|
||
|
||
assert_eq!(params.title, "Action required");
|
||
assert_eq!(params.description, Some("Server: payments".to_string()));
|
||
assert_eq!(params.url, "https://payments.example/checkout/123");
|
||
assert_eq!(
|
||
params.elicitation_target,
|
||
Some(crate::bottom_pane::AppLinkElicitationTarget {
|
||
thread_id,
|
||
server_name: "payments".to_string(),
|
||
request_id: AppServerRequestId::Integer(9),
|
||
})
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn inactive_thread_invalid_url_elicitation_is_declined() {
|
||
let (app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let thread_id = ThreadId::new();
|
||
let request = ServerRequest::McpServerElicitationRequest {
|
||
request_id: AppServerRequestId::Integer(10),
|
||
params: McpServerElicitationRequestParams {
|
||
thread_id: thread_id.to_string(),
|
||
turn_id: Some("turn-auth".to_string()),
|
||
server_name: "payments".to_string(),
|
||
request: McpServerElicitationRequest::Url {
|
||
meta: None,
|
||
message: "Review the payment details to continue.".to_string(),
|
||
url: "http://payments.example/checkout/123".to_string(),
|
||
elicitation_id: "payment-123".to_string(),
|
||
},
|
||
},
|
||
};
|
||
|
||
assert!(
|
||
app.interactive_request_for_thread_request(thread_id, &request)
|
||
.await
|
||
.expect("valid localized paths")
|
||
.is_none()
|
||
);
|
||
assert_matches!(
|
||
app_event_rx.try_recv(),
|
||
Ok(AppEvent::SubmitThreadOp {
|
||
thread_id: op_thread_id,
|
||
op: Op::ResolveElicitation {
|
||
server_name,
|
||
request_id: AppServerRequestId::Integer(10),
|
||
decision: codex_app_server_protocol::McpServerElicitationAction::Decline,
|
||
content: None,
|
||
meta: None,
|
||
},
|
||
}) if op_thread_id == thread_id && server_name == "payments"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn inactive_thread_approval_badge_clears_after_turn_completion_notification() -> Result<()> {
|
||
let mut app = make_test_app().await;
|
||
let main_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread");
|
||
let agent_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000202").expect("valid thread");
|
||
|
||
app.primary_thread_id = Some(main_thread_id);
|
||
app.active_thread_id = Some(main_thread_id);
|
||
app.thread_event_channels
|
||
.insert(main_thread_id, ThreadEventChannel::new(/*capacity*/ 1));
|
||
app.thread_event_channels.insert(
|
||
agent_thread_id,
|
||
ThreadEventChannel::new_with_session(
|
||
/*capacity*/ 4,
|
||
ThreadSessionState {
|
||
approval_policy: AskForApproval::OnRequest,
|
||
permission_profile: PermissionProfile::workspace_write(),
|
||
rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")),
|
||
..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent"))
|
||
},
|
||
Vec::new(),
|
||
),
|
||
);
|
||
app.agent_navigation.upsert(
|
||
agent_thread_id,
|
||
Some("Robie".to_string()),
|
||
Some("explorer".to_string()),
|
||
/*is_closed*/ false,
|
||
);
|
||
|
||
app.enqueue_thread_request(
|
||
agent_thread_id,
|
||
exec_approval_request(
|
||
agent_thread_id,
|
||
"turn-approval",
|
||
"call-approval",
|
||
/*approval_id*/ None,
|
||
),
|
||
)
|
||
.await?;
|
||
assert_eq!(
|
||
app.chat_widget.pending_thread_approvals(),
|
||
&["Robie [explorer]".to_string()]
|
||
);
|
||
|
||
app.enqueue_thread_notification(
|
||
agent_thread_id,
|
||
turn_completed_notification(agent_thread_id, "turn-approval", TurnStatus::Completed),
|
||
)
|
||
.await?;
|
||
|
||
assert!(
|
||
app.chat_widget.pending_thread_approvals().is_empty(),
|
||
"turn completion should clear inactive-thread approval badge immediately"
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn inactive_thread_started_notification_initializes_replay_session() -> Result<()> {
|
||
let mut app = make_test_app().await;
|
||
let temp_dir = tempdir()?;
|
||
let main_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread");
|
||
let agent_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000202").expect("valid thread");
|
||
let primary_cwd = test_path_buf("/tmp/main").abs();
|
||
let shared_root = test_path_buf("/tmp/shared").abs();
|
||
let primary_session = ThreadSessionState {
|
||
daybreak_enabled: false,
|
||
windows_sandbox_host: WindowsSandboxHost::Local,
|
||
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");
|
||
app.enqueue_thread_notification(
|
||
agent_thread_id,
|
||
ServerNotification::ThreadStarted(ThreadStartedNotification {
|
||
thread: Thread {
|
||
originator: None,
|
||
environments: Some(vec![codex_app_server_protocol::ThreadEnvironment {
|
||
environment_id: "remote".to_string(),
|
||
cwd: test_path_buf("/tmp/agent").abs().into(),
|
||
runtime_workspace_roots: Vec::new(),
|
||
}]),
|
||
id: agent_thread_id.to_string(),
|
||
extra: None,
|
||
session_id: agent_thread_id.to_string(),
|
||
forked_from_id: None,
|
||
parent_thread_id: None,
|
||
preview: "agent thread".to_string(),
|
||
ephemeral: false,
|
||
section: None,
|
||
section_entered_at: None,
|
||
project_id: None,
|
||
daybreak_enabled: Some(true),
|
||
history_mode: Default::default(),
|
||
model_provider: "agent-provider".to_string(),
|
||
model: Some("gpt-agent".to_string()),
|
||
reasoning_effort: None,
|
||
created_at: 1,
|
||
updated_at: 2,
|
||
recency_at: Some(2),
|
||
status: codex_app_server_protocol::ThreadStatus::Idle,
|
||
path: Some(rollout_path.clone()),
|
||
cwd: test_path_buf("/tmp/agent").abs(),
|
||
cli_version: "0.0.0".to_string(),
|
||
source: codex_app_server_protocol::SessionSource::Unknown,
|
||
can_accept_direct_input: None,
|
||
thread_source: None,
|
||
agent_nickname: Some("Robie".to_string()),
|
||
agent_role: Some("explorer".to_string()),
|
||
git_info: None,
|
||
name: Some("agent thread".to_string()),
|
||
turns: Vec::new(),
|
||
},
|
||
}),
|
||
)
|
||
.await?;
|
||
|
||
let store = app
|
||
.thread_event_channels
|
||
.get(&agent_thread_id)
|
||
.expect("agent thread channel")
|
||
.store
|
||
.lock()
|
||
.await;
|
||
let session = store.session.clone().expect("inferred session");
|
||
drop(store);
|
||
|
||
assert_eq!(session.thread_id, agent_thread_id);
|
||
assert!(session.daybreak_enabled);
|
||
assert_eq!(session.windows_sandbox_host, WindowsSandboxHost::Remote);
|
||
assert_eq!(session.thread_name, Some("agent thread".to_string()));
|
||
assert_eq!(session.model, "gpt-agent");
|
||
assert_eq!(session.model_provider_id, "agent-provider");
|
||
assert_eq!(session.approval_policy, primary_session.approval_policy);
|
||
assert_eq!(session.cwd.as_path(), test_path_buf("/tmp/agent").as_path());
|
||
assert_eq!(
|
||
session.runtime_workspace_roots,
|
||
vec![test_path_buf("/tmp/agent").abs(), shared_root]
|
||
);
|
||
assert_eq!(session.rollout_path, Some(rollout_path));
|
||
assert_eq!(
|
||
app.agent_navigation.get(&agent_thread_id),
|
||
Some(&AgentPickerThreadEntry {
|
||
agent_nickname: Some("Robie".to_string()),
|
||
agent_role: Some("explorer".to_string()),
|
||
agent_path: None,
|
||
is_running: false,
|
||
is_closed: false,
|
||
})
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn inactive_thread_started_notification_preserves_primary_model_when_path_missing()
|
||
-> Result<()> {
|
||
let mut app = make_test_app().await;
|
||
let main_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000301").expect("valid thread");
|
||
let agent_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000302").expect("valid thread");
|
||
let primary_cwd = test_path_buf("/tmp/main").abs();
|
||
let primary_session = ThreadSessionState {
|
||
approval_policy: AskForApproval::OnRequest,
|
||
permission_profile: PermissionProfile::workspace_write(),
|
||
runtime_workspace_roots: vec![primary_cwd.clone()],
|
||
..test_thread_session(main_thread_id, primary_cwd.to_path_buf())
|
||
};
|
||
|
||
app.primary_thread_id = Some(main_thread_id);
|
||
app.active_thread_id = Some(main_thread_id);
|
||
app.primary_session_configured = Some(primary_session.clone());
|
||
app.thread_event_channels.insert(
|
||
main_thread_id,
|
||
ThreadEventChannel::new_with_session(
|
||
/*capacity*/ 4,
|
||
primary_session.clone(),
|
||
Vec::new(),
|
||
),
|
||
);
|
||
|
||
app.enqueue_thread_notification(
|
||
agent_thread_id,
|
||
ServerNotification::ThreadStarted(ThreadStartedNotification {
|
||
thread: Thread {
|
||
originator: None,
|
||
environments: None,
|
||
id: agent_thread_id.to_string(),
|
||
extra: None,
|
||
session_id: agent_thread_id.to_string(),
|
||
forked_from_id: None,
|
||
parent_thread_id: None,
|
||
preview: "agent thread".to_string(),
|
||
ephemeral: false,
|
||
section: None,
|
||
section_entered_at: None,
|
||
project_id: None,
|
||
daybreak_enabled: None,
|
||
history_mode: Default::default(),
|
||
model_provider: "agent-provider".to_string(),
|
||
model: None,
|
||
reasoning_effort: None,
|
||
created_at: 1,
|
||
updated_at: 2,
|
||
recency_at: Some(2),
|
||
status: codex_app_server_protocol::ThreadStatus::Idle,
|
||
path: None,
|
||
cwd: test_path_buf("/tmp/agent").abs(),
|
||
cli_version: "0.0.0".to_string(),
|
||
source: codex_app_server_protocol::SessionSource::Unknown,
|
||
can_accept_direct_input: None,
|
||
thread_source: None,
|
||
agent_nickname: Some("Robie".to_string()),
|
||
agent_role: Some("explorer".to_string()),
|
||
git_info: None,
|
||
name: Some("agent thread".to_string()),
|
||
turns: Vec::new(),
|
||
},
|
||
}),
|
||
)
|
||
.await?;
|
||
|
||
let store = app
|
||
.thread_event_channels
|
||
.get(&agent_thread_id)
|
||
.expect("agent thread channel")
|
||
.store
|
||
.lock()
|
||
.await;
|
||
let session = store.session.clone().expect("inferred session");
|
||
|
||
assert_eq!(session.model, primary_session.model);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// `thread/read` is metadata/replay hydration and does not return a fresh
|
||
/// server-authored `PermissionProfile`, so it must not reuse the cached primary
|
||
/// session profile after swapping in the read thread's cwd.
|
||
#[tokio::test]
|
||
async fn thread_read_session_state_does_not_reuse_primary_permission_profile() {
|
||
let mut app = make_test_app().await;
|
||
let main_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000401").expect("valid thread");
|
||
let read_thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000402").expect("valid thread");
|
||
let primary_cwd = test_path_buf("/tmp/main").abs();
|
||
let primary_session = ThreadSessionState {
|
||
approval_policy: AskForApproval::OnRequest,
|
||
permission_profile: PermissionProfile::workspace_write(),
|
||
runtime_workspace_roots: vec![primary_cwd.clone()],
|
||
..test_thread_session(main_thread_id, primary_cwd.to_path_buf())
|
||
};
|
||
app.primary_session_configured = Some(primary_session);
|
||
|
||
let thread = Thread {
|
||
originator: None,
|
||
environments: None,
|
||
id: read_thread_id.to_string(),
|
||
extra: None,
|
||
session_id: read_thread_id.to_string(),
|
||
forked_from_id: None,
|
||
parent_thread_id: None,
|
||
preview: "read thread".to_string(),
|
||
ephemeral: false,
|
||
section: None,
|
||
section_entered_at: None,
|
||
project_id: None,
|
||
daybreak_enabled: None,
|
||
history_mode: Default::default(),
|
||
model_provider: "read-provider".to_string(),
|
||
model: Some("server-reported-model".to_string()),
|
||
reasoning_effort: None,
|
||
created_at: 1,
|
||
updated_at: 2,
|
||
recency_at: Some(2),
|
||
status: codex_app_server_protocol::ThreadStatus::Idle,
|
||
path: None,
|
||
cwd: test_path_buf("/tmp/read").abs(),
|
||
cli_version: "0.0.0".to_string(),
|
||
source: codex_app_server_protocol::SessionSource::Unknown,
|
||
can_accept_direct_input: None,
|
||
thread_source: None,
|
||
agent_nickname: None,
|
||
agent_role: None,
|
||
git_info: None,
|
||
name: Some("read thread".to_string()),
|
||
turns: Vec::new(),
|
||
};
|
||
|
||
let session = app
|
||
.session_state_for_thread_read(read_thread_id, &thread)
|
||
.await;
|
||
|
||
assert_eq!(session.thread_id, read_thread_id);
|
||
assert_eq!(session.model, "server-reported-model");
|
||
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"
|
||
);
|
||
|
||
app.enqueue_primary_thread_session(session, Vec::new())
|
||
.await
|
||
.expect("switch to read thread");
|
||
assert_eq!(app.chat_widget.current_model(), "server-reported-model");
|
||
}
|
||
|
||
#[test]
|
||
fn agent_picker_item_name_snapshot() {
|
||
let thread_id =
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
|
||
let snapshot = [
|
||
format!(
|
||
"{} | {}",
|
||
format_agent_picker_item_name(
|
||
Some("Robie"),
|
||
Some("explorer"),
|
||
/*is_primary*/ true
|
||
),
|
||
thread_id
|
||
),
|
||
format!(
|
||
"{} | {}",
|
||
format_agent_picker_item_name(
|
||
Some("Robie"),
|
||
Some("explorer"),
|
||
/*is_primary*/ false
|
||
),
|
||
thread_id
|
||
),
|
||
format!(
|
||
"{} | {}",
|
||
format_agent_picker_item_name(
|
||
Some("Robie"),
|
||
/*agent_role*/ None,
|
||
/*is_primary*/ false
|
||
),
|
||
thread_id
|
||
),
|
||
format!(
|
||
"{} | {}",
|
||
format_agent_picker_item_name(
|
||
/*agent_nickname*/ None,
|
||
Some("explorer"),
|
||
/*is_primary*/ false
|
||
),
|
||
thread_id
|
||
),
|
||
format!(
|
||
"{} | {}",
|
||
format_agent_picker_item_name(
|
||
/*agent_nickname*/ None, /*agent_role*/ None, /*is_primary*/ false
|
||
),
|
||
thread_id
|
||
),
|
||
]
|
||
.join("\n");
|
||
assert_app_snapshot!("agent_picker_item_name", snapshot);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn side_fork_config_is_ephemeral_and_appends_developer_guardrails() {
|
||
let app = make_test_app().await;
|
||
let original_approval_policy = app.config.permissions.approval_policy.value();
|
||
let original_sandbox_policy = app.config.legacy_sandbox_policy();
|
||
|
||
let fork_config = app.side_fork_config();
|
||
|
||
assert!(fork_config.ephemeral);
|
||
assert_eq!(
|
||
fork_config.permissions.approval_policy.value(),
|
||
original_approval_policy
|
||
);
|
||
assert_eq!(fork_config.legacy_sandbox_policy(), original_sandbox_policy);
|
||
let developer_instructions = fork_config
|
||
.developer_instructions
|
||
.as_deref()
|
||
.expect("side developer instructions");
|
||
assert!(
|
||
developer_instructions.contains("You are in a side conversation, not the main thread.")
|
||
);
|
||
assert!(
|
||
developer_instructions
|
||
.contains("inherited fork history is provided only as reference context")
|
||
);
|
||
assert!(
|
||
developer_instructions.contains(
|
||
"Only instructions submitted after the side-conversation boundary are active"
|
||
)
|
||
);
|
||
assert!(developer_instructions.contains("Do not continue, execute, or complete any task"));
|
||
assert!(
|
||
developer_instructions
|
||
.contains("External tools may be available according to this thread's current")
|
||
);
|
||
assert!(
|
||
developer_instructions
|
||
.contains("Any MCP or external tool calls or outputs visible in the inherited")
|
||
);
|
||
assert!(developer_instructions.contains("non-mutating inspection"));
|
||
assert!(developer_instructions.contains("Do not modify files"));
|
||
assert!(developer_instructions.contains("Do not request escalated permissions"));
|
||
assert!(app.transcript_cells.is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn side_fork_config_inherits_parent_thread_runtime_settings() {
|
||
let mut app = make_test_app().await;
|
||
app.config.model = Some("persisted-default-model".to_string());
|
||
app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Low);
|
||
|
||
let parent_service_tier = ServiceTier::Fast.request_value();
|
||
let parent_permission_profile = PermissionProfile::workspace_write();
|
||
app.chat_widget.set_model("parent-thread-model");
|
||
app.chat_widget
|
||
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
|
||
app.chat_widget
|
||
.set_service_tier(Some(parent_service_tier.to_string()));
|
||
app.chat_widget
|
||
.set_approval_policy(AskForApproval::OnRequest);
|
||
app.chat_widget
|
||
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy(
|
||
parent_permission_profile.clone(),
|
||
))
|
||
.expect("test permission profile should be accepted");
|
||
app.chat_widget
|
||
.set_approvals_reviewer(ApprovalsReviewer::AutoReview);
|
||
|
||
let fork_config = app.side_fork_config();
|
||
|
||
assert_eq!(
|
||
(
|
||
fork_config.model.as_deref(),
|
||
fork_config.model_reasoning_effort,
|
||
fork_config.service_tier.as_deref(),
|
||
fork_config.permissions.approval_policy.value(),
|
||
fork_config.permissions.permission_profile(),
|
||
fork_config.approvals_reviewer,
|
||
),
|
||
(
|
||
Some("parent-thread-model"),
|
||
Some(ReasoningEffortConfig::High),
|
||
Some(parent_service_tier),
|
||
AskForApproval::OnRequest.to_core(),
|
||
&parent_permission_profile,
|
||
ApprovalsReviewer::AutoReview,
|
||
)
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn side_start_block_message_allows_replacing_open_side_conversation() {
|
||
let mut app = make_test_app().await;
|
||
assert_eq!(
|
||
app.side_start_block_message(),
|
||
Some("'/side' is unavailable until the main thread is ready.")
|
||
);
|
||
|
||
app.primary_thread_id = Some(ThreadId::new());
|
||
assert_eq!(app.side_start_block_message(), None);
|
||
|
||
let parent_thread_id = ThreadId::new();
|
||
let side_thread_id = ThreadId::new();
|
||
app.side_threads
|
||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||
|
||
app.active_thread_id = Some(parent_thread_id);
|
||
assert_eq!(app.side_start_block_message(), None);
|
||
|
||
app.active_thread_id = Some(side_thread_id);
|
||
assert_eq!(
|
||
app.side_start_block_message(),
|
||
Some(
|
||
"A side conversation is already open. Press ctrl + c to return before starting another."
|
||
)
|
||
);
|
||
|
||
app.side_threads.remove(&side_thread_id);
|
||
assert_eq!(app.side_start_block_message(), None);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn side_parent_status_tracks_parent_turn_lifecycle() -> Result<()> {
|
||
let mut app = make_test_app().await;
|
||
let parent_thread_id = ThreadId::new();
|
||
let side_thread_id = ThreadId::new();
|
||
app.primary_thread_id = Some(parent_thread_id);
|
||
app.active_thread_id = Some(side_thread_id);
|
||
app.side_threads
|
||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||
|
||
app.enqueue_thread_notification(
|
||
parent_thread_id,
|
||
turn_completed_notification(parent_thread_id, "turn-1", TurnStatus::Completed),
|
||
)
|
||
.await?;
|
||
assert_eq!(
|
||
app.side_threads
|
||
.get(&side_thread_id)
|
||
.and_then(|state| state.parent_status),
|
||
Some(SideParentStatus::Finished)
|
||
);
|
||
|
||
app.enqueue_thread_notification(
|
||
parent_thread_id,
|
||
turn_started_notification(parent_thread_id, "turn-2"),
|
||
)
|
||
.await?;
|
||
assert_eq!(
|
||
app.side_threads
|
||
.get(&side_thread_id)
|
||
.and_then(|state| state.parent_status),
|
||
None
|
||
);
|
||
|
||
app.enqueue_thread_notification(
|
||
parent_thread_id,
|
||
turn_completed_notification(parent_thread_id, "turn-2", TurnStatus::Failed),
|
||
)
|
||
.await?;
|
||
assert_eq!(
|
||
app.side_threads
|
||
.get(&side_thread_id)
|
||
.and_then(|state| state.parent_status),
|
||
Some(SideParentStatus::Failed)
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn side_parent_status_prioritizes_input_over_approval() -> Result<()> {
|
||
let mut app = make_test_app().await;
|
||
let parent_thread_id = ThreadId::new();
|
||
let side_thread_id = ThreadId::new();
|
||
app.primary_thread_id = Some(parent_thread_id);
|
||
app.active_thread_id = Some(side_thread_id);
|
||
app.side_threads
|
||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||
|
||
app.enqueue_thread_request(
|
||
parent_thread_id,
|
||
exec_approval_request(
|
||
parent_thread_id,
|
||
"turn-approval",
|
||
"call-approval",
|
||
/*approval_id*/ None,
|
||
),
|
||
)
|
||
.await?;
|
||
assert_eq!(
|
||
app.side_threads
|
||
.get(&side_thread_id)
|
||
.and_then(|state| state.parent_status),
|
||
Some(SideParentStatus::NeedsApproval)
|
||
);
|
||
|
||
app.enqueue_thread_request(
|
||
parent_thread_id,
|
||
request_user_input_request(parent_thread_id, "turn-input", "call-input"),
|
||
)
|
||
.await?;
|
||
assert_eq!(
|
||
app.side_threads
|
||
.get(&side_thread_id)
|
||
.and_then(|state| state.parent_status),
|
||
Some(SideParentStatus::NeedsInput)
|
||
);
|
||
|
||
app.enqueue_thread_notification(
|
||
parent_thread_id,
|
||
ServerNotification::ServerRequestResolved(
|
||
codex_app_server_protocol::ServerRequestResolvedNotification {
|
||
thread_id: parent_thread_id.to_string(),
|
||
request_id: AppServerRequestId::Integer(2),
|
||
},
|
||
),
|
||
)
|
||
.await?;
|
||
assert_eq!(
|
||
app.side_threads
|
||
.get(&side_thread_id)
|
||
.and_then(|state| state.parent_status),
|
||
Some(SideParentStatus::NeedsApproval)
|
||
);
|
||
|
||
app.enqueue_thread_notification(
|
||
parent_thread_id,
|
||
ServerNotification::ServerRequestResolved(
|
||
codex_app_server_protocol::ServerRequestResolvedNotification {
|
||
thread_id: parent_thread_id.to_string(),
|
||
request_id: AppServerRequestId::Integer(1),
|
||
},
|
||
),
|
||
)
|
||
.await?;
|
||
assert_eq!(
|
||
app.side_threads
|
||
.get(&side_thread_id)
|
||
.and_then(|state| state.parent_status),
|
||
None
|
||
);
|
||
|
||
app.enqueue_thread_request(
|
||
parent_thread_id,
|
||
request_user_input_request(parent_thread_id, "turn-eviction", "input-eviction"),
|
||
)
|
||
.await?;
|
||
let chunk = "x".repeat(4 * 1024);
|
||
app.enqueue_thread_notification(
|
||
parent_thread_id,
|
||
agent_message_delta_notification(
|
||
parent_thread_id,
|
||
"turn-eviction",
|
||
"item-eviction",
|
||
&chunk,
|
||
),
|
||
)
|
||
.await?;
|
||
app.enqueue_thread_request(
|
||
parent_thread_id,
|
||
exec_approval_request(
|
||
parent_thread_id,
|
||
"turn-eviction",
|
||
"approval-eviction",
|
||
/*approval_id*/ None,
|
||
),
|
||
)
|
||
.await?;
|
||
|
||
let side_footer = |app: &App| {
|
||
render_bottom_popup(&app.chat_widget, /*width*/ 120)
|
||
.lines()
|
||
.find_map(|line| {
|
||
line.find("Side from main thread")
|
||
.map(|start| line[start..].trim().to_string())
|
||
})
|
||
.expect("side conversation footer should be rendered")
|
||
};
|
||
assert_eq!(
|
||
app.side_threads
|
||
.get(&side_thread_id)
|
||
.and_then(|state| state.parent_status),
|
||
Some(SideParentStatus::NeedsInput)
|
||
);
|
||
let input_footer = side_footer(&app);
|
||
|
||
for _ in 0..64 {
|
||
app.enqueue_thread_notification(
|
||
parent_thread_id,
|
||
agent_message_delta_notification(
|
||
parent_thread_id,
|
||
"turn-eviction",
|
||
"item-eviction",
|
||
&chunk,
|
||
),
|
||
)
|
||
.await?;
|
||
}
|
||
assert_eq!(
|
||
app.side_threads
|
||
.get(&side_thread_id)
|
||
.and_then(|state| state.parent_status),
|
||
Some(SideParentStatus::NeedsApproval)
|
||
);
|
||
let approval_footer = side_footer(&app);
|
||
assert!(
|
||
app.thread_event_channels
|
||
.get(&parent_thread_id)
|
||
.expect("parent thread channel should exist")
|
||
.store
|
||
.lock()
|
||
.await
|
||
.has_pending_thread_approvals()
|
||
);
|
||
|
||
app.enqueue_thread_notification(
|
||
parent_thread_id,
|
||
agent_message_delta_notification(
|
||
parent_thread_id,
|
||
"turn-eviction",
|
||
"item-eviction",
|
||
&chunk,
|
||
),
|
||
)
|
||
.await?;
|
||
assert_eq!(
|
||
app.side_threads
|
||
.get(&side_thread_id)
|
||
.and_then(|state| state.parent_status),
|
||
None
|
||
);
|
||
let cleared_footer = side_footer(&app);
|
||
assert!(
|
||
!app.thread_event_channels
|
||
.get(&parent_thread_id)
|
||
.expect("parent thread channel should exist")
|
||
.store
|
||
.lock()
|
||
.await
|
||
.has_pending_thread_approvals()
|
||
);
|
||
assert_snapshot!(
|
||
format!("{input_footer}\n{approval_footer}\n{cleared_footer}"),
|
||
@"
|
||
Side from main thread · main needs input · ⌃/ to switch · ⌃c to close
|
||
Side from main thread · main needs approval · ⌃/ to switch · ⌃c to close
|
||
Side from main thread · ⌃/ to switch · ⌃c to close
|
||
"
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn side_thread_snapshot_hides_forked_parent_transcript() {
|
||
let parent_thread_id = ThreadId::new();
|
||
let side_thread_id = ThreadId::new();
|
||
let mut store = ThreadEventStore::new(/*capacity*/ 4);
|
||
let session = ThreadSessionState {
|
||
daybreak_enabled: false,
|
||
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
||
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![ThreadItemPayload::UserMessage {
|
||
id: "parent-user".to_string(),
|
||
client_id: None,
|
||
content: vec![AppServerUserInput::Text {
|
||
text: "parent prompt should stay hidden".to_string(),
|
||
text_elements: Vec::new(),
|
||
}],
|
||
}],
|
||
);
|
||
|
||
App::install_side_thread_snapshot(&mut store, session, vec![parent_turn]);
|
||
|
||
let stored_session = store.session.as_ref().expect("side session");
|
||
assert_eq!(stored_session.thread_id, side_thread_id);
|
||
assert_eq!(stored_session.forked_from_id, None);
|
||
assert_eq!(store.turns, Vec::<Turn>::new());
|
||
assert_eq!(store.active_turn_id(), None);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn side_thread_snapshot_does_not_refresh_from_fork_history() {
|
||
let mut app = make_test_app().await;
|
||
let parent_thread_id = ThreadId::new();
|
||
let side_thread_id = ThreadId::new();
|
||
app.side_threads
|
||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||
|
||
let snapshot = ThreadEventSnapshot {
|
||
delegated_turns: Vec::new(),
|
||
session: Some(ThreadSessionState {
|
||
rollout_path: None,
|
||
..test_thread_session(side_thread_id, test_path_buf("/tmp/side"))
|
||
}),
|
||
turns: Vec::new(),
|
||
events: Vec::new(),
|
||
active_reasoning_item: None,
|
||
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 {
|
||
delegated_turns: Vec::new(),
|
||
session: Some(ThreadSessionState {
|
||
daybreak_enabled: false,
|
||
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
||
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(),
|
||
active_reasoning_item: None,
|
||
input_state: None,
|
||
};
|
||
|
||
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);
|
||
|
||
let mut rendered_cells = Vec::new();
|
||
while let Ok(event) = app_event_rx.try_recv() {
|
||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||
rendered_cells.push(lines_to_single_string(&cell.display_lines(/*width*/ 120)));
|
||
}
|
||
}
|
||
assert_eq!(app.chat_widget.thread_id(), Some(side_thread_id));
|
||
assert_eq!(rendered_cells, Vec::<String>::new());
|
||
assert_eq!(
|
||
app.chat_widget.active_cell_transcript_lines(/*width*/ 120),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn primary_thread_ignores_child_mcp_startup_notifications() {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
while app_event_rx.try_recv().is_ok() {}
|
||
let sentry_config = toml::from_str::<toml::Value>("command = 'true'")
|
||
.expect("test MCP config should parse")
|
||
.try_into()
|
||
.expect("test MCP config should deserialize");
|
||
app.config
|
||
.mcp_servers
|
||
.set(std::collections::HashMap::from([(
|
||
"sentry".to_string(),
|
||
sentry_config,
|
||
)]))
|
||
.expect("test MCP servers should accept any configuration");
|
||
let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
|
||
.await
|
||
.expect("embedded app server");
|
||
let parent_thread_id = ThreadId::new();
|
||
let child_thread_id = ThreadId::new();
|
||
app.primary_thread_id = Some(parent_thread_id);
|
||
app.active_thread_id = Some(parent_thread_id);
|
||
app.upsert_agent_picker_thread(
|
||
child_thread_id,
|
||
/*agent_nickname*/ None,
|
||
/*agent_role*/ None,
|
||
/*is_closed*/ false,
|
||
);
|
||
|
||
app.handle_app_server_event(
|
||
&app_server,
|
||
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
|
||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||
thread_id: Some(child_thread_id.to_string()),
|
||
name: "sentry".to_string(),
|
||
status: McpServerStartupState::Failed,
|
||
error: Some("sentry is not logged in".to_string()),
|
||
failure_reason: None,
|
||
}),
|
||
)),
|
||
)
|
||
.await;
|
||
|
||
assert!(app_event_rx.try_recv().is_err());
|
||
let mut child_snapshot = app
|
||
.thread_event_channels
|
||
.get(&child_thread_id)
|
||
.expect("child thread channel should be created")
|
||
.store
|
||
.lock()
|
||
.await
|
||
.snapshot();
|
||
assert!(
|
||
matches!(
|
||
child_snapshot.events.as_slice(),
|
||
[ThreadBufferedEvent::Notification(notification)]
|
||
if matches!(
|
||
notification.as_ref(),
|
||
ServerNotification::McpServerStatusUpdated(_)
|
||
)
|
||
),
|
||
"child MCP startup notification should be buffered for the child thread"
|
||
);
|
||
|
||
app.apply_refreshed_snapshot_thread(
|
||
child_thread_id,
|
||
AppServerStartedThread {
|
||
session: test_thread_session(child_thread_id, test_path_buf("/tmp/child")),
|
||
turns: Vec::new(),
|
||
blocks_direct_input: false,
|
||
task_tools_available: false,
|
||
},
|
||
&mut child_snapshot,
|
||
)
|
||
.await;
|
||
app.replay_thread_snapshot(child_snapshot, /*resume_restored_queue*/ false);
|
||
|
||
let mut rendered_cells = Vec::new();
|
||
while let Ok(event) = app_event_rx.try_recv() {
|
||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||
rendered_cells.push(lines_to_single_string(
|
||
&cell.transcript_lines(/*width*/ 120),
|
||
));
|
||
}
|
||
}
|
||
let rendered = rendered_cells.join("\n");
|
||
assert_eq!(app.chat_widget.thread_id(), Some(child_thread_id));
|
||
assert_eq!(rendered.matches("sentry is not logged in").count(), 1);
|
||
assert_eq!(
|
||
rendered
|
||
.matches("MCP startup incomplete (failed: sentry)")
|
||
.count(),
|
||
1
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn app_scoped_mcp_startup_notifications_do_not_render_in_active_thread() {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
while app_event_rx.try_recv().is_ok() {}
|
||
let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
|
||
.await
|
||
.expect("embedded app server");
|
||
let thread_id = ThreadId::new();
|
||
app.primary_thread_id = Some(thread_id);
|
||
app.active_thread_id = Some(thread_id);
|
||
|
||
app.handle_app_server_event(
|
||
&app_server,
|
||
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
|
||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||
thread_id: None,
|
||
name: "sentry".to_string(),
|
||
status: McpServerStartupState::Failed,
|
||
error: Some("sentry is not logged in".to_string()),
|
||
failure_reason: None,
|
||
}),
|
||
)),
|
||
)
|
||
.await;
|
||
|
||
assert!(app_event_rx.try_recv().is_err());
|
||
assert_eq!(
|
||
app.chat_widget.active_cell_transcript_lines(/*width*/ 120),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn active_side_thread_renders_live_mcp_startup_notifications() {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
while app_event_rx.try_recv().is_ok() {}
|
||
let sentry_config = toml::from_str::<toml::Value>("command = 'true'")
|
||
.expect("test MCP config should parse")
|
||
.try_into()
|
||
.expect("test MCP config should deserialize");
|
||
app.config
|
||
.mcp_servers
|
||
.set(std::collections::HashMap::from([(
|
||
"sentry".to_string(),
|
||
sentry_config,
|
||
)]))
|
||
.expect("test MCP servers should accept any configuration");
|
||
let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
|
||
.await
|
||
.expect("embedded app server");
|
||
let parent_thread_id = ThreadId::new();
|
||
let side_thread_id = ThreadId::new();
|
||
app.primary_thread_id = Some(parent_thread_id);
|
||
app.side_threads
|
||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||
app.ensure_thread_channel(side_thread_id);
|
||
app.activate_thread_channel(side_thread_id).await;
|
||
app.replay_thread_snapshot(
|
||
ThreadEventSnapshot {
|
||
delegated_turns: Vec::new(),
|
||
session: Some(test_thread_session(
|
||
side_thread_id,
|
||
test_path_buf("/tmp/side"),
|
||
)),
|
||
turns: Vec::new(),
|
||
events: Vec::new(),
|
||
active_reasoning_item: None,
|
||
input_state: None,
|
||
},
|
||
/*resume_restored_queue*/ false,
|
||
);
|
||
app.sync_side_thread_ui();
|
||
|
||
for status in [
|
||
McpServerStartupState::Starting,
|
||
McpServerStartupState::Failed,
|
||
] {
|
||
app.handle_app_server_event(
|
||
&app_server,
|
||
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
|
||
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
||
thread_id: Some(side_thread_id.to_string()),
|
||
name: "sentry".to_string(),
|
||
status,
|
||
error: matches!(status, McpServerStartupState::Failed)
|
||
.then(|| "sentry is not logged in".to_string()),
|
||
failure_reason: None,
|
||
}),
|
||
)),
|
||
)
|
||
.await;
|
||
}
|
||
|
||
let mut active_thread_events = Vec::new();
|
||
let active_thread_rx = app
|
||
.active_thread_rx
|
||
.as_mut()
|
||
.expect("side thread receiver should be active");
|
||
while let Ok(event) = active_thread_rx.try_recv() {
|
||
active_thread_events.push(event);
|
||
}
|
||
for event in active_thread_events {
|
||
app.handle_thread_event_now(event);
|
||
}
|
||
|
||
let mut tui = crate::tui::test_support::make_test_tui().expect("test tui");
|
||
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.transcript_lines(/*width*/ 120),
|
||
));
|
||
app.insert_history_cell(&mut tui, cell);
|
||
}
|
||
}
|
||
assert_eq!(history_cell::warning_count(&app.transcript_cells), 1);
|
||
let rendered = rendered_cells.join("\n");
|
||
assert!(app.chat_widget.side_conversation_active());
|
||
assert_eq!(rendered.matches("sentry is not logged in").count(), 1);
|
||
assert_eq!(
|
||
rendered
|
||
.matches("MCP startup incomplete (failed: sentry)")
|
||
.count(),
|
||
1
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn side_restore_user_message_puts_inline_question_back_in_composer() {
|
||
let mut app = make_test_app().await;
|
||
let user_message = crate::chatwidget::UserMessage::from("side question");
|
||
|
||
app.restore_side_user_message(Some(user_message));
|
||
|
||
assert_eq!(
|
||
app.chat_widget.composer_text_with_pending(),
|
||
"side question"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn side_discard_selection_only_closes_the_active_side() {
|
||
let mut app = make_test_app().await;
|
||
let parent_thread_id = ThreadId::new();
|
||
let side_thread_id = ThreadId::new();
|
||
app.active_thread_id = Some(side_thread_id);
|
||
app.side_threads
|
||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||
|
||
assert_eq!(
|
||
app.side_thread_to_discard_after_switch(side_thread_id),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
app.side_thread_to_discard_after_switch(parent_thread_id),
|
||
Some(side_thread_id)
|
||
);
|
||
|
||
app.active_thread_id = Some(parent_thread_id);
|
||
assert_eq!(
|
||
app.side_thread_to_discard_after_switch(ThreadId::new()),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
app.side_thread_to_discard_after_switch(side_thread_id),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn discard_side_thread_removes_agent_navigation_entry() -> Result<()> {
|
||
Box::pin(async {
|
||
let mut app = make_test_app().await;
|
||
let mut app_server =
|
||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||
let mut side_config = app.chat_widget.config_ref().clone();
|
||
side_config.ephemeral = true;
|
||
let started = app_server.start_thread(&side_config).await?;
|
||
let side_thread_id = started.session.thread_id;
|
||
app.side_threads
|
||
.insert(side_thread_id, SideThreadState::new(ThreadId::new()));
|
||
app.agent_navigation.upsert(
|
||
side_thread_id,
|
||
Some("Side".to_string()),
|
||
Some("side".to_string()),
|
||
/*is_closed*/ false,
|
||
);
|
||
|
||
assert!(
|
||
app.discard_side_thread(&mut app_server, side_thread_id)
|
||
.await
|
||
);
|
||
|
||
assert_eq!(app.agent_navigation.get(&side_thread_id), None);
|
||
assert!(!app.side_threads.contains_key(&side_thread_id));
|
||
Ok(())
|
||
})
|
||
.await
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn discard_side_thread_keeps_local_state_when_server_close_fails() -> Result<()> {
|
||
Box::pin(async {
|
||
let mut app = make_test_app().await;
|
||
let mut app_server =
|
||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||
let parent_thread_id = ThreadId::new();
|
||
let side_thread_id = ThreadId::new();
|
||
app.active_thread_id = Some(side_thread_id);
|
||
app.side_threads
|
||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||
app.agent_navigation.upsert(
|
||
side_thread_id,
|
||
Some("Side".to_string()),
|
||
Some("side".to_string()),
|
||
/*is_closed*/ false,
|
||
);
|
||
|
||
assert!(
|
||
!app.discard_side_thread(&mut app_server, side_thread_id)
|
||
.await
|
||
);
|
||
|
||
assert_eq!(app.active_thread_id, Some(side_thread_id));
|
||
assert_eq!(
|
||
app.side_threads
|
||
.get(&side_thread_id)
|
||
.map(|state| state.parent_thread_id),
|
||
Some(parent_thread_id)
|
||
);
|
||
assert!(app.agent_navigation.get(&side_thread_id).is_some());
|
||
Ok(())
|
||
})
|
||
.await
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_side_cleanup_removes_local_state_and_ignores_late_events() -> Result<()> {
|
||
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
|
||
let mut app_server =
|
||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||
let parent_thread_id = ThreadId::new();
|
||
let side_thread_id = ThreadId::new();
|
||
app.active_thread_id = Some(parent_thread_id);
|
||
app.side_threads
|
||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||
app.thread_event_channels
|
||
.insert(side_thread_id, ThreadEventChannel::new(/*capacity*/ 4));
|
||
app.agent_navigation.upsert(
|
||
side_thread_id,
|
||
Some("Side".to_string()),
|
||
Some("side".to_string()),
|
||
/*is_closed*/ false,
|
||
);
|
||
app.dynamic_tool_tasks.insert(
|
||
AppServerRequestId::Integer(123),
|
||
(
|
||
side_thread_id.to_string(),
|
||
tokio::spawn(std::future::pending::<()>()),
|
||
),
|
||
);
|
||
app.discard_side_thread_in_background(&mut app_server, side_thread_id)
|
||
.await;
|
||
|
||
assert_matches!(
|
||
events.try_recv(),
|
||
Ok(AppEvent::DynamicToolCallCompleted { response, .. }) if !response.success
|
||
);
|
||
assert!(app.dynamic_tool_tasks.is_empty());
|
||
assert_eq!(app.active_thread_id, Some(parent_thread_id));
|
||
assert!(!app.side_threads.contains_key(&side_thread_id));
|
||
assert!(!app.thread_event_channels.contains_key(&side_thread_id));
|
||
assert_eq!(app.agent_navigation.get(&side_thread_id), None);
|
||
assert!(app.abandoned_side_threads.contains(&side_thread_id));
|
||
|
||
app.enqueue_thread_notification(
|
||
side_thread_id,
|
||
agent_message_delta_notification(side_thread_id, "turn-1", "item-1", "late"),
|
||
)
|
||
.await?;
|
||
assert!(!app.thread_event_channels.contains_key(&side_thread_id));
|
||
|
||
app.handle_app_server_event(
|
||
&app_server,
|
||
codex_app_server_client::AppServerEvent::ServerRequest(Box::new(exec_approval_request(
|
||
side_thread_id,
|
||
"turn-1",
|
||
"item-1",
|
||
Some("approval-1"),
|
||
))),
|
||
)
|
||
.await;
|
||
let resolution = app
|
||
.pending_app_server_requests
|
||
.take_resolution(
|
||
&side_thread_id.to_string(),
|
||
Op::ExecApproval {
|
||
id: "approval-1".to_string(),
|
||
turn_id: None,
|
||
decision: codex_app_server_protocol::CommandExecutionApprovalDecision::Accept,
|
||
},
|
||
)
|
||
.expect("approval resolution should serialize");
|
||
assert_eq!(resolution, None);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn discard_closed_side_thread_removes_local_state_without_server_rpc() {
|
||
let mut app = make_test_app().await;
|
||
let parent_thread_id = ThreadId::new();
|
||
let side_thread_id = ThreadId::new();
|
||
app.active_thread_id = Some(side_thread_id);
|
||
app.side_threads
|
||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||
app.thread_event_channels
|
||
.insert(side_thread_id, ThreadEventChannel::new(/*capacity*/ 4));
|
||
app.agent_navigation.upsert(
|
||
side_thread_id,
|
||
Some("Side".to_string()),
|
||
Some("side".to_string()),
|
||
/*is_closed*/ false,
|
||
);
|
||
|
||
app.discard_closed_side_thread(side_thread_id).await;
|
||
|
||
assert_eq!(app.active_thread_id, None);
|
||
assert!(!app.side_threads.contains_key(&side_thread_id));
|
||
assert!(!app.thread_event_channels.contains_key(&side_thread_id));
|
||
assert_eq!(app.agent_navigation.get(&side_thread_id), None);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn active_non_primary_shutdown_target_returns_none_for_non_shutdown_event() -> Result<()> {
|
||
let mut app = make_test_app().await;
|
||
app.active_thread_id = Some(ThreadId::new());
|
||
app.primary_thread_id = Some(ThreadId::new());
|
||
|
||
assert_eq!(
|
||
app.active_non_primary_shutdown_target(&ServerNotification::SkillsChanged(
|
||
codex_app_server_protocol::SkillsChangedNotification {},
|
||
)),
|
||
None
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn active_non_primary_shutdown_target_returns_none_for_primary_thread_shutdown() -> Result<()>
|
||
{
|
||
let mut app = make_test_app().await;
|
||
let thread_id = ThreadId::new();
|
||
app.active_thread_id = Some(thread_id);
|
||
app.primary_thread_id = Some(thread_id);
|
||
|
||
assert_eq!(
|
||
app.active_non_primary_shutdown_target(&thread_closed_notification(thread_id)),
|
||
None
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn active_non_primary_shutdown_target_returns_ids_for_non_primary_shutdown() -> Result<()> {
|
||
let mut app = make_test_app().await;
|
||
let active_thread_id = ThreadId::new();
|
||
let primary_thread_id = ThreadId::new();
|
||
app.active_thread_id = Some(active_thread_id);
|
||
app.primary_thread_id = Some(primary_thread_id);
|
||
|
||
assert_eq!(
|
||
app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)),
|
||
Some((active_thread_id, primary_thread_id))
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn active_non_primary_shutdown_target_returns_none_when_shutdown_exit_is_pending()
|
||
-> Result<()> {
|
||
let mut app = make_test_app().await;
|
||
let active_thread_id = ThreadId::new();
|
||
let primary_thread_id = ThreadId::new();
|
||
app.active_thread_id = Some(active_thread_id);
|
||
app.primary_thread_id = Some(primary_thread_id);
|
||
app.pending_shutdown_exit_thread_id = Some(active_thread_id);
|
||
|
||
assert_eq!(
|
||
app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)),
|
||
None
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn active_non_primary_shutdown_target_still_switches_for_other_pending_exit_thread()
|
||
-> Result<()> {
|
||
let mut app = make_test_app().await;
|
||
let active_thread_id = ThreadId::new();
|
||
let primary_thread_id = ThreadId::new();
|
||
app.active_thread_id = Some(active_thread_id);
|
||
app.primary_thread_id = Some(primary_thread_id);
|
||
app.pending_shutdown_exit_thread_id = Some(ThreadId::new());
|
||
|
||
assert_eq!(
|
||
app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)),
|
||
Some((active_thread_id, primary_thread_id))
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String {
|
||
let mut app = make_test_app().await;
|
||
app.config.cwd = test_path_buf("/tmp/project").abs();
|
||
app.chat_widget.set_model("gpt-test");
|
||
app.chat_widget
|
||
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
|
||
let story_part_one = "In the cliffside town of Bracken Ferry, the lighthouse had been dark for \
|
||
nineteen years, and the children were told it was because the sea no longer wanted a \
|
||
guide. Mara, who repaired clocks for a living, found that hard to believe. Every dawn she \
|
||
heard the gulls circling the empty tower, and every dusk she watched ships hesitate at the \
|
||
mouth of the bay as if listening for a signal that never came. When an old brass key fell \
|
||
out of a cracked parcel in her workshop, tagged only with the words 'for the lamp room,' \
|
||
she decided to climb the hill and see what the town had forgotten.";
|
||
let story_part_two = "Inside the lighthouse she found gears wrapped in oilcloth, logbooks filled \
|
||
with weather notes, and a lens shrouded beneath salt-stiff canvas. The mechanism was not \
|
||
broken, only unfinished. Someone had removed the governor spring and hidden it in a false \
|
||
drawer, along with a letter from the last keeper admitting he had darkened the light on \
|
||
purpose after smugglers threatened his family. Mara spent the night rebuilding the clockwork \
|
||
from spare watch parts, her fingers blackened with soot and grease, while a storm gathered \
|
||
over the water and the harbor bells began to ring.";
|
||
let story_part_three = "At midnight the first squall hit, and the fishing boats returned early, \
|
||
blind in sheets of rain. Mara wound the mechanism, set the teeth by hand, and watched the \
|
||
great lens begin to turn in slow, certain arcs. The beam swept across the bay, caught the \
|
||
whitecaps, and reached the boats just as they were drifting toward the rocks below the \
|
||
eastern cliffs. In the morning the town square was crowded with wet sailors, angry elders, \
|
||
and wide-eyed children, but when the oldest captain placed the keeper's log on the fountain \
|
||
and thanked Mara for relighting the coast, nobody argued. By sunset, Bracken Ferry had a \
|
||
lighthouse again, and Mara had more clocks to mend than ever because everyone wanted \
|
||
something in town to keep better time.";
|
||
|
||
let user_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
||
Arc::new(UserHistoryCell {
|
||
presentation: Default::default(),
|
||
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 {
|
||
daybreak_enabled: false,
|
||
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
||
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,
|
||
message_history: None,
|
||
network_proxy: None,
|
||
rollout_path: Some(PathBuf::new()),
|
||
};
|
||
Arc::new(new_session_info(
|
||
app.chat_widget.config_ref(),
|
||
&app.local_settings,
|
||
app.chat_widget.current_model(),
|
||
&session.model,
|
||
&session,
|
||
is_first,
|
||
/*tooltip_override*/ None,
|
||
/*auth_plan*/ None,
|
||
)) 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]
|
||
async fn ctrl_l_preserves_the_transcript_and_draft_after_clearing_the_screen() -> Result<()> {
|
||
let (mut app, _codex_home, thread_id) =
|
||
pagination_completion_tests::completed_history_app(&["Oldest", "Newest"]).await?;
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||
let started = app_server
|
||
.resume_thread(
|
||
&app.local_settings,
|
||
app.config.clone(),
|
||
thread_id,
|
||
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
|
||
)
|
||
.await?;
|
||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||
.await?;
|
||
app.scrollback_has_older_history = app_server.has_older_history(thread_id);
|
||
assert!(app.scrollback_has_older_history);
|
||
let cursor = app_server
|
||
.begin_older_history_page(thread_id)
|
||
.expect("older page");
|
||
let page = app_server
|
||
.thread_items_page(
|
||
thread_id,
|
||
/*turn_id*/ None,
|
||
Some(cursor.clone()),
|
||
/*limit*/ 100,
|
||
)
|
||
.await?;
|
||
app.chat_widget.set_model("gpt-test");
|
||
app.chat_widget.insert_str("draft survives");
|
||
app.transcript_cells = vec![Arc::new(history_cell::new_user_prompt(
|
||
"Earlier exchange".into(),
|
||
Vec::new(),
|
||
Vec::new(),
|
||
Vec::new(),
|
||
))];
|
||
tui.set_owned_screen(/*owned*/ true)?;
|
||
let size = ratatui::layout::Size::new(/*width*/ 80, /*height*/ 12);
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)),
|
||
)
|
||
.await?;
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
app.handle_older_history_page(
|
||
&mut tui,
|
||
&mut app_server,
|
||
thread_id,
|
||
&cursor,
|
||
Ok(page.clone()),
|
||
)
|
||
.await?;
|
||
assert!(app_server.has_older_history(thread_id));
|
||
app.request_owned_history(&mut tui, &mut app_server);
|
||
assert!(!app_server.is_older_history_page_pending(thread_id, &cursor));
|
||
app.insert_history_cell(
|
||
&mut tui,
|
||
Box::new(history_cell::PlainHistoryCell::new(vec![
|
||
"New exchange".into(),
|
||
])),
|
||
);
|
||
let size = Size::new(/*width*/ 64, /*height*/ 16);
|
||
tui.screen_size_for_event(&TuiEvent::Resize(size))?;
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
let compact = transcript_composer::buffer_text(
|
||
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal),
|
||
);
|
||
assert!(compact.contains("OpenAI Codex"));
|
||
assert!(compact.contains("New exchange"));
|
||
assert!(!compact.contains("Earlier exchange"));
|
||
let ctrl_t = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL);
|
||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(ctrl_t))
|
||
.await?;
|
||
let area = Rect::new(/*x*/ 0, /*y*/ 0, size.width, size.height);
|
||
let mut buffer = ratatui::buffer::Buffer::empty(area);
|
||
app.transcript_view
|
||
.render(area, &mut buffer, &app.transcript_cells);
|
||
assert_app_snapshot!(
|
||
"ctrl_l_detailed_transcript",
|
||
transcript_composer::buffer_text(&buffer).trim()
|
||
);
|
||
|
||
let home = KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL);
|
||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(home))
|
||
.await?;
|
||
assert_eq!(
|
||
app.transcript_view.history,
|
||
crate::pager_overlay::TranscriptHistoryState::LoadingBeginning
|
||
);
|
||
assert!(app_server.is_older_history_page_pending(thread_id, &cursor));
|
||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(ctrl_t))
|
||
.await?;
|
||
app.handle_older_history_page(&mut tui, &mut app_server, thread_id, &cursor, Ok(page))
|
||
.await?;
|
||
for event in [TuiEvent::Draw, TuiEvent::Key(home)] {
|
||
app.handle_tui_event(&mut tui, &mut app_server, event)
|
||
.await?;
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
let compact = transcript_composer::buffer_text(
|
||
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal),
|
||
);
|
||
assert!(!compact.contains("Earlier exchange"));
|
||
}
|
||
// Native scrollback replay uses a separate renderer when the terminal changes width.
|
||
app.local_settings.tui.terminal_resize_reflow_max_rows = None;
|
||
let replay = app
|
||
.render_transcript_lines_for_reflow(size.width)
|
||
.lines
|
||
.iter()
|
||
.map(rendered_line_text)
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(replay.contains("New exchange"));
|
||
assert!(!replay.contains("Earlier exchange"));
|
||
assert_eq!(
|
||
app.chat_widget.composer_text_with_pending(),
|
||
"draft survives"
|
||
);
|
||
assert!(tui.is_owned_screen());
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
async fn make_test_app() -> Box<App> {
|
||
let (mut chat_widget, app_event_tx, _rx, _op_rx) =
|
||
boxed_future!(make_chatwidget_manual_with_sender()).await;
|
||
let test_codex_home = chat_widget.test_codex_home.take();
|
||
let config = chat_widget.config_ref().clone();
|
||
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
|
||
let model = get_model_offline_for_tests(config.model.as_deref());
|
||
let session_telemetry = test_session_telemetry(&config, model.as_str());
|
||
|
||
Box::new(App {
|
||
feature_write_lock: Arc::default(),
|
||
config_notification_test_generation: 0,
|
||
config_notification_test_pending: None,
|
||
model_catalog: chat_widget.model_catalog(),
|
||
session_telemetry,
|
||
terminal_size_tags: Vec::new(),
|
||
app_event_tx,
|
||
chat_widget,
|
||
workspace_command_runner: None,
|
||
backend_preference_context: backend_selection::BackendPreferenceContext::new(
|
||
&config,
|
||
Some(config.cwd.to_path_buf()),
|
||
),
|
||
launch_cwd: config.cwd.to_path_buf(),
|
||
runtime_working_directory_override: None,
|
||
local_settings: crate::local_settings::LocalSettings::from(&config),
|
||
config,
|
||
state_db: None,
|
||
cli_kv_overrides: Vec::new(),
|
||
harness_overrides: ConfigOverrides::default(),
|
||
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
|
||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||
runtime_approval_policy_override: None,
|
||
runtime_approvals_reviewer_override: None,
|
||
runtime_permission_profile_override: None,
|
||
file_search,
|
||
transcript_cells: Vec::new(),
|
||
native_history: Default::default(),
|
||
turn_tips: Default::default(),
|
||
transcript_view: Default::default(),
|
||
conversation_frame: Default::default(),
|
||
last_rendered_history_tail: None,
|
||
last_thread_usage_status_cell: None,
|
||
pending_thread_usage_history_refresh: false,
|
||
overlay: None,
|
||
retained_analytics: None,
|
||
deferred_history_lines: Vec::new(),
|
||
has_emitted_history_lines: false,
|
||
transcript_reflow: TranscriptReflowState::default(),
|
||
initial_history_replay_buffer: None,
|
||
pending_thread_switch_resets: 0,
|
||
scrollback_has_older_history: false,
|
||
enhanced_keys_supported: false,
|
||
keymap: crate::keymap::RuntimeKeymap::defaults(),
|
||
key_chord_matcher: crate::keymap::KeyChordMatcher::default(),
|
||
routed_key_activation: None,
|
||
commit_animation: None,
|
||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||
skill_load_warnings: SkillLoadWarningState::default(),
|
||
backtrack: BacktrackState::default(),
|
||
backtrack_render_pending: false,
|
||
feedback: codex_feedback::CodexFeedback::new(),
|
||
feedback_audience: FeedbackAudience::External,
|
||
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
||
app_server_target: crate::AppServerTarget::Embedded,
|
||
reconnect: Default::default(),
|
||
pending_right_click_paste: None,
|
||
right_click_paste_environment: super::right_click_paste::PasteEnvironment {
|
||
primary: false,
|
||
platform_default: true,
|
||
ssh: false,
|
||
wsl: false,
|
||
vscode: crate::tui::VscodeDetection::Other,
|
||
},
|
||
daemon_cli_executable: None,
|
||
pending_update_action: None,
|
||
daemon_restart: None,
|
||
pending_shutdown_exit_thread_id: None,
|
||
windows_sandbox: WindowsSandboxState::default(),
|
||
thread_event_channels: HashMap::new(),
|
||
pending_realtime_speech_replay: HashMap::new(),
|
||
pending_realtime_transcript_replay: HashMap::new(),
|
||
realtime_replay_order: VecDeque::new(),
|
||
background_voice: None,
|
||
background_voice_error: None,
|
||
temporary_structured_requests: HashMap::new(),
|
||
pending_thread_titles: HashMap::new(),
|
||
thread_event_listener_tasks: HashMap::new(),
|
||
agent_navigation: AgentNavigationState::default(),
|
||
pending_server_profiles: HashMap::new(),
|
||
agents_overview: Default::default(),
|
||
side_threads: HashMap::new(),
|
||
side_companions: HashMap::new(),
|
||
abandoned_side_threads: HashSet::new(),
|
||
active_thread_id: None,
|
||
active_thread_rx: None,
|
||
primary_thread_id: None,
|
||
last_subagent_backfill_attempt: None,
|
||
primary_session_configured: None,
|
||
pending_primary_events: VecDeque::new(),
|
||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||
dynamic_tool_status_updates: tokio::sync::broadcast::channel(/*capacity*/ 64).0,
|
||
dynamic_tool_tasks: HashMap::new(),
|
||
pending_startup_thread_start: false,
|
||
pending_server_version_notice: None,
|
||
pending_open_resume_picker: false,
|
||
pending_managed_worktree_creation: false,
|
||
pending_working_directory_change: None,
|
||
pending_start_managed_worktree: None,
|
||
pending_managed_worktree_created: None,
|
||
pending_managed_worktree_transition: None,
|
||
pending_managed_worktree_attach: None,
|
||
startup_protected_input_boundary: false,
|
||
startup_pending_protected_request: false,
|
||
account_email_request_id: None,
|
||
rate_limit_hard_stop_generation: 0,
|
||
rate_limit_refresh_state: Default::default(),
|
||
pending_mcp_login_start: None,
|
||
active_mcp_login_ids: HashMap::new(),
|
||
pending_plugin_enabled_writes: HashMap::new(),
|
||
pending_hook_enabled_writes: HashMap::new(),
|
||
recap: recap::RecapState::default(),
|
||
_test_codex_home: test_codex_home,
|
||
})
|
||
}
|
||
|
||
pub(super) async fn make_test_app_with_channels() -> (
|
||
Box<App>,
|
||
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||
tokio::sync::mpsc::UnboundedReceiver<Op>,
|
||
) {
|
||
let (mut chat_widget, app_event_tx, rx, op_rx) =
|
||
boxed_future!(make_chatwidget_manual_with_sender()).await;
|
||
let test_codex_home = chat_widget.test_codex_home.take();
|
||
let config = chat_widget.config_ref().clone();
|
||
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
|
||
let model = get_model_offline_for_tests(config.model.as_deref());
|
||
let session_telemetry = test_session_telemetry(&config, model.as_str());
|
||
|
||
(
|
||
Box::new(App {
|
||
feature_write_lock: Arc::default(),
|
||
config_notification_test_generation: 0,
|
||
config_notification_test_pending: None,
|
||
model_catalog: chat_widget.model_catalog(),
|
||
session_telemetry,
|
||
terminal_size_tags: Vec::new(),
|
||
app_event_tx,
|
||
chat_widget,
|
||
workspace_command_runner: None,
|
||
backend_preference_context: backend_selection::BackendPreferenceContext::new(
|
||
&config,
|
||
Some(config.cwd.to_path_buf()),
|
||
),
|
||
launch_cwd: config.cwd.to_path_buf(),
|
||
runtime_working_directory_override: None,
|
||
local_settings: crate::local_settings::LocalSettings::from(&config),
|
||
config,
|
||
state_db: None,
|
||
cli_kv_overrides: Vec::new(),
|
||
harness_overrides: ConfigOverrides::default(),
|
||
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
|
||
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
||
runtime_approval_policy_override: None,
|
||
runtime_approvals_reviewer_override: None,
|
||
runtime_permission_profile_override: None,
|
||
file_search,
|
||
transcript_cells: Vec::new(),
|
||
native_history: Default::default(),
|
||
turn_tips: Default::default(),
|
||
transcript_view: Default::default(),
|
||
conversation_frame: Default::default(),
|
||
last_rendered_history_tail: None,
|
||
last_thread_usage_status_cell: None,
|
||
pending_thread_usage_history_refresh: false,
|
||
overlay: None,
|
||
retained_analytics: None,
|
||
deferred_history_lines: Vec::new(),
|
||
has_emitted_history_lines: false,
|
||
transcript_reflow: TranscriptReflowState::default(),
|
||
initial_history_replay_buffer: None,
|
||
pending_thread_switch_resets: 0,
|
||
scrollback_has_older_history: false,
|
||
enhanced_keys_supported: false,
|
||
keymap: crate::keymap::RuntimeKeymap::defaults(),
|
||
key_chord_matcher: crate::keymap::KeyChordMatcher::default(),
|
||
routed_key_activation: None,
|
||
commit_animation: None,
|
||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||
skill_load_warnings: SkillLoadWarningState::default(),
|
||
backtrack: BacktrackState::default(),
|
||
backtrack_render_pending: false,
|
||
feedback: codex_feedback::CodexFeedback::new(),
|
||
feedback_audience: FeedbackAudience::External,
|
||
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
||
app_server_target: crate::AppServerTarget::Embedded,
|
||
reconnect: Default::default(),
|
||
pending_right_click_paste: None,
|
||
right_click_paste_environment: super::right_click_paste::PasteEnvironment {
|
||
primary: false,
|
||
platform_default: true,
|
||
ssh: false,
|
||
wsl: false,
|
||
vscode: crate::tui::VscodeDetection::Other,
|
||
},
|
||
daemon_cli_executable: None,
|
||
pending_update_action: None,
|
||
daemon_restart: None,
|
||
pending_shutdown_exit_thread_id: None,
|
||
windows_sandbox: WindowsSandboxState::default(),
|
||
thread_event_channels: HashMap::new(),
|
||
pending_realtime_speech_replay: HashMap::new(),
|
||
pending_realtime_transcript_replay: HashMap::new(),
|
||
realtime_replay_order: VecDeque::new(),
|
||
background_voice: None,
|
||
background_voice_error: None,
|
||
temporary_structured_requests: HashMap::new(),
|
||
pending_thread_titles: HashMap::new(),
|
||
thread_event_listener_tasks: HashMap::new(),
|
||
agent_navigation: AgentNavigationState::default(),
|
||
pending_server_profiles: HashMap::new(),
|
||
agents_overview: Default::default(),
|
||
side_threads: HashMap::new(),
|
||
side_companions: HashMap::new(),
|
||
abandoned_side_threads: HashSet::new(),
|
||
active_thread_id: None,
|
||
active_thread_rx: None,
|
||
primary_thread_id: None,
|
||
last_subagent_backfill_attempt: None,
|
||
primary_session_configured: None,
|
||
pending_primary_events: VecDeque::new(),
|
||
pending_app_server_requests: PendingAppServerRequests::default(),
|
||
dynamic_tool_status_updates: tokio::sync::broadcast::channel(/*capacity*/ 64).0,
|
||
dynamic_tool_tasks: HashMap::new(),
|
||
pending_startup_thread_start: false,
|
||
pending_server_version_notice: None,
|
||
pending_open_resume_picker: false,
|
||
pending_managed_worktree_creation: false,
|
||
pending_working_directory_change: None,
|
||
pending_start_managed_worktree: None,
|
||
pending_managed_worktree_created: None,
|
||
pending_managed_worktree_transition: None,
|
||
pending_managed_worktree_attach: None,
|
||
startup_protected_input_boundary: false,
|
||
startup_pending_protected_request: false,
|
||
account_email_request_id: None,
|
||
rate_limit_hard_stop_generation: 0,
|
||
rate_limit_refresh_state: Default::default(),
|
||
pending_mcp_login_start: None,
|
||
active_mcp_login_ids: HashMap::new(),
|
||
pending_plugin_enabled_writes: HashMap::new(),
|
||
pending_hook_enabled_writes: HashMap::new(),
|
||
recap: recap::RecapState::default(),
|
||
_test_codex_home: test_codex_home,
|
||
}),
|
||
rx,
|
||
op_rx,
|
||
)
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn set_thread_goal_draft_materializes_long_objective_and_confirms_before_paste() -> Result<()>
|
||
{
|
||
let mut app = make_test_app().await;
|
||
let mut app_server =
|
||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||
let started = app_server
|
||
.start_thread(app.chat_widget.config_ref())
|
||
.await?;
|
||
let thread_id = started.session.thread_id;
|
||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||
.await?;
|
||
let objective = "x".repeat(MAX_THREAD_GOAL_OBJECTIVE_CHARS + 1);
|
||
|
||
app.set_thread_goal_draft(
|
||
&mut app_server,
|
||
thread_id,
|
||
crate::goal_files::GoalDraft {
|
||
objective: objective.clone(),
|
||
..Default::default()
|
||
},
|
||
crate::app_event::ThreadGoalSetMode::ConfirmIfExists,
|
||
)
|
||
.await;
|
||
|
||
let response = app_server.thread_goal_get(thread_id).await?;
|
||
let goal = response.goal.expect("goal should be set");
|
||
let saved_objective = goal.objective.clone();
|
||
let codex_home = app_server
|
||
.codex_home_path(&app.chat_widget.config_ref().codex_home)
|
||
.expect("codex home");
|
||
assert!(goal_files::objective_file_path(&goal.objective, Some(&codex_home)).is_some());
|
||
assert_eq!(
|
||
goal_files::objective_text_for_edit(&mut app_server, Some(&codex_home), &goal.objective)
|
||
.await
|
||
.expect("managed goal file should be readable"),
|
||
objective
|
||
);
|
||
let is_managed = |home: &AppServerPath, path: &str| {
|
||
let reference = goal_files::objective_file_reference(&AppServerPath::from_app_server(path))
|
||
.expect("goal objective reference");
|
||
goal_files::objective_file_path(&reference, Some(home)).is_some()
|
||
};
|
||
let suffix = "attachments/00000000-0000-4000-8000-000000000000/goal-objective.md";
|
||
for path in [
|
||
format!("/tmp/{suffix}"),
|
||
format!("{codex_home}/../other/{suffix}"),
|
||
format!("{codex_home}/other/{suffix}"),
|
||
] {
|
||
assert!(!is_managed(&codex_home, &path));
|
||
}
|
||
assert!(!is_managed(
|
||
&AppServerPath::from_app_server("/tmp/codex\\home"),
|
||
&format!("/tmp/codex/home/{suffix}")
|
||
));
|
||
let unix_path = AppServerPath::from_app_server("/tmp/codex\\").join("a");
|
||
assert_eq!(unix_path.as_str(), "/tmp/codex\\/a");
|
||
let attachments_dir = app.chat_widget.config_ref().codex_home.join("attachments");
|
||
let attachment_count = std::fs::read_dir(&attachments_dir)?.count();
|
||
let placeholder = "[Pasted Content 5 chars]";
|
||
let paste_draft = crate::goal_files::GoalDraft {
|
||
objective: format!("Use {placeholder}"),
|
||
text_elements: vec![TextElement::new(
|
||
(4..4 + placeholder.len()).into(),
|
||
Some(placeholder.to_string()),
|
||
)],
|
||
pending_pastes: vec![(placeholder.to_string(), "hello".to_string())],
|
||
..Default::default()
|
||
};
|
||
|
||
app.set_thread_goal_draft(
|
||
&mut app_server,
|
||
thread_id,
|
||
paste_draft.clone(),
|
||
crate::app_event::ThreadGoalSetMode::ConfirmIfExists,
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(
|
||
std::fs::read_dir(&attachments_dir)?.count(),
|
||
attachment_count
|
||
);
|
||
assert_eq!(
|
||
app_server
|
||
.thread_goal_get(thread_id)
|
||
.await?
|
||
.goal
|
||
.expect("goal should still be set")
|
||
.objective,
|
||
saved_objective
|
||
);
|
||
|
||
app.set_thread_goal_draft(
|
||
&mut app_server,
|
||
thread_id,
|
||
paste_draft,
|
||
crate::app_event::ThreadGoalSetMode::ReplaceExisting,
|
||
)
|
||
.await;
|
||
let goal = app_server
|
||
.thread_goal_get(thread_id)
|
||
.await?
|
||
.goal
|
||
.expect("replacement goal should be set");
|
||
let paste_path = goal
|
||
.objective
|
||
.strip_prefix("Use pasted text file: ")
|
||
.and_then(|text| text.strip_suffix(". Read this file before continuing."))
|
||
.expect("paste file reference");
|
||
assert_eq!(std::fs::read_to_string(paste_path)?, "hello");
|
||
let attachment_count = std::fs::read_dir(&attachments_dir)?.count();
|
||
|
||
let stale_paste = (placeholder.to_string(), "hello".to_string());
|
||
app.set_thread_goal_draft(
|
||
&mut app_server,
|
||
thread_id,
|
||
crate::goal_files::GoalDraft {
|
||
objective: "small goal".to_string(),
|
||
pending_pastes: vec![stale_paste],
|
||
..Default::default()
|
||
},
|
||
crate::app_event::ThreadGoalSetMode::ReplaceExisting,
|
||
)
|
||
.await;
|
||
assert_eq!(
|
||
std::fs::read_dir(&attachments_dir)?.count(),
|
||
attachment_count
|
||
);
|
||
|
||
let whitespace_placeholder = "[Pasted Content 3 chars]";
|
||
app.set_thread_goal_draft(
|
||
&mut app_server,
|
||
thread_id,
|
||
crate::goal_files::GoalDraft {
|
||
objective: whitespace_placeholder.to_string(),
|
||
text_elements: vec![TextElement::new(
|
||
(0..whitespace_placeholder.len()).into(),
|
||
Some(whitespace_placeholder.to_string()),
|
||
)],
|
||
pending_pastes: vec![(whitespace_placeholder.to_string(), " \n\t".to_string())],
|
||
..Default::default()
|
||
},
|
||
crate::app_event::ThreadGoalSetMode::ReplaceExisting,
|
||
)
|
||
.await;
|
||
assert_eq!(
|
||
std::fs::read_dir(&attachments_dir)?.count(),
|
||
attachment_count
|
||
);
|
||
assert_eq!(
|
||
app_server
|
||
.thread_goal_get(thread_id)
|
||
.await?
|
||
.goal
|
||
.expect("small goal should remain set")
|
||
.objective,
|
||
"small goal"
|
||
);
|
||
|
||
let image_dir = tempfile::tempdir()?;
|
||
let image_path = image_dir.path().join("local-image.png");
|
||
std::fs::write(&image_path, b"png bytes")?;
|
||
let image_placeholder = "[Image #3]";
|
||
app.set_thread_goal_draft(
|
||
&mut app_server,
|
||
thread_id,
|
||
crate::goal_files::GoalDraft {
|
||
objective: format!("Describe {image_placeholder}"),
|
||
text_elements: vec![TextElement::new(
|
||
(9..9 + image_placeholder.len()).into(),
|
||
Some(image_placeholder.to_string()),
|
||
)],
|
||
local_images: vec![crate::bottom_pane::LocalImageAttachment {
|
||
placeholder: image_placeholder.to_string(),
|
||
path: image_path,
|
||
}],
|
||
remote_image_urls: vec![
|
||
"https://example.com/first.png".to_string(),
|
||
"https://example.com/second.png".to_string(),
|
||
],
|
||
..Default::default()
|
||
},
|
||
crate::app_event::ThreadGoalSetMode::ReplaceExisting,
|
||
)
|
||
.await;
|
||
let objective = app_server
|
||
.thread_goal_get(thread_id)
|
||
.await?
|
||
.goal
|
||
.expect("image goal should be set")
|
||
.objective;
|
||
let copied_image = objective
|
||
.strip_prefix("Describe image file: ")
|
||
.and_then(|text| text.split_once("\n\n"))
|
||
.map(|(path, _)| path)
|
||
.expect("copied image path");
|
||
assert_eq!(std::fs::read(copied_image)?, b"png bytes");
|
||
assert!(objective.contains(
|
||
"Referenced image URLs:\n- [Image #1]: https://example.com/first.png\n- [Image #2]: https://example.com/second.png"
|
||
));
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn replace_goal_confirmation_snapshot() {
|
||
let mut app = make_test_app().await;
|
||
app.show_replace_thread_goal_confirmation(
|
||
ThreadId::new(),
|
||
goal_files::GoalDraft {
|
||
objective: "New goal".to_string(),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert_app_snapshot!(
|
||
"replace_goal_confirmation",
|
||
render_bottom_popup(&app.chat_widget, /*width*/ 80)
|
||
);
|
||
}
|
||
|
||
pub(super) fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState {
|
||
ThreadSessionState {
|
||
daybreak_enabled: false,
|
||
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
||
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,
|
||
message_history: None,
|
||
network_proxy: None,
|
||
rollout_path: Some(PathBuf::new()),
|
||
}
|
||
}
|
||
|
||
fn plain_line_cell(text: impl Into<String>) -> Arc<dyn HistoryCell> {
|
||
Arc::new(PlainHistoryCell::new(vec![Line::from(text.into())])) as Arc<dyn HistoryCell>
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn owned_stream_reflows_at_allocated_width_without_reporting_activity() -> Result<()> {
|
||
let table = "| Left | Right |\n| --- | --- |\n| one two three four five six seven | red blue green white orange |\n";
|
||
for kind in ["answer", "plan"] {
|
||
let mut app = make_test_app().await;
|
||
let id = ThreadId::new();
|
||
super::owned_transcript::tests::attach_thread(&mut app, id);
|
||
app.transcript_cells = (0..40)
|
||
.map(|i| plain_line_cell(format!("history-{i}")))
|
||
.collect();
|
||
if kind == "plan" {
|
||
app.chat_widget
|
||
.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
|
||
app.chat_widget.set_collaboration_mask(
|
||
crate::collaboration_modes::plan_mask(&app.model_catalog).unwrap(),
|
||
);
|
||
}
|
||
let delta = |text: &str| match kind {
|
||
"plan" => {
|
||
ServerNotification::PlanDelta(codex_app_server_protocol::PlanDeltaNotification {
|
||
thread_id: id.to_string(),
|
||
turn_id: kind.into(),
|
||
item_id: kind.into(),
|
||
delta: text.into(),
|
||
})
|
||
}
|
||
_ => agent_message_delta_notification(id, kind, kind, text),
|
||
};
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
tui.set_owned_screen(/*owned*/ true)?;
|
||
let mut draw = |app: &mut App, width| -> Result<String> {
|
||
let screen = tui.terminal.last_known_screen_size;
|
||
app.handle_draw_pre_render(&mut tui, screen)?;
|
||
let frames = tui.frame_requester();
|
||
let mut painted = Default::default();
|
||
tui.draw(screen.height, |frame| {
|
||
let area = Rect {
|
||
width,
|
||
..frame.area()
|
||
};
|
||
let prepared = app.prepare_conversation(frame.area(), Some(area), &frames);
|
||
painted = app.paint_conversation(frame, &prepared);
|
||
})?;
|
||
app.finish_conversation(&mut tui, painted)?;
|
||
Ok(super::owned_transcript::tests::buffer_text(
|
||
tui.terminal.previous_buffer(),
|
||
))
|
||
};
|
||
app.chat_widget
|
||
.handle_server_notification(delta(table), /*replay_kind*/ None);
|
||
for width in [80, 44, 80] {
|
||
let frame = draw(&mut app, width)?;
|
||
let mut expected = Vec::new();
|
||
let reserved = if kind == "plan" { 4 } else { 2 };
|
||
crate::markdown::append_markdown_agent(
|
||
table,
|
||
Some(usize::from(width) - reserved),
|
||
&mut expected,
|
||
);
|
||
let separator = expected
|
||
.iter()
|
||
.map(ToString::to_string)
|
||
.find(|line| line.contains('━'))
|
||
.unwrap();
|
||
assert!(
|
||
frame.lines().any(|line| line.trim() == separator.trim()),
|
||
"{kind} at {width}: {frame}"
|
||
);
|
||
if kind == "answer" && width == 44 {
|
||
insta::assert_snapshot!(
|
||
"allocated_stream_table",
|
||
normalize_snapshot_paths(frame.as_str())
|
||
);
|
||
}
|
||
}
|
||
app.transcript_view
|
||
.jump_to_entry(&app.transcript_cells, /*index*/ 8);
|
||
for width in [80, 44, 80] {
|
||
let held = draw(&mut app, width)?;
|
||
assert!(
|
||
held.contains("history-8")
|
||
&& held.contains("Back to bottom")
|
||
&& !held.contains("New activity"),
|
||
"{kind}: {held}"
|
||
);
|
||
}
|
||
// Deliver output without painting it. The next resize must preserve that unseen revision.
|
||
app.chat_widget.handle_server_notification(
|
||
delta("| new row | arrived |\n"),
|
||
/*replay_kind*/ None,
|
||
);
|
||
let changed = draw(&mut app, 44)?;
|
||
assert!(
|
||
changed.contains("history-8") && changed.contains("New activity"),
|
||
"{kind}: {changed}"
|
||
);
|
||
app.transcript_view.jump_to_latest();
|
||
assert_eq!(draw(&mut app, 80)?.matches("new row").count(), 1, "{kind}");
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn app_server_thread_replacement_clears_previous_transcript_before_replay() -> Result<()> {
|
||
let (mut app, mut events, _op_rx) = make_test_app_with_channels().await;
|
||
app.local_settings.tui.show_tooltips = false;
|
||
let previous_thread_id = ThreadId::new();
|
||
app.enqueue_primary_thread_session(
|
||
test_thread_session(previous_thread_id, test_path_buf("/tmp/previous")),
|
||
Vec::new(),
|
||
)
|
||
.await?;
|
||
while events.try_recv().is_ok() {}
|
||
app.transcript_cells = vec![plain_line_cell("Previous thread transcript")];
|
||
app.deferred_history_lines = vec![Line::from("Previous pending history").into()];
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
tui.insert_history_lines(vec![Line::from("Previous pending history")]);
|
||
app.chat_widget
|
||
.add_plain_history_lines(vec![Line::from("Previous queued history")]);
|
||
app.open_transcript_overlay(&mut tui);
|
||
assert!(tui.is_alt_screen_active());
|
||
|
||
let next_thread_id = ThreadId::new();
|
||
app.replace_chat_widget_with_app_server_thread(
|
||
&mut tui,
|
||
AppServerStartedThread {
|
||
session: test_thread_session(next_thread_id, test_path_buf("/tmp/next")),
|
||
turns: vec![test_turn(
|
||
"next-turn",
|
||
TurnStatus::Completed,
|
||
vec![ThreadItemPayload::UserMessage {
|
||
id: "next-user".to_string(),
|
||
client_id: None,
|
||
content: vec![AppServerUserInput::Text {
|
||
text: "Next thread prompt".to_string(),
|
||
text_elements: Vec::new(),
|
||
}],
|
||
}],
|
||
)],
|
||
blocks_direct_input: false,
|
||
task_tools_available: false,
|
||
},
|
||
session_lifecycle::ThreadAttachPresentation::SessionLineage,
|
||
/*initial_user_message*/ None,
|
||
)
|
||
.await?;
|
||
|
||
assert_eq!(app.chat_widget.thread_id(), Some(next_thread_id));
|
||
assert!(!tui.is_alt_screen_active());
|
||
assert!(app.transcript_cells.is_empty());
|
||
assert!(app.deferred_history_lines.is_empty());
|
||
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||
// The reset must still run if the transport disconnects before queued events are handled.
|
||
app.reconnect.offline = true;
|
||
while let Ok(event) = events.try_recv() {
|
||
app.handle_event(&mut tui, &mut app_server, event).await?;
|
||
}
|
||
let replayed_prompts = app
|
||
.transcript_cells
|
||
.iter()
|
||
.filter_map(|cell| {
|
||
cell.as_any()
|
||
.downcast_ref::<UserHistoryCell>()
|
||
.map(|user| user.message.as_str())
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_snapshot!(replayed_prompts.join("\n"), @"Next thread prompt");
|
||
assert!(app.transcript_cells.iter().all(|cell| {
|
||
!lines_to_single_string(&cell.display_lines(/*width*/ 80))
|
||
.contains("Previous queued history")
|
||
}));
|
||
let rendered = app
|
||
.render_transcript_lines_for_reflow(/*width*/ 80)
|
||
.lines
|
||
.iter()
|
||
.map(|line| {
|
||
let text = rendered_line_text(line);
|
||
if text.trim() == test_path_buf("/tmp/next").display().to_string() {
|
||
" <thread cwd>".to_string()
|
||
} else {
|
||
text
|
||
}
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(!rendered.contains("Previous thread transcript"));
|
||
assert!(!rendered.contains("Previous queued history"));
|
||
assert_snapshot!(rendered);
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn snapshot_thread_switch_discards_queued_previous_history() -> Result<()> {
|
||
let (mut app, mut events, _op_rx) = make_test_app_with_channels().await;
|
||
app.transcript_cells = vec![plain_line_cell("Previous thread transcript")];
|
||
app.chat_widget
|
||
.add_plain_history_lines(vec![Line::from("Previous queued history")]);
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||
let next_thread_id = ThreadId::new();
|
||
app.render_thread_snapshot(
|
||
&mut tui,
|
||
&app_server,
|
||
next_thread_id,
|
||
ThreadEventSnapshot {
|
||
delegated_turns: Vec::new(),
|
||
session: Some(test_thread_session(
|
||
next_thread_id,
|
||
test_path_buf("/tmp/next"),
|
||
)),
|
||
turns: vec![test_turn(
|
||
"next-turn",
|
||
TurnStatus::Completed,
|
||
vec![ThreadItemPayload::UserMessage {
|
||
id: "next-user".to_string(),
|
||
client_id: None,
|
||
content: vec![AppServerUserInput::Text {
|
||
text: "Next thread prompt".to_string(),
|
||
text_elements: Vec::new(),
|
||
}],
|
||
}],
|
||
)],
|
||
events: Vec::new(),
|
||
active_reasoning_item: None,
|
||
input_state: None,
|
||
},
|
||
/*resume_restored_queue*/ false,
|
||
)?;
|
||
assert_eq!(app.pending_thread_switch_resets, 1);
|
||
while let Ok(event) = events.try_recv() {
|
||
app.handle_event(&mut tui, &mut app_server, event).await?;
|
||
}
|
||
assert_eq!(app.pending_thread_switch_resets, 0);
|
||
let rendered = app
|
||
.render_transcript_lines_for_reflow(/*width*/ 80)
|
||
.lines
|
||
.iter()
|
||
.map(rendered_line_text)
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(!rendered.contains("Previous thread transcript"));
|
||
assert!(!rendered.contains("Previous queued history"));
|
||
assert!(rendered.contains("Next thread prompt"));
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
fn rendered_line_text(line: &crate::terminal_hyperlinks::HyperlinkLine) -> String {
|
||
line.line
|
||
.spans
|
||
.iter()
|
||
.map(|span| span.content.as_ref())
|
||
.collect()
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn capped_resize_reflow_renders_recent_suffix_only() {
|
||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(5);
|
||
app.transcript_cells = (0..20)
|
||
.map(|i| plain_line_cell(format!("cell {i}")))
|
||
.collect();
|
||
|
||
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
|
||
|
||
assert_eq!(rendered.lines.len(), 5);
|
||
assert_eq!(
|
||
rendered
|
||
.lines
|
||
.iter()
|
||
.map(rendered_line_text)
|
||
.collect::<Vec<_>>(),
|
||
vec![
|
||
"Earlier messages are available — press ⌃t to view the full transcript".to_string(),
|
||
String::new(),
|
||
"cell 18".to_string(),
|
||
String::new(),
|
||
"cell 19".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn uncapped_resize_reflow_renders_all_cells_when_row_cap_absent() {
|
||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(0);
|
||
app.transcript_cells = (0..20)
|
||
.map(|i| plain_line_cell(format!("cell {i}")))
|
||
.collect();
|
||
|
||
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
|
||
|
||
assert_eq!(rendered.lines.len(), 39);
|
||
assert_eq!(rendered_line_text(&rendered.lines[0]), "cell 0");
|
||
assert_eq!(rendered_line_text(&rendered.lines[38]), "cell 19");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn resize_reflow_wraps_transcript_early_when_pet_is_enabled() {
|
||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(0);
|
||
app.transcript_cells = vec![Arc::new(AgentMarkdownCell::new(
|
||
"alpha beta gamma delta epsilon zeta eta theta iota kappa lambda".to_string(),
|
||
Path::new("/tmp"),
|
||
))];
|
||
|
||
let without_pet = app.render_transcript_lines_for_reflow(/*width*/ 40);
|
||
app.chat_widget
|
||
.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported(
|
||
crate::pets::ImageProtocol::Kitty,
|
||
));
|
||
app.chat_widget
|
||
.install_test_ambient_pet_for_tests(/*animations_enabled*/ false);
|
||
let width = app.chat_widget.history_wrap_width(/*width*/ 40);
|
||
assert!(width < 40);
|
||
let with_pet = app.render_transcript_lines_for_reflow(width);
|
||
|
||
assert!(
|
||
with_pet.lines.len() > without_pet.lines.len(),
|
||
"expected pet-enabled transcript reflow to wrap earlier"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn closing_fullscreen_inline_overlay_restores_history_once() -> Result<()> {
|
||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||
app.transcript_cells = vec![plain_line_cell("Previous conversation")];
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
tui.set_alt_screen_enabled(/*enabled*/ false);
|
||
let screen_size = tui.terminal.last_known_screen_size;
|
||
tui.insert_history_lines(vec![Line::from("Previous conversation")]);
|
||
app.render_chat_widget_frame(&mut tui, screen_size)?;
|
||
|
||
// Content-height popups keep the existing history without rebuilding scrollback.
|
||
let popup_height = screen_size.height - 1;
|
||
tui.draw(popup_height, |_| {})?;
|
||
app.render_chat_widget_frame(&mut tui, screen_size)?;
|
||
assert!(app.last_rendered_history_tail.is_none());
|
||
|
||
tui.enter_alt_screen()?;
|
||
tui.draw(screen_size.height, |_| {})?;
|
||
tui.leave_alt_screen()?;
|
||
app.render_chat_widget_frame(&mut tui, screen_size)?;
|
||
|
||
assert!(tui.terminal.viewport_area.height < screen_size.height);
|
||
assert!(tui.terminal.viewport_area.y > 0);
|
||
let tail = app
|
||
.last_rendered_history_tail
|
||
.as_ref()
|
||
.expect("fullscreen exit rebuilt history");
|
||
assert_snapshot!(
|
||
tail.lines
|
||
.iter()
|
||
.map(rendered_line_text)
|
||
.collect::<Vec<_>>()
|
||
.join("\n"),
|
||
@"Previous conversation"
|
||
);
|
||
|
||
let deferred = vec![Line::from("Pending history").into()];
|
||
app.deferred_history_lines = deferred.clone();
|
||
app.render_chat_widget_frame(&mut tui, screen_size)?;
|
||
assert_eq!(app.deferred_history_lines, deferred);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn copy_picker_opening_preserves_terminal_scrollback_without_reflow() {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
app.chat_widget.local_settings.transcript_mode =
|
||
crate::transcript_mode::TranscriptMode::Terminal;
|
||
let response = "Existing response\n\n```rust\nkeep_scrollback();\n```";
|
||
app.chat_widget.handle_server_notification(
|
||
ServerNotification::ItemCompleted(
|
||
codex_app_server_protocol::ItemCompletedNotification::new(
|
||
String::new(),
|
||
"turn-1".to_string(),
|
||
serde_json::from_value(serde_json::json!({
|
||
"type": "agentMessage",
|
||
"id": "message-1",
|
||
"text": response,
|
||
}))
|
||
.expect("valid completed agent message"),
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
),
|
||
),
|
||
/*replay_kind*/ None,
|
||
);
|
||
while app_event_rx.try_recv().is_ok() {}
|
||
|
||
app.transcript_cells = vec![
|
||
plain_line_cell("Older terminal scrollback"),
|
||
Arc::new(AgentMarkdownCell::new(
|
||
response.to_string(),
|
||
Path::new("/tmp"),
|
||
)),
|
||
];
|
||
app.deferred_history_lines = vec![Line::from("Buffered scrollback").into()];
|
||
app.has_emitted_history_lines = true;
|
||
app.scrollback_has_older_history = true;
|
||
app.transcript_reflow.note_width(/*width*/ 80);
|
||
let before = app
|
||
.render_transcript_lines_for_reflow(/*width*/ 80)
|
||
.lines
|
||
.iter()
|
||
.map(rendered_line_text)
|
||
.collect::<Vec<_>>();
|
||
|
||
app.chat_widget.apply_external_edit("/copy".to_string());
|
||
assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Whole response"));
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Whole response"));
|
||
assert_eq!(
|
||
app.render_transcript_lines_for_reflow(/*width*/ 80)
|
||
.lines
|
||
.iter()
|
||
.map(rendered_line_text)
|
||
.collect::<Vec<_>>(),
|
||
before
|
||
);
|
||
assert_eq!(app.transcript_cells.len(), 2);
|
||
assert_eq!(app.deferred_history_lines.len(), 1);
|
||
assert!(app.has_emitted_history_lines);
|
||
assert!(app.scrollback_has_older_history);
|
||
assert!(!app.transcript_reflow.has_pending_reflow());
|
||
while let Ok(event) = app_event_rx.try_recv() {
|
||
assert!(
|
||
!matches!(
|
||
event,
|
||
AppEvent::ClearUi { .. }
|
||
| AppEvent::ClearUiAndSubmitUserMessage { .. }
|
||
| AppEvent::InsertHistoryCell(_)
|
||
| AppEvent::ConsolidateAgentMessage { .. }
|
||
),
|
||
"opening a picker must not modify terminal scrollback: {event:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() {
|
||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(100);
|
||
app.transcript_cells = (0..3)
|
||
.map(|i| plain_line_cell(format!("cell {i}")))
|
||
.collect();
|
||
|
||
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
|
||
|
||
assert_eq!(
|
||
rendered
|
||
.lines
|
||
.iter()
|
||
.map(rendered_line_text)
|
||
.collect::<Vec<_>>(),
|
||
vec![
|
||
"cell 0".to_string(),
|
||
String::new(),
|
||
"cell 1".to_string(),
|
||
String::new(),
|
||
"cell 2".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() {
|
||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(3);
|
||
|
||
app.begin_initial_history_replay_buffer();
|
||
for index in 0..5 {
|
||
App::buffer_initial_history_replay_display_lines(
|
||
app.initial_history_replay_buffer
|
||
.as_mut()
|
||
.expect("initial replay buffer active"),
|
||
vec![Line::from(format!("line {index}")).into()],
|
||
/*max_rows*/ 3,
|
||
);
|
||
}
|
||
|
||
let buffer = app
|
||
.initial_history_replay_buffer
|
||
.as_ref()
|
||
.expect("initial replay buffer should remain active");
|
||
assert_eq!(
|
||
buffer
|
||
.retained_lines
|
||
.iter()
|
||
.map(rendered_line_text)
|
||
.collect::<Vec<_>>(),
|
||
vec![
|
||
"line 2".to_string(),
|
||
"line 3".to_string(),
|
||
"line 4".to_string(),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn required_stream_reflow_during_capped_initial_replay_uses_transcript_tail() -> Result<()> {
|
||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(20);
|
||
app.transcript_cells = vec![
|
||
plain_line_cell("latest user question"),
|
||
Arc::new(AgentMarkdownCell::new(
|
||
"Final answer:\n\n| Pattern | Outcome |\n| --- | --- |\n| Table tail | Preserved |"
|
||
.to_string(),
|
||
Path::new("/tmp"),
|
||
)),
|
||
];
|
||
|
||
app.begin_initial_history_replay_buffer();
|
||
App::buffer_initial_history_replay_display_lines(
|
||
app.initial_history_replay_buffer
|
||
.as_mut()
|
||
.expect("initial replay buffer active"),
|
||
vec![Line::from("latest user question").into()],
|
||
/*max_rows*/ 20,
|
||
);
|
||
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
app.finish_required_stream_reflow(&mut tui)?;
|
||
|
||
let buffer = app
|
||
.initial_history_replay_buffer
|
||
.as_ref()
|
||
.expect("initial replay buffer should remain active");
|
||
assert_eq!(
|
||
(
|
||
buffer.retained_lines.len(),
|
||
buffer.render_from_transcript_tail
|
||
),
|
||
(0, true),
|
||
);
|
||
|
||
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
|
||
assert_snapshot!(
|
||
"required_stream_reflow_during_capped_initial_replay",
|
||
rendered
|
||
.lines
|
||
.iter()
|
||
.map(rendered_line_text)
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
);
|
||
|
||
app.finish_initial_history_replay_buffer(&mut tui);
|
||
assert!(app.initial_history_replay_buffer.is_none());
|
||
assert!(app.transcript_reflow.has_pending_reflow());
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn directive_only_completion_removes_streamed_directive() -> Result<()> {
|
||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(20);
|
||
app.begin_initial_history_replay_buffer();
|
||
app.transcript_cells = vec![
|
||
plain_line_cell("before directive"),
|
||
Arc::new(AgentMessageCell::new(
|
||
vec![Line::from(r#"::git-stage{cwd="/tmp"}"#)],
|
||
/*is_first_line*/ true,
|
||
)),
|
||
];
|
||
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
app.handle_consolidate_agent_message(
|
||
&mut tui,
|
||
AgentMarkdownCell::new(String::new(), Path::new("/tmp")),
|
||
ConsolidationScrollbackReflow::Required,
|
||
/*deferred_history_cell*/ None,
|
||
)?;
|
||
|
||
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
|
||
assert_snapshot!(
|
||
"directive_only_completion_removes_streamed_directive",
|
||
rendered
|
||
.lines
|
||
.iter()
|
||
.map(rendered_line_text)
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay()
|
||
-> Result<()> {
|
||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(7);
|
||
app.transcript_cells = vec![
|
||
plain_line_cell("latest user question"),
|
||
Arc::new(AgentMessageCell::new(
|
||
vec![Line::from("stale streamed table tail")],
|
||
/*is_first_line*/ true,
|
||
)),
|
||
];
|
||
|
||
app.begin_initial_history_replay_buffer();
|
||
App::buffer_initial_history_replay_display_lines(
|
||
app.initial_history_replay_buffer
|
||
.as_mut()
|
||
.expect("initial replay buffer active"),
|
||
vec![Line::from("stale streamed table tail").into()],
|
||
/*max_rows*/ 7,
|
||
);
|
||
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
app.handle_consolidate_agent_message(
|
||
&mut tui,
|
||
AgentMarkdownCell::new(
|
||
"Final answer:\n\n| Pattern | Outcome |\n| --- | --- |\n| Table tail | Preserved |"
|
||
.into(),
|
||
Path::new("/tmp"),
|
||
),
|
||
ConsolidationScrollbackReflow::Required,
|
||
/*deferred_history_cell*/ None,
|
||
)?;
|
||
app.open_transcript_overlay(&mut tui);
|
||
assert!(tui.is_alt_screen_active());
|
||
|
||
app.finish_initial_history_replay_buffer(&mut tui);
|
||
assert!(app.initial_history_replay_buffer.is_none());
|
||
assert!(app.transcript_reflow.has_pending_reflow());
|
||
|
||
let screen_size = tui.terminal.last_known_screen_size;
|
||
app.maybe_run_resize_reflow(&mut tui, screen_size)?;
|
||
assert!(app.transcript_reflow.has_pending_reflow());
|
||
|
||
app.close_transcript_overlay(&mut tui);
|
||
assert!(!tui.is_alt_screen_active());
|
||
assert!(app.transcript_reflow.has_pending_reflow());
|
||
|
||
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
|
||
assert_eq!(rendered.lines.len(), 7);
|
||
let rendered = rendered
|
||
.lines
|
||
.iter()
|
||
.map(rendered_line_text)
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(rendered.contains("Table tail"));
|
||
assert!(rendered.contains("Preserved"));
|
||
assert!(!rendered.contains("stale streamed table tail"));
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn thread_switch_replay_buffer_uses_transcript_tail_mode_when_row_cap_present() {
|
||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(3);
|
||
|
||
app.begin_thread_switch_history_replay_buffer();
|
||
|
||
let buffer = app
|
||
.initial_history_replay_buffer
|
||
.as_ref()
|
||
.expect("thread switch replay buffer should be active");
|
||
assert!(buffer.render_from_transcript_tail);
|
||
assert!(buffer.retained_lines.is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn thread_switch_replay_buffer_is_disabled_without_row_cap() {
|
||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||
app.local_settings.tui.terminal_resize_reflow_max_rows = Some(0);
|
||
|
||
app.begin_thread_switch_history_replay_buffer();
|
||
|
||
assert!(app.initial_history_replay_buffer.is_none());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn height_shrink_schedules_resize_reflow() {
|
||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||
let frame_requester = crate::tui::FrameRequester::test_dummy();
|
||
|
||
assert!(!app.handle_draw_size_change(
|
||
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
|
||
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
|
||
&frame_requester,
|
||
));
|
||
|
||
assert!(app.handle_draw_size_change(
|
||
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 24),
|
||
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
|
||
&frame_requester,
|
||
));
|
||
assert!(app.transcript_reflow.has_pending_reflow());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn resizing_empty_transcript_schedules_settled_size_recheck() {
|
||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||
let mut tui = crate::tui::test_support::make_test_tui().expect("test tui");
|
||
let frame_requester = crate::tui::FrameRequester::test_dummy();
|
||
let initial_size = ratatui::layout::Size::new(/*width*/ 80, /*height*/ 24);
|
||
let resized_size = ratatui::layout::Size::new(/*width*/ 100, /*height*/ 24);
|
||
|
||
assert!(!app.handle_draw_size_change(initial_size, initial_size, &frame_requester));
|
||
tui.screen_size_for_event(&TuiEvent::Resize(resized_size))
|
||
.expect("resolve resize event");
|
||
tui.terminal.resize(resized_size).expect("apply event size");
|
||
assert!(app.handle_draw_size_change(resized_size, initial_size, &frame_requester));
|
||
tokio::time::sleep(crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE).await;
|
||
assert_eq!(
|
||
tui.screen_size_for_event(&TuiEvent::Draw)
|
||
.expect("resolve settled size"),
|
||
initial_size
|
||
);
|
||
}
|
||
|
||
fn test_turn(turn_id: &str, status: TurnStatus, items: Vec<ThreadItemPayload>) -> Turn {
|
||
Turn {
|
||
id: turn_id.to_string(),
|
||
root_turn_id: None,
|
||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||
items: items.into_iter().map(ThreadItem::untimed).collect(),
|
||
status,
|
||
error: None,
|
||
started_at: None,
|
||
completed_at: None,
|
||
duration_ms: None,
|
||
}
|
||
}
|
||
|
||
fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification {
|
||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||
thread_id: thread_id.to_string(),
|
||
turn: Turn {
|
||
started_at: Some(0),
|
||
..test_turn(turn_id, TurnStatus::InProgress, Vec::new())
|
||
},
|
||
})
|
||
}
|
||
|
||
fn turn_completed_notification(
|
||
thread_id: ThreadId,
|
||
turn_id: &str,
|
||
status: TurnStatus,
|
||
) -> ServerNotification {
|
||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||
thread_id: thread_id.to_string(),
|
||
turn: Turn {
|
||
completed_at: Some(0),
|
||
duration_ms: Some(1),
|
||
..test_turn(turn_id, status, Vec::new())
|
||
},
|
||
})
|
||
}
|
||
|
||
fn thread_closed_notification(thread_id: ThreadId) -> ServerNotification {
|
||
ServerNotification::ThreadClosed(ThreadClosedNotification {
|
||
thread_id: thread_id.to_string(),
|
||
})
|
||
}
|
||
|
||
fn token_usage_notification(
|
||
thread_id: ThreadId,
|
||
turn_id: &str,
|
||
model_context_window: Option<i64>,
|
||
) -> ServerNotification {
|
||
ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification {
|
||
thread_id: thread_id.to_string(),
|
||
turn_id: turn_id.to_string(),
|
||
token_usage: ThreadTokenUsage {
|
||
total: TokenUsageBreakdown {
|
||
total_tokens: 10,
|
||
input_tokens: 4,
|
||
cached_input_tokens: 1,
|
||
cache_write_input_tokens: 0,
|
||
output_tokens: 5,
|
||
reasoning_output_tokens: 0,
|
||
},
|
||
last: TokenUsageBreakdown {
|
||
total_tokens: 10,
|
||
input_tokens: 4,
|
||
cached_input_tokens: 1,
|
||
cache_write_input_tokens: 0,
|
||
output_tokens: 5,
|
||
reasoning_output_tokens: 0,
|
||
},
|
||
model_context_window,
|
||
},
|
||
})
|
||
}
|
||
|
||
fn agent_message_delta_notification(
|
||
thread_id: ThreadId,
|
||
turn_id: &str,
|
||
item_id: &str,
|
||
delta: &str,
|
||
) -> ServerNotification {
|
||
ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification {
|
||
thread_id: thread_id.to_string(),
|
||
turn_id: turn_id.to_string(),
|
||
item_id: item_id.to_string(),
|
||
delta: delta.to_string(),
|
||
})
|
||
}
|
||
|
||
fn exec_approval_request(
|
||
thread_id: ThreadId,
|
||
turn_id: &str,
|
||
item_id: &str,
|
||
approval_id: Option<&str>,
|
||
) -> ServerRequest {
|
||
ServerRequest::CommandExecutionRequestApproval {
|
||
request_id: AppServerRequestId::Integer(1),
|
||
params: CommandExecutionRequestApprovalParams {
|
||
kind: Default::default(),
|
||
thread_id: thread_id.to_string(),
|
||
turn_id: turn_id.to_string(),
|
||
item_id: item_id.to_string(),
|
||
started_at_ms: 0,
|
||
approval_id: approval_id.map(str::to_string),
|
||
environment_id: None,
|
||
reason: Some("needs approval".to_string()),
|
||
network_approval_context: None,
|
||
command: Some("echo hello".to_string()),
|
||
cwd: Some(test_path_buf("/tmp/project").abs().into()),
|
||
command_actions: None,
|
||
additional_permissions: None,
|
||
proposed_execpolicy_amendment: None,
|
||
proposed_network_policy_amendments: None,
|
||
available_decisions: None,
|
||
},
|
||
}
|
||
}
|
||
|
||
fn request_user_input_request(thread_id: ThreadId, turn_id: &str, item_id: &str) -> ServerRequest {
|
||
ServerRequest::ToolRequestUserInput {
|
||
request_id: AppServerRequestId::Integer(2),
|
||
params: ToolRequestUserInputParams {
|
||
thread_id: thread_id.to_string(),
|
||
turn_id: turn_id.to_string(),
|
||
item_id: item_id.to_string(),
|
||
questions: Vec::new(),
|
||
is_blocking: true,
|
||
auto_resolution_ms: None,
|
||
},
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn feedback_submission_stages_logs_cleans_up_and_emits_error_history_cell() -> Result<()> {
|
||
use base64::Engine;
|
||
use base64::engine::general_purpose::STANDARD;
|
||
use std::io::Write;
|
||
use tracing_subscriber::fmt::writer::MakeWriter;
|
||
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let (server, requests, proxy) =
|
||
session_lifecycle_requests::start_recording_remote_app_server(&app.config).await?;
|
||
let diagnostic = "SQLITE LOG WRITE FAILURE: disk full\n";
|
||
let expected_logs = format!("{}{diagnostic}", "x".repeat(1024 * 1024 - diagnostic.len()));
|
||
let mut writer = app.feedback.make_writer().make_writer();
|
||
writer.write_all(&vec![b'x'; 1024 * 1024])?;
|
||
writer.write_all(diagnostic.as_bytes())?;
|
||
for (include_logs, staging_fails) in [(true, false), (false, false), (true, true)] {
|
||
let home = tempdir()?;
|
||
let tmp = home.path().join("tmp");
|
||
if staging_fails {
|
||
std::fs::write(&tmp, "not a directory")?;
|
||
}
|
||
let mut params = background_requests::build_feedback_upload_params(
|
||
/*origin_thread_id*/ None,
|
||
Some(PathBuf::from("existing-rollout.jsonl")),
|
||
FeedbackCategory::Bug,
|
||
/*reason*/ None,
|
||
/*turn_id*/ None,
|
||
include_logs,
|
||
);
|
||
// Reject before network upload, after the client has staged its diagnostics.
|
||
params.thread_id = Some("invalid-thread-id".into());
|
||
let error = feedback_upload::fetch_feedback_upload(
|
||
server.request_handle(),
|
||
Some(AppServerPath::from_app_server(
|
||
home.path().display().to_string(),
|
||
)),
|
||
params,
|
||
app.feedback.clone(),
|
||
)
|
||
.await
|
||
.unwrap_err();
|
||
assert!(format!("{error:#}").contains("invalid thread id"));
|
||
let recorded = std::mem::take(&mut *requests.lock().unwrap());
|
||
let upload = recorded
|
||
.iter()
|
||
.find(|r| r.method == "feedback/upload")
|
||
.unwrap();
|
||
let upload: FeedbackUploadParams = serde_json::from_value(upload.params.clone().unwrap())?;
|
||
if include_logs && !staging_fails {
|
||
let write = recorded
|
||
.iter()
|
||
.find(|r| r.method == "fs/writeFile")
|
||
.unwrap();
|
||
let write = write.params.as_ref().unwrap();
|
||
assert_eq!(
|
||
STANDARD.decode(write["dataBase64"].as_str().unwrap())?,
|
||
expected_logs.as_bytes()
|
||
);
|
||
assert_eq!(
|
||
upload.extra_log_files,
|
||
Some(vec![
|
||
PathBuf::from("existing-rollout.jsonl"),
|
||
PathBuf::from(write["path"].as_str().unwrap())
|
||
])
|
||
);
|
||
assert_eq!(std::fs::read_dir(&tmp)?.count(), 0);
|
||
} else if staging_fails {
|
||
assert_eq!(
|
||
upload.extra_log_files,
|
||
Some(vec![PathBuf::from("existing-rollout.jsonl")])
|
||
);
|
||
assert!(
|
||
upload
|
||
.reason
|
||
.unwrap()
|
||
.contains("TUI client logs were omitted")
|
||
);
|
||
} else {
|
||
assert_eq!(recorded.len(), 1);
|
||
assert_eq!(upload.extra_log_files, None);
|
||
assert!(!tmp.exists());
|
||
}
|
||
}
|
||
server.shutdown().await?;
|
||
proxy.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"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[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_history_message(events: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> String {
|
||
let cell = std::iter::from_fn(|| events.try_recv().ok())
|
||
.find_map(|event| match event {
|
||
AppEvent::InsertHistoryCell(cell) => Some(cell),
|
||
_ => None,
|
||
})
|
||
.expect("history message");
|
||
lines_to_single_string(&cell.display_lines(/*width*/ 80))
|
||
}
|
||
|
||
fn next_user_turn_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Op {
|
||
let mut seen = Vec::new();
|
||
while let Ok(op) = op_rx.try_recv() {
|
||
if matches!(op, Op::UserTurn { .. }) {
|
||
return op;
|
||
}
|
||
seen.push(format!("{op:?}"));
|
||
}
|
||
panic!("expected UserTurn op, saw: {seen:?}");
|
||
}
|
||
|
||
fn lines_to_single_string(lines: &[Line<'_>]) -> String {
|
||
lines
|
||
.iter()
|
||
.map(|line| {
|
||
line.spans
|
||
.iter()
|
||
.map(|span| span.content.as_ref())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry {
|
||
let model_info =
|
||
construct_model_info_offline_for_tests(model, &config.to_models_manager_config());
|
||
SessionTelemetry::new(
|
||
ThreadId::new(),
|
||
model,
|
||
model_info.slug.as_str(),
|
||
/*account_id*/ None,
|
||
/*account_email*/ None,
|
||
/*auth_mode*/ None,
|
||
"test_originator".to_string(),
|
||
/*log_user_prompts*/ false,
|
||
"test".to_string(),
|
||
crate::test_support::session_source_cli(),
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn active_turn_not_steerable_turn_error_extracts_structured_server_error() {
|
||
let turn_error = AppServerTurnError {
|
||
misalignment: None,
|
||
message: "cannot steer a review turn".to_string(),
|
||
codex_error_info: Some(AppServerCodexErrorInfo::ActiveTurnNotSteerable {
|
||
turn_kind: AppServerNonSteerableTurnKind::Review,
|
||
}),
|
||
additional_details: None,
|
||
};
|
||
let error = TypedRequestError::Server {
|
||
method: "turn/steer".to_string(),
|
||
source: JSONRPCErrorError {
|
||
code: -32602,
|
||
message: turn_error.message.clone(),
|
||
data: Some(serde_json::to_value(&turn_error).expect("turn error should serialize")),
|
||
},
|
||
};
|
||
|
||
assert_eq!(
|
||
active_turn_not_steerable_turn_error(&error),
|
||
Some(turn_error)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn active_turn_steer_race_detects_missing_active_turn() {
|
||
let error = TypedRequestError::Server {
|
||
method: "turn/steer".to_string(),
|
||
source: JSONRPCErrorError {
|
||
code: -32602,
|
||
message: "no active turn to steer".to_string(),
|
||
data: None,
|
||
},
|
||
};
|
||
|
||
assert_eq!(
|
||
active_turn_steer_race(&error),
|
||
Some(ActiveTurnSteerRace::Missing)
|
||
);
|
||
assert_eq!(active_turn_not_steerable_turn_error(&error), None);
|
||
}
|
||
|
||
#[test]
|
||
fn active_turn_steer_race_extracts_actual_turn_id_from_mismatch() {
|
||
let error = TypedRequestError::Server {
|
||
method: "turn/steer".to_string(),
|
||
source: JSONRPCErrorError {
|
||
code: -32602,
|
||
message: "expected active turn id `turn-expected` but found `turn-actual`".to_string(),
|
||
data: None,
|
||
},
|
||
};
|
||
|
||
assert_eq!(
|
||
active_turn_steer_race(&error),
|
||
Some(ActiveTurnSteerRace::ExpectedTurnMismatch {
|
||
actual_turn_id: "turn-actual".to_string(),
|
||
})
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn active_turn_interrupt_race_extracts_actual_turn_id_from_mismatch() {
|
||
let error = TypedRequestError::Server {
|
||
method: "turn/interrupt".to_string(),
|
||
source: JSONRPCErrorError {
|
||
code: -32602,
|
||
message: "expected active turn id turn-expected but found turn-actual".to_string(),
|
||
data: None,
|
||
},
|
||
};
|
||
|
||
assert_eq!(
|
||
active_turn_interrupt_race(&error),
|
||
Some("turn-actual".to_string())
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
|
||
let user_cell = |text: &str,
|
||
text_elements: Vec<TextElement>,
|
||
local_image_paths: Vec<PathBuf>,
|
||
remote_image_urls: Vec<String>|
|
||
-> Arc<dyn HistoryCell> {
|
||
Arc::new(UserHistoryCell {
|
||
presentation: Default::default(),
|
||
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 {
|
||
daybreak_enabled: false,
|
||
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
||
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,
|
||
message_history: None,
|
||
network_proxy: None,
|
||
rollout_path: Some(PathBuf::new()),
|
||
};
|
||
Arc::new(new_session_info(
|
||
app.chat_widget.config_ref(),
|
||
&app.local_settings,
|
||
app.chat_widget.current_model(),
|
||
&session.model,
|
||
&session,
|
||
is_first,
|
||
/*tooltip_override*/ None,
|
||
/*auth_plan*/ None,
|
||
)) as Arc<dyn HistoryCell>
|
||
};
|
||
|
||
let placeholder = "[Image #1]";
|
||
let edited_text = format!("follow-up (edited) {placeholder}");
|
||
let edited_range = edited_text.len().saturating_sub(placeholder.len())..edited_text.len();
|
||
let edited_text_elements = vec![TextElement::new(
|
||
edited_range.into(),
|
||
/*placeholder*/ None,
|
||
)];
|
||
let edited_local_image_paths = vec![PathBuf::from("/tmp/fake-image.png")];
|
||
|
||
// Simulate a transcript with duplicated history (e.g., from prior backtracks)
|
||
// and an edited turn appended after a session header boundary.
|
||
app.transcript_cells = vec![
|
||
make_header(true),
|
||
user_cell("first question", Vec::new(), Vec::new(), Vec::new()),
|
||
agent_cell("answer first"),
|
||
user_cell("follow-up", Vec::new(), Vec::new(), Vec::new()),
|
||
agent_cell("answer follow-up"),
|
||
make_header(false),
|
||
user_cell("first question", Vec::new(), Vec::new(), Vec::new()),
|
||
agent_cell("answer first"),
|
||
user_cell(
|
||
&edited_text,
|
||
edited_text_elements.clone(),
|
||
edited_local_image_paths.clone(),
|
||
vec!["https://example.com/backtrack.png".to_string()],
|
||
),
|
||
agent_cell("answer edited"),
|
||
];
|
||
|
||
assert_eq!(user_count(&app.transcript_cells), 2);
|
||
let transcript_before: Vec<String> = app
|
||
.transcript_cells
|
||
.iter()
|
||
.map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 80)))
|
||
.collect();
|
||
|
||
let base_id = ThreadId::new();
|
||
app.chat_widget
|
||
.handle_thread_session(crate::session_state::ThreadSessionState {
|
||
daybreak_enabled: false,
|
||
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
||
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,
|
||
message_history: None,
|
||
network_proxy: None,
|
||
rollout_path: Some(PathBuf::new()),
|
||
});
|
||
|
||
app.backtrack.base_id = Some(base_id);
|
||
app.backtrack.primed = true;
|
||
app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1);
|
||
|
||
let selection = app
|
||
.confirm_backtrack_from_main()
|
||
.expect("backtrack selection");
|
||
let expected = BacktrackSelection {
|
||
thread_id: base_id,
|
||
nth_user_message: 1,
|
||
prompt: crate::chatwidget::UserMessage {
|
||
text: edited_text,
|
||
local_images: vec![crate::bottom_pane::LocalImageAttachment {
|
||
placeholder: placeholder.to_string(),
|
||
path: edited_local_image_paths[0].clone(),
|
||
}],
|
||
remote_image_urls: vec!["https://example.com/backtrack.png".to_string()],
|
||
text_elements: edited_text_elements,
|
||
mention_bindings: Vec::new(),
|
||
},
|
||
};
|
||
assert_eq!(selection, expected);
|
||
|
||
app.apply_backtrack_selection(selection);
|
||
let event = std::iter::from_fn(|| app_event_rx.try_recv().ok())
|
||
.find(|event| matches!(event, AppEvent::RevertSessionForPromptEdit { .. }))
|
||
.expect("prompt edit fork should be requested");
|
||
assert_matches!(
|
||
event,
|
||
AppEvent::RevertSessionForPromptEdit {
|
||
thread_id,
|
||
selected_cell,
|
||
prompt,
|
||
} if thread_id == expected.thread_id
|
||
&& Arc::ptr_eq(&selected_cell, &app.transcript_cells[nth_user_position(&app.transcript_cells, expected.nth_user_message).unwrap()])
|
||
&& prompt == expected.prompt
|
||
);
|
||
|
||
let transcript_after: Vec<String> = app
|
||
.transcript_cells
|
||
.iter()
|
||
.map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 80)))
|
||
.collect();
|
||
assert_eq!(transcript_after, transcript_before);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn backtrack_branch_failure_restores_selected_prompt_snapshot() {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
|
||
app.restore_backtrack_prompt_after_revert_error(
|
||
crate::chatwidget::UserMessage::from("edit this prompt"),
|
||
"branch unavailable",
|
||
);
|
||
|
||
assert_eq!(
|
||
app.chat_widget.composer_text_with_pending(),
|
||
"edit this prompt"
|
||
);
|
||
let cell = match app_event_rx.try_recv() {
|
||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
||
};
|
||
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
|
||
assert_app_snapshot!(
|
||
"backtrack_branch_failure_restores_selected_prompt",
|
||
rendered
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn remote_resume_current_cwd_rejection_snapshot() -> Result<()> {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
std::fs::write(
|
||
app.config.codex_home.join("config.toml"),
|
||
"[tui]\nresume_cwd = \"current\"\n",
|
||
)?;
|
||
app.app_server_target = crate::AppServerTarget::Remote {
|
||
endpoint: crate::RemoteAppServerEndpoint::WebSocket {
|
||
websocket_url: "ws://127.0.0.1:4500".to_string(),
|
||
auth_token: None,
|
||
},
|
||
};
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||
let backend_id = app_server.backend_id();
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
|
||
let control = app
|
||
.resume_target_session(
|
||
&mut tui,
|
||
&mut app_server,
|
||
crate::resume_picker::SessionTarget {
|
||
backend_id,
|
||
path: None,
|
||
thread_id: ThreadId::new(),
|
||
cwd: None,
|
||
history_mode: None,
|
||
},
|
||
)
|
||
.await?;
|
||
|
||
assert!(matches!(control, AppRunControl::Continue));
|
||
let cell = match app_event_rx.try_recv() {
|
||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
||
};
|
||
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
|
||
assert_app_snapshot!("remote_resume_current_cwd_rejected", rendered);
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn remote_exec_resume_current_cwd_is_rejected() -> Result<()> {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
std::fs::write(
|
||
app.config.codex_home.join("config.toml"),
|
||
"[tui]\nresume_cwd = \"current\"\n",
|
||
)?;
|
||
app.environment_manager = Arc::new(
|
||
EnvironmentManager::create_for_tests(
|
||
Some("ws://127.0.0.1:8765".to_string()),
|
||
Some(codex_exec_server::ExecServerRuntimeOptions::new(
|
||
std::env::current_exe()?,
|
||
/*codex_linux_sandbox_exe*/ None,
|
||
)?),
|
||
)
|
||
.await,
|
||
);
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||
let backend_id = app_server.backend_id();
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
|
||
let control = app
|
||
.resume_target_session(
|
||
&mut tui,
|
||
&mut app_server,
|
||
crate::resume_picker::SessionTarget {
|
||
backend_id,
|
||
path: None,
|
||
thread_id: ThreadId::new(),
|
||
cwd: None,
|
||
history_mode: None,
|
||
},
|
||
)
|
||
.await?;
|
||
|
||
assert!(matches!(control, AppRunControl::Continue));
|
||
let cell = match app_event_rx.try_recv() {
|
||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
||
};
|
||
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
|
||
assert_eq!(
|
||
rendered,
|
||
"■ `tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace"
|
||
);
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn in_app_resume_session_cwd_without_metadata_is_non_fatal() -> Result<()> {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
std::fs::write(
|
||
app.config.codex_home.join("config.toml"),
|
||
"[tui]\nresume_cwd = \"session\"\n",
|
||
)?;
|
||
app.state_db = None;
|
||
let active_thread_id = app.chat_widget.thread_id();
|
||
app.chat_widget.insert_str("keep this draft");
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||
let backend_id = app_server.backend_id();
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
|
||
let control = app
|
||
.resume_target_session(
|
||
&mut tui,
|
||
&mut app_server,
|
||
crate::resume_picker::SessionTarget {
|
||
backend_id,
|
||
path: None,
|
||
thread_id: ThreadId::new(),
|
||
cwd: None,
|
||
history_mode: None,
|
||
},
|
||
)
|
||
.await?;
|
||
|
||
assert!(matches!(control, AppRunControl::Continue));
|
||
assert_eq!(app.chat_widget.thread_id(), active_thread_id);
|
||
assert_eq!(
|
||
app.chat_widget.composer_text_with_pending(),
|
||
"keep this draft"
|
||
);
|
||
let cell = match app_event_rx.try_recv() {
|
||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
||
};
|
||
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 100));
|
||
assert_app_snapshot!("in_app_resume_session_cwd_without_metadata", rendered);
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn remote_resume_keeps_server_only_cwd_out_of_local_config() -> Result<()> {
|
||
// Keep the large setup and resume futures off the Windows test stack.
|
||
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||
let local_cwd = app.config.cwd.to_path_buf();
|
||
let local_workspace_roots = Box::pin(app.rebuild_config_for_cwd(local_cwd.clone()))
|
||
.await?
|
||
.workspace_roots;
|
||
let remote_cwd = if cfg!(windows) {
|
||
PathBuf::from("/srv/remote/project")
|
||
} else {
|
||
PathBuf::from(r"C:\remote\project")
|
||
};
|
||
let filename_timestamp = "2025-01-05T12-00-00";
|
||
let thread_id = app_test_support::create_fake_rollout(
|
||
app.config.codex_home.as_path(),
|
||
filename_timestamp,
|
||
"2025-01-05T12:00:00Z",
|
||
"Saved user message",
|
||
Some(&app.config.model_provider_id),
|
||
/*git_info*/ None,
|
||
)
|
||
.expect("materialized rollout should be created");
|
||
let rollout_path = app_test_support::rollout_path(
|
||
app.config.codex_home.as_path(),
|
||
filename_timestamp,
|
||
&thread_id,
|
||
);
|
||
app.app_server_target = crate::AppServerTarget::Remote {
|
||
endpoint: crate::RemoteAppServerEndpoint::WebSocket {
|
||
websocket_url: "ws://127.0.0.1:4500".to_string(),
|
||
auth_token: None,
|
||
},
|
||
};
|
||
app.harness_overrides.cwd = Some(remote_cwd.clone());
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||
let backend_id = app_server.backend_id();
|
||
Box::pin(app_server.resume_thread(
|
||
&crate::local_settings::LocalSettings::from(&app.config),
|
||
app.config.clone(),
|
||
ThreadId::from_string(&thread_id)?,
|
||
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
|
||
))
|
||
.await?;
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Restored(
|
||
AskForApproval::Never,
|
||
));
|
||
let mut prior_config = app.config.clone();
|
||
prior_config
|
||
.permissions
|
||
.set_permission_profile(PermissionProfile::read_only())?;
|
||
app.runtime_permission_profile_override = Some(
|
||
RuntimePermissionProfileOverride::from_restored_config(&prior_config),
|
||
);
|
||
|
||
let control = Box::pin(app.resume_target_session(
|
||
&mut tui,
|
||
&mut app_server,
|
||
crate::resume_picker::SessionTarget {
|
||
backend_id,
|
||
path: Some(rollout_path),
|
||
thread_id: ThreadId::from_string(&thread_id)?,
|
||
cwd: None,
|
||
history_mode: None,
|
||
},
|
||
))
|
||
.await?;
|
||
|
||
assert!(matches!(control, AppRunControl::Continue));
|
||
assert_eq!(app.harness_overrides.cwd, Some(remote_cwd));
|
||
assert!(!crate::session_resume::cwds_differ(
|
||
app.config.cwd.as_path(),
|
||
&local_cwd,
|
||
));
|
||
assert_eq!(app.config.workspace_roots, local_workspace_roots);
|
||
assert_eq!(app.runtime_approval_policy_override, None);
|
||
assert!(app.runtime_permission_profile_override.is_none());
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||
async fn in_app_resume_uses_configured_or_explicit_cwd() -> Result<()> {
|
||
let temp_dir = tempdir()?;
|
||
let codex_home = temp_dir.path().join("codex-home");
|
||
let launch_cwd = temp_dir.path().join("launch");
|
||
let active_cwd = temp_dir.path().join("active");
|
||
let session_cwd = temp_dir.path().join("session");
|
||
let explicit_cwd = temp_dir.path().join("explicit");
|
||
let runtime_cwd = temp_dir.path().join("runtime");
|
||
for directory in [
|
||
&codex_home,
|
||
&launch_cwd,
|
||
&active_cwd,
|
||
&session_cwd,
|
||
&explicit_cwd,
|
||
&runtime_cwd,
|
||
] {
|
||
std::fs::create_dir_all(directory)?;
|
||
}
|
||
let mut local_app_server = None;
|
||
let mut remote_app_server = None;
|
||
|
||
for (configured_mode, has_explicit_cwd, has_remote_exec, has_runtime_cwd, expected_directory) in [
|
||
("current", false, false, false, "launch"),
|
||
("session", false, false, false, "session"),
|
||
("session", true, false, false, "explicit"),
|
||
("session", false, true, false, "session"),
|
||
("session", true, true, false, "explicit"),
|
||
("current", false, false, true, "runtime"),
|
||
("current", true, false, true, "runtime"),
|
||
("session", false, false, true, "runtime"),
|
||
("session", true, false, true, "runtime"),
|
||
] {
|
||
std::fs::write(
|
||
codex_home.join("config.toml"),
|
||
format!("[tui]\nresume_cwd = \"{configured_mode}\"\n"),
|
||
)?;
|
||
for cwd in [
|
||
&launch_cwd,
|
||
&active_cwd,
|
||
&session_cwd,
|
||
&explicit_cwd,
|
||
&runtime_cwd,
|
||
] {
|
||
crate::legacy_core::config::set_project_trust_level(
|
||
&codex_home,
|
||
cwd,
|
||
codex_protocol::config_types::TrustLevel::Trusted,
|
||
)
|
||
.map_err(std::io::Error::other)?;
|
||
}
|
||
let config = ConfigBuilder::default()
|
||
.codex_home(codex_home.clone())
|
||
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||
.harness_overrides(ConfigOverrides {
|
||
cwd: Some(active_cwd.clone()),
|
||
..Default::default()
|
||
})
|
||
.build()
|
||
.await?;
|
||
let filename_timestamp = "2025-01-05T12-00-00";
|
||
let thread_id = app_test_support::create_fake_rollout(
|
||
&codex_home,
|
||
filename_timestamp,
|
||
"2025-01-05T12:00:00Z",
|
||
"Saved user message",
|
||
Some(&config.model_provider_id),
|
||
/*git_info*/ None,
|
||
)
|
||
.expect("materialized rollout should be created");
|
||
let rollout_path =
|
||
app_test_support::rollout_path(&codex_home, filename_timestamp, &thread_id);
|
||
let mut rollout_lines = std::fs::read_to_string(&rollout_path)?
|
||
.lines()
|
||
.map(serde_json::from_str::<serde_json::Value>)
|
||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||
rollout_lines[0]["payload"]["cwd"] = serde_json::to_value(&session_cwd)?;
|
||
std::fs::write(
|
||
&rollout_path,
|
||
format!(
|
||
"{}\n",
|
||
rollout_lines
|
||
.into_iter()
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
),
|
||
)?;
|
||
let thread_id = ThreadId::from_string(&thread_id)?;
|
||
let environment_manager = if has_remote_exec {
|
||
Arc::new(
|
||
EnvironmentManager::create_for_tests(
|
||
Some("ws://127.0.0.1:8765".to_string()),
|
||
Some(codex_exec_server::ExecServerRuntimeOptions::new(
|
||
std::env::current_exe()?,
|
||
/*codex_linux_sandbox_exe*/ None,
|
||
)?),
|
||
)
|
||
.await,
|
||
)
|
||
} else {
|
||
Arc::new(EnvironmentManager::default_for_tests())
|
||
};
|
||
let server = if has_remote_exec {
|
||
&mut remote_app_server
|
||
} else {
|
||
&mut local_app_server
|
||
};
|
||
let app_server = match server {
|
||
Some(server) => server,
|
||
slot @ None => slot.insert(
|
||
crate::start_app_server_for_picker(
|
||
crate::backend_id::BackendId::new(),
|
||
&config,
|
||
&crate::AppServerTarget::Embedded,
|
||
Vec::new(),
|
||
LoaderOverrides::without_managed_config_for_tests(),
|
||
/*state_db*/ None,
|
||
Arc::clone(&environment_manager),
|
||
)
|
||
.await?,
|
||
),
|
||
};
|
||
let backend_id = app_server.backend_id();
|
||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
app.config = config;
|
||
app.launch_cwd = launch_cwd.clone();
|
||
app.environment_manager = environment_manager;
|
||
app.harness_overrides.cwd = has_explicit_cwd.then_some(explicit_cwd.clone());
|
||
app.runtime_working_directory_override = has_runtime_cwd.then_some(runtime_cwd.clone());
|
||
app.chat_widget
|
||
.handle_thread_session_quiet(test_thread_session(ThreadId::new(), active_cwd.clone()));
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
|
||
let settings = app.backend_settings();
|
||
// Prepare a destination while another backend owns the displayed configuration.
|
||
let other_home = tempdir()?;
|
||
app.config.codex_home = other_home.path().to_path_buf().abs();
|
||
let active_settings = (app.config.clone(), app.local_settings.clone());
|
||
let active_thread_id = app.chat_widget.thread_id();
|
||
app.chat_widget
|
||
.insert_str("keep this draft until attachment");
|
||
let prepared = match app
|
||
.prepare_target_session(
|
||
&mut tui,
|
||
app_server,
|
||
&crate::resume_picker::SessionTarget {
|
||
backend_id,
|
||
path: Some(rollout_path),
|
||
thread_id,
|
||
cwd: None,
|
||
history_mode: None,
|
||
},
|
||
super::resume_preparation::ResumeSource::Backend(&settings),
|
||
)
|
||
.await
|
||
{
|
||
Ok(prepared) => prepared,
|
||
Err(_) => panic!("saved thread should be ready for attachment"),
|
||
};
|
||
assert_eq!(
|
||
(&app.config, &app.local_settings),
|
||
(&active_settings.0, &active_settings.1)
|
||
);
|
||
assert_eq!(app.chat_widget.thread_id(), active_thread_id);
|
||
assert_eq!(
|
||
app.chat_widget.composer_text_with_pending(),
|
||
"keep this draft until attachment"
|
||
);
|
||
assert_eq!(app.chat_widget.config_ref().cwd.as_path(), active_cwd);
|
||
let control = app
|
||
.activate_prepared_session(&mut tui, app_server, prepared)
|
||
.await?;
|
||
|
||
assert!(matches!(control, AppRunControl::Continue));
|
||
let expected_cwd = temp_dir.path().join(expected_directory);
|
||
assert!(!crate::session_resume::cwds_differ(
|
||
app.config.cwd.as_path(),
|
||
&expected_cwd,
|
||
));
|
||
assert!(!crate::session_resume::cwds_differ(
|
||
app.chat_widget.config_ref().cwd.as_path(),
|
||
&expected_cwd,
|
||
));
|
||
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
|
||
}
|
||
|
||
if let Some(app_server) = local_app_server {
|
||
app_server.shutdown().await?;
|
||
}
|
||
if let Some(app_server) = remote_app_server {
|
||
app_server.shutdown().await?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn remembered_current_cwd_stays_at_launch_across_in_app_resumes() -> Result<()> {
|
||
let temp_dir = tempdir()?;
|
||
let codex_home = temp_dir.path().join("codex-home");
|
||
let launch_cwd = temp_dir.path().join("launch");
|
||
let active_cwd = temp_dir.path().join("active");
|
||
let first_session_cwd = temp_dir.path().join("first-session");
|
||
let second_session_cwd = temp_dir.path().join("second-session");
|
||
std::fs::create_dir_all(&codex_home)?;
|
||
std::fs::create_dir_all(&launch_cwd)?;
|
||
std::fs::create_dir_all(&active_cwd)?;
|
||
std::fs::create_dir_all(&first_session_cwd)?;
|
||
std::fs::create_dir_all(&second_session_cwd)?;
|
||
crate::legacy_core::config::set_project_trust_level(
|
||
&codex_home,
|
||
&launch_cwd,
|
||
codex_protocol::config_types::TrustLevel::Trusted,
|
||
)
|
||
.map_err(std::io::Error::other)?;
|
||
let config = ConfigBuilder::default()
|
||
.codex_home(codex_home.clone())
|
||
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||
.harness_overrides(ConfigOverrides {
|
||
cwd: Some(active_cwd.clone()),
|
||
..Default::default()
|
||
})
|
||
.build()
|
||
.await?;
|
||
let immediately_selected_cwd = crate::cwd_prompt::CwdSelection::CurrentAndRemember
|
||
.selected_cwd(&active_cwd, &first_session_cwd, &launch_cwd);
|
||
assert!(!crate::session_resume::cwds_differ(
|
||
immediately_selected_cwd,
|
||
&launch_cwd,
|
||
));
|
||
crate::legacy_core::config::edit::ConfigEditsBuilder::for_config(&config)
|
||
.set_resume_cwd(codex_config::types::ResumeCwdMode::Current)
|
||
.apply()
|
||
.await
|
||
.map_err(std::io::Error::other)?;
|
||
|
||
let backend_id = crate::backend_id::BackendId::new();
|
||
let mut targets = Vec::new();
|
||
for (filename_timestamp, metadata_timestamp, session_cwd) in [
|
||
(
|
||
"2025-01-05T12-00-00",
|
||
"2025-01-05T12:00:00Z",
|
||
first_session_cwd,
|
||
),
|
||
(
|
||
"2025-01-05T12-01-00",
|
||
"2025-01-05T12:01:00Z",
|
||
second_session_cwd,
|
||
),
|
||
] {
|
||
let thread_id = app_test_support::create_fake_rollout(
|
||
&codex_home,
|
||
filename_timestamp,
|
||
metadata_timestamp,
|
||
"Saved user message",
|
||
Some(&config.model_provider_id),
|
||
/*git_info*/ None,
|
||
)
|
||
.expect("materialized rollout should be created");
|
||
let rollout_path =
|
||
app_test_support::rollout_path(&codex_home, filename_timestamp, &thread_id);
|
||
let mut rollout_lines = std::fs::read_to_string(&rollout_path)?
|
||
.lines()
|
||
.map(serde_json::from_str::<serde_json::Value>)
|
||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||
rollout_lines[0]["payload"]["cwd"] = serde_json::to_value(&session_cwd)?;
|
||
std::fs::write(
|
||
&rollout_path,
|
||
format!(
|
||
"{}\n",
|
||
rollout_lines
|
||
.into_iter()
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
),
|
||
)?;
|
||
targets.push(crate::resume_picker::SessionTarget {
|
||
backend_id,
|
||
path: Some(rollout_path),
|
||
thread_id: ThreadId::from_string(&thread_id)?,
|
||
cwd: None,
|
||
history_mode: None,
|
||
});
|
||
}
|
||
let state_db =
|
||
crate::init_state_db_for_app_server_target(&config, &crate::AppServerTarget::Embedded)
|
||
.await?;
|
||
let mut app_server = Box::pin(crate::start_app_server_for_picker(
|
||
backend_id,
|
||
&config,
|
||
&crate::AppServerTarget::Embedded,
|
||
Vec::new(),
|
||
LoaderOverrides::without_managed_config_for_tests(),
|
||
state_db.clone(),
|
||
Arc::new(EnvironmentManager::default_for_tests()),
|
||
))
|
||
.await?;
|
||
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||
app.config = config;
|
||
app.launch_cwd = launch_cwd.clone();
|
||
app.state_db = state_db;
|
||
app.chat_widget
|
||
.handle_thread_session_quiet(test_thread_session(ThreadId::new(), active_cwd));
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
|
||
for target_session in targets {
|
||
let control =
|
||
Box::pin(app.resume_target_session(&mut tui, &mut app_server, target_session)).await?;
|
||
|
||
assert!(matches!(control, AppRunControl::Continue));
|
||
assert!(!crate::session_resume::cwds_differ(
|
||
app.config.cwd.as_path(),
|
||
&launch_cwd,
|
||
));
|
||
assert!(!crate::session_resume::cwds_differ(
|
||
app.chat_widget.config_ref().cwd.as_path(),
|
||
&launch_cwd,
|
||
));
|
||
}
|
||
app_server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Result<()> {
|
||
// Allocate the whole scenario before Tokio polls it on the test thread.
|
||
boxed_future!(prompt_edit_reverts_earlier_and_first_visible_prompts_in_place_scenario()).await
|
||
}
|
||
|
||
async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place_scenario() -> Result<()> {
|
||
use codex_protocol::items::TurnItem;
|
||
use codex_protocol::items::UserMessageItem;
|
||
use codex_protocol::models::ImageReference;
|
||
use codex_protocol::protocol::ItemCompletedEvent;
|
||
use codex_protocol::user_input::UserInput;
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let config = app.chat_widget.config_ref().clone();
|
||
let filename_ts = "2025-01-05T12-00-00";
|
||
let source_thread_id = app_test_support::create_fake_paginated_rollout(
|
||
config.codex_home.as_path(),
|
||
filename_ts,
|
||
"2025-01-05T12:00:00Z",
|
||
"unused preview",
|
||
Some(config.model_provider_id.as_str()),
|
||
/*git_info*/ None,
|
||
)
|
||
.expect("materialized rollout should be created");
|
||
let source_path =
|
||
app_test_support::rollout_path(config.codex_home.as_path(), filename_ts, &source_thread_id);
|
||
let session_meta = std::fs::read_to_string(&source_path)?
|
||
.lines()
|
||
.next()
|
||
.expect("fake rollout should have session metadata")
|
||
.to_string();
|
||
std::fs::write(&source_path, format!("{session_meta}\n"))?;
|
||
for (turn_id, message, images, local_images) in [
|
||
("turn-0", "older prompt", None, Vec::new()),
|
||
("turn-1", "retained prompt", None, Vec::new()),
|
||
(
|
||
"turn-2",
|
||
"selected prompt [Image #1]",
|
||
Some(vec!["https://example.com/backtrack.png".to_string()]),
|
||
vec![PathBuf::from("/tmp/fake-image.png")],
|
||
),
|
||
(
|
||
"turn-3",
|
||
"selected prompt [Image #1]",
|
||
Some(vec!["https://example.com/backtrack.png".to_string()]),
|
||
vec![PathBuf::from("/tmp/fake-image.png")],
|
||
),
|
||
] {
|
||
let mut content = vec![UserInput::Text {
|
||
text: message.to_string(),
|
||
text_elements: Vec::new(),
|
||
}];
|
||
content.extend(
|
||
images
|
||
.into_iter()
|
||
.flatten()
|
||
.map(|image_url| UserInput::Image {
|
||
image: ImageReference::Inline { image_url },
|
||
detail: None,
|
||
}),
|
||
);
|
||
content.extend(
|
||
local_images
|
||
.into_iter()
|
||
.map(|path| UserInput::LocalImage { path, detail: None }),
|
||
);
|
||
for item in [
|
||
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
||
turn_attribution: None,
|
||
turn_id: turn_id.to_string(),
|
||
root_turn_id: None,
|
||
trace_id: None,
|
||
started_at: None,
|
||
model_context_window: None,
|
||
collaboration_mode_kind: ModeKind::default(),
|
||
})),
|
||
RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
|
||
content_token_count: None,
|
||
thread_id: ThreadId::from_string(&source_thread_id)?,
|
||
turn_id: turn_id.to_string(),
|
||
item: TurnItem::UserMessage(UserMessageItem {
|
||
id: format!("user-{turn_id}"),
|
||
client_id: None,
|
||
content,
|
||
}),
|
||
started_at_ms: None,
|
||
completed_at_ms: 0,
|
||
})),
|
||
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
||
root_turn_id: None,
|
||
turn_id: turn_id.to_string(),
|
||
last_agent_message: None,
|
||
error: None,
|
||
started_at: None,
|
||
completed_at: None,
|
||
duration_ms: None,
|
||
time_to_first_token_ms: None,
|
||
})),
|
||
] {
|
||
codex_rollout::append_rollout_item_to_path(&source_path, &item).await?;
|
||
}
|
||
}
|
||
|
||
let source_thread_id = ThreadId::from_string(&source_thread_id)?;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
|
||
// Bound fixture hydration independently from the owned viewport exercised below.
|
||
// Inline hydration's row cap leaves the oldest prompt on an unloaded page.
|
||
let mut local_settings = crate::local_settings::LocalSettings::from(&config);
|
||
local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
local_settings.tui.alternate_screen = codex_config::types::AltScreenMode::Never;
|
||
local_settings.tui.terminal_resize_reflow_max_rows = Some(2);
|
||
let started = app_server
|
||
.resume_thread(
|
||
&local_settings,
|
||
config.clone(),
|
||
source_thread_id,
|
||
crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig,
|
||
)
|
||
.await?;
|
||
assert!(app_server.has_older_history(source_thread_id));
|
||
let selected_turn = started.turns[3].clone();
|
||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||
.await?;
|
||
app.scrollback_has_older_history = app_server.has_older_history(source_thread_id);
|
||
{
|
||
let mut store = app
|
||
.thread_event_channels
|
||
.get(&source_thread_id)
|
||
.expect("source thread event channel")
|
||
.store
|
||
.lock()
|
||
.await;
|
||
// The selected prompt can outlive its bounded replay-buffer entries.
|
||
store.turns.truncate(/*len*/ 2);
|
||
store.push_notification(turn_started_notification(
|
||
source_thread_id,
|
||
&selected_turn.id,
|
||
));
|
||
for item in selected_turn.items {
|
||
store.push_notification(ServerNotification::ItemCompleted(
|
||
codex_app_server_protocol::ItemCompletedNotification::new(
|
||
source_thread_id.to_string(),
|
||
selected_turn.id.clone(),
|
||
item.payload,
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
),
|
||
));
|
||
}
|
||
store.push_notification(turn_completed_notification(
|
||
source_thread_id,
|
||
&selected_turn.id,
|
||
TurnStatus::Interrupted,
|
||
));
|
||
}
|
||
while let Ok(event) = app_event_rx.try_recv() {
|
||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||
app.transcript_cells.push(Arc::from(cell));
|
||
}
|
||
}
|
||
assert_eq!(crate::app_backtrack::user_count(&app.transcript_cells), 2);
|
||
let child_id = ThreadId::new();
|
||
let child = Arc::clone(&app.ensure_thread_channel(child_id).store);
|
||
let goal = app_server
|
||
.thread_goal_set(
|
||
source_thread_id,
|
||
Some("Keep this goal".into()),
|
||
Some(codex_app_server_protocol::ThreadGoalStatus::Paused),
|
||
/*token_budget*/ None,
|
||
)
|
||
.await?
|
||
.goal;
|
||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||
tui.set_owned_screen(/*owned*/ true)?;
|
||
let size = Size::new(/*width*/ 80, /*height*/ 24);
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
app.transcript_view
|
||
.scroll(&app.transcript_cells, /*rows*/ -1);
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
// Freeze a visible selection before the same thread's authoritative history changes.
|
||
let selected_row = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal)
|
||
.content()
|
||
.chunks(usize::from(size.width))
|
||
.position(|row| {
|
||
row.iter()
|
||
.map(ratatui::buffer::Cell::symbol)
|
||
.collect::<String>()
|
||
.contains("selected prompt")
|
||
})
|
||
.expect("selected prompt should be visible") as u16;
|
||
for (kind, column) in [
|
||
(
|
||
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||
2,
|
||
),
|
||
(
|
||
crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
|
||
8,
|
||
),
|
||
] {
|
||
app.transcript_view.handle_mouse(
|
||
crossterm::event::MouseEvent {
|
||
kind,
|
||
column,
|
||
row: selected_row,
|
||
modifiers: KeyModifiers::NONE,
|
||
},
|
||
&app.transcript_cells,
|
||
);
|
||
}
|
||
assert!(
|
||
app.transcript_view
|
||
.selected_text(&app.transcript_cells)
|
||
.is_some()
|
||
);
|
||
let prompt = crate::chatwidget::UserMessage {
|
||
text: "selected prompt [Image #1]".to_string(),
|
||
local_images: vec![crate::bottom_pane::LocalImageAttachment {
|
||
placeholder: "[Image #1]".to_string(),
|
||
path: PathBuf::from("/tmp/fake-image.png"),
|
||
}],
|
||
remote_image_urls: vec!["https://example.com/backtrack.png".to_string()],
|
||
text_elements: Vec::new(),
|
||
mention_bindings: Vec::new(),
|
||
};
|
||
|
||
Box::pin(app.handle_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
AppEvent::UpdateModel("gpt-5.4".into()),
|
||
))
|
||
.await?;
|
||
app.app_server_target = crate::AppServerTarget::Remote {
|
||
endpoint: crate::RemoteAppServerEndpoint::WebSocket {
|
||
websocket_url: "ws://127.0.0.1:4500".to_string(),
|
||
auth_token: None,
|
||
},
|
||
};
|
||
let before = app_server
|
||
.thread_read(source_thread_id, /*include_turns*/ true)
|
||
.await?;
|
||
for text in [None, Some("/help"), Some(" \t!")] {
|
||
let mut remote_prompt = prompt.clone();
|
||
if let Some(text) = text {
|
||
remote_prompt.text = text.into();
|
||
remote_prompt.local_images.clear();
|
||
}
|
||
Box::pin(app.handle_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
AppEvent::RevertSessionForPromptEdit {
|
||
thread_id: source_thread_id,
|
||
selected_cell: Arc::clone(
|
||
&app.transcript_cells
|
||
[nth_user_position(&app.transcript_cells, /*nth*/ 1).unwrap()],
|
||
),
|
||
prompt: remote_prompt,
|
||
},
|
||
))
|
||
.await?;
|
||
assert!(app.chat_widget.composer_is_empty());
|
||
assert_eq!(
|
||
app_server
|
||
.thread_read(source_thread_id, /*include_turns*/ true)
|
||
.await?
|
||
.turns,
|
||
before.turns
|
||
);
|
||
}
|
||
app.app_server_target = crate::AppServerTarget::Embedded;
|
||
let selection = AppEvent::RevertSessionForPromptEdit {
|
||
thread_id: source_thread_id,
|
||
selected_cell: Arc::clone(
|
||
&app.transcript_cells[nth_user_position(&app.transcript_cells, /*nth*/ 1).unwrap()],
|
||
),
|
||
prompt: prompt.clone(),
|
||
};
|
||
// A queued page shifts both identical prompts before confirmation is dispatched.
|
||
let cursor = app_server
|
||
.begin_older_history_page(source_thread_id)
|
||
.expect("older page");
|
||
let page = app_server
|
||
.thread_items_page(
|
||
source_thread_id,
|
||
/*turn_id*/ None,
|
||
Some(cursor.clone()),
|
||
/*limit*/ 1,
|
||
)
|
||
.await?;
|
||
app.handle_older_history_page(
|
||
&mut tui,
|
||
&mut app_server,
|
||
source_thread_id,
|
||
&cursor,
|
||
Ok(page),
|
||
)
|
||
.await?;
|
||
let control = Box::pin(app.handle_event(&mut tui, &mut app_server, selection)).await?;
|
||
|
||
while let Ok(event) = app_event_rx.try_recv() {
|
||
match event {
|
||
AppEvent::InsertHistoryCell(cell) => app.transcript_cells.push(Arc::from(cell)),
|
||
event @ AppEvent::FinishPromptRevert { .. } => {
|
||
Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
assert_eq!(
|
||
(
|
||
app.transcript_view.is_following(),
|
||
app.transcript_view.selected_text(&app.transcript_cells)
|
||
),
|
||
(true, None),
|
||
);
|
||
let bottom = app.render_owned_transcript(&mut tui, size)?;
|
||
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
|
||
let transcript = buffer
|
||
.content()
|
||
.chunks(usize::from(size.width))
|
||
.take(usize::from(bottom.y))
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(ratatui::buffer::Cell::symbol)
|
||
.collect::<String>()
|
||
.trim_end()
|
||
.to_string()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let stable_cwd = "/tmp/project";
|
||
let cwd = test_path_buf(stable_cwd).display().to_string();
|
||
let transcript = transcript.replace(&cwd, &format!("{stable_cwd:<width$}", width = cwd.len()));
|
||
insta::assert_snapshot!("owned_prompt_revert_retained_history", transcript);
|
||
assert!(Arc::ptr_eq(
|
||
&child,
|
||
&app.thread_event_channels[&child_id].store
|
||
));
|
||
assert_eq!(app.chat_widget.current_model(), "gpt-5.4");
|
||
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 120).contains("Goal paused"));
|
||
assert!(matches!(control, AppRunControl::Continue));
|
||
let reverted_thread_id = app
|
||
.chat_widget
|
||
.thread_id()
|
||
.expect("prompt edit should keep the current thread");
|
||
assert_eq!(reverted_thread_id, source_thread_id);
|
||
assert_eq!(app.chat_widget.composer_text_with_pending(), prompt.text);
|
||
assert_eq!(
|
||
app.chat_widget.remote_image_urls(),
|
||
prompt.remote_image_urls
|
||
);
|
||
assert_eq!(
|
||
app_server
|
||
.thread_read(reverted_thread_id, /*include_turns*/ true)
|
||
.await?
|
||
.turns
|
||
.iter()
|
||
.map(|turn| turn.id.as_str())
|
||
.collect::<Vec<_>>(),
|
||
vec!["turn-0", "turn-1", "turn-2"]
|
||
);
|
||
|
||
let history = app
|
||
.transcript_cells
|
||
.iter()
|
||
.map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 120)))
|
||
.collect::<Vec<_>>();
|
||
let retained_index = history
|
||
.iter()
|
||
.position(|line| line.contains("retained prompt"))
|
||
.expect("revert should keep the earlier prompt");
|
||
let notice_index = history
|
||
.iter()
|
||
.position(|line| {
|
||
line == "• Conversation reverted to this point. File changes are unchanged."
|
||
})
|
||
.expect("prompt edit should emit the revert notice");
|
||
assert!(retained_index < notice_index);
|
||
assert!(
|
||
!history
|
||
.iter()
|
||
.any(|line| line.contains("Thread forked from"))
|
||
);
|
||
// Editing the first visible prompt also discards search state from the removed window.
|
||
app.transcript_view.begin_search();
|
||
assert!(app.transcript_view.is_search_editing());
|
||
app.chat_widget.restore_thread_input_state(
|
||
/*input_state*/ None,
|
||
crate::chatwidget::ThreadInputStateRestoreMode {
|
||
preserve_in_flight_turn: false,
|
||
},
|
||
);
|
||
Box::pin(app.handle_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
AppEvent::RevertSessionForPromptEdit {
|
||
thread_id: source_thread_id,
|
||
selected_cell: Arc::clone(
|
||
&app.transcript_cells[nth_user_position(&app.transcript_cells, /*nth*/ 0).unwrap()],
|
||
),
|
||
prompt: "retained prompt".into(),
|
||
},
|
||
))
|
||
.await?;
|
||
while let Ok(event) = app_event_rx.try_recv() {
|
||
if matches!(event, AppEvent::FinishPromptRevert { .. }) {
|
||
Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?;
|
||
}
|
||
}
|
||
assert!(!app.transcript_view.is_search_editing());
|
||
assert!(app.transcript_view.is_following());
|
||
assert_eq!(app.chat_widget.thread_id(), Some(source_thread_id));
|
||
assert_eq!(
|
||
app.chat_widget.composer_text_with_pending(),
|
||
"retained prompt"
|
||
);
|
||
assert_eq!(crate::app_backtrack::user_count(&app.transcript_cells), 0);
|
||
assert_eq!(
|
||
app_server
|
||
.thread_read(source_thread_id, /*include_turns*/ true)
|
||
.await?
|
||
.turns
|
||
.iter()
|
||
.map(|turn| turn.id.as_str())
|
||
.collect::<Vec<_>>(),
|
||
vec!["turn-0"]
|
||
);
|
||
tui.set_owned_screen(/*owned*/ true)?;
|
||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw)
|
||
.await?;
|
||
assert_eq!(
|
||
app.transcript_view.history,
|
||
crate::pager_overlay::TranscriptHistoryState::LoadingOlder
|
||
);
|
||
loop {
|
||
let event = app_event_rx.recv().await.expect("older history page");
|
||
if let AppEvent::OlderThreadHistoryLoaded { .. } = event {
|
||
Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?;
|
||
break;
|
||
}
|
||
}
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
let prompts = app
|
||
.transcript_cells
|
||
.iter()
|
||
.filter_map(|cell| {
|
||
cell.as_any()
|
||
.downcast_ref::<crate::history_cell::UserHistoryCell>()
|
||
})
|
||
.map(|cell| cell.message.as_str())
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(prompts, vec!["older prompt"]);
|
||
assert!(!app_server.has_older_history(source_thread_id));
|
||
assert_eq!(
|
||
app_server.thread_goal_get(source_thread_id).await?.goal,
|
||
Some(goal)
|
||
);
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
app_server.shutdown().await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn replay_thread_snapshot_replays_turn_history_in_order() {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let thread_id = ThreadId::new();
|
||
app.replay_thread_snapshot(
|
||
ThreadEventSnapshot {
|
||
delegated_turns: Vec::new(),
|
||
session: Some(test_thread_session(
|
||
thread_id,
|
||
test_path_buf("/home/user/project"),
|
||
)),
|
||
turns: vec![
|
||
Turn {
|
||
id: "turn-1".to_string(),
|
||
root_turn_id: None,
|
||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||
items: vec![ThreadItem::untimed(ThreadItemPayload::UserMessage {
|
||
id: "user-1".to_string(),
|
||
client_id: None,
|
||
content: vec![AppServerUserInput::Text {
|
||
text: "first prompt".to_string(),
|
||
text_elements: Vec::new(),
|
||
}],
|
||
})],
|
||
status: TurnStatus::Completed,
|
||
error: None,
|
||
started_at: None,
|
||
completed_at: None,
|
||
duration_ms: None,
|
||
},
|
||
Turn {
|
||
id: "turn-2".to_string(),
|
||
root_turn_id: None,
|
||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||
items: vec![
|
||
ThreadItem::untimed(ThreadItemPayload::UserMessage {
|
||
id: "user-2".to_string(),
|
||
client_id: None,
|
||
content: vec![AppServerUserInput::Text {
|
||
text: "third prompt".to_string(),
|
||
text_elements: Vec::new(),
|
||
}],
|
||
}),
|
||
ThreadItem::untimed(ThreadItemPayload::AgentMessage {
|
||
id: "assistant-2".to_string(),
|
||
text: "done".to_string(),
|
||
phase: None,
|
||
memory_citation: None,
|
||
delivery: None,
|
||
questions: None,
|
||
}),
|
||
],
|
||
status: TurnStatus::Completed,
|
||
error: None,
|
||
started_at: None,
|
||
completed_at: None,
|
||
duration_ms: None,
|
||
},
|
||
],
|
||
events: Vec::new(),
|
||
active_reasoning_item: None,
|
||
input_state: None,
|
||
},
|
||
/*resume_restored_queue*/ false,
|
||
);
|
||
|
||
while let Ok(event) = app_event_rx.try_recv() {
|
||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||
let cell: Arc<dyn HistoryCell> = cell.into();
|
||
app.transcript_cells.push(cell);
|
||
}
|
||
}
|
||
|
||
let user_messages: Vec<String> = app
|
||
.transcript_cells
|
||
.iter()
|
||
.filter_map(|cell| {
|
||
cell.as_any()
|
||
.downcast_ref::<UserHistoryCell>()
|
||
.map(|cell| cell.message.clone())
|
||
})
|
||
.collect();
|
||
assert_eq!(
|
||
user_messages,
|
||
vec!["first prompt".to_string(), "third prompt".to_string()]
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let receiver_thread_id =
|
||
ThreadId::from_string("019cff70-2599-75e2-af72-b958ce5dc1cc").expect("valid thread");
|
||
app.agent_navigation.upsert(
|
||
receiver_thread_id,
|
||
Some("Robie".to_string()),
|
||
Some("explorer".to_string()),
|
||
/*is_closed*/ false,
|
||
);
|
||
|
||
let replacement = ChatWidget::new_with_app_event(ChatWidgetInit {
|
||
requires_openai_auth: true,
|
||
local_settings: crate::local_settings::LocalSettings::from(&app.config),
|
||
config: app.config.clone(),
|
||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||
app_event_tx: app.app_event_tx.clone(),
|
||
workspace_command_runner: None,
|
||
initial_user_message: None,
|
||
enhanced_keys_supported: app.enhanced_keys_supported,
|
||
has_chatgpt_account: app.chat_widget.has_chatgpt_account(),
|
||
has_codex_backend_auth: app.chat_widget.has_codex_backend_auth(),
|
||
model_catalog: app.model_catalog.clone(),
|
||
feedback: app.feedback.clone(),
|
||
is_first_run: false,
|
||
status_account_display: app.chat_widget.status_account_display().cloned(),
|
||
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 {
|
||
delegated_turns: Vec::new(),
|
||
session: None,
|
||
turns: Vec::new(),
|
||
events: vec![ThreadBufferedEvent::Notification(Box::new(
|
||
ServerNotification::ItemStarted(
|
||
codex_app_server_protocol::ItemStartedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
ThreadItemPayload::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(),
|
||
},
|
||
/*started_at_ms*/ 0,
|
||
),
|
||
),
|
||
))],
|
||
active_reasoning_item: None,
|
||
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![ThreadItemPayload::UserMessage {
|
||
id: "user-1".to_string(),
|
||
client_id: None,
|
||
content: vec![AppServerUserInput::Text {
|
||
text: "restored prompt".to_string(),
|
||
text_elements: Vec::new(),
|
||
}],
|
||
}],
|
||
)];
|
||
let resumed_session = ThreadSessionState {
|
||
cwd: test_path_buf("/tmp/refreshed").abs(),
|
||
runtime_workspace_roots: Vec::new(),
|
||
instruction_source_paths: Vec::new(),
|
||
..initial_session.clone()
|
||
};
|
||
let mut snapshot = ThreadEventSnapshot {
|
||
delegated_turns: Vec::new(),
|
||
session: Some(initial_session),
|
||
turns: Vec::new(),
|
||
events: Vec::new(),
|
||
active_reasoning_item: None,
|
||
input_state: None,
|
||
};
|
||
|
||
app.apply_refreshed_snapshot_thread(
|
||
thread_id,
|
||
AppServerStartedThread {
|
||
session: resumed_session.clone(),
|
||
turns: resumed_turns.clone(),
|
||
blocks_direct_input: true,
|
||
task_tools_available: false,
|
||
},
|
||
&mut snapshot,
|
||
)
|
||
.await;
|
||
|
||
assert!(app.agent_navigation.is_parent_owned(thread_id));
|
||
assert_eq!(snapshot.session, Some(resumed_session.clone()));
|
||
assert_eq!(snapshot.turns, resumed_turns);
|
||
|
||
let store = app
|
||
.thread_event_channels
|
||
.get(&thread_id)
|
||
.expect("thread channel")
|
||
.store
|
||
.lock()
|
||
.await;
|
||
let store_snapshot = store.snapshot();
|
||
assert_eq!(store_snapshot.session, Some(resumed_session));
|
||
assert_eq!(store_snapshot.turns, snapshot.turns);
|
||
assert_eq!(
|
||
store.recap_progress(),
|
||
recap::RecapProgress {
|
||
completed_turns: 1,
|
||
last_recapped_turn_count: None,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn 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 {
|
||
daybreak_enabled: false,
|
||
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
||
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,
|
||
message_history: None,
|
||
network_proxy: None,
|
||
rollout_path: Some(PathBuf::new()),
|
||
};
|
||
|
||
app.chat_widget.handle_thread_session(event);
|
||
|
||
while app_event_rx.try_recv().is_ok() {}
|
||
while op_rx.try_recv().is_ok() {}
|
||
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await
|
||
.expect("embedded app server");
|
||
Box::pin(app.shutdown_current_thread(&mut app_server)).await;
|
||
|
||
assert!(
|
||
op_rx.try_recv().is_err(),
|
||
"shutdown should not submit Op::Shutdown"
|
||
);
|
||
})
|
||
.await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() {
|
||
let mut app = make_test_app().await;
|
||
let thread_id = ThreadId::new();
|
||
app.active_thread_id = Some(thread_id);
|
||
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await
|
||
.expect("embedded app server");
|
||
let control = Box::pin(app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst)).await;
|
||
|
||
assert_eq!(app.pending_shutdown_exit_thread_id, None);
|
||
assert!(matches!(
|
||
control,
|
||
AppRunControl::Exit(ExitReason::UserRequested)
|
||
));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn shutdown_first_exit_uses_app_server_shutdown_without_submitting_op() {
|
||
let (mut app, _app_event_rx, mut op_rx) = Box::pin(make_test_app_with_channels()).await;
|
||
let thread_id = ThreadId::new();
|
||
app.active_thread_id = Some(thread_id);
|
||
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await
|
||
.expect("embedded app server");
|
||
let control = Box::pin(app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst)).await;
|
||
|
||
assert_eq!(app.pending_shutdown_exit_thread_id, None);
|
||
assert!(matches!(
|
||
control,
|
||
AppRunControl::Exit(ExitReason::UserRequested)
|
||
));
|
||
assert!(
|
||
op_rx.try_recv().is_err(),
|
||
"shutdown should not submit Op::Shutdown"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn interrupt_without_active_turn_is_treated_as_handled() {
|
||
Box::pin(async {
|
||
let mut app = make_test_app().await;
|
||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
||
app.chat_widget.config_ref(),
|
||
))
|
||
.await
|
||
.expect("embedded app server");
|
||
let started = app_server
|
||
.start_thread(app.chat_widget.config_ref())
|
||
.await
|
||
.expect("thread/start should succeed");
|
||
let thread_id = started.session.thread_id;
|
||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||
.await
|
||
.expect("primary thread should be registered");
|
||
app.backtrack.primed = true;
|
||
let op = AppCommand::interrupt();
|
||
|
||
let handled = Box::pin(app.try_submit_active_thread_op_via_app_server(
|
||
&mut app_server,
|
||
thread_id,
|
||
&op,
|
||
))
|
||
.await
|
||
.expect("interrupt submission should not fail");
|
||
|
||
assert_eq!(handled, true);
|
||
assert!(!app.backtrack.primed);
|
||
})
|
||
.await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn override_turn_context_sends_thread_settings_update() {
|
||
Box::pin(async {
|
||
let mut app = make_test_app().await;
|
||
let mut app_server =
|
||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
|
||
.await
|
||
.expect("embedded app server");
|
||
let started = app_server
|
||
.start_thread(app.chat_widget.config_ref())
|
||
.await
|
||
.expect("thread/start should succeed");
|
||
let thread_id = started.session.thread_id;
|
||
let initial_model = started.session.model.clone();
|
||
let initial_effort = started.session.reasoning_effort.clone();
|
||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||
.await
|
||
.expect("primary thread should be registered");
|
||
let service_tier = ServiceTier::Fast.request_value();
|
||
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,
|
||
)),
|
||
Some("gpt-5.4".to_string()),
|
||
Some(Some(ReasoningEffortConfig::High)),
|
||
/*summary*/ None,
|
||
Some(Some(service_tier.to_string())),
|
||
Some(collaboration_mode.clone()),
|
||
);
|
||
|
||
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.as_deref(),
|
||
Some(service_tier)
|
||
);
|
||
assert_eq!(
|
||
notification.thread_settings.approval_policy,
|
||
AskForApproval::OnRequest
|
||
);
|
||
assert_eq!(
|
||
notification.thread_settings.approvals_reviewer.to_core(),
|
||
ApprovalsReviewer::AutoReview
|
||
);
|
||
let notified_mode = ¬ification.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
|
||
);
|
||
|
||
app.handle_app_server_event(
|
||
&app_server,
|
||
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
|
||
ServerNotification::ThreadSettingsUpdated(notification),
|
||
)),
|
||
)
|
||
.await;
|
||
let updated_session = app
|
||
.primary_session_configured
|
||
.as_ref()
|
||
.expect("primary session should be updated from notification");
|
||
assert_eq!(updated_session.model, initial_model);
|
||
assert_eq!(updated_session.reasoning_effort, initial_effort);
|
||
let updated_mode = updated_session
|
||
.collaboration_mode
|
||
.as_deref()
|
||
.expect("collaboration mode should be cached");
|
||
assert_eq!(updated_mode.mode, collaboration_mode.mode);
|
||
assert_eq!(
|
||
updated_mode.settings.model,
|
||
collaboration_mode.settings.model
|
||
);
|
||
assert_eq!(
|
||
updated_mode.settings.reasoning_effort,
|
||
collaboration_mode.settings.reasoning_effort
|
||
);
|
||
assert_eq!(updated_session.service_tier.as_deref(), Some(service_tier));
|
||
assert_eq!(updated_session.approval_policy, AskForApproval::OnRequest);
|
||
assert_eq!(
|
||
updated_session.approvals_reviewer,
|
||
ApprovalsReviewer::AutoReview
|
||
);
|
||
assert_eq!(
|
||
updated_session
|
||
.active_permission_profile
|
||
.as_ref()
|
||
.expect("active profile")
|
||
.id,
|
||
codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE
|
||
);
|
||
})
|
||
.await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn selecting_cyber_model_defaults_active_thread_to_auto_review() {
|
||
Box::pin(async {
|
||
let (mut app, mut events, _) = make_test_app_with_channels().await;
|
||
app.config
|
||
.permissions
|
||
.approval_policy
|
||
.set(AskForApproval::UnlessTrusted.to_core())
|
||
.expect("set approval policy");
|
||
app.chat_widget
|
||
.set_approval_policy(AskForApproval::UnlessTrusted);
|
||
let mut model = app
|
||
.model_catalog
|
||
.try_list_models()
|
||
.expect("model catalog")
|
||
.into_iter()
|
||
.find(|model| model.model == "gpt-5.5")
|
||
.expect("gpt-5.5 model");
|
||
model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string());
|
||
app.model_catalog = Arc::new(ModelCatalog::new(vec![model]));
|
||
|
||
let mut app_server =
|
||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
|
||
.await
|
||
.expect("embedded app server");
|
||
let started = app_server
|
||
.start_thread(app.chat_widget.config_ref())
|
||
.await
|
||
.expect("thread/start should succeed");
|
||
assert_eq!(
|
||
started.session.approval_policy,
|
||
AskForApproval::UnlessTrusted
|
||
);
|
||
assert_eq!(started.session.approvals_reviewer, ApprovalsReviewer::User);
|
||
let thread_id = started.session.thread_id;
|
||
app.chat_widget
|
||
.handle_thread_session_quiet(started.session.clone());
|
||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||
.await
|
||
.expect("primary thread should be registered");
|
||
|
||
let mut tui = crate::tui::test_support::make_test_tui().expect("test tui");
|
||
let previous_model = app.chat_widget.current_model().to_string();
|
||
app.pending_server_profiles.insert(
|
||
thread_id,
|
||
PermissionProfileSelection {
|
||
profile_id: "server-only".into(),
|
||
approval_policy: None,
|
||
approvals_reviewer: None,
|
||
display_label: "server-only".into(),
|
||
},
|
||
);
|
||
app.handle_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
AppEvent::ApplyAdvancedReasoning {
|
||
model: "gpt-5.5".to_string(),
|
||
effort: ReasoningEffortConfig::High,
|
||
},
|
||
)
|
||
.await
|
||
.expect("pending permission selection should block the cyber model");
|
||
assert_eq!(app.chat_widget.current_model(), previous_model);
|
||
let previous_selection = app.pending_server_profiles.remove(&thread_id).unwrap();
|
||
app.agents_overview
|
||
.requested_permission_profiles
|
||
.insert(thread_id, previous_selection);
|
||
app.handle_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
AppEvent::UpdateModel("gpt-5.5".to_string()),
|
||
)
|
||
.await
|
||
.expect("model selection should succeed");
|
||
|
||
assert!(app.pending_server_profiles.contains_key(&thread_id));
|
||
assert!(
|
||
!app.agents_overview
|
||
.requested_permission_profiles
|
||
.contains_key(&thread_id)
|
||
);
|
||
while events.try_recv().is_ok() {}
|
||
// Until the cyber model's permissions are confirmed, do not offer another selection.
|
||
app.chat_widget.handle_paste("/permissions".into());
|
||
app.chat_widget.handle_key_event(KeyCode::Enter.into());
|
||
let event = std::iter::from_fn(|| events.try_recv().ok())
|
||
.find(|event| matches!(event, AppEvent::OpenPermissionsPopup))
|
||
.expect("slash permissions goes through app permission guards");
|
||
app.handle_event(&mut tui, &mut app_server, event)
|
||
.await
|
||
.expect("permissions menu");
|
||
insta::assert_snapshot!(
|
||
next_history_message(&mut events),
|
||
@"■ Wait for permissions to update before changing permissions."
|
||
);
|
||
let notification = next_thread_settings_updated(&mut app_server, thread_id).await;
|
||
assert_eq!(
|
||
notification.thread_settings.approval_policy,
|
||
AskForApproval::OnRequest
|
||
);
|
||
assert_eq!(
|
||
notification.thread_settings.approvals_reviewer.to_core(),
|
||
ApprovalsReviewer::AutoReview
|
||
);
|
||
assert_eq!(
|
||
notification
|
||
.thread_settings
|
||
.active_permission_profile
|
||
.as_ref()
|
||
.expect("active permission profile")
|
||
.id,
|
||
codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE
|
||
);
|
||
app.enqueue_thread_notification(
|
||
thread_id,
|
||
ServerNotification::ThreadSettingsUpdated(notification),
|
||
)
|
||
.await
|
||
.expect("settings notification should apply");
|
||
assert!(!app.pending_server_profiles.contains_key(&thread_id));
|
||
})
|
||
.await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn changing_cyber_model_reasoning_preserves_selected_permissions() {
|
||
Box::pin(async {
|
||
let mut app = make_test_app().await;
|
||
let model_name = app.chat_widget.current_model().to_string();
|
||
let mut model = app
|
||
.model_catalog
|
||
.try_list_models()
|
||
.expect("model catalog")
|
||
.into_iter()
|
||
.find(|model| model.model == model_name)
|
||
.expect("current model");
|
||
model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string());
|
||
app.model_catalog = Arc::new(ModelCatalog::new(vec![model]));
|
||
|
||
assert!(
|
||
app.apply_permission_profile_selection(PermissionProfileSelection {
|
||
profile_id: codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY
|
||
.to_string(),
|
||
approval_policy: Some(AskForApproval::OnRequest),
|
||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||
display_label: "Read Only".to_string(),
|
||
})
|
||
.await
|
||
);
|
||
|
||
let mut app_server =
|
||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
|
||
.await
|
||
.expect("embedded app server");
|
||
let started = app_server
|
||
.start_thread(app.chat_widget.config_ref())
|
||
.await
|
||
.expect("thread/start should succeed");
|
||
let thread_id = started.session.thread_id;
|
||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||
.await
|
||
.expect("primary thread should be registered");
|
||
|
||
let mut tui = crate::tui::test_support::make_test_tui().expect("test tui");
|
||
for (effort, session_only) in [
|
||
(ReasoningEffortConfig::High, false),
|
||
(ReasoningEffortConfig::Medium, true),
|
||
(ReasoningEffortConfig::Ultra, false),
|
||
(ReasoningEffortConfig::High, true),
|
||
(ReasoningEffortConfig::Ultra, true),
|
||
] {
|
||
app.chat_widget.set_model(&model_name);
|
||
if session_only {
|
||
app.chat_widget.set_model("different-base-model");
|
||
let default_mode = app.chat_widget.current_collaboration_mode().clone();
|
||
app.chat_widget
|
||
.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
|
||
app.chat_widget
|
||
.set_collaboration_mask(CollaborationModeMask {
|
||
name: "Plan".to_string(),
|
||
mode: Some(ModeKind::Plan),
|
||
model: Some(model_name.clone()),
|
||
reasoning_effort: Some(Some(effort.clone())),
|
||
developer_instructions: None,
|
||
});
|
||
app.handle_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
AppEvent::SelectSessionModel {
|
||
model: model_name.clone(),
|
||
effort: Some(effort.clone()),
|
||
service_tier: None,
|
||
},
|
||
)
|
||
.await
|
||
.expect("session-only reasoning selection should succeed");
|
||
assert_eq!(app.chat_widget.current_collaboration_mode(), &default_mode);
|
||
} else if effort == ReasoningEffortConfig::Ultra {
|
||
app.chat_widget
|
||
.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
|
||
app.chat_widget
|
||
.set_collaboration_mask(CollaborationModeMask {
|
||
name: "Plan".to_string(),
|
||
mode: Some(ModeKind::Plan),
|
||
model: Some(model_name.clone()),
|
||
reasoning_effort: Some(Some(effort.clone())),
|
||
developer_instructions: None,
|
||
});
|
||
app.handle_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
AppEvent::ApplyAdvancedReasoning {
|
||
model: model_name.clone(),
|
||
effort: effort.clone(),
|
||
},
|
||
)
|
||
.await
|
||
.expect("advanced reasoning selection should succeed");
|
||
} else {
|
||
app.handle_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
AppEvent::UpdateModel(model_name.clone()),
|
||
)
|
||
.await
|
||
.expect("same-model selection should succeed");
|
||
app.handle_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
AppEvent::UpdateReasoningEffort(Some(effort.clone())),
|
||
)
|
||
.await
|
||
.expect("reasoning selection should succeed");
|
||
}
|
||
|
||
let settings = next_thread_settings_updated(&mut app_server, thread_id)
|
||
.await
|
||
.thread_settings;
|
||
assert_eq!(settings.effort, Some(effort));
|
||
assert_eq!(settings.approval_policy, AskForApproval::OnRequest);
|
||
assert_eq!(
|
||
settings.approvals_reviewer.to_core(),
|
||
ApprovalsReviewer::User
|
||
);
|
||
assert_eq!(
|
||
settings
|
||
.active_permission_profile
|
||
.expect("active permission profile")
|
||
.id,
|
||
codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY
|
||
);
|
||
assert_eq!(
|
||
settings.collaboration_mode.mode,
|
||
app.chat_widget.effective_collaboration_mode().mode
|
||
);
|
||
assert_eq!(settings.collaboration_mode.settings.model, model_name);
|
||
}
|
||
})
|
||
.await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn selecting_cyber_model_falls_back_to_user_when_auto_review_is_unavailable() {
|
||
let mut app = make_test_app().await;
|
||
let mut model = app
|
||
.model_catalog
|
||
.try_list_models()
|
||
.expect("model catalog")
|
||
.into_iter()
|
||
.find(|model| model.model == "gpt-5.5")
|
||
.expect("gpt-5.5 model");
|
||
model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string());
|
||
app.model_catalog = Arc::new(ModelCatalog::new(vec![model]));
|
||
let _ = app.config.features.disable(Feature::GuardianApproval);
|
||
app.chat_widget
|
||
.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false);
|
||
app.active_thread_id = Some(ThreadId::new());
|
||
|
||
let params = app
|
||
.active_thread_model_setting_update_params("gpt-5.5".to_string())
|
||
.expect("active thread should produce update params");
|
||
|
||
assert_eq!(
|
||
params.approval_policy,
|
||
Some(codex_app_server_protocol::AskForApproval::OnRequest)
|
||
);
|
||
assert_eq!(
|
||
params.approvals_reviewer,
|
||
Some(codex_app_server_protocol::ApprovalsReviewer::User)
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn selecting_cyber_model_respects_auto_review_requirements() {
|
||
Box::pin(async {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
let requirements_toml = codex_config::ConfigRequirementsToml {
|
||
allowed_approval_policies: Some(vec![AskForApproval::UnlessTrusted.to_core()]),
|
||
..Default::default()
|
||
};
|
||
let mut requirements_with_sources = codex_config::ConfigRequirementsWithSources::default();
|
||
requirements_with_sources.merge_unset_fields(
|
||
codex_config::RequirementSource::Unknown,
|
||
requirements_toml.clone(),
|
||
);
|
||
let requirements = codex_config::ConfigRequirements::try_from(requirements_with_sources)
|
||
.expect("reviewer requirements");
|
||
app.config.config_layer_stack =
|
||
codex_config::ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)
|
||
.expect("auto-review requirements stack");
|
||
app.config
|
||
.permissions
|
||
.approval_policy
|
||
.set(AskForApproval::UnlessTrusted.to_core())
|
||
.expect("set approval policy");
|
||
app.chat_widget
|
||
.set_approval_policy(AskForApproval::UnlessTrusted);
|
||
app.chat_widget.sync_plugin_mentions_config(&app.config);
|
||
|
||
let mut model = app
|
||
.model_catalog
|
||
.try_list_models()
|
||
.expect("model catalog")
|
||
.into_iter()
|
||
.find(|model| model.model == "gpt-5.5")
|
||
.expect("gpt-5.5 model");
|
||
model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string());
|
||
app.model_catalog = Arc::new(ModelCatalog::new(vec![model]));
|
||
|
||
let mut app_server =
|
||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
|
||
.await
|
||
.expect("embedded app server");
|
||
let started = app_server
|
||
.start_thread(app.chat_widget.config_ref())
|
||
.await
|
||
.expect("thread/start should succeed");
|
||
assert_eq!(
|
||
started.session.approval_policy,
|
||
AskForApproval::UnlessTrusted
|
||
);
|
||
let thread_id = started.session.thread_id;
|
||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||
.await
|
||
.expect("primary thread should be registered");
|
||
|
||
let mut tui = crate::tui::test_support::make_test_tui().expect("test tui");
|
||
app.handle_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
AppEvent::UpdateModel("gpt-5.5".to_string()),
|
||
)
|
||
.await
|
||
.expect("model selection should succeed");
|
||
|
||
let notification = next_thread_settings_updated(&mut app_server, thread_id).await;
|
||
assert_eq!(
|
||
notification.thread_settings.approval_policy,
|
||
AskForApproval::UnlessTrusted
|
||
);
|
||
assert_eq!(
|
||
notification.thread_settings.approvals_reviewer.to_core(),
|
||
ApprovalsReviewer::User
|
||
);
|
||
assert!(
|
||
std::iter::from_fn(|| app_event_rx.try_recv().ok())
|
||
.all(|event| !matches!(event, AppEvent::CyberModelAutoReviewNotice))
|
||
);
|
||
})
|
||
.await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn thread_setting_update_params_sync_model_and_default_reasoning() {
|
||
let mut app = make_test_app().await;
|
||
let thread_id = ThreadId::new();
|
||
app.active_thread_id = Some(thread_id);
|
||
|
||
app.chat_widget.set_model("gpt-5.4");
|
||
let params = app
|
||
.active_thread_model_setting_update_params("gpt-5.4".to_string())
|
||
.expect("active thread should produce update params");
|
||
|
||
assert_eq!(params.thread_id, thread_id.to_string());
|
||
assert_eq!(params.model, Some("gpt-5.4".to_string()));
|
||
assert_eq!(
|
||
params
|
||
.collaboration_mode
|
||
.as_ref()
|
||
.expect("collaboration mode should sync with model")
|
||
.settings
|
||
.model,
|
||
"gpt-5.4"
|
||
);
|
||
|
||
app.chat_widget
|
||
.set_reasoning_effort(Some(ReasoningEffortConfig::Low));
|
||
app.chat_widget
|
||
.set_collaboration_mask(CollaborationModeMask {
|
||
name: "Plan".to_string(),
|
||
mode: Some(ModeKind::Plan),
|
||
model: Some("gpt-plan".to_string()),
|
||
reasoning_effort: Some(Some(ReasoningEffortConfig::Medium)),
|
||
developer_instructions: None,
|
||
});
|
||
app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High));
|
||
|
||
let params = app
|
||
.active_thread_reasoning_setting_update_params(Some(ReasoningEffortConfig::High))
|
||
.expect("active thread should produce update params");
|
||
|
||
assert_eq!(params.thread_id, thread_id.to_string());
|
||
assert_eq!(params.effort, Some(ReasoningEffortConfig::High));
|
||
let collaboration_mode = params
|
||
.collaboration_mode
|
||
.expect("collaboration mode should sync with reasoning");
|
||
assert_eq!(collaboration_mode.mode, ModeKind::Default);
|
||
assert_eq!(
|
||
collaboration_mode.settings.reasoning_effort,
|
||
Some(ReasoningEffortConfig::High)
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn inactive_thread_settings_notification_updates_cached_collaboration_mode() {
|
||
let mut app = make_test_app().await;
|
||
let primary_thread_id = ThreadId::new();
|
||
let inactive_thread_id = ThreadId::new();
|
||
let primary_session = test_thread_session(primary_thread_id, test_path_buf("/tmp/main"));
|
||
let inactive_session = test_thread_session(inactive_thread_id, test_path_buf("/tmp/inactive"));
|
||
let collaboration_mode = CollaborationMode {
|
||
mode: ModeKind::Plan,
|
||
settings: Settings {
|
||
model: "gpt-plan".to_string(),
|
||
reasoning_effort: Some(ReasoningEffortConfig::High),
|
||
developer_instructions: Some("draft a plan first".to_string()),
|
||
},
|
||
};
|
||
|
||
app.primary_thread_id = Some(primary_thread_id);
|
||
app.active_thread_id = Some(primary_thread_id);
|
||
app.primary_session_configured = Some(primary_session.clone());
|
||
app.thread_event_channels.insert(
|
||
primary_thread_id,
|
||
ThreadEventChannel::new_with_session(
|
||
THREAD_EVENT_CHANNEL_CAPACITY,
|
||
primary_session,
|
||
Vec::new(),
|
||
),
|
||
);
|
||
app.thread_event_channels.insert(
|
||
inactive_thread_id,
|
||
ThreadEventChannel::new_with_session(
|
||
THREAD_EVENT_CHANNEL_CAPACITY,
|
||
inactive_session,
|
||
Vec::new(),
|
||
),
|
||
);
|
||
|
||
let notification = ThreadSettingsUpdatedNotification {
|
||
thread_id: inactive_thread_id.to_string(),
|
||
thread_settings: ThreadSettings {
|
||
disabled_plugin_ids: Vec::new(),
|
||
cwd: test_absolute_path("/tmp/thread-settings"),
|
||
approval_policy: AskForApproval::OnRequest,
|
||
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview,
|
||
sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly {
|
||
network_access: false,
|
||
},
|
||
active_permission_profile: Some(
|
||
codex_app_server_protocol::ActivePermissionProfile::read_only(),
|
||
),
|
||
model: "gpt-plan".to_string(),
|
||
model_provider: "openai".to_string(),
|
||
service_tier: None,
|
||
effort: collaboration_mode.settings.reasoning_effort.clone(),
|
||
summary: None,
|
||
collaboration_mode: collaboration_mode.clone(),
|
||
multi_agent_mode: Default::default(),
|
||
personality: None,
|
||
},
|
||
};
|
||
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.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)
|
||
);
|
||
}
|
||
|
||
#[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 {
|
||
daybreak_enabled: false,
|
||
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
||
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,
|
||
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 {
|
||
presentation: Default::default(),
|
||
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,
|
||
/*copy_on_select*/ false,
|
||
/*mouse_scroll_speed*/ 1.0,
|
||
));
|
||
app.deferred_history_lines = vec![Line::from("stale buffered line").into()];
|
||
app.has_emitted_history_lines = true;
|
||
app.backtrack.primed = true;
|
||
app.backtrack.overlay_preview_active = true;
|
||
app.backtrack.nth_user_message = 0;
|
||
app.backtrack_render_pending = true;
|
||
|
||
app.reset_app_ui_state_after_clear();
|
||
|
||
assert!(app.overlay.is_none());
|
||
assert!(app.transcript_cells.is_empty());
|
||
assert!(app.deferred_history_lines.is_empty());
|
||
assert!(!app.has_emitted_history_lines);
|
||
assert!(!app.backtrack.primed);
|
||
assert!(!app.backtrack.overlay_preview_active);
|
||
assert!(!app.backtrack_render_pending);
|
||
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
|
||
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn clear_only_ui_reset_allows_active_skill_warning_to_render_again() {
|
||
let mut app = make_test_app().await;
|
||
let error = SkillErrorInfo {
|
||
path: test_path_buf("/tmp/project/.codex/skills/abc/SKILL.md"),
|
||
message: "invalid description".to_string(),
|
||
};
|
||
|
||
assert_eq!(
|
||
app.skill_load_warnings
|
||
.newly_active_errors(std::slice::from_ref(&error)),
|
||
vec![error.clone()]
|
||
);
|
||
assert_eq!(
|
||
app.skill_load_warnings
|
||
.newly_active_errors(std::slice::from_ref(&error)),
|
||
Vec::<SkillErrorInfo>::new()
|
||
);
|
||
|
||
app.reset_app_ui_state_after_clear();
|
||
|
||
assert_eq!(
|
||
app.skill_load_warnings
|
||
.newly_active_errors(std::slice::from_ref(&error)),
|
||
vec![error]
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn backtrack_esc_does_not_steal_empty_vim_insert_escape() {
|
||
let mut app = make_test_app().await;
|
||
let esc = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Esc, KeyModifiers::NONE);
|
||
|
||
assert!(app.chat_widget.composer_is_empty());
|
||
assert!(app.should_handle_backtrack_esc(esc));
|
||
|
||
app.chat_widget.toggle_vim_mode_and_notify();
|
||
assert!(app.should_handle_backtrack_esc(esc));
|
||
|
||
app.chat_widget
|
||
.handle_key_event(crossterm::event::KeyEvent::new(
|
||
crossterm::event::KeyCode::Char('i'),
|
||
KeyModifiers::NONE,
|
||
));
|
||
assert!(app.chat_widget.should_handle_vim_insert_escape(esc));
|
||
assert!(!app.should_handle_backtrack_esc(esc));
|
||
|
||
app.chat_widget.handle_key_event(esc);
|
||
|
||
assert!(!app.backtrack.primed);
|
||
assert!(!app.chat_widget.should_handle_vim_insert_escape(esc));
|
||
assert!(app.should_handle_backtrack_esc(esc));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn side_conversations_reject_backtrack_esc_without_stealing_vim_insert_escape() {
|
||
let mut app = make_test_app().await;
|
||
let esc = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Esc, KeyModifiers::NONE);
|
||
|
||
app.chat_widget
|
||
.set_side_conversation_active(/*active*/ true);
|
||
assert!(app.chat_widget.composer_is_empty());
|
||
assert!(!app.should_handle_backtrack_esc(esc));
|
||
assert!(app.should_reject_side_backtrack_esc(esc));
|
||
|
||
app.chat_widget.toggle_vim_mode_and_notify();
|
||
app.chat_widget
|
||
.handle_key_event(crossterm::event::KeyEvent::new(
|
||
crossterm::event::KeyCode::Char('i'),
|
||
KeyModifiers::NONE,
|
||
));
|
||
|
||
assert!(app.chat_widget.should_handle_vim_insert_escape(esc));
|
||
assert!(!app.should_handle_backtrack_esc(esc));
|
||
assert!(!app.should_reject_side_backtrack_esc(esc));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn side_backtrack_rejection_reports_unavailable_message_snapshot() {
|
||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||
app.backtrack.primed = true;
|
||
|
||
app.reject_side_backtrack_esc();
|
||
|
||
assert!(!app.backtrack.primed);
|
||
let cell = match app_event_rx.try_recv() {
|
||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
||
};
|
||
let rendered = cell
|
||
.display_lines(/*width*/ 80)
|
||
.into_iter()
|
||
.map(|line| line.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert_app_snapshot!(
|
||
"side_backtrack_rejection_reports_unavailable_message",
|
||
rendered
|
||
);
|
||
}
|
||
async fn start_config_write_test_app_server(app: &App) -> Result<AppServerSession> {
|
||
Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await
|
||
}
|
||
|
||
#[path = "tests/active_reconnect_tests.rs"]
|
||
mod active_reconnect;
|
||
|
||
#[cfg(unix)]
|
||
#[path = "tests/navigation_reconnect_tests.rs"]
|
||
mod navigation_reconnect;
|
||
|
||
#[path = "tests/backend_event_tests.rs"]
|
||
mod backend_event_tests;
|