mirror of
https://github.com/openai/codex.git
synced 2026-11-03 00:21:31 +00:00
## Stack 1. [1 of 3] Support long raw TUI goal objectives - #27508 2. [2 of 3] Support long pasted text in TUI goals - #27509 3. **[3 of 3] Support images in TUI goals** - this PR ## Why The first two PRs make goal definitions resilient to long text, but `/goal` still dropped image inputs from the composer. That meant a user could attach images while defining a goal and the resulting goal continuation would not have any useful reference to those images. Goal state still persists only objective text, so image inputs need to become paths or URLs that the agent can read later. ## What Changed - Extends TUI `GoalDraft` with local image attachments and remote image URLs. - Copies local goal images through the app-server filesystem layer into the managed goal attachment directory, then rewrites active image placeholders to file references. - Appends unplaced local images and remote image URLs to the objective as referenced image files or URLs. - Preserves goal image metadata through live `/goal` submission and queued `/goal` dispatch. ## Verification - Added goal materialization coverage for local image files and remote image URLs. - Added/updated TUI slash-command coverage showing `/goal` drafts include attached images instead of dropping them. ## Manual Testing - Attached an image by bracketed-pasting its local path into a live `/goal` composer. The `[Image #1]` placeholder became a server-host `image-1.png` reference, copied bytes matched exactly, and no attachment was written under the TUI's local home. - Deleted an image placeholder before submitting a small goal and verified no image was copied. - Attached PNG and JPEG files to the same goal. Placeholder order was preserved as `image-1.png` and `image-2.jpg`, and both remote copies matched their source bytes. - Tried extensionless, malformed-extension, and extension/content-mismatched paths; the composer rejected them as image attachments before goal dispatch rather than creating misleading managed image files. - Combined a local image, a large pasted block, and enough raw text to exceed 4,000 characters. The remote attachment directory contained the image, paste sidecar, and `goal-objective.md`; all embedded references used server-host paths and both payloads matched their sources. - Submitted an image replacement while a goal was active, verified no image was copied before confirmation, then canceled and confirmed the attachment count was unchanged.
6195 lines
216 KiB
Rust
6195 lines
216 KiB
Rust
//! App-level orchestration tests for the TUI.
|
|
|
|
mod model_catalog;
|
|
mod session_summary;
|
|
mod startup;
|
|
|
|
use super::*;
|
|
use crate::app_backtrack::BacktrackSelection;
|
|
use crate::app_backtrack::BacktrackState;
|
|
use crate::app_backtrack::user_count;
|
|
|
|
use crate::chatwidget::ChatWidgetInit;
|
|
use crate::chatwidget::create_initial_user_message;
|
|
use crate::chatwidget::tests::helpers::render_bottom_popup;
|
|
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::diff_model::FileChange;
|
|
use crate::legacy_core::config::ConfigBuilder;
|
|
use crate::legacy_core::config::ConfigOverrides;
|
|
use crate::legacy_core::config::PermissionProfileSnapshot;
|
|
use crate::legacy_core::config::TerminalResizeReflowMaxRows;
|
|
use codex_app_server_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::ConfigWarningNotification;
|
|
use codex_app_server_protocol::FileChangeRequestApprovalParams;
|
|
use codex_app_server_protocol::FileUpdateChange;
|
|
use codex_app_server_protocol::ItemStartedNotification;
|
|
use codex_app_server_protocol::JSONRPCErrorError;
|
|
use codex_app_server_protocol::McpServerElicitationRequest;
|
|
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
|
use codex_app_server_protocol::McpServerStartupState;
|
|
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
|
|
use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext;
|
|
use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol;
|
|
use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyAmendment;
|
|
use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction;
|
|
use codex_app_server_protocol::NonSteerableTurnKind as AppServerNonSteerableTurnKind;
|
|
use codex_app_server_protocol::PatchChangeKind;
|
|
use codex_app_server_protocol::PermissionsRequestApprovalParams;
|
|
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
|
use codex_app_server_protocol::ServerNotification;
|
|
use codex_app_server_protocol::ServerRequest;
|
|
use codex_app_server_protocol::SessionSource;
|
|
use codex_app_server_protocol::Thread;
|
|
use codex_app_server_protocol::ThreadClosedNotification;
|
|
use codex_app_server_protocol::ThreadItem;
|
|
use codex_app_server_protocol::ThreadSettings;
|
|
use codex_app_server_protocol::ThreadSettingsUpdatedNotification;
|
|
use codex_app_server_protocol::ThreadStartedNotification;
|
|
use codex_app_server_protocol::ThreadTokenUsage;
|
|
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
|
|
use codex_app_server_protocol::TokenUsageBreakdown;
|
|
use codex_app_server_protocol::ToolRequestUserInputParams;
|
|
use codex_app_server_protocol::Turn;
|
|
use codex_app_server_protocol::TurnCompletedNotification;
|
|
use codex_app_server_protocol::TurnError as AppServerTurnError;
|
|
use codex_app_server_protocol::TurnStartedNotification;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use codex_app_server_protocol::UserInput;
|
|
use codex_app_server_protocol::UserInput as AppServerUserInput;
|
|
use codex_app_server_protocol::WarningNotification;
|
|
use codex_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::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS;
|
|
use codex_protocol::request_permissions::RequestPermissionProfile;
|
|
use codex_protocol::user_input::TextElement;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use crossterm::event::KeyModifiers;
|
|
use insta::assert_snapshot;
|
|
use pretty_assertions::assert_eq;
|
|
use ratatui::prelude::Line;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicBool;
|
|
use tempfile::tempdir;
|
|
use tokio::time;
|
|
|
|
macro_rules! assert_app_snapshot {
|
|
($name:expr, $value:expr $(,)?) => {
|
|
insta::with_settings!({snapshot_path => "../snapshots"}, {
|
|
assert_snapshot!($name, $value);
|
|
});
|
|
};
|
|
}
|
|
|
|
fn test_absolute_path(path: &str) -> AbsolutePathBuf {
|
|
AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path")
|
|
}
|
|
|
|
async fn next_thread_settings_updated(
|
|
app_server: &mut AppServerSession,
|
|
thread_id: ThreadId,
|
|
) -> ThreadSettingsUpdatedNotification {
|
|
for _ in 0..20 {
|
|
let event = time::timeout(
|
|
std::time::Duration::from_secs(/*secs*/ 2),
|
|
app_server.next_event(),
|
|
)
|
|
.await
|
|
.expect("app-server should emit an event")
|
|
.expect("app-server event stream should remain open");
|
|
if let codex_app_server_client::AppServerEvent::ServerNotification(
|
|
ServerNotification::ThreadSettingsUpdated(notification),
|
|
) = event
|
|
&& notification.thread_id == thread_id.to_string()
|
|
{
|
|
return notification;
|
|
}
|
|
}
|
|
panic!("expected ThreadSettingsUpdated for thread {thread_id}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn handle_mcp_inventory_result_respects_origin_thread() {
|
|
let mut app = make_test_app().await;
|
|
app.transcript_cells
|
|
.push(Arc::new(history_cell::new_mcp_inventory_loading(
|
|
/*animations_enabled*/ false,
|
|
)));
|
|
|
|
app.handle_mcp_inventory_result(
|
|
Ok(vec![McpServerStatus {
|
|
name: "docs".to_string(),
|
|
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 enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> {
|
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let approval_request =
|
|
exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None);
|
|
|
|
assert_eq!(
|
|
app.pending_app_server_requests
|
|
.note_server_request(&approval_request),
|
|
None
|
|
);
|
|
app.enqueue_primary_thread_request(approval_request).await?;
|
|
app.enqueue_primary_thread_session(
|
|
test_thread_session(thread_id, test_path_buf("/tmp/project")),
|
|
Vec::new(),
|
|
)
|
|
.await?;
|
|
|
|
let rx = app
|
|
.active_thread_rx
|
|
.as_mut()
|
|
.expect("primary thread receiver should be active");
|
|
let event = time::timeout(Duration::from_millis(50), rx.recv())
|
|
.await
|
|
.expect("timed out waiting for buffered approval event")
|
|
.expect("channel closed unexpectedly");
|
|
|
|
assert!(matches!(
|
|
&event,
|
|
ThreadBufferedEvent::Request(ServerRequest::CommandExecutionRequestApproval {
|
|
params,
|
|
..
|
|
}) if params.turn_id == "turn-1"
|
|
));
|
|
|
|
app.handle_thread_event_now(event);
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
|
|
while let Ok(app_event) = app_event_rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
thread_id: op_thread_id,
|
|
..
|
|
} = app_event
|
|
{
|
|
assert_eq!(op_thread_id, thread_id);
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
panic!("expected approval action to submit a thread-scoped op");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolved_buffered_approval_does_not_become_actionable_after_drain() -> Result<()> {
|
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let approval_request =
|
|
exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None);
|
|
|
|
app.enqueue_primary_thread_session(
|
|
test_thread_session(thread_id, test_path_buf("/tmp/project")),
|
|
Vec::new(),
|
|
)
|
|
.await?;
|
|
while app_event_rx.try_recv().is_ok() {}
|
|
|
|
assert_eq!(
|
|
app.pending_app_server_requests
|
|
.note_server_request(&approval_request),
|
|
None
|
|
);
|
|
app.enqueue_thread_request(thread_id, approval_request)
|
|
.await?;
|
|
|
|
let resolved = app
|
|
.pending_app_server_requests
|
|
.resolve_notification(&AppServerRequestId::Integer(1))
|
|
.expect("matching app-server request should resolve");
|
|
app.chat_widget.dismiss_app_server_request(&resolved);
|
|
while app_event_rx.try_recv().is_ok() {}
|
|
|
|
let rx = app
|
|
.active_thread_rx
|
|
.as_mut()
|
|
.expect("primary thread receiver should be active");
|
|
let event = time::timeout(Duration::from_millis(50), rx.recv())
|
|
.await
|
|
.expect("timed out waiting for buffered approval event")
|
|
.expect("channel closed unexpectedly");
|
|
|
|
assert!(matches!(
|
|
&event,
|
|
ThreadBufferedEvent::Request(ServerRequest::CommandExecutionRequestApproval {
|
|
params,
|
|
..
|
|
}) if params.turn_id == "turn-1"
|
|
));
|
|
|
|
app.handle_thread_event_now(event);
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
|
|
while let Ok(app_event) = app_event_rx.try_recv() {
|
|
assert!(
|
|
!matches!(app_event, AppEvent::SubmitThreadOp { .. }),
|
|
"resolved buffered approval should not become actionable"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_submit() -> Result<()> {
|
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let initial_prompt = "follow-up after replay".to_string();
|
|
let config = app.config.clone();
|
|
let model = get_model_offline_for_tests(config.model.as_deref());
|
|
app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit {
|
|
config,
|
|
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
|
app_event_tx: app.app_event_tx.clone(),
|
|
workspace_command_runner: None,
|
|
initial_user_message: create_initial_user_message(
|
|
Some(initial_prompt.clone()),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
),
|
|
enhanced_keys_supported: false,
|
|
has_chatgpt_account: false,
|
|
model_catalog: app.model_catalog.clone(),
|
|
feedback: codex_feedback::CodexFeedback::new(),
|
|
is_first_run: false,
|
|
status_account_display: None,
|
|
runtime_model_provider_base_url: None,
|
|
initial_plan_type: None,
|
|
model: Some(model),
|
|
startup_tooltip_override: None,
|
|
status_line_invalid_items_warned: app.status_line_invalid_items_warned.clone(),
|
|
terminal_title_invalid_items_warned: app.terminal_title_invalid_items_warned.clone(),
|
|
session_telemetry: app.session_telemetry.clone(),
|
|
});
|
|
|
|
app.enqueue_primary_thread_session(
|
|
test_thread_session(thread_id, test_path_buf("/tmp/project")),
|
|
vec![test_turn(
|
|
"turn-1",
|
|
TurnStatus::Completed,
|
|
vec![ThreadItem::UserMessage {
|
|
id: "user-1".to_string(),
|
|
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 submitted_items = None;
|
|
while let Ok(event) = app_event_rx.try_recv() {
|
|
match event {
|
|
AppEvent::InsertHistoryCell(cell) => {
|
|
let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80));
|
|
saw_replayed_answer |= transcript.contains("earlier prompt");
|
|
}
|
|
AppEvent::SubmitThreadOp {
|
|
thread_id: op_thread_id,
|
|
op: Op::UserTurn { items, .. },
|
|
} => {
|
|
assert_eq!(op_thread_id, thread_id);
|
|
submitted_items = Some(items);
|
|
}
|
|
AppEvent::CodexOp(Op::UserTurn { items, .. }) => {
|
|
submitted_items = Some(items);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
assert!(
|
|
saw_replayed_answer,
|
|
"expected replayed history before initial prompt submit"
|
|
);
|
|
assert_eq!(
|
|
submitted_items,
|
|
Some(vec![UserInput::Text {
|
|
text: initial_prompt,
|
|
text_elements: Vec::new(),
|
|
}])
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reset_thread_event_state_aborts_listener_tasks() {
|
|
struct NotifyOnDrop(Option<tokio::sync::oneshot::Sender<()>>);
|
|
|
|
impl Drop for NotifyOnDrop {
|
|
fn drop(&mut self) {
|
|
if let Some(tx) = self.0.take() {
|
|
let _ = tx.send(());
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
|
let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
|
|
let handle = tokio::spawn(async move {
|
|
let _notify_on_drop = NotifyOnDrop(Some(dropped_tx));
|
|
let _ = started_tx.send(());
|
|
std::future::pending::<()>().await;
|
|
});
|
|
app.thread_event_listener_tasks.insert(thread_id, handle);
|
|
started_rx
|
|
.await
|
|
.expect("listener task should report it started");
|
|
|
|
app.reset_thread_event_state();
|
|
|
|
assert_eq!(app.thread_event_listener_tasks.is_empty(), true);
|
|
time::timeout(Duration::from_millis(50), dropped_rx)
|
|
.await
|
|
.expect("timed out waiting for listener task abort")
|
|
.expect("listener task drop notification should succeed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn history_lookup_response_is_routed_to_requesting_thread() -> Result<()> {
|
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
|
|
app.lookup_message_history_entry(thread_id, /*offset*/ 0, /*log_id*/ 1)
|
|
.await?;
|
|
|
|
let app_event = tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv())
|
|
.await
|
|
.expect("history lookup should emit an app event")
|
|
.expect("app event channel should stay open");
|
|
|
|
let AppEvent::ThreadHistoryEntryResponse {
|
|
thread_id: routed_thread_id,
|
|
event,
|
|
} = app_event
|
|
else {
|
|
panic!("expected thread-routed history response");
|
|
};
|
|
assert_eq!(routed_thread_id, thread_id);
|
|
assert_eq!(event.offset, 0);
|
|
assert_eq!(event.log_id, 1);
|
|
assert!(event.entry.is_none());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enqueue_thread_event_does_not_block_when_channel_full() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
app.thread_event_channels
|
|
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
|
|
app.set_thread_active(thread_id, /*active*/ true).await;
|
|
|
|
let event = thread_closed_notification(thread_id);
|
|
|
|
app.enqueue_thread_notification(thread_id, event.clone())
|
|
.await?;
|
|
time::timeout(
|
|
Duration::from_millis(50),
|
|
app.enqueue_thread_notification(thread_id, event),
|
|
)
|
|
.await
|
|
.expect("enqueue_thread_notification blocked on a full channel")?;
|
|
|
|
let mut rx = app
|
|
.thread_event_channels
|
|
.get_mut(&thread_id)
|
|
.expect("missing thread channel")
|
|
.receiver
|
|
.take()
|
|
.expect("missing receiver");
|
|
|
|
time::timeout(Duration::from_millis(50), rx.recv())
|
|
.await
|
|
.expect("timed out waiting for first event")
|
|
.expect("channel closed unexpectedly");
|
|
time::timeout(Duration::from_millis(50), rx.recv())
|
|
.await
|
|
.expect("timed out waiting for second event")
|
|
.expect("channel closed unexpectedly");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_restores_draft_and_queued_input() {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
|
app.thread_event_channels.insert(
|
|
thread_id,
|
|
ThreadEventChannel::new_with_session(
|
|
THREAD_EVENT_CHANNEL_CAPACITY,
|
|
session.clone(),
|
|
Vec::new(),
|
|
),
|
|
);
|
|
app.activate_thread_channel(thread_id).await;
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
|
|
app.chat_widget
|
|
.apply_external_edit("draft prompt".to_string());
|
|
app.chat_widget.submit_user_message_with_mode(
|
|
"queued follow-up".to_string(),
|
|
CollaborationModeMask {
|
|
name: "Default".to_string(),
|
|
mode: None,
|
|
model: None,
|
|
reasoning_effort: None,
|
|
developer_instructions: None,
|
|
},
|
|
);
|
|
let expected_input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected thread input state");
|
|
|
|
app.store_active_thread_receiver().await;
|
|
|
|
let snapshot = {
|
|
let channel = app
|
|
.thread_event_channels
|
|
.get(&thread_id)
|
|
.expect("thread channel should exist");
|
|
let store = channel.store.lock().await;
|
|
assert_eq!(store.input_state, Some(expected_input_state));
|
|
store.snapshot()
|
|
};
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
|
|
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ true);
|
|
|
|
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt");
|
|
assert!(app.chat_widget.queued_user_message_texts().is_empty());
|
|
while let Ok(op) = new_op_rx.try_recv() {
|
|
assert!(
|
|
!matches!(op, Op::UserTurn { .. }),
|
|
"draft-only replay should not auto-submit queued input"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn active_turn_id_for_thread_uses_snapshot_turns() {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
|
app.thread_event_channels.insert(
|
|
thread_id,
|
|
ThreadEventChannel::new_with_session(
|
|
THREAD_EVENT_CHANNEL_CAPACITY,
|
|
session,
|
|
vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())],
|
|
),
|
|
);
|
|
|
|
assert_eq!(
|
|
app.active_turn_id_for_thread(thread_id).await,
|
|
Some("turn-1".to_string())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_turn_complete_submits_restored_queued_follow_up() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
app.chat_widget.handle_server_notification(
|
|
turn_started_notification(thread_id, "turn-1"),
|
|
/*replay_kind*/ None,
|
|
);
|
|
app.chat_widget.handle_server_notification(
|
|
agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"),
|
|
/*replay_kind*/ None,
|
|
);
|
|
app.chat_widget
|
|
.apply_external_edit("queued follow-up".to_string());
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected queued follow-up state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
while new_op_rx.try_recv().is_ok() {}
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session: None,
|
|
turns: Vec::new(),
|
|
events: vec![ThreadBufferedEvent::Notification(
|
|
turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed),
|
|
)],
|
|
input_state: Some(input_state),
|
|
},
|
|
/*resume_restored_queue*/ true,
|
|
);
|
|
|
|
match next_user_turn_op(&mut new_op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "queued follow-up".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected queued follow-up submission, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_only_thread_keeps_restored_queue_visible() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
app.chat_widget.handle_server_notification(
|
|
turn_started_notification(thread_id, "turn-1"),
|
|
/*replay_kind*/ None,
|
|
);
|
|
app.chat_widget.handle_server_notification(
|
|
agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"),
|
|
/*replay_kind*/ None,
|
|
);
|
|
app.chat_widget
|
|
.apply_external_edit("queued follow-up".to_string());
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected queued follow-up state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
while new_op_rx.try_recv().is_ok() {}
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session: None,
|
|
turns: Vec::new(),
|
|
events: vec![ThreadBufferedEvent::Notification(
|
|
turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed),
|
|
)],
|
|
input_state: Some(input_state),
|
|
},
|
|
/*resume_restored_queue*/ false,
|
|
);
|
|
|
|
assert_eq!(
|
|
app.chat_widget.queued_user_message_texts(),
|
|
vec!["queued follow-up".to_string()]
|
|
);
|
|
assert!(
|
|
new_op_rx.try_recv().is_err(),
|
|
"replay-only threads should not auto-submit restored queue"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_keeps_queue_when_running_state_only_comes_from_snapshot() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
app.chat_widget.handle_server_notification(
|
|
turn_started_notification(thread_id, "turn-1"),
|
|
/*replay_kind*/ None,
|
|
);
|
|
app.chat_widget.handle_server_notification(
|
|
agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"),
|
|
/*replay_kind*/ None,
|
|
);
|
|
app.chat_widget
|
|
.apply_external_edit("queued follow-up".to_string());
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected queued follow-up state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
while new_op_rx.try_recv().is_ok() {}
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session: None,
|
|
turns: Vec::new(),
|
|
events: vec![],
|
|
input_state: Some(input_state),
|
|
},
|
|
/*resume_restored_queue*/ true,
|
|
);
|
|
|
|
assert_eq!(
|
|
app.chat_widget.queued_user_message_texts(),
|
|
vec!["queued follow-up".to_string()]
|
|
);
|
|
assert!(
|
|
new_op_rx.try_recv().is_err(),
|
|
"restored queue should stay queued when replay did not prove the turn finished"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_in_progress_turn_restores_running_queue_state() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
app.chat_widget.handle_server_notification(
|
|
turn_started_notification(thread_id, "turn-1"),
|
|
/*replay_kind*/ None,
|
|
);
|
|
app.chat_widget.handle_server_notification(
|
|
agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"),
|
|
/*replay_kind*/ None,
|
|
);
|
|
app.chat_widget
|
|
.apply_external_edit("queued follow-up".to_string());
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected queued follow-up state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
while new_op_rx.try_recv().is_ok() {}
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session: None,
|
|
turns: vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())],
|
|
events: Vec::new(),
|
|
input_state: Some(input_state),
|
|
},
|
|
/*resume_restored_queue*/ true,
|
|
);
|
|
|
|
assert_eq!(
|
|
app.chat_widget.queued_user_message_texts(),
|
|
vec!["queued follow-up".to_string()]
|
|
);
|
|
assert!(
|
|
new_op_rx.try_recv().is_err(),
|
|
"restored queue should stay queued while replayed turn is still running"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_in_progress_turn_restores_running_state_without_input_state() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
|
let (chat_widget, _app_event_tx, _rx, _new_op_rx) = make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_thread_session(session);
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session: None,
|
|
turns: vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())],
|
|
events: Vec::new(),
|
|
input_state: None,
|
|
},
|
|
/*resume_restored_queue*/ false,
|
|
);
|
|
|
|
assert!(app.chat_widget.is_task_running_for_test());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_does_not_submit_queue_before_replay_catches_up() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
app.chat_widget.handle_server_notification(
|
|
turn_started_notification(thread_id, "turn-1"),
|
|
/*replay_kind*/ None,
|
|
);
|
|
app.chat_widget.handle_server_notification(
|
|
agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"),
|
|
/*replay_kind*/ None,
|
|
);
|
|
app.chat_widget
|
|
.apply_external_edit("queued follow-up".to_string());
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected queued follow-up state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
while new_op_rx.try_recv().is_ok() {}
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session: None,
|
|
turns: Vec::new(),
|
|
events: vec![
|
|
ThreadBufferedEvent::Notification(turn_completed_notification(
|
|
thread_id,
|
|
"turn-0",
|
|
TurnStatus::Completed,
|
|
)),
|
|
ThreadBufferedEvent::Notification(turn_started_notification(thread_id, "turn-1")),
|
|
],
|
|
input_state: Some(input_state),
|
|
},
|
|
/*resume_restored_queue*/ true,
|
|
);
|
|
|
|
assert!(
|
|
new_op_rx.try_recv().is_err(),
|
|
"queued follow-up should stay queued until the latest turn completes"
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.queued_user_message_texts(),
|
|
vec!["queued follow-up".to_string()]
|
|
);
|
|
|
|
app.chat_widget.handle_server_notification(
|
|
turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed),
|
|
/*replay_kind*/ None,
|
|
);
|
|
|
|
match next_user_turn_op(&mut new_op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "queued follow-up".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected queued follow-up submission, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_restores_pending_pastes_for_submit() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
|
app.thread_event_channels.insert(
|
|
thread_id,
|
|
ThreadEventChannel::new_with_session(
|
|
THREAD_EVENT_CHANNEL_CAPACITY,
|
|
session.clone(),
|
|
Vec::new(),
|
|
),
|
|
);
|
|
app.activate_thread_channel(thread_id).await;
|
|
app.chat_widget.handle_thread_session(session);
|
|
|
|
let large = "x".repeat(1005);
|
|
app.chat_widget.handle_paste(large.clone());
|
|
let expected_input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected thread input state");
|
|
|
|
app.store_active_thread_receiver().await;
|
|
|
|
let snapshot = {
|
|
let channel = app
|
|
.thread_event_channels
|
|
.get(&thread_id)
|
|
.expect("thread channel should exist");
|
|
let store = channel.store.lock().await;
|
|
assert_eq!(store.input_state, Some(expected_input_state));
|
|
store.snapshot()
|
|
};
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ true);
|
|
|
|
assert_eq!(app.chat_widget.composer_text_with_pending(), large);
|
|
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
match next_user_turn_op(&mut new_op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: large,
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected restored paste submission, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_restores_collaboration_mode_for_draft_submit() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
|
|
app.chat_widget
|
|
.set_collaboration_mask(CollaborationModeMask {
|
|
name: "Plan".to_string(),
|
|
mode: Some(ModeKind::Plan),
|
|
model: Some("gpt-restored".to_string()),
|
|
reasoning_effort: Some(Some(ReasoningEffortConfig::High)),
|
|
developer_instructions: None,
|
|
});
|
|
app.chat_widget
|
|
.apply_external_edit("draft prompt".to_string());
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected draft input state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::Low));
|
|
app.chat_widget
|
|
.set_collaboration_mask(CollaborationModeMask {
|
|
name: "Default".to_string(),
|
|
mode: Some(ModeKind::Default),
|
|
model: Some("gpt-replacement".to_string()),
|
|
reasoning_effort: Some(Some(ReasoningEffortConfig::Low)),
|
|
developer_instructions: None,
|
|
});
|
|
while new_op_rx.try_recv().is_ok() {}
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session: None,
|
|
turns: Vec::new(),
|
|
events: vec![],
|
|
input_state: Some(input_state),
|
|
},
|
|
/*resume_restored_queue*/ true,
|
|
);
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
match next_user_turn_op(&mut new_op_rx) {
|
|
Op::UserTurn {
|
|
items,
|
|
model,
|
|
effort,
|
|
collaboration_mode,
|
|
..
|
|
} => {
|
|
assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "draft prompt".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
);
|
|
assert_eq!(model, "gpt-restored".to_string());
|
|
assert_eq!(effort, Some(ReasoningEffortConfig::High));
|
|
assert_eq!(
|
|
collaboration_mode,
|
|
Some(CollaborationMode {
|
|
mode: ModeKind::Plan,
|
|
settings: Settings {
|
|
model: "gpt-restored".to_string(),
|
|
reasoning_effort: Some(ReasoningEffortConfig::High),
|
|
developer_instructions: None,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
other => panic!("expected restored draft submission, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_restores_collaboration_mode_without_input() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
|
|
app.chat_widget
|
|
.set_collaboration_mask(CollaborationModeMask {
|
|
name: "Plan".to_string(),
|
|
mode: Some(ModeKind::Plan),
|
|
model: Some("gpt-restored".to_string()),
|
|
reasoning_effort: Some(Some(ReasoningEffortConfig::High)),
|
|
developer_instructions: None,
|
|
});
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected collaboration-only input state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, _new_op_rx) = make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::Low));
|
|
app.chat_widget
|
|
.set_collaboration_mask(CollaborationModeMask {
|
|
name: "Default".to_string(),
|
|
mode: Some(ModeKind::Default),
|
|
model: Some("gpt-replacement".to_string()),
|
|
reasoning_effort: Some(Some(ReasoningEffortConfig::Low)),
|
|
developer_instructions: None,
|
|
});
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session: None,
|
|
turns: Vec::new(),
|
|
events: vec![],
|
|
input_state: Some(input_state),
|
|
},
|
|
/*resume_restored_queue*/ true,
|
|
);
|
|
|
|
assert_eq!(
|
|
app.chat_widget.active_collaboration_mode_kind(),
|
|
ModeKind::Plan
|
|
);
|
|
assert_eq!(app.chat_widget.current_model(), "gpt-restored");
|
|
assert_eq!(
|
|
app.chat_widget.current_reasoning_effort(),
|
|
Some(ReasoningEffortConfig::High)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_interrupted_turn_restores_queued_input_to_composer() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
app.chat_widget.handle_server_notification(
|
|
turn_started_notification(thread_id, "turn-1"),
|
|
/*replay_kind*/ None,
|
|
);
|
|
app.chat_widget.handle_server_notification(
|
|
agent_message_delta_notification(thread_id, "turn-1", "agent-1", "streaming"),
|
|
/*replay_kind*/ None,
|
|
);
|
|
app.chat_widget
|
|
.apply_external_edit("queued follow-up".to_string());
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected queued follow-up state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_thread_session(session.clone());
|
|
while new_op_rx.try_recv().is_ok() {}
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session: None,
|
|
turns: Vec::new(),
|
|
events: vec![ThreadBufferedEvent::Notification(
|
|
turn_completed_notification(thread_id, "turn-1", TurnStatus::Interrupted),
|
|
)],
|
|
input_state: Some(input_state),
|
|
},
|
|
/*resume_restored_queue*/ true,
|
|
);
|
|
|
|
assert_eq!(
|
|
app.chat_widget.composer_text_with_pending(),
|
|
"queued follow-up"
|
|
);
|
|
assert!(app.chat_widget.queued_user_message_texts().is_empty());
|
|
assert!(
|
|
new_op_rx.try_recv().is_err(),
|
|
"replayed interrupted turns should restore queued input for editing, not submit it"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn token_usage_update_refreshes_status_line_with_runtime_context_window() {
|
|
let mut app = make_test_app().await;
|
|
app.chat_widget.setup_status_line(
|
|
vec![crate::bottom_pane::StatusLineItem::ContextWindowSize],
|
|
/*use_theme_colors*/ true,
|
|
);
|
|
|
|
assert_eq!(app.chat_widget.status_line_text(), None);
|
|
|
|
app.handle_thread_event_now(ThreadBufferedEvent::Notification(token_usage_notification(
|
|
ThreadId::new(),
|
|
"turn-1",
|
|
Some(950_000),
|
|
)));
|
|
|
|
assert_eq!(
|
|
app.chat_widget.status_line_text(),
|
|
Some("950K window".into())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn collab_receiver_notification_caches_thread_without_app_server_read() {
|
|
let mut app = make_test_app().await;
|
|
let receiver_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
|
|
|
|
app.handle_thread_event_now(ThreadBufferedEvent::Notification(
|
|
ServerNotification::ItemStarted(ItemStartedNotification {
|
|
thread_id: ThreadId::new().to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
started_at_ms: 0,
|
|
item: ThreadItem::CollabAgentToolCall {
|
|
id: "wait-1".to_string(),
|
|
tool: codex_app_server_protocol::CollabAgentTool::Wait,
|
|
status: codex_app_server_protocol::CollabAgentToolCallStatus::InProgress,
|
|
sender_thread_id: ThreadId::new().to_string(),
|
|
receiver_thread_ids: vec![receiver_thread_id.to_string()],
|
|
prompt: None,
|
|
model: None,
|
|
reasoning_effort: None,
|
|
agents_states: HashMap::new(),
|
|
},
|
|
}),
|
|
));
|
|
|
|
assert_eq!(
|
|
app.agent_navigation.get(&receiver_thread_id),
|
|
Some(&AgentPickerThreadEntry {
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
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(
|
|
ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification {
|
|
thread_id: ThreadId::new().to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
completed_at_ms: 0,
|
|
item: ThreadItem::CollabAgentToolCall {
|
|
id: "send-1".to_string(),
|
|
tool: codex_app_server_protocol::CollabAgentTool::SendInput,
|
|
status: codex_app_server_protocol::CollabAgentToolCallStatus::Failed,
|
|
sender_thread_id: ThreadId::new().to_string(),
|
|
receiver_thread_ids: vec![receiver_thread_id.to_string()],
|
|
prompt: Some("hello".to_string()),
|
|
model: None,
|
|
reasoning_effort: None,
|
|
agents_states: HashMap::from([(
|
|
receiver_thread_id.to_string(),
|
|
codex_app_server_protocol::CollabAgentState {
|
|
status: codex_app_server_protocol::CollabAgentStatus::NotFound,
|
|
message: None,
|
|
},
|
|
)]),
|
|
},
|
|
}),
|
|
));
|
|
|
|
assert_eq!(app.agent_navigation.get(&receiver_thread_id), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> {
|
|
let mut app = Box::pin(make_test_app()).await;
|
|
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
|
app.chat_widget.config_ref(),
|
|
))
|
|
.await
|
|
.expect("embedded app server");
|
|
let thread_id = ThreadId::new();
|
|
app.thread_event_channels
|
|
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
|
|
|
|
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
|
|
|
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
|
|
assert_eq!(
|
|
app.agent_navigation.get(&thread_id),
|
|
Some(&AgentPickerThreadEntry {
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
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_clears_completed_path_backed_agent_running_state() -> 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 channel = ThreadEventChannel::new(/*capacity*/ 4);
|
|
{
|
|
let mut store = channel.store.lock().await;
|
|
store.push_notification(turn_started_notification(thread_id, "turn-1"));
|
|
store.push_notification(turn_completed_notification(
|
|
thread_id,
|
|
"turn-1",
|
|
TurnStatus::Completed,
|
|
));
|
|
}
|
|
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: false,
|
|
})
|
|
);
|
|
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 attach_live_thread_for_selection_rejects_empty_non_ephemeral_fallback_threads() -> Result<()> {
|
|
const WORKER_THREADS: usize = 1;
|
|
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
|
|
|
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
|
.worker_threads(WORKER_THREADS)
|
|
.thread_stack_size(TEST_STACK_SIZE_BYTES)
|
|
.enable_all()
|
|
.build()?;
|
|
|
|
runtime.block_on(async {
|
|
let config = {
|
|
let app = make_test_app().await;
|
|
app.chat_widget.config_ref().clone()
|
|
};
|
|
let mut app_server = crate::start_embedded_app_server_for_picker(&config)
|
|
.await
|
|
.expect("embedded app server");
|
|
let started = app_server.start_thread(&config).await?;
|
|
let thread_id = started.session.thread_id;
|
|
let mut app = make_test_app().await;
|
|
app.agent_navigation.upsert(
|
|
thread_id,
|
|
Some("Scout".to_string()),
|
|
Some("worker".to_string()),
|
|
/*is_closed*/ false,
|
|
);
|
|
|
|
let err = app
|
|
.attach_live_thread_for_selection(&mut app_server, thread_id)
|
|
.await
|
|
.expect_err("empty fallback should not attach as a blank replay-only thread");
|
|
|
|
assert_eq!(
|
|
err.to_string(),
|
|
format!("Agent thread {thread_id} is not yet available for replay or live attach.")
|
|
);
|
|
assert!(!app.thread_event_channels.contains_key(&thread_id));
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn attach_live_thread_for_selection_rejects_unmaterialized_fallback_threads() -> Result<()> {
|
|
const WORKER_THREADS: usize = 1;
|
|
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
|
|
|
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
|
.worker_threads(WORKER_THREADS)
|
|
.thread_stack_size(TEST_STACK_SIZE_BYTES)
|
|
.enable_all()
|
|
.build()?;
|
|
|
|
runtime.block_on(async {
|
|
let mut app = make_test_app().await;
|
|
let mut app_server =
|
|
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
|
let mut ephemeral_config = app.chat_widget.config_ref().clone();
|
|
ephemeral_config.ephemeral = true;
|
|
let started = app_server.start_thread(&ephemeral_config).await?;
|
|
let thread_id = started.session.thread_id;
|
|
app.agent_navigation.upsert(
|
|
thread_id,
|
|
Some("Scout".to_string()),
|
|
Some("worker".to_string()),
|
|
/*is_closed*/ false,
|
|
);
|
|
|
|
let err = app
|
|
.attach_live_thread_for_selection(&mut app_server, thread_id)
|
|
.await
|
|
.expect_err("ephemeral fallback should not attach as a blank live thread");
|
|
|
|
assert_eq!(
|
|
err.to_string(),
|
|
format!("Agent thread {thread_id} is not yet available for replay or live attach.")
|
|
);
|
|
assert!(!app.thread_event_channels.contains_key(&thread_id));
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn should_attach_live_thread_for_selection_skips_closed_metadata_only_threads() {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
app.agent_navigation.upsert(
|
|
thread_id,
|
|
Some("Ghost".to_string()),
|
|
Some("worker".to_string()),
|
|
/*is_closed*/ true,
|
|
);
|
|
|
|
assert!(!app.should_attach_live_thread_for_selection(thread_id));
|
|
|
|
app.agent_navigation.upsert(
|
|
thread_id,
|
|
Some("Ghost".to_string()),
|
|
Some("worker".to_string()),
|
|
/*is_closed*/ false,
|
|
);
|
|
assert!(app.should_attach_live_thread_for_selection(thread_id));
|
|
|
|
app.thread_event_channels
|
|
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
|
|
assert!(!app.should_attach_live_thread_for_selection(thread_id));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_agent_picker_thread_liveness_prunes_closed_metadata_only_threads() -> Result<()> {
|
|
let mut app = Box::pin(make_test_app()).await;
|
|
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
|
app.chat_widget.config_ref(),
|
|
))
|
|
.await
|
|
.expect("embedded app server");
|
|
let thread_id = ThreadId::new();
|
|
app.agent_navigation.upsert(
|
|
thread_id,
|
|
Some("Ghost".to_string()),
|
|
Some("worker".to_string()),
|
|
/*is_closed*/ false,
|
|
);
|
|
|
|
let is_available =
|
|
Box::pin(app.refresh_agent_picker_thread_liveness(&mut app_server, thread_id)).await;
|
|
|
|
assert!(!is_available);
|
|
assert_eq!(app.agent_navigation.get(&thread_id), None);
|
|
assert!(!app.thread_event_channels.contains_key(&thread_id));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> {
|
|
let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
|
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
|
app.chat_widget.config_ref(),
|
|
))
|
|
.await
|
|
.expect("embedded app server");
|
|
let _ = app.config.features.disable(Feature::Collab);
|
|
|
|
Box::pin(app.open_agent_picker(&mut app_server)).await;
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
assert_matches!(
|
|
app_event_rx.try_recv(),
|
|
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)]
|
|
);
|
|
let cell = match app_event_rx.try_recv() {
|
|
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
|
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
|
};
|
|
let rendered = cell
|
|
.display_lines(/*width*/ 120)
|
|
.into_iter()
|
|
.map(|line| line.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(rendered.contains("Subagents will be enabled in the next session."));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_memory_settings_persists_and_updates_widget_config() -> Result<()> {
|
|
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
|
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
|
|
|
Box::pin(app.update_memory_settings_with_app_server(
|
|
&mut app_server,
|
|
/*use_memories*/ false,
|
|
/*generate_memories*/ false,
|
|
))
|
|
.await;
|
|
|
|
assert!(!app.config.memories.use_memories);
|
|
assert!(!app.config.memories.generate_memories);
|
|
assert!(!app.chat_widget.config_ref().memories.use_memories);
|
|
assert!(!app.chat_widget.config_ref().memories.generate_memories);
|
|
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
let config_value = toml::from_str::<TomlValue>(&config)?;
|
|
let memories = config_value
|
|
.as_table()
|
|
.and_then(|table| table.get("memories"))
|
|
.and_then(TomlValue::as_table)
|
|
.expect("memories table should exist");
|
|
assert_eq!(
|
|
memories.get("use_memories"),
|
|
Some(&TomlValue::Boolean(false))
|
|
);
|
|
assert_eq!(
|
|
memories.get("generate_memories"),
|
|
Some(&TomlValue::Boolean(false))
|
|
);
|
|
assert!(
|
|
!memories.contains_key("disable_on_external_context")
|
|
&& !memories.contains_key("no_memories_if_mcp_or_web_search"),
|
|
"the TUI menu should not write the external-context memory setting"
|
|
);
|
|
app_server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> {
|
|
const WORKER_THREADS: usize = 1;
|
|
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
|
|
|
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
|
.worker_threads(WORKER_THREADS)
|
|
.thread_stack_size(TEST_STACK_SIZE_BYTES)
|
|
.enable_all()
|
|
.build()?;
|
|
|
|
runtime.block_on(async {
|
|
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
|
app.config.sqlite_home = codex_home.path().to_path_buf();
|
|
// Seed the previous setting so this test exercises the thread-mode update path.
|
|
app.config.memories.generate_memories = true;
|
|
|
|
let mut app_server =
|
|
Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
|
let started = app_server.start_thread(&app.config).await?;
|
|
let thread_id = started.session.thread_id;
|
|
app.active_thread_id = Some(thread_id);
|
|
|
|
Box::pin(app.update_memory_settings_with_app_server(
|
|
&mut app_server,
|
|
/*use_memories*/ true,
|
|
/*generate_memories*/ false,
|
|
))
|
|
.await;
|
|
|
|
let state_db = codex_state::StateRuntime::init(
|
|
codex_home.path().to_path_buf(),
|
|
app.config.model_provider_id.clone(),
|
|
)
|
|
.await
|
|
.expect("state db should initialize");
|
|
let memory_mode = state_db
|
|
.get_thread_memory_mode(thread_id)
|
|
.await
|
|
.expect("thread memory mode should be readable");
|
|
assert_eq!(memory_mode.as_deref(), Some("disabled"));
|
|
|
|
app_server.shutdown().await?;
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reset_memories_clears_local_memory_directories() -> Result<()> {
|
|
Box::pin(async {
|
|
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
|
app.config.sqlite_home = codex_home.path().to_path_buf();
|
|
|
|
let memory_root = codex_home.path().join("memories");
|
|
let extensions_root = memory_root.join("extensions");
|
|
std::fs::create_dir_all(memory_root.join("rollout_summaries"))?;
|
|
std::fs::create_dir_all(&extensions_root)?;
|
|
std::fs::write(memory_root.join("MEMORY.md"), "stale memory\n")?;
|
|
std::fs::write(
|
|
memory_root.join("rollout_summaries").join("stale.md"),
|
|
"stale summary\n",
|
|
)?;
|
|
std::fs::write(extensions_root.join("stale.txt"), "stale extension\n")?;
|
|
|
|
let mut app_server =
|
|
Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
|
|
|
Box::pin(app.reset_memories_with_app_server(&mut app_server)).await;
|
|
|
|
assert_eq!(std::fs::read_dir(&memory_root)?.count(), 0);
|
|
|
|
app_server.shutdown().await?;
|
|
Ok(())
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apply_permission_profile_selection_preserves_loader_overrides() -> Result<()> {
|
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let codex_home = tempdir()?;
|
|
let selected_config = codex_home.path().join("work.config.toml");
|
|
std::fs::write(
|
|
&selected_config,
|
|
r#"
|
|
default_permissions = "locked-down"
|
|
|
|
[permissions.locked-down.filesystem]
|
|
":minimal" = "read"
|
|
"#,
|
|
)?;
|
|
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
|
app.loader_overrides.user_config_path = Some(selected_config.abs());
|
|
app.harness_overrides.sandbox_mode = Some(SandboxMode::WorkspaceWrite);
|
|
app.harness_overrides.permission_profile = Some(PermissionProfile::workspace_write());
|
|
|
|
assert!(
|
|
app.apply_permission_profile_selection(PermissionProfileSelection {
|
|
profile_id: "locked-down".to_string(),
|
|
approval_policy: None,
|
|
approvals_reviewer: None,
|
|
display_label: "locked-down".to_string(),
|
|
})
|
|
.await
|
|
);
|
|
|
|
assert_eq!(
|
|
app.config
|
|
.permissions
|
|
.active_permission_profile()
|
|
.as_ref()
|
|
.map(|profile| profile.id.as_str()),
|
|
Some("locked-down")
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget
|
|
.config_ref()
|
|
.permissions
|
|
.active_permission_profile()
|
|
.as_ref()
|
|
.map(|profile| profile.id.as_str()),
|
|
Some("locked-down")
|
|
);
|
|
assert_eq!(
|
|
app.runtime_permission_profile_override,
|
|
Some(RuntimePermissionProfileOverride::from_config(&app.config))
|
|
);
|
|
let op = match app_event_rx.try_recv() {
|
|
Ok(AppEvent::CodexOp(op)) => op,
|
|
other => panic!("expected CodexOp event, got {other:?}"),
|
|
};
|
|
assert_eq!(
|
|
op,
|
|
Op::OverrideTurnContext {
|
|
cwd: None,
|
|
approval_policy: None,
|
|
approvals_reviewer: None,
|
|
permission_profile: Some(app.config.permissions.permission_profile().clone()),
|
|
active_permission_profile: app.config.permissions.active_permission_profile(),
|
|
windows_sandbox_level: None,
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
service_tier: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
}
|
|
);
|
|
let cell = match app_event_rx.try_recv() {
|
|
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
|
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
|
};
|
|
let rendered = cell
|
|
.display_lines(/*width*/ 120)
|
|
.into_iter()
|
|
.map(|line| line.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(rendered.contains("Permissions updated to locked-down"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result<()> {
|
|
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
|
let auto_review = auto_review_mode();
|
|
let mut app_server = start_config_write_test_app_server(&app).await?;
|
|
|
|
app.update_feature_flags(&mut app_server, vec![(Feature::GuardianApproval, true)])
|
|
.await;
|
|
|
|
assert!(app.config.features.enabled(Feature::GuardianApproval));
|
|
assert!(
|
|
app.chat_widget
|
|
.config_ref()
|
|
.features
|
|
.enabled(Feature::GuardianApproval)
|
|
);
|
|
assert_eq!(
|
|
app.config.approvals_reviewer,
|
|
auto_review.approvals_reviewer
|
|
);
|
|
assert_eq!(
|
|
AskForApproval::from(app.config.permissions.approval_policy.value()),
|
|
auto_review.approval_policy
|
|
);
|
|
assert_eq!(
|
|
AskForApproval::from(
|
|
app.chat_widget
|
|
.config_ref()
|
|
.permissions
|
|
.approval_policy
|
|
.value(),
|
|
),
|
|
auto_review.approval_policy
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget
|
|
.config_ref()
|
|
.permissions
|
|
.permission_profile(),
|
|
&auto_review.permission_profile()
|
|
);
|
|
assert_eq!(
|
|
app.config.permissions.active_permission_profile(),
|
|
Some(auto_review.active_permission_profile.clone())
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget
|
|
.config_ref()
|
|
.permissions
|
|
.active_permission_profile(),
|
|
Some(auto_review.active_permission_profile.clone())
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.config_ref().approvals_reviewer,
|
|
auto_review.approvals_reviewer
|
|
);
|
|
assert_eq!(app.runtime_approval_policy_override, None);
|
|
assert_eq!(
|
|
app.runtime_permission_profile_override,
|
|
Some(RuntimePermissionProfileOverride::from_config(&app.config))
|
|
);
|
|
assert_eq!(
|
|
op_rx.try_recv(),
|
|
Ok(Op::OverrideTurnContext {
|
|
cwd: None,
|
|
approval_policy: Some(auto_review.approval_policy),
|
|
approvals_reviewer: Some(auto_review.approvals_reviewer),
|
|
permission_profile: Some(auto_review.permission_profile()),
|
|
active_permission_profile: Some(auto_review.active_permission_profile.clone()),
|
|
windows_sandbox_level: None,
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
service_tier: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
})
|
|
);
|
|
let cell = match app_event_rx.try_recv() {
|
|
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
|
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
|
};
|
|
let rendered = cell
|
|
.display_lines(/*width*/ 120)
|
|
.into_iter()
|
|
.map(|line| line.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(rendered.contains("Permissions updated to 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(ServerNotification::Warning(
|
|
WarningNotification {
|
|
thread_id: Some(thread_id.to_string()),
|
|
message: stale_warning.to_string(),
|
|
},
|
|
)),
|
|
ThreadBufferedEvent::Request(exec_approval_request(
|
|
thread_id,
|
|
"turn-approval",
|
|
"call-approval",
|
|
/*approval_id*/ None,
|
|
)),
|
|
],
|
|
input_state: None,
|
|
},
|
|
/*resume_restored_queue*/ false,
|
|
);
|
|
|
|
assert_eq!(app.chat_widget.has_active_view(), true);
|
|
|
|
let mut replayed_history = String::new();
|
|
while let Ok(event) = app_event_rx.try_recv() {
|
|
if let AppEvent::InsertHistoryCell(cell) = event {
|
|
replayed_history.push_str(&lines_to_single_string(
|
|
&cell.transcript_lines(/*width*/ 80),
|
|
));
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
replayed_history.is_empty(),
|
|
"expected pending approval replay to suppress session notices, got {replayed_history:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn side_defers_subagent_approval_overlay_until_side_exits() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let main_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000011").expect("valid thread");
|
|
let side_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000022").expect("valid thread");
|
|
let agent_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000033").expect("valid thread");
|
|
|
|
app.primary_thread_id = Some(main_thread_id);
|
|
app.active_thread_id = Some(side_thread_id);
|
|
app.side_threads
|
|
.insert(side_thread_id, SideThreadState::new(main_thread_id));
|
|
app.thread_event_channels.insert(
|
|
agent_thread_id,
|
|
ThreadEventChannel::new_with_session(
|
|
/*capacity*/ 4,
|
|
ThreadSessionState {
|
|
approval_policy: AskForApproval::OnRequest,
|
|
permission_profile: PermissionProfile::workspace_write(),
|
|
rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")),
|
|
..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent"))
|
|
},
|
|
Vec::new(),
|
|
),
|
|
);
|
|
app.agent_navigation.upsert(
|
|
agent_thread_id,
|
|
Some("Robie".to_string()),
|
|
Some("explorer".to_string()),
|
|
/*is_closed*/ false,
|
|
);
|
|
|
|
app.enqueue_thread_request(
|
|
agent_thread_id,
|
|
exec_approval_request(
|
|
agent_thread_id,
|
|
"turn-approval",
|
|
"call-approval",
|
|
/*approval_id*/ None,
|
|
),
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(app.chat_widget.has_active_view(), false);
|
|
assert_eq!(
|
|
app.chat_widget.pending_thread_approvals(),
|
|
&["Robie [explorer]".to_string()]
|
|
);
|
|
|
|
app.side_threads.remove(&side_thread_id);
|
|
app.active_thread_id = Some(main_thread_id);
|
|
app.surface_pending_inactive_thread_interactive_requests()
|
|
.await;
|
|
|
|
assert_eq!(app.chat_widget.has_active_view(), true);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inactive_thread_exec_approval_preserves_context() {
|
|
let app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
let mut request = exec_approval_request(
|
|
thread_id,
|
|
"turn-approval",
|
|
"call-approval",
|
|
/*approval_id*/ None,
|
|
);
|
|
let ServerRequest::CommandExecutionRequestApproval { params, .. } = &mut request else {
|
|
panic!("expected exec approval request");
|
|
};
|
|
params.network_approval_context = Some(AppServerNetworkApprovalContext {
|
|
host: "example.com".to_string(),
|
|
protocol: AppServerNetworkApprovalProtocol::Socks5Tcp,
|
|
});
|
|
params.additional_permissions = Some(AdditionalPermissionProfile {
|
|
network: Some(AdditionalNetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
file_system: Some(AdditionalFileSystemPermissions {
|
|
read: Some(vec![test_absolute_path("/tmp/read-only")]),
|
|
write: Some(vec![test_absolute_path("/tmp/write")]),
|
|
glob_scan_max_depth: None,
|
|
entries: None,
|
|
}),
|
|
});
|
|
params.proposed_network_policy_amendments = Some(vec![AppServerNetworkPolicyAmendment {
|
|
host: "example.com".to_string(),
|
|
action: AppServerNetworkPolicyRuleAction::Allow,
|
|
}]);
|
|
|
|
let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec {
|
|
available_decisions,
|
|
network_approval_context,
|
|
additional_permissions,
|
|
..
|
|
})) = app
|
|
.interactive_request_for_thread_request(thread_id, &request)
|
|
.await
|
|
else {
|
|
panic!("expected exec approval request");
|
|
};
|
|
|
|
assert_eq!(
|
|
network_approval_context,
|
|
Some(AppServerNetworkApprovalContext {
|
|
host: "example.com".to_string(),
|
|
protocol: AppServerNetworkApprovalProtocol::Socks5Tcp,
|
|
})
|
|
);
|
|
assert_eq!(
|
|
additional_permissions,
|
|
Some(AdditionalPermissionProfile {
|
|
network: Some(AdditionalNetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
file_system: Some(AdditionalFileSystemPermissions {
|
|
read: Some(vec![test_absolute_path("/tmp/read-only")]),
|
|
write: Some(vec![test_absolute_path("/tmp/write")]),
|
|
glob_scan_max_depth: None,
|
|
entries: None,
|
|
}),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
available_decisions,
|
|
vec![
|
|
codex_app_server_protocol::CommandExecutionApprovalDecision::Accept,
|
|
codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession,
|
|
codex_app_server_protocol::CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment {
|
|
network_policy_amendment: AppServerNetworkPolicyAmendment {
|
|
host: "example.com".to_string(),
|
|
action: AppServerNetworkPolicyRuleAction::Allow,
|
|
},
|
|
},
|
|
codex_app_server_protocol::CommandExecutionApprovalDecision::Cancel,
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inactive_thread_exec_approval_splits_shell_wrapped_command() {
|
|
let app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
let script = r#"python3 -c 'print("Hello, world!")'"#;
|
|
let mut request = exec_approval_request(
|
|
thread_id,
|
|
"turn-approval",
|
|
"call-approval",
|
|
/*approval_id*/ None,
|
|
);
|
|
let ServerRequest::CommandExecutionRequestApproval { params, .. } = &mut request else {
|
|
panic!("expected exec approval request");
|
|
};
|
|
params.command =
|
|
Some(shlex::try_join(["/bin/zsh", "-lc", script]).expect("round-trippable shell wrapper"));
|
|
|
|
let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec { command, .. })) = app
|
|
.interactive_request_for_thread_request(thread_id, &request)
|
|
.await
|
|
else {
|
|
panic!("expected exec approval request");
|
|
};
|
|
|
|
assert_eq!(
|
|
command,
|
|
vec![
|
|
"/bin/zsh".to_string(),
|
|
"-lc".to_string(),
|
|
script.to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inactive_thread_file_change_approval_recovers_buffered_changes() {
|
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
app.enqueue_thread_notification(
|
|
thread_id,
|
|
ServerNotification::ItemStarted(ItemStartedNotification {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id: "turn-approval".to_string(),
|
|
started_at_ms: 0,
|
|
item: ThreadItem::FileChange {
|
|
id: "patch-approval".to_string(),
|
|
changes: vec![FileUpdateChange {
|
|
path: "README.md".to_string(),
|
|
kind: PatchChangeKind::Add,
|
|
diff: "hello\n".to_string(),
|
|
}],
|
|
status: codex_app_server_protocol::PatchApplyStatus::InProgress,
|
|
},
|
|
}),
|
|
)
|
|
.await
|
|
.expect("enqueue file change item");
|
|
|
|
let request = ServerRequest::FileChangeRequestApproval {
|
|
request_id: AppServerRequestId::Integer(9),
|
|
params: FileChangeRequestApprovalParams {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id: "turn-approval".to_string(),
|
|
item_id: "patch-approval".to_string(),
|
|
started_at_ms: 0,
|
|
reason: Some("command failed; retry without sandbox?".to_string()),
|
|
grant_root: None,
|
|
},
|
|
};
|
|
|
|
let request = app
|
|
.interactive_request_for_thread_request(thread_id, &request)
|
|
.await
|
|
.expect("expected file change approval request");
|
|
|
|
let ThreadInteractiveRequest::Approval(ApprovalRequest::ApplyPatch {
|
|
changes, reason, ..
|
|
}) = &request
|
|
else {
|
|
panic!("expected apply-patch approval request");
|
|
};
|
|
assert_eq!(
|
|
changes,
|
|
&HashMap::from([(
|
|
PathBuf::from("README.md"),
|
|
FileChange::Add {
|
|
content: "hello\n".to_string(),
|
|
},
|
|
)])
|
|
);
|
|
assert_eq!(
|
|
reason,
|
|
&Some("command failed; retry without sandbox?".to_string())
|
|
);
|
|
|
|
app.push_thread_interactive_request(request);
|
|
let cell = match app_event_rx.try_recv() {
|
|
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
|
other => panic!("expected patch preview history cell, saw {other:?}"),
|
|
};
|
|
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
|
|
assert!(rendered.contains("• Added README.md (+1 -0)"));
|
|
assert!(rendered.contains("1 +hello"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inactive_thread_permissions_approval_preserves_file_system_permissions() {
|
|
let app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
let request = ServerRequest::PermissionsRequestApproval {
|
|
request_id: AppServerRequestId::Integer(7),
|
|
params: PermissionsRequestApprovalParams {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id: "turn-approval".to_string(),
|
|
item_id: "call-approval".to_string(),
|
|
environment_id: Some("remote".to_string()),
|
|
started_at_ms: 0,
|
|
cwd: test_absolute_path("/tmp"),
|
|
reason: Some("Need access to .git".to_string()),
|
|
permissions: codex_app_server_protocol::RequestPermissionProfile {
|
|
network: Some(AdditionalNetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
file_system: Some(AdditionalFileSystemPermissions {
|
|
read: Some(vec![test_absolute_path("/tmp/read-only")]),
|
|
write: Some(vec![test_absolute_path("/tmp/write")]),
|
|
glob_scan_max_depth: None,
|
|
entries: None,
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
|
|
let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Permissions {
|
|
environment_id,
|
|
permissions,
|
|
..
|
|
})) = app
|
|
.interactive_request_for_thread_request(thread_id, &request)
|
|
.await
|
|
else {
|
|
panic!("expected permissions approval request");
|
|
};
|
|
|
|
assert_eq!(environment_id.as_deref(), Some("remote"));
|
|
assert_eq!(
|
|
permissions,
|
|
RequestPermissionProfile {
|
|
network: Some(NetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
file_system: Some(FileSystemPermissions::from_read_write_roots(
|
|
Some(vec![test_absolute_path("/tmp/read-only")]),
|
|
Some(vec![test_absolute_path("/tmp/write")]),
|
|
)),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inactive_thread_url_elicitation_routes_to_app_link() {
|
|
let app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
let request = ServerRequest::McpServerElicitationRequest {
|
|
request_id: AppServerRequestId::Integer(9),
|
|
params: McpServerElicitationRequestParams {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id: Some("turn-auth".to_string()),
|
|
server_name: "payments".to_string(),
|
|
request: McpServerElicitationRequest::Url {
|
|
meta: None,
|
|
message: "Review the payment details to continue.".to_string(),
|
|
url: "https://payments.example/checkout/123".to_string(),
|
|
elicitation_id: "payment-123".to_string(),
|
|
},
|
|
},
|
|
};
|
|
|
|
let Some(ThreadInteractiveRequest::AppLink(params)) = app
|
|
.interactive_request_for_thread_request(thread_id, &request)
|
|
.await
|
|
else {
|
|
panic!("expected app link request");
|
|
};
|
|
|
|
assert_eq!(params.title, "Action required");
|
|
assert_eq!(params.description, Some("Server: payments".to_string()));
|
|
assert_eq!(params.url, "https://payments.example/checkout/123");
|
|
assert_eq!(
|
|
params.elicitation_target,
|
|
Some(crate::bottom_pane::AppLinkElicitationTarget {
|
|
thread_id,
|
|
server_name: "payments".to_string(),
|
|
request_id: AppServerRequestId::Integer(9),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inactive_thread_invalid_url_elicitation_is_declined() {
|
|
let (app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let request = ServerRequest::McpServerElicitationRequest {
|
|
request_id: AppServerRequestId::Integer(10),
|
|
params: McpServerElicitationRequestParams {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id: Some("turn-auth".to_string()),
|
|
server_name: "payments".to_string(),
|
|
request: McpServerElicitationRequest::Url {
|
|
meta: None,
|
|
message: "Review the payment details to continue.".to_string(),
|
|
url: "http://payments.example/checkout/123".to_string(),
|
|
elicitation_id: "payment-123".to_string(),
|
|
},
|
|
},
|
|
};
|
|
|
|
assert!(
|
|
app.interactive_request_for_thread_request(thread_id, &request)
|
|
.await
|
|
.is_none()
|
|
);
|
|
assert_matches!(
|
|
app_event_rx.try_recv(),
|
|
Ok(AppEvent::SubmitThreadOp {
|
|
thread_id: op_thread_id,
|
|
op: Op::ResolveElicitation {
|
|
server_name,
|
|
request_id: AppServerRequestId::Integer(10),
|
|
decision: codex_app_server_protocol::McpServerElicitationAction::Decline,
|
|
content: None,
|
|
meta: None,
|
|
},
|
|
}) if op_thread_id == thread_id && server_name == "payments"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inactive_thread_approval_badge_clears_after_turn_completion_notification() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let main_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread");
|
|
let agent_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000202").expect("valid thread");
|
|
|
|
app.primary_thread_id = Some(main_thread_id);
|
|
app.active_thread_id = Some(main_thread_id);
|
|
app.thread_event_channels
|
|
.insert(main_thread_id, ThreadEventChannel::new(/*capacity*/ 1));
|
|
app.thread_event_channels.insert(
|
|
agent_thread_id,
|
|
ThreadEventChannel::new_with_session(
|
|
/*capacity*/ 4,
|
|
ThreadSessionState {
|
|
approval_policy: AskForApproval::OnRequest,
|
|
permission_profile: PermissionProfile::workspace_write(),
|
|
rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")),
|
|
..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent"))
|
|
},
|
|
Vec::new(),
|
|
),
|
|
);
|
|
app.agent_navigation.upsert(
|
|
agent_thread_id,
|
|
Some("Robie".to_string()),
|
|
Some("explorer".to_string()),
|
|
/*is_closed*/ false,
|
|
);
|
|
|
|
app.enqueue_thread_request(
|
|
agent_thread_id,
|
|
exec_approval_request(
|
|
agent_thread_id,
|
|
"turn-approval",
|
|
"call-approval",
|
|
/*approval_id*/ None,
|
|
),
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
app.chat_widget.pending_thread_approvals(),
|
|
&["Robie [explorer]".to_string()]
|
|
);
|
|
|
|
app.enqueue_thread_notification(
|
|
agent_thread_id,
|
|
turn_completed_notification(agent_thread_id, "turn-approval", TurnStatus::Completed),
|
|
)
|
|
.await?;
|
|
|
|
assert!(
|
|
app.chat_widget.pending_thread_approvals().is_empty(),
|
|
"turn completion should clear inactive-thread approval badge immediately"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inactive_thread_started_notification_initializes_replay_session() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let temp_dir = tempdir()?;
|
|
let main_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread");
|
|
let agent_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000202").expect("valid thread");
|
|
let primary_cwd = test_path_buf("/tmp/main").abs();
|
|
let shared_root = test_path_buf("/tmp/shared").abs();
|
|
let primary_session = ThreadSessionState {
|
|
approval_policy: AskForApproval::OnRequest,
|
|
permission_profile: PermissionProfile::workspace_write(),
|
|
runtime_workspace_roots: vec![primary_cwd.clone(), shared_root.clone()],
|
|
..test_thread_session(main_thread_id, primary_cwd.to_path_buf())
|
|
};
|
|
|
|
app.primary_thread_id = Some(main_thread_id);
|
|
app.active_thread_id = Some(main_thread_id);
|
|
app.primary_session_configured = Some(primary_session.clone());
|
|
app.thread_event_channels.insert(
|
|
main_thread_id,
|
|
ThreadEventChannel::new_with_session(
|
|
/*capacity*/ 4,
|
|
primary_session.clone(),
|
|
Vec::new(),
|
|
),
|
|
);
|
|
|
|
let rollout_path = temp_dir.path().join("agent-rollout.jsonl");
|
|
let rollout = serde_json::json!({
|
|
"timestamp": "t0",
|
|
"type": "turn_context",
|
|
"payload": {
|
|
"cwd": test_path_buf("/tmp/agent"),
|
|
"model": "gpt-agent",
|
|
},
|
|
});
|
|
std::fs::write(
|
|
&rollout_path,
|
|
format!("{}\n", serde_json::to_string(&rollout)?),
|
|
)?;
|
|
app.enqueue_thread_notification(
|
|
agent_thread_id,
|
|
ServerNotification::ThreadStarted(ThreadStartedNotification {
|
|
thread: Thread {
|
|
id: agent_thread_id.to_string(),
|
|
session_id: agent_thread_id.to_string(),
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
preview: "agent thread".to_string(),
|
|
ephemeral: false,
|
|
model_provider: "agent-provider".to_string(),
|
|
created_at: 1,
|
|
updated_at: 2,
|
|
status: codex_app_server_protocol::ThreadStatus::Idle,
|
|
path: Some(rollout_path.clone()),
|
|
cwd: test_path_buf("/tmp/agent").abs(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: codex_app_server_protocol::SessionSource::Unknown,
|
|
thread_source: None,
|
|
agent_nickname: Some("Robie".to_string()),
|
|
agent_role: Some("explorer".to_string()),
|
|
git_info: None,
|
|
name: Some("agent thread".to_string()),
|
|
turns: Vec::new(),
|
|
},
|
|
}),
|
|
)
|
|
.await?;
|
|
|
|
let store = app
|
|
.thread_event_channels
|
|
.get(&agent_thread_id)
|
|
.expect("agent thread channel")
|
|
.store
|
|
.lock()
|
|
.await;
|
|
let session = store.session.clone().expect("inferred session");
|
|
drop(store);
|
|
|
|
assert_eq!(session.thread_id, agent_thread_id);
|
|
assert_eq!(session.thread_name, Some("agent thread".to_string()));
|
|
assert_eq!(session.model, "gpt-agent");
|
|
assert_eq!(session.model_provider_id, "agent-provider");
|
|
assert_eq!(session.approval_policy, primary_session.approval_policy);
|
|
assert_eq!(session.cwd.as_path(), test_path_buf("/tmp/agent").as_path());
|
|
assert_eq!(
|
|
session.runtime_workspace_roots,
|
|
vec![test_path_buf("/tmp/agent").abs(), shared_root]
|
|
);
|
|
assert_eq!(session.rollout_path, Some(rollout_path));
|
|
assert_eq!(
|
|
app.agent_navigation.get(&agent_thread_id),
|
|
Some(&AgentPickerThreadEntry {
|
|
agent_nickname: Some("Robie".to_string()),
|
|
agent_role: Some("explorer".to_string()),
|
|
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 {
|
|
id: agent_thread_id.to_string(),
|
|
session_id: agent_thread_id.to_string(),
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
preview: "agent thread".to_string(),
|
|
ephemeral: false,
|
|
model_provider: "agent-provider".to_string(),
|
|
created_at: 1,
|
|
updated_at: 2,
|
|
status: codex_app_server_protocol::ThreadStatus::Idle,
|
|
path: None,
|
|
cwd: test_path_buf("/tmp/agent").abs(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: codex_app_server_protocol::SessionSource::Unknown,
|
|
thread_source: None,
|
|
agent_nickname: Some("Robie".to_string()),
|
|
agent_role: Some("explorer".to_string()),
|
|
git_info: None,
|
|
name: Some("agent thread".to_string()),
|
|
turns: Vec::new(),
|
|
},
|
|
}),
|
|
)
|
|
.await?;
|
|
|
|
let store = app
|
|
.thread_event_channels
|
|
.get(&agent_thread_id)
|
|
.expect("agent thread channel")
|
|
.store
|
|
.lock()
|
|
.await;
|
|
let session = store.session.clone().expect("inferred session");
|
|
|
|
assert_eq!(session.model, primary_session.model);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// `thread/read` is metadata/replay hydration and does not return a fresh
|
|
/// server-authored `PermissionProfile`, so it must not reuse the cached primary
|
|
/// session profile after swapping in the read thread's cwd.
|
|
#[tokio::test]
|
|
async fn thread_read_session_state_does_not_reuse_primary_permission_profile() {
|
|
let mut app = make_test_app().await;
|
|
let main_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000401").expect("valid thread");
|
|
let read_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000402").expect("valid thread");
|
|
let primary_cwd = test_path_buf("/tmp/main").abs();
|
|
let primary_session = ThreadSessionState {
|
|
approval_policy: AskForApproval::OnRequest,
|
|
permission_profile: PermissionProfile::workspace_write(),
|
|
runtime_workspace_roots: vec![primary_cwd.clone()],
|
|
..test_thread_session(main_thread_id, primary_cwd.to_path_buf())
|
|
};
|
|
app.primary_session_configured = Some(primary_session);
|
|
|
|
let thread = Thread {
|
|
id: read_thread_id.to_string(),
|
|
session_id: read_thread_id.to_string(),
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
preview: "read thread".to_string(),
|
|
ephemeral: false,
|
|
model_provider: "read-provider".to_string(),
|
|
created_at: 1,
|
|
updated_at: 2,
|
|
status: codex_app_server_protocol::ThreadStatus::Idle,
|
|
path: None,
|
|
cwd: test_path_buf("/tmp/read").abs(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: codex_app_server_protocol::SessionSource::Unknown,
|
|
thread_source: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
git_info: None,
|
|
name: Some("read thread".to_string()),
|
|
turns: Vec::new(),
|
|
};
|
|
|
|
let session = app
|
|
.session_state_for_thread_read(read_thread_id, &thread)
|
|
.await;
|
|
|
|
assert_eq!(session.thread_id, read_thread_id);
|
|
assert_eq!(session.cwd.as_path(), test_path_buf("/tmp/read").as_path());
|
|
assert_eq!(
|
|
session.runtime_workspace_roots,
|
|
vec![test_path_buf("/tmp/read").abs()]
|
|
);
|
|
let expected_permission_profile = app
|
|
.chat_widget
|
|
.config_ref()
|
|
.permissions
|
|
.permission_profile()
|
|
.clone();
|
|
assert_eq!(
|
|
session.permission_profile, expected_permission_profile,
|
|
"thread/read does not return fresh server permissions; the fallback profile must use the \
|
|
active widget permissions rather than reusing the cached primary session profile"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn agent_picker_item_name_snapshot() {
|
|
let thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
|
|
let snapshot = [
|
|
format!(
|
|
"{} | {}",
|
|
format_agent_picker_item_name(
|
|
Some("Robie"),
|
|
Some("explorer"),
|
|
/*is_primary*/ true
|
|
),
|
|
thread_id
|
|
),
|
|
format!(
|
|
"{} | {}",
|
|
format_agent_picker_item_name(
|
|
Some("Robie"),
|
|
Some("explorer"),
|
|
/*is_primary*/ false
|
|
),
|
|
thread_id
|
|
),
|
|
format!(
|
|
"{} | {}",
|
|
format_agent_picker_item_name(
|
|
Some("Robie"),
|
|
/*agent_role*/ None,
|
|
/*is_primary*/ false
|
|
),
|
|
thread_id
|
|
),
|
|
format!(
|
|
"{} | {}",
|
|
format_agent_picker_item_name(
|
|
/*agent_nickname*/ None,
|
|
Some("explorer"),
|
|
/*is_primary*/ false
|
|
),
|
|
thread_id
|
|
),
|
|
format!(
|
|
"{} | {}",
|
|
format_agent_picker_item_name(
|
|
/*agent_nickname*/ None, /*agent_role*/ None, /*is_primary*/ false
|
|
),
|
|
thread_id
|
|
),
|
|
]
|
|
.join("\n");
|
|
assert_app_snapshot!("agent_picker_item_name", snapshot);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn side_fork_config_is_ephemeral_and_appends_developer_guardrails() {
|
|
let app = make_test_app().await;
|
|
let original_approval_policy = app.config.permissions.approval_policy.value();
|
|
let original_sandbox_policy = app.config.legacy_sandbox_policy();
|
|
|
|
let fork_config = app.side_fork_config();
|
|
|
|
assert!(fork_config.ephemeral);
|
|
assert_eq!(
|
|
fork_config.permissions.approval_policy.value(),
|
|
original_approval_policy
|
|
);
|
|
assert_eq!(fork_config.legacy_sandbox_policy(), original_sandbox_policy);
|
|
let developer_instructions = fork_config
|
|
.developer_instructions
|
|
.as_deref()
|
|
.expect("side developer instructions");
|
|
assert!(
|
|
developer_instructions.contains("You are in a side conversation, not the main thread.")
|
|
);
|
|
assert!(
|
|
developer_instructions
|
|
.contains("inherited fork history is provided only as reference context")
|
|
);
|
|
assert!(
|
|
developer_instructions.contains(
|
|
"Only instructions submitted after the side-conversation boundary are active"
|
|
)
|
|
);
|
|
assert!(developer_instructions.contains("Do not continue, execute, or complete any task"));
|
|
assert!(
|
|
developer_instructions
|
|
.contains("External tools may be available according to this thread's current")
|
|
);
|
|
assert!(
|
|
developer_instructions
|
|
.contains("Any MCP or external tool calls or outputs visible in the inherited")
|
|
);
|
|
assert!(developer_instructions.contains("non-mutating inspection"));
|
|
assert!(developer_instructions.contains("Do not modify files"));
|
|
assert!(developer_instructions.contains("Do not request escalated permissions"));
|
|
assert!(app.transcript_cells.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn side_fork_config_inherits_parent_thread_runtime_settings() {
|
|
let mut app = make_test_app().await;
|
|
app.config.model = Some("persisted-default-model".to_string());
|
|
app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Low);
|
|
|
|
let parent_service_tier = ServiceTier::Fast.request_value();
|
|
let parent_permission_profile = PermissionProfile::workspace_write();
|
|
app.chat_widget.set_model("parent-thread-model");
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
|
|
app.chat_widget
|
|
.set_service_tier(Some(parent_service_tier.to_string()));
|
|
app.chat_widget
|
|
.set_approval_policy(AskForApproval::OnRequest);
|
|
app.chat_widget
|
|
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy(
|
|
parent_permission_profile.clone(),
|
|
))
|
|
.expect("test permission profile should be accepted");
|
|
app.chat_widget
|
|
.set_approvals_reviewer(ApprovalsReviewer::AutoReview);
|
|
|
|
let fork_config = app.side_fork_config();
|
|
|
|
assert_eq!(
|
|
(
|
|
fork_config.model.as_deref(),
|
|
fork_config.model_reasoning_effort,
|
|
fork_config.service_tier.as_deref(),
|
|
fork_config.permissions.approval_policy.value(),
|
|
fork_config.permissions.permission_profile(),
|
|
fork_config.approvals_reviewer,
|
|
),
|
|
(
|
|
Some("parent-thread-model"),
|
|
Some(ReasoningEffortConfig::High),
|
|
Some(parent_service_tier),
|
|
AskForApproval::OnRequest.to_core(),
|
|
&parent_permission_profile,
|
|
ApprovalsReviewer::AutoReview,
|
|
)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn side_start_block_message_tracks_open_side_conversation() {
|
|
let mut app = make_test_app().await;
|
|
assert_eq!(
|
|
app.side_start_block_message(),
|
|
Some("'/side' is unavailable until the main thread is ready.")
|
|
);
|
|
|
|
app.primary_thread_id = Some(ThreadId::new());
|
|
assert_eq!(app.side_start_block_message(), None);
|
|
|
|
let parent_thread_id = ThreadId::new();
|
|
let side_thread_id = ThreadId::new();
|
|
app.side_threads
|
|
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
|
|
|
assert_eq!(
|
|
app.side_start_block_message(),
|
|
Some(
|
|
"A side conversation is already open. Press Ctrl+C to return before starting another."
|
|
)
|
|
);
|
|
|
|
app.side_threads.remove(&side_thread_id);
|
|
assert_eq!(app.side_start_block_message(), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn side_parent_status_tracks_parent_turn_lifecycle() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let parent_thread_id = ThreadId::new();
|
|
let side_thread_id = ThreadId::new();
|
|
app.primary_thread_id = Some(parent_thread_id);
|
|
app.active_thread_id = Some(side_thread_id);
|
|
app.side_threads
|
|
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
|
|
|
app.enqueue_thread_notification(
|
|
parent_thread_id,
|
|
turn_completed_notification(parent_thread_id, "turn-1", TurnStatus::Completed),
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
app.side_threads
|
|
.get(&side_thread_id)
|
|
.and_then(|state| state.parent_status),
|
|
Some(SideParentStatus::Finished)
|
|
);
|
|
|
|
app.enqueue_thread_notification(
|
|
parent_thread_id,
|
|
turn_started_notification(parent_thread_id, "turn-2"),
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
app.side_threads
|
|
.get(&side_thread_id)
|
|
.and_then(|state| state.parent_status),
|
|
None
|
|
);
|
|
|
|
app.enqueue_thread_notification(
|
|
parent_thread_id,
|
|
turn_completed_notification(parent_thread_id, "turn-2", TurnStatus::Failed),
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
app.side_threads
|
|
.get(&side_thread_id)
|
|
.and_then(|state| state.parent_status),
|
|
Some(SideParentStatus::Failed)
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn side_parent_status_prioritizes_input_over_approval() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let parent_thread_id = ThreadId::new();
|
|
let side_thread_id = ThreadId::new();
|
|
app.primary_thread_id = Some(parent_thread_id);
|
|
app.active_thread_id = Some(side_thread_id);
|
|
app.side_threads
|
|
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
|
|
|
app.enqueue_thread_request(
|
|
parent_thread_id,
|
|
exec_approval_request(
|
|
parent_thread_id,
|
|
"turn-approval",
|
|
"call-approval",
|
|
/*approval_id*/ None,
|
|
),
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
app.side_threads
|
|
.get(&side_thread_id)
|
|
.and_then(|state| state.parent_status),
|
|
Some(SideParentStatus::NeedsApproval)
|
|
);
|
|
|
|
app.enqueue_thread_request(
|
|
parent_thread_id,
|
|
request_user_input_request(parent_thread_id, "turn-input", "call-input"),
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
app.side_threads
|
|
.get(&side_thread_id)
|
|
.and_then(|state| state.parent_status),
|
|
Some(SideParentStatus::NeedsInput)
|
|
);
|
|
|
|
app.enqueue_thread_notification(
|
|
parent_thread_id,
|
|
ServerNotification::ServerRequestResolved(
|
|
codex_app_server_protocol::ServerRequestResolvedNotification {
|
|
thread_id: parent_thread_id.to_string(),
|
|
request_id: AppServerRequestId::Integer(2),
|
|
},
|
|
),
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
app.side_threads
|
|
.get(&side_thread_id)
|
|
.and_then(|state| state.parent_status),
|
|
Some(SideParentStatus::NeedsApproval)
|
|
);
|
|
|
|
app.enqueue_thread_notification(
|
|
parent_thread_id,
|
|
ServerNotification::ServerRequestResolved(
|
|
codex_app_server_protocol::ServerRequestResolvedNotification {
|
|
thread_id: parent_thread_id.to_string(),
|
|
request_id: AppServerRequestId::Integer(1),
|
|
},
|
|
),
|
|
)
|
|
.await?;
|
|
assert_eq!(
|
|
app.side_threads
|
|
.get(&side_thread_id)
|
|
.and_then(|state| state.parent_status),
|
|
None
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn side_thread_snapshot_hides_forked_parent_transcript() {
|
|
let parent_thread_id = ThreadId::new();
|
|
let side_thread_id = ThreadId::new();
|
|
let mut store = ThreadEventStore::new(/*capacity*/ 4);
|
|
let session = ThreadSessionState {
|
|
forked_from_id: Some(parent_thread_id),
|
|
fork_parent_title: None,
|
|
..test_thread_session(side_thread_id, test_path_buf("/tmp/side"))
|
|
};
|
|
let parent_turn = test_turn(
|
|
"parent-turn",
|
|
TurnStatus::Completed,
|
|
vec![ThreadItem::UserMessage {
|
|
id: "parent-user".to_string(),
|
|
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(
|
|
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()),
|
|
}),
|
|
),
|
|
)
|
|
.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(
|
|
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(),
|
|
},
|
|
&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(
|
|
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
|
|
thread_id: None,
|
|
name: "sentry".to_string(),
|
|
status: McpServerStartupState::Failed,
|
|
error: Some("sentry is not logged in".to_string()),
|
|
}),
|
|
),
|
|
)
|
|
.await;
|
|
|
|
assert!(app_event_rx.try_recv().is_err());
|
|
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(
|
|
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()),
|
|
}),
|
|
),
|
|
)
|
|
.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)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discard_side_thread_removes_agent_navigation_entry() -> Result<()> {
|
|
Box::pin(async {
|
|
let mut app = make_test_app().await;
|
|
let mut app_server =
|
|
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
|
let mut side_config = app.chat_widget.config_ref().clone();
|
|
side_config.ephemeral = true;
|
|
let started = app_server.start_thread(&side_config).await?;
|
|
let side_thread_id = started.session.thread_id;
|
|
app.side_threads
|
|
.insert(side_thread_id, SideThreadState::new(ThreadId::new()));
|
|
app.agent_navigation.upsert(
|
|
side_thread_id,
|
|
Some("Side".to_string()),
|
|
Some("side".to_string()),
|
|
/*is_closed*/ false,
|
|
);
|
|
|
|
assert!(
|
|
app.discard_side_thread(&mut app_server, side_thread_id)
|
|
.await
|
|
);
|
|
|
|
assert_eq!(app.agent_navigation.get(&side_thread_id), None);
|
|
assert!(!app.side_threads.contains_key(&side_thread_id));
|
|
Ok(())
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discard_side_thread_keeps_local_state_when_server_close_fails() -> Result<()> {
|
|
Box::pin(async {
|
|
let mut app = make_test_app().await;
|
|
let mut app_server =
|
|
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
|
let parent_thread_id = ThreadId::new();
|
|
let side_thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(side_thread_id);
|
|
app.side_threads
|
|
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
|
app.agent_navigation.upsert(
|
|
side_thread_id,
|
|
Some("Side".to_string()),
|
|
Some("side".to_string()),
|
|
/*is_closed*/ false,
|
|
);
|
|
|
|
assert!(
|
|
!app.discard_side_thread(&mut app_server, side_thread_id)
|
|
.await
|
|
);
|
|
|
|
assert_eq!(app.active_thread_id, Some(side_thread_id));
|
|
assert_eq!(
|
|
app.side_threads
|
|
.get(&side_thread_id)
|
|
.map(|state| state.parent_thread_id),
|
|
Some(parent_thread_id)
|
|
);
|
|
assert!(app.agent_navigation.get(&side_thread_id).is_some());
|
|
Ok(())
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn discard_closed_side_thread_removes_local_state_without_server_rpc() {
|
|
let mut app = make_test_app().await;
|
|
let parent_thread_id = ThreadId::new();
|
|
let side_thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(side_thread_id);
|
|
app.side_threads
|
|
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
|
app.thread_event_channels
|
|
.insert(side_thread_id, ThreadEventChannel::new(/*capacity*/ 4));
|
|
app.agent_navigation.upsert(
|
|
side_thread_id,
|
|
Some("Side".to_string()),
|
|
Some("side".to_string()),
|
|
/*is_closed*/ false,
|
|
);
|
|
|
|
app.discard_closed_side_thread(side_thread_id).await;
|
|
|
|
assert_eq!(app.active_thread_id, None);
|
|
assert!(!app.side_threads.contains_key(&side_thread_id));
|
|
assert!(!app.thread_event_channels.contains_key(&side_thread_id));
|
|
assert_eq!(app.agent_navigation.get(&side_thread_id), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn active_non_primary_shutdown_target_returns_none_for_non_shutdown_event() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
app.active_thread_id = Some(ThreadId::new());
|
|
app.primary_thread_id = Some(ThreadId::new());
|
|
|
|
assert_eq!(
|
|
app.active_non_primary_shutdown_target(&ServerNotification::SkillsChanged(
|
|
codex_app_server_protocol::SkillsChangedNotification {},
|
|
)),
|
|
None
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn active_non_primary_shutdown_target_returns_none_for_primary_thread_shutdown() -> Result<()>
|
|
{
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(thread_id);
|
|
app.primary_thread_id = Some(thread_id);
|
|
|
|
assert_eq!(
|
|
app.active_non_primary_shutdown_target(&thread_closed_notification(thread_id)),
|
|
None
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn active_non_primary_shutdown_target_returns_ids_for_non_primary_shutdown() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let active_thread_id = ThreadId::new();
|
|
let primary_thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(active_thread_id);
|
|
app.primary_thread_id = Some(primary_thread_id);
|
|
|
|
assert_eq!(
|
|
app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)),
|
|
Some((active_thread_id, primary_thread_id))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn active_non_primary_shutdown_target_returns_none_when_shutdown_exit_is_pending()
|
|
-> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let active_thread_id = ThreadId::new();
|
|
let primary_thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(active_thread_id);
|
|
app.primary_thread_id = Some(primary_thread_id);
|
|
app.pending_shutdown_exit_thread_id = Some(active_thread_id);
|
|
|
|
assert_eq!(
|
|
app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)),
|
|
None
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn active_non_primary_shutdown_target_still_switches_for_other_pending_exit_thread()
|
|
-> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let active_thread_id = ThreadId::new();
|
|
let primary_thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(active_thread_id);
|
|
app.primary_thread_id = Some(primary_thread_id);
|
|
app.pending_shutdown_exit_thread_id = Some(ThreadId::new());
|
|
|
|
assert_eq!(
|
|
app.active_non_primary_shutdown_target(&thread_closed_notification(active_thread_id)),
|
|
Some((active_thread_id, primary_thread_id))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String {
|
|
let mut app = make_test_app().await;
|
|
app.config.cwd = test_path_buf("/tmp/project").abs();
|
|
app.chat_widget.set_model("gpt-test");
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
|
|
let story_part_one = "In the cliffside town of Bracken Ferry, the lighthouse had been dark for \
|
|
nineteen years, and the children were told it was because the sea no longer wanted a \
|
|
guide. Mara, who repaired clocks for a living, found that hard to believe. Every dawn she \
|
|
heard the gulls circling the empty tower, and every dusk she watched ships hesitate at the \
|
|
mouth of the bay as if listening for a signal that never came. When an old brass key fell \
|
|
out of a cracked parcel in her workshop, tagged only with the words 'for the lamp room,' \
|
|
she decided to climb the hill and see what the town had forgotten.";
|
|
let story_part_two = "Inside the lighthouse she found gears wrapped in oilcloth, logbooks filled \
|
|
with weather notes, and a lens shrouded beneath salt-stiff canvas. The mechanism was not \
|
|
broken, only unfinished. Someone had removed the governor spring and hidden it in a false \
|
|
drawer, along with a letter from the last keeper admitting he had darkened the light on \
|
|
purpose after smugglers threatened his family. Mara spent the night rebuilding the clockwork \
|
|
from spare watch parts, her fingers blackened with soot and grease, while a storm gathered \
|
|
over the water and the harbor bells began to ring.";
|
|
let story_part_three = "At midnight the first squall hit, and the fishing boats returned early, \
|
|
blind in sheets of rain. Mara wound the mechanism, set the teeth by hand, and watched the \
|
|
great lens begin to turn in slow, certain arcs. The beam swept across the bay, caught the \
|
|
whitecaps, and reached the boats just as they were drifting toward the rocks below the \
|
|
eastern cliffs. In the morning the town square was crowded with wet sailors, angry elders, \
|
|
and wide-eyed children, but when the oldest captain placed the keeper's log on the fountain \
|
|
and thanked Mara for relighting the coast, nobody argued. By sunset, Bracken Ferry had a \
|
|
lighthouse again, and Mara had more clocks to mend than ever because everyone wanted \
|
|
something in town to keep better time.";
|
|
|
|
let user_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
|
Arc::new(UserHistoryCell {
|
|
message: text.to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
}) as Arc<dyn HistoryCell>
|
|
};
|
|
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
|
Arc::new(AgentMessageCell::new(
|
|
vec![Line::from(text.to_string())],
|
|
/*is_first_line*/ true,
|
|
)) as Arc<dyn HistoryCell>
|
|
};
|
|
let make_header = |is_first| -> Arc<dyn HistoryCell> {
|
|
let session = ThreadSessionState {
|
|
thread_id: ThreadId::new(),
|
|
forked_from_id: None,
|
|
fork_parent_title: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/tmp/project").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: Some(ReasoningEffortConfig::High),
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
};
|
|
Arc::new(new_session_info(
|
|
app.chat_widget.config_ref(),
|
|
app.chat_widget.current_model(),
|
|
&session,
|
|
is_first,
|
|
/*tooltip_override*/ None,
|
|
/*auth_plan*/ None,
|
|
/*show_fast_status*/ false,
|
|
)) as Arc<dyn HistoryCell>
|
|
};
|
|
|
|
app.transcript_cells = vec![
|
|
make_header(true),
|
|
Arc::new(crate::history_cell::new_info_event(
|
|
"startup tip that used to replay".to_string(),
|
|
/*hint*/ None,
|
|
)) as Arc<dyn HistoryCell>,
|
|
user_cell("Tell me a long story about a town with a dark lighthouse."),
|
|
agent_cell(story_part_one),
|
|
user_cell("Continue the story and reveal why the light went out."),
|
|
agent_cell(story_part_two),
|
|
user_cell("Finish the story with a storm and a resolution."),
|
|
agent_cell(story_part_three),
|
|
];
|
|
app.has_emitted_history_lines = true;
|
|
|
|
let rendered = app
|
|
.clear_ui_header_lines_with_version(/*width*/ 80, "<VERSION>")
|
|
.iter()
|
|
.map(|line| {
|
|
line.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
assert!(
|
|
!rendered.contains("startup tip that used to replay"),
|
|
"clear header should not replay startup notices"
|
|
);
|
|
assert!(
|
|
!rendered.contains("Bracken Ferry"),
|
|
"clear header should not replay prior conversation turns"
|
|
);
|
|
rendered
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[cfg_attr(
|
|
target_os = "windows",
|
|
ignore = "snapshot path rendering differs on Windows"
|
|
)]
|
|
async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() {
|
|
let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await;
|
|
assert_app_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[cfg_attr(
|
|
target_os = "windows",
|
|
ignore = "snapshot path rendering differs on Windows"
|
|
)]
|
|
async fn ctrl_l_clear_ui_after_long_transcript_reuses_clear_header_snapshot() {
|
|
let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await;
|
|
assert_app_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[cfg_attr(
|
|
target_os = "windows",
|
|
ignore = "snapshot path rendering differs on Windows"
|
|
)]
|
|
async fn clear_ui_header_shows_fast_status_for_fast_capable_models() {
|
|
let mut app = make_test_app().await;
|
|
app.config.cwd = test_path_buf("/tmp/project").abs();
|
|
app.chat_widget.set_model("gpt-5.4");
|
|
set_fast_mode_test_catalog(&mut app.chat_widget);
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
|
|
app.chat_widget.set_service_tier(Some(
|
|
codex_protocol::config_types::ServiceTier::Fast
|
|
.request_value()
|
|
.to_string(),
|
|
));
|
|
set_chatgpt_auth(&mut app.chat_widget);
|
|
set_fast_mode_test_catalog(&mut app.chat_widget);
|
|
|
|
let rendered = app
|
|
.clear_ui_header_lines_with_version(/*width*/ 80, "<VERSION>")
|
|
.iter()
|
|
.map(|line| {
|
|
line.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
assert_app_snapshot!("clear_ui_header_fast_status_fast_capable_models", rendered);
|
|
}
|
|
|
|
async fn make_test_app() -> App {
|
|
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
|
|
let config = chat_widget.config_ref().clone();
|
|
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
|
|
let model = 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,
|
|
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(),
|
|
overlay: None,
|
|
deferred_history_lines: Vec::new(),
|
|
has_emitted_history_lines: false,
|
|
transcript_reflow: TranscriptReflowState::default(),
|
|
initial_history_replay_buffer: None,
|
|
enhanced_keys_supported: false,
|
|
keymap: crate::keymap::RuntimeKeymap::defaults(),
|
|
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
|
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
|
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
|
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,
|
|
pending_update_action: None,
|
|
pending_shutdown_exit_thread_id: None,
|
|
windows_sandbox: WindowsSandboxState::default(),
|
|
thread_event_channels: HashMap::new(),
|
|
thread_event_listener_tasks: HashMap::new(),
|
|
agent_navigation: AgentNavigationState::default(),
|
|
side_threads: HashMap::new(),
|
|
active_thread_id: None,
|
|
active_thread_rx: None,
|
|
primary_thread_id: None,
|
|
last_subagent_backfill_attempt: None,
|
|
primary_session_configured: None,
|
|
pending_primary_events: VecDeque::new(),
|
|
pending_app_server_requests: PendingAppServerRequests::default(),
|
|
pending_startup_thread_start: false,
|
|
pending_plugin_enabled_writes: HashMap::new(),
|
|
pending_hook_enabled_writes: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
async fn make_test_app_with_channels() -> (
|
|
App,
|
|
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
|
tokio::sync::mpsc::UnboundedReceiver<Op>,
|
|
) {
|
|
let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await;
|
|
let config = chat_widget.config_ref().clone();
|
|
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
|
|
let model = 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,
|
|
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(),
|
|
overlay: None,
|
|
deferred_history_lines: Vec::new(),
|
|
has_emitted_history_lines: false,
|
|
transcript_reflow: TranscriptReflowState::default(),
|
|
initial_history_replay_buffer: None,
|
|
enhanced_keys_supported: false,
|
|
keymap: crate::keymap::RuntimeKeymap::defaults(),
|
|
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
|
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
|
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
|
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,
|
|
pending_update_action: None,
|
|
pending_shutdown_exit_thread_id: None,
|
|
windows_sandbox: WindowsSandboxState::default(),
|
|
thread_event_channels: HashMap::new(),
|
|
thread_event_listener_tasks: HashMap::new(),
|
|
agent_navigation: AgentNavigationState::default(),
|
|
side_threads: HashMap::new(),
|
|
active_thread_id: None,
|
|
active_thread_rx: None,
|
|
primary_thread_id: None,
|
|
last_subagent_backfill_attempt: None,
|
|
primary_session_configured: None,
|
|
pending_primary_events: VecDeque::new(),
|
|
pending_app_server_requests: PendingAppServerRequests::default(),
|
|
pending_startup_thread_start: false,
|
|
pending_plugin_enabled_writes: HashMap::new(),
|
|
pending_hook_enabled_writes: HashMap::new(),
|
|
},
|
|
rx,
|
|
op_rx,
|
|
)
|
|
}
|
|
|
|
#[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 enable_terminal_resize_reflow(app: &mut App) {
|
|
app.config
|
|
.features
|
|
.set_enabled(Feature::TerminalResizeReflow, /*enabled*/ true)
|
|
.expect("feature should be configurable");
|
|
}
|
|
|
|
fn plain_line_cell(text: impl Into<String>) -> Arc<dyn HistoryCell> {
|
|
Arc::new(PlainHistoryCell::new(vec![Line::from(text.into())])) as Arc<dyn HistoryCell>
|
|
}
|
|
|
|
fn rendered_line_text(line: &crate::terminal_hyperlinks::HyperlinkLine) -> String {
|
|
line.line
|
|
.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn capped_resize_reflow_renders_recent_suffix_only() {
|
|
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
|
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(5);
|
|
app.transcript_cells = (0..20)
|
|
.map(|i| plain_line_cell(format!("cell {i}")))
|
|
.collect();
|
|
|
|
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
|
|
|
|
assert_eq!(rendered.lines.len(), 5);
|
|
assert_eq!(
|
|
rendered
|
|
.lines
|
|
.iter()
|
|
.map(rendered_line_text)
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"cell 17".to_string(),
|
|
String::new(),
|
|
"cell 18".to_string(),
|
|
String::new(),
|
|
"cell 19".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn uncapped_resize_reflow_renders_all_cells_when_row_cap_absent() {
|
|
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
|
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
|
|
app.transcript_cells = (0..20)
|
|
.map(|i| plain_line_cell(format!("cell {i}")))
|
|
.collect();
|
|
|
|
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
|
|
|
|
assert_eq!(rendered.lines.len(), 39);
|
|
assert_eq!(rendered_line_text(&rendered.lines[0]), "cell 0");
|
|
assert_eq!(rendered_line_text(&rendered.lines[38]), "cell 19");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resize_reflow_wraps_transcript_early_when_pet_is_enabled() {
|
|
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
|
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
|
|
app.transcript_cells = vec![Arc::new(AgentMarkdownCell::new(
|
|
"alpha beta gamma delta epsilon zeta eta theta iota kappa lambda".to_string(),
|
|
Path::new("/tmp"),
|
|
))];
|
|
|
|
let without_pet = app.render_transcript_lines_for_reflow(/*width*/ 40);
|
|
app.chat_widget
|
|
.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported(
|
|
crate::pets::ImageProtocol::Kitty,
|
|
));
|
|
app.chat_widget
|
|
.install_test_ambient_pet_for_tests(/*animations_enabled*/ false);
|
|
let width = app.chat_widget.history_wrap_width(/*width*/ 40);
|
|
assert!(width < 40);
|
|
let with_pet = app.render_transcript_lines_for_reflow(width);
|
|
|
|
assert!(
|
|
with_pet.lines.len() > without_pet.lines.len(),
|
|
"expected pet-enabled transcript reflow to wrap earlier"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() {
|
|
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
|
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(100);
|
|
app.transcript_cells = (0..3)
|
|
.map(|i| plain_line_cell(format!("cell {i}")))
|
|
.collect();
|
|
|
|
let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80);
|
|
|
|
assert_eq!(
|
|
rendered
|
|
.lines
|
|
.iter()
|
|
.map(rendered_line_text)
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"cell 0".to_string(),
|
|
String::new(),
|
|
"cell 1".to_string(),
|
|
String::new(),
|
|
"cell 2".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() {
|
|
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
|
enable_terminal_resize_reflow(&mut app);
|
|
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(3);
|
|
|
|
app.begin_initial_history_replay_buffer();
|
|
for index in 0..5 {
|
|
App::buffer_initial_history_replay_display_lines(
|
|
app.initial_history_replay_buffer
|
|
.as_mut()
|
|
.expect("initial replay buffer active"),
|
|
vec![Line::from(format!("line {index}")).into()],
|
|
/*max_rows*/ 3,
|
|
);
|
|
}
|
|
|
|
let buffer = app
|
|
.initial_history_replay_buffer
|
|
.as_ref()
|
|
.expect("initial replay buffer should remain active");
|
|
assert_eq!(
|
|
buffer
|
|
.retained_lines
|
|
.iter()
|
|
.map(rendered_line_text)
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"line 2".to_string(),
|
|
"line 3".to_string(),
|
|
"line 4".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_switch_replay_buffer_uses_transcript_tail_mode_when_row_cap_present() {
|
|
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
|
enable_terminal_resize_reflow(&mut app);
|
|
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(3);
|
|
|
|
app.begin_thread_switch_history_replay_buffer();
|
|
|
|
let buffer = app
|
|
.initial_history_replay_buffer
|
|
.as_ref()
|
|
.expect("thread switch replay buffer should be active");
|
|
assert!(buffer.render_from_transcript_tail);
|
|
assert!(buffer.retained_lines.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_switch_replay_buffer_is_disabled_without_row_cap() {
|
|
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
|
enable_terminal_resize_reflow(&mut app);
|
|
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
|
|
|
|
app.begin_thread_switch_history_replay_buffer();
|
|
|
|
assert!(app.initial_history_replay_buffer.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn height_shrink_schedules_resize_reflow() {
|
|
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
|
enable_terminal_resize_reflow(&mut app);
|
|
let frame_requester = crate::tui::FrameRequester::test_dummy();
|
|
|
|
assert!(!app.handle_draw_size_change(
|
|
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
|
|
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
|
|
&frame_requester,
|
|
));
|
|
|
|
assert!(app.handle_draw_size_change(
|
|
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 24),
|
|
ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35),
|
|
&frame_requester,
|
|
));
|
|
assert!(app.transcript_reflow.has_pending_reflow());
|
|
}
|
|
|
|
fn test_turn(turn_id: &str, status: TurnStatus, items: Vec<ThreadItem>) -> Turn {
|
|
Turn {
|
|
id: turn_id.to_string(),
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items,
|
|
status,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
}
|
|
}
|
|
|
|
fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotification {
|
|
ServerNotification::TurnStarted(TurnStartedNotification {
|
|
thread_id: thread_id.to_string(),
|
|
turn: Turn {
|
|
started_at: Some(0),
|
|
..test_turn(turn_id, TurnStatus::InProgress, Vec::new())
|
|
},
|
|
})
|
|
}
|
|
|
|
fn turn_completed_notification(
|
|
thread_id: ThreadId,
|
|
turn_id: &str,
|
|
status: TurnStatus,
|
|
) -> ServerNotification {
|
|
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
|
thread_id: thread_id.to_string(),
|
|
turn: Turn {
|
|
completed_at: Some(0),
|
|
duration_ms: Some(1),
|
|
..test_turn(turn_id, status, Vec::new())
|
|
},
|
|
})
|
|
}
|
|
|
|
fn thread_closed_notification(thread_id: ThreadId) -> ServerNotification {
|
|
ServerNotification::ThreadClosed(ThreadClosedNotification {
|
|
thread_id: thread_id.to_string(),
|
|
})
|
|
}
|
|
|
|
fn token_usage_notification(
|
|
thread_id: ThreadId,
|
|
turn_id: &str,
|
|
model_context_window: Option<i64>,
|
|
) -> ServerNotification {
|
|
ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id: turn_id.to_string(),
|
|
token_usage: ThreadTokenUsage {
|
|
total: TokenUsageBreakdown {
|
|
total_tokens: 10,
|
|
input_tokens: 4,
|
|
cached_input_tokens: 1,
|
|
output_tokens: 5,
|
|
reasoning_output_tokens: 0,
|
|
},
|
|
last: TokenUsageBreakdown {
|
|
total_tokens: 10,
|
|
input_tokens: 4,
|
|
cached_input_tokens: 1,
|
|
output_tokens: 5,
|
|
reasoning_output_tokens: 0,
|
|
},
|
|
model_context_window,
|
|
},
|
|
})
|
|
}
|
|
|
|
fn agent_message_delta_notification(
|
|
thread_id: ThreadId,
|
|
turn_id: &str,
|
|
item_id: &str,
|
|
delta: &str,
|
|
) -> ServerNotification {
|
|
ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id: turn_id.to_string(),
|
|
item_id: item_id.to_string(),
|
|
delta: delta.to_string(),
|
|
})
|
|
}
|
|
|
|
fn exec_approval_request(
|
|
thread_id: ThreadId,
|
|
turn_id: &str,
|
|
item_id: &str,
|
|
approval_id: Option<&str>,
|
|
) -> ServerRequest {
|
|
ServerRequest::CommandExecutionRequestApproval {
|
|
request_id: AppServerRequestId::Integer(1),
|
|
params: CommandExecutionRequestApprovalParams {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id: turn_id.to_string(),
|
|
item_id: item_id.to_string(),
|
|
started_at_ms: 0,
|
|
approval_id: approval_id.map(str::to_string),
|
|
reason: Some("needs approval".to_string()),
|
|
network_approval_context: None,
|
|
command: Some("echo hello".to_string()),
|
|
cwd: Some(test_path_buf("/tmp/project").abs()),
|
|
command_actions: None,
|
|
additional_permissions: None,
|
|
proposed_execpolicy_amendment: None,
|
|
proposed_network_policy_amendments: None,
|
|
available_decisions: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn request_user_input_request(thread_id: ThreadId, turn_id: &str, item_id: &str) -> ServerRequest {
|
|
ServerRequest::ToolRequestUserInput {
|
|
request_id: AppServerRequestId::Integer(2),
|
|
params: ToolRequestUserInputParams {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id: turn_id.to_string(),
|
|
item_id: item_id.to_string(),
|
|
questions: Vec::new(),
|
|
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 {
|
|
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 session_start_error_surfaces_archived_guidance_without_rollout_path() {
|
|
let thread_id =
|
|
ThreadId::from_string("019e72f4-e09a-70f2-b2c2-a153a57b8cc0").expect("thread id");
|
|
let target_session = SessionTarget {
|
|
path: Some(std::path::PathBuf::from(
|
|
"/Users/me/.codex/archived_sessions/rollout.jsonl",
|
|
)),
|
|
thread_id,
|
|
};
|
|
let expected = format!(
|
|
"session {thread_id} is archived. Run `codex unarchive {thread_id}` to unarchive it first."
|
|
);
|
|
|
|
for action in ["resume", "fork"] {
|
|
let err = color_eyre::eyre::eyre!(
|
|
"thread/{action} failed during TUI bootstrap: thread/{action} failed: {expected} (code -32600)"
|
|
);
|
|
|
|
assert_eq!(
|
|
session_start_error(action, &target_session, err).to_string(),
|
|
expected
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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_with_duplicate_history_targets_unique_turn() {
|
|
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
|
|
let user_cell = |text: &str,
|
|
text_elements: Vec<TextElement>,
|
|
local_image_paths: Vec<PathBuf>,
|
|
remote_image_urls: Vec<String>|
|
|
-> Arc<dyn HistoryCell> {
|
|
Arc::new(UserHistoryCell {
|
|
message: text.to_string(),
|
|
text_elements,
|
|
local_image_paths,
|
|
remote_image_urls,
|
|
}) as Arc<dyn HistoryCell>
|
|
};
|
|
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
|
Arc::new(AgentMessageCell::new(
|
|
vec![Line::from(text.to_string())],
|
|
/*is_first_line*/ true,
|
|
)) as Arc<dyn HistoryCell>
|
|
};
|
|
|
|
let make_header = |is_first| {
|
|
let session = ThreadSessionState {
|
|
thread_id: ThreadId::new(),
|
|
forked_from_id: None,
|
|
fork_parent_title: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/home/user/project").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
};
|
|
Arc::new(new_session_info(
|
|
app.chat_widget.config_ref(),
|
|
app.chat_widget.current_model(),
|
|
&session,
|
|
is_first,
|
|
/*tooltip_override*/ None,
|
|
/*auth_plan*/ None,
|
|
/*show_fast_status*/ false,
|
|
)) as Arc<dyn HistoryCell>
|
|
};
|
|
|
|
let placeholder = "[Image #1]";
|
|
let edited_text = format!("follow-up (edited) {placeholder}");
|
|
let edited_range = edited_text.len().saturating_sub(placeholder.len())..edited_text.len();
|
|
let edited_text_elements = vec![TextElement::new(
|
|
edited_range.into(),
|
|
/*placeholder*/ None,
|
|
)];
|
|
let edited_local_image_paths = vec![PathBuf::from("/tmp/fake-image.png")];
|
|
|
|
// Simulate a transcript with duplicated history (e.g., from prior backtracks)
|
|
// and an edited turn appended after a session header boundary.
|
|
app.transcript_cells = vec![
|
|
make_header(true),
|
|
user_cell("first question", Vec::new(), Vec::new(), Vec::new()),
|
|
agent_cell("answer first"),
|
|
user_cell("follow-up", Vec::new(), Vec::new(), Vec::new()),
|
|
agent_cell("answer follow-up"),
|
|
make_header(false),
|
|
user_cell("first question", Vec::new(), Vec::new(), Vec::new()),
|
|
agent_cell("answer first"),
|
|
user_cell(
|
|
&edited_text,
|
|
edited_text_elements.clone(),
|
|
edited_local_image_paths.clone(),
|
|
vec!["https://example.com/backtrack.png".to_string()],
|
|
),
|
|
agent_cell("answer edited"),
|
|
];
|
|
|
|
assert_eq!(user_count(&app.transcript_cells), 2);
|
|
|
|
let base_id = ThreadId::new();
|
|
app.chat_widget
|
|
.handle_thread_session(crate::session_state::ThreadSessionState {
|
|
thread_id: base_id,
|
|
forked_from_id: None,
|
|
fork_parent_title: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/home/user/project").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
});
|
|
|
|
app.backtrack.base_id = Some(base_id);
|
|
app.backtrack.primed = true;
|
|
app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1);
|
|
|
|
let selection = app
|
|
.confirm_backtrack_from_main()
|
|
.expect("backtrack selection");
|
|
assert_eq!(selection.nth_user_message, 1);
|
|
assert_eq!(selection.prefill, edited_text);
|
|
assert_eq!(selection.text_elements, edited_text_elements);
|
|
assert_eq!(selection.local_image_paths, edited_local_image_paths);
|
|
assert_eq!(
|
|
selection.remote_image_urls,
|
|
vec!["https://example.com/backtrack.png".to_string()]
|
|
);
|
|
|
|
app.apply_backtrack_rollback(selection);
|
|
assert_eq!(
|
|
app.chat_widget.remote_image_urls(),
|
|
vec!["https://example.com/backtrack.png".to_string()]
|
|
);
|
|
|
|
let mut rollback_turns = None;
|
|
while let Ok(op) = op_rx.try_recv() {
|
|
if let Op::ThreadRollback { num_turns } = op {
|
|
rollback_turns = Some(num_turns);
|
|
}
|
|
}
|
|
|
|
assert_eq!(rollback_turns, Some(1));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn backtrack_remote_image_only_selection_clears_existing_composer_draft() {
|
|
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
|
|
app.transcript_cells = vec![Arc::new(UserHistoryCell {
|
|
message: "original".to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
}) as Arc<dyn HistoryCell>];
|
|
app.chat_widget
|
|
.set_composer_text("stale draft".to_string(), Vec::new(), Vec::new());
|
|
|
|
let remote_image_url = "https://example.com/remote-only.png".to_string();
|
|
app.apply_backtrack_rollback(BacktrackSelection {
|
|
nth_user_message: 0,
|
|
prefill: String::new(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: vec![remote_image_url.clone()],
|
|
});
|
|
|
|
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
|
|
assert_eq!(app.chat_widget.remote_image_urls(), vec![remote_image_url]);
|
|
|
|
let mut rollback_turns = None;
|
|
while let Ok(op) = op_rx.try_recv() {
|
|
if let Op::ThreadRollback { num_turns } = op {
|
|
rollback_turns = Some(num_turns);
|
|
}
|
|
}
|
|
assert_eq!(rollback_turns, Some(1));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancelled_turn_edit_restores_prompt_and_rolls_back_latest_turn() {
|
|
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
app.transcript_cells = vec![Arc::new(UserHistoryCell {
|
|
message: "original".to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
}) as Arc<dyn HistoryCell>];
|
|
let prompt = crate::chatwidget::UserMessage {
|
|
text: "edit me".to_string(),
|
|
local_images: Vec::new(),
|
|
remote_image_urls: vec!["https://example.com/edit.png".to_string()],
|
|
text_elements: Vec::new(),
|
|
mention_bindings: Vec::new(),
|
|
};
|
|
|
|
app.apply_cancelled_turn_edit(prompt);
|
|
|
|
assert_eq!(app.chat_widget.composer_text_with_pending(), "edit me");
|
|
assert_snapshot!(
|
|
"cancelled_turn_edit_restores_composer",
|
|
app.chat_widget.composer_text_with_pending()
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.remote_image_urls(),
|
|
vec!["https://example.com/edit.png".to_string()]
|
|
);
|
|
assert_matches!(op_rx.try_recv(), Ok(Op::ThreadRollback { num_turns: 1 }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn first_cancelled_turn_edit_restores_prompt_without_local_history() {
|
|
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
let prompt = crate::chatwidget::UserMessage {
|
|
text: "edit first prompt".to_string(),
|
|
local_images: Vec::new(),
|
|
remote_image_urls: vec!["https://example.com/edit.png".to_string()],
|
|
text_elements: Vec::new(),
|
|
mention_bindings: Vec::new(),
|
|
};
|
|
|
|
app.apply_cancelled_turn_edit(prompt);
|
|
|
|
assert_eq!(
|
|
app.chat_widget.composer_text_with_pending(),
|
|
"edit first prompt"
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.remote_image_urls(),
|
|
vec!["https://example.com/edit.png".to_string()]
|
|
);
|
|
assert_matches!(op_rx.try_recv(), Ok(Op::ThreadRollback { num_turns: 1 }));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
|
|
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
|
|
let thread_id = ThreadId::new();
|
|
app.chat_widget
|
|
.handle_thread_session(crate::session_state::ThreadSessionState {
|
|
thread_id,
|
|
forked_from_id: None,
|
|
fork_parent_title: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/home/user/project").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
});
|
|
|
|
let data_image_url = "data:image/png;base64,abc123".to_string();
|
|
app.transcript_cells = vec![Arc::new(UserHistoryCell {
|
|
message: "please inspect this".to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: vec![data_image_url.clone()],
|
|
}) as Arc<dyn HistoryCell>];
|
|
|
|
app.apply_backtrack_rollback(BacktrackSelection {
|
|
nth_user_message: 0,
|
|
prefill: "please inspect this".to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: vec![data_image_url.clone()],
|
|
});
|
|
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
let mut saw_rollback = false;
|
|
let mut submitted_items: Option<Vec<UserInput>> = None;
|
|
while let Ok(op) = op_rx.try_recv() {
|
|
match op {
|
|
Op::ThreadRollback { .. } => saw_rollback = true,
|
|
Op::UserTurn { items, .. } => submitted_items = Some(items),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
assert!(saw_rollback);
|
|
let items = submitted_items.expect("expected user turn after backtrack resubmit");
|
|
assert!(items.iter().any(|item| {
|
|
matches!(
|
|
item,
|
|
UserInput::Image { url, .. } if url == &data_image_url
|
|
)
|
|
}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_replays_turn_history_in_order() {
|
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session: Some(test_thread_session(
|
|
thread_id,
|
|
test_path_buf("/home/user/project"),
|
|
)),
|
|
turns: vec![
|
|
Turn {
|
|
id: "turn-1".to_string(),
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: vec![ThreadItem::UserMessage {
|
|
id: "user-1".to_string(),
|
|
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,
|
|
},
|
|
],
|
|
status: TurnStatus::Completed,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
],
|
|
events: Vec::new(),
|
|
input_state: None,
|
|
},
|
|
/*resume_restored_queue*/ false,
|
|
);
|
|
|
|
while let Ok(event) = app_event_rx.try_recv() {
|
|
if let AppEvent::InsertHistoryCell(cell) = event {
|
|
let cell: Arc<dyn HistoryCell> = cell.into();
|
|
app.transcript_cells.push(cell);
|
|
}
|
|
}
|
|
|
|
let user_messages: Vec<String> = app
|
|
.transcript_cells
|
|
.iter()
|
|
.filter_map(|cell| {
|
|
cell.as_any()
|
|
.downcast_ref::<UserHistoryCell>()
|
|
.map(|cell| cell.message.clone())
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
user_messages,
|
|
vec!["first prompt".to_string(), "third prompt".to_string()]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() {
|
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let receiver_thread_id =
|
|
ThreadId::from_string("019cff70-2599-75e2-af72-b958ce5dc1cc").expect("valid thread");
|
|
app.agent_navigation.upsert(
|
|
receiver_thread_id,
|
|
Some("Robie".to_string()),
|
|
Some("explorer".to_string()),
|
|
/*is_closed*/ false,
|
|
);
|
|
|
|
let replacement = ChatWidget::new_with_app_event(ChatWidgetInit {
|
|
config: app.config.clone(),
|
|
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
|
app_event_tx: app.app_event_tx.clone(),
|
|
workspace_command_runner: None,
|
|
initial_user_message: None,
|
|
enhanced_keys_supported: app.enhanced_keys_supported,
|
|
has_chatgpt_account: app.chat_widget.has_chatgpt_account(),
|
|
model_catalog: app.model_catalog.clone(),
|
|
feedback: app.feedback.clone(),
|
|
is_first_run: false,
|
|
status_account_display: app.chat_widget.status_account_display().cloned(),
|
|
runtime_model_provider_base_url: app
|
|
.chat_widget
|
|
.runtime_model_provider_base_url()
|
|
.map(str::to_string),
|
|
initial_plan_type: app.chat_widget.current_plan_type(),
|
|
model: Some(app.chat_widget.current_model().to_string()),
|
|
startup_tooltip_override: None,
|
|
status_line_invalid_items_warned: app.status_line_invalid_items_warned.clone(),
|
|
terminal_title_invalid_items_warned: app.terminal_title_invalid_items_warned.clone(),
|
|
session_telemetry: app.session_telemetry.clone(),
|
|
});
|
|
app.replace_chat_widget(replacement);
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session: None,
|
|
turns: Vec::new(),
|
|
events: vec![ThreadBufferedEvent::Notification(
|
|
ServerNotification::ItemStarted(
|
|
codex_app_server_protocol::ItemStartedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
started_at_ms: 0,
|
|
item: ThreadItem::CollabAgentToolCall {
|
|
id: "wait-1".to_string(),
|
|
tool: codex_app_server_protocol::CollabAgentTool::Wait,
|
|
status:
|
|
codex_app_server_protocol::CollabAgentToolCallStatus::InProgress,
|
|
sender_thread_id: ThreadId::new().to_string(),
|
|
receiver_thread_ids: vec![receiver_thread_id.to_string()],
|
|
prompt: None,
|
|
model: None,
|
|
reasoning_effort: None,
|
|
agents_states: HashMap::new(),
|
|
},
|
|
},
|
|
),
|
|
)],
|
|
input_state: None,
|
|
},
|
|
/*resume_restored_queue*/ false,
|
|
);
|
|
|
|
let mut saw_named_wait = false;
|
|
while let Ok(event) = app_event_rx.try_recv() {
|
|
if let AppEvent::InsertHistoryCell(cell) = event {
|
|
let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80));
|
|
saw_named_wait |= transcript.contains("Robie [explorer]");
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
saw_named_wait,
|
|
"expected replayed wait item to keep agent name"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refreshed_snapshot_session_persists_resumed_turns() {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
let initial_session = test_thread_session(thread_id, test_path_buf("/tmp/original"));
|
|
app.thread_event_channels.insert(
|
|
thread_id,
|
|
ThreadEventChannel::new_with_session(
|
|
/*capacity*/ 4,
|
|
initial_session.clone(),
|
|
Vec::new(),
|
|
),
|
|
);
|
|
|
|
let resumed_turns = vec![test_turn(
|
|
"turn-1",
|
|
TurnStatus::Completed,
|
|
vec![ThreadItem::UserMessage {
|
|
id: "user-1".to_string(),
|
|
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(),
|
|
},
|
|
&mut snapshot,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(snapshot.session, Some(resumed_session.clone()));
|
|
assert_eq!(snapshot.turns, resumed_turns);
|
|
|
|
let store = app
|
|
.thread_event_channels
|
|
.get(&thread_id)
|
|
.expect("thread channel")
|
|
.store
|
|
.lock()
|
|
.await;
|
|
let store_snapshot = store.snapshot();
|
|
assert_eq!(store_snapshot.session, Some(resumed_session));
|
|
assert_eq!(store_snapshot.turns, snapshot.turns);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queued_rollback_syncs_overlay_and_clears_deferred_history() {
|
|
let mut app = make_test_app().await;
|
|
app.transcript_cells = vec![
|
|
Arc::new(UserHistoryCell {
|
|
message: "first".to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
}) as Arc<dyn HistoryCell>,
|
|
Arc::new(AgentMessageCell::new(
|
|
vec![Line::from("after first")],
|
|
/*is_first_line*/ false,
|
|
)) as Arc<dyn HistoryCell>,
|
|
Arc::new(UserHistoryCell {
|
|
message: "second".to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
}) as Arc<dyn HistoryCell>,
|
|
Arc::new(AgentMessageCell::new(
|
|
vec![Line::from("after second")],
|
|
/*is_first_line*/ false,
|
|
)) as Arc<dyn HistoryCell>,
|
|
];
|
|
app.overlay = Some(Overlay::new_transcript(
|
|
app.transcript_cells.clone(),
|
|
app.keymap.pager.clone(),
|
|
));
|
|
app.deferred_history_lines = vec![Line::from("stale buffered line").into()];
|
|
app.backtrack.overlay_preview_active = true;
|
|
app.backtrack.nth_user_message = 1;
|
|
|
|
let changed = app.apply_non_pending_thread_rollback(/*num_turns*/ 1);
|
|
|
|
assert!(changed);
|
|
assert!(app.backtrack_render_pending);
|
|
assert!(app.deferred_history_lines.is_empty());
|
|
assert_eq!(app.backtrack.nth_user_message, 0);
|
|
let user_messages: Vec<String> = app
|
|
.transcript_cells
|
|
.iter()
|
|
.filter_map(|cell| {
|
|
cell.as_any()
|
|
.downcast_ref::<UserHistoryCell>()
|
|
.map(|cell| cell.message.clone())
|
|
})
|
|
.collect();
|
|
assert_eq!(user_messages, vec!["first".to_string()]);
|
|
let overlay_cell_count = match app.overlay.as_ref() {
|
|
Some(Overlay::Transcript(t)) => t.committed_cell_count(),
|
|
_ => panic!("expected transcript overlay"),
|
|
};
|
|
assert_eq!(overlay_cell_count, app.transcript_cells.len());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_rollback_response_discards_queued_active_thread_events() {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
let (tx, rx) = mpsc::channel(8);
|
|
app.active_thread_id = Some(thread_id);
|
|
app.active_thread_rx = Some(rx);
|
|
tx.send(ThreadBufferedEvent::Notification(
|
|
ServerNotification::ConfigWarning(ConfigWarningNotification {
|
|
summary: "stale warning".to_string(),
|
|
details: None,
|
|
path: None,
|
|
range: None,
|
|
}),
|
|
))
|
|
.await
|
|
.expect("event should queue");
|
|
|
|
app.handle_thread_rollback_response(
|
|
thread_id,
|
|
/*num_turns*/ 1,
|
|
&ThreadRollbackResponse {
|
|
thread: Thread {
|
|
id: thread_id.to_string(),
|
|
session_id: thread_id.to_string(),
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
preview: String::new(),
|
|
ephemeral: false,
|
|
model_provider: "openai".to_string(),
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
status: codex_app_server_protocol::ThreadStatus::Idle,
|
|
path: None,
|
|
cwd: test_path_buf("/tmp/project").abs(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: SessionSource::Cli,
|
|
thread_source: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
git_info: None,
|
|
name: None,
|
|
turns: Vec::new(),
|
|
},
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let rx = app
|
|
.active_thread_rx
|
|
.as_mut()
|
|
.expect("active receiver should remain attached");
|
|
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn new_session_requests_shutdown_for_previous_conversation() {
|
|
Box::pin(async {
|
|
let (mut app, mut app_event_rx, mut op_rx) = Box::pin(make_test_app_with_channels()).await;
|
|
|
|
let thread_id = ThreadId::new();
|
|
let event = crate::session_state::ThreadSessionState {
|
|
thread_id,
|
|
forked_from_id: None,
|
|
fork_parent_title: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/home/user/project").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
};
|
|
|
|
app.chat_widget.handle_thread_session(event);
|
|
|
|
while app_event_rx.try_recv().is_ok() {}
|
|
while op_rx.try_recv().is_ok() {}
|
|
|
|
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
|
app.chat_widget.config_ref(),
|
|
))
|
|
.await
|
|
.expect("embedded app server");
|
|
Box::pin(app.shutdown_current_thread(&mut app_server)).await;
|
|
|
|
assert!(
|
|
op_rx.try_recv().is_err(),
|
|
"shutdown should not submit Op::Shutdown"
|
|
);
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(thread_id);
|
|
|
|
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
|
app.chat_widget.config_ref(),
|
|
))
|
|
.await
|
|
.expect("embedded app server");
|
|
let control = Box::pin(app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst)).await;
|
|
|
|
assert_eq!(app.pending_shutdown_exit_thread_id, None);
|
|
assert!(matches!(
|
|
control,
|
|
AppRunControl::Exit(ExitReason::UserRequested)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shutdown_first_exit_uses_app_server_shutdown_without_submitting_op() {
|
|
let (mut app, _app_event_rx, mut op_rx) = Box::pin(make_test_app_with_channels()).await;
|
|
let thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(thread_id);
|
|
|
|
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
|
app.chat_widget.config_ref(),
|
|
))
|
|
.await
|
|
.expect("embedded app server");
|
|
let control = Box::pin(app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst)).await;
|
|
|
|
assert_eq!(app.pending_shutdown_exit_thread_id, None);
|
|
assert!(matches!(
|
|
control,
|
|
AppRunControl::Exit(ExitReason::UserRequested)
|
|
));
|
|
assert!(
|
|
op_rx.try_recv().is_err(),
|
|
"shutdown should not submit Op::Shutdown"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn interrupt_without_active_turn_is_treated_as_handled() {
|
|
Box::pin(async {
|
|
let mut app = make_test_app().await;
|
|
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
|
|
app.chat_widget.config_ref(),
|
|
))
|
|
.await
|
|
.expect("embedded app server");
|
|
let started = app_server
|
|
.start_thread(app.chat_widget.config_ref())
|
|
.await
|
|
.expect("thread/start should succeed");
|
|
let thread_id = started.session.thread_id;
|
|
app.enqueue_primary_thread_session(started.session, started.turns)
|
|
.await
|
|
.expect("primary thread should be registered");
|
|
let op = AppCommand::interrupt();
|
|
|
|
let handled = Box::pin(app.try_submit_active_thread_op_via_app_server(
|
|
&mut app_server,
|
|
thread_id,
|
|
&op,
|
|
))
|
|
.await
|
|
.expect("interrupt submission should not fail");
|
|
|
|
assert_eq!(handled, true);
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn override_turn_context_sends_thread_settings_update() {
|
|
Box::pin(async {
|
|
let mut app = make_test_app().await;
|
|
let mut app_server =
|
|
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
|
|
.await
|
|
.expect("embedded app server");
|
|
let started = app_server
|
|
.start_thread(app.chat_widget.config_ref())
|
|
.await
|
|
.expect("thread/start should succeed");
|
|
let thread_id = started.session.thread_id;
|
|
let initial_model = started.session.model.clone();
|
|
let initial_effort = started.session.reasoning_effort.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(
|
|
ServerNotification::ThreadSettingsUpdated(notification),
|
|
),
|
|
)
|
|
.await;
|
|
let updated_session = app
|
|
.primary_session_configured
|
|
.as_ref()
|
|
.expect("primary session should be updated from notification");
|
|
assert_eq!(updated_session.model, initial_model);
|
|
assert_eq!(updated_session.reasoning_effort, initial_effort);
|
|
let updated_mode = updated_session
|
|
.collaboration_mode
|
|
.as_deref()
|
|
.expect("collaboration mode should be cached");
|
|
assert_eq!(updated_mode.mode, collaboration_mode.mode);
|
|
assert_eq!(
|
|
updated_mode.settings.model,
|
|
collaboration_mode.settings.model
|
|
);
|
|
assert_eq!(
|
|
updated_mode.settings.reasoning_effort,
|
|
collaboration_mode.settings.reasoning_effort
|
|
);
|
|
assert_eq!(updated_session.personality, Some(Personality::Pragmatic));
|
|
assert_eq!(updated_session.service_tier, Some(service_tier));
|
|
assert_eq!(updated_session.approval_policy, AskForApproval::OnRequest);
|
|
assert_eq!(
|
|
updated_session.approvals_reviewer,
|
|
ApprovalsReviewer::AutoReview
|
|
);
|
|
assert_eq!(
|
|
updated_session
|
|
.active_permission_profile
|
|
.as_ref()
|
|
.expect("active profile")
|
|
.id,
|
|
codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE
|
|
);
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_setting_update_params_sync_model_and_default_reasoning() {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(thread_id);
|
|
|
|
app.chat_widget.set_model("gpt-5.4");
|
|
let params = app
|
|
.active_thread_model_setting_update_params("gpt-5.4".to_string())
|
|
.expect("active thread should produce update params");
|
|
|
|
assert_eq!(params.thread_id, thread_id.to_string());
|
|
assert_eq!(params.model, Some("gpt-5.4".to_string()));
|
|
assert_eq!(
|
|
params
|
|
.collaboration_mode
|
|
.as_ref()
|
|
.expect("collaboration mode should sync with model")
|
|
.settings
|
|
.model,
|
|
"gpt-5.4"
|
|
);
|
|
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::Low));
|
|
app.chat_widget
|
|
.set_collaboration_mask(CollaborationModeMask {
|
|
name: "Plan".to_string(),
|
|
mode: Some(ModeKind::Plan),
|
|
model: Some("gpt-plan".to_string()),
|
|
reasoning_effort: Some(Some(ReasoningEffortConfig::Medium)),
|
|
developer_instructions: None,
|
|
});
|
|
app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High));
|
|
|
|
let params = app
|
|
.active_thread_reasoning_setting_update_params(Some(ReasoningEffortConfig::High))
|
|
.expect("active thread should produce update params");
|
|
|
|
assert_eq!(params.thread_id, thread_id.to_string());
|
|
assert_eq!(params.effort, Some(ReasoningEffortConfig::High));
|
|
let collaboration_mode = params
|
|
.collaboration_mode
|
|
.expect("collaboration mode should sync with reasoning");
|
|
assert_eq!(collaboration_mode.mode, ModeKind::Default);
|
|
assert_eq!(
|
|
collaboration_mode.settings.reasoning_effort,
|
|
Some(ReasoningEffortConfig::High)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inactive_thread_settings_notification_updates_cached_collaboration_mode() {
|
|
let mut app = make_test_app().await;
|
|
let primary_thread_id = ThreadId::new();
|
|
let inactive_thread_id = ThreadId::new();
|
|
let primary_session = test_thread_session(primary_thread_id, test_path_buf("/tmp/main"));
|
|
let inactive_session = test_thread_session(inactive_thread_id, test_path_buf("/tmp/inactive"));
|
|
let collaboration_mode = CollaborationMode {
|
|
mode: ModeKind::Plan,
|
|
settings: Settings {
|
|
model: "gpt-plan".to_string(),
|
|
reasoning_effort: Some(ReasoningEffortConfig::High),
|
|
developer_instructions: Some("draft a plan first".to_string()),
|
|
},
|
|
};
|
|
|
|
app.primary_thread_id = Some(primary_thread_id);
|
|
app.active_thread_id = Some(primary_thread_id);
|
|
app.primary_session_configured = Some(primary_session.clone());
|
|
app.thread_event_channels.insert(
|
|
primary_thread_id,
|
|
ThreadEventChannel::new_with_session(
|
|
THREAD_EVENT_CHANNEL_CAPACITY,
|
|
primary_session,
|
|
Vec::new(),
|
|
),
|
|
);
|
|
app.thread_event_channels.insert(
|
|
inactive_thread_id,
|
|
ThreadEventChannel::new_with_session(
|
|
THREAD_EVENT_CHANNEL_CAPACITY,
|
|
inactive_session,
|
|
Vec::new(),
|
|
),
|
|
);
|
|
|
|
let notification = ThreadSettingsUpdatedNotification {
|
|
thread_id: inactive_thread_id.to_string(),
|
|
thread_settings: ThreadSettings {
|
|
cwd: test_absolute_path("/tmp/thread-settings"),
|
|
approval_policy: AskForApproval::OnRequest,
|
|
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview,
|
|
sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly {
|
|
network_access: false,
|
|
},
|
|
active_permission_profile: Some(
|
|
codex_app_server_protocol::ActivePermissionProfile::read_only(),
|
|
),
|
|
model: "gpt-plan".to_string(),
|
|
model_provider: "openai".to_string(),
|
|
service_tier: None,
|
|
effort: collaboration_mode.settings.reasoning_effort.clone(),
|
|
summary: None,
|
|
collaboration_mode: collaboration_mode.clone(),
|
|
personality: Some(Personality::Pragmatic),
|
|
},
|
|
};
|
|
app.enqueue_thread_notification(
|
|
inactive_thread_id,
|
|
ServerNotification::ThreadSettingsUpdated(notification),
|
|
)
|
|
.await
|
|
.expect("settings notification should be cached");
|
|
|
|
let cached_session = app
|
|
.thread_event_channels
|
|
.get(&inactive_thread_id)
|
|
.expect("inactive thread channel")
|
|
.store
|
|
.lock()
|
|
.await
|
|
.session
|
|
.clone()
|
|
.expect("inactive session should remain cached");
|
|
assert_eq!(cached_session.model, "gpt-test");
|
|
assert_eq!(cached_session.personality, Some(Personality::Pragmatic));
|
|
assert_eq!(
|
|
cached_session.collaboration_mode.as_deref(),
|
|
Some(&collaboration_mode)
|
|
);
|
|
|
|
app.chat_widget.handle_thread_session(cached_session);
|
|
assert_eq!(
|
|
app.chat_widget.active_collaboration_mode_kind(),
|
|
ModeKind::Plan
|
|
);
|
|
assert_eq!(app.chat_widget.current_model(), "gpt-plan");
|
|
assert_eq!(
|
|
app.chat_widget.current_collaboration_mode().model(),
|
|
"gpt-test"
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.current_reasoning_effort(),
|
|
Some(ReasoningEffortConfig::High)
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.config_ref().personality,
|
|
Some(Personality::Pragmatic)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clear_only_ui_reset_preserves_chat_session_state() {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
app.chat_widget
|
|
.handle_thread_session(crate::session_state::ThreadSessionState {
|
|
thread_id,
|
|
forked_from_id: None,
|
|
fork_parent_title: None,
|
|
thread_name: Some("keep me".to_string()),
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/tmp/project").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
});
|
|
app.chat_widget
|
|
.apply_external_edit("draft prompt".to_string());
|
|
app.transcript_cells = vec![Arc::new(UserHistoryCell {
|
|
message: "old message".to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
}) as Arc<dyn HistoryCell>];
|
|
app.overlay = Some(Overlay::new_transcript(
|
|
app.transcript_cells.clone(),
|
|
crate::keymap::RuntimeKeymap::defaults().pager,
|
|
));
|
|
app.deferred_history_lines = vec![Line::from("stale buffered line").into()];
|
|
app.has_emitted_history_lines = true;
|
|
app.backtrack.primed = true;
|
|
app.backtrack.overlay_preview_active = true;
|
|
app.backtrack.nth_user_message = 0;
|
|
app.backtrack_render_pending = true;
|
|
|
|
app.reset_app_ui_state_after_clear();
|
|
|
|
assert!(app.overlay.is_none());
|
|
assert!(app.transcript_cells.is_empty());
|
|
assert!(app.deferred_history_lines.is_empty());
|
|
assert!(!app.has_emitted_history_lines);
|
|
assert!(!app.backtrack.primed);
|
|
assert!(!app.backtrack.overlay_preview_active);
|
|
assert!(app.backtrack.pending_rollback.is_none());
|
|
assert!(!app.backtrack_render_pending);
|
|
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
|
|
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn 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
|
|
}
|