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