mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
## What changed For live, retryable `ResponseStreamDisconnected` errors with HTTP status `429`, replace `Reconnecting... <attempt>` with `Server is busy, reconnecting <attempt>` in the TUI status header. ## Testing Update the stream recovery test to assert `Server is busy, reconnecting 2/5` and verify that the previous status is restored when streaming resumes. GitOrigin-RevId: 7fb97f61b856a830565b86ec1bac2ef8608875b8
2345 lines
89 KiB
Rust
2345 lines
89 KiB
Rust
use super::helpers::drain_insert_history_transcript;
|
||
use super::*;
|
||
use codex_app_server_protocol::AuthRecoveryNotification;
|
||
use codex_app_server_protocol::ThreadItem;
|
||
use codex_protocol::error::CodexErr;
|
||
use codex_protocol::error::CodexErrorDetails;
|
||
use pretty_assertions::assert_eq;
|
||
|
||
const SAFETY_BUFFERING_HEADER_TEXT: &str = "Giving this request a little extra thought";
|
||
|
||
fn thread_settings_for_test(
|
||
model: &str,
|
||
thread_id: ThreadId,
|
||
) -> codex_app_server_protocol::ThreadSettingsUpdatedNotification {
|
||
codex_app_server_protocol::ThreadSettingsUpdatedNotification {
|
||
thread_id: thread_id.to_string(),
|
||
thread_settings: codex_app_server_protocol::ThreadSettings {
|
||
disabled_plugin_ids: Vec::new(),
|
||
cwd: test_path_buf("/tmp/thread-settings").abs(),
|
||
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: model.to_string(),
|
||
model_provider: "openai".to_string(),
|
||
service_tier: Some(ServiceTier::Fast.request_value().to_string()),
|
||
effort: Some(ReasoningEffortConfig::High),
|
||
summary: None,
|
||
collaboration_mode: CollaborationMode {
|
||
mode: ModeKind::Plan,
|
||
settings: codex_protocol::config_types::Settings {
|
||
model: model.to_string(),
|
||
reasoning_effort: Some(ReasoningEffortConfig::High),
|
||
developer_instructions: None,
|
||
},
|
||
},
|
||
multi_agent_mode: Default::default(),
|
||
personality: None,
|
||
},
|
||
}
|
||
}
|
||
|
||
fn configured_thread_session(thread_id: ThreadId) -> crate::session_state::ThreadSessionState {
|
||
crate::session_state::ThreadSessionState {
|
||
daybreak_enabled: false,
|
||
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
||
thread_id,
|
||
forked_from_id: None,
|
||
fork_parent_title: None,
|
||
thread_name: None,
|
||
model: "gpt-5.2".to_string(),
|
||
model_provider_id: "openai".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/thread-settings").abs(),
|
||
runtime_workspace_roots: vec![test_path_buf("/tmp/thread-settings").abs()],
|
||
instruction_source_paths: Vec::new(),
|
||
reasoning_effort: None,
|
||
collaboration_mode: None,
|
||
message_history: None,
|
||
network_proxy: None,
|
||
rollout_path: None,
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn session_header_uses_catalog_display_name_without_changing_model() {
|
||
let slug = "us.openai.gpt-5.6-luna";
|
||
for (name, first_event, display_name) in [
|
||
("startup", true, Some("GPT-5.6 Luna")),
|
||
("resume", false, Some("GPT-5.6 Luna")),
|
||
("unknown_model", false, None),
|
||
] {
|
||
let (mut chat, mut events, _ops) = make_chatwidget_manual(Some(slug)).await;
|
||
let mut preset = get_available_model(&chat, "gpt-5.5");
|
||
preset.model = slug.to_string();
|
||
preset.display_name = display_name.unwrap_or_default().to_string();
|
||
chat.model_catalog = Arc::new(ModelCatalog::new(
|
||
display_name.map(|_| preset).into_iter().collect(),
|
||
));
|
||
chat.show_welcome_banner = first_event;
|
||
chat.local_settings.tui.show_tooltips = false;
|
||
let mut session = configured_thread_session(ThreadId::new());
|
||
session.model = slug.to_string();
|
||
session.reasoning_effort = Some(ReasoningEffortConfig::High);
|
||
chat.handle_thread_session(session);
|
||
|
||
let rendered = drain_insert_history_with(&mut events, HistoryCell::raw_lines)
|
||
.iter()
|
||
.map(|lines| lines_to_single_string(lines))
|
||
.collect::<String>()
|
||
.replace(CODEX_CLI_VERSION, "<VERSION>")
|
||
.replace("C:\\tmp\\thread-settings", "/tmp/thread-settings");
|
||
assert_chatwidget_snapshot!(format!("catalog_model_session_header_{name}"), rendered);
|
||
assert_eq!(chat.current_model(), slug);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn session_and_settings_sync_server_provider_id() {
|
||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||
chat.config.model_provider.base_url = Some("https://local-provider.example/v1".to_string());
|
||
chat.has_chatgpt_account = true;
|
||
chat.config
|
||
.features
|
||
.enable(Feature::CliDaybreak)
|
||
.expect("enable Daybreak");
|
||
chat.config
|
||
.model_providers
|
||
.insert("server-bedrock".into(), chat.config.model_provider.clone());
|
||
chat.local_settings.tui.status_line = Some(vec!["profile".into(), "provider".into()]);
|
||
chat.refresh_status_line();
|
||
assert_eq!(status_line_text(&chat), None);
|
||
let status = |chat: &mut ChatWidget| {
|
||
chat.add_status_output(
|
||
/*refreshing_rate_limits*/ false, /*request_id*/ None,
|
||
);
|
||
chat.transcript
|
||
.last_status_copy_targets
|
||
.as_ref()
|
||
.expect("status")
|
||
.handle
|
||
.copy_text()
|
||
};
|
||
assert!(!status(&mut chat).contains("Model provider:"));
|
||
|
||
let first_id = ThreadId::new();
|
||
let mut first = configured_thread_session(first_id);
|
||
first.model_provider_id = "server-ollama".to_string();
|
||
chat.status_account_display = Some(StatusAccountDisplay::ChatGpt {
|
||
email: None,
|
||
plan: None,
|
||
workspace: Some("account-123".to_string()),
|
||
});
|
||
let mut provider = chat.config.model_provider.clone();
|
||
provider.env_key = Some("CUSTOM_API_KEY".to_string());
|
||
provider.capabilities = Some(codex_model_provider_info::ModelProviderCapabilities {
|
||
cyber_access_programs: Some(true),
|
||
..Default::default()
|
||
});
|
||
chat.config
|
||
.model_providers
|
||
.insert(first.model_provider_id.clone(), provider);
|
||
chat.handle_thread_session(first.clone());
|
||
assert!(chat.daybreak_account_eligible());
|
||
let displayed = status(&mut chat);
|
||
assert!(displayed.contains("Model provider:") && displayed.contains("server-ollama"));
|
||
assert!(!displayed.contains("local-provider.example"));
|
||
assert!(!displayed.contains("ChatGPT workspace:"));
|
||
assert_eq!(
|
||
status_line_text(&chat).as_deref(),
|
||
Some("provider: server-ollama")
|
||
);
|
||
|
||
let mut other = configured_thread_session(ThreadId::new());
|
||
other.model_provider_id = "server-bedrock".to_string();
|
||
chat.handle_thread_session_quiet(other);
|
||
assert!(!chat.daybreak_account_eligible());
|
||
let displayed = status(&mut chat);
|
||
assert!(displayed.contains("server-bedrock"));
|
||
assert!(!displayed.contains("server-ollama"));
|
||
|
||
chat.handle_thread_session_quiet(first);
|
||
assert_eq!(chat.config.model_provider_id, "server-ollama");
|
||
assert!(chat.daybreak_account_eligible());
|
||
chat.bottom_pane
|
||
.set_composer_text("/daybreak".to_string(), Vec::new(), Vec::new());
|
||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("broader access"));
|
||
|
||
chat.config.config_layer_stack = chat
|
||
.config
|
||
.config_layer_stack
|
||
.with_user_config_profile(
|
||
&chat.config.codex_home.join("work.config.toml"),
|
||
Some(&"work".parse().unwrap()),
|
||
toml::Table::new().into(),
|
||
)
|
||
.unwrap();
|
||
let mut updated = thread_settings_for_test("gpt-5.4", first_id);
|
||
updated.thread_settings.model_provider = "server-updated".to_string();
|
||
chat.handle_server_notification(
|
||
ServerNotification::ThreadSettingsUpdated(updated),
|
||
/*replay_kind*/ None,
|
||
);
|
||
assert!(status(&mut chat).contains("server-updated"));
|
||
assert!(!chat.daybreak_account_eligible());
|
||
insta::assert_snapshot!(
|
||
status_line_text(&chat).unwrap(),
|
||
@"profile: work · provider: server-updated"
|
||
);
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert!(!popup.contains("broader access"));
|
||
assert_chatwidget_snapshot!("daybreak_hidden_after_provider_settings_change", popup);
|
||
|
||
let mut restored = thread_settings_for_test("gpt-5.4", first_id);
|
||
restored.thread_settings.model_provider = "server-ollama".to_string();
|
||
chat.handle_server_notification(
|
||
ServerNotification::ThreadSettingsUpdated(restored),
|
||
/*replay_kind*/ None,
|
||
);
|
||
assert!(chat.daybreak_account_eligible());
|
||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("broader access"));
|
||
}
|
||
|
||
fn start_safety_buffering_test_turn(
|
||
chat: &mut ChatWidget,
|
||
op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>,
|
||
) -> (ThreadId, &'static str, Op) {
|
||
let thread_id = ThreadId::new();
|
||
let turn_id = "turn-safety-buffering";
|
||
chat.thread_id = Some(thread_id);
|
||
chat.submit_user_message(UserMessage::from("Explain the request"));
|
||
let turn = next_submit_op(op_rx);
|
||
assert_matches!(&turn, Op::UserTurn { .. });
|
||
chat.record_safety_buffering_turn(turn_id.to_string(), &turn);
|
||
chat.handle_server_notification(
|
||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||
thread_id: thread_id.to_string(),
|
||
turn: AppServerTurn {
|
||
id: turn_id.to_string(),
|
||
root_turn_id: None,
|
||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||
items: Vec::new(),
|
||
status: AppServerTurnStatus::InProgress,
|
||
error: None,
|
||
started_at: Some(0),
|
||
completed_at: None,
|
||
duration_ms: None,
|
||
},
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
(thread_id, turn_id, turn)
|
||
}
|
||
|
||
fn safety_buffering_notification(
|
||
thread_id: ThreadId,
|
||
turn_id: &str,
|
||
faster_model: Option<&str>,
|
||
) -> ModelSafetyBufferingUpdatedNotification {
|
||
ModelSafetyBufferingUpdatedNotification {
|
||
thread_id: thread_id.to_string(),
|
||
turn_id: turn_id.to_string(),
|
||
model: "current-model".to_string(),
|
||
use_cases: Vec::new(),
|
||
reasons: Vec::new(),
|
||
show_buffering_ui: true,
|
||
faster_model: faster_model.map(str::to_string),
|
||
}
|
||
}
|
||
|
||
fn open_safety_buffering_retry_confirmation(
|
||
chat: &mut ChatWidget,
|
||
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||
) {
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
loop {
|
||
match rx.try_recv() {
|
||
Ok(AppEvent::ConfirmSafetyBufferedRetry {
|
||
thread_id,
|
||
turn_id,
|
||
model,
|
||
turn,
|
||
prompt,
|
||
}) => {
|
||
chat.confirm_safety_buffered_retry(thread_id, turn_id, model, turn, prompt);
|
||
break;
|
||
}
|
||
Ok(AppEvent::RetrySafetyBufferedTurn { .. }) => {
|
||
panic!("retry must wait for confirmation");
|
||
}
|
||
Ok(_) => continue,
|
||
Err(err) => panic!("expected safety-buffering confirmation event: {err}"),
|
||
}
|
||
}
|
||
assert!(chat.turn_lifecycle.agent_turn_running);
|
||
assert!(render_bottom_popup(chat, /*width*/ 80).contains("Stop this attempt and retry?"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn safety_buffering_offers_one_retry_with_app_wording() {
|
||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let mut preset = get_available_model(&chat, "gpt-5.5");
|
||
preset.model = "faster-model".to_string();
|
||
preset.display_name = "Faster Model".to_string();
|
||
chat.model_catalog = Arc::new(ModelCatalog::new(vec![preset]));
|
||
let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
|
||
|
||
let notification = safety_buffering_notification(thread_id, turn_id, Some("faster-model"));
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(notification.clone()),
|
||
/*replay_kind*/ None,
|
||
);
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(notification),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert_chatwidget_snapshot!("safety_buffering_retry_prompt", popup);
|
||
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
let opened_url = loop {
|
||
match rx.try_recv() {
|
||
Ok(AppEvent::OpenUrlInBrowser { url }) => break url,
|
||
Ok(_) => continue,
|
||
Err(err) => panic!("expected learn-more URL event: {err}"),
|
||
}
|
||
};
|
||
assert_eq!(opened_url, "https://help.openai.com/en/articles/20001326");
|
||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains(SAFETY_BUFFERING_HEADER_TEXT));
|
||
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
open_safety_buffering_retry_confirmation(&mut chat, &mut rx);
|
||
assert_chatwidget_snapshot!(
|
||
"safety_buffering_retry_confirmation",
|
||
render_bottom_popup(&chat, /*width*/ 80)
|
||
);
|
||
assert_chatwidget_snapshot!(
|
||
"safety_buffering_retry_confirmation_narrow",
|
||
render_bottom_popup(&chat, /*width*/ 40)
|
||
);
|
||
assert!(op_rx.try_recv().is_err());
|
||
while let Ok(event) = rx.try_recv() {
|
||
assert!(!matches!(event, AppEvent::RetrySafetyBufferedTurn { .. }));
|
||
}
|
||
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
let (event_thread_id, event_turn_id, model, turn, prompt) = loop {
|
||
match rx.try_recv() {
|
||
Ok(AppEvent::RetrySafetyBufferedTurn {
|
||
thread_id,
|
||
turn_id,
|
||
model,
|
||
turn,
|
||
prompt,
|
||
}) => break (thread_id, turn_id, model, turn, prompt),
|
||
Ok(_) => continue,
|
||
Err(err) => panic!("expected safety-buffering retry event: {err}"),
|
||
}
|
||
};
|
||
assert_eq!(event_thread_id, thread_id);
|
||
assert_eq!(event_turn_id, turn_id);
|
||
assert_eq!(model, "faster-model");
|
||
assert_matches!(turn, Op::UserTurn { .. });
|
||
assert_eq!(prompt, UserMessage::from("Explain the request"));
|
||
assert!(
|
||
!render_bottom_popup(&chat, /*width*/ 80)
|
||
.contains("Press enter to confirm or esc to go back")
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn safety_buffering_retry_confirmation_can_keep_waiting() {
|
||
for key in [KeyCode::Enter, KeyCode::Esc] {
|
||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
|
||
thread_id,
|
||
turn_id,
|
||
Some("faster-model"),
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
open_safety_buffering_retry_confirmation(&mut chat, &mut rx);
|
||
|
||
chat.handle_key_event(KeyEvent::new(key, KeyModifiers::NONE));
|
||
|
||
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Stop this attempt and retry?"));
|
||
assert!(chat.can_retry_safety_buffered_turn(turn_id));
|
||
assert!(op_rx.try_recv().is_err());
|
||
while let Ok(event) = rx.try_recv() {
|
||
assert!(!matches!(event, AppEvent::RetrySafetyBufferedTurn { .. }));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn safety_buffering_retry_confirmation_closes_when_turn_completes() {
|
||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let (thread_id, turn_id, turn) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
|
||
thread_id,
|
||
turn_id,
|
||
Some("faster-model"),
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
open_safety_buffering_retry_confirmation(&mut chat, &mut rx);
|
||
|
||
handle_turn_completed(&mut chat, turn_id, /*duration_ms*/ None);
|
||
// A queued request to open the confirmation must not reopen it after completion.
|
||
chat.confirm_safety_buffered_retry(
|
||
thread_id,
|
||
turn_id.to_string(),
|
||
"faster-model".to_string(),
|
||
turn,
|
||
UserMessage::from("Explain the request"),
|
||
);
|
||
|
||
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Stop this attempt and retry?"));
|
||
assert!(!chat.can_retry_safety_buffered_turn(turn_id));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn safety_buffering_does_not_offer_retry_in_side_conversation() {
|
||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.set_side_conversation_active(/*active*/ true);
|
||
let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
|
||
thread_id,
|
||
turn_id,
|
||
Some("faster-model"),
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert_chatwidget_snapshot!("safety_buffering_side_conversation_without_retry", popup);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn safety_buffering_retry_confirmation_closes_when_response_starts() {
|
||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
|
||
thread_id,
|
||
turn_id,
|
||
Some("faster-model"),
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
assert!(chat.can_retry_safety_buffered_turn(turn_id));
|
||
open_safety_buffering_retry_confirmation(&mut chat, &mut rx);
|
||
|
||
chat.on_agent_message_delta("Visible response".to_string());
|
||
|
||
assert!(!chat.can_retry_safety_buffered_turn(turn_id));
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert!(!popup.contains("Stop this attempt and retry?"));
|
||
assert!(!popup.contains(SAFETY_BUFFERING_HEADER_TEXT));
|
||
|
||
for (show_buffering_ui, faster_model) in [
|
||
(true, Some("faster-model")),
|
||
(true, None),
|
||
(false, None),
|
||
(true, Some("faster-model")),
|
||
] {
|
||
let mut notification = safety_buffering_notification(thread_id, turn_id, faster_model);
|
||
notification.show_buffering_ui = show_buffering_ui;
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(notification),
|
||
/*replay_kind*/ None,
|
||
);
|
||
assert!(!chat.can_retry_safety_buffered_turn(turn_id));
|
||
assert_eq!(render_bottom_popup(&chat, /*width*/ 80), popup);
|
||
}
|
||
|
||
handle_turn_completed(&mut chat, turn_id, /*duration_ms*/ None);
|
||
|
||
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains(SAFETY_BUFFERING_HEADER_TEXT));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn safety_buffering_without_retry_shows_short_app_message() {
|
||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let (thread_id, turn_id, turn) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
|
||
thread_id, turn_id, /*faster_model*/ None,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let render_popup = |chat: &ChatWidget| {
|
||
normalize_snapshot_paths(render_bottom_popup(chat, /*width*/ 80))
|
||
};
|
||
let popup = render_popup(&chat);
|
||
assert_chatwidget_snapshot!("safety_buffering_status_without_retry", popup,);
|
||
|
||
let notification = safety_buffering_notification(thread_id, turn_id, Some("faster-model"));
|
||
chat.record_safety_buffering_turn("other-turn".to_string(), &turn);
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(notification.clone()),
|
||
/*replay_kind*/ None,
|
||
);
|
||
assert_eq!(render_popup(&chat), popup);
|
||
|
||
chat.record_safety_buffering_turn(turn_id.to_string(), &turn);
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(notification),
|
||
Some(ReplayKind::ThreadSnapshot),
|
||
);
|
||
assert_eq!(render_popup(&chat), popup);
|
||
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
assert!(
|
||
!render_bottom_popup(&chat, /*width*/ 80)
|
||
.contains("Press enter to confirm or esc to go back")
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn safety_buffering_ignores_hidden_stale_and_historical_updates() {
|
||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx);
|
||
|
||
let mut hidden = safety_buffering_notification(thread_id, turn_id, Some("faster-model"));
|
||
hidden.show_buffering_ui = false;
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(hidden),
|
||
/*replay_kind*/ None,
|
||
);
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
|
||
thread_id,
|
||
"stale-turn",
|
||
Some("faster-model"),
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification(
|
||
thread_id,
|
||
turn_id,
|
||
Some("faster-model"),
|
||
)),
|
||
Some(ReplayKind::ResumeInitialMessages),
|
||
);
|
||
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains(SAFETY_BUFFERING_HEADER_TEXT));
|
||
|
||
let mut hidden = safety_buffering_notification(thread_id, turn_id, Some("faster-model"));
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(hidden.clone()),
|
||
/*replay_kind*/ None,
|
||
);
|
||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains(SAFETY_BUFFERING_HEADER_TEXT));
|
||
open_safety_buffering_retry_confirmation(&mut chat, &mut rx);
|
||
hidden.show_buffering_ui = false;
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelSafetyBufferingUpdated(hidden),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
assert_eq!(
|
||
chat.bottom_pane
|
||
.status_widget()
|
||
.expect("status indicator should be visible")
|
||
.details(),
|
||
None
|
||
);
|
||
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains(SAFETY_BUFFERING_HEADER_TEXT));
|
||
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Stop this attempt and retry?"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn tool_suggestion_install_url_is_validated_before_opening() {
|
||
for install_url in [
|
||
"file:///tmp/connector",
|
||
"http://example.test/install",
|
||
"custom://example.test/install",
|
||
"not a URL",
|
||
"https://user:password@example.test/install",
|
||
"https://example.test/install",
|
||
] {
|
||
let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await;
|
||
let request_thread_id = ThreadId::new();
|
||
chat.thread_id = Some(ThreadId::new());
|
||
let initial_popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
|
||
chat.handle_elicitation_request_now(
|
||
codex_app_server_protocol::RequestId::Integer(9),
|
||
codex_app_server_protocol::McpServerElicitationRequestParams {
|
||
thread_id: request_thread_id.to_string(),
|
||
turn_id: Some("turn-install".to_string()),
|
||
server_name: "connector-server".to_string(),
|
||
request: codex_app_server_protocol::McpServerElicitationRequest::Form {
|
||
meta: Some(serde_json::json!({
|
||
"codex_approval_kind": "tool_suggestion",
|
||
"tool_type": "connector",
|
||
"suggest_type": "install",
|
||
"suggest_reason": "Install the connector to continue",
|
||
"tool_id": "connector_test",
|
||
"tool_name": "Test Connector",
|
||
"install_url": install_url,
|
||
})),
|
||
message: "Install Test Connector".to_string(),
|
||
requested_schema: serde_json::from_value(serde_json::json!({
|
||
"type": "object",
|
||
"properties": {},
|
||
}))
|
||
.expect("valid schema"),
|
||
},
|
||
},
|
||
);
|
||
|
||
if install_url == "https://example.test/install" {
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
assert_matches!(
|
||
rx.try_recv(),
|
||
Ok(AppEvent::OpenUrlInBrowser { url }) if url == install_url
|
||
);
|
||
} else {
|
||
assert_matches!(
|
||
rx.try_recv(),
|
||
Ok(AppEvent::SubmitThreadOp {
|
||
thread_id,
|
||
op: Op::ResolveElicitation {
|
||
server_name,
|
||
request_id: codex_app_server_protocol::RequestId::Integer(9),
|
||
decision: codex_app_server_protocol::McpServerElicitationAction::Decline,
|
||
content: None,
|
||
meta: None,
|
||
},
|
||
}) if thread_id == request_thread_id && server_name == "connector-server"
|
||
);
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert_eq!(popup, initial_popup);
|
||
assert_chatwidget_snapshot!(
|
||
"declined_tool_suggestion",
|
||
normalize_snapshot_paths(popup)
|
||
);
|
||
}
|
||
assert!(rx.try_recv().is_err());
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn invalid_url_elicitation_is_declined() {
|
||
let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await;
|
||
let visible_thread_id = ThreadId::new();
|
||
let request_thread_id = ThreadId::new();
|
||
chat.thread_id = Some(visible_thread_id);
|
||
|
||
chat.handle_elicitation_request_now(
|
||
codex_app_server_protocol::RequestId::Integer(9),
|
||
codex_app_server_protocol::McpServerElicitationRequestParams {
|
||
thread_id: request_thread_id.to_string(),
|
||
turn_id: Some("turn-auth".to_string()),
|
||
server_name: "payments".to_string(),
|
||
request: codex_app_server_protocol::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_matches!(
|
||
rx.try_recv(),
|
||
Ok(AppEvent::SubmitThreadOp {
|
||
thread_id: op_thread_id,
|
||
op: Op::ResolveElicitation {
|
||
server_name,
|
||
request_id: codex_app_server_protocol::RequestId::Integer(9),
|
||
decision: codex_app_server_protocol::McpServerElicitationAction::Decline,
|
||
content: None,
|
||
meta: None,
|
||
},
|
||
}) if op_thread_id == request_thread_id && server_name == "payments"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn thread_settings_updated_updates_visible_state_without_transcript() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||
set_chatgpt_auth(&mut chat);
|
||
set_fast_mode_test_catalog(&mut chat);
|
||
chat.set_feature_enabled(Feature::Apps, /*enabled*/ true);
|
||
let thread_id = ThreadId::new();
|
||
let mut session = configured_thread_session(thread_id);
|
||
session.cwd = test_path_buf("/tmp/original-workspace").abs();
|
||
chat.handle_thread_session(session);
|
||
chat.config.permissions.approval_policy =
|
||
Constrained::allow_only(AskForApproval::Never.to_core());
|
||
let previous_generation = chat.connector_scope_generation();
|
||
let old_app = serde_json::from_str(r#"{"id":"old","name":"Old","isAccessible":true}"#)
|
||
.expect("valid app");
|
||
chat.connectors.mention_snapshot = Some(crate::app_event::ConnectorsSnapshot {
|
||
connectors: vec![old_app],
|
||
});
|
||
let _ = drain_insert_history(&mut rx);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ThreadSettingsUpdated(thread_settings_for_test("gpt-5.4", thread_id)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
assert_ne!(chat.connector_scope_generation(), previous_generation);
|
||
assert!(chat.connectors_for_mentions().is_none());
|
||
assert!(chat.connectors.mention_refresh_in_flight);
|
||
assert_eq!(chat.current_model(), "gpt-5.4");
|
||
assert_eq!(
|
||
chat.current_reasoning_effort(),
|
||
Some(ReasoningEffortConfig::High)
|
||
);
|
||
assert_eq!(
|
||
chat.current_service_tier(),
|
||
Some(ServiceTier::Fast.request_value())
|
||
);
|
||
assert_eq!(
|
||
chat.config_ref().permissions.approval_policy.value(),
|
||
AskForApproval::OnRequest.to_core()
|
||
);
|
||
assert_eq!(
|
||
chat.config_ref().approvals_reviewer,
|
||
ApprovalsReviewer::AutoReview
|
||
);
|
||
assert_eq!(
|
||
chat.config_ref()
|
||
.permissions
|
||
.active_permission_profile()
|
||
.expect("active profile")
|
||
.id,
|
||
codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY
|
||
);
|
||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
||
assert!(
|
||
drain_insert_history(&mut rx).is_empty(),
|
||
"ThreadSettingsUpdated should not render transcript history"
|
||
);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ThreadSettingsUpdated(thread_settings_for_test(
|
||
"gpt-5.2",
|
||
ThreadId::new(),
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
assert_eq!(chat.current_model(), "gpt-5.4");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn thread_settings_updated_preserves_default_settings_for_plan_mode() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||
let thread_id = ThreadId::new();
|
||
let mut session = configured_thread_session(thread_id);
|
||
session.model = "gpt-default".to_string();
|
||
session.reasoning_effort = Some(ReasoningEffortConfig::Low);
|
||
chat.handle_thread_session(session);
|
||
let _ = drain_insert_history(&mut rx);
|
||
let default_mode = chat.current_collaboration_mode().clone();
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ThreadSettingsUpdated(thread_settings_for_test("gpt-plan", thread_id)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
||
assert_eq!(chat.current_model(), "gpt-plan");
|
||
assert_eq!(
|
||
chat.current_reasoning_effort(),
|
||
Some(ReasoningEffortConfig::High)
|
||
);
|
||
assert_eq!(chat.current_collaboration_mode(), &default_mode);
|
||
|
||
let default_mask = collaboration_modes::default_mask(chat.model_catalog.as_ref())
|
||
.expect("expected default collaboration mode");
|
||
chat.set_collaboration_mask(default_mask);
|
||
|
||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default);
|
||
assert_eq!(chat.current_model(), "gpt-default");
|
||
assert_eq!(
|
||
chat.current_reasoning_effort(),
|
||
Some(ReasoningEffortConfig::Low)
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn collab_spawn_end_shows_requested_model_and_effort() {
|
||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let sender_thread_id = ThreadId::new();
|
||
let spawned_thread_id = ThreadId::new();
|
||
chat.set_collab_agent_metadata(
|
||
spawned_thread_id,
|
||
Some("Robie".to_string()),
|
||
Some("explorer".to_string()),
|
||
);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemStarted(ItemStartedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
AppServerThreadItemPayload::CollabAgentToolCall {
|
||
id: "call-spawn".to_string(),
|
||
tool: AppServerCollabAgentTool::SpawnAgent,
|
||
status: AppServerCollabAgentToolCallStatus::InProgress,
|
||
sender_thread_id: sender_thread_id.to_string(),
|
||
receiver_thread_ids: Vec::new(),
|
||
prompt: Some("Explore the repo".to_string()),
|
||
model: Some("gpt-5".to_string()),
|
||
reasoning_effort: Some(ReasoningEffortConfig::High),
|
||
agents_states: HashMap::new(),
|
||
},
|
||
/*started_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
AppServerThreadItemPayload::CollabAgentToolCall {
|
||
id: "call-spawn".to_string(),
|
||
tool: AppServerCollabAgentTool::SpawnAgent,
|
||
status: AppServerCollabAgentToolCallStatus::Completed,
|
||
sender_thread_id: sender_thread_id.to_string(),
|
||
receiver_thread_ids: vec![spawned_thread_id.to_string()],
|
||
prompt: Some("Explore the repo".to_string()),
|
||
model: None,
|
||
reasoning_effort: None,
|
||
agents_states: HashMap::from([(
|
||
spawned_thread_id.to_string(),
|
||
AppServerCollabAgentState {
|
||
status: AppServerCollabAgentStatus::PendingInit,
|
||
message: None,
|
||
},
|
||
)]),
|
||
},
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let cells = drain_insert_history(&mut rx);
|
||
let rendered = cells
|
||
.iter()
|
||
.map(|lines| lines_to_single_string(lines))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
|
||
assert!(
|
||
rendered.contains("Spawned Robie [explorer] (gpt-5 high)"),
|
||
"expected spawn line to include agent metadata and requested model, got {rendered:?}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_user_message_item_completed_does_not_duplicate_rendered_prompt() {
|
||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.thread_id = Some(ThreadId::new());
|
||
|
||
chat.bottom_pane
|
||
.set_composer_text("Hi, are you there?".to_string(), Vec::new(), Vec::new());
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
match next_submit_op(&mut op_rx) {
|
||
Op::UserTurn { .. } => {}
|
||
other => panic!("expected Op::UserTurn, got {other:?}"),
|
||
}
|
||
|
||
let inserted = drain_insert_history(&mut rx);
|
||
assert_eq!(inserted.len(), 1);
|
||
assert!(lines_to_single_string(&inserted[0]).contains("Hi, are you there?"));
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
AppServerThreadItemPayload::UserMessage {
|
||
id: "user-1".to_string(),
|
||
client_id: None,
|
||
content: vec![AppServerUserInput::Text {
|
||
text: "Hi, are you there?".to_string(),
|
||
text_elements: Vec::new(),
|
||
}],
|
||
},
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
assert!(drain_insert_history(&mut rx).is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_user_message_omits_unsupported_media() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
AppServerThreadItemPayload::UserMessage {
|
||
id: "user-1".to_string(),
|
||
client_id: None,
|
||
content: vec![
|
||
AppServerUserInput::Text {
|
||
text: "Please inspect the attachments.".to_string(),
|
||
text_elements: Vec::new(),
|
||
},
|
||
AppServerUserInput::Audio {
|
||
audio: codex_app_server_protocol::AudioReference::Inline {
|
||
url: "https://example.com/one.wav".to_string(),
|
||
},
|
||
},
|
||
AppServerUserInput::LocalAudio {
|
||
path: test_path_buf("/tmp/two.wav"),
|
||
},
|
||
],
|
||
},
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let inserted = drain_insert_history(&mut rx);
|
||
assert_eq!(inserted.len(), 1);
|
||
assert_chatwidget_snapshot!(
|
||
"live_app_server_user_message_omits_unsupported_media",
|
||
lines_to_single_string(&inserted[0]),
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_turn_completed_clears_working_status_after_answer_item() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
turn: AppServerTurn {
|
||
id: "turn-1".to_string(),
|
||
root_turn_id: None,
|
||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||
items: Vec::new(),
|
||
status: AppServerTurnStatus::InProgress,
|
||
error: None,
|
||
started_at: Some(0),
|
||
completed_at: None,
|
||
duration_ms: None,
|
||
},
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
assert!(chat.bottom_pane.is_task_running());
|
||
let status = chat
|
||
.bottom_pane
|
||
.status_widget()
|
||
.expect("status indicator should be visible");
|
||
assert_eq!(status.header(), "Working");
|
||
|
||
let item = AppServerThreadItemPayload::AgentMessage {
|
||
id: "msg-1".to_string(),
|
||
text: "Yes. What do you need?".to_string(),
|
||
phase: Some(MessagePhase::FinalAnswer),
|
||
memory_citation: None,
|
||
delivery: None,
|
||
questions: None,
|
||
};
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
item.clone(),
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let cells = drain_insert_history(&mut rx);
|
||
assert_eq!(cells.len(), 1);
|
||
assert!(lines_to_single_string(&cells[0]).contains("Yes. What do you need?"));
|
||
assert!(chat.bottom_pane.is_task_running());
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
turn: AppServerTurn {
|
||
id: "turn-1".to_string(),
|
||
root_turn_id: None,
|
||
items_view: codex_app_server_protocol::TurnItemsView::Summary,
|
||
items: vec![ThreadItem::untimed(item)],
|
||
status: AppServerTurnStatus::Completed,
|
||
error: None,
|
||
started_at: None,
|
||
completed_at: Some(0),
|
||
duration_ms: None,
|
||
},
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let completion_cells = drain_insert_history_normalized(&mut rx)
|
||
.iter()
|
||
.map(|lines| lines_to_single_string(lines).trim().to_string())
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(
|
||
completion_cells,
|
||
vec!["Worked for [duration] • [completion time]"]
|
||
);
|
||
assert!(!chat.bottom_pane.is_task_running());
|
||
assert!(chat.bottom_pane.status_widget().is_none());
|
||
assert_eq!(
|
||
chat.transcript.last_completed_agent_message,
|
||
Some(("turn-1".to_string(), "msg-1".to_string()))
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_turn_started_sets_feedback_turn_id() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
turn: AppServerTurn {
|
||
id: "turn-1".to_string(),
|
||
root_turn_id: None,
|
||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||
items: Vec::new(),
|
||
status: AppServerTurnStatus::InProgress,
|
||
error: None,
|
||
started_at: Some(0),
|
||
completed_at: None,
|
||
duration_ms: None,
|
||
},
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
chat.open_feedback_note(
|
||
crate::app_event::FeedbackCategory::Bug,
|
||
/*include_logs*/ false,
|
||
crate::bottom_pane::FeedbackAudience::External,
|
||
);
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert_matches!(
|
||
rx.try_recv(),
|
||
Ok(AppEvent::SubmitFeedback {
|
||
category: crate::app_event::FeedbackCategory::Bug,
|
||
reason: None,
|
||
turn_id: Some(turn_id),
|
||
include_logs: false,
|
||
}) if turn_id == "turn-1"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_warning_notification_renders_message() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::Warning(WarningNotification {
|
||
thread_id: None,
|
||
message: "Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list.".to_string(),
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let cells = drain_insert_history_transcript(&mut rx);
|
||
assert_eq!(cells.len(), 1, "expected one warning history cell");
|
||
let rendered = lines_to_single_string(&cells[0]);
|
||
let normalized = rendered.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
assert!(
|
||
normalized.contains("Exceeded skills context budget of 2%."),
|
||
"expected warning notification message, got {rendered}"
|
||
);
|
||
assert!(
|
||
normalized.contains(
|
||
"All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
|
||
),
|
||
"expected warning guidance, got {rendered}"
|
||
);
|
||
|
||
let notification = AuthRecoveryNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
turn_id: "turn-1".to_string(),
|
||
provider: "example".to_string(),
|
||
message: "Session expired. Sign in again.".to_string(),
|
||
};
|
||
let mut recovery_messages = String::new();
|
||
for notification in [
|
||
ServerNotification::AuthRecoveryStarted(notification.clone()),
|
||
ServerNotification::AuthRecoveryCompleted(AuthRecoveryNotification {
|
||
message: "Signed in successfully.".to_string(),
|
||
..notification
|
||
}),
|
||
] {
|
||
chat.handle_server_notification(notification, /*replay_kind*/ None);
|
||
|
||
let [cell] = drain_insert_history_transcript(&mut rx)
|
||
.try_into()
|
||
.expect("expected one authentication recovery history cell");
|
||
recovery_messages.push_str(&lines_to_single_string(&cell));
|
||
}
|
||
insta::assert_snapshot!(recovery_messages, @r"
|
||
• Session expired. Sign in again.
|
||
✓ Signed in successfully.
|
||
");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_guardian_warning_notification_renders_message() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::GuardianWarning(GuardianWarningNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
message: "Automatic approval review denied the requested action.".to_string(),
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let cells = drain_insert_history_transcript(&mut rx);
|
||
assert_eq!(cells.len(), 1, "expected one warning history cell");
|
||
let rendered = lines_to_single_string(&cells[0]);
|
||
assert!(
|
||
rendered.contains("Automatic approval review denied the requested action."),
|
||
"expected guardian warning notification message, got {rendered}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_strict_review_required_notification_renders_message() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
handle_turn_started(&mut chat, "turn-1");
|
||
begin_exec(&mut chat, "cmd-1", "printf 'streamed output\\n'");
|
||
drain_insert_history(&mut rx);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::StrictReviewRequired(
|
||
codex_app_server_protocol::StrictReviewRequiredNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
turn_id: "turn-1".to_string(),
|
||
started_at_ms: 1_000,
|
||
},
|
||
),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let cells = drain_insert_history_transcript(&mut rx);
|
||
assert_eq!(cells.len(), 1, "expected one warning history cell");
|
||
assert_chatwidget_snapshot!("strict_review_required", lines_to_single_string(&cells[0]));
|
||
chat.on_exec_command_output_delta("cmd-1", "streamed output\n");
|
||
assert!(
|
||
lines_to_single_string(
|
||
&chat
|
||
.active_cell_transcript_lines(/*width*/ 80)
|
||
.expect("strict review should preserve the active command")
|
||
)
|
||
.contains("streamed output")
|
||
);
|
||
assert!(chat.bottom_pane.is_task_running());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn config_warning_during_turn_retains_transcript_details() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
handle_turn_started(&mut chat, "turn-1");
|
||
chat.handle_server_notification(
|
||
ServerNotification::ConfigWarning(ConfigWarningNotification {
|
||
summary: "Invalid configuration; using defaults.".into(),
|
||
details: Some("Check the project configuration.".into()),
|
||
path: None,
|
||
range: None,
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
let cells = drain_insert_history_transcript(&mut rx);
|
||
insta::assert_snapshot!(
|
||
"runtime_config_warning",
|
||
cells
|
||
.iter()
|
||
.map(|lines| lines_to_single_string(lines))
|
||
.collect::<String>()
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn sqlite_recovery_warning_shows_backup_and_metadata_limitations() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.handle_server_notification(
|
||
ServerNotification::ConfigWarning(ConfigWarningNotification {
|
||
summary: "Codex rebuilt its local database".into(),
|
||
details: Some("Damaged local databases were rebuilt. Saved conversations remain in rollout files and can restore the thread list and history. Some database-only metadata may be unavailable. The original database files were preserved at the backup locations below.\n\nDatabase path: /codex/state_5.sqlite\nBackup folder: /codex/db-backups/recovery".into()),
|
||
path: None,
|
||
range: None,
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
let cells = drain_insert_history_transcript(&mut rx);
|
||
insta::assert_snapshot!(
|
||
"sqlite_recovery_warning",
|
||
cells
|
||
.iter()
|
||
.map(|lines| lines_to_single_string(lines))
|
||
.collect::<String>()
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn startup_config_warning_is_not_repeated_by_thread() {
|
||
let mut warnings = Vec::new();
|
||
for (local_daemon, managed_daemon) in [(false, false), (true, true), (true, false)] {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
if local_daemon {
|
||
chat.remote_connection =
|
||
Some(crate::status::remote_connection::RemoteConnectionStatus {
|
||
address: "local".into(),
|
||
version: "v0.153.0".into(),
|
||
is_local_daemon: true,
|
||
is_managed_daemon: managed_daemon,
|
||
});
|
||
}
|
||
let message = "Codex is ignoring 1 unrecognized configuration setting. Check for typos or deprecated settings.";
|
||
for notification in [
|
||
ServerNotification::ConfigWarning(ConfigWarningNotification {
|
||
summary: message.to_string(),
|
||
details: None,
|
||
path: None,
|
||
range: None,
|
||
}),
|
||
ServerNotification::Warning(WarningNotification {
|
||
thread_id: Some("thread-1".to_string()),
|
||
message: message.to_string(),
|
||
}),
|
||
ServerNotification::ConfigWarning(ConfigWarningNotification {
|
||
summary: "Second config warning".into(),
|
||
details: Some("A different setting needs attention.".into()),
|
||
path: None,
|
||
range: None,
|
||
}),
|
||
] {
|
||
chat.handle_server_notification(notification, /*replay_kind*/ None);
|
||
}
|
||
let cells: Vec<Arc<dyn HistoryCell>> = std::iter::from_fn(|| rx.try_recv().ok())
|
||
.filter_map(|event| match event {
|
||
AppEvent::InsertHistoryCell(cell) => Some(Arc::from(cell)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(cells.len(), 2);
|
||
let original_warnings = history_cell::warning_entries(&cells);
|
||
chat.open_warnings(&cells);
|
||
let first = super::helpers::render_bottom_popup(&chat, /*width*/ 80);
|
||
if local_daemon && !managed_daemon {
|
||
assert!(
|
||
!first.contains("/daemon"),
|
||
"unmanaged servers cannot be restarted"
|
||
);
|
||
} else {
|
||
warnings.push(format!("local daemon: {local_daemon}\n{first}"));
|
||
}
|
||
chat.handle_key_event(KeyCode::Right.into());
|
||
let second = super::helpers::render_bottom_popup(&chat, /*width*/ 80);
|
||
assert!(second.contains("Second config warning"));
|
||
assert!(
|
||
!second.contains("/daemon"),
|
||
"one restart hint per warning panel"
|
||
);
|
||
chat.handle_key_event(KeyCode::Esc.into());
|
||
let dismissed = std::iter::from_fn(|| rx.try_recv().ok())
|
||
.find_map(|event| match event {
|
||
AppEvent::UpdateWarnings { dismissed, .. } => Some(dismissed),
|
||
_ => None,
|
||
})
|
||
.expect("closing the panel dismisses the viewed diagnostics");
|
||
assert_eq!(dismissed, original_warnings);
|
||
}
|
||
insta::assert_snapshot!("startup_config_warning_panel", warnings.join("\n\n"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_file_change_item_started_preserves_changes() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
let item = AppServerThreadItemPayload::FileChange {
|
||
id: "patch-1".to_string(),
|
||
changes: vec![FileUpdateChange {
|
||
path: "foo.txt".to_string(),
|
||
kind: PatchChangeKind::Add,
|
||
diff: "hello\n".to_string(),
|
||
}],
|
||
status: AppServerPatchApplyStatus::InProgress,
|
||
};
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemStarted(ItemStartedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
item.clone(),
|
||
/*started_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let cells = drain_insert_history(&mut rx);
|
||
assert!(!cells.is_empty(), "expected patch history to be rendered");
|
||
let transcript = lines_to_single_string(cells.last().expect("patch cell"));
|
||
let AppServerThreadItemPayload::FileChange { id, changes, .. } = item else {
|
||
unreachable!()
|
||
};
|
||
chat.replay_thread_item(
|
||
AppServerThreadItemPayload::FileChange {
|
||
id,
|
||
changes,
|
||
status: AppServerPatchApplyStatus::Completed,
|
||
},
|
||
"turn-1".to_string(),
|
||
ReplayKind::ResumeInitialMessages,
|
||
);
|
||
let replayed = drain_insert_history(&mut rx);
|
||
assert_eq!(replayed.len(), 1);
|
||
assert_eq!(lines_to_single_string(&replayed[0]), transcript);
|
||
insta::assert_snapshot!(transcript, @"
|
||
• Added foo.txt (+1 -0)
|
||
1 +hello
|
||
");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_command_execution_strips_shell_wrapper() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let script = r#"python3 -c 'print("Hello, world!")'"#;
|
||
let command =
|
||
shlex::try_join(["/bin/zsh", "-lc", script]).expect("round-trippable shell wrapper");
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemStarted(ItemStartedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
AppServerThreadItemPayload::CommandExecution {
|
||
model_context: None,
|
||
sandbox_type: None,
|
||
id: "cmd-1".to_string(),
|
||
command: command.clone(),
|
||
cwd: test_path_buf("/tmp").abs().into(),
|
||
process_id: None,
|
||
plugin_id: None,
|
||
script_path: None,
|
||
source: AppServerCommandExecutionSource::UserShell,
|
||
status: AppServerCommandExecutionStatus::InProgress,
|
||
command_actions: vec![AppServerCommandAction::Unknown {
|
||
command: script.to_string(),
|
||
}],
|
||
aggregated_output: None,
|
||
exit_code: None,
|
||
duration_ms: None,
|
||
},
|
||
/*started_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
AppServerThreadItemPayload::CommandExecution {
|
||
model_context: None,
|
||
sandbox_type: None,
|
||
id: "cmd-1".to_string(),
|
||
command,
|
||
cwd: test_path_buf("/tmp").abs().into(),
|
||
process_id: None,
|
||
plugin_id: None,
|
||
script_path: None,
|
||
source: AppServerCommandExecutionSource::UserShell,
|
||
status: AppServerCommandExecutionStatus::Completed,
|
||
command_actions: vec![AppServerCommandAction::Unknown {
|
||
command: script.to_string(),
|
||
}],
|
||
aggregated_output: Some("Hello, world!\n".to_string()),
|
||
exit_code: Some(0),
|
||
duration_ms: Some(5),
|
||
},
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let cells = drain_insert_history(&mut rx);
|
||
assert_eq!(
|
||
cells.len(),
|
||
1,
|
||
"expected one completed command history cell"
|
||
);
|
||
let blob = lines_to_single_string(cells.first().expect("command cell"));
|
||
assert_chatwidget_snapshot!(
|
||
"live_app_server_command_execution_strips_shell_wrapper",
|
||
blob
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_command_output_delta_transcript_snapshot() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.on_task_started();
|
||
begin_exec(&mut chat, "cmd-1", "printf 'stdout\\nstderr\\n'");
|
||
|
||
for delta in ["stdout\n", "stderr\n"] {
|
||
chat.handle_server_notification(
|
||
ServerNotification::CommandExecutionOutputDelta(
|
||
codex_app_server_protocol::CommandExecutionOutputDeltaNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
turn_id: "turn-1".to_string(),
|
||
item_id: "cmd-1".to_string(),
|
||
delta: delta.to_string(),
|
||
},
|
||
),
|
||
/*replay_kind*/ None,
|
||
);
|
||
}
|
||
|
||
let active = active_blob(&chat);
|
||
assert_chatwidget_snapshot!("live_app_server_command_output_delta_active", active);
|
||
|
||
let transcript = chat
|
||
.active_cell_transcript_lines(/*width*/ 80)
|
||
.expect("active exec transcript lines");
|
||
assert_chatwidget_snapshot!(
|
||
"live_app_server_command_output_delta_transcript",
|
||
lines_to_single_string(&transcript)
|
||
);
|
||
|
||
handle_turn_interrupted(&mut chat, "turn-1");
|
||
let mut completed = None;
|
||
while let Ok(event) = rx.try_recv() {
|
||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||
let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80));
|
||
if transcript.contains("printf 'stdout\\nstderr\\n'") {
|
||
completed = Some(transcript);
|
||
}
|
||
}
|
||
}
|
||
let completed = completed.expect("expected the interrupted command in history");
|
||
let completed = regex_lite::Regex::new(r"(?m) • (?:\d+ms|\d+\.\d+s|\d+m \d+s)$")
|
||
.expect("valid duration regex")
|
||
.replace(&completed, " • <duration>");
|
||
assert_chatwidget_snapshot!(
|
||
"live_app_server_command_output_delta_interrupted",
|
||
completed
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_sub_agent_activity_renders_once() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let activity = AppServerThreadItemPayload::SubAgentActivity {
|
||
model: None,
|
||
reasoning_effort: None,
|
||
id: "activity-1".to_string(),
|
||
kind: codex_app_server_protocol::SubAgentActivityKind::Completed,
|
||
agent_thread_id: ThreadId::new().to_string(),
|
||
agent_path: "/root/researcher".to_string(),
|
||
};
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemStarted(ItemStartedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
activity.clone(),
|
||
/*started_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
activity,
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let cells = drain_insert_history(&mut rx);
|
||
assert_eq!(cells.len(), 1);
|
||
let rendered = lines_to_single_string(&cells[0]);
|
||
assert_chatwidget_snapshot!("app_server_sub_agent_activity_renders_once", rendered);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_collab_wait_items_render_history() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let sender_thread_id =
|
||
ThreadId::from_string("019cff70-2599-75e2-af72-b90000000001").expect("valid thread id");
|
||
let receiver_thread_id =
|
||
ThreadId::from_string("019cff70-2599-75e2-af72-b958ce5dc1cc").expect("valid thread id");
|
||
let other_receiver_thread_id =
|
||
ThreadId::from_string("019cff70-2599-75e2-af72-b96db334332d").expect("valid thread id");
|
||
chat.set_collab_agent_metadata(
|
||
receiver_thread_id,
|
||
Some("Robie".to_string()),
|
||
Some("explorer".to_string()),
|
||
);
|
||
chat.set_collab_agent_metadata(
|
||
other_receiver_thread_id,
|
||
Some("Ada".to_string()),
|
||
Some("reviewer".to_string()),
|
||
);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemStarted(ItemStartedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
AppServerThreadItemPayload::CollabAgentToolCall {
|
||
id: "wait-1".to_string(),
|
||
tool: AppServerCollabAgentTool::Wait,
|
||
status: AppServerCollabAgentToolCallStatus::InProgress,
|
||
sender_thread_id: sender_thread_id.to_string(),
|
||
receiver_thread_ids: vec![
|
||
receiver_thread_id.to_string(),
|
||
other_receiver_thread_id.to_string(),
|
||
],
|
||
prompt: None,
|
||
model: None,
|
||
reasoning_effort: None,
|
||
agents_states: HashMap::new(),
|
||
},
|
||
/*started_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
AppServerThreadItemPayload::CollabAgentToolCall {
|
||
id: "wait-1".to_string(),
|
||
tool: AppServerCollabAgentTool::Wait,
|
||
status: AppServerCollabAgentToolCallStatus::Completed,
|
||
sender_thread_id: sender_thread_id.to_string(),
|
||
receiver_thread_ids: vec![
|
||
receiver_thread_id.to_string(),
|
||
other_receiver_thread_id.to_string(),
|
||
],
|
||
prompt: None,
|
||
model: None,
|
||
reasoning_effort: None,
|
||
agents_states: HashMap::from([
|
||
(
|
||
receiver_thread_id.to_string(),
|
||
AppServerCollabAgentState {
|
||
status: AppServerCollabAgentStatus::Completed,
|
||
message: Some("Done".to_string()),
|
||
},
|
||
),
|
||
(
|
||
other_receiver_thread_id.to_string(),
|
||
AppServerCollabAgentState {
|
||
status: AppServerCollabAgentStatus::Running,
|
||
message: None,
|
||
},
|
||
),
|
||
]),
|
||
},
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let combined = drain_insert_history(&mut rx)
|
||
.into_iter()
|
||
.map(|lines| lines_to_single_string(&lines))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert_chatwidget_snapshot!("app_server_collab_wait_items_render_history", combined);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_collab_spawn_completed_renders_requested_model_and_effort() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let sender_thread_id =
|
||
ThreadId::from_string("019cff70-2599-75e2-af72-b90000000002").expect("valid thread id");
|
||
let spawned_thread_id =
|
||
ThreadId::from_string("019cff70-2599-75e2-af72-b91781b41a8e").expect("valid thread id");
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemStarted(ItemStartedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
AppServerThreadItemPayload::CollabAgentToolCall {
|
||
id: "spawn-1".to_string(),
|
||
tool: AppServerCollabAgentTool::SpawnAgent,
|
||
status: AppServerCollabAgentToolCallStatus::InProgress,
|
||
sender_thread_id: sender_thread_id.to_string(),
|
||
receiver_thread_ids: Vec::new(),
|
||
prompt: Some("Explore the repo".to_string()),
|
||
model: Some("gpt-5".to_string()),
|
||
reasoning_effort: Some(ReasoningEffortConfig::High),
|
||
agents_states: HashMap::new(),
|
||
},
|
||
/*started_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
|
||
"thread-1".to_string(),
|
||
"turn-1".to_string(),
|
||
AppServerThreadItemPayload::CollabAgentToolCall {
|
||
id: "spawn-1".to_string(),
|
||
tool: AppServerCollabAgentTool::SpawnAgent,
|
||
status: AppServerCollabAgentToolCallStatus::Completed,
|
||
sender_thread_id: sender_thread_id.to_string(),
|
||
receiver_thread_ids: vec![spawned_thread_id.to_string()],
|
||
prompt: Some("Explore the repo".to_string()),
|
||
model: Some("gpt-5".to_string()),
|
||
reasoning_effort: Some(ReasoningEffortConfig::High),
|
||
agents_states: HashMap::from([(
|
||
spawned_thread_id.to_string(),
|
||
AppServerCollabAgentState {
|
||
status: AppServerCollabAgentStatus::PendingInit,
|
||
message: None,
|
||
},
|
||
)]),
|
||
},
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let combined = drain_insert_history(&mut rx)
|
||
.into_iter()
|
||
.map(|lines| lines_to_single_string(&lines))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert_chatwidget_snapshot!(
|
||
"app_server_collab_spawn_completed_renders_requested_model_and_effort",
|
||
combined
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_failed_turn_does_not_duplicate_error_history() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
turn: AppServerTurn {
|
||
id: "turn-1".to_string(),
|
||
root_turn_id: None,
|
||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||
items: Vec::new(),
|
||
status: AppServerTurnStatus::InProgress,
|
||
error: None,
|
||
started_at: Some(0),
|
||
completed_at: None,
|
||
duration_ms: None,
|
||
},
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::Error(ErrorNotification {
|
||
error: AppServerTurnError {
|
||
misalignment: None,
|
||
message: "permission denied".to_string(),
|
||
codex_error_info: None,
|
||
additional_details: None,
|
||
},
|
||
will_retry: false,
|
||
thread_id: "thread-1".to_string(),
|
||
turn_id: "turn-1".to_string(),
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
let first_cells = drain_insert_history(&mut rx);
|
||
assert_eq!(first_cells.len(), 1);
|
||
assert!(lines_to_single_string(&first_cells[0]).contains("permission denied"));
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
turn: AppServerTurn {
|
||
id: "turn-1".to_string(),
|
||
root_turn_id: None,
|
||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||
items: Vec::new(),
|
||
status: AppServerTurnStatus::Failed,
|
||
error: Some(AppServerTurnError {
|
||
misalignment: None,
|
||
message: "permission denied".to_string(),
|
||
codex_error_info: None,
|
||
additional_details: None,
|
||
}),
|
||
started_at: None,
|
||
completed_at: Some(0),
|
||
duration_ms: None,
|
||
},
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
assert!(drain_insert_history(&mut rx).is_empty());
|
||
assert!(!chat.bottom_pane.is_task_running());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_failed_turn_consolidates_streamed_answer() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
handle_turn_started(&mut chat, "turn-1");
|
||
while rx.try_recv().is_ok() {}
|
||
|
||
handle_agent_message_delta(&mut chat, "```diff\n+ streamed patch\n```\n");
|
||
chat.run_commit_tick();
|
||
while rx.try_recv().is_ok() {}
|
||
|
||
handle_error(
|
||
&mut chat,
|
||
"stream disconnected before completion",
|
||
/*codex_error_info*/ None,
|
||
);
|
||
|
||
let mut saw_consolidate = false;
|
||
while let Ok(event) = rx.try_recv() {
|
||
if let AppEvent::ConsolidateAgentMessage { source, .. } = event {
|
||
saw_consolidate = true;
|
||
assert!(
|
||
source.contains("streamed patch"),
|
||
"expected partial stream source to be consolidated, got {source:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
assert!(
|
||
saw_consolidate,
|
||
"failed turn should consolidate streamed cells before clearing the stream controller"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_turn_completion_repairs_dropped_message_deltas() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
handle_turn_started(&mut chat, "turn-1");
|
||
while rx.try_recv().is_ok() {}
|
||
handle_agent_message_delta(&mut chat, "The transport kept this.\n");
|
||
chat.run_commit_tick();
|
||
while rx.try_recv().is_ok() {}
|
||
|
||
let mut completed_turn = app_server_turn(
|
||
"turn-1",
|
||
AppServerTurnStatus::Completed,
|
||
Some(1_000),
|
||
/*error*/ None,
|
||
);
|
||
completed_turn.items_view = codex_app_server_protocol::TurnItemsView::Summary;
|
||
completed_turn.items = vec![ThreadItem::untimed(AppServerThreadItemPayload::AgentMessage {
|
||
id: "msg-1".to_string(),
|
||
text: concat!(
|
||
"The transport kept this.\nAnd dropped this.\n\n",
|
||
r#"::code-comment{title="Finding" body="Keep ::git-stage{cwd=/tmp} literal." file="/tmp/file.rs"}"#,
|
||
)
|
||
.to_string(),
|
||
phase: Some(MessagePhase::FinalAnswer),
|
||
memory_citation: None,
|
||
delivery: None,
|
||
questions: Some(vec![codex_protocol::items::AsyncUserInputQuestion {
|
||
title: "Which way?".into(),
|
||
options: None,
|
||
}]),
|
||
})];
|
||
chat.handle_server_notification(
|
||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
turn: completed_turn,
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let consolidations = std::iter::from_fn(|| rx.try_recv().ok())
|
||
.filter_map(|event| match event {
|
||
AppEvent::ConsolidateAgentMessage {
|
||
source,
|
||
scrollback_reflow,
|
||
..
|
||
} => {
|
||
assert_eq!(
|
||
scrollback_reflow,
|
||
crate::app_event::ConsolidationScrollbackReflow::Required
|
||
);
|
||
assert_chatwidget_snapshot!(
|
||
"live_app_server_turn_completion_repairs_dropped_message_deltas",
|
||
source,
|
||
);
|
||
Some(())
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(consolidations.len(), 1);
|
||
assert_eq!(chat.bottom_pane.question_editor().unanswered_count(), 0);
|
||
assert!(matches!(
|
||
chat.pending_notification,
|
||
Some(Notification::AgentTurnComplete { .. })
|
||
));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_interleaved_flush_repairs_mismatched_completion() {
|
||
for (completed_item_id, completed) in [
|
||
("msg-1", "The transport kept this.\nAnd dropped this."),
|
||
("msg-2", "The transport kept this."),
|
||
] {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
handle_turn_started(&mut chat, "turn-1");
|
||
while rx.try_recv().is_ok() {}
|
||
handle_agent_message_delta(&mut chat, "The transport kept this.");
|
||
chat.flush_answer_stream_with_separator();
|
||
while rx.try_recv().is_ok() {}
|
||
|
||
complete_assistant_message(
|
||
&mut chat,
|
||
completed_item_id,
|
||
completed,
|
||
Some(MessagePhase::FinalAnswer),
|
||
);
|
||
|
||
let consolidations = std::iter::from_fn(|| rx.try_recv().ok())
|
||
.filter_map(|event| match event {
|
||
AppEvent::ConsolidateAgentMessage {
|
||
source,
|
||
scrollback_reflow,
|
||
..
|
||
} => Some((source, scrollback_reflow)),
|
||
_ => None,
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(
|
||
consolidations,
|
||
vec![(
|
||
completed.to_string(),
|
||
crate::app_event::ConsolidationScrollbackReflow::IfResizeReflowRan,
|
||
)]
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_stream_recovery_restores_previous_status_header() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
turn: AppServerTurn {
|
||
id: "turn-1".to_string(),
|
||
root_turn_id: None,
|
||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||
items: Vec::new(),
|
||
status: AppServerTurnStatus::InProgress,
|
||
error: None,
|
||
started_at: Some(0),
|
||
completed_at: None,
|
||
duration_ms: None,
|
||
},
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
drain_insert_history(&mut rx);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::Error(ErrorNotification {
|
||
error: AppServerTurnError {
|
||
misalignment: None,
|
||
message: "Reconnecting... 2/5".to_string(),
|
||
codex_error_info: Some(CodexErrorInfo::ResponseStreamDisconnected {
|
||
http_status_code: Some(429),
|
||
}),
|
||
additional_details: None,
|
||
},
|
||
will_retry: true,
|
||
thread_id: "thread-1".to_string(),
|
||
turn_id: "turn-1".to_string(),
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
drain_insert_history(&mut rx);
|
||
|
||
insta::assert_snapshot!(
|
||
chat.bottom_pane.status_widget().unwrap().header(),
|
||
@"Server is busy, reconnecting 2/5"
|
||
);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::AgentMessageDelta(
|
||
codex_app_server_protocol::AgentMessageDeltaNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
turn_id: "turn-1".to_string(),
|
||
item_id: "item-1".to_string(),
|
||
delta: "hello".to_string(),
|
||
},
|
||
),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
assert_eq!(chat.status_state.current_status.header, "Working");
|
||
assert_eq!(chat.status_state.current_status.details, None);
|
||
assert!(chat.status_state.retry_status_header.is_none());
|
||
assert!(chat.bottom_pane.status_widget().is_none());
|
||
assert!(chat.active_cell_is_stream_tail());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_errors_render_upstream_message() {
|
||
for (error, snapshot) in [
|
||
(
|
||
CodexErr::from(CodexErrorDetails::RateLimitExceeded(
|
||
"Please try again in 10s.".to_string(),
|
||
)),
|
||
"rate_limit_exceeded_error",
|
||
),
|
||
(
|
||
CodexErr::SessionBudgetExceeded,
|
||
"session_budget_exceeded_error",
|
||
),
|
||
] {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
handle_turn_started(&mut chat, "turn-1");
|
||
drain_insert_history(&mut rx);
|
||
assert!(chat.bottom_pane.is_task_running());
|
||
handle_error(
|
||
&mut chat,
|
||
error.to_string(),
|
||
Some(error.to_codex_protocol_error().into()),
|
||
);
|
||
|
||
let lines = drain_insert_history(&mut rx)
|
||
.into_iter()
|
||
.flatten()
|
||
.collect::<Vec<_>>();
|
||
assert_chatwidget_snapshot!(snapshot, lines_to_single_string(&lines));
|
||
assert!(!chat.bottom_pane.is_task_running());
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_server_overloaded_error_renders_error() {
|
||
for (message, error_info) in [
|
||
("server overloaded", CodexErrorInfo::ServerOverloaded),
|
||
(
|
||
"server overloaded",
|
||
CodexErrorInfo::PolicyCapacityUnavailable,
|
||
),
|
||
("", CodexErrorInfo::ServerOverloaded),
|
||
("", CodexErrorInfo::PolicyCapacityUnavailable),
|
||
] {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
turn: AppServerTurn {
|
||
id: "turn-1".to_string(),
|
||
root_turn_id: None,
|
||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||
items: Vec::new(),
|
||
status: AppServerTurnStatus::InProgress,
|
||
error: None,
|
||
started_at: Some(0),
|
||
completed_at: None,
|
||
duration_ms: None,
|
||
},
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
drain_insert_history(&mut rx);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::Error(ErrorNotification {
|
||
error: AppServerTurnError {
|
||
misalignment: None,
|
||
message: message.to_string(),
|
||
codex_error_info: Some(error_info),
|
||
additional_details: None,
|
||
},
|
||
will_retry: false,
|
||
thread_id: "thread-1".to_string(),
|
||
turn_id: "turn-1".to_string(),
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let cells = drain_insert_history(&mut rx);
|
||
assert_eq!(cells.len(), 1);
|
||
let rendered = lines_to_single_string(&cells[0]);
|
||
insta::allow_duplicates! {
|
||
if message.is_empty() {
|
||
insta::assert_snapshot!(rendered, @"■ Codex is currently experiencing high load.");
|
||
} else {
|
||
insta::assert_snapshot!(rendered, @"■ server overloaded");
|
||
}
|
||
}
|
||
assert!(!chat.bottom_pane.is_task_running());
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_cyber_policy_error_renders_dedicated_notice() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
turn: AppServerTurn {
|
||
id: "turn-1".to_string(),
|
||
root_turn_id: None,
|
||
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
||
items: Vec::new(),
|
||
status: AppServerTurnStatus::InProgress,
|
||
error: None,
|
||
started_at: Some(0),
|
||
completed_at: None,
|
||
duration_ms: None,
|
||
},
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
drain_insert_history(&mut rx);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::Error(ErrorNotification {
|
||
error: AppServerTurnError {
|
||
misalignment: None,
|
||
message: "server fallback message".to_string(),
|
||
codex_error_info: Some(CodexErrorInfo::CyberPolicy),
|
||
additional_details: None,
|
||
},
|
||
will_retry: false,
|
||
thread_id: "thread-1".to_string(),
|
||
turn_id: "turn-1".to_string(),
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let cells = drain_insert_history(&mut rx);
|
||
assert_eq!(cells.len(), 1);
|
||
let rendered = lines_to_single_string(&cells[0]);
|
||
assert!(rendered.contains("This content can’t be shown"));
|
||
assert!(rendered.contains("We take extra care with some cybersecurity requests"));
|
||
assert!(!rendered.contains("server fallback message"));
|
||
assert!(!chat.bottom_pane.is_task_running());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn app_server_safety_access_errors_render_dedicated_notice() {
|
||
let legacy_message = "Invalid prompt: we've limited access to this content for safety reasons.";
|
||
let bio_policy_message = "This content was flagged for possible biological risk.";
|
||
let cases = [
|
||
("legacy plain message", legacy_message.to_string()),
|
||
(
|
||
"legacy JSON message",
|
||
json!({ "error": { "message": legacy_message } }).to_string(),
|
||
),
|
||
("bio policy plain message", bio_policy_message.to_string()),
|
||
(
|
||
"bio policy JSON message",
|
||
json!({ "error": { "message": bio_policy_message } }).to_string(),
|
||
),
|
||
(
|
||
"bio policy code",
|
||
json!({ "error": { "code": "bio_policy", "message": "copy may change" } }).to_string(),
|
||
),
|
||
];
|
||
let mut rendered_cases = Vec::new();
|
||
for (case, message) in cases {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.handle_non_retry_error(message, /*codex_error_info*/ None);
|
||
|
||
let cells = drain_insert_history(&mut rx);
|
||
assert_eq!(cells.len(), 1);
|
||
let rendered = lines_to_single_string(&cells[0]);
|
||
assert!(rendered.contains("This content can't be shown"));
|
||
assert!(rendered.contains("biological research"));
|
||
rendered_cases.push((case, rendered));
|
||
}
|
||
|
||
let canonical = &rendered_cases[0].1;
|
||
for (case, rendered) in &rendered_cases[1..] {
|
||
assert_eq!(rendered, canonical, "unexpected rendering for {case}");
|
||
}
|
||
insta::assert_snapshot!(
|
||
"app_server_bio_policy_error_renders_dedicated_notice",
|
||
rendered_cases.last().unwrap().1.as_str()
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_model_verification_renders_warning() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ModelVerification(ModelVerificationNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
turn_id: "turn-1".to_string(),
|
||
verifications: vec![AppServerModelVerification::TrustedAccessForCyber],
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
let cells = drain_insert_history_transcript(&mut rx);
|
||
assert_eq!(cells.len(), 1);
|
||
let rendered = lines_to_single_string(&cells[0]);
|
||
assert!(rendered.contains("multiple flags for possible cybersecurity risk"));
|
||
assert!(rendered.contains("extra safety checks are on"));
|
||
assert!(rendered.contains("Trusted Access for Cyber"));
|
||
assert!(rendered.contains("https://chatgpt.com/cyber"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_invalid_thread_name_update_is_ignored() {
|
||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let thread_id = ThreadId::new();
|
||
chat.thread_id = Some(thread_id);
|
||
chat.thread_name = Some("original name".to_string());
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ThreadNameUpdated(
|
||
codex_app_server_protocol::ThreadNameUpdatedNotification {
|
||
thread_id: "not-a-thread-id".to_string(),
|
||
thread_name: Some("bad update".to_string()),
|
||
},
|
||
),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
assert_eq!(chat.thread_id, Some(thread_id));
|
||
assert_eq!(chat.thread_name, Some("original name".to_string()));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_manual_thread_name_updates_status_surfaces() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let thread_id =
|
||
ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").expect("thread id");
|
||
chat.thread_id = Some(thread_id);
|
||
|
||
chat.expect_manual_thread_name(thread_id, "review-fix".to_string());
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ThreadNameUpdated(
|
||
codex_app_server_protocol::ThreadNameUpdatedNotification {
|
||
thread_id: thread_id.to_string(),
|
||
thread_name: Some("review-fix".to_string()),
|
||
},
|
||
),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
assert_eq!(chat.thread_name, Some("review-fix".to_string()));
|
||
let cells = drain_insert_history(&mut rx);
|
||
let transcript = cells
|
||
.iter()
|
||
.map(|lines| lines_to_single_string(lines))
|
||
.collect::<String>();
|
||
let footer = render_bottom_popup(&chat, /*width*/ 80);
|
||
let title = chat.last_terminal_title.as_deref().unwrap();
|
||
assert_chatwidget_snapshot!(
|
||
"manual_thread_name_status_surfaces",
|
||
normalize_snapshot_paths(format!(
|
||
"transcript:\n{transcript}\nfooter:\n{footer}\ntitle: {title}"
|
||
))
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_automatic_thread_name_update_is_silent() {
|
||
for replay_kind in [
|
||
None,
|
||
Some(ReplayKind::ThreadSnapshot),
|
||
Some(ReplayKind::ResumeInitialMessages),
|
||
] {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let thread_id = ThreadId::new();
|
||
let mut session = configured_thread_session(thread_id);
|
||
session.thread_name = Some("Generated title".to_string());
|
||
chat.handle_thread_session(session);
|
||
drain_insert_history(&mut rx);
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ThreadNameUpdated(
|
||
codex_app_server_protocol::ThreadNameUpdatedNotification {
|
||
thread_id: thread_id.to_string(),
|
||
thread_name: Some("Generated title".to_string()),
|
||
},
|
||
),
|
||
replay_kind,
|
||
);
|
||
|
||
assert_eq!(chat.thread_name(), Some("Generated title".to_string()));
|
||
let transcript = drain_insert_history(&mut rx)
|
||
.iter()
|
||
.map(|lines| lines_to_single_string(lines))
|
||
.collect::<String>();
|
||
insta::allow_duplicates! {
|
||
assert_snapshot!(transcript, @"");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_manual_thread_name_is_visible_before_notification() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let thread_id = ThreadId::new();
|
||
chat.thread_id = Some(thread_id);
|
||
|
||
chat.expect_manual_thread_name(thread_id, "Manual title".to_string());
|
||
|
||
assert_eq!(chat.thread_name(), Some("Manual title".to_string()));
|
||
assert!(drain_insert_history(&mut rx).is_empty());
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ThreadNameUpdated(
|
||
codex_app_server_protocol::ThreadNameUpdatedNotification {
|
||
thread_id: thread_id.to_string(),
|
||
thread_name: Some("Manual title".to_string()),
|
||
},
|
||
),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
assert_eq!(chat.thread_name(), Some("Manual title".to_string()));
|
||
assert!(drain_insert_history(&mut rx).is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn thread_name_suggestion_requires_matching_thread_and_request() {
|
||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let thread_id = ThreadId::new();
|
||
let request_id = uuid::Uuid::new_v4();
|
||
chat.thread_id = Some(thread_id);
|
||
|
||
let view = CustomPromptView::new(
|
||
"Rename thread".to_string(),
|
||
"Type a name and press Enter".to_string(),
|
||
/*initial_text*/ String::new(),
|
||
/*context_label*/ None,
|
||
Box::new(|_| {}),
|
||
)
|
||
.with_text_suggestion(request_id, "Loading".into(), "Ready".into());
|
||
chat.bottom_pane.show_text_prompt(view);
|
||
|
||
chat.apply_thread_name_suggestion(ThreadId::new(), request_id, Some("Wrong thread"));
|
||
chat.apply_thread_name_suggestion(thread_id, uuid::Uuid::new_v4(), Some("Wrong request"));
|
||
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert!(popup.contains("Loading"));
|
||
assert!(!popup.contains("Wrong"));
|
||
|
||
chat.apply_thread_name_suggestion(thread_id, request_id, Some("Suggested title"));
|
||
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert!(popup.contains("Ready"));
|
||
assert!(popup.contains("Suggested title"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn live_app_server_thread_closed_requests_immediate_exit() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ThreadClosed(ThreadClosedNotification {
|
||
thread_id: "thread-1".to_string(),
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::Immediate)));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn permission_discovery_invalidates_on_thread_settings_and_uses_updated_cwd() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let thread_id = ThreadId::new();
|
||
chat.thread_id = Some(thread_id);
|
||
chat.config.explicit_permission_profile_mode = true;
|
||
for complete in [false, true] {
|
||
chat.open_permissions_popup();
|
||
let request_id = chat.permission_popup_request_id.unwrap();
|
||
if complete {
|
||
chat.on_permission_profiles_loaded(request_id, Err("disconnected".to_string()));
|
||
}
|
||
chat.on_thread_settings_updated(thread_settings_for_test("gpt-5.2", thread_id));
|
||
chat.on_permission_profiles_loaded(request_id, Err("stale".to_string()));
|
||
assert!(!chat.bottom_pane.has_active_view());
|
||
}
|
||
while rx.try_recv().is_ok() {}
|
||
chat.open_permissions_popup();
|
||
let AppEvent::FetchPermissionProfiles { thread_cwd, .. } = rx.try_recv().unwrap() else {
|
||
panic!("expected discovery")
|
||
};
|
||
assert_eq!(thread_cwd, Some(test_path_buf("/tmp/thread-settings")));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn sqlite_log_write_warning_is_visible_in_warnings() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.warning_display_state.startup_complete = true;
|
||
chat.handle_server_notification(
|
||
ServerNotification::Warning(WarningNotification {
|
||
thread_id: None,
|
||
message: "Codex couldn't save diagnostic logs to its local database. Use /feedback with logs included before closing Codex, or run `codex doctor` for diagnostics.".to_string(),
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
let cells: Vec<Arc<dyn HistoryCell>> = std::iter::from_fn(|| rx.try_recv().ok())
|
||
.filter_map(|event| match event {
|
||
AppEvent::InsertHistoryCell(cell) => Some(Arc::from(cell)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(cells.len(), 1);
|
||
chat.open_warnings(&cells);
|
||
insta::assert_snapshot!(
|
||
"sqlite_log_write_warning",
|
||
super::helpers::render_bottom_popup(&chat, /*width*/ 80)
|
||
);
|
||
}
|