Extend server version notices to prerelease and local clients (#46673)

## Why

Server version notices previously required both versions to be stable releases, leaving prerelease and local clients without notices about differing background services.

## What changed

- Use semantic version parsing to distinguish older servers from version mismatches.
- Compare stable clients against older releases, including prereleases. Compare prerelease clients by precedence within the same release line and report other release lines as different.
- Report differing versions for local clients (`0.0.0` or versions with build metadata). Ignore malformed versions.
- Show the corresponding `older than` or `different from` notice and `<` or `≠` overview indicator, while preserving `/daemon` guidance and the `show_server_version_notice` setting.

## Testing

Add version-policy tests and notice snapshots covering prerelease ordering, local builds, mismatches, and malformed versions. Add daemon tests for notice suppression and manual update guidance without scheduling an update.

GitOrigin-RevId: 29df4713a7dbcbddcf30d56995a8496e7e8d0c6a
This commit is contained in:
Eric Traut
2026-09-19 17:03:26 +00:00
committed by copyberry
parent 595cc91e8c
commit a5290028a2
16 changed files with 234 additions and 65 deletions
+1
View File
@@ -4855,6 +4855,7 @@ dependencies = [
"ratatui-macros",
"regex-lite",
"rmcp",
"semver",
"serde",
"serde_json",
"serial_test",
+1 -1
View File
@@ -763,7 +763,7 @@ pub struct Tui {
#[serde(default = "default_true")]
pub show_tooltips: bool,
/// Show an informational notice when the connected app server is an older stable release.
/// Show informational notices about connected app server version differences.
/// Defaults to `true`; this does not control compatibility errors or version status.
#[serde(default = "default_true")]
pub show_server_version_notice: bool,
+1 -1
View File
@@ -4479,7 +4479,7 @@
},
"show_server_version_notice": {
"default": true,
"description": "Show an informational notice when the connected app server is an older stable release. Defaults to `true`; this does not control compatibility errors or version status.",
"description": "Show informational notices about connected app server version differences. Defaults to `true`; this does not control compatibility errors or version status.",
"type": "boolean"
},
"show_tooltips": {
+1
View File
@@ -99,6 +99,7 @@ ratatui = { workspace = true, features = [
ratatui-macros = { workspace = true }
regex-lite = { workspace = true }
rmcp = { workspace = true, features = ["server", "transport-streamable-http-server"] }
semver = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["preserve_order"] }
sha2 = { workspace = true }
@@ -76,7 +76,7 @@ async fn server_version_overview_notice_updates_and_clears() {
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);
app.update_server_version_overview_notice("0.153.0", /*server_version*/ 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);
+49
View File
@@ -6,6 +6,55 @@ use crate::status::remote_connection::RemoteConnectionStatus;
use crossterm::event::KeyCode;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn daemon_version_notice_preserves_manual_update_guidance() {
let mut app = make_test_app().await;
app.app_server_target = AppServerTarget::LocalDaemon {
allow_embedded_fallback: true,
endpoint: crate::RemoteAppServerEndpoint::UnixSocket {
socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock").unwrap(),
},
};
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let mut notices = Vec::new();
for (client, server, comparison) in [
("0.155.0-alpha.23", "0.155.0-alpha.22", "older than"),
("0.155.0-alpha.23", "0.156.0", "different from"),
("0.0.0", "0.156.0", "different from"),
] {
app.local_settings.tui.show_server_version_notice = true;
assert_eq!(
app.initialize_server_version_notice(client, Some(server)),
Some(format!(
"A background Codex service is running v{server}, {comparison} your Codex CLI v{client}."
))
);
let overview = render_bottom_popup(&app.chat_widget, /*width*/ 100);
notices.push(overview.lines().next().unwrap().trim().to_string());
assert_eq!(app.pending_update_action, None);
app.local_settings.tui.show_server_version_notice = false;
assert_eq!(
app.initialize_server_version_notice(client, Some(server)),
None
);
assert_eq!(
app.agents_overview
.view_state
.lock()
.unwrap()
.server_version_notice,
None
);
}
insta::assert_snapshot!(notices.join("\n"), @"
Service v0.155.0-alpha.22 < Codex CLI v0.155.0-alpha.23 · /daemon
Service v0.156.0 ≠ Codex CLI v0.155.0-alpha.23 · /daemon
Service v0.156.0 ≠ Codex CLI v0.0.0 · /daemon
");
}
#[tokio::test]
async fn daemon_menu_is_read_only_and_confirmation_can_cancel_or_handoff() {
let mut app = make_test_app().await;
+5 -2
View File
@@ -189,7 +189,7 @@ impl App {
self.reconnect.seen_version_notice = None;
self.update_server_version_overview_notice(
CODEX_CLI_VERSION,
/*older_server*/ None,
/*server_version*/ None,
);
}
self.cancel_pending_key_chord();
@@ -492,7 +492,10 @@ impl App {
|| 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);
self.update_server_version_overview_notice(
client_version,
/*server_version*/ None,
);
}
if let Some((notice, key)) = crate::status::remote_connection::pending_server_version_notice(
&self.local_settings.tui,
+12 -4
View File
@@ -1,6 +1,7 @@
//! Keeps the older server notice and local maintenance guidance in the overview.
//! Keeps the server version notice and local maintenance guidance in the overview.
use super::*;
use crate::update_versions::ServerVersionNoticeKind;
impl App {
pub(super) fn refresh_server_version_overview_notice(&mut self, client_version: &str) {
@@ -41,20 +42,27 @@ impl App {
pub(super) fn update_server_version_overview_notice(
&mut self,
client_version: &str,
older_server: Option<&str>,
server_version: Option<&str>,
) {
self.agents_overview
.view_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.server_version_notice = older_server.map(|server| {
.server_version_notice = server_version.and_then(|server| {
let guidance = if matches!(self.app_server_target, AppServerTarget::LocalDaemon { .. })
{
" · /daemon"
} else {
""
};
format!("Service v{server} < Codex CLI v{client_version}{guidance}")
let comparison =
match crate::update_versions::server_version_notice_kind(client_version, server)? {
ServerVersionNoticeKind::Older => "<",
ServerVersionNoticeKind::Different => "≠",
};
Some(format!(
"Service v{server} {comparison} Codex CLI v{client_version}{guidance}"
))
});
}
}
+1 -1
View File
@@ -826,7 +826,7 @@ See the Codex keymap documentation for supported actions and examples."
if initial_server_version_notice.is_none() {
app.update_server_version_overview_notice(
CODEX_CLI_VERSION,
/*older_server*/ None,
/*server_version*/ None,
);
}
if start_in_agents_overview {
@@ -1,9 +1,11 @@
---
source: tui/src/app/tests/active_reconnect_tests.rs
expression: "format!(\"{history}\\n{}\", render_bottom_popup(&app.chat_widget, 80))"
expression: "format!(\"{history}\\n{}\",\nrender_bottom_popup(&app.chat_widget,\n80)).replace(&test_path_buf(\"/tmp/project\").display().to_string(),\n\"/tmp/project\")"
---
• This conversation is unavailable. Its cached transcript and draft remain here; input is paused. Open the agent picker or return to the parent to continue.
• Reconnected. No input was resent. Review uncertain submissions before retrying; recovered queues remain paused.
⚠ A background Codex service is running v2.0.0, different from your Codex CLI
v0.0.0.
› unavailable draft
gpt-test default · /tmp/project
@@ -2,7 +2,7 @@
source: tui/src/app/tests/navigation_reconnect_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 100)"
---
Agent command center
Service v2.0.0 ≠ Codex CLI v0.0.0 · /daemon
0 need input 1 working 1 ready
────────────────────────────────────────────────────────────────────────────────────────────────
/tmp/project 2 │ Task details
@@ -0,0 +1,7 @@
---
source: tui/src/history_cell/tests.rs
expression: "render_lines(&cell.display_lines(100)).join(\"\\n\")"
---
⚠ A background Codex service is running v0.156.0, different from your Codex CLI v0.155.0-alpha.12.
Use /daemon to manage the local background server.
Updating may interrupt active or queued work.
@@ -0,0 +1,7 @@
---
source: tui/src/history_cell/tests.rs
expression: "render_lines(&cell.display_lines(100)).join(\"\\n\")"
---
⚠ A background Codex service is running v0.156.0, different from your Codex CLI v0.0.0.
Use /daemon to manage the local background server.
Updating may interrupt active or queued work.
+20 -11
View File
@@ -78,17 +78,26 @@ fn local_daemon_version_notice_snapshot() {
show_server_version_notice: true,
..Default::default()
};
let (notice, _) = crate::status::remote_connection::pending_server_version_notice(
&settings,
&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"));
for (client, server, snapshot) in [
("0.153.0", "0.152.1", "local_daemon_version_notice_snapshot"),
("0.155.0-alpha.12", "0.156.0", "local_daemon_alpha_mismatch"),
("0.0.0", "0.156.0", "local_daemon_source_mismatch"),
] {
let (notice, _) = crate::status::remote_connection::pending_server_version_notice(
&settings,
&target,
/*server_home*/ None,
client,
Some(server),
/*last_shown*/ None,
)
.expect("local service should have a notice");
let cell = new_server_version_warning(notice);
insta::assert_snapshot!(
snapshot,
render_lines(&cell.display_lines(/*width*/ 100)).join("\n")
);
}
}
async fn test_config() -> Config {
+31 -10
View File
@@ -1,5 +1,6 @@
use crate::AppServerTarget;
use crate::RemoteAppServerEndpoint;
use crate::update_versions::ServerVersionNoticeKind;
use sha2::Digest;
use sha2::Sha256;
use url::Url;
@@ -44,11 +45,13 @@ pub(crate) fn remote_connection_status_value(
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}."
)
})
let comparison = match crate::update_versions::server_version_notice_kind(client, server)? {
ServerVersionNoticeKind::Older => "older than",
ServerVersionNoticeKind::Different => "different from",
};
Some(format!(
"A background Codex service is running v{server}, {comparison} your Codex CLI v{client}."
))
}
pub(crate) fn server_version_notice_for_tui(
@@ -205,14 +208,31 @@ mod tests {
}
#[test]
fn server_version_notice_only_for_older_official_server() {
fn server_version_notice_uses_client_release_policy() {
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.0.0", Some("0.152.1")),
Some("A background Codex service is running v0.152.1, different from your Codex CLI v0.0.0.".to_string())
);
assert_eq!(server_version_notice("0.153.0", /*server*/ None), None);
assert_eq!(
server_version_notice("0.153.0-alpha.10", Some("0.153.0-alpha.9")),
Some("A background Codex service is running v0.153.0-alpha.9, older than your Codex CLI v0.153.0-alpha.10.".to_string())
);
for server in ["0.153.0-alpha.10", "0.153.0-alpha.11", "0.153.0"] {
assert_eq!(
server_version_notice("0.153.0-alpha.10", Some(server)),
None
);
}
assert_eq!(
server_version_notice("0.155.0-alpha.12", Some("0.156.0")),
Some("A background Codex service is running v0.156.0, different from your Codex CLI v0.155.0-alpha.12.".to_string())
);
}
#[test]
@@ -234,13 +254,14 @@ mod tests {
&settings,
target,
/*server_home*/ None,
"0.153.0",
Some("0.152.1"),
"0.153.0-alpha.10.1",
Some("0.153.0-alpha.9.2"),
/*last_shown*/ None,
)
.map(|(notice, _)| notice)
};
let remote_notice = server_version_notice("0.153.0", Some("0.152.1")).unwrap();
let remote_notice =
server_version_notice("0.153.0-alpha.10.1", Some("0.153.0-alpha.9.2")).unwrap();
assert_eq!(
notice(&remote),
Some(ServerVersionNotice {
+93 -32
View File
@@ -19,30 +19,28 @@ pub(crate) fn is_source_build_version(version: &str) -> bool {
parse_version(version) == Some((0, 0, 0))
}
/// Whether an official stable TUI release is newer than the connected app server.
pub(crate) fn is_official_server_older(client: &str, server: &str) -> bool {
fn stable_version(version: &str) -> Option<(u64, u64, u64)> {
fn component(value: Option<&str>) -> Option<u64> {
let value = value?;
if value.is_empty()
|| !value.bytes().all(|byte| byte.is_ascii_digit())
|| (value.len() > 1 && value.starts_with('0'))
{
return None;
}
value.parse().ok()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ServerVersionNoticeKind {
Older,
Different,
}
let mut parts = version.split('.');
let version = (
component(parts.next())?,
component(parts.next())?,
component(parts.next())?,
);
(parts.next().is_none() && version != (0, 0, 0)).then_some(version)
/// Stable clients compare release precedence. Prerelease clients only order versions
/// within the same release line; local builds and other release lines compare identity.
pub(crate) fn server_version_notice_kind(
client: &str,
server: &str,
) -> Option<ServerVersionNoticeKind> {
let client = semver::Version::parse(client).ok()?;
let server = semver::Version::parse(server).ok()?;
let client_release = (client.major, client.minor, client.patch);
let server_release = (server.major, server.minor, server.patch);
let client_is_local = client_release == (0, 0, 0) || !client.build.is_empty();
if client_is_local || (!client.pre.is_empty() && client_release != server_release) {
return (client != server).then_some(ServerVersionNoticeKind::Different);
}
matches!((stable_version(client), stable_version(server)), (Some(client), Some(server)) if client > server)
(server.build.is_empty() && server_release != (0, 0, 0) && client > server)
.then_some(ServerVersionNoticeKind::Older)
}
#[cfg(any(not(debug_assertions), test))]
@@ -99,24 +97,87 @@ mod tests {
}
#[test]
fn official_server_version_comparison() {
assert!(is_official_server_older("0.152.1", "0.152.0"));
assert!(is_official_server_older("0.153.0", "0.152.1"));
assert!(!is_official_server_older("0.152.0", "0.152.0"));
assert!(!is_official_server_older("0.152.0", "0.153.0"));
fn stable_clients_only_warn_for_older_releases() {
for (client, server, expected) in [
("0.152.1", "0.152.0", Some(ServerVersionNoticeKind::Older)),
("0.153.0", "0.152.1", Some(ServerVersionNoticeKind::Older)),
(
"0.153.0",
"0.153.0-alpha.10.1",
Some(ServerVersionNoticeKind::Older),
),
(
"0.156.0",
"0.155.0-alpha.12",
Some(ServerVersionNoticeKind::Older),
),
("0.153.0", "0.153.0", None),
("0.153.0", "0.154.0", None),
("0.153.0", "0.154.0-alpha.1", None),
("0.153.0", "0.0.0", None),
("0.153.0", "0.0.0-alpha.1", None),
("0.153.0", "0.152.0+dev", None),
] {
assert_eq!(server_version_notice_kind(client, server), expected);
}
}
#[test]
fn prerelease_clients_compare_within_the_same_release_line() {
for (newer, older) in [
("0.155.0-alpha.23", "0.155.0-alpha.22"),
("0.155.0-alpha.24", "0.155.0-alpha.23"),
("0.153.0-alpha.10", "0.153.0-alpha.9"),
("0.153.0-alpha.10.1", "0.153.0-alpha.9.2"),
("0.153.0-alpha.10.10", "0.153.0-alpha.10.9"),
("0.153.0-alpha.10.1", "0.153.0-alpha.10"),
("0.155.0", "0.155.0-alpha.23"),
] {
assert_eq!(
server_version_notice_kind(newer, older),
Some(ServerVersionNoticeKind::Older)
);
assert_eq!(server_version_notice_kind(older, newer), None);
assert_eq!(server_version_notice_kind(newer, newer), None);
}
}
#[test]
fn prerelease_clients_on_other_release_lines_and_local_clients_warn_for_mismatches() {
for (client, server) in [
("0.155.0-alpha.23", "0.156.0"),
("0.155.0-alpha.12", "0.154.0"),
("0.0.0", "0.153.0"),
("0.0.0", "0.153.0-alpha.10"),
("0.153.0+dev", "0.153.0"),
("0.153.0-alpha.10", "0.0.0"),
] {
assert_eq!(
server_version_notice_kind(client, server),
Some(ServerVersionNoticeKind::Different)
);
assert_eq!(server_version_notice_kind(client, client), None);
}
}
#[test]
fn unknown_or_malformed_versions_do_not_produce_notices() {
for version in [
"0.0.0",
"0.0.0.0",
"0.153.0-alpha.1",
"unknown",
"dev",
"0.0.0.0",
"0.153",
"0.153.0.1",
" 0.153.0",
"+0.153.0",
"0.0153.0",
"0.153.0-alpha.01",
"0.153.0-alpha..1",
] {
assert!(!is_official_server_older(version, "0.152.0"));
assert!(!is_official_server_older("0.153.0", version));
for release in ["0.153.0", "0.153.0-alpha.10", "0.0.0"] {
assert_eq!(server_version_notice_kind(version, release), None);
assert_eq!(server_version_notice_kind(release, version), None);
}
}
}
}