mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Warn when the connected Codex service is older than the CLI (#43622)
## What changed Show an older-service notice at startup and reconnect when both versions are official stable releases. Display it in conversation history or the agents overview, with wrapping and a compact fallback for small terminals. For the implicit local daemon, suggest `codex app-server daemon update` and warn that updating may interrupt active or queued work. Suppress repeated notices for the same service and version pair, and defer fresh-session notices until session attachment. ## Testing Add coverage for version notice eligibility, local-only update guidance, service identity, startup ordering, reconnect deduplication, and overview rendering at narrow and short sizes. GitOrigin-RevId: e0a5b306e0a4d405544c5f6eaa72f77df6d68e6d
This commit is contained in:
@@ -242,6 +242,7 @@ mod replay_filter;
|
||||
mod resize_reflow;
|
||||
mod resume_config;
|
||||
mod safety_buffering;
|
||||
mod server_version_notice;
|
||||
mod session_lifecycle;
|
||||
mod session_picker;
|
||||
mod side;
|
||||
@@ -641,6 +642,7 @@ pub(crate) struct App {
|
||||
tokio::sync::broadcast::Sender<codex_app_server_protocol::ThreadStatusChangedNotification>,
|
||||
dynamic_tool_tasks: HashMap<codex_app_server_protocol::RequestId, (String, JoinHandle<()>)>,
|
||||
pending_startup_thread_start: bool,
|
||||
pending_server_version_notice: Option<crate::status::remote_connection::ServerVersionNotice>,
|
||||
/// Opens the session picker after event dispatch returns, with a fresh stack.
|
||||
pending_open_resume_picker: bool,
|
||||
/// Runs a requested /cd after event dispatch returns, with a fresh stack.
|
||||
|
||||
@@ -39,6 +39,16 @@ impl AgentsOverviewView {
|
||||
}
|
||||
}
|
||||
pub(super) fn layout_areas(&self, area: Rect) -> [Rect; 7] {
|
||||
let header_height = self
|
||||
.state()
|
||||
.server_version_notice
|
||||
.as_deref()
|
||||
.map(|notice| {
|
||||
textwrap::wrap(notice, usize::from(area.width.saturating_sub(4).max(1))).len()
|
||||
as u16
|
||||
})
|
||||
.unwrap_or(1)
|
||||
.min(area.height.saturating_sub(7).max(1));
|
||||
let footer_height = if self.state().composing() {
|
||||
0
|
||||
} else {
|
||||
@@ -79,7 +89,7 @@ impl AgentsOverviewView {
|
||||
3
|
||||
};
|
||||
Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(header_height),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
|
||||
@@ -156,7 +156,22 @@ impl Renderable for AgentsOverviewView {
|
||||
let [header, summary, divider, body, title, prompt, footer] = self.layout_areas(area);
|
||||
let inset =
|
||||
|rect: Rect| rect.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0));
|
||||
Line::from("Agent command center".bold()).render(inset(header), buf);
|
||||
if let Some(notice) = &self.state().server_version_notice {
|
||||
let header = inset(header);
|
||||
let lines = textwrap::wrap(notice, usize::from(header.width.max(1)));
|
||||
if lines.len() > usize::from(header.height) {
|
||||
Line::from("Old srv".cyan()).render(header, buf);
|
||||
} else {
|
||||
for (offset, line) in lines.iter().enumerate() {
|
||||
Line::from(line.as_ref().cyan()).render(
|
||||
Rect::new(header.x, header.y + offset as u16, header.width, 1),
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Line::from("Agent command center".bold()).render(inset(header), buf);
|
||||
}
|
||||
let (needs_you, working, ready) = self.rows.iter().fold((0, 0, 0), |counts, row| {
|
||||
let (needs_you, working, ready) = counts;
|
||||
match row.group {
|
||||
|
||||
@@ -1,4 +1,64 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn older_server_notice_is_visible_in_agents_overview() {
|
||||
let mut app = make_test_app().await;
|
||||
app.update_server_version_overview_notice("0.153.0", Some("0.152.1"));
|
||||
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
|
||||
insta::assert_snapshot!(rendered.lines().take(2).collect::<Vec<_>>().join("\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_version_overview_notice_updates_and_clears() {
|
||||
let mut app = make_test_app().await;
|
||||
app.update_server_version_overview_notice("0.153.0", Some("0.152.1"));
|
||||
app.update_server_version_overview_notice("0.153.0", Some("0.151.0"));
|
||||
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
|
||||
insta::assert_snapshot!(rendered.lines().take(2).collect::<Vec<_>>().join("\n"), @" Service v0.151.0 < Codex CLI v0.153.0
|
||||
0 need input 0 working 0 ready");
|
||||
|
||||
app.update_server_version_overview_notice("0.153.0", /*older_server*/ None);
|
||||
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
|
||||
insta::assert_snapshot!(rendered.lines().take(2).collect::<Vec<_>>().join("\n"), @" Agent command center
|
||||
0 need input 0 working 0 ready");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn older_server_notice_wraps_in_narrow_overview() {
|
||||
let mut app = make_test_app().await;
|
||||
app.update_server_version_overview_notice("0.153.0", Some("0.152.1"));
|
||||
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
insta::assert_snapshot!(
|
||||
"older_server_narrow_overview",
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 12)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn older_server_notice_falls_back_in_short_overview() {
|
||||
let mut app = make_test_app().await;
|
||||
app.update_server_version_overview_notice("0.153.0", Some("0.152.1"));
|
||||
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
let area = ratatui::layout::Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 8,
|
||||
);
|
||||
let mut buffer = ratatui::buffer::Buffer::empty(area);
|
||||
view.render(area, &mut buffer);
|
||||
let header = buffer
|
||||
.content()
|
||||
.iter()
|
||||
.take(usize::from(area.width))
|
||||
.map(ratatui::buffer::Cell::symbol)
|
||||
.collect::<String>();
|
||||
insta::assert_snapshot!(header.trim_end(), @" Old srv");
|
||||
}
|
||||
use crate::app::test_support::make_test_app;
|
||||
use crate::app_event::AgentsOverviewThreadRefresh;
|
||||
use crate::bottom_pane::BottomPaneView;
|
||||
|
||||
@@ -132,6 +132,7 @@ pub(super) struct AgentsOverviewViewState {
|
||||
pub(super) key_chord_hint: Option<Vec<(String, String)>>,
|
||||
pub(super) focus: AgentsOverviewFocus,
|
||||
pub(super) connection_notice: Option<&'static str>,
|
||||
pub(super) server_version_notice: Option<String>,
|
||||
search: String,
|
||||
searching: bool,
|
||||
pub(super) status_grouping: bool,
|
||||
|
||||
@@ -18,6 +18,7 @@ pub(super) struct ReconnectState {
|
||||
pub(super) offline: bool,
|
||||
pub(super) failed: bool,
|
||||
pub(super) presentation: ReconnectPresentation,
|
||||
pub(super) seen_version_notice: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) struct Reconnected {
|
||||
@@ -176,6 +177,13 @@ impl App {
|
||||
if !self.reconnect.offline {
|
||||
self.reconnect.offline = true;
|
||||
self.reconnect.failed = false;
|
||||
if self.pending_server_version_notice.take().is_some() {
|
||||
self.reconnect.seen_version_notice = None;
|
||||
self.update_server_version_overview_notice(
|
||||
CODEX_CLI_VERSION,
|
||||
/*older_server*/ None,
|
||||
);
|
||||
}
|
||||
self.cancel_pending_key_chord();
|
||||
self.overlay = None;
|
||||
self.commit_animation = None;
|
||||
@@ -218,6 +226,7 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
app_event_rx: &mut mpsc::UnboundedReceiver<AppEvent>,
|
||||
connected: Reconnected,
|
||||
client_version: &str,
|
||||
) -> Result<()> {
|
||||
let Reconnected {
|
||||
mut session,
|
||||
@@ -425,6 +434,29 @@ impl App {
|
||||
self.chat_widget.add_info_message(
|
||||
"Reconnected. No input was resent. Review uncertain submissions before retrying; recovered queues remain paused.".into(), /*hint*/ None,
|
||||
);
|
||||
let connected_notice_key = crate::status::remote_connection::server_version_notice_key(
|
||||
&self.app_server_target,
|
||||
app_server.server_codex_home(),
|
||||
client_version,
|
||||
app_server.server_version(),
|
||||
);
|
||||
if self.reconnect.seen_version_notice != connected_notice_key {
|
||||
self.reconnect.seen_version_notice = None;
|
||||
self.update_server_version_overview_notice(client_version, /*older_server*/ None);
|
||||
}
|
||||
if let Some((notice, key)) = crate::status::remote_connection::pending_server_version_notice(
|
||||
&self.app_server_target,
|
||||
app_server.server_codex_home(),
|
||||
client_version,
|
||||
app_server.server_version(),
|
||||
self.reconnect.seen_version_notice.as_deref(),
|
||||
) {
|
||||
self.reconnect.seen_version_notice = Some(key);
|
||||
self.update_server_version_overview_notice(client_version, app_server.server_version());
|
||||
if self.reconnect.presentation != ReconnectPresentation::Overview {
|
||||
self.chat_widget.add_server_version_warning(notice);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Keeps the older remote server notice in the overview's shared view state.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
pub(super) fn update_server_version_overview_notice(
|
||||
&mut self,
|
||||
client_version: &str,
|
||||
older_server: Option<&str>,
|
||||
) {
|
||||
self.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.server_version_notice =
|
||||
older_server.map(|server| format!("Service v{server} < Codex CLI v{client_version}"));
|
||||
}
|
||||
}
|
||||
@@ -779,6 +779,7 @@ impl App {
|
||||
self.pending_primary_events.clear();
|
||||
self.pending_app_server_requests.clear();
|
||||
self.pending_startup_thread_start = false;
|
||||
self.pending_server_version_notice = None;
|
||||
self.chat_widget.set_pending_thread_approvals(Vec::new());
|
||||
self.sync_active_agent_label();
|
||||
}
|
||||
@@ -860,6 +861,9 @@ impl App {
|
||||
self.enqueue_primary_thread_session(started.session, started.turns)
|
||||
.await?;
|
||||
self.apply_backend_banner_fallback(app_server).await;
|
||||
if let Some(notice) = self.pending_server_version_notice.take() {
|
||||
self.chat_widget.add_server_version_warning(notice);
|
||||
}
|
||||
if !recovery_was_pending {
|
||||
self.chat_widget.finish_rate_limit_recovery();
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
---
|
||||
source: tui/src/app/agents_overview_tests.rs
|
||||
assertion_line: 38
|
||||
expression: "render_bottom_popup(&app.chat_widget, 12)"
|
||||
---
|
||||
Service
|
||||
v0.152.1
|
||||
< Codex
|
||||
CLI
|
||||
v0.153.0
|
||||
0 need i
|
||||
────────
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
New task
|
||||
|
||||
› Describe
|
||||
|
||||
enter cre
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/app/agents_overview_tests.rs
|
||||
assertion_line: 10
|
||||
expression: "rendered.lines().take(2).collect::<Vec<_>>().join(\"\\n\")"
|
||||
---
|
||||
Service v0.152.1 < Codex CLI v0.153.0
|
||||
0 need input 0 working 0 ready
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Owns the main app run loop from app-server bootstrap through terminal shutdown. Startup input
|
||||
//! remains isolated from protected interactive requests until the initialized composer owns it.
|
||||
|
||||
use super::reconnect::ReconnectState;
|
||||
use super::*;
|
||||
use crate::session_start::SessionStartAction;
|
||||
use crate::session_start::cancel_session_start;
|
||||
@@ -258,6 +259,18 @@ impl App {
|
||||
&app_server_target,
|
||||
app_server.server_version(),
|
||||
);
|
||||
let initial_server_version_notice =
|
||||
if !matches!(app_server_target, AppServerTarget::Embedded) {
|
||||
crate::status::remote_connection::pending_server_version_notice(
|
||||
&app_server_target,
|
||||
app_server.server_codex_home(),
|
||||
CODEX_CLI_VERSION,
|
||||
app_server.server_version(),
|
||||
/*last_shown*/ None,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Err(err) = startup_draft.flush_pending_events(tui).await {
|
||||
return shutdown_on_startup_error(app_server, err).await;
|
||||
}
|
||||
@@ -702,7 +715,12 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
feedback_audience,
|
||||
environment_manager,
|
||||
app_server_target,
|
||||
reconnect: Default::default(),
|
||||
reconnect: ReconnectState {
|
||||
seen_version_notice: initial_server_version_notice
|
||||
.as_ref()
|
||||
.map(|(_, key)| key.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
pending_update_action: None,
|
||||
pending_shutdown_exit_thread_id: None,
|
||||
windows_sandbox: WindowsSandboxState::default(),
|
||||
@@ -727,6 +745,13 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
dynamic_tool_status_updates,
|
||||
dynamic_tool_tasks: HashMap::new(),
|
||||
pending_startup_thread_start,
|
||||
pending_server_version_notice: if pending_startup_thread_start {
|
||||
initial_server_version_notice
|
||||
.as_ref()
|
||||
.map(|(notice, _)| notice.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
pending_open_resume_picker: false,
|
||||
pending_working_directory_change: None,
|
||||
pending_start_managed_worktree: None,
|
||||
@@ -745,6 +770,12 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
if !tui.is_terminal_focused() {
|
||||
app.recap.note_focus_lost(Instant::now());
|
||||
}
|
||||
app.update_server_version_overview_notice(
|
||||
CODEX_CLI_VERSION,
|
||||
initial_server_version_notice
|
||||
.as_ref()
|
||||
.and(app_server.server_version()),
|
||||
);
|
||||
if start_in_agents_overview {
|
||||
app.open_agents_overview(&app_server);
|
||||
} else if !matches!(app.app_server_target, AppServerTarget::Embedded) {
|
||||
@@ -794,6 +825,14 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
return shutdown_on_startup_error(app_server, err).await;
|
||||
}
|
||||
}
|
||||
if !start_in_agents_overview
|
||||
&& !pending_startup_thread_start
|
||||
&& let Some((notice, _)) = &initial_server_version_notice
|
||||
{
|
||||
app.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_server_version_warning(notice.clone()),
|
||||
)));
|
||||
}
|
||||
let initial_session_ms = initial_session_started_at.elapsed().as_millis();
|
||||
|
||||
// On startup, if a managed filesystem sandbox is active, warn about
|
||||
@@ -1086,7 +1125,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
reconnect = None;
|
||||
match result {
|
||||
Ok(connected) => {
|
||||
app.finish_reconnect(tui, &mut app_server, &mut app_event_rx, connected).await?;
|
||||
app.finish_reconnect(tui, &mut app_server, &mut app_event_rx, connected, CODEX_CLI_VERSION).await?;
|
||||
listen_for_app_server_events = true;
|
||||
waiting_for_initial_session_configured = false;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ pub(super) async fn make_test_app() -> App {
|
||||
dynamic_tool_status_updates: tokio::sync::broadcast::channel(/*capacity*/ 64).0,
|
||||
dynamic_tool_tasks: HashMap::new(),
|
||||
pending_startup_thread_start: false,
|
||||
pending_server_version_notice: None,
|
||||
pending_open_resume_picker: false,
|
||||
pending_working_directory_change: None,
|
||||
pending_start_managed_worktree: None,
|
||||
|
||||
@@ -5878,6 +5878,7 @@ async fn make_test_app() -> App {
|
||||
dynamic_tool_status_updates: tokio::sync::broadcast::channel(/*capacity*/ 64).0,
|
||||
dynamic_tool_tasks: HashMap::new(),
|
||||
pending_startup_thread_start: false,
|
||||
pending_server_version_notice: None,
|
||||
pending_open_resume_picker: false,
|
||||
pending_managed_worktree_creation: false,
|
||||
pending_working_directory_change: None,
|
||||
@@ -5975,6 +5976,7 @@ pub(super) async fn make_test_app_with_channels() -> (
|
||||
dynamic_tool_status_updates: tokio::sync::broadcast::channel(/*capacity*/ 64).0,
|
||||
dynamic_tool_tasks: HashMap::new(),
|
||||
pending_startup_thread_start: false,
|
||||
pending_server_version_notice: None,
|
||||
pending_open_resume_picker: false,
|
||||
pending_managed_worktree_creation: false,
|
||||
pending_working_directory_change: None,
|
||||
|
||||
@@ -11,13 +11,14 @@ use super::disconnect::serve_reconnect_requests;
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() -> Result<()> {
|
||||
for (recovered_queue, edit_offline, resume_error_code) in [
|
||||
(true, false, -32603),
|
||||
(false, false, -32603),
|
||||
(true, true, -32603),
|
||||
(true, false, -32600),
|
||||
(false, false, -32600),
|
||||
(true, true, -32600),
|
||||
for (recovered_queue, edit_offline, resume_error_code, deferred_notice) in [
|
||||
(true, false, -32603, false),
|
||||
(false, false, -32603, false),
|
||||
(true, true, -32603, false),
|
||||
(true, false, -32600, false),
|
||||
(false, false, -32600, false),
|
||||
(true, true, -32600, false),
|
||||
(true, false, -32603, true),
|
||||
] {
|
||||
let pending_profile = !recovered_queue && resume_error_code == -32600;
|
||||
let (mut app, mut events, mut ops) = make_test_app_with_channels().await;
|
||||
@@ -75,6 +76,19 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?;
|
||||
app.app_server_target = AppServerTarget::Remote { endpoint };
|
||||
let (notice, key) = crate::status::remote_connection::pending_server_version_notice(
|
||||
&app.app_server_target,
|
||||
/*server_home*/ None,
|
||||
"2.1.0",
|
||||
Some("2.0.0"),
|
||||
/*last_shown*/ None,
|
||||
)
|
||||
.expect("older server should have a pending notice");
|
||||
app.reconnect.seen_version_notice = Some(key);
|
||||
if deferred_notice {
|
||||
app.pending_server_version_notice = Some(notice);
|
||||
app.update_server_version_overview_notice("2.1.0", Some("2.0.0"));
|
||||
}
|
||||
let thread = json!({
|
||||
"id": id, "sessionId": id, "preview": "only once", "ephemeral": false,
|
||||
"modelProvider": "test-provider", "createdAt": 1, "updatedAt": 2,
|
||||
@@ -179,6 +193,16 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
|
||||
);
|
||||
}
|
||||
app.begin_reconnect();
|
||||
if deferred_notice {
|
||||
assert_eq!(
|
||||
app.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.server_version_notice,
|
||||
None
|
||||
);
|
||||
}
|
||||
if edit_offline {
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
@@ -225,7 +249,7 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
|
||||
paused.contains("TUI is reconnecting; tool was not sent"),
|
||||
"{paused}"
|
||||
);
|
||||
app.finish_reconnect(&mut tui, &mut session, &mut events, connected)
|
||||
app.finish_reconnect(&mut tui, &mut session, &mut events, connected, "2.1.0")
|
||||
.await?;
|
||||
assert!(app.pending_server_profiles.is_empty());
|
||||
assert!(!app.reconnect.offline);
|
||||
@@ -290,6 +314,23 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
|
||||
}
|
||||
);
|
||||
let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?;
|
||||
let notices = history
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
line.contains("Reconnected.") || line.contains("background Codex service")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if deferred_notice {
|
||||
assert_snapshot!(notices, @r###"
|
||||
• Reconnected. No input was resent. Review uncertain submissions before retrying; recovered queues remain paused.
|
||||
⚠ A background Codex service is running v2.0.0, older than your Codex CLI
|
||||
"###);
|
||||
} else {
|
||||
insta::allow_duplicates! {
|
||||
assert_snapshot!(notices, @"• Reconnected. No input was resent. Review uncertain submissions before retrying; recovered queues remain paused.");
|
||||
}
|
||||
}
|
||||
assert_eq!(history.matches("only once").count(), 1);
|
||||
if recovered_queue {
|
||||
assert!(app.chat_widget.has_queued_follow_up_messages());
|
||||
@@ -439,8 +480,14 @@ async fn reconnect_reconciles_offscreen_pending_profile_before_restoring_permiss
|
||||
ReconnectPresentation::Conversation,
|
||||
)
|
||||
.await?;
|
||||
app.finish_reconnect(&mut tui, &mut session, &mut events, connected)
|
||||
.await?;
|
||||
app.finish_reconnect(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
&mut events,
|
||||
connected,
|
||||
CODEX_CLI_VERSION,
|
||||
)
|
||||
.await?;
|
||||
assert!(app.pending_server_profiles.contains_key(&primary));
|
||||
|
||||
app.select_agent_thread(&mut tui, &mut session, primary)
|
||||
@@ -574,8 +621,14 @@ async fn reconnect_allows_slow_hydration_but_bounds_a_stalled_server() -> Result
|
||||
app.begin_reconnect();
|
||||
let mut session = crate::start_embedded_app_server_for_picker(&app.config).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.finish_reconnect(&mut tui, &mut session, &mut events, result?)
|
||||
.await?;
|
||||
app.finish_reconnect(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
&mut events,
|
||||
result?,
|
||||
CODEX_CLI_VERSION,
|
||||
)
|
||||
.await?;
|
||||
assert!(app.thread_unavailable(id));
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
|
||||
@@ -306,8 +306,14 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
|
||||
ReconnectPresentation::Overview,
|
||||
)
|
||||
.await?;
|
||||
app.finish_reconnect(&mut tui, &mut session, &mut events, connected)
|
||||
.await?;
|
||||
app.finish_reconnect(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
&mut events,
|
||||
connected,
|
||||
CODEX_CLI_VERSION,
|
||||
)
|
||||
.await?;
|
||||
assert!(!app.reconnect.offline);
|
||||
assert_eq!(app.current_displayed_thread_id(), previous_thread);
|
||||
|
||||
|
||||
@@ -1050,6 +1050,43 @@ async fn startup_thread_started_submits_queued_startup_input() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_startup_notice_follows_session_attachment() {
|
||||
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
|
||||
app.pending_startup_thread_start = true;
|
||||
app.pending_server_version_notice =
|
||||
Some(crate::status::remote_connection::ServerVersionNotice {
|
||||
message: "Older server notice".to_string(),
|
||||
offer_update: false,
|
||||
});
|
||||
assert!(events.try_recv().is_err());
|
||||
|
||||
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
|
||||
.await
|
||||
.expect("embedded app server");
|
||||
app.handle_startup_thread_started(
|
||||
&mut app_server,
|
||||
Ok(AppServerStartedThread {
|
||||
session: test_thread_session(ThreadId::new(), test_path_buf("/tmp/project")),
|
||||
turns: Vec::new(),
|
||||
blocks_direct_input: false,
|
||||
task_tools_available: false,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("startup thread should attach");
|
||||
|
||||
let cells = std::iter::from_fn(|| events.try_recv().ok())
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell) => Some(cell),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(cells.len() > 1, "session history should precede the notice");
|
||||
insta::assert_snapshot!(lines_to_single_string(&cells.last().unwrap().display_lines(/*width*/ 80)), @"⚠ Older server notice");
|
||||
assert_eq!(app.pending_server_version_notice, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_thread_started_discards_another_threads_buffered_events() {
|
||||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
|
||||
@@ -556,6 +556,13 @@ impl AppServerSession {
|
||||
client.server_version()
|
||||
}
|
||||
|
||||
pub(crate) fn server_codex_home(&self) -> Option<&str> {
|
||||
let AppServerClient::Remote(client) = &self.client else {
|
||||
return None;
|
||||
};
|
||||
client.codex_home()
|
||||
}
|
||||
|
||||
pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result<AppServerBootstrap> {
|
||||
let started_at = Instant::now();
|
||||
let account = self.read_account().await?;
|
||||
|
||||
@@ -1562,6 +1562,14 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn add_server_version_warning(
|
||||
&mut self,
|
||||
notice: crate::status::remote_connection::ServerVersionNotice,
|
||||
) {
|
||||
self.add_to_history(history_cell::new_server_version_warning(notice));
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn add_error_message(&mut self, message: String) {
|
||||
self.add_to_history(history_cell::new_error_event(message));
|
||||
self.request_redraw();
|
||||
|
||||
@@ -88,6 +88,21 @@ pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell {
|
||||
PrefixedWrappedHistoryCell::new(message.yellow(), "⚠ ".yellow(), " ")
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
pub(crate) fn new_server_version_warning(
|
||||
notice: crate::status::remote_connection::ServerVersionNotice,
|
||||
) -> PrefixedWrappedHistoryCell {
|
||||
let mut lines = vec![Line::from(notice.message.yellow())];
|
||||
if notice.offer_update {
|
||||
lines.push(Line::from("To update the service, run:".yellow()));
|
||||
lines.push(Line::from(" codex app-server daemon update".cyan()));
|
||||
lines.push(Line::from(
|
||||
"Updating may interrupt active or queued work.".yellow(),
|
||||
));
|
||||
}
|
||||
PrefixedWrappedHistoryCell::new(Text::from(lines), "⚠ ".yellow(), " ")
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct SafetyAccessBlockCell {
|
||||
title: &'static str,
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell/tests.rs
|
||||
expression: "render_lines(&cell.display_lines(100)).join(\"\\n\")"
|
||||
---
|
||||
⚠ A background Codex service is running v0.152.1, older than your Codex CLI v0.153.0.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/history_cell/tests.rs
|
||||
expression: "render_lines(&cell.display_lines(100)).join(\"\\n\")"
|
||||
---
|
||||
⚠ A background Codex service is running v0.152.1, older than your Codex CLI v0.153.0.
|
||||
To update the service, run:
|
||||
codex app-server daemon update
|
||||
Updating may interrupt active or queued work.
|
||||
@@ -19,6 +19,7 @@ use codex_protocol::ThreadId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::error::UnexpectedResponseError;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dirs::home_dir;
|
||||
use http::StatusCode;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -34,6 +35,51 @@ use codex_protocol::mcp::Tool;
|
||||
use rmcp::model::ContentBlock;
|
||||
|
||||
const SMALL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
|
||||
|
||||
#[test]
|
||||
fn connected_server_version_notice_snapshot() {
|
||||
let target = crate::AppServerTarget::Remote {
|
||||
endpoint: crate::RemoteAppServerEndpoint::UnixSocket {
|
||||
socket_path: AbsolutePathBuf::from_absolute_path(
|
||||
std::env::temp_dir().join("codex.sock"),
|
||||
)
|
||||
.expect("absolute socket path"),
|
||||
},
|
||||
};
|
||||
let (notice, _) = crate::status::remote_connection::pending_server_version_notice(
|
||||
&target,
|
||||
/*server_home*/ None,
|
||||
"0.153.0",
|
||||
Some("0.152.1"),
|
||||
/*last_shown*/ None,
|
||||
)
|
||||
.expect("older remote service should have a notice");
|
||||
let cell = new_server_version_warning(notice);
|
||||
insta::assert_snapshot!(render_lines(&cell.display_lines(/*width*/ 100)).join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_daemon_version_notice_snapshot() {
|
||||
let target = crate::AppServerTarget::LocalDaemon {
|
||||
endpoint: crate::RemoteAppServerEndpoint::UnixSocket {
|
||||
socket_path: AbsolutePathBuf::from_absolute_path(
|
||||
std::env::temp_dir().join("codex.sock"),
|
||||
)
|
||||
.expect("absolute socket path"),
|
||||
},
|
||||
};
|
||||
let (notice, _) = crate::status::remote_connection::pending_server_version_notice(
|
||||
&target,
|
||||
/*server_home*/ None,
|
||||
"0.153.0",
|
||||
Some("0.152.1"),
|
||||
/*last_shown*/ None,
|
||||
)
|
||||
.expect("older local service should have a notice");
|
||||
let cell = new_server_version_warning(notice);
|
||||
insta::assert_snapshot!(render_lines(&cell.display_lines(/*width*/ 100)).join("\n"));
|
||||
}
|
||||
|
||||
async fn test_config() -> Config {
|
||||
let codex_home = std::env::temp_dir();
|
||||
ConfigBuilder::default()
|
||||
|
||||
@@ -225,7 +225,6 @@ pub use update_action::UpdateAction;
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub use update_action::get_update_action;
|
||||
mod update_prompt;
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
mod update_versions;
|
||||
mod updates;
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::AppServerTarget;
|
||||
use crate::RemoteAppServerEndpoint;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -8,6 +10,12 @@ pub(crate) struct RemoteConnectionStatus {
|
||||
pub(crate) version: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ServerVersionNotice {
|
||||
pub(crate) message: String,
|
||||
pub(crate) offer_update: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn remote_connection_status_value(
|
||||
app_server_target: &AppServerTarget,
|
||||
server_version: Option<&str>,
|
||||
@@ -34,6 +42,102 @@ pub(crate) fn remote_connection_status_value(
|
||||
Some(RemoteConnectionStatus { address, version })
|
||||
}
|
||||
|
||||
pub(crate) fn server_version_notice(client: &str, server: Option<&str>) -> Option<String> {
|
||||
let server = server?;
|
||||
crate::update_versions::is_official_server_older(client, server).then(|| {
|
||||
format!(
|
||||
"A background Codex service is running v{server}, older than your Codex CLI v{client}."
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn hash_identity_part(hasher: &mut Sha256, tag: &[u8], value: &[u8]) {
|
||||
hasher.update(tag);
|
||||
hasher.update((value.len() as u64).to_le_bytes());
|
||||
hasher.update(value);
|
||||
}
|
||||
|
||||
pub(crate) fn server_version_notice_key(
|
||||
target: &AppServerTarget,
|
||||
server_home: Option<&str>,
|
||||
client: &str,
|
||||
server: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let server = server?;
|
||||
server_version_notice(client, Some(server))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let endpoint = match target {
|
||||
AppServerTarget::Embedded => None,
|
||||
AppServerTarget::LocalDaemon { endpoint } | AppServerTarget::Remote { endpoint } => {
|
||||
Some(endpoint)
|
||||
}
|
||||
};
|
||||
if let Some(server_home) = server_home {
|
||||
hash_identity_part(&mut hasher, b"server-home:", server_home.as_bytes());
|
||||
}
|
||||
match endpoint {
|
||||
Some(RemoteAppServerEndpoint::UnixSocket { socket_path }) => {
|
||||
hash_identity_part(
|
||||
&mut hasher,
|
||||
b"unix:",
|
||||
socket_path.as_path().as_os_str().as_encoded_bytes(),
|
||||
);
|
||||
}
|
||||
Some(RemoteAppServerEndpoint::WebSocket { websocket_url, .. }) => {
|
||||
if let Ok(mut url) = Url::parse(websocket_url) {
|
||||
let routing = url
|
||||
.query_pairs()
|
||||
.filter(|(name, _)| {
|
||||
matches!(
|
||||
name.to_ascii_lowercase().as_str(),
|
||||
"workspace"
|
||||
| "project"
|
||||
| "tenant"
|
||||
| "environment"
|
||||
| "namespace"
|
||||
| "organization"
|
||||
| "org"
|
||||
| "account"
|
||||
| "route"
|
||||
| "instance"
|
||||
| "region"
|
||||
| "deployment"
|
||||
| "cluster"
|
||||
)
|
||||
})
|
||||
.map(|(name, value)| (name.into_owned(), value.into_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
let _ = url.set_username("");
|
||||
let _ = url.set_password(None);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
if !routing.is_empty() {
|
||||
url.query_pairs_mut().extend_pairs(routing);
|
||||
}
|
||||
hash_identity_part(&mut hasher, b"websocket:", url.as_str().as_bytes());
|
||||
}
|
||||
}
|
||||
None => hash_identity_part(&mut hasher, b"embedded:", b""),
|
||||
}
|
||||
Some(format!("{:x}:{client}-{server}", hasher.finalize()))
|
||||
}
|
||||
|
||||
pub(crate) fn pending_server_version_notice(
|
||||
target: &AppServerTarget,
|
||||
server_home: Option<&str>,
|
||||
client: &str,
|
||||
server: Option<&str>,
|
||||
last_shown: Option<&str>,
|
||||
) -> Option<(ServerVersionNotice, String)> {
|
||||
let message = server_version_notice(client, server)?;
|
||||
let notice = ServerVersionNotice {
|
||||
message,
|
||||
offer_update: matches!(target, AppServerTarget::LocalDaemon { .. }),
|
||||
};
|
||||
let key = server_version_notice_key(target, server_home, client, server)?;
|
||||
(last_shown != Some(key.as_str())).then_some((notice, key))
|
||||
}
|
||||
|
||||
pub(crate) fn sanitized_websocket_url(raw: &str) -> Option<Url> {
|
||||
let mut url = Url::parse(raw).ok()?;
|
||||
let _ = url.set_username("");
|
||||
@@ -47,6 +151,7 @@ pub(crate) fn sanitized_websocket_url(raw: &str) -> Option<Url> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn remote_connection_status_value_formats_display_value() -> color_eyre::Result<()> {
|
||||
@@ -84,4 +189,77 @@ mod tests {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_version_notice_only_for_older_official_server() {
|
||||
assert_eq!(
|
||||
server_version_notice("0.153.0", Some("0.152.1")),
|
||||
Some("A background Codex service is running v0.152.1, older than your Codex CLI v0.153.0.".to_string())
|
||||
);
|
||||
assert_eq!(server_version_notice("0.153.0", Some("0.153.0")), None);
|
||||
assert_eq!(server_version_notice("0.0.0", Some("0.152.1")), None);
|
||||
assert_eq!(server_version_notice("0.153.0", /*server*/ None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_command_is_only_suggested_for_implicit_local_daemon() -> color_eyre::Result<()> {
|
||||
let endpoint = RemoteAppServerEndpoint::UnixSocket {
|
||||
socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?,
|
||||
};
|
||||
let local = AppServerTarget::LocalDaemon {
|
||||
endpoint: endpoint.clone(),
|
||||
};
|
||||
let remote = AppServerTarget::Remote { endpoint };
|
||||
let notice = |target| {
|
||||
pending_server_version_notice(
|
||||
target,
|
||||
/*server_home*/ None,
|
||||
"0.153.0",
|
||||
Some("0.152.1"),
|
||||
/*last_shown*/ None,
|
||||
)
|
||||
.map(|(notice, _)| notice)
|
||||
};
|
||||
let remote_notice = server_version_notice("0.153.0", Some("0.152.1")).unwrap();
|
||||
assert_eq!(
|
||||
notice(&remote),
|
||||
Some(ServerVersionNotice {
|
||||
message: remote_notice.clone(),
|
||||
offer_update: false,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
notice(&local),
|
||||
Some(ServerVersionNotice {
|
||||
message: remote_notice,
|
||||
offer_update: true,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notice_key_identifies_socket_and_server_home_across_connection_modes() -> anyhow::Result<()>
|
||||
{
|
||||
let socket = |path| -> anyhow::Result<RemoteAppServerEndpoint> {
|
||||
Ok(RemoteAppServerEndpoint::UnixSocket {
|
||||
socket_path: AbsolutePathBuf::from_absolute_path(path)?,
|
||||
})
|
||||
};
|
||||
let local = AppServerTarget::LocalDaemon {
|
||||
endpoint: socket("/c")?,
|
||||
};
|
||||
let remote = AppServerTarget::Remote {
|
||||
endpoint: socket("/c")?,
|
||||
};
|
||||
let key = |target: &AppServerTarget, server_home| {
|
||||
server_version_notice_key(target, server_home, "0.153.0", Some("0.152.0"))
|
||||
};
|
||||
assert_eq!(key(&local, Some("/a")), key(&remote, Some("/a")));
|
||||
let other = AppServerTarget::Remote {
|
||||
endpoint: socket("/bunix:/c")?,
|
||||
};
|
||||
assert_ne!(key(&remote, Some("/aunix:/b")), key(&other, Some("/a")));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
pub(crate) fn is_newer(latest: &str, current: &str) -> Option<bool> {
|
||||
match (parse_version(latest), parse_version(current)) {
|
||||
(Some(l), Some(c)) => Some(l > c),
|
||||
@@ -5,6 +6,7 @@ pub(crate) fn is_newer(latest: &str, current: &str) -> Option<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
pub(crate) fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result<String> {
|
||||
latest_tag_name
|
||||
.strip_prefix("rust-v")
|
||||
@@ -12,6 +14,7 @@ pub(crate) fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))
|
||||
}
|
||||
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
pub(crate) fn is_source_build_version(version: &str) -> bool {
|
||||
parse_version(version) == Some((0, 0, 0))
|
||||
}
|
||||
@@ -42,6 +45,7 @@ pub(crate) fn is_official_server_older(client: &str, server: &str) -> bool {
|
||||
matches!((stable_version(client), stable_version(server)), (Some(client), Some(server)) if client > server)
|
||||
}
|
||||
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
|
||||
let mut iter = v.trim().split('.');
|
||||
let maj = iter.next()?.parse::<u64>().ok()?;
|
||||
|
||||
Reference in New Issue
Block a user