mirror of
https://github.com/openai/codex.git
synced 2026-02-04 07:53:43 +00:00
Compare commits
29 Commits
queue/stee
...
nudge-upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
449df00c2b | ||
|
|
ce023c0341 | ||
|
|
2b9b689f9e | ||
|
|
dca60646d8 | ||
|
|
cc8d60c32f | ||
|
|
baf075e49f | ||
|
|
6bf3d3eccf | ||
|
|
405731caa8 | ||
|
|
72502f2709 | ||
|
|
c7a46d3f0d | ||
|
|
f43d6b2d11 | ||
|
|
afbd362a3e | ||
|
|
597ce69188 | ||
|
|
b4d69e985f | ||
|
|
0f20ba1dad | ||
|
|
483532f28d | ||
|
|
a6c980d4e8 | ||
|
|
48e9eeaa7a | ||
|
|
8255a75000 | ||
|
|
61b0ad6c45 | ||
|
|
6b2d26fbbd | ||
|
|
22b02ea9f8 | ||
|
|
210ab25aee | ||
|
|
63e5498e24 | ||
|
|
98e7b58beb | ||
|
|
9ba67c9a29 | ||
|
|
f4028287e3 | ||
|
|
a4132d7523 | ||
|
|
309c2f5f94 |
@@ -2476,6 +2476,7 @@ async fn try_run_turn(
|
||||
let mut needs_follow_up = false;
|
||||
let mut last_agent_message: Option<String> = None;
|
||||
let mut active_item: Option<TurnItem> = None;
|
||||
let mut should_emit_turn_diff = false;
|
||||
let receiving_span = info_span!("receiving_stream");
|
||||
let outcome: CodexResult<TurnRunResult> = loop {
|
||||
let handle_responses = info_span!(
|
||||
@@ -2551,14 +2552,7 @@ async fn try_run_turn(
|
||||
} => {
|
||||
sess.update_token_usage_info(&turn_context, token_usage.as_ref())
|
||||
.await;
|
||||
let unified_diff = {
|
||||
let mut tracker = turn_diff_tracker.lock().await;
|
||||
tracker.get_unified_diff()
|
||||
};
|
||||
if let Ok(Some(unified_diff)) = unified_diff {
|
||||
let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff });
|
||||
sess.send_event(&turn_context, msg).await;
|
||||
}
|
||||
should_emit_turn_diff = true;
|
||||
|
||||
break Ok(TurnRunResult {
|
||||
needs_follow_up,
|
||||
@@ -2632,7 +2626,18 @@ async fn try_run_turn(
|
||||
}
|
||||
};
|
||||
|
||||
drain_in_flight(&mut in_flight, sess, turn_context).await?;
|
||||
drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?;
|
||||
|
||||
if should_emit_turn_diff {
|
||||
let unified_diff = {
|
||||
let mut tracker = turn_diff_tracker.lock().await;
|
||||
tracker.get_unified_diff()
|
||||
};
|
||||
if let Ok(Some(unified_diff)) = unified_diff {
|
||||
let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff });
|
||||
sess.clone().send_event(&turn_context, msg).await;
|
||||
}
|
||||
}
|
||||
|
||||
outcome
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ pub mod spawn;
|
||||
pub mod terminal;
|
||||
mod tools;
|
||||
pub mod turn_diff_tracker;
|
||||
pub mod version;
|
||||
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
|
||||
pub use rollout::INTERACTIVE_SESSION_SOURCES;
|
||||
pub use rollout::RolloutRecorder;
|
||||
@@ -94,6 +95,7 @@ pub use rollout::list::read_head_for_summary;
|
||||
mod function_tool;
|
||||
mod state;
|
||||
mod tasks;
|
||||
pub mod update_action;
|
||||
mod user_notification;
|
||||
mod user_shell_command;
|
||||
pub mod util;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/// Update action the CLI should perform after the TUI exits.
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UpdateAction {
|
||||
/// Update via `npm install -g @openai/codex@latest`.
|
||||
@@ -28,7 +30,7 @@ impl UpdateAction {
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub(crate) fn get_update_action() -> Option<UpdateAction> {
|
||||
pub fn get_update_action() -> Option<UpdateAction> {
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
|
||||
let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some();
|
||||
@@ -41,10 +43,27 @@ pub(crate) fn get_update_action() -> Option<UpdateAction> {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn get_update_action() -> Option<UpdateAction> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the standard update-available message for clients to display.
|
||||
pub fn update_available_nudge() -> String {
|
||||
match get_update_action() {
|
||||
Some(action) => {
|
||||
let command = action.command_str();
|
||||
format!("Update available. Run `{command}` to update.")
|
||||
}
|
||||
None => "Update available. See https://github.com/openai/codex for installation options."
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
fn detect_update_action(
|
||||
is_macos: bool,
|
||||
current_exe: &std::path::Path,
|
||||
current_exe: &Path,
|
||||
managed_by_npm: bool,
|
||||
managed_by_bun: bool,
|
||||
) -> Option<UpdateAction> {
|
||||
@@ -68,33 +87,23 @@ mod tests {
|
||||
#[test]
|
||||
fn detects_update_action_without_env_mutation() {
|
||||
assert_eq!(
|
||||
detect_update_action(false, std::path::Path::new("/any/path"), false, false),
|
||||
detect_update_action(false, Path::new("/any/path"), false, false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
detect_update_action(false, std::path::Path::new("/any/path"), true, false),
|
||||
detect_update_action(false, Path::new("/any/path"), true, false),
|
||||
Some(UpdateAction::NpmGlobalLatest)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_update_action(false, std::path::Path::new("/any/path"), false, true),
|
||||
detect_update_action(false, Path::new("/any/path"), false, true),
|
||||
Some(UpdateAction::BunGlobalLatest)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_update_action(
|
||||
true,
|
||||
std::path::Path::new("/opt/homebrew/bin/codex"),
|
||||
false,
|
||||
false
|
||||
),
|
||||
detect_update_action(true, Path::new("/opt/homebrew/bin/codex"), false, false),
|
||||
Some(UpdateAction::BrewUpgrade)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_update_action(
|
||||
true,
|
||||
std::path::Path::new("/usr/local/bin/codex"),
|
||||
false,
|
||||
false
|
||||
),
|
||||
detect_update_action(true, Path::new("/usr/local/bin/codex"), false, false),
|
||||
Some(UpdateAction::BrewUpgrade)
|
||||
);
|
||||
}
|
||||
247
codex-rs/core/src/version.rs
Normal file
247
codex-rs/core/src/version.rs
Normal file
@@ -0,0 +1,247 @@
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
pub const VERSION_FILENAME: &str = "version.json";
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct VersionInfo {
|
||||
pub latest_version: String,
|
||||
// ISO-8601 timestamp (RFC3339)
|
||||
pub last_checked_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub dismissed_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Version {
|
||||
major: u64,
|
||||
minor: u64,
|
||||
patch: u64,
|
||||
pre: Option<Vec<PrereleaseIdent>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum PrereleaseIdent {
|
||||
Numeric(u64),
|
||||
Alpha(String),
|
||||
}
|
||||
|
||||
impl Version {
|
||||
pub fn parse(input: &str) -> Option<Self> {
|
||||
let mut input = input.trim();
|
||||
if let Some(stripped) = input.strip_prefix("rust-v") {
|
||||
input = stripped;
|
||||
}
|
||||
if let Some(stripped) = input.strip_prefix('v') {
|
||||
input = stripped;
|
||||
}
|
||||
let input = input.split('+').next().unwrap_or(input);
|
||||
let mut parts = input.splitn(2, '-');
|
||||
let core = parts.next()?;
|
||||
let pre = parts.next();
|
||||
let mut nums = core.split('.');
|
||||
let major = nums.next()?.parse::<u64>().ok()?;
|
||||
let minor = nums.next()?.parse::<u64>().ok()?;
|
||||
let patch = nums.next()?.parse::<u64>().ok()?;
|
||||
if nums.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let pre = match pre {
|
||||
None => None,
|
||||
Some("") => None,
|
||||
Some(value) => {
|
||||
let mut idents = Vec::new();
|
||||
for ident in value.split('.') {
|
||||
if ident.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let parsed = if ident.chars().all(|c| c.is_ascii_digit()) {
|
||||
ident.parse::<u64>().ok().map(PrereleaseIdent::Numeric)
|
||||
} else {
|
||||
Some(PrereleaseIdent::Alpha(ident.to_string()))
|
||||
};
|
||||
idents.push(parsed?);
|
||||
}
|
||||
Some(idents)
|
||||
}
|
||||
};
|
||||
Some(Self {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
pre,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Version {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
match self.major.cmp(&other.major) {
|
||||
std::cmp::Ordering::Equal => {}
|
||||
ordering => return ordering,
|
||||
}
|
||||
match self.minor.cmp(&other.minor) {
|
||||
std::cmp::Ordering::Equal => {}
|
||||
ordering => return ordering,
|
||||
}
|
||||
match self.patch.cmp(&other.patch) {
|
||||
std::cmp::Ordering::Equal => {}
|
||||
ordering => return ordering,
|
||||
}
|
||||
match (&self.pre, &other.pre) {
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(Some(left), Some(right)) => compare_prerelease_idents(left, right),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Version {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_newer(latest: &str, current: &str) -> Option<bool> {
|
||||
let latest = Version::parse(latest)?;
|
||||
if latest.pre.is_some() {
|
||||
return Some(false);
|
||||
}
|
||||
let current = Version::parse(current)?;
|
||||
let current = Version {
|
||||
pre: None,
|
||||
..current
|
||||
};
|
||||
Some(latest > current)
|
||||
}
|
||||
|
||||
pub fn is_up_to_date(latest: &str, current: &str) -> Option<bool> {
|
||||
let latest = Version::parse(latest)?;
|
||||
if latest.pre.is_some() {
|
||||
return Some(true);
|
||||
}
|
||||
let current = Version::parse(current)?;
|
||||
let current = Version {
|
||||
pre: None,
|
||||
..current
|
||||
};
|
||||
Some(current >= latest)
|
||||
}
|
||||
|
||||
pub fn read_version_info(version_file: &Path) -> anyhow::Result<VersionInfo> {
|
||||
let contents = std::fs::read_to_string(version_file)?;
|
||||
Ok(serde_json::from_str(&contents)?)
|
||||
}
|
||||
|
||||
pub fn read_latest_version(version_file: &Path) -> Option<String> {
|
||||
read_version_info(version_file)
|
||||
.ok()
|
||||
.map(|info| info.latest_version)
|
||||
}
|
||||
|
||||
pub fn extract_version_from_cask(cask_contents: &str) -> anyhow::Result<String> {
|
||||
cask_contents
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let line = line.trim();
|
||||
line.strip_prefix("version \"")
|
||||
.and_then(|rest| rest.strip_suffix('"'))
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to find version in Homebrew cask file"))
|
||||
}
|
||||
|
||||
pub fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result<String> {
|
||||
latest_tag_name
|
||||
.strip_prefix("rust-v")
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))
|
||||
}
|
||||
|
||||
fn compare_prerelease_idents(
|
||||
left: &[PrereleaseIdent],
|
||||
right: &[PrereleaseIdent],
|
||||
) -> std::cmp::Ordering {
|
||||
for (l, r) in left.iter().zip(right.iter()) {
|
||||
let ordering = match (l, r) {
|
||||
(PrereleaseIdent::Numeric(a), PrereleaseIdent::Numeric(b)) => a.cmp(b),
|
||||
(PrereleaseIdent::Alpha(a), PrereleaseIdent::Alpha(b)) => a.cmp(b),
|
||||
(PrereleaseIdent::Numeric(_), PrereleaseIdent::Alpha(_)) => std::cmp::Ordering::Less,
|
||||
(PrereleaseIdent::Alpha(_), PrereleaseIdent::Numeric(_)) => std::cmp::Ordering::Greater,
|
||||
};
|
||||
if ordering != std::cmp::Ordering::Equal {
|
||||
return ordering;
|
||||
}
|
||||
}
|
||||
left.len().cmp(&right.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prerelease_current_is_ignored() {
|
||||
assert_eq!(is_newer("1.2.3", "1.2.3-alpha.1"), Some(false));
|
||||
assert_eq!(is_up_to_date("1.2.3", "1.2.3-alpha.1"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prerelease_latest_is_ignored() {
|
||||
assert_eq!(is_newer("1.2.4-alpha.1", "1.2.3"), Some(false));
|
||||
assert_eq!(is_up_to_date("1.2.4-alpha.1", "1.2.3"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prerelease_latest_is_not_considered_newer() {
|
||||
assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), Some(false));
|
||||
assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_semver_comparisons_work() {
|
||||
assert_eq!(is_newer("0.11.1", "0.11.0"), Some(true));
|
||||
assert_eq!(is_newer("0.11.0", "0.11.1"), Some(false));
|
||||
assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true));
|
||||
assert_eq!(is_newer("0.9.9", "1.0.0"), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_is_ignored() {
|
||||
assert_eq!(Version::parse(" 1.2.3 \n").is_some(), true);
|
||||
assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_version_from_cask_contents() {
|
||||
let cask = r#"
|
||||
cask "codex" do
|
||||
version "0.55.0"
|
||||
end
|
||||
"#;
|
||||
assert_eq!(
|
||||
extract_version_from_cask(cask).expect("failed to parse version"),
|
||||
"0.55.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_version_from_latest_tag() {
|
||||
assert_eq!(
|
||||
extract_version_from_latest_tag("rust-v1.5.0").expect("failed to parse version"),
|
||||
"1.5.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_tag_without_prefix_is_invalid() {
|
||||
assert!(extract_version_from_latest_tag("v1.5.0").is_err());
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_sandbox;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::Duration;
|
||||
@@ -298,6 +299,108 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn remote_models_preserve_builtin_presets() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let remote_model = test_remote_model("remote-alpha", ModelVisibility::List, 0);
|
||||
let models_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: vec![remote_model.clone()],
|
||||
etag: String::new(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
|
||||
let auth = CodexAuth::from_api_key("dummy");
|
||||
let provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
..built_in_model_providers()["openai"].clone()
|
||||
};
|
||||
let manager = ModelsManager::with_provider(
|
||||
codex_core::auth::AuthManager::from_auth_for_testing(auth),
|
||||
provider,
|
||||
);
|
||||
|
||||
manager
|
||||
.refresh_available_models(&config)
|
||||
.await
|
||||
.expect("refresh succeeds");
|
||||
|
||||
let available = manager.list_models(&config).await;
|
||||
let remote = available
|
||||
.iter()
|
||||
.find(|model| model.model == "remote-alpha")
|
||||
.expect("remote model should be listed");
|
||||
let mut expected_remote: ModelPreset = remote_model.into();
|
||||
expected_remote.is_default = true;
|
||||
assert_eq!(*remote, expected_remote);
|
||||
assert!(
|
||||
available
|
||||
.iter()
|
||||
.any(|model| model.model == "gpt-5.1-codex-max"),
|
||||
"builtin presets should remain available after refresh"
|
||||
);
|
||||
assert_eq!(
|
||||
models_mock.requests().len(),
|
||||
1,
|
||||
"expected a single /models request"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn remote_models_hide_picker_only_models() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let remote_model = test_remote_model("codex-auto-balanced", ModelVisibility::Hide, 0);
|
||||
mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: vec![remote_model],
|
||||
etag: String::new(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
..built_in_model_providers()["openai"].clone()
|
||||
};
|
||||
let manager = ModelsManager::with_provider(
|
||||
codex_core::auth::AuthManager::from_auth_for_testing(auth),
|
||||
provider,
|
||||
);
|
||||
|
||||
let selected = manager.get_model(&None, &config).await;
|
||||
assert_eq!(selected, "gpt-5.1-codex-max");
|
||||
|
||||
let available = manager.list_models(&config).await;
|
||||
assert!(
|
||||
available
|
||||
.iter()
|
||||
.all(|model| model.model != "codex-auto-balanced"),
|
||||
"hidden models should not appear in the picker list"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_model_available(
|
||||
manager: &Arc<ModelsManager>,
|
||||
slug: &str,
|
||||
@@ -362,3 +465,32 @@ where
|
||||
conversation_manager,
|
||||
})
|
||||
}
|
||||
|
||||
fn test_remote_model(slug: &str, visibility: ModelVisibility, priority: i32) -> ModelInfo {
|
||||
ModelInfo {
|
||||
slug: slug.to_string(),
|
||||
display_name: format!("{slug} display"),
|
||||
description: Some(format!("{slug} description")),
|
||||
default_reasoning_level: ReasoningEffort::Medium,
|
||||
supported_reasoning_levels: vec![ReasoningEffortPreset {
|
||||
effort: ReasoningEffort::Medium,
|
||||
description: ReasoningEffort::Medium.to_string(),
|
||||
}],
|
||||
shell_type: ConfigShellToolType::ShellCommand,
|
||||
visibility,
|
||||
minimal_client_version: ClientVersion(0, 1, 0),
|
||||
supported_in_api: true,
|
||||
priority,
|
||||
upgrade: None,
|
||||
base_instructions: None,
|
||||
supports_reasoning_summaries: false,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
apply_patch_tool_type: None,
|
||||
truncation_policy: TruncationPolicyConfig::bytes(10_000),
|
||||
supports_parallel_tool_calls: false,
|
||||
context_window: None,
|
||||
reasoning_summary_format: ReasoningSummaryFormat::None,
|
||||
experimental_supported_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,15 @@ use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
mod interrupts;
|
||||
use self::interrupts::InterruptManager;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
#[cfg(test)]
|
||||
use codex_core::version::VERSION_FILENAME;
|
||||
#[cfg(test)]
|
||||
use codex_core::version::is_newer;
|
||||
#[cfg(test)]
|
||||
use codex_core::version::read_version_info;
|
||||
mod agent;
|
||||
use self::agent::spawn_agent;
|
||||
use self::agent::spawn_agent_from_existing;
|
||||
@@ -676,7 +685,45 @@ impl ChatWidget {
|
||||
self.model_family.clone()
|
||||
}
|
||||
|
||||
fn maybe_append_update_nudge(&self, message: String) -> String {
|
||||
if !self.should_show_update_nudge() {
|
||||
return message;
|
||||
}
|
||||
let nudge = crate::update_action::update_available_nudge();
|
||||
if message.is_empty() {
|
||||
nudge
|
||||
} else {
|
||||
format!("{message}\n{nudge}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
fn should_show_update_nudge(&self) -> bool {
|
||||
if env!("CARGO_PKG_VERSION") == "0.0.0" {
|
||||
return false;
|
||||
}
|
||||
crate::updates::get_upgrade_version(&self.config).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn should_show_update_nudge(&self) -> bool {
|
||||
if !self.config.check_for_update_on_startup {
|
||||
return false;
|
||||
}
|
||||
let version_file = self.config.codex_home.join(VERSION_FILENAME);
|
||||
read_version_info(&version_file)
|
||||
.ok()
|
||||
.and_then(|info| is_newer(&info.latest_version, CODEX_CLI_VERSION))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(all(debug_assertions, not(test)))]
|
||||
fn should_show_update_nudge(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn on_error(&mut self, message: String) {
|
||||
let message = self.maybe_append_update_nudge(message);
|
||||
self.finalize_turn();
|
||||
self.add_to_history(history_cell::new_error_event(message));
|
||||
self.request_redraw();
|
||||
@@ -955,6 +1002,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn on_stream_error(&mut self, message: String) {
|
||||
let message = self.maybe_append_update_nudge(message);
|
||||
if self.retry_status_header.is_none() {
|
||||
self.retry_status_header = Some(self.current_status_header.clone());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: last
|
||||
---
|
||||
■ Something failed.
|
||||
Update available. See https://github.com/openai/codex for installation options.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: status.header()
|
||||
---
|
||||
Reconnecting... 2/5
|
||||
Update available. See https://github.com/openai/codex for installation options.
|
||||
@@ -4,6 +4,7 @@ use crate::app_event_sender::AppEventSender;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use crate::tui::FrameRequester;
|
||||
use assert_matches::assert_matches;
|
||||
use chrono::Utc;
|
||||
use codex_common::approval_presets::builtin_approval_presets;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
@@ -18,6 +19,7 @@ use codex_core::protocol::AgentReasoningEvent;
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::BackgroundEventEvent;
|
||||
use codex_core::protocol::CreditsSnapshot;
|
||||
use codex_core::protocol::ErrorEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
@@ -49,6 +51,8 @@ use codex_core::protocol::UndoCompletedEvent;
|
||||
use codex_core::protocol::UndoStartedEvent;
|
||||
use codex_core::protocol::ViewImageToolCallEvent;
|
||||
use codex_core::protocol::WarningEvent;
|
||||
use codex_core::version::VERSION_FILENAME;
|
||||
use codex_core::version::VersionInfo;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
@@ -64,6 +68,7 @@ use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
@@ -492,6 +497,24 @@ fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
fn set_update_available(config: &mut Config) -> tempfile::TempDir {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
config.codex_home = codex_home.path().to_path_buf();
|
||||
config.check_for_update_on_startup = true;
|
||||
let info = VersionInfo {
|
||||
latest_version: "9999.0.0".to_string(),
|
||||
last_checked_at: Utc::now(),
|
||||
dismissed_version: None,
|
||||
};
|
||||
let json_line = format!(
|
||||
"{}\n",
|
||||
serde_json::to_string(&info).expect("serialize version info")
|
||||
);
|
||||
std::fs::write(codex_home.path().join(VERSION_FILENAME), json_line)
|
||||
.expect("write version info");
|
||||
codex_home
|
||||
}
|
||||
|
||||
fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUsageInfo {
|
||||
fn usage(total_tokens: i64) -> TokenUsage {
|
||||
TokenUsage {
|
||||
@@ -2924,6 +2947,7 @@ fn plan_update_renders_history_cell() {
|
||||
#[test]
|
||||
fn stream_error_updates_status_indicator() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None);
|
||||
let _tempdir = set_update_available(&mut chat.config);
|
||||
chat.bottom_pane.set_task_running(true);
|
||||
let msg = "Reconnecting... 2/5";
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -2943,7 +2967,10 @@ fn stream_error_updates_status_indicator() {
|
||||
.bottom_pane
|
||||
.status_widget()
|
||||
.expect("status indicator should be visible");
|
||||
assert_eq!(status.header(), msg);
|
||||
let nudge = crate::update_action::update_available_nudge();
|
||||
let expected = format!("{msg}\n{nudge}");
|
||||
assert_eq!(status.header(), expected);
|
||||
assert_snapshot!("stream_error_status_header", status.header());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2965,6 +2992,23 @@ fn warning_event_adds_warning_history_cell() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_event_renders_history_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None);
|
||||
let _tempdir = set_update_available(&mut chat.config);
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-1".into(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: "Something failed.".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let last = lines_to_single_string(cells.last().expect("error history cell"));
|
||||
assert_snapshot!("error_event_history", last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_recovery_restores_previous_status_header() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None);
|
||||
|
||||
@@ -78,7 +78,7 @@ mod text_formatting;
|
||||
mod tooltips;
|
||||
mod tui;
|
||||
mod ui_consts;
|
||||
pub mod update_action;
|
||||
pub use codex_core::update_action;
|
||||
mod update_prompt;
|
||||
mod updates;
|
||||
mod version;
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
|
||||
use crate::update_action;
|
||||
use crate::update_action::UpdateAction;
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration;
|
||||
use chrono::Utc;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::default_client::create_client;
|
||||
use codex_core::version::VERSION_FILENAME;
|
||||
use codex_core::version::VersionInfo;
|
||||
use codex_core::version::extract_version_from_cask;
|
||||
use codex_core::version::extract_version_from_latest_tag;
|
||||
use codex_core::version::is_newer;
|
||||
use codex_core::version::read_version_info;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -45,16 +49,6 @@ pub fn get_upgrade_version(config: &Config) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
struct VersionInfo {
|
||||
latest_version: String,
|
||||
// ISO-8601 timestamp (RFC3339)
|
||||
last_checked_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
dismissed_version: Option<String>,
|
||||
}
|
||||
|
||||
const VERSION_FILENAME: &str = "version.json";
|
||||
// We use the latest version from the cask if installation is via homebrew - homebrew does not immediately pick up the latest release and can lag behind.
|
||||
const HOMEBREW_CASK_URL: &str =
|
||||
"https://raw.githubusercontent.com/Homebrew/homebrew-cask/HEAD/Casks/c/codex.rb";
|
||||
@@ -69,11 +63,6 @@ fn version_filepath(config: &Config) -> PathBuf {
|
||||
config.codex_home.join(VERSION_FILENAME)
|
||||
}
|
||||
|
||||
fn read_version_info(version_file: &Path) -> anyhow::Result<VersionInfo> {
|
||||
let contents = std::fs::read_to_string(version_file)?;
|
||||
Ok(serde_json::from_str(&contents)?)
|
||||
}
|
||||
|
||||
async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
|
||||
let latest_version = match update_action::get_update_action() {
|
||||
Some(UpdateAction::BrewUpgrade) => {
|
||||
@@ -116,32 +105,6 @@ async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_newer(latest: &str, current: &str) -> Option<bool> {
|
||||
match (parse_version(latest), parse_version(current)) {
|
||||
(Some(l), Some(c)) => Some(l > c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_version_from_cask(cask_contents: &str) -> anyhow::Result<String> {
|
||||
cask_contents
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let line = line.trim();
|
||||
line.strip_prefix("version \"")
|
||||
.and_then(|rest| rest.strip_suffix('"'))
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to find version in Homebrew cask file"))
|
||||
}
|
||||
|
||||
fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result<String> {
|
||||
latest_tag_name
|
||||
.strip_prefix("rust-v")
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))
|
||||
}
|
||||
|
||||
/// Returns the latest version to show in a popup, if it should be shown.
|
||||
/// This respects the user's dismissal choice for the current latest version.
|
||||
pub fn get_upgrade_version_for_popup(config: &Config) -> Option<String> {
|
||||
@@ -177,48 +140,14 @@ pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
|
||||
let mut iter = v.trim().split('.');
|
||||
let maj = iter.next()?.parse::<u64>().ok()?;
|
||||
let min = iter.next()?.parse::<u64>().ok()?;
|
||||
let pat = iter.next()?.parse::<u64>().ok()?;
|
||||
Some((maj, min, pat))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_version_from_cask_contents() {
|
||||
let cask = r#"
|
||||
cask "codex" do
|
||||
version "0.55.0"
|
||||
end
|
||||
"#;
|
||||
assert_eq!(
|
||||
extract_version_from_cask(cask).expect("failed to parse version"),
|
||||
"0.55.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_version_from_latest_tag() {
|
||||
assert_eq!(
|
||||
extract_version_from_latest_tag("rust-v1.5.0").expect("failed to parse version"),
|
||||
"1.5.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_tag_without_prefix_is_invalid() {
|
||||
assert!(extract_version_from_latest_tag("v1.5.0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prerelease_version_is_not_considered_newer() {
|
||||
assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None);
|
||||
assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), None);
|
||||
assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), Some(false));
|
||||
assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -231,7 +160,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn whitespace_is_ignored() {
|
||||
assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3)));
|
||||
assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user