Files
codex/codex-rs/tui/src/chatwidget/tests/app_server.rs
T
Eric Traut f2e0bf2d62 Clarify TUI reconnect status for HTTP 429 stream errors (#59253)
## 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
2026-11-03 23:22:03 +00:00

2345 lines
89 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
);
}