From ed823f34a3ef75922db21a073767cf5fe1d346ee Mon Sep 17 00:00:00 2001 From: James Peter Date: Fri, 31 Oct 2025 05:54:47 +1000 Subject: [PATCH 1/4] feat(core): include current date in environment context (#5965) - add optional `current_date` field to `EnvironmentContext` so turn context carries ISO dates - serialize `` tags and extend comparisons/tests to tolerate deterministic values - cover default date formatting with new unit tests --- COMMIT_MESSAGE_ISSUE_5965.txt | 5 +++ PR_BODY_ISSUE_5965.md | 12 ++++++ code-rs/core/src/environment_context.rs | 54 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 COMMIT_MESSAGE_ISSUE_5965.txt create mode 100644 PR_BODY_ISSUE_5965.md diff --git a/COMMIT_MESSAGE_ISSUE_5965.txt b/COMMIT_MESSAGE_ISSUE_5965.txt new file mode 100644 index 00000000000..0807759bbe4 --- /dev/null +++ b/COMMIT_MESSAGE_ISSUE_5965.txt @@ -0,0 +1,5 @@ +feat(core): include current date in environment context (#5965) + +- add optional `current_date` field to `EnvironmentContext` so turn context carries ISO dates +- serialize `` tags and extend comparisons/tests to tolerate deterministic values +- cover default date formatting with new unit tests diff --git a/PR_BODY_ISSUE_5965.md b/PR_BODY_ISSUE_5965.md new file mode 100644 index 00000000000..606a77e1517 --- /dev/null +++ b/PR_BODY_ISSUE_5965.md @@ -0,0 +1,12 @@ +## Summary +- inject the current local date into `EnvironmentContext` so every turn shares an ISO8601 `` tag with the model +- extend the serializer/equality helpers to account for the new field while keeping comparisons deterministic in tests +- add lightweight unit coverage to lock the XML output and default date format + +## Testing +- ./build-fast.sh + +## Acceptance Criteria +- environment context payloads now surface a `` element in YYYY-MM-DD form +- existing comparisons that ignore shell differences remain stable once the date is normalized +- unit tests document the new field and its formatting so regressions are caught automatically diff --git a/code-rs/core/src/environment_context.rs b/code-rs/core/src/environment_context.rs index 93110b64e92..90495aa4384 100644 --- a/code-rs/core/src/environment_context.rs +++ b/code-rs/core/src/environment_context.rs @@ -1,3 +1,4 @@ +use chrono::Local; use os_info::Type as OsType; use os_info::Version; use serde::Deserialize; @@ -33,6 +34,7 @@ pub(crate) struct EnvironmentContext { pub operating_system: Option, pub common_tools: Option>, pub shell: Option, + pub current_date: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -145,6 +147,7 @@ impl EnvironmentContext { operating_system: detect_operating_system_info(), common_tools: detect_common_tools(), shell, + current_date: Some(Local::now().format("%Y-%m-%d").to_string()), } } @@ -161,6 +164,7 @@ impl EnvironmentContext { writable_roots, operating_system, common_tools, + current_date, // should compare all fields except shell shell: _, } = other; @@ -172,6 +176,7 @@ impl EnvironmentContext { && self.writable_roots == *writable_roots && self.operating_system == *operating_system && self.common_tools == *common_tools + && self.current_date == *current_date } } @@ -249,6 +254,9 @@ impl EnvironmentContext { lines.push(" ".to_string()); } } + if let Some(current_date) = self.current_date { + lines.push(format!(" {current_date}")); + } if let Some(shell) = self.shell && let Some(shell_name) = shell.name() { @@ -360,6 +368,7 @@ mod tests { ); context.operating_system = None; context.common_tools = None; + context.current_date = Some("2025-01-02".to_string()); let expected = r#" /repo @@ -370,6 +379,7 @@ mod tests { /repo /tmp + 2025-01-02 "#; assert_eq!(context.serialize_to_xml(), expected); @@ -385,11 +395,13 @@ mod tests { ); context.operating_system = None; context.common_tools = None; + context.current_date = Some("2025-01-02".to_string()); let expected = r#" never read-only restricted + 2025-01-02 "#; assert_eq!(context.serialize_to_xml(), expected); @@ -405,11 +417,13 @@ mod tests { ); context.operating_system = None; context.common_tools = None; + context.current_date = Some("2025-01-02".to_string()); let expected = r#" on-failure danger-full-access enabled + 2025-01-02 "#; assert_eq!(context.serialize_to_xml(), expected); @@ -429,6 +443,7 @@ mod tests { architecture: Some("aarch64".to_string()), }); context.common_tools = Some(vec!["rg".to_string(), "git".to_string()]); + context.current_date = Some("2025-01-02".to_string()); let xml = context.serialize_to_xml(); assert!(xml.contains("")); @@ -438,6 +453,7 @@ mod tests { assert!(xml.contains("")); assert!(xml.contains("rg")); assert!(xml.contains("git")); + assert!(xml.contains("2025-01-02")); } #[test] @@ -455,6 +471,12 @@ mod tests { Some(workspace_write_policy(vec!["/repo"], true)), None, ); + // ensure current_date doesn't influence this comparison + let fixed_date = Some("2025-01-02".to_string()); + let mut context1 = context1; + context1.current_date = fixed_date.clone(); + let mut context2 = context2; + context2.current_date = fixed_date; assert!(!context1.equals_except_shell(&context2)); } @@ -472,6 +494,10 @@ mod tests { Some(SandboxPolicy::new_workspace_write_policy()), None, ); + let mut context1 = context1; + context1.current_date = Some("2025-01-02".to_string()); + let mut context2 = context2; + context2.current_date = Some("2025-01-02".to_string()); assert!(!context1.equals_except_shell(&context2)); } @@ -490,6 +516,10 @@ mod tests { Some(workspace_write_policy(vec!["/repo", "/tmp"], true)), None, ); + let mut context1 = context1; + context1.current_date = Some("2025-01-02".to_string()); + let mut context2 = context2; + context2.current_date = Some("2025-01-02".to_string()); assert!(!context1.equals_except_shell(&context2)); } @@ -514,7 +544,31 @@ mod tests { zshrc_path: "/home/user/.zshrc".into(), })), ); + let mut context1 = context1; + context1.current_date = Some("2025-01-02".to_string()); + let mut context2 = context2; + context2.current_date = Some("2025-01-02".to_string()); assert!(context1.equals_except_shell(&context2)); } + + #[test] + fn serialize_environment_context_includes_current_date() { + let mut context = EnvironmentContext::new(None, None, None, None); + context.current_date = Some("2025-01-02".to_string()); + + let xml = context.serialize_to_xml(); + assert!(xml.contains("2025-01-02")); + } + + #[test] + fn current_date_format_is_iso8601() { + let context = EnvironmentContext::new(None, None, None, None); + let date = context + .current_date + .expect("current_date should be populated"); + assert_eq!(date.len(), 10); + assert_eq!(date.chars().nth(4), Some('-')); + assert_eq!(date.chars().nth(7), Some('-')); + } } From cebb2ddfd87083efe1ff2a985f1569949d9e88ed Mon Sep 17 00:00:00 2001 From: James Peter Date: Fri, 31 Oct 2025 06:19:24 +1000 Subject: [PATCH 2/4] fix(tui/update): ignore cached upstream upgrade metadata (#376) - reject cached version.json written by the upstream repo so we don't keep prompting for 0.50.0 - stamp new cache entries with the just-every/code origin to avoid future mixups - add regression tests covering legacy and current cache formats --- code-rs/tui/src/updates.rs | 56 +++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/code-rs/tui/src/updates.rs b/code-rs/tui/src/updates.rs index ca18f4c6414..aea80fc54de 100644 --- a/code-rs/tui/src/updates.rs +++ b/code-rs/tui/src/updates.rs @@ -109,6 +109,8 @@ struct VersionInfo { latest_version: String, // ISO-8601 timestamp (RFC3339) last_checked_at: DateTime, + #[serde(default)] + release_repo: Option, } #[derive(Deserialize, Debug, Clone)] @@ -118,6 +120,8 @@ struct ReleaseInfo { const VERSION_FILENAME: &str = "version.json"; const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/just-every/code/releases/latest"; +const CURRENT_RELEASE_REPO: &str = "just-every/code"; +const LEGACY_RELEASE_REPO: &str = "openai/codex"; pub const CODE_RELEASE_URL: &str = "https://github.com/just-every/code/releases/latest"; const AUTO_UPGRADE_LOCK_FILE: &str = "auto-upgrade.lock"; @@ -460,7 +464,17 @@ fn truncate_for_log(text: &str) -> String { fn read_version_info(version_file: &Path) -> anyhow::Result { let contents = std::fs::read_to_string(version_file)?; - Ok(serde_json::from_str(&contents)?) + let info: VersionInfo = serde_json::from_str(&contents)?; + let repo = info + .release_repo + .as_deref() + .unwrap_or(LEGACY_RELEASE_REPO); + if repo != CURRENT_RELEASE_REPO { + anyhow::bail!( + "stale version info from {repo}; discarding cached update metadata" + ); + } + Ok(info) } async fn check_for_update(version_file: &Path, originator: &str) -> anyhow::Result { @@ -496,6 +510,7 @@ async fn check_for_update(version_file: &Path, originator: &str) -> anyhow::Resu let info = VersionInfo { latest_version, last_checked_at: Utc::now(), + release_repo: Some(CURRENT_RELEASE_REPO.to_string()), }; let json_line = format!("{}\n", serde_json::to_string(&info)?); @@ -520,3 +535,42 @@ fn parse_version(v: &str) -> Option<(u64, u64, u64)> { let pat = iter.next()?.parse::().ok()?; Some((maj, min, pat)) } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use tempfile::tempdir; + + #[test] + fn read_version_info_rejects_legacy_repo_cache() { + let dir = tempdir().unwrap(); + let path = dir.path().join("version.json"); + let legacy = serde_json::json!({ + "latest_version": "0.50.0", + "last_checked_at": Utc.timestamp_opt(1_696_000_000, 0).unwrap().to_rfc3339(), + }); + std::fs::write(&path, format!("{}\n", legacy)).unwrap(); + + let err = read_version_info(&path).expect_err("legacy cache should be rejected"); + assert!(err + .to_string() + .contains("stale version info")); + } + + #[test] + fn read_version_info_accepts_current_repo_cache() { + let dir = tempdir().unwrap(); + let path = dir.path().join("version.json"); + let info = serde_json::json!({ + "latest_version": "0.4.7", + "last_checked_at": Utc::now().to_rfc3339(), + "release_repo": CURRENT_RELEASE_REPO, + }); + std::fs::write(&path, format!("{}\n", info)).unwrap(); + + let parsed = read_version_info(&path).expect("current repo cache should load"); + assert_eq!(parsed.latest_version, "0.4.7"); + assert_eq!(parsed.release_repo.as_deref(), Some(CURRENT_RELEASE_REPO)); + } +} From f600d5ff070068b29edeec74e53416029556620f Mon Sep 17 00:00:00 2001 From: James Peter Date: Fri, 31 Oct 2025 06:49:12 +1000 Subject: [PATCH 3/4] fix(core/chat): surface DNS/network guidance on stream errors (#377) - detect reqwest connect/timeout failures while starting model streams and wrap them with actionable hints - add DNS-specific guidance so users can spot resolver misconfigurations (e.g., empty /etc/resolv.conf) - cover hint detection with lightweight unit tests --- COMMIT_MESSAGE_ISSUE_377.txt | 5 ++ PR_BODY_ISSUE_377.md | 10 ++++ code-rs/core/src/chat_completions.rs | 76 +++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 COMMIT_MESSAGE_ISSUE_377.txt create mode 100644 PR_BODY_ISSUE_377.md diff --git a/COMMIT_MESSAGE_ISSUE_377.txt b/COMMIT_MESSAGE_ISSUE_377.txt new file mode 100644 index 00000000000..bd4cf5c32bd --- /dev/null +++ b/COMMIT_MESSAGE_ISSUE_377.txt @@ -0,0 +1,5 @@ +fix(core/chat): surface DNS/network guidance on stream errors (#377) + +- detect reqwest connect/timeout failures while starting model streams and wrap them with actionable hints +- add DNS-specific guidance so users can spot resolver misconfigurations (e.g., empty /etc/resolv.conf) +- cover hint detection with lightweight unit tests diff --git a/PR_BODY_ISSUE_377.md b/PR_BODY_ISSUE_377.md new file mode 100644 index 00000000000..9938046f456 --- /dev/null +++ b/PR_BODY_ISSUE_377.md @@ -0,0 +1,10 @@ +## Summary +- wrap model stream connect errors with contextual guidance so DNS/connection failures no longer surface as opaque reqwest messages +- treat timeouts and DNS resolution failures as `CodexErr::Stream` with actionable hints (e.g., check `/etc/resolv.conf`) +- add unit coverage for the DNS hint helper + +## Testing +- cargo test -p code-core chat_completions::tests::dns_hint_matches_common_messages +- ./build-fast.sh + +Fixes #377. diff --git a/code-rs/core/src/chat_completions.rs b/code-rs/core/src/chat_completions.rs index 89c4a724c25..f058903ab2f 100644 --- a/code-rs/core/src/chat_completions.rs +++ b/code-rs/core/src/chat_completions.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::error::Error as StdError; use std::time::Duration; use bytes::Bytes; @@ -468,7 +469,7 @@ pub(crate) async fn stream_chat_completions( ); let _ = logger.end_request_log(&request_id); } - return Err(e.into()); + return Err(enrich_reqwest_error(e)); } let delay = backoff(attempt); tokio::time::sleep(delay).await; @@ -851,6 +852,79 @@ async fn process_chat_sse( } } +fn enrich_reqwest_error(err: reqwest::Error) -> CodexErr { + let url_hint = err + .url() + .map(|u| format!(" while requesting {u}")) + .unwrap_or_default(); + let root_msg = deepest_error_message(&err); + let full_msg = err.to_string(); + + if err.is_timeout() { + let message = format!( + "network timeout{url_hint}: {root_msg}. Please check your internet connection and try again." + ); + return CodexErr::Stream(message, None); + } + + if err.is_connect() { + let hint = dns_resolution_hint(&root_msg) + .or_else(|| dns_resolution_hint(&full_msg)) + .unwrap_or( + "Verify that you have an active internet connection and that outbound HTTPS access to the model endpoint is allowed.", + ); + let message = format!("network error{url_hint}: {root_msg}. {hint}"); + return CodexErr::Stream(message, None); + } + + CodexErr::Reqwest(err) +} + +fn deepest_error_message(err: &reqwest::Error) -> String { + let mut current: &dyn StdError = err; + let mut last = current.to_string(); + while let Some(source) = current.source() { + last = source.to_string(); + current = source; + } + last +} + +fn dns_resolution_hint(message: &str) -> Option<&'static str> { + let lower = message.to_ascii_lowercase(); + if lower.contains("dns error") + || lower.contains("failed to lookup address information") + || lower.contains("temporary failure in name resolution") + || lower.contains("name or service not known") + || lower.contains("no such host") + || lower.contains("failed host lookup") + { + Some( + "Check that your DNS configuration is working (for example verify /etc/resolv.conf or your system resolver settings) and then retry.", + ) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::dns_resolution_hint; + + #[test] + fn dns_hint_matches_common_messages() { + assert!(dns_resolution_hint("dns error: failed to lookup address information").is_some()); + assert!(dns_resolution_hint("Temporary failure in name resolution").is_some()); + assert!(dns_resolution_hint("Name or service not known").is_some()); + } + + #[test] + fn dns_hint_ignores_non_dns_errors() { + assert!(dns_resolution_hint("connection refused").is_none()); + assert!(dns_resolution_hint("TLS handshake timeout").is_none()); + } +} + /// Optional client-side aggregation helper /// /// Stream adapter that merges the incremental `OutputItemDone` chunks coming from From cda87bf4ef16e19ca419dc98654a3faa680f5098 Mon Sep 17 00:00:00 2001 From: James Peter Date: Fri, 7 Nov 2025 06:29:41 +1000 Subject: [PATCH 4/4] fix(core/chat): retain innermost reqwest error context --- code-rs/core/src/chat_completions.rs | 47 ++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/code-rs/core/src/chat_completions.rs b/code-rs/core/src/chat_completions.rs index f058903ab2f..ea2bffed0bf 100644 --- a/code-rs/core/src/chat_completions.rs +++ b/code-rs/core/src/chat_completions.rs @@ -880,14 +880,21 @@ fn enrich_reqwest_error(err: reqwest::Error) -> CodexErr { CodexErr::Reqwest(err) } -fn deepest_error_message(err: &reqwest::Error) -> String { +fn deepest_error_message(err: &dyn StdError) -> String { let mut current: &dyn StdError = err; let mut last = current.to_string(); while let Some(source) = current.source() { - last = source.to_string(); + let candidate = source.to_string(); + if !candidate.is_empty() { + last = candidate; + } current = source; } - last + if last.is_empty() { + err.to_string() + } else { + last + } } fn dns_resolution_hint(message: &str) -> Option<&'static str> { @@ -898,6 +905,10 @@ fn dns_resolution_hint(message: &str) -> Option<&'static str> { || lower.contains("name or service not known") || lower.contains("no such host") || lower.contains("failed host lookup") + || lower.contains("nodename nor servname provided") + || lower.contains("getaddrinfo failed") + || lower.contains("could not resolve host") + || lower.contains("name resolution failed") { Some( "Check that your DNS configuration is working (for example verify /etc/resolv.conf or your system resolver settings) and then retry.", @@ -910,12 +921,17 @@ fn dns_resolution_hint(message: &str) -> Option<&'static str> { #[cfg(test)] mod tests { use super::dns_resolution_hint; + use super::deepest_error_message; + use std::error::Error as StdError; #[test] fn dns_hint_matches_common_messages() { assert!(dns_resolution_hint("dns error: failed to lookup address information").is_some()); assert!(dns_resolution_hint("Temporary failure in name resolution").is_some()); assert!(dns_resolution_hint("Name or service not known").is_some()); + assert!(dns_resolution_hint("No such host is known").is_some()); + assert!(dns_resolution_hint("nodename nor servname provided, or not known").is_some()); + assert!(dns_resolution_hint("getaddrinfo failed: Name or service not known").is_some()); } #[test] @@ -923,6 +939,31 @@ mod tests { assert!(dns_resolution_hint("connection refused").is_none()); assert!(dns_resolution_hint("TLS handshake timeout").is_none()); } + + #[test] + fn deepest_error_message_prefers_innermost_source() { + let err = anyhow::anyhow!("lowest cause") + .context("wrapper level 1") + .context("wrapper level 2"); + let message = deepest_error_message(err.as_ref()); + assert_eq!(message, "lowest cause"); + } + + #[test] + fn deepest_error_message_handles_empty_source_messages() { + #[derive(Debug)] + struct EmptySourceError; + impl std::fmt::Display for EmptySourceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "") + } + } + impl StdError for EmptySourceError {} + + let err = anyhow::anyhow!("top level").context(EmptySourceError); + let message = deepest_error_message(err.as_ref()); + assert_eq!(message, "top level"); + } } /// Optional client-side aggregation helper