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:
Eric Traut
2026-09-07 23:52:29 +00:00
committed by copyberry
parent 4b0f44d304
commit 530383e36d
25 changed files with 605 additions and 19 deletions
+2
View File
@@ -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.
+11 -1
View File
@@ -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),
+16 -1
View File
@@ -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,
+32
View File
@@ -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();
}
@@ -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
@@ -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
+41 -2
View File
@@ -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;
}
+1
View File
@@ -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,
+2
View File
@@ -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);
+37
View File
@@ -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;
+7
View File
@@ -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?;
+8
View File
@@ -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();
+15
View File
@@ -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,
@@ -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.
@@ -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.
+46
View File
@@ -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()
-1
View File
@@ -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(())
}
}
+4
View File
@@ -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()?;