diff --git a/AGENTS.md b/AGENTS.md index 14788cb6b1..968ce5b9cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,17 @@ See `codex-rs/tui/styles.md`. - Example: patch summary file lines - Desired: vec![" └ ".into(), "M".red(), " ".dim(), "tui/src/app.rs".dim()] +### TUI Styling (ratatui) +- Prefer Stylize helpers: use "text".dim(), .bold(), .cyan(), .italic(), .underlined() instead of manual Style where possible. +- Prefer simple conversions: use "text".into() for spans and vec![…].into() for lines; when inference is ambiguous (e.g., Paragraph::new/Cell::from), use Line::from(spans) or Span::from(text). +- Computed styles: if the Style is computed at runtime, using `Span::styled` is OK (`Span::from(text).set_style(style)` is also acceptable). +- Avoid hardcoded white: do not use `.white()`; prefer the default foreground (no color). +- Chaining: combine helpers by chaining for readability (e.g., url.cyan().underlined()). +- Single items: prefer "text".into(); use Line::from(text) or Span::from(text) only when the target type isn’t obvious from context, or when using .into() would require extra type annotations. +- Building lines: use vec![…].into() to construct a Line when the target type is obvious and no extra type annotations are needed; otherwise use Line::from(vec![…]). +- Avoid churn: don’t refactor between equivalent forms (Span::styled ↔ set_style, Line::from ↔ .into()) without a clear readability or functional gain; follow file‑local conventions and do not introduce type annotations solely to satisfy .into(). +- Compactness: prefer the form that stays on one line after rustfmt; if only one of Line::from(vec![…]) or vec![…].into() avoids wrapping, choose that. If both wrap, pick the one with fewer wrapped lines. + ## Snapshot tests This repo uses snapshot tests (via `insta`), especially in `codex-rs/tui`, to validate rendered output. When UI or text output changes intentionally, update the snapshots as follows: diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 157c09a2d7..c4f0aa4959 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -636,6 +636,7 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", + "once_cell", "pretty_assertions", "similar", "tempfile", @@ -665,7 +666,7 @@ dependencies = [ "clap", "codex-common", "codex-core", - "codex-login", + "codex-protocol", "reqwest", "serde", "serde_json", @@ -718,7 +719,6 @@ dependencies = [ "bytes", "chrono", "codex-apply-patch", - "codex-login", "codex-mcp-client", "codex-protocol", "core_test_support", @@ -847,6 +847,7 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "chrono", + "codex-core", "codex-protocol", "pretty_assertions", "rand 0.8.5", diff --git a/codex-rs/ansi-escape/src/lib.rs b/codex-rs/ansi-escape/src/lib.rs index 3daaf46eda..68ea5e9aa9 100644 --- a/codex-rs/ansi-escape/src/lib.rs +++ b/codex-rs/ansi-escape/src/lib.rs @@ -9,7 +9,7 @@ use ratatui::text::Text; pub fn ansi_escape_line(s: &str) -> Line<'static> { let text = ansi_escape(s); match text.lines.as_slice() { - [] => Line::from(""), + [] => "".into(), [only] => only.clone(), [first, rest @ ..] => { tracing::warn!("ansi_escape_line: expected a single line, got {first:?} and {rest:?}"); diff --git a/codex-rs/apply-patch/Cargo.toml b/codex-rs/apply-patch/Cargo.toml index 32c7f6e43f..84d3630cfa 100644 --- a/codex-rs/apply-patch/Cargo.toml +++ b/codex-rs/apply-patch/Cargo.toml @@ -20,6 +20,7 @@ similar = "2.7.0" thiserror = "2.0.12" tree-sitter = "0.25.8" tree-sitter-bash = "0.25.0" +once_cell = "1" [dev-dependencies] assert_cmd = "2" diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 46ddf056c8..18d3bd4d32 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -9,6 +9,7 @@ use std::str::Utf8Error; use anyhow::Context; use anyhow::Result; +use once_cell::sync::Lazy; pub use parser::Hunk; pub use parser::ParseError; use parser::ParseError::*; @@ -18,6 +19,9 @@ use similar::TextDiff; use thiserror::Error; use tree_sitter::LanguageError; use tree_sitter::Parser; +use tree_sitter::Query; +use tree_sitter::QueryCursor; +use tree_sitter::StreamingIterator; use tree_sitter_bash::LANGUAGE as BASH; pub use standalone_executable::main; @@ -84,6 +88,7 @@ pub enum MaybeApplyPatch { pub struct ApplyPatchArgs { pub patch: String, pub hunks: Vec, + pub workdir: Option, } pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { @@ -92,18 +97,18 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { Ok(source) => MaybeApplyPatch::Body(source), Err(e) => MaybeApplyPatch::PatchParseError(e), }, - [bash, flag, script] - if bash == "bash" - && flag == "-lc" - && APPLY_PATCH_COMMANDS - .iter() - .any(|cmd| script.trim_start().starts_with(cmd)) => - { - match extract_heredoc_body_from_apply_patch_command(script) { - Ok(body) => match parse_patch(&body) { - Ok(source) => MaybeApplyPatch::Body(source), + [bash, flag, script] if bash == "bash" && flag == "-lc" => { + match extract_apply_patch_from_bash(script) { + Ok((body, workdir)) => match parse_patch(&body) { + Ok(mut source) => { + source.workdir = workdir; + MaybeApplyPatch::Body(source) + } Err(e) => MaybeApplyPatch::PatchParseError(e), }, + Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => { + MaybeApplyPatch::NotApplyPatch + } Err(e) => MaybeApplyPatch::ShellParseError(e), } } @@ -203,10 +208,25 @@ impl ApplyPatchAction { /// patch. pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified { match maybe_parse_apply_patch(argv) { - MaybeApplyPatch::Body(ApplyPatchArgs { patch, hunks }) => { + MaybeApplyPatch::Body(ApplyPatchArgs { + patch, + hunks, + workdir, + }) => { + let effective_cwd = workdir + .as_ref() + .map(|dir| { + let path = Path::new(dir); + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } + }) + .unwrap_or_else(|| cwd.to_path_buf()); let mut changes = HashMap::new(); for hunk in hunks { - let path = hunk.resolve_path(cwd); + let path = hunk.resolve_path(&effective_cwd); match hunk { Hunk::AddFile { contents, .. } => { changes.insert(path, ApplyPatchFileChange::Add { content: contents }); @@ -251,7 +271,7 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp MaybeApplyPatchVerified::Body(ApplyPatchAction { changes, patch, - cwd: cwd.to_path_buf(), + cwd: effective_cwd, }) } MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e), @@ -260,33 +280,96 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp } } -/// Attempts to extract a heredoc_body object from a string bash command like: -/// Optimistically +/// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script +/// that invokes the apply_patch tool using a heredoc. /// -/// ```bash -/// bash -lc 'apply_patch < && apply_patch <<'EOF'\n...\nEOF` /// -/// # Arguments +/// Notes about matching: +/// - Parsed with Tree‑sitter Bash and a strict query that uses anchors so the +/// heredoc‑redirected statement is the only top‑level statement. +/// - The connector between `cd` and `apply_patch` must be `&&` (not `|` or `||`). +/// - Exactly one positional `word` argument is allowed for `cd` (no flags, no quoted +/// strings, no second argument). +/// - The apply command is validated in‑query via `#any-of?` to allow `apply_patch` +/// or `applypatch`. +/// - Preceding or trailing commands (e.g., `echo ...;` or `... && echo done`) do not match. /// -/// * `src` - A string slice that holds the full command -/// -/// # Returns -/// -/// This function returns a `Result` which is: -/// -/// * `Ok(String)` - The heredoc body if the extraction is successful. -/// * `Err(anyhow::Error)` - An error if the extraction fails. -/// -fn extract_heredoc_body_from_apply_patch_command( +/// Returns `(heredoc_body, Some(path))` when the `cd` variant matches, or +/// `(heredoc_body, None)` for the direct form. Errors are returned if the script +/// cannot be parsed or does not match the allowed patterns. +fn extract_apply_patch_from_bash( src: &str, -) -> std::result::Result { - if !APPLY_PATCH_COMMANDS - .iter() - .any(|cmd| src.trim_start().starts_with(cmd)) - { - return Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch); - } +) -> std::result::Result<(String, Option), ExtractHeredocError> { + // This function uses a Tree-sitter query to recognize one of two + // whole-script forms, each expressed as a single top-level statement: + // + // 1. apply_patch <<'EOF'\n...\nEOF + // 2. cd && apply_patch <<'EOF'\n...\nEOF + // + // Key ideas when reading the query: + // - dots (`.`) between named nodes enforces adjacency among named children and + // anchor to the start/end of the expression. + // - we match a single redirected_statement directly under program with leading + // and trailing anchors (`.`). This ensures it is the only top-level statement + // (so prefixes like `echo ...;` or suffixes like `... && echo done` do not match). + // + // Overall, we want to be conservative and only match the intended forms, as other + // forms are likely to be model errors, or incorrectly interpreted by later code. + // + // If you're editing this query, it's helpful to start by creating a debugging binary + // which will let you see the AST of an arbitrary bash script passed in, and optionally + // also run an arbitrary query against the AST. This is useful for understanding + // how tree-sitter parses the script and whether the query syntax is correct. Be sure + // to test both positive and negative cases. + static APPLY_PATCH_QUERY: Lazy = Lazy::new(|| { + let language = BASH.into(); + #[expect(clippy::expect_used)] + Query::new( + &language, + r#" + ( + program + . (redirected_statement + body: (command + name: (command_name (word) @apply_name) .) + (#any-of? @apply_name "apply_patch" "applypatch") + redirect: (heredoc_redirect + . (heredoc_start) + . (heredoc_body) @heredoc + . (heredoc_end) + .)) + .) + + ( + program + . (redirected_statement + body: (list + . (command + name: (command_name (word) @cd_name) . + argument: [ + (word) @cd_path + (string (string_content) @cd_path) + (raw_string) @cd_raw_string + ] .) + "&&" + . (command + name: (command_name (word) @apply_name)) + .) + (#eq? @cd_name "cd") + (#any-of? @apply_name "apply_patch" "applypatch") + redirect: (heredoc_redirect + . (heredoc_start) + . (heredoc_body) @heredoc + . (heredoc_end) + .)) + .) + "#, + ) + .expect("valid bash query") + }); let lang = BASH.into(); let mut parser = Parser::new(); @@ -298,26 +381,55 @@ fn extract_heredoc_body_from_apply_patch_command( .ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?; let bytes = src.as_bytes(); - let mut c = tree.root_node().walk(); + let root = tree.root_node(); - loop { - let node = c.node(); - if node.kind() == "heredoc_body" { - let text = node - .utf8_text(bytes) - .map_err(ExtractHeredocError::HeredocNotUtf8)?; - return Ok(text.trim_end_matches('\n').to_owned()); - } + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(&APPLY_PATCH_QUERY, root, bytes); + while let Some(m) = matches.next() { + let mut heredoc_text: Option = None; + let mut cd_path: Option = None; - if c.goto_first_child() { - continue; - } - while !c.goto_next_sibling() { - if !c.goto_parent() { - return Err(ExtractHeredocError::FailedToFindHeredocBody); + for capture in m.captures.iter() { + let name = APPLY_PATCH_QUERY.capture_names()[capture.index as usize]; + match name { + "heredoc" => { + let text = capture + .node + .utf8_text(bytes) + .map_err(ExtractHeredocError::HeredocNotUtf8)? + .trim_end_matches('\n') + .to_string(); + heredoc_text = Some(text); + } + "cd_path" => { + let text = capture + .node + .utf8_text(bytes) + .map_err(ExtractHeredocError::HeredocNotUtf8)? + .to_string(); + cd_path = Some(text); + } + "cd_raw_string" => { + let raw = capture + .node + .utf8_text(bytes) + .map_err(ExtractHeredocError::HeredocNotUtf8)?; + let trimmed = raw + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .unwrap_or(raw); + cd_path = Some(trimmed.to_string()); + } + _ => {} } } + + if let Some(heredoc) = heredoc_text { + return Ok((heredoc, cd_path)); + } } + + Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) } #[derive(Debug, PartialEq)] @@ -718,6 +830,49 @@ mod tests { strs.iter().map(|s| s.to_string()).collect() } + // Test helpers to reduce repetition when building bash -lc heredoc scripts + fn args_bash(script: &str) -> Vec { + strs_to_strings(&["bash", "-lc", script]) + } + + fn heredoc_script(prefix: &str) -> String { + format!( + "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH" + ) + } + + fn heredoc_script_ps(prefix: &str, suffix: &str) -> String { + format!( + "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}" + ) + } + + fn expected_single_add() -> Vec { + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string(), + }] + } + + fn assert_match(script: &str, expected_workdir: Option<&str>) { + let args = args_bash(script); + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { + assert_eq!(workdir.as_deref(), expected_workdir); + assert_eq!(hunks, expected_single_add()); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + + fn assert_not_match(script: &str) { + let args = args_bash(script); + assert!(matches!( + maybe_parse_apply_patch(&args), + MaybeApplyPatch::NotApplyPatch + )); + } + #[test] fn test_literal() { let args = strs_to_strings(&[ @@ -730,7 +885,7 @@ mod tests { ]); match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { assert_eq!( hunks, vec![Hunk::AddFile { @@ -755,7 +910,7 @@ mod tests { ]); match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { assert_eq!( hunks, vec![Hunk::AddFile { @@ -770,29 +925,7 @@ mod tests { #[test] fn test_heredoc() { - let args = strs_to_strings(&[ - "bash", - "-lc", - r#"apply_patch <<'PATCH' -*** Begin Patch -*** Add File: foo -+hi -*** End Patch -PATCH"#, - ]); - - match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => { - assert_eq!( - hunks, - vec![Hunk::AddFile { - path: PathBuf::from("foo"), - contents: "hi\n".to_string() - }] - ); - } - result => panic!("expected MaybeApplyPatch::Body got {result:?}"), - } + assert_match(&heredoc_script(""), None); } #[test] @@ -809,7 +942,8 @@ PATCH"#, ]); match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { + assert_eq!(workdir, None); assert_eq!( hunks, vec![Hunk::AddFile { @@ -822,6 +956,69 @@ PATCH"#, } } + #[test] + fn test_heredoc_with_leading_cd() { + assert_match(&heredoc_script("cd foo && "), Some("foo")); + } + + #[test] + fn test_cd_with_semicolon_is_ignored() { + assert_not_match(&heredoc_script("cd foo; ")); + } + + #[test] + fn test_cd_or_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd bar || ")); + } + + #[test] + fn test_cd_pipe_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd bar | ")); + } + + #[test] + fn test_cd_single_quoted_path_with_spaces() { + assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar")); + } + + #[test] + fn test_cd_double_quoted_path_with_spaces() { + assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar")); + } + + #[test] + fn test_echo_and_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("echo foo && ")); + } + + #[test] + fn test_apply_patch_with_arg_is_ignored() { + let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"; + assert_not_match(script); + } + + #[test] + fn test_double_cd_then_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd foo && cd bar && ")); + } + + #[test] + fn test_cd_two_args_is_ignored() { + assert_not_match(&heredoc_script("cd foo bar && ")); + } + + #[test] + fn test_cd_then_apply_patch_then_extra_is_ignored() { + let script = heredoc_script_ps("cd bar && ", " && echo done"); + assert_not_match(&script); + } + + #[test] + fn test_echo_then_cd_and_apply_patch_is_ignored() { + // Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match. + assert_not_match(&heredoc_script("echo foo; cd bar && ")); + } + #[test] fn test_add_file_hunk_creates_file_with_contents() { let dir = tempdir().unwrap(); diff --git a/codex-rs/apply-patch/src/parser.rs b/codex-rs/apply-patch/src/parser.rs index ff9dfd6f8f..3b89c4da85 100644 --- a/codex-rs/apply-patch/src/parser.rs +++ b/codex-rs/apply-patch/src/parser.rs @@ -175,7 +175,11 @@ fn parse_patch_text(patch: &str, mode: ParseMode) -> Result>> = LazyLock::new(|| RwLock::new(None)); diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 959bf46f18..f03509618d 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -1,14 +1,14 @@ use codex_common::CliConfigOverrides; +use codex_core::CodexAuth; +use codex_core::auth::CLIENT_ID; +use codex_core::auth::OPENAI_API_KEY_ENV_VAR; +use codex_core::auth::login_with_api_key; +use codex_core::auth::logout; use codex_core::config::Config; use codex_core::config::ConfigOverrides; -use codex_login::AuthMode; -use codex_login::CLIENT_ID; -use codex_login::CodexAuth; -use codex_login::OPENAI_API_KEY_ENV_VAR; use codex_login::ServerOptions; -use codex_login::login_with_api_key; -use codex_login::logout; use codex_login::run_login_server; +use codex_protocol::mcp_protocol::AuthMode; use std::env; use std::path::PathBuf; diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs index 9f8c4d3b00..9b8cb92ee2 100644 --- a/codex-rs/cli/src/proto.rs +++ b/codex-rs/cli/src/proto.rs @@ -2,6 +2,7 @@ use std::io::IsTerminal; use clap::Parser; use codex_common::CliConfigOverrides; +use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::NewConversation; use codex_core::config::Config; @@ -9,7 +10,6 @@ use codex_core::config::ConfigOverrides; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::Submission; -use codex_login::AuthManager; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tracing::error; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8ccf54a489..a4e40c7b55 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -18,7 +18,6 @@ base64 = "0.22" bytes = "1.10.1" chrono = { version = "0.4", features = ["serde"] } codex-apply-patch = { path = "../apply-patch" } -codex-login = { path = "../login" } codex-mcp-client = { path = "../mcp-client" } codex-protocol = { path = "../protocol" } dirs = "6" diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs new file mode 100644 index 0000000000..d2954ba6c6 --- /dev/null +++ b/codex-rs/core/src/auth.rs @@ -0,0 +1,766 @@ +use chrono::DateTime; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; +use std::env; +use std::fs::File; +use std::fs::OpenOptions; +use std::io::Read; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use codex_protocol::mcp_protocol::AuthMode; + +use crate::token_data::TokenData; +use crate::token_data::parse_id_token; + +#[derive(Debug, Clone)] +pub struct CodexAuth { + pub mode: AuthMode, + + pub(crate) api_key: Option, + pub(crate) auth_dot_json: Arc>>, + pub(crate) auth_file: PathBuf, +} + +impl PartialEq for CodexAuth { + fn eq(&self, other: &Self) -> bool { + self.mode == other.mode + } +} + +impl CodexAuth { + pub fn from_api_key(api_key: &str) -> Self { + Self { + api_key: Some(api_key.to_owned()), + mode: AuthMode::ApiKey, + auth_file: PathBuf::new(), + auth_dot_json: Arc::new(Mutex::new(None)), + } + } + + pub async fn refresh_token(&self) -> Result { + let token_data = self + .get_current_token_data() + .ok_or(std::io::Error::other("Token data is not available."))?; + let token = token_data.refresh_token; + + let refresh_response = try_refresh_token(token) + .await + .map_err(std::io::Error::other)?; + + let updated = update_tokens( + &self.auth_file, + refresh_response.id_token, + refresh_response.access_token, + refresh_response.refresh_token, + ) + .await?; + + if let Ok(mut auth_lock) = self.auth_dot_json.lock() { + *auth_lock = Some(updated.clone()); + } + + let access = match updated.tokens { + Some(t) => t.access_token, + None => { + return Err(std::io::Error::other( + "Token data is not available after refresh.", + )); + } + }; + Ok(access) + } + + /// Loads the available auth information from the auth.json or + /// OPENAI_API_KEY environment variable. + pub fn from_codex_home( + codex_home: &Path, + preferred_auth_method: AuthMode, + ) -> std::io::Result> { + load_auth(codex_home, true, preferred_auth_method) + } + + pub async fn get_token_data(&self) -> Result { + let auth_dot_json: Option = self.get_current_auth_json(); + match auth_dot_json { + Some(AuthDotJson { + tokens: Some(mut tokens), + last_refresh: Some(last_refresh), + .. + }) => { + if last_refresh < Utc::now() - chrono::Duration::days(28) { + let refresh_response = tokio::time::timeout( + Duration::from_secs(60), + try_refresh_token(tokens.refresh_token.clone()), + ) + .await + .map_err(|_| { + std::io::Error::other("timed out while refreshing OpenAI API key") + })? + .map_err(std::io::Error::other)?; + + let updated_auth_dot_json = update_tokens( + &self.auth_file, + refresh_response.id_token, + refresh_response.access_token, + refresh_response.refresh_token, + ) + .await?; + + tokens = updated_auth_dot_json + .tokens + .clone() + .ok_or(std::io::Error::other( + "Token data is not available after refresh.", + ))?; + + #[expect(clippy::unwrap_used)] + let mut auth_lock = self.auth_dot_json.lock().unwrap(); + *auth_lock = Some(updated_auth_dot_json); + } + + Ok(tokens) + } + _ => Err(std::io::Error::other("Token data is not available.")), + } + } + + pub async fn get_token(&self) -> Result { + match self.mode { + AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()), + AuthMode::ChatGPT => { + let id_token = self.get_token_data().await?.access_token; + Ok(id_token) + } + } + } + + pub fn get_account_id(&self) -> Option { + self.get_current_token_data() + .and_then(|t| t.account_id.clone()) + } + + pub fn get_plan_type(&self) -> Option { + self.get_current_token_data() + .and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string())) + } + + fn get_current_auth_json(&self) -> Option { + #[expect(clippy::unwrap_used)] + self.auth_dot_json.lock().unwrap().clone() + } + + fn get_current_token_data(&self) -> Option { + self.get_current_auth_json().and_then(|t| t.tokens.clone()) + } + + /// Consider this private to integration tests. + pub fn create_dummy_chatgpt_auth_for_testing() -> Self { + let auth_dot_json = AuthDotJson { + openai_api_key: None, + tokens: Some(TokenData { + id_token: Default::default(), + access_token: "Access Token".to_string(), + refresh_token: "test".to_string(), + account_id: Some("account_id".to_string()), + }), + last_refresh: Some(Utc::now()), + }; + + let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json))); + Self { + api_key: None, + mode: AuthMode::ChatGPT, + auth_file: PathBuf::new(), + auth_dot_json, + } + } +} + +pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; + +fn read_openai_api_key_from_env() -> Option { + env::var(OPENAI_API_KEY_ENV_VAR) + .ok() + .filter(|s| !s.is_empty()) +} + +pub fn get_auth_file(codex_home: &Path) -> PathBuf { + codex_home.join("auth.json") +} + +/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)` +/// if a file was removed, `Ok(false)` if no auth file was present. +pub fn logout(codex_home: &Path) -> std::io::Result { + let auth_file = get_auth_file(codex_home); + match std::fs::remove_file(&auth_file) { + Ok(_) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err), + } +} + +/// Writes an `auth.json` that contains only the API key. Intended for CLI use. +pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> { + let auth_dot_json = AuthDotJson { + openai_api_key: Some(api_key.to_string()), + tokens: None, + last_refresh: None, + }; + write_auth_json(&get_auth_file(codex_home), &auth_dot_json) +} + +fn load_auth( + codex_home: &Path, + include_env_var: bool, + preferred_auth_method: AuthMode, +) -> std::io::Result> { + // First, check to see if there is a valid auth.json file. If not, we fall + // back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable + // (if it is set). + let auth_file = get_auth_file(codex_home); + let auth_dot_json = match try_read_auth_json(&auth_file) { + Ok(auth) => auth, + // If auth.json does not exist, try to read the OPENAI_API_KEY from the + // environment variable. + Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => { + return match read_openai_api_key_from_env() { + Some(api_key) => Ok(Some(CodexAuth::from_api_key(&api_key))), + None => Ok(None), + }; + } + // Though if auth.json exists but is malformed, do not fall back to the + // env var because the user may be expecting to use AuthMode::ChatGPT. + Err(e) => { + return Err(e); + } + }; + + let AuthDotJson { + openai_api_key: auth_json_api_key, + tokens, + last_refresh, + } = auth_dot_json; + + // If the auth.json has an API key AND does not appear to be on a plan that + // should prefer AuthMode::ChatGPT, use AuthMode::ApiKey. + if let Some(api_key) = &auth_json_api_key { + // Should any of these be AuthMode::ChatGPT with the api_key set? + // Does AuthMode::ChatGPT indicate that there is an auth.json that is + // "refreshable" even if we are using the API key for auth? + match &tokens { + Some(tokens) => { + if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) { + return Ok(Some(CodexAuth::from_api_key(api_key))); + } else { + // Ignore the API key and fall through to ChatGPT auth. + } + } + None => { + // We have an API key but no tokens in the auth.json file. + // Perhaps the user ran `codex login --api-key ` or updated + // auth.json by hand. Either way, let's assume they are trying + // to use their API key. + return Ok(Some(CodexAuth::from_api_key(api_key))); + } + } + } + + // For the AuthMode::ChatGPT variant, perhaps neither api_key nor + // openai_api_key should exist? + Ok(Some(CodexAuth { + api_key: None, + mode: AuthMode::ChatGPT, + auth_file, + auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson { + openai_api_key: None, + tokens, + last_refresh, + }))), + })) +} + +/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. +/// Returns the full AuthDotJson structure after refreshing if necessary. +pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result { + let mut file = File::open(auth_file)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; + + Ok(auth_dot_json) +} + +pub fn write_auth_json(auth_file: &Path, auth_dot_json: &AuthDotJson) -> std::io::Result<()> { + let json_data = serde_json::to_string_pretty(auth_dot_json)?; + let mut options = OpenOptions::new(); + options.truncate(true).write(true).create(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options.open(auth_file)?; + file.write_all(json_data.as_bytes())?; + file.flush()?; + Ok(()) +} + +async fn update_tokens( + auth_file: &Path, + id_token: String, + access_token: Option, + refresh_token: Option, +) -> std::io::Result { + let mut auth_dot_json = try_read_auth_json(auth_file)?; + + let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default); + tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?; + if let Some(access_token) = access_token { + tokens.access_token = access_token.to_string(); + } + if let Some(refresh_token) = refresh_token { + tokens.refresh_token = refresh_token.to_string(); + } + auth_dot_json.last_refresh = Some(Utc::now()); + write_auth_json(auth_file, &auth_dot_json)?; + Ok(auth_dot_json) +} + +async fn try_refresh_token(refresh_token: String) -> std::io::Result { + let refresh_request = RefreshRequest { + client_id: CLIENT_ID, + grant_type: "refresh_token", + refresh_token, + scope: "openid profile email", + }; + + let client = reqwest::Client::new(); + let response = client + .post("https://auth.openai.com/oauth/token") + .header("Content-Type", "application/json") + .json(&refresh_request) + .send() + .await + .map_err(std::io::Error::other)?; + + if response.status().is_success() { + let refresh_response = response + .json::() + .await + .map_err(std::io::Error::other)?; + Ok(refresh_response) + } else { + Err(std::io::Error::other(format!( + "Failed to refresh token: {}", + response.status() + ))) + } +} + +#[derive(Serialize)] +struct RefreshRequest { + client_id: &'static str, + grant_type: &'static str, + refresh_token: String, + scope: &'static str, +} + +#[derive(Deserialize, Clone)] +struct RefreshResponse { + id_token: String, + access_token: Option, + refresh_token: Option, +} + +/// Expected structure for $CODEX_HOME/auth.json. +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] +pub struct AuthDotJson { + #[serde(rename = "OPENAI_API_KEY")] + pub openai_api_key: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_refresh: Option>, +} + +// Shared constant for token refresh (client id used for oauth token refresh flow) +pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; + +use std::sync::RwLock; + +/// Internal cached auth state. +#[derive(Clone, Debug)] +struct CachedAuth { + preferred_auth_mode: AuthMode, + auth: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::token_data::IdTokenInfo; + use crate::token_data::KnownPlan; + use crate::token_data::PlanType; + use base64::Engine; + use pretty_assertions::assert_eq; + use serde::Serialize; + use serde_json::json; + use tempfile::tempdir; + + const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z"; + + #[tokio::test] + async fn roundtrip_auth_dot_json() { + let codex_home = tempdir().unwrap(); + let _ = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "pro".to_string(), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let file = get_auth_file(codex_home.path()); + let auth_dot_json = try_read_auth_json(&file).unwrap(); + write_auth_json(&file, &auth_dot_json).unwrap(); + + let same_auth_dot_json = try_read_auth_json(&file).unwrap(); + assert_eq!(auth_dot_json, same_auth_dot_json); + } + + #[tokio::test] + async fn pro_account_with_no_api_key_uses_chatgpt_auth() { + let codex_home = tempdir().unwrap(); + let fake_jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "pro".to_string(), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let CodexAuth { + api_key, + mode, + auth_dot_json, + auth_file: _, + } = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT) + .unwrap() + .unwrap(); + assert_eq!(None, api_key); + assert_eq!(AuthMode::ChatGPT, mode); + + let guard = auth_dot_json.lock().unwrap(); + let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); + assert_eq!( + &AuthDotJson { + openai_api_key: None, + tokens: Some(TokenData { + id_token: IdTokenInfo { + email: Some("user@example.com".to_string()), + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), + raw_jwt: fake_jwt, + }, + access_token: "test-access-token".to_string(), + refresh_token: "test-refresh-token".to_string(), + account_id: None, + }), + last_refresh: Some( + DateTime::parse_from_rfc3339(LAST_REFRESH) + .unwrap() + .with_timezone(&Utc) + ), + }, + auth_dot_json + ) + } + + /// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in + /// [`TokenData::is_plan_that_should_use_api_key`], it should use + /// [`AuthMode::ChatGPT`]. + #[tokio::test] + async fn pro_account_with_api_key_still_uses_chatgpt_auth() { + let codex_home = tempdir().unwrap(); + let fake_jwt = write_auth_file( + AuthFileParams { + openai_api_key: Some("sk-test-key".to_string()), + chatgpt_plan_type: "pro".to_string(), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let CodexAuth { + api_key, + mode, + auth_dot_json, + auth_file: _, + } = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT) + .unwrap() + .unwrap(); + assert_eq!(None, api_key); + assert_eq!(AuthMode::ChatGPT, mode); + + let guard = auth_dot_json.lock().unwrap(); + let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); + assert_eq!( + &AuthDotJson { + openai_api_key: None, + tokens: Some(TokenData { + id_token: IdTokenInfo { + email: Some("user@example.com".to_string()), + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), + raw_jwt: fake_jwt, + }, + access_token: "test-access-token".to_string(), + refresh_token: "test-refresh-token".to_string(), + account_id: None, + }), + last_refresh: Some( + DateTime::parse_from_rfc3339(LAST_REFRESH) + .unwrap() + .with_timezone(&Utc) + ), + }, + auth_dot_json + ) + } + + /// If the OPENAI_API_KEY is set in auth.json and it is an enterprise + /// account, then it should use [`AuthMode::ApiKey`]. + #[tokio::test] + async fn enterprise_account_with_api_key_uses_apikey_auth() { + let codex_home = tempdir().unwrap(); + write_auth_file( + AuthFileParams { + openai_api_key: Some("sk-test-key".to_string()), + chatgpt_plan_type: "enterprise".to_string(), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let CodexAuth { + api_key, + mode, + auth_dot_json, + auth_file: _, + } = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT) + .unwrap() + .unwrap(); + assert_eq!(Some("sk-test-key".to_string()), api_key); + assert_eq!(AuthMode::ApiKey, mode); + + let guard = auth_dot_json.lock().expect("should unwrap"); + assert!(guard.is_none(), "auth_dot_json should be None"); + } + + #[tokio::test] + async fn loads_api_key_from_auth_json() { + let dir = tempdir().unwrap(); + let auth_file = dir.path().join("auth.json"); + std::fs::write( + auth_file, + r#"{"OPENAI_API_KEY":"sk-test-key","tokens":null,"last_refresh":null}"#, + ) + .unwrap(); + + let auth = super::load_auth(dir.path(), false, AuthMode::ChatGPT) + .unwrap() + .unwrap(); + assert_eq!(auth.mode, AuthMode::ApiKey); + assert_eq!(auth.api_key, Some("sk-test-key".to_string())); + + assert!(auth.get_token_data().await.is_err()); + } + + #[test] + fn logout_removes_auth_file() -> Result<(), std::io::Error> { + let dir = tempdir()?; + let auth_dot_json = AuthDotJson { + openai_api_key: Some("sk-test-key".to_string()), + tokens: None, + last_refresh: None, + }; + write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; + assert!(dir.path().join("auth.json").exists()); + let removed = logout(dir.path())?; + assert!(removed); + assert!(!dir.path().join("auth.json").exists()); + Ok(()) + } + + struct AuthFileParams { + openai_api_key: Option, + chatgpt_plan_type: String, + } + + fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result { + let auth_file = get_auth_file(codex_home); + // Create a minimal valid JWT for the id_token field. + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ + "email": "user@example.com", + "email_verified": true, + "https://api.openai.com/auth": { + "chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53", + "chatgpt_plan_type": params.chatgpt_plan_type, + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + } + }); + let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); + let header_b64 = b64(&serde_json::to_vec(&header)?); + let payload_b64 = b64(&serde_json::to_vec(&payload)?); + let signature_b64 = b64(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let auth_json_data = json!({ + "OPENAI_API_KEY": params.openai_api_key, + "tokens": { + "id_token": fake_jwt, + "access_token": "test-access-token", + "refresh_token": "test-refresh-token" + }, + "last_refresh": LAST_REFRESH, + }); + let auth_json = serde_json::to_string_pretty(&auth_json_data)?; + std::fs::write(auth_file, auth_json)?; + Ok(fake_jwt) + } +} + +/// Central manager providing a single source of truth for auth.json derived +/// authentication data. It loads once (or on preference change) and then +/// hands out cloned `CodexAuth` values so the rest of the program has a +/// consistent snapshot. +/// +/// External modifications to `auth.json` will NOT be observed until +/// `reload()` is called explicitly. This matches the design goal of avoiding +/// different parts of the program seeing inconsistent auth data mid‑run. +#[derive(Debug)] +pub struct AuthManager { + codex_home: PathBuf, + inner: RwLock, +} + +impl AuthManager { + /// Create a new manager loading the initial auth using the provided + /// preferred auth method. Errors loading auth are swallowed; `auth()` will + /// simply return `None` in that case so callers can treat it as an + /// unauthenticated state. + pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Self { + let auth = CodexAuth::from_codex_home(&codex_home, preferred_auth_mode) + .ok() + .flatten(); + Self { + codex_home, + inner: RwLock::new(CachedAuth { + preferred_auth_mode, + auth, + }), + } + } + + /// Create an AuthManager with a specific CodexAuth, for testing only. + pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { + let preferred_auth_mode = auth.mode; + let cached = CachedAuth { + preferred_auth_mode, + auth: Some(auth), + }; + Arc::new(Self { + codex_home: PathBuf::new(), + inner: RwLock::new(cached), + }) + } + + /// Current cached auth (clone). May be `None` if not logged in or load failed. + pub fn auth(&self) -> Option { + self.inner.read().ok().and_then(|c| c.auth.clone()) + } + + /// Preferred auth method used when (re)loading. + pub fn preferred_auth_method(&self) -> AuthMode { + self.inner + .read() + .map(|c| c.preferred_auth_mode) + .unwrap_or(AuthMode::ApiKey) + } + + /// Force a reload using the existing preferred auth method. Returns + /// whether the auth value changed. + pub fn reload(&self) -> bool { + let preferred = self.preferred_auth_method(); + let new_auth = CodexAuth::from_codex_home(&self.codex_home, preferred) + .ok() + .flatten(); + if let Ok(mut guard) = self.inner.write() { + let changed = !AuthManager::auths_equal(&guard.auth, &new_auth); + guard.auth = new_auth; + changed + } else { + false + } + } + + fn auths_equal(a: &Option, b: &Option) -> bool { + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => a == b, + _ => false, + } + } + + /// Convenience constructor returning an `Arc` wrapper. + pub fn shared(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Arc { + Arc::new(Self::new(codex_home, preferred_auth_mode)) + } + + /// Attempt to refresh the current auth token (if any). On success, reload + /// the auth state from disk so other components observe refreshed token. + pub async fn refresh_token(&self) -> std::io::Result> { + let auth = match self.auth() { + Some(a) => a, + None => return Ok(None), + }; + match auth.refresh_token().await { + Ok(token) => { + // Reload to pick up persisted changes. + self.reload(); + Ok(Some(token)) + } + Err(e) => Err(e), + } + } + + /// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true) + /// if a file was removed, Ok(false) if no auth file existed. On success, + /// reloads the in‑memory auth cache so callers immediately observe the + /// unauthenticated state. + pub fn logout(&self) -> std::io::Result { + let removed = super::auth::logout(&self.codex_home)?; + // Always reload to clear any cached auth (even if file absent). + self.reload(); + Ok(removed) + } +} diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 6eca119f3e..fc8602de8e 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -43,7 +43,107 @@ pub(crate) async fn stream_chat_completions( let input = prompt.get_formatted_input(); + // Pre-scan: map Reasoning blocks to the adjacent assistant anchor after the last user. + // - If the last emitted message is a user message, drop all reasoning. + // - Otherwise, for each Reasoning item after the last user message, attach it + // to the immediate previous assistant message (stop turns) or the immediate + // next assistant anchor (tool-call turns: function/local shell call, or assistant message). + let mut reasoning_by_anchor_index: std::collections::HashMap = + std::collections::HashMap::new(); + + // Determine the last role that would be emitted to Chat Completions. + let mut last_emitted_role: Option<&str> = None; for item in &input { + match item { + ResponseItem::Message { role, .. } => last_emitted_role = Some(role.as_str()), + ResponseItem::FunctionCall { .. } | ResponseItem::LocalShellCall { .. } => { + last_emitted_role = Some("assistant") + } + ResponseItem::FunctionCallOutput { .. } => last_emitted_role = Some("tool"), + ResponseItem::Reasoning { .. } | ResponseItem::Other => {} + ResponseItem::CustomToolCall { .. } => {} + ResponseItem::CustomToolCallOutput { .. } => {} + ResponseItem::WebSearchCall { .. } => {} + } + } + + // Find the last user message index in the input. + let mut last_user_index: Option = None; + for (idx, item) in input.iter().enumerate() { + if let ResponseItem::Message { role, .. } = item + && role == "user" + { + last_user_index = Some(idx); + } + } + + // Attach reasoning only if the conversation does not end with a user message. + if !matches!(last_emitted_role, Some("user")) { + for (idx, item) in input.iter().enumerate() { + // Only consider reasoning that appears after the last user message. + if let Some(u_idx) = last_user_index + && idx <= u_idx + { + continue; + } + + if let ResponseItem::Reasoning { + content: Some(items), + .. + } = item + { + let mut text = String::new(); + for c in items { + match c { + ReasoningItemContent::ReasoningText { text: t } + | ReasoningItemContent::Text { text: t } => text.push_str(t), + } + } + if text.trim().is_empty() { + continue; + } + + // Prefer immediate previous assistant message (stop turns) + let mut attached = false; + if idx > 0 + && let ResponseItem::Message { role, .. } = &input[idx - 1] + && role == "assistant" + { + reasoning_by_anchor_index + .entry(idx - 1) + .and_modify(|v| v.push_str(&text)) + .or_insert(text.clone()); + attached = true; + } + + // Otherwise, attach to immediate next assistant anchor (tool-calls or assistant message) + if !attached && idx + 1 < input.len() { + match &input[idx + 1] { + ResponseItem::FunctionCall { .. } | ResponseItem::LocalShellCall { .. } => { + reasoning_by_anchor_index + .entry(idx + 1) + .and_modify(|v| v.push_str(&text)) + .or_insert(text.clone()); + } + ResponseItem::Message { role, .. } if role == "assistant" => { + reasoning_by_anchor_index + .entry(idx + 1) + .and_modify(|v| v.push_str(&text)) + .or_insert(text.clone()); + } + _ => {} + } + } + } + } + } + + // Track last assistant text we emitted to avoid duplicate assistant messages + // in the outbound Chat Completions payload (can happen if a final + // aggregated assistant message was recorded alongside an earlier partial). + let mut last_assistant_text: Option = None; + + for (idx, item) in input.iter().enumerate() { match item { ResponseItem::Message { role, content, .. } => { let mut text = String::new(); @@ -56,7 +156,24 @@ pub(crate) async fn stream_chat_completions( _ => {} } } - messages.push(json!({"role": role, "content": text})); + // Skip exact-duplicate assistant messages. + if role == "assistant" { + if let Some(prev) = &last_assistant_text + && prev == &text + { + continue; + } + last_assistant_text = Some(text.clone()); + } + + let mut msg = json!({"role": role, "content": text}); + if role == "assistant" + && let Some(reasoning) = reasoning_by_anchor_index.get(&idx) + && let Some(obj) = msg.as_object_mut() + { + obj.insert("reasoning".to_string(), json!(reasoning)); + } + messages.push(msg); } ResponseItem::FunctionCall { name, @@ -64,7 +181,7 @@ pub(crate) async fn stream_chat_completions( call_id, .. } => { - messages.push(json!({ + let mut msg = json!({ "role": "assistant", "content": null, "tool_calls": [{ @@ -75,7 +192,13 @@ pub(crate) async fn stream_chat_completions( "arguments": arguments, } }] - })); + }); + if let Some(reasoning) = reasoning_by_anchor_index.get(&idx) + && let Some(obj) = msg.as_object_mut() + { + obj.insert("reasoning".to_string(), json!(reasoning)); + } + messages.push(msg); } ResponseItem::LocalShellCall { id, @@ -84,7 +207,7 @@ pub(crate) async fn stream_chat_completions( action, } => { // Confirm with API team. - messages.push(json!({ + let mut msg = json!({ "role": "assistant", "content": null, "tool_calls": [{ @@ -93,7 +216,13 @@ pub(crate) async fn stream_chat_completions( "status": status, "action": action, }] - })); + }); + if let Some(reasoning) = reasoning_by_anchor_index.get(&idx) + && let Some(obj) = msg.as_object_mut() + { + obj.insert("reasoning".to_string(), json!(reasoning)); + } + messages.push(msg); } ResponseItem::FunctionCallOutput { call_id, output } => { messages.push(json!({ @@ -331,7 +460,10 @@ async fn process_chat_sse( // Some providers stream `reasoning` as a plain string while others // nest the text under an object (e.g. `{ "reasoning": { "text": "…" } }`). if let Some(reasoning_val) = choice.get("delta").and_then(|d| d.get("reasoning")) { - let mut maybe_text = reasoning_val.as_str().map(|s| s.to_string()); + let mut maybe_text = reasoning_val + .as_str() + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()); if maybe_text.is_none() && reasoning_val.is_object() { if let Some(s) = reasoning_val @@ -350,12 +482,39 @@ async fn process_chat_sse( } if let Some(reasoning) = maybe_text { + // Accumulate so we can emit a terminal Reasoning item at the end. + reasoning_text.push_str(&reasoning); let _ = tx_event .send(Ok(ResponseEvent::ReasoningContentDelta(reasoning))) .await; } } + // Some providers only include reasoning on the final message object. + if let Some(message_reasoning) = choice.get("message").and_then(|m| m.get("reasoning")) + { + // Accept either a plain string or an object with { text | content } + if let Some(s) = message_reasoning.as_str() { + if !s.is_empty() { + reasoning_text.push_str(s); + let _ = tx_event + .send(Ok(ResponseEvent::ReasoningContentDelta(s.to_string()))) + .await; + } + } else if let Some(obj) = message_reasoning.as_object() + && let Some(s) = obj + .get("text") + .and_then(|v| v.as_str()) + .or_else(|| obj.get("content").and_then(|v| v.as_str())) + && !s.is_empty() + { + reasoning_text.push_str(s); + let _ = tx_event + .send(Ok(ResponseEvent::ReasoningContentDelta(s.to_string()))) + .await; + } + } + // Handle streaming function / tool calls. if let Some(tool_calls) = choice .get("delta") @@ -511,27 +670,47 @@ where // do NOT emit yet. Forward any other item (e.g. FunctionCall) right // away so downstream consumers see it. - let is_assistant_delta = matches!(&item, codex_protocol::models::ResponseItem::Message { role, .. } if role == "assistant"); + let is_assistant_message = matches!( + &item, + codex_protocol::models::ResponseItem::Message { role, .. } if role == "assistant" + ); - if is_assistant_delta { - // Only use the final assistant message if we have not - // seen any deltas; otherwise, deltas already built the - // cumulative text and this would duplicate it. - if this.cumulative.is_empty() - && let codex_protocol::models::ResponseItem::Message { content, .. } = - &item - && let Some(text) = content.iter().find_map(|c| match c { - codex_protocol::models::ContentItem::OutputText { text } => { - Some(text) + if is_assistant_message { + match this.mode { + AggregateMode::AggregatedOnly => { + // Only use the final assistant message if we have not + // seen any deltas; otherwise, deltas already built the + // cumulative text and this would duplicate it. + if this.cumulative.is_empty() + && let codex_protocol::models::ResponseItem::Message { + content, + .. + } = &item + && let Some(text) = content.iter().find_map(|c| match c { + codex_protocol::models::ContentItem::OutputText { + text, + } => Some(text), + _ => None, + }) + { + this.cumulative.push_str(text); } - _ => None, - }) - { - this.cumulative.push_str(text); + // Swallow assistant message here; emit on Completed. + continue; + } + AggregateMode::Streaming => { + // In streaming mode, if we have not seen any deltas, forward + // the final assistant message directly. If deltas were seen, + // suppress the final message to avoid duplication. + if this.cumulative.is_empty() { + return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone( + item, + )))); + } else { + continue; + } + } } - - // Swallow assistant message here; emit on Completed. - continue; } // Not an assistant message – forward immediately. @@ -563,6 +742,11 @@ where emitted_any = true; } + // Always emit the final aggregated assistant message when any + // content deltas have been observed. In AggregatedOnly mode this + // is the sole assistant output; in Streaming mode this finalizes + // the streamed deltas into a terminal OutputItemDone so callers + // can persist/render the message once per turn. if !this.cumulative.is_empty() { let aggregated_message = codex_protocol::models::ResponseItem::Message { id: None, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index b7bfeaa7ca..b92acef413 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1,12 +1,14 @@ use std::io::BufRead; use std::path::Path; +use std::sync::OnceLock; use std::time::Duration; +use crate::AuthManager; use bytes::Bytes; -use codex_login::AuthManager; -use codex_login::AuthMode; +use codex_protocol::mcp_protocol::AuthMode; use eventsource_stream::Eventsource; use futures::prelude::*; +use regex_lite::Regex; use reqwest::StatusCode; use serde::Deserialize; use serde::Serialize; @@ -53,6 +55,8 @@ struct ErrorResponse { #[derive(Debug, Deserialize)] struct Error { r#type: Option, + #[allow(dead_code)] + code: Option, message: Option, // Optional fields available on "usage_limit_reached" and "usage_not_included" errors @@ -564,8 +568,9 @@ async fn process_sse( if let Some(error) = error { match serde_json::from_value::(error.clone()) { Ok(error) => { + let delay = try_parse_retry_after(&error); let message = error.message.unwrap_or_default(); - response_error = Some(CodexErr::Stream(message, None)); + response_error = Some(CodexErr::Stream(message, delay)); } Err(e) => { debug!("failed to parse ErrorResponse: {e}"); @@ -651,6 +656,40 @@ async fn stream_from_fixture( Ok(ResponseStream { rx_event }) } +fn rate_limit_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + + #[expect(clippy::unwrap_used)] + RE.get_or_init(|| Regex::new(r"Please try again in (\d+(?:\.\d+)?)(s|ms)").unwrap()) +} + +fn try_parse_retry_after(err: &Error) -> Option { + if err.code != Some("rate_limit_exceeded".to_string()) { + return None; + } + + // parse the Please try again in 1.898s format using regex + let re = rate_limit_regex(); + if let Some(message) = &err.message + && let Some(captures) = re.captures(message) + { + let seconds = captures.get(1); + let unit = captures.get(2); + + if let (Some(value), Some(unit)) = (seconds, unit) { + let value = value.as_str().parse::().ok()?; + let unit = unit.as_str(); + + if unit == "s" { + return Some(Duration::from_secs_f64(value)); + } else if unit == "ms" { + return Some(Duration::from_millis(value as u64)); + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -871,7 +910,7 @@ mod tests { msg, "Rate limit reached for gpt-5 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more." ); - assert_eq!(*delay, None); + assert_eq!(*delay, Some(Duration::from_secs_f64(11.054))); } other => panic!("unexpected second event: {other:?}"), } @@ -975,4 +1014,31 @@ mod tests { ); } } + + #[test] + fn test_try_parse_retry_after() { + let err = Error { + r#type: None, + message: Some("Rate limit reached for gpt-5 in organization org- on tokens per min (TPM): Limit 1, Used 1, Requested 19304. Please try again in 28ms. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), + code: Some("rate_limit_exceeded".to_string()), + plan_type: None, + resets_in_seconds: None + }; + + let delay = try_parse_retry_after(&err); + assert_eq!(delay, Some(Duration::from_millis(28))); + } + + #[test] + fn test_try_parse_retry_after_no_delay() { + let err = Error { + r#type: None, + message: Some("Rate limit reached for gpt-5 in organization on tokens per min (TPM): Limit 30000, Used 6899, Requested 24050. Please try again in 1.898s. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), + code: Some("rate_limit_exceeded".to_string()), + plan_type: None, + resets_in_seconds: None + }; + let delay = try_parse_retry_after(&err); + assert_eq!(delay, Some(Duration::from_secs_f64(1.898))); + } } diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 2740105100..77f6a63a09 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -35,7 +35,7 @@ pub struct Prompt { /// Tools available to the model, including additional tools sourced from /// external MCP servers. - pub tools: Vec, + pub(crate) tools: Vec, /// Optional override for the built-in BASE_INSTRUCTIONS. pub base_instructions_override: Option, @@ -174,7 +174,7 @@ pub(crate) fn create_text_param_for_request( }) } -pub(crate) struct ResponseStream { +pub struct ResponseStream { pub(crate) rx_event: mpsc::Receiver>, } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d7c39c14a5..3b3d43bf2e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -8,12 +8,12 @@ use std::sync::MutexGuard; use std::sync::atomic::AtomicU64; use std::time::Duration; +use crate::AuthManager; use async_channel::Receiver; use async_channel::Sender; use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::MaybeApplyPatchVerified; use codex_apply_patch::maybe_parse_apply_patch_verified; -use codex_login::AuthManager; use codex_protocol::protocol::ConversationHistoryResponseEvent; use codex_protocol::protocol::TaskStartedEvent; use codex_protocol::protocol::TurnAbortReason; @@ -43,6 +43,7 @@ use crate::client_common::ResponseEvent; use crate::config::Config; use crate::config_types::ShellEnvironmentPolicy; use crate::conversation_history::ConversationHistory; +use crate::conversation_manager::InitialHistory; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -169,7 +170,7 @@ impl Codex { pub async fn spawn( config: Config, auth_manager: Arc, - initial_history: Option>, + conversation_history: InitialHistory, ) -> CodexResult { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -177,7 +178,6 @@ impl Codex { let user_instructions = get_user_instructions(&config).await; let config = Arc::new(config); - let resume_path = config.experimental_resume.clone(); let configure_session = ConfigureSession { provider: config.model_provider.clone(), @@ -191,7 +191,6 @@ impl Codex { disable_response_storage: config.disable_response_storage, notify: config.notify.clone(), cwd: config.cwd.clone(), - resume_path, }; // Generate a unique ID for the lifetime of this Codex session. @@ -200,13 +199,15 @@ impl Codex { config.clone(), auth_manager.clone(), tx_event.clone(), - initial_history, ) .await .map_err(|e| { error!("Failed to create session: {e:#}"); CodexErr::InternalAgentDied })?; + session + .record_initial_history(&turn_context, conversation_history) + .await; let session_id = session.session_id; // This task will run until Op::Shutdown is received. @@ -352,8 +353,6 @@ struct ConfigureSession { /// `ConfigureSession` operation so that the business-logic layer can /// operate deterministically. cwd: PathBuf, - - resume_path: Option, } impl Session { @@ -362,8 +361,8 @@ impl Session { config: Arc, auth_manager: Arc, tx_event: Sender, - initial_history: Option>, ) -> anyhow::Result<(Arc, TurnContext)> { + let session_id = Uuid::new_v4(); let ConfigureSession { provider, model, @@ -376,7 +375,6 @@ impl Session { disable_response_storage, notify, cwd, - resume_path, } = configure_session; debug!("Configuring session: model={model}; provider={provider:?}"); if !cwd.is_absolute() { @@ -392,89 +390,25 @@ impl Session { // - spin up MCP connection manager // - perform default shell discovery // - load history metadata - let rollout_fut = async { - match resume_path.as_ref() { - Some(path) => RolloutRecorder::resume(path, cwd.clone()) - .await - .map(|(rec, saved)| (saved.session_id, Some(saved), rec)), - None => { - let session_id = Uuid::new_v4(); - RolloutRecorder::new(&config, session_id, user_instructions.clone()) - .await - .map(|rec| (session_id, None, rec)) - } - } - }; + let rollout_fut = RolloutRecorder::new(&config, session_id, user_instructions.clone()); let mcp_fut = McpConnectionManager::new(config.mcp_servers.clone()); let default_shell_fut = shell::default_user_shell(); let history_meta_fut = crate::message_history::history_metadata(&config); // Join all independent futures. - let (rollout_res, mcp_res, default_shell, (history_log_id, history_entry_count)) = + let (rollout_recorder, mcp_res, default_shell, (history_log_id, history_entry_count)) = tokio::join!(rollout_fut, mcp_fut, default_shell_fut, history_meta_fut); - // Handle rollout result, which determines the session_id. - struct RolloutResult { - session_id: Uuid, - rollout_recorder: Option, - restored_items: Option>, - } - let rollout_result = match rollout_res { - Ok((session_id, maybe_saved, recorder)) => { - let restored_items: Option> = initial_history.or_else(|| { - maybe_saved.and_then(|saved_session| { - if saved_session.items.is_empty() { - None - } else { - Some(saved_session.items) - } - }) - }); - RolloutResult { - session_id, - rollout_recorder: Some(recorder), - restored_items, - } - } - Err(e) => { - if let Some(path) = resume_path.as_ref() { - return Err(anyhow::anyhow!( - "failed to resume rollout from {path:?}: {e}" - )); - } - - let message = format!("failed to initialize rollout recorder: {e}"); - post_session_configured_error_events.push(Event { - id: INITIAL_SUBMIT_ID.to_owned(), - msg: EventMsg::Error(ErrorEvent { - message: message.clone(), - }), - }); - warn!("{message}"); - - RolloutResult { - session_id: Uuid::new_v4(), - rollout_recorder: None, - restored_items: None, - } - } - }; - - let RolloutResult { - session_id, - rollout_recorder, - restored_items, - } = rollout_result; - + let rollout_recorder = rollout_recorder.map_err(|e| { + error!("failed to initialize rollout recorder: {e:#}"); + anyhow::anyhow!("failed to initialize rollout recorder: {e:#}") + })?; // Create the mutable state for the Session. - let mut state = State { + let state = State { history: ConversationHistory::new(), ..Default::default() }; - if let Some(restored_items) = restored_items { - state.history.record_items(&restored_items); - } // Handle MCP manager result and record any startup failures. let (mcp_connection_manager, failed_clients) = match mcp_res { @@ -539,26 +473,12 @@ impl Session { session_manager: ExecSessionManager::default(), notify, state: Mutex::new(state), - rollout: Mutex::new(rollout_recorder), + rollout: Mutex::new(Some(rollout_recorder)), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), user_shell: default_shell, show_raw_agent_reasoning: config.show_raw_agent_reasoning, }); - // record the initial user instructions and environment context, - // regardless of whether we restored items. - let mut conversation_items = Vec::::with_capacity(2); - if let Some(user_instructions) = turn_context.user_instructions.as_deref() { - conversation_items.push(Prompt::format_user_instructions_message(user_instructions)); - } - conversation_items.push(ResponseItem::from(EnvironmentContext::new( - Some(turn_context.cwd.clone()), - Some(turn_context.approval_policy), - Some(turn_context.sandbox_policy.clone()), - Some(sess.user_shell.clone()), - ))); - sess.record_conversation_items(&conversation_items).await; - // Dispatch the SessionConfiguredEvent first and then report any errors. let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), @@ -596,6 +516,42 @@ impl Session { } } + async fn record_initial_history( + &self, + turn_context: &TurnContext, + conversation_history: InitialHistory, + ) { + match conversation_history { + InitialHistory::New => { + self.record_initial_history_new(turn_context).await; + } + InitialHistory::Resumed(items) => { + self.record_initial_history_resumed(items).await; + } + } + } + + async fn record_initial_history_new(&self, turn_context: &TurnContext) { + // record the initial user instructions and environment context, + // regardless of whether we restored items. + // TODO: Those items shouldn't be "user messages" IMO. Maybe developer messages. + let mut conversation_items = Vec::::with_capacity(2); + if let Some(user_instructions) = turn_context.user_instructions.as_deref() { + conversation_items.push(Prompt::format_user_instructions_message(user_instructions)); + } + conversation_items.push(ResponseItem::from(EnvironmentContext::new( + Some(turn_context.cwd.clone()), + Some(turn_context.approval_policy), + Some(turn_context.sandbox_policy.clone()), + Some(self.user_shell.clone()), + ))); + self.record_conversation_items(&conversation_items).await; + } + + async fn record_initial_history_resumed(&self, items: Vec) { + self.record_conversation_items(&items).await; + } + /// Sends the given event to the client and swallows the send event, if /// any, logging it as an error. pub(crate) async fn send_event(&self, event: Event) { diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 7845f5d4ce..6a3f39b652 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -15,10 +15,10 @@ use crate::model_provider_info::built_in_model_providers; use crate::openai_model_info::get_model_info; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; -use codex_login::AuthMode; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; +use codex_protocol::mcp_protocol::AuthMode; use dirs::home_dir; use serde::Deserialize; use std::collections::HashMap; @@ -185,6 +185,8 @@ pub struct Config { /// All characters are inserted as they are received, and no buffering /// or placeholder replacement will occur for fast keypress bursts. pub disable_paste_burst: bool, + + pub use_experimental_reasoning_summary: bool, } impl Config { @@ -482,6 +484,8 @@ pub struct ConfigToml { pub experimental_use_exec_command_tool: Option, + pub use_experimental_reasoning_summary: Option, + /// The value for the `originator` header included with Responses API requests. pub responses_originator_header_internal_override: Option, @@ -807,6 +811,9 @@ impl Config { .unwrap_or(false), include_view_image_tool, disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), + use_experimental_reasoning_summary: cfg + .use_experimental_reasoning_summary + .unwrap_or(false), }; Ok(config) } @@ -1177,6 +1184,7 @@ disable_response_storage = true use_experimental_streamable_shell_tool: false, include_view_image_tool: true, disable_paste_burst: false, + use_experimental_reasoning_summary: false, }, o3_profile_config ); @@ -1235,6 +1243,7 @@ disable_response_storage = true use_experimental_streamable_shell_tool: false, include_view_image_tool: true, disable_paste_burst: false, + use_experimental_reasoning_summary: false, }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -1308,6 +1317,7 @@ disable_response_storage = true use_experimental_streamable_shell_tool: false, include_view_image_tool: true, disable_paste_burst: false, + use_experimental_reasoning_summary: false, }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index ae169ed56f..78674cd7e7 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -1,8 +1,9 @@ use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; -use codex_login::AuthManager; -use codex_login::CodexAuth; +use crate::AuthManager; +use crate::CodexAuth; use tokio::sync::RwLock; use uuid::Uuid; @@ -16,8 +17,15 @@ use crate::error::Result as CodexResult; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::SessionConfiguredEvent; +use crate::rollout::RolloutRecorder; use codex_protocol::models::ResponseItem; +#[derive(Debug, Clone, PartialEq)] +pub enum InitialHistory { + New, + Resumed(Vec), +} + /// Represents a newly created Codex conversation, including the first event /// (which is [`EventMsg::SessionConfigured`]). pub struct NewConversation { @@ -44,7 +52,7 @@ impl ConversationManager { /// Construct with a dummy AuthManager containing the provided CodexAuth. /// Used for integration tests: should not be used by ordinary business logic. pub fn with_auth(auth: CodexAuth) -> Self { - Self::new(codex_login::AuthManager::from_auth_for_testing(auth)) + Self::new(crate::AuthManager::from_auth_for_testing(auth)) } pub async fn new_conversation(&self, config: Config) -> CodexResult { @@ -57,14 +65,21 @@ impl ConversationManager { config: Config, auth_manager: Arc, ) -> CodexResult { - let CodexSpawnOk { - codex, - session_id: conversation_id, - } = { - let initial_history = None; - Codex::spawn(config, auth_manager, initial_history).await? - }; - self.finalize_spawn(codex, conversation_id).await + // TO BE REFACTORED: use the config experimental_resume field until we have a mainstream way. + if let Some(resume_path) = config.experimental_resume.as_ref() { + let initial_history = RolloutRecorder::get_rollout_history(resume_path).await?; + let CodexSpawnOk { + codex, + session_id: conversation_id, + } = Codex::spawn(config, auth_manager, initial_history).await?; + self.finalize_spawn(codex, conversation_id).await + } else { + let CodexSpawnOk { + codex, + session_id: conversation_id, + } = { Codex::spawn(config, auth_manager, InitialHistory::New).await? }; + self.finalize_spawn(codex, conversation_id).await + } } async fn finalize_spawn( @@ -110,6 +125,20 @@ impl ConversationManager { .ok_or_else(|| CodexErr::ConversationNotFound(conversation_id)) } + pub async fn resume_conversation_from_rollout( + &self, + config: Config, + rollout_path: PathBuf, + auth_manager: Arc, + ) -> CodexResult { + let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?; + let CodexSpawnOk { + codex, + session_id: conversation_id, + } = Codex::spawn(config, auth_manager, initial_history).await?; + self.finalize_spawn(codex, conversation_id).await + } + pub async fn remove_conversation(&self, conversation_id: Uuid) { self.conversations.write().await.remove(&conversation_id); } @@ -125,7 +154,7 @@ impl ConversationManager { config: Config, ) -> CodexResult { // Compute the prefix up to the cut point. - let truncated_history = + let history = truncate_after_dropping_last_messages(conversation_history, num_messages_to_drop); // Spawn a new conversation with the computed initial history. @@ -133,7 +162,7 @@ impl ConversationManager { let CodexSpawnOk { codex, session_id: conversation_id, - } = Codex::spawn(config, auth_manager, Some(truncated_history)).await?; + } = Codex::spawn(config, auth_manager, history).await?; self.finalize_spawn(codex, conversation_id).await } @@ -141,9 +170,9 @@ impl ConversationManager { /// Return a prefix of `items` obtained by dropping the last `n` user messages /// and all items that follow them. -fn truncate_after_dropping_last_messages(items: Vec, n: usize) -> Vec { - if n == 0 || items.is_empty() { - return items; +fn truncate_after_dropping_last_messages(items: Vec, n: usize) -> InitialHistory { + if n == 0 { + return InitialHistory::Resumed(items); } // Walk backwards counting only `user` Message items, find cut index. @@ -161,11 +190,11 @@ fn truncate_after_dropping_last_messages(items: Vec, n: usize) -> } } } - if count < n { - // If fewer than n messages exist, drop everything. - Vec::new() + if cut_index == 0 { + // No prefix remains after dropping; start a new conversation. + InitialHistory::New } else { - items.into_iter().take(cut_index).collect() + InitialHistory::Resumed(items.into_iter().take(cut_index).collect()) } } @@ -223,10 +252,10 @@ mod tests { let truncated = truncate_after_dropping_last_messages(items.clone(), 1); assert_eq!( truncated, - vec![items[0].clone(), items[1].clone(), items[2].clone()] + InitialHistory::Resumed(vec![items[0].clone(), items[1].clone(), items[2].clone(),]) ); let truncated2 = truncate_after_dropping_last_messages(items, 2); - assert!(truncated2.is_empty()); + assert_eq!(truncated2, InitialHistory::New); } } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 9f23420c7e..b4966e7988 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -6,12 +6,14 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] mod apply_patch; +pub mod auth; mod bash; mod chat_completions; mod client; mod client_common; pub mod codex; mod codex_conversation; +pub mod token_data; pub use codex_conversation::CodexConversation; pub mod config; pub mod config_profile; @@ -40,6 +42,9 @@ pub use model_provider_info::create_oss_provider_with_base_url; mod conversation_manager; pub use conversation_manager::ConversationManager; pub use conversation_manager::NewConversation; +// Re-export common auth types for workspace consumers +pub use auth::AuthManager; +pub use auth::CodexAuth; pub mod model_family; mod openai_model_info; mod openai_tools; @@ -64,3 +69,14 @@ pub use codex_protocol::protocol; // Re-export protocol config enums to ensure call sites can use the same types // as those in the protocol crate when constructing protocol messages. pub use codex_protocol::config_types as protocol_config_types; + +pub use client::ModelClient; +pub use client_common::Prompt; +pub use client_common::ResponseEvent; +pub use client_common::ResponseStream; +pub use codex_protocol::models::ContentItem; +pub use codex_protocol::models::LocalShellAction; +pub use codex_protocol::models::LocalShellExecAction; +pub use codex_protocol::models::LocalShellStatus; +pub use codex_protocol::models::ReasoningItemContent; +pub use codex_protocol::models::ResponseItem; diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 1a3e5a2767..c7da1194ec 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -5,8 +5,8 @@ //! 2. User-defined entries inside `~/.codex/config.toml` under the `model_providers` //! key. These override or extend the defaults at runtime. -use codex_login::AuthMode; -use codex_login::CodexAuth; +use crate::CodexAuth; +use codex_protocol::mcp_protocol::AuthMode; use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index bcf0dba79b..15dd2ff411 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -235,6 +235,28 @@ mod tests { ); } + #[test] + fn bash_cd_then_bar_is_same_as_bar() { + // Ensure a leading `cd` inside bash -lc is dropped when followed by another command. + assert_parsed( + &shlex_split_safe("bash -lc 'cd foo && bar'"), + vec![ParsedCommand::Unknown { + cmd: "bar".to_string(), + }], + ); + } + + #[test] + fn bash_cd_then_cat_is_read() { + assert_parsed( + &shlex_split_safe("bash -lc 'cd foo && cat foo.txt'"), + vec![ParsedCommand::Read { + cmd: "cat foo.txt".to_string(), + name: "foo.txt".to_string(), + }], + ); + } + #[test] fn supports_ls_with_pipe() { let inner = "ls -la | sed -n '1,120p'"; @@ -1149,6 +1171,10 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { .collect(); if commands.len() > 1 { commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true")); + // Apply the same simplifications used for non-bash parsing, e.g., drop leading `cd`. + while let Some(next) = simplify_once(&commands) { + commands = next; + } } if commands.len() == 1 { // If we reduced to a single command, attribute the full original script diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index 3c7162e883..98d576ccc0 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -20,6 +20,7 @@ use tracing::warn; use uuid::Uuid; use crate::config::Config; +use crate::conversation_manager::InitialHistory; use crate::git_info::GitInfo; use crate::git_info::collect_git_info; use codex_protocol::models::ResponseItem; @@ -157,20 +158,14 @@ impl RolloutRecorder { .map_err(|e| IoError::other(format!("failed to queue rollout state: {e}"))) } - pub async fn resume( - path: &Path, - cwd: std::path::PathBuf, - ) -> std::io::Result<(Self, SavedSession)> { + pub async fn get_rollout_history(path: &Path) -> std::io::Result { info!("Resuming rollout from {path:?}"); let text = tokio::fs::read_to_string(path).await?; let mut lines = text.lines(); - let meta_line = lines + let _ = lines .next() .ok_or_else(|| IoError::other("empty session file"))?; - let session: SessionMeta = serde_json::from_str(meta_line) - .map_err(|e| IoError::other(format!("failed to parse session meta: {e}")))?; let mut items = Vec::new(); - let mut state = SessionStateSnapshot::default(); for line in lines { if line.trim().is_empty() { @@ -185,9 +180,6 @@ impl RolloutRecorder { .map(|s| s == "state") .unwrap_or(false) { - if let Ok(s) = serde_json::from_value::(v.clone()) { - state = s - } continue; } match serde_json::from_value::(v.clone()) { @@ -207,27 +199,12 @@ impl RolloutRecorder { } } - let saved = SavedSession { - session: session.clone(), - items: items.clone(), - state: state.clone(), - session_id: session.id, - }; - - let file = std::fs::OpenOptions::new() - .append(true) - .read(true) - .open(path)?; - - let (tx, rx) = mpsc::channel::(256); - tokio::task::spawn(rollout_writer( - tokio::fs::File::from_std(file), - rx, - None, - cwd, - )); info!("Resumed rollout successfully from {path:?}"); - Ok((Self { tx }, saved)) + if items.is_empty() { + Ok(InitialHistory::New) + } else { + Ok(InitialHistory::Resumed(items)) + } } pub async fn shutdown(&self) -> std::io::Result<()> { diff --git a/codex-rs/login/src/token_data.rs b/codex-rs/core/src/token_data.rs similarity index 82% rename from codex-rs/login/src/token_data.rs rename to codex-rs/core/src/token_data.rs index f6d04f16a6..626b030541 100644 --- a/codex-rs/login/src/token_data.rs +++ b/codex-rs/core/src/token_data.rs @@ -3,7 +3,7 @@ use serde::Deserialize; use serde::Serialize; use thiserror::Error; -use crate::AuthMode; +use codex_protocol::mcp_protocol::AuthMode; #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] pub struct TokenData { @@ -58,7 +58,7 @@ pub struct IdTokenInfo { pub email: Option, /// The ChatGPT subscription plan type /// (e.g., "free", "plus", "pro", "business", "enterprise", "edu"). - /// (Note: ae has not verified that those are the exact values.) + /// (Note: values may vary by backend.) pub(crate) chatgpt_plan_type: Option, pub raw_jwt: String, } @@ -137,7 +137,7 @@ pub enum IdTokenInfoError { Json(#[from] serde_json::Error), } -pub(crate) fn parse_id_token(id_token: &str) -> Result { +pub fn parse_id_token(id_token: &str) -> Result { // JWT format: header.payload.signature let mut parts = id_token.split('.'); let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) { @@ -204,9 +204,33 @@ mod tests { let info = parse_id_token(&fake_jwt).expect("should parse"); assert_eq!(info.email.as_deref(), Some("user@example.com")); - assert_eq!( - info.chatgpt_plan_type, - Some(PlanType::Known(KnownPlan::Pro)) - ); + assert_eq!(info.get_chatgpt_plan_type().as_deref(), Some("Pro")); + } + + #[test] + fn id_token_info_handles_missing_fields() { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = serde_json::json!({ "sub": "123" }); + + fn b64url_no_pad(bytes: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + } + + let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); + let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); + let signature_b64 = b64url_no_pad(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + let info = parse_id_token(&fake_jwt).expect("should parse"); + assert!(info.email.is_none()); + assert!(info.get_chatgpt_plan_type().is_none()); } } diff --git a/codex-rs/core/src/tool_apply_patch.lark b/codex-rs/core/src/tool_apply_patch.lark new file mode 100644 index 0000000000..5aa41b0af7 --- /dev/null +++ b/codex-rs/core/src/tool_apply_patch.lark @@ -0,0 +1,19 @@ +start: begin_patch hunk+ end_patch +begin_patch: "*** Begin Patch" LF +end_patch: "*** End Patch" LF? + +hunk: add_hunk | delete_hunk | update_hunk +add_hunk: "*** Add File: " filename LF add_line+ +delete_hunk: "*** Delete File: " filename LF +update_hunk: "*** Update File: " filename LF change_move? change? + +filename: /(.+)/ +add_line: "+" /(.*)/ LF -> line + +change_move: "*** Move to: " filename LF +change: (change_context | change_line)+ eof_line? +change_context: ("@@" | "@@ " /(.+)/) LF +change_line: ("+" | "-" | " ") /(.*)/ LF +eof_line: "*** End of File" LF + +%import common.LF diff --git a/codex-rs/core/src/tool_apply_patch.rs b/codex-rs/core/src/tool_apply_patch.rs index 3868cf0e70..d4039fd192 100644 --- a/codex-rs/core/src/tool_apply_patch.rs +++ b/codex-rs/core/src/tool_apply_patch.rs @@ -8,6 +8,8 @@ use crate::openai_tools::JsonSchema; use crate::openai_tools::OpenAiTool; use crate::openai_tools::ResponsesApiTool; +const APPLY_PATCH_LARK_GRAMMAR: &str = include_str!("tool_apply_patch.lark"); + #[derive(Serialize, Deserialize)] pub(crate) struct ApplyPatchToolArgs { pub(crate) input: String, @@ -29,27 +31,7 @@ pub(crate) fn create_apply_patch_freeform_tool() -> OpenAiTool { format: FreeformToolFormat { r#type: "grammar".to_string(), syntax: "lark".to_string(), - definition: r#"start: begin_patch hunk+ end_patch -begin_patch: "*** Begin Patch" LF -end_patch: "*** End Patch" LF? - -hunk: add_hunk | delete_hunk | update_hunk -add_hunk: "*** Add File: " filename LF add_line+ -delete_hunk: "*** Delete File: " filename LF -update_hunk: "*** Update File: " filename LF change_move? change? - -filename: /(.+)/ -add_line: "+" /(.+)/ LF -> line - -change_move: "*** Move to: " filename LF -change: (change_context | change_line)+ eof_line? -change_context: ("@@" | "@@ " /(.+)/) LF -change_line: ("+" | "-" | " ") /(.+)/ LF -eof_line: "*** End of File" LF - -%import common.LF -"# - .to_string(), + definition: APPLY_PATCH_LARK_GRAMMAR.to_string(), }, }) } diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs new file mode 100644 index 0000000000..6b21894d75 --- /dev/null +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -0,0 +1,345 @@ +use std::sync::Arc; + +use codex_core::ContentItem; +use codex_core::LocalShellAction; +use codex_core::LocalShellExecAction; +use codex_core::LocalShellStatus; +use codex_core::ModelClient; +use codex_core::ModelProviderInfo; +use codex_core::Prompt; +use codex_core::ReasoningItemContent; +use codex_core::ResponseItem; +use codex_core::WireApi; +use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use core_test_support::load_default_config_for_test; +use futures::StreamExt; +use serde_json::Value; +use tempfile::TempDir; +use uuid::Uuid; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +fn network_disabled() -> bool { + std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() +} + +async fn run_request(input: Vec) -> Value { + let server = MockServer::start().await; + + let template = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw( + "data: {\"choices\":[{\"delta\":{}}]}\n\ndata: [DONE]\n\n", + "text/event-stream", + ); + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(template) + .expect(1) + .mount(&server) + .await; + + let provider = ModelProviderInfo { + name: "mock".into(), + base_url: Some(format!("{}/v1", server.uri())), + env_key: None, + env_key_instructions: None, + wire_api: WireApi::Chat, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(5_000), + requires_openai_auth: false, + }; + + let codex_home = match TempDir::new() { + Ok(dir) => dir, + Err(e) => panic!("failed to create TempDir: {e}"), + }; + let mut config = load_default_config_for_test(&codex_home); + config.model_provider_id = provider.name.clone(); + config.model_provider = provider.clone(); + config.show_raw_agent_reasoning = true; + let effort = config.model_reasoning_effort; + let summary = config.model_reasoning_summary; + let config = Arc::new(config); + + let client = ModelClient::new( + Arc::clone(&config), + None, + provider, + effort, + summary, + Uuid::new_v4(), + ); + + let mut prompt = Prompt::default(); + prompt.input = input; + + let mut stream = match client.stream(&prompt).await { + Ok(s) => s, + Err(e) => panic!("stream chat failed: {e}"), + }; + while let Some(event) = stream.next().await { + if let Err(e) = event { + panic!("stream event error: {e}"); + } + } + + let requests = match server.received_requests().await { + Some(reqs) => reqs, + None => panic!("request not made"), + }; + match requests[0].body_json() { + Ok(v) => v, + Err(e) => panic!("invalid json body: {e}"), + } +} + +fn user_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + } +} + +fn assistant_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + } +} + +fn reasoning_item(text: &str) -> ResponseItem { + ResponseItem::Reasoning { + id: String::new(), + summary: Vec::new(), + content: Some(vec![ReasoningItemContent::ReasoningText { + text: text.to_string(), + }]), + encrypted_content: None, + } +} + +fn function_call() -> ResponseItem { + ResponseItem::FunctionCall { + id: None, + name: "f".to_string(), + arguments: "{}".to_string(), + call_id: "c1".to_string(), + } +} + +fn local_shell_call() -> ResponseItem { + ResponseItem::LocalShellCall { + id: Some("id1".to_string()), + call_id: None, + status: LocalShellStatus::InProgress, + action: LocalShellAction::Exec(LocalShellExecAction { + command: vec!["echo".to_string()], + timeout_ms: Some(1_000), + working_directory: None, + env: None, + user: None, + }), + } +} + +fn messages_from(body: &Value) -> Vec { + match body["messages"].as_array() { + Some(arr) => arr.clone(), + None => panic!("messages array missing"), + } +} + +fn first_assistant(messages: &[Value]) -> &Value { + match messages.iter().find(|msg| msg["role"] == "assistant") { + Some(v) => v, + None => panic!("assistant message not present"), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn omits_reasoning_when_none_present() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![user_message("u1"), assistant_message("a1")]).await; + let messages = messages_from(&body); + let assistant = first_assistant(&messages); + + assert_eq!(assistant["content"], Value::String("a1".into())); + assert!(assistant.get("reasoning").is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attaches_reasoning_to_previous_assistant() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![ + user_message("u1"), + assistant_message("a1"), + reasoning_item("rA"), + ]) + .await; + let messages = messages_from(&body); + let assistant = first_assistant(&messages); + + assert_eq!(assistant["content"], Value::String("a1".into())); + assert_eq!(assistant["reasoning"], Value::String("rA".into())); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attaches_reasoning_to_function_call_anchor() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![ + user_message("u1"), + reasoning_item("rFunc"), + function_call(), + ]) + .await; + let messages = messages_from(&body); + let assistant = first_assistant(&messages); + + assert_eq!(assistant["reasoning"], Value::String("rFunc".into())); + let tool_calls = match assistant["tool_calls"].as_array() { + Some(arr) => arr, + None => panic!("tool call list missing"), + }; + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0]["type"], Value::String("function".into())); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attaches_reasoning_to_local_shell_call() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![ + user_message("u1"), + reasoning_item("rShell"), + local_shell_call(), + ]) + .await; + let messages = messages_from(&body); + let assistant = first_assistant(&messages); + + assert_eq!(assistant["reasoning"], Value::String("rShell".into())); + assert_eq!( + assistant["tool_calls"][0]["type"], + Value::String("local_shell_call".into()) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn drops_reasoning_when_last_role_is_user() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![ + assistant_message("aPrev"), + reasoning_item("rHist"), + user_message("uNew"), + ]) + .await; + let messages = messages_from(&body); + assert!(messages.iter().all(|msg| msg.get("reasoning").is_none())); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ignores_reasoning_before_last_user() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![ + user_message("u1"), + assistant_message("a1"), + user_message("u2"), + reasoning_item("rAfterU1"), + ]) + .await; + let messages = messages_from(&body); + assert!(messages.iter().all(|msg| msg.get("reasoning").is_none())); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn skips_empty_reasoning_segments() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![ + user_message("u1"), + assistant_message("a1"), + reasoning_item(""), + reasoning_item(" "), + ]) + .await; + let messages = messages_from(&body); + let assistant = first_assistant(&messages); + assert!(assistant.get("reasoning").is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn suppresses_duplicate_assistant_messages() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let body = run_request(vec![assistant_message("dup"), assistant_message("dup")]).await; + let messages = messages_from(&body); + let assistant_messages: Vec<_> = messages + .iter() + .filter(|msg| msg["role"] == "assistant") + .collect(); + assert_eq!(assistant_messages.len(), 1); + assert_eq!( + assistant_messages[0]["content"], + Value::String("dup".into()) + ); +} diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs new file mode 100644 index 0000000000..1df658dad6 --- /dev/null +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -0,0 +1,320 @@ +use std::sync::Arc; + +use codex_core::ContentItem; +use codex_core::ModelClient; +use codex_core::ModelProviderInfo; +use codex_core::Prompt; +use codex_core::ResponseEvent; +use codex_core::ResponseItem; +use codex_core::WireApi; +use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use core_test_support::load_default_config_for_test; +use futures::StreamExt; +use tempfile::TempDir; +use uuid::Uuid; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +fn network_disabled() -> bool { + std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() +} + +async fn run_stream(sse_body: &str) -> Vec { + let server = MockServer::start().await; + + let template = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse_body.to_string(), "text/event-stream"); + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(template) + .expect(1) + .mount(&server) + .await; + + let provider = ModelProviderInfo { + name: "mock".into(), + base_url: Some(format!("{}/v1", server.uri())), + env_key: None, + env_key_instructions: None, + wire_api: WireApi::Chat, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(5_000), + requires_openai_auth: false, + }; + + let codex_home = match TempDir::new() { + Ok(dir) => dir, + Err(e) => panic!("failed to create TempDir: {e}"), + }; + let mut config = load_default_config_for_test(&codex_home); + config.model_provider_id = provider.name.clone(); + config.model_provider = provider.clone(); + config.show_raw_agent_reasoning = true; + let effort = config.model_reasoning_effort; + let summary = config.model_reasoning_summary; + let config = Arc::new(config); + + let client = ModelClient::new( + Arc::clone(&config), + None, + provider, + effort, + summary, + Uuid::new_v4(), + ); + + let mut prompt = Prompt::default(); + prompt.input = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "hello".to_string(), + }], + }]; + + let mut stream = match client.stream(&prompt).await { + Ok(s) => s, + Err(e) => panic!("stream chat failed: {e}"), + }; + let mut events = Vec::new(); + while let Some(event) = stream.next().await { + match event { + Ok(ev) => events.push(ev), + Err(e) => panic!("stream event error: {e}"), + } + } + events +} + +fn assert_message(item: &ResponseItem, expected: &str) { + if let ResponseItem::Message { content, .. } = item { + let text = content.iter().find_map(|part| match part { + ContentItem::OutputText { text } | ContentItem::InputText { text } => Some(text), + _ => None, + }); + let Some(text) = text else { + panic!("message missing text: {item:?}"); + }; + assert_eq!(text, expected); + } else { + panic!("expected message item, got: {item:?}"); + } +} + +fn assert_reasoning(item: &ResponseItem, expected: &str) { + if let ResponseItem::Reasoning { + content: Some(parts), + .. + } = item + { + let mut combined = String::new(); + for part in parts { + match part { + codex_core::ReasoningItemContent::ReasoningText { text } + | codex_core::ReasoningItemContent::Text { text } => combined.push_str(text), + } + } + assert_eq!(combined, expected); + } else { + panic!("expected reasoning item, got: {item:?}"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn streams_text_without_reasoning() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let sse = concat!( + "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{}}]}\n\n", + "data: [DONE]\n\n", + ); + + let events = run_stream(sse).await; + assert_eq!(events.len(), 3, "unexpected events: {events:?}"); + + match &events[0] { + ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "hi"), + other => panic!("expected text delta, got {other:?}"), + } + + match &events[1] { + ResponseEvent::OutputItemDone(item) => assert_message(item, "hi"), + other => panic!("expected terminal message, got {other:?}"), + } + + assert!(matches!(events[2], ResponseEvent::Completed { .. })); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn streams_reasoning_from_string_delta() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let sse = concat!( + "data: {\"choices\":[{\"delta\":{\"reasoning\":\"think1\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{} ,\"finish_reason\":\"stop\"}]}\n\n", + ); + + let events = run_stream(sse).await; + assert_eq!(events.len(), 5, "unexpected events: {events:?}"); + + match &events[0] { + ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "think1"), + other => panic!("expected reasoning delta, got {other:?}"), + } + + match &events[1] { + ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "ok"), + other => panic!("expected text delta, got {other:?}"), + } + + match &events[2] { + ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "think1"), + other => panic!("expected reasoning item, got {other:?}"), + } + + match &events[3] { + ResponseEvent::OutputItemDone(item) => assert_message(item, "ok"), + other => panic!("expected message item, got {other:?}"), + } + + assert!(matches!(events[4], ResponseEvent::Completed { .. })); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn streams_reasoning_from_object_delta() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let sse = concat!( + "data: {\"choices\":[{\"delta\":{\"reasoning\":{\"text\":\"partA\"}}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"reasoning\":{\"content\":\"partB\"}}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"answer\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{} ,\"finish_reason\":\"stop\"}]}\n\n", + ); + + let events = run_stream(sse).await; + assert_eq!(events.len(), 6, "unexpected events: {events:?}"); + + match &events[0] { + ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "partA"), + other => panic!("expected reasoning delta, got {other:?}"), + } + + match &events[1] { + ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "partB"), + other => panic!("expected reasoning delta, got {other:?}"), + } + + match &events[2] { + ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "answer"), + other => panic!("expected text delta, got {other:?}"), + } + + match &events[3] { + ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "partApartB"), + other => panic!("expected reasoning item, got {other:?}"), + } + + match &events[4] { + ResponseEvent::OutputItemDone(item) => assert_message(item, "answer"), + other => panic!("expected message item, got {other:?}"), + } + + assert!(matches!(events[5], ResponseEvent::Completed { .. })); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn streams_reasoning_from_final_message() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let sse = "data: {\"choices\":[{\"message\":{\"reasoning\":\"final-cot\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let events = run_stream(sse).await; + assert_eq!(events.len(), 3, "unexpected events: {events:?}"); + + match &events[0] { + ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "final-cot"), + other => panic!("expected reasoning delta, got {other:?}"), + } + + match &events[1] { + ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "final-cot"), + other => panic!("expected reasoning item, got {other:?}"), + } + + assert!(matches!(events[2], ResponseEvent::Completed { .. })); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn streams_reasoning_before_tool_call() { + if network_disabled() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let sse = concat!( + "data: {\"choices\":[{\"delta\":{\"reasoning\":\"pre-tool\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"run\",\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n", + ); + + let events = run_stream(sse).await; + assert_eq!(events.len(), 4, "unexpected events: {events:?}"); + + match &events[0] { + ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "pre-tool"), + other => panic!("expected reasoning delta, got {other:?}"), + } + + match &events[1] { + ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "pre-tool"), + other => panic!("expected reasoning item, got {other:?}"), + } + + match &events[2] { + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { + name, + arguments, + call_id, + .. + }) => { + assert_eq!(name, "run"); + assert_eq!(arguments, "{}"); + assert_eq!(call_id, "call_1"); + } + other => panic!("expected function call, got {other:?}"), + } + + assert!(matches!(events[3], ResponseEvent::Completed { .. })); +} diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index dd53a8d302..695aa9f1ba 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -388,7 +388,7 @@ async fn integration_creates_and_checks_session_file() { "No message found in session file containing the marker" ); - // Second run: resume and append. + // Second run: resume should create a NEW session file that contains both old and new history. let orig_len = content.lines().count(); let marker2 = format!("integration-resume-{}", Uuid::new_v4()); let prompt2 = format!("echo {marker2}"); @@ -419,31 +419,58 @@ async fn integration_creates_and_checks_session_file() { let output2 = cmd2.output().unwrap(); assert!(output2.status.success(), "resume codex-cli run failed"); - // The rollout writer runs on a background async task; give it a moment to flush. - let mut new_len = orig_len; - let deadline = Instant::now() + Duration::from_secs(5); - let mut content2 = String::new(); - while Instant::now() < deadline { - if let Ok(c) = std::fs::read_to_string(&path) { - let count = c.lines().count(); - if count > orig_len { - content2 = c; - new_len = count; + // Find the new session file containing the resumed marker. + let deadline = Instant::now() + Duration::from_secs(10); + let mut resumed_path: Option = None; + while Instant::now() < deadline && resumed_path.is_none() { + for entry in WalkDir::new(&sessions_dir) { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + if !entry.file_type().is_file() { + continue; + } + if !entry.file_name().to_string_lossy().ends_with(".jsonl") { + continue; + } + let p = entry.path(); + let Ok(c) = std::fs::read_to_string(p) else { + continue; + }; + if c.contains(&marker2) { + resumed_path = Some(p.to_path_buf()); break; } } - std::thread::sleep(Duration::from_millis(50)); + if resumed_path.is_none() { + std::thread::sleep(Duration::from_millis(50)); + } } - if content2.is_empty() { - // last attempt - content2 = std::fs::read_to_string(&path).unwrap(); - new_len = content2.lines().count(); - } - assert!(new_len > orig_len, "rollout file did not grow after resume"); - assert!(content2.contains(&marker), "rollout lost original marker"); + + let resumed_path = resumed_path.expect("No resumed session file found containing the marker2"); + // Resume should have written to a new file, not the original one. + assert_ne!( + resumed_path, path, + "resume should create a new session file" + ); + + let resumed_content = std::fs::read_to_string(&resumed_path).unwrap(); assert!( - content2.contains(&marker2), - "rollout missing resumed marker" + resumed_content.contains(&marker), + "resumed file missing original marker" + ); + assert!( + resumed_content.contains(&marker2), + "resumed file missing resumed marker" + ); + + // Original file should remain unchanged. + let content_after = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + content_after.lines().count(), + orig_len, + "original rollout file should not change on resume" ); } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index aed34dc3a1..f77749b4a9 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,3 +1,4 @@ +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::NewConversation; @@ -7,8 +8,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_login::AuthMode; -use codex_login::CodexAuth; +use codex_protocol::mcp_protocol::AuthMode; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::wait_for_event; @@ -416,7 +416,7 @@ async fn prefers_chatgpt_token_when_config_prefers_chatgpt() { let auth_manager = match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) { - Ok(Some(auth)) => codex_login::AuthManager::from_auth_for_testing(auth), + Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), Ok(None) => panic!("No CodexAuth found in codex_home"), Err(e) => panic!("Failed to load CodexAuth: {e}"), }; @@ -497,7 +497,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { let auth_manager = match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) { - Ok(Some(auth)) => codex_login::AuthManager::from_auth_for_testing(auth), + Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), Ok(None) => panic!("No CodexAuth found in codex_home"), Err(e) => panic!("Failed to load CodexAuth: {e}"), }; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 404a88e813..f5e854d275 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1,5 +1,6 @@ #![expect(clippy::unwrap_used)] +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; @@ -7,7 +8,6 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; use serde_json::Value; diff --git a/codex-rs/core/tests/suite/fork_conversation.rs b/codex-rs/core/tests/suite/fork_conversation.rs new file mode 100644 index 0000000000..92e43dbfeb --- /dev/null +++ b/codex-rs/core/tests/suite/fork_conversation.rs @@ -0,0 +1,154 @@ +use codex_core::CodexAuth; +use codex_core::ConversationManager; +use codex_core::ModelProviderInfo; +use codex_core::NewConversation; +use codex_core::built_in_model_providers; +use codex_core::protocol::ConversationHistoryResponseEvent; +use codex_core::protocol::EventMsg; +use codex_core::protocol::InputItem; +use codex_core::protocol::Op; +use core_test_support::load_default_config_for_test; +use core_test_support::wait_for_event; +use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +/// Build minimal SSE stream with completed marker using the JSON fixture. +fn sse_completed(id: &str) -> String { + core_test_support::load_sse_fixture_with_id("tests/fixtures/completed_template.json", id) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fork_conversation_twice_drops_to_first_message() { + // Start a mock server that completes three turns. + let server = MockServer::start().await; + let sse = sse_completed("resp"); + let first = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse.clone(), "text/event-stream"); + + // Expect three calls to /v1/responses – one per user input. + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(first) + .expect(3) + .mount(&server) + .await; + + // Configure Codex to use the mock server. + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&home); + config.model_provider = model_provider.clone(); + let config_for_fork = config.clone(); + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let NewConversation { + conversation: codex, + .. + } = conversation_manager + .new_conversation(config) + .await + .expect("create conversation"); + + // Send three user messages; wait for three completed turns. + for text in ["first", "second", "third"] { + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: text.to_string(), + }], + }) + .await + .unwrap(); + let _ = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + } + + // Request history from the base conversation. + codex.submit(Op::GetHistory).await.unwrap(); + let base_history = + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ConversationHistory(_))).await; + + // Capture entries from the base history and compute expected prefixes after each fork. + let entries_after_three = match &base_history { + EventMsg::ConversationHistory(ConversationHistoryResponseEvent { entries, .. }) => { + entries.clone() + } + _ => panic!("expected ConversationHistory event"), + }; + // History layout for this test: + // [0] user instructions, + // [1] environment context, + // [2] "first" user message, + // [3] "second" user message, + // [4] "third" user message. + + // Fork 1: drops the last user message and everything after. + let expected_after_first = vec![ + entries_after_three[0].clone(), + entries_after_three[1].clone(), + entries_after_three[2].clone(), + entries_after_three[3].clone(), + ]; + + // Fork 2: drops the last user message and everything after. + // [0] user instructions, + // [1] environment context, + // [2] "first" user message, + let expected_after_second = vec![ + entries_after_three[0].clone(), + entries_after_three[1].clone(), + entries_after_three[2].clone(), + ]; + + // Fork once with n=1 → drops the last user message and everything after. + let NewConversation { + conversation: codex_fork1, + .. + } = conversation_manager + .fork_conversation(entries_after_three.clone(), 1, config_for_fork.clone()) + .await + .expect("fork 1"); + + codex_fork1.submit(Op::GetHistory).await.unwrap(); + let fork1_history = wait_for_event(&codex_fork1, |ev| { + matches!(ev, EventMsg::ConversationHistory(_)) + }) + .await; + let entries_after_first_fork = match &fork1_history { + EventMsg::ConversationHistory(ConversationHistoryResponseEvent { entries, .. }) => { + assert!(matches!( + fork1_history, + EventMsg::ConversationHistory(ConversationHistoryResponseEvent { ref entries, .. }) if *entries == expected_after_first + )); + entries.clone() + } + _ => panic!("expected ConversationHistory event after first fork"), + }; + + // Fork again with n=1 → drops the (new) last user message, leaving only the first. + let NewConversation { + conversation: codex_fork2, + .. + } = conversation_manager + .fork_conversation(entries_after_first_fork.clone(), 1, config_for_fork.clone()) + .await + .expect("fork 2"); + + codex_fork2.submit(Op::GetHistory).await.unwrap(); + let fork2_history = wait_for_event(&codex_fork2, |ev| { + matches!(ev, EventMsg::ConversationHistory(_)) + }) + .await; + assert!(matches!( + fork2_history, + EventMsg::ConversationHistory(ConversationHistoryResponseEvent { ref entries, .. }) if *entries == expected_after_second + )); +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 22aa826699..aabf83bbe3 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -5,6 +5,7 @@ mod client; mod compact; mod exec; mod exec_stream_events; +mod fork_conversation; mod live_cli; mod prompt_caching; mod seatbelt; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 999f807286..11c6ea9acf 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used)] +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; @@ -12,7 +13,6 @@ use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; use codex_core::shell::default_user_shell; -use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::wait_for_event; diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index 8d4b2c99f2..3395eef954 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::WireApi; @@ -7,7 +8,6 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::wait_for_event_with_timeout; diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index a425cfa742..6e6938b932 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -3,13 +3,13 @@ use std::time::Duration; +use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture; use core_test_support::load_sse_fixture_with_id; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 124d3390b1..d57d55bc8f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -8,6 +8,7 @@ use std::io::Read; use std::path::PathBuf; pub use cli::Cli; +use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::ConversationManager; use codex_core::NewConversation; @@ -20,7 +21,6 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::TaskCompleteEvent; -use codex_login::AuthManager; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; use event_processor_with_human_output::EventProcessorWithHumanOutput; diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index bf04a8e3ff..0a3579293e 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } +codex-core = { path = "../core" } codex-protocol = { path = "../protocol" } rand = "0.8" reqwest = { version = "0.12", features = ["json", "blocking"] } diff --git a/codex-rs/login/src/auth_manager.rs b/codex-rs/login/src/auth_manager.rs deleted file mode 100644 index 5e892b28ce..0000000000 --- a/codex-rs/login/src/auth_manager.rs +++ /dev/null @@ -1,129 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::RwLock; - -use crate::AuthMode; -use crate::CodexAuth; - -/// Internal cached auth state. -#[derive(Clone, Debug)] -struct CachedAuth { - preferred_auth_mode: AuthMode, - auth: Option, -} - -/// Central manager providing a single source of truth for auth.json derived -/// authentication data. It loads once (or on preference change) and then -/// hands out cloned `CodexAuth` values so the rest of the program has a -/// consistent snapshot. -/// -/// External modifications to `auth.json` will NOT be observed until -/// `reload()` is called explicitly. This matches the design goal of avoiding -/// different parts of the program seeing inconsistent auth data mid‑run. -#[derive(Debug)] -pub struct AuthManager { - codex_home: PathBuf, - inner: RwLock, -} - -impl AuthManager { - /// Create a new manager loading the initial auth using the provided - /// preferred auth method. Errors loading auth are swallowed; `auth()` will - /// simply return `None` in that case so callers can treat it as an - /// unauthenticated state. - pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Self { - let auth = crate::CodexAuth::from_codex_home(&codex_home, preferred_auth_mode) - .ok() - .flatten(); - Self { - codex_home, - inner: RwLock::new(CachedAuth { - preferred_auth_mode, - auth, - }), - } - } - - /// Create an AuthManager with a specific CodexAuth, for testing only. - pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { - let preferred_auth_mode = auth.mode; - let cached = CachedAuth { - preferred_auth_mode, - auth: Some(auth), - }; - Arc::new(Self { - codex_home: PathBuf::new(), - inner: RwLock::new(cached), - }) - } - - /// Current cached auth (clone). May be `None` if not logged in or load failed. - pub fn auth(&self) -> Option { - self.inner.read().ok().and_then(|c| c.auth.clone()) - } - - /// Preferred auth method used when (re)loading. - pub fn preferred_auth_method(&self) -> AuthMode { - self.inner - .read() - .map(|c| c.preferred_auth_mode) - .unwrap_or(AuthMode::ApiKey) - } - - /// Force a reload using the existing preferred auth method. Returns - /// whether the auth value changed. - pub fn reload(&self) -> bool { - let preferred = self.preferred_auth_method(); - let new_auth = crate::CodexAuth::from_codex_home(&self.codex_home, preferred) - .ok() - .flatten(); - if let Ok(mut guard) = self.inner.write() { - let changed = !AuthManager::auths_equal(&guard.auth, &new_auth); - guard.auth = new_auth; - changed - } else { - false - } - } - - fn auths_equal(a: &Option, b: &Option) -> bool { - match (a, b) { - (None, None) => true, - (Some(a), Some(b)) => a == b, - _ => false, - } - } - - /// Convenience constructor returning an `Arc` wrapper. - pub fn shared(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Arc { - Arc::new(Self::new(codex_home, preferred_auth_mode)) - } - - /// Attempt to refresh the current auth token (if any). On success, reload - /// the auth state from disk so other components observe refreshed token. - pub async fn refresh_token(&self) -> std::io::Result> { - let auth = match self.auth() { - Some(a) => a, - None => return Ok(None), - }; - match auth.refresh_token().await { - Ok(token) => { - // Reload to pick up persisted changes. - self.reload(); - Ok(Some(token)) - } - Err(e) => Err(e), - } - } - - /// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true) - /// if a file was removed, Ok(false) if no auth file existed. On success, - /// reloads the in‑memory auth cache so callers immediately observe the - /// unauthenticated state. - pub fn logout(&self) -> std::io::Result { - let removed = crate::logout(&self.codex_home)?; - // Always reload to clear any cached auth (even if file absent). - self.reload(); - Ok(removed) - } -} diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 6d5297ea09..a737af22c8 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -1,693 +1,21 @@ -use chrono::DateTime; -use chrono::Utc; -use serde::Deserialize; -use serde::Serialize; -use std::env; -use std::fs::File; -use std::fs::OpenOptions; -use std::fs::remove_file; -use std::io::Read; -use std::io::Write; -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::time::Duration; - -pub use crate::server::LoginServer; -pub use crate::server::ServerOptions; -pub use crate::server::ShutdownHandle; -pub use crate::server::run_login_server; -pub use crate::token_data::TokenData; -use crate::token_data::parse_id_token; - -mod auth_manager; mod pkce; mod server; -mod token_data; -pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; -pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; -pub use auth_manager::AuthManager; +pub use server::LoginServer; +pub use server::ServerOptions; +pub use server::ShutdownHandle; +pub use server::run_login_server; + +// Re-export commonly used auth types and helpers from codex-core for compatibility +pub use codex_core::AuthManager; +pub use codex_core::CodexAuth; +pub use codex_core::auth::AuthDotJson; +pub use codex_core::auth::CLIENT_ID; +pub use codex_core::auth::OPENAI_API_KEY_ENV_VAR; +pub use codex_core::auth::get_auth_file; +pub use codex_core::auth::login_with_api_key; +pub use codex_core::auth::logout; +pub use codex_core::auth::try_read_auth_json; +pub use codex_core::auth::write_auth_json; +pub use codex_core::token_data::TokenData; pub use codex_protocol::mcp_protocol::AuthMode; - -#[derive(Debug, Clone)] -pub struct CodexAuth { - pub mode: AuthMode, - - api_key: Option, - auth_dot_json: Arc>>, - auth_file: PathBuf, -} - -impl PartialEq for CodexAuth { - fn eq(&self, other: &Self) -> bool { - self.mode == other.mode - } -} - -impl CodexAuth { - pub fn from_api_key(api_key: &str) -> Self { - Self { - api_key: Some(api_key.to_owned()), - mode: AuthMode::ApiKey, - auth_file: PathBuf::new(), - auth_dot_json: Arc::new(Mutex::new(None)), - } - } - - pub async fn refresh_token(&self) -> Result { - let token_data = self - .get_current_token_data() - .ok_or(std::io::Error::other("Token data is not available."))?; - let token = token_data.refresh_token; - - let refresh_response = try_refresh_token(token) - .await - .map_err(std::io::Error::other)?; - - let updated = update_tokens( - &self.auth_file, - refresh_response.id_token, - refresh_response.access_token, - refresh_response.refresh_token, - ) - .await?; - - if let Ok(mut auth_lock) = self.auth_dot_json.lock() { - *auth_lock = Some(updated.clone()); - } - - let access = match updated.tokens { - Some(t) => t.access_token, - None => { - return Err(std::io::Error::other( - "Token data is not available after refresh.", - )); - } - }; - Ok(access) - } - - /// Loads the available auth information from the auth.json or - /// OPENAI_API_KEY environment variable. - pub fn from_codex_home( - codex_home: &Path, - preferred_auth_method: AuthMode, - ) -> std::io::Result> { - load_auth(codex_home, true, preferred_auth_method) - } - - pub async fn get_token_data(&self) -> Result { - let auth_dot_json: Option = self.get_current_auth_json(); - match auth_dot_json { - Some(AuthDotJson { - tokens: Some(mut tokens), - last_refresh: Some(last_refresh), - .. - }) => { - if last_refresh < Utc::now() - chrono::Duration::days(28) { - let refresh_response = tokio::time::timeout( - Duration::from_secs(60), - try_refresh_token(tokens.refresh_token.clone()), - ) - .await - .map_err(|_| { - std::io::Error::other("timed out while refreshing OpenAI API key") - })? - .map_err(std::io::Error::other)?; - - let updated_auth_dot_json = update_tokens( - &self.auth_file, - refresh_response.id_token, - refresh_response.access_token, - refresh_response.refresh_token, - ) - .await?; - - tokens = updated_auth_dot_json - .tokens - .clone() - .ok_or(std::io::Error::other( - "Token data is not available after refresh.", - ))?; - - #[expect(clippy::unwrap_used)] - let mut auth_lock = self.auth_dot_json.lock().unwrap(); - *auth_lock = Some(updated_auth_dot_json); - } - - Ok(tokens) - } - _ => Err(std::io::Error::other("Token data is not available.")), - } - } - - pub async fn get_token(&self) -> Result { - match self.mode { - AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()), - AuthMode::ChatGPT => { - let id_token = self.get_token_data().await?.access_token; - - Ok(id_token) - } - } - } - - pub fn get_account_id(&self) -> Option { - self.get_current_token_data() - .and_then(|t| t.account_id.clone()) - } - - pub fn get_plan_type(&self) -> Option { - self.get_current_token_data() - .and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string())) - } - - fn get_current_auth_json(&self) -> Option { - #[expect(clippy::unwrap_used)] - self.auth_dot_json.lock().unwrap().clone() - } - - fn get_current_token_data(&self) -> Option { - self.get_current_auth_json().and_then(|t| t.tokens.clone()) - } - - /// Consider this private to integration tests. - pub fn create_dummy_chatgpt_auth_for_testing() -> Self { - let auth_dot_json = AuthDotJson { - openai_api_key: None, - tokens: Some(TokenData { - id_token: Default::default(), - access_token: "Access Token".to_string(), - refresh_token: "test".to_string(), - account_id: Some("account_id".to_string()), - }), - last_refresh: Some(Utc::now()), - }; - - let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json))); - Self { - api_key: None, - mode: AuthMode::ChatGPT, - auth_file: PathBuf::new(), - auth_dot_json, - } - } -} - -fn load_auth( - codex_home: &Path, - include_env_var: bool, - preferred_auth_method: AuthMode, -) -> std::io::Result> { - // First, check to see if there is a valid auth.json file. If not, we fall - // back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable - // (if it is set). - let auth_file = get_auth_file(codex_home); - let auth_dot_json = match try_read_auth_json(&auth_file) { - Ok(auth) => auth, - // If auth.json does not exist, try to read the OPENAI_API_KEY from the - // environment variable. - Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => { - return match read_openai_api_key_from_env() { - Some(api_key) => Ok(Some(CodexAuth::from_api_key(&api_key))), - None => Ok(None), - }; - } - // Though if auth.json exists but is malformed, do not fall back to the - // env var because the user may be expecting to use AuthMode::ChatGPT. - Err(e) => { - return Err(e); - } - }; - - let AuthDotJson { - openai_api_key: auth_json_api_key, - tokens, - last_refresh, - } = auth_dot_json; - - // If the auth.json has an API key AND does not appear to be on a plan that - // should prefer AuthMode::ChatGPT, use AuthMode::ApiKey. - if let Some(api_key) = &auth_json_api_key { - // Should any of these be AuthMode::ChatGPT with the api_key set? - // Does AuthMode::ChatGPT indicate that there is an auth.json that is - // "refreshable" even if we are using the API key for auth? - match &tokens { - Some(tokens) => { - if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) { - return Ok(Some(CodexAuth::from_api_key(api_key))); - } else { - // Ignore the API key and fall through to ChatGPT auth. - } - } - None => { - // We have an API key but no tokens in the auth.json file. - // Perhaps the user ran `codex login --api-key ` or updated - // auth.json by hand. Either way, let's assume they are trying - // to use their API key. - return Ok(Some(CodexAuth::from_api_key(api_key))); - } - } - } - - // For the AuthMode::ChatGPT variant, perhaps neither api_key nor - // openai_api_key should exist? - Ok(Some(CodexAuth { - api_key: None, - mode: AuthMode::ChatGPT, - auth_file, - auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson { - openai_api_key: None, - tokens, - last_refresh, - }))), - })) -} - -fn read_openai_api_key_from_env() -> Option { - env::var(OPENAI_API_KEY_ENV_VAR) - .ok() - .filter(|s| !s.is_empty()) -} - -pub fn get_auth_file(codex_home: &Path) -> PathBuf { - codex_home.join("auth.json") -} - -/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)` -/// if a file was removed, `Ok(false)` if no auth file was present. -pub fn logout(codex_home: &Path) -> std::io::Result { - let auth_file = get_auth_file(codex_home); - match remove_file(&auth_file) { - Ok(_) => Ok(true), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(err) => Err(err), - } -} - -pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> { - let auth_dot_json = AuthDotJson { - openai_api_key: Some(api_key.to_string()), - tokens: None, - last_refresh: None, - }; - write_auth_json(&get_auth_file(codex_home), &auth_dot_json) -} - -/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. -/// Returns the full AuthDotJson structure after refreshing if necessary. -pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result { - let mut file = File::open(auth_file)?; - let mut contents = String::new(); - file.read_to_string(&mut contents)?; - let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; - - Ok(auth_dot_json) -} - -fn write_auth_json(auth_file: &Path, auth_dot_json: &AuthDotJson) -> std::io::Result<()> { - let json_data = serde_json::to_string_pretty(auth_dot_json)?; - let mut options = OpenOptions::new(); - options.truncate(true).write(true).create(true); - #[cfg(unix)] - { - options.mode(0o600); - } - let mut file = options.open(auth_file)?; - file.write_all(json_data.as_bytes())?; - file.flush()?; - Ok(()) -} - -async fn update_tokens( - auth_file: &Path, - id_token: String, - access_token: Option, - refresh_token: Option, -) -> std::io::Result { - let mut auth_dot_json = try_read_auth_json(auth_file)?; - - let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default); - tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?; - if let Some(access_token) = access_token { - tokens.access_token = access_token.to_string(); - } - if let Some(refresh_token) = refresh_token { - tokens.refresh_token = refresh_token.to_string(); - } - auth_dot_json.last_refresh = Some(Utc::now()); - write_auth_json(auth_file, &auth_dot_json)?; - Ok(auth_dot_json) -} - -async fn try_refresh_token(refresh_token: String) -> std::io::Result { - let refresh_request = RefreshRequest { - client_id: CLIENT_ID, - grant_type: "refresh_token", - refresh_token, - scope: "openid profile email", - }; - - let client = reqwest::Client::new(); - let response = client - .post("https://auth.openai.com/oauth/token") - .header("Content-Type", "application/json") - .json(&refresh_request) - .send() - .await - .map_err(std::io::Error::other)?; - - if response.status().is_success() { - let refresh_response = response - .json::() - .await - .map_err(std::io::Error::other)?; - Ok(refresh_response) - } else { - Err(std::io::Error::other(format!( - "Failed to refresh token: {}", - response.status() - ))) - } -} - -#[derive(Serialize)] -struct RefreshRequest { - client_id: &'static str, - grant_type: &'static str, - refresh_token: String, - scope: &'static str, -} - -#[derive(Deserialize, Clone)] -struct RefreshResponse { - id_token: String, - access_token: Option, - refresh_token: Option, -} - -/// Expected structure for $CODEX_HOME/auth.json. -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] -pub struct AuthDotJson { - #[serde(rename = "OPENAI_API_KEY")] - pub openai_api_key: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tokens: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_refresh: Option>, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::token_data::IdTokenInfo; - use crate::token_data::KnownPlan; - use crate::token_data::PlanType; - use base64::Engine; - use pretty_assertions::assert_eq; - use serde_json::json; - use tempfile::tempdir; - - const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z"; - - #[test] - fn writes_api_key_and_loads_auth() { - let dir = tempdir().unwrap(); - login_with_api_key(dir.path(), "sk-test-key").unwrap(); - let auth = load_auth(dir.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(auth.mode, AuthMode::ApiKey); - assert_eq!(auth.api_key.as_deref(), Some("sk-test-key")); - } - - #[test] - fn loads_from_env_var_if_env_var_exists() { - let dir = tempdir().unwrap(); - - let env_var = std::env::var(OPENAI_API_KEY_ENV_VAR); - - if let Ok(env_var) = env_var { - let auth = load_auth(dir.path(), true, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(auth.mode, AuthMode::ApiKey); - assert_eq!(auth.api_key, Some(env_var)); - } - } - - #[tokio::test] - async fn roundtrip_auth_dot_json() { - let codex_home = tempdir().unwrap(); - write_auth_file( - AuthFileParams { - openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let file = get_auth_file(codex_home.path()); - let auth_dot_json = try_read_auth_json(&file).unwrap(); - write_auth_json(&file, &auth_dot_json).unwrap(); - - let same_auth_dot_json = try_read_auth_json(&file).unwrap(); - assert_eq!(auth_dot_json, same_auth_dot_json); - } - - #[tokio::test] - async fn pro_account_with_no_api_key_uses_chatgpt_auth() { - let codex_home = tempdir().unwrap(); - let fake_jwt = write_auth_file( - AuthFileParams { - openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let CodexAuth { - api_key, - mode, - auth_dot_json, - auth_file: _, - } = load_auth(codex_home.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(None, api_key); - assert_eq!(AuthMode::ChatGPT, mode); - - let guard = auth_dot_json.lock().unwrap(); - let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); - assert_eq!( - &AuthDotJson { - openai_api_key: None, - tokens: Some(TokenData { - id_token: IdTokenInfo { - email: Some("user@example.com".to_string()), - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), - raw_jwt: fake_jwt, - }, - access_token: "test-access-token".to_string(), - refresh_token: "test-refresh-token".to_string(), - account_id: None, - }), - last_refresh: Some( - DateTime::parse_from_rfc3339(LAST_REFRESH) - .unwrap() - .with_timezone(&Utc) - ), - }, - auth_dot_json - ) - } - - /// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in - /// [`TokenData::is_plan_that_should_use_api_key`], it should use - /// [`AuthMode::ChatGPT`]. - #[tokio::test] - async fn pro_account_with_api_key_still_uses_chatgpt_auth() { - let codex_home = tempdir().unwrap(); - let fake_jwt = write_auth_file( - AuthFileParams { - openai_api_key: Some("sk-test-key".to_string()), - chatgpt_plan_type: "pro".to_string(), - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let CodexAuth { - api_key, - mode, - auth_dot_json, - auth_file: _, - } = load_auth(codex_home.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(None, api_key); - assert_eq!(AuthMode::ChatGPT, mode); - - let guard = auth_dot_json.lock().unwrap(); - let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); - assert_eq!( - &AuthDotJson { - openai_api_key: None, - tokens: Some(TokenData { - id_token: IdTokenInfo { - email: Some("user@example.com".to_string()), - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), - raw_jwt: fake_jwt, - }, - access_token: "test-access-token".to_string(), - refresh_token: "test-refresh-token".to_string(), - account_id: None, - }), - last_refresh: Some( - DateTime::parse_from_rfc3339(LAST_REFRESH) - .unwrap() - .with_timezone(&Utc) - ), - }, - auth_dot_json - ) - } - - /// If the OPENAI_API_KEY is set in auth.json and it is an enterprise - /// account, then it should use [`AuthMode::ApiKey`]. - #[tokio::test] - async fn enterprise_account_with_api_key_uses_chatgpt_auth() { - let codex_home = tempdir().unwrap(); - write_auth_file( - AuthFileParams { - openai_api_key: Some("sk-test-key".to_string()), - chatgpt_plan_type: "enterprise".to_string(), - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let CodexAuth { - api_key, - mode, - auth_dot_json, - auth_file: _, - } = load_auth(codex_home.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(Some("sk-test-key".to_string()), api_key); - assert_eq!(AuthMode::ApiKey, mode); - - let guard = auth_dot_json.lock().expect("should unwrap"); - assert!(guard.is_none(), "auth_dot_json should be None"); - } - - struct AuthFileParams { - openai_api_key: Option, - chatgpt_plan_type: String, - } - - fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result { - let auth_file = get_auth_file(codex_home); - // Create a minimal valid JWT for the id_token field. - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = serde_json::json!({ - "email": "user@example.com", - "email_verified": true, - "https://api.openai.com/auth": { - "chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53", - "chatgpt_plan_type": params.chatgpt_plan_type, - "chatgpt_user_id": "user-12345", - "user_id": "user-12345", - } - }); - let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); - let header_b64 = b64(&serde_json::to_vec(&header)?); - let payload_b64 = b64(&serde_json::to_vec(&payload)?); - let signature_b64 = b64(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let auth_json_data = json!({ - "OPENAI_API_KEY": params.openai_api_key, - "tokens": { - "id_token": fake_jwt, - "access_token": "test-access-token", - "refresh_token": "test-refresh-token" - }, - "last_refresh": LAST_REFRESH, - }); - let auth_json = serde_json::to_string_pretty(&auth_json_data)?; - std::fs::write(auth_file, auth_json)?; - - Ok(fake_jwt) - } - - #[test] - fn id_token_info_handles_missing_fields() { - // Payload without email or plan should yield None values. - let header = serde_json::json!({"alg": "none", "typ": "JWT"}); - let payload = serde_json::json!({"sub": "123"}); - let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&header).unwrap()); - let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&payload).unwrap()); - let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"sig"); - let jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); - - let info = parse_id_token(&jwt).expect("should parse"); - assert!(info.email.is_none()); - assert!(info.chatgpt_plan_type.is_none()); - } - - #[tokio::test] - async fn loads_api_key_from_auth_json() { - let dir = tempdir().unwrap(); - let auth_file = dir.path().join("auth.json"); - std::fs::write( - auth_file, - r#" - { - "OPENAI_API_KEY": "sk-test-key", - "tokens": null, - "last_refresh": null - } - "#, - ) - .unwrap(); - - let auth = load_auth(dir.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(auth.mode, AuthMode::ApiKey); - assert_eq!(auth.api_key, Some("sk-test-key".to_string())); - - assert!(auth.get_token_data().await.is_err()); - } - - #[test] - fn logout_removes_auth_file() -> Result<(), std::io::Error> { - let dir = tempdir()?; - login_with_api_key(dir.path(), "sk-test-key")?; - assert!(dir.path().join("auth.json").exists()); - let removed = logout(dir.path())?; - assert!(removed); - assert!(!dir.path().join("auth.json").exists()); - Ok(()) - } -} diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 6ffc86219d..5cc08d4205 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -5,12 +5,14 @@ use std::path::PathBuf; use std::sync::Arc; use std::thread; -use crate::AuthDotJson; -use crate::get_auth_file; use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; use base64::Engine; use chrono::Utc; +use codex_core::auth::AuthDotJson; +use codex_core::auth::get_auth_file; +use codex_core::token_data::TokenData; +use codex_core::token_data::parse_id_token; use rand::RngCore; use tiny_http::Header; use tiny_http::Request; @@ -374,10 +376,8 @@ async fn persist_tokens_async( if let Some(key) = api_key { auth.openai_api_key = Some(key); } - let tokens = auth - .tokens - .get_or_insert_with(crate::token_data::TokenData::default); - tokens.id_token = crate::token_data::parse_id_token(&id_token).map_err(io::Error::other)?; + let tokens = auth.tokens.get_or_insert_with(TokenData::default); + tokens.id_token = parse_id_token(&id_token).map_err(io::Error::other)?; // Persist chatgpt_account_id if present in claims if let Some(acc) = jwt_auth_claims(&id_token) .get("chatgpt_account_id") @@ -392,14 +392,14 @@ async fn persist_tokens_async( tokens.refresh_token = rt; } auth.last_refresh = Some(Utc::now()); - super::write_auth_json(&auth_file, &auth) + codex_core::auth::write_auth_json(&auth_file, &auth) }) .await .map_err(|e| io::Error::other(format!("persist task failed: {e}")))? } fn read_or_default(path: &Path) -> AuthDotJson { - match super::try_read_auth_json(path) { + match codex_core::auth::try_read_auth_json(path) { Ok(auth) => auth, Err(_) => AuthDotJson { openai_api_key: None, diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index aae463ad92..72474d9866 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use codex_core::AuthManager; use codex_core::CodexConversation; use codex_core::ConversationManager; use codex_core::NewConversation; @@ -16,7 +17,6 @@ use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ReviewDecision; -use codex_login::AuthManager; use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::GitDiffToRemoteResponse; use mcp_types::JSONRPCErrorError; @@ -31,9 +31,9 @@ use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::json_to_toml::json_to_toml; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; +use codex_core::auth::CLIENT_ID; use codex_core::protocol::InputItem as CoreInputItem; use codex_core::protocol::Op; -use codex_login::CLIENT_ID; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 6be60151d2..dbd1077d13 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -10,10 +10,10 @@ use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::outgoing_message::OutgoingMessageSender; use codex_protocol::mcp_protocol::ClientRequest; +use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::protocol::Submission; -use codex_login::AuthManager; use mcp_types::CallToolRequestParams; use mcp_types::CallToolResult; use mcp_types::ClientRequest as McpClientRequest; diff --git a/codex-rs/mcp-server/tests/suite/auth.rs b/codex-rs/mcp-server/tests/suite/auth.rs index 533cb90313..3bf972842a 100644 --- a/codex-rs/mcp-server/tests/suite/auth.rs +++ b/codex-rs/mcp-server/tests/suite/auth.rs @@ -1,6 +1,6 @@ use std::path::Path; -use codex_login::login_with_api_key; +use codex_core::auth::login_with_api_key; use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::GetAuthStatusParams; use codex_protocol::mcp_protocol::GetAuthStatusResponse; diff --git a/codex-rs/mcp-server/tests/suite/login.rs b/codex-rs/mcp-server/tests/suite/login.rs index 7a796c01a0..ba5021c1fa 100644 --- a/codex-rs/mcp-server/tests/suite/login.rs +++ b/codex-rs/mcp-server/tests/suite/login.rs @@ -1,7 +1,7 @@ use std::path::Path; use std::time::Duration; -use codex_login::login_with_api_key; +use codex_core::auth::login_with_api_key; use codex_protocol::mcp_protocol::CancelLoginChatGptParams; use codex_protocol::mcp_protocol::CancelLoginChatGptResponse; use codex_protocol::mcp_protocol::GetAuthStatusParams; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5a74b4582c..439dbcdfab 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -7,10 +7,10 @@ use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; use codex_ansi_escape::ansi_escape_line; +use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::protocol::TokenUsage; -use codex_login::AuthManager; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; diff --git a/codex-rs/tui/src/backtrack_helpers.rs b/codex-rs/tui/src/backtrack_helpers.rs index c275519c20..b006ae38eb 100644 --- a/codex-rs/tui/src/backtrack_helpers.rs +++ b/codex-rs/tui/src/backtrack_helpers.rs @@ -113,10 +113,9 @@ fn highlight_range_from_header(lines: &[Line<'_>], header_idx: usize) -> (usize, #[cfg(test)] mod tests { use super::*; - use ratatui::text::Span; fn line(s: &str) -> Line<'static> { - Line::from(Span::raw(s.to_string())) + s.to_string().into() } fn transcript_with_users(count: usize) -> Vec> { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index fc44d5efad..c873ac8d55 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1262,9 +1262,9 @@ impl WidgetRef for ChatComposer { let key_hint_style = Style::default().fg(Color::Cyan); let mut hint = if self.ctrl_c_quit_hint { vec![ - Span::from(" "), + " ".into(), "Ctrl+C again".set_style(key_hint_style), - Span::from(" to quit"), + " to quit".into(), ] } else { let newline_hint_key = if self.use_shift_enter_hint { @@ -1273,28 +1273,28 @@ impl WidgetRef for ChatComposer { "Ctrl+J" }; vec![ - Span::from(" "), + " ".into(), "⏎".set_style(key_hint_style), - Span::from(" send "), + " send ".into(), newline_hint_key.set_style(key_hint_style), - Span::from(" newline "), + " newline ".into(), "Ctrl+T".set_style(key_hint_style), - Span::from(" transcript "), + " transcript ".into(), "Ctrl+C".set_style(key_hint_style), - Span::from(" quit"), + " quit".into(), ] }; if !self.ctrl_c_quit_hint && self.esc_backtrack_hint { - hint.push(Span::from(" ")); + hint.push(" ".into()); hint.push("Esc".set_style(key_hint_style)); - hint.push(Span::from(" edit prev")); + hint.push(" edit prev".into()); } // Append token/context usage info to the footer hints when available. if let Some(token_usage_info) = &self.token_usage_info { let token_usage = &token_usage_info.total_token_usage; - hint.push(Span::from(" ")); + hint.push(" ".into()); hint.push( Span::from(format!("{} tokens used", token_usage.blended_total())) .style(Style::default().add_modifier(Modifier::DIM)), @@ -1309,7 +1309,7 @@ impl WidgetRef for ChatComposer { } else { 100 }; - hint.push(Span::from(" ")); + hint.push(" ".into()); hint.push( Span::from(format!("{percent_remaining}% context left")) .style(Style::default().add_modifier(Modifier::DIM)), @@ -2042,7 +2042,7 @@ mod tests { composer.handle_paste(paste.clone()); composer .textarea - .set_cursor((placeholder.len() - pos_from_end) as usize); + .set_cursor(placeholder.len() - pos_from_end); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); let result = ( composer.textarea.text().contains(&placeholder), diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 4a0f3c13ff..a5961e3188 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -195,7 +195,15 @@ impl WidgetRef for CommandPopup { }) .collect() }; - render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS, false); + render_rows( + area, + buf, + &rows_all, + &self.state, + MAX_POPUP_ROWS, + false, + "no matches", + ); } } diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index f046a2f144..65dfe6933b 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -132,11 +132,20 @@ impl WidgetRef for &FileSearchPopup { .collect() }; - if self.waiting && rows_all.is_empty() { - // Render a minimal waiting stub using the shared renderer (no rows -> "no matches"). - render_rows(area, buf, &[], &self.state, MAX_POPUP_ROWS, false); + let empty_message = if self.waiting { + "loading..." } else { - render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS, false); - } + "no matches" + }; + + render_rows( + area, + buf, + &rows_all, + &self.state, + MAX_POPUP_ROWS, + false, + empty_message, + ); } } diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 74b5274f26..357639693b 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -3,8 +3,7 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::style::Modifier; -use ratatui::style::Style; +use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Paragraph; @@ -42,7 +41,7 @@ pub(crate) struct ListSelectionView { impl ListSelectionView { fn dim_prefix_span() -> Span<'static> { - Span::styled("▌ ", Style::default().add_modifier(Modifier::DIM)) + "▌ ".dim() } fn render_dim_prefix_line(area: Rect, buf: &mut Buffer) { @@ -162,13 +161,8 @@ impl BottomPaneView for ListSelectionView { height: 1, }; - let title_spans: Vec> = vec![ - Self::dim_prefix_span(), - Span::styled( - self.title.clone(), - Style::default().add_modifier(Modifier::BOLD), - ), - ]; + let title_spans: Vec> = + vec![Self::dim_prefix_span(), self.title.clone().bold()]; let title_para = Paragraph::new(Line::from(title_spans)); title_para.render(title_area, buf); @@ -180,10 +174,8 @@ impl BottomPaneView for ListSelectionView { width: area.width, height: 1, }; - let subtitle_spans: Vec> = vec![ - Self::dim_prefix_span(), - Span::styled(sub.clone(), Style::default().add_modifier(Modifier::DIM)), - ]; + let subtitle_spans: Vec> = + vec![Self::dim_prefix_span(), sub.clone().dim()]; let subtitle_para = Paragraph::new(Line::from(subtitle_spans)); subtitle_para.render(subtitle_area, buf); // Render the extra spacer line with the dimmed prefix to align with title/subtitle @@ -230,7 +222,15 @@ impl BottomPaneView for ListSelectionView { }) .collect(); if rows_area.height > 0 { - render_rows(rows_area, buf, &rows, &self.state, MAX_POPUP_ROWS, true); + render_rows( + rows_area, + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + true, + "no matches", + ); } if let Some(hint) = &self.footer_hint { @@ -240,10 +240,7 @@ impl BottomPaneView for ListSelectionView { width: area.width, height: 1, }; - let footer_para = Paragraph::new(Line::from(Span::styled( - hint.clone(), - Style::default().add_modifier(Modifier::DIM), - ))); + let footer_para = Paragraph::new(hint.clone().dim()); footer_para.render(footer_area, buf); } } diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index a83ec15182..3852d10bc1 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -4,6 +4,7 @@ use ratatui::prelude::Constraint; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; +use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Block; @@ -35,13 +36,13 @@ pub(crate) fn render_rows( state: &ScrollState, max_results: usize, _dim_non_selected: bool, + empty_message: &str, ) { let mut rows: Vec = Vec::new(); if rows_all.is_empty() { - rows.push(Row::new(vec![Cell::from(Line::from(Span::styled( - "no matches", - Style::default().add_modifier(Modifier::ITALIC | Modifier::DIM), - )))])); + rows.push(Row::new(vec![Cell::from(Line::from( + empty_message.dim().italic(), + ))])); } else { let max_rows_from_area = area.height as usize; let visible_rows = max_results @@ -79,23 +80,20 @@ pub(crate) fn render_rows( if let Some(idxs) = match_indices.as_ref() { let mut idx_iter = idxs.iter().peekable(); for (char_idx, ch) in name.chars().enumerate() { - let mut style = Style::default(); if idx_iter.peek().is_some_and(|next| **next == char_idx) { idx_iter.next(); - style = style.add_modifier(Modifier::BOLD); + spans.push(ch.to_string().bold()); + } else { + spans.push(ch.to_string().into()); } - spans.push(Span::styled(ch.to_string(), style)); } } else { - spans.push(Span::raw(name.clone())); + spans.push(name.clone().into()); } if let Some(desc) = description.as_ref() { - spans.push(Span::raw(" ")); - spans.push(Span::styled( - desc.clone(), - Style::default().add_modifier(Modifier::DIM), - )); + spans.push(" ".into()); + spans.push(desc.clone().dim()); } let mut cell = Cell::from(Line::from(spans)); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 75bf3ebd71..f86658a8aa 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -191,10 +191,12 @@ impl ChatWidget { // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); if !self.full_reasoning_buffer.is_empty() { - self.add_to_history(history_cell::new_reasoning_block( + for cell in history_cell::new_reasoning_summary_block( self.full_reasoning_buffer.clone(), &self.config, - )); + ) { + self.add_boxed_history(cell); + } } self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); @@ -798,7 +800,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::ExitRequest); } SlashCommand::Logout => { - if let Err(e) = codex_login::logout(&self.config.codex_home) { + if let Err(e) = codex_core::auth::logout(&self.config.codex_home) { tracing::error!("failed to logout: {e}"); } self.app_event_tx.send(AppEvent::ExitRequest); @@ -900,16 +902,14 @@ impl ChatWidget { } fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { + self.add_boxed_history(Box::new(cell)); + } + + fn add_boxed_history(&mut self, cell: Box) { if !cell.display_lines(u16::MAX).is_empty() { // Only break exec grouping if the cell renders visible lines. self.flush_active_exec_cell(); } - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(cell))); - } - - fn add_boxed_history(&mut self, cell: Box) { - self.flush_active_exec_cell(); self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 4ad82c75c5..3ca1b84142 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -23,7 +24,6 @@ use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskStartedEvent; -use codex_login::CodexAuth; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index db4609ca8f..dea72436cb 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -8,6 +8,8 @@ use base64::Engine; use codex_ansi_escape::ansi_escape_line; use codex_common::create_config_summary_entries; use codex_common::elapsed::format_duration; +use codex_core::auth::get_auth_file; +use codex_core::auth::try_read_auth_json; use codex_core::config::Config; use codex_core::plan_tool::PlanItemArg; use codex_core::plan_tool::StepStatus; @@ -18,8 +20,6 @@ use codex_core::protocol::McpInvocation; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TokenUsage; -use codex_login::get_auth_file; -use codex_login::try_read_auth_json; use codex_protocol::parse_command::ParsedCommand; use image::DynamicImage; use image::ImageReader; @@ -27,7 +27,6 @@ use itertools::Itertools; use mcp_types::EmbeddedResourceResource; use mcp_types::ResourceLink; use ratatui::prelude::*; -use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Styled; @@ -101,15 +100,15 @@ impl HistoryCell for UserHistoryCell { ); for line in wrapped { - lines.push(Line::from(vec!["▌".cyan().dim(), line.to_string().dim()])); + lines.push(vec!["▌".cyan().dim(), line.to_string().dim()].into()); } lines } fn transcript_lines(&self) -> Vec> { let mut lines: Vec> = Vec::new(); - lines.push(Line::from("user".cyan().bold())); - lines.extend(self.message.lines().map(|l| Line::from(l.to_string()))); + lines.push("user".cyan().bold().into()); + lines.extend(self.message.lines().map(|l| l.to_string().into())); lines } } @@ -148,7 +147,7 @@ impl HistoryCell for AgentMessageCell { " ".into() }); spans.extend(piece.spans.into_iter()); - out.push(Line::from(spans)); + out.push(spans.into()); } is_first_visual = false; } @@ -158,7 +157,7 @@ impl HistoryCell for AgentMessageCell { fn transcript_lines(&self) -> Vec> { let mut out: Vec> = Vec::new(); if self.is_first_line { - out.push(Line::from("codex".magenta().bold())); + out.push("codex".magenta().bold().into()); } out.extend(self.lines.clone()); out @@ -242,9 +241,9 @@ impl HistoryCell for ExecCell { let cmd_display = strip_bash_lc_and_escape(&call.command); for (i, part) in cmd_display.lines().enumerate() { if i == 0 { - lines.push(Line::from(vec!["$ ".magenta(), part.to_string().into()])); + lines.push(vec!["$ ".magenta(), part.to_string().into()].into()); } else { - lines.push(Line::from(vec![" ".into(), part.to_string().into()])); + lines.push(vec![" ".into(), part.to_string().into()].into()); } } @@ -254,7 +253,7 @@ impl HistoryCell for ExecCell { .duration .map(format_duration) .unwrap_or_else(|| "unknown".to_string()); - let mut result = if output.exit_code == 0 { + let mut result: Line = if output.exit_code == 0 { Line::from("✓".green().bold()) } else { Line::from(vec![ @@ -373,7 +372,7 @@ impl ExecCell { for (title, line) in call_lines { let prefix_len = 4 + title.len() + 1; // " └ " + title + " " let wrapped = crate::insert_history::word_wrap_lines( - &[Line::from(line)], + &[line.into()], width.saturating_sub(prefix_len as u16), ); let mut first_sub = true; @@ -437,7 +436,7 @@ impl ExecCell { ])); } else { branch_consumed = true; - lines.push(Line::from(vec![bullet, " ".into(), title.bold()])); + lines.push(vec![bullet, " ".into(), title.bold()].into()); // Wrap the command line. for (i, line) in cmd_display.lines().enumerate() { @@ -450,9 +449,9 @@ impl ExecCell { ); lines.extend(wrapped.into_iter().enumerate().map(|(j, l)| { if i == 0 && j == 0 { - Line::from(vec![" └ ".dim(), l[4..].to_string().into()]) + vec![" └ ".dim(), l[4..].to_string().into()].into() } else { - Line::from(l.to_string()) + l.to_string().into() } })); } @@ -604,7 +603,7 @@ struct CompletedMcpToolCallWithImageOutput { } impl HistoryCell for CompletedMcpToolCallWithImageOutput { fn display_lines(&self, _width: u16) -> Vec> { - vec![Line::from("tool result (image output omitted)")] + vec!["tool result (image output omitted)".into()] } } @@ -696,9 +695,9 @@ pub(crate) fn new_session_info( PlainHistoryCell { lines: Vec::new() } } else { let lines = vec![ - Line::from("model changed:".magenta().bold()), - Line::from(format!("requested: {}", config.model)), - Line::from(format!("used: {model}")), + "model changed:".magenta().bold().into(), + format!("requested: {}", config.model).into(), + format!("used: {model}").into(), ]; PlainHistoryCell { lines } } @@ -854,13 +853,7 @@ pub(crate) fn new_completed_mcp_tool_call( } } Err(e) => { - lines.push(Line::from(vec![ - Span::styled( - "Error: ", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ), - Span::raw(e), - ])); + lines.push(vec!["Error: ".red().bold(), e.into()].into()); } }; @@ -873,7 +866,7 @@ pub(crate) fn new_status_output( session_id: &Option, ) -> PlainHistoryCell { let mut lines: Vec> = Vec::new(); - lines.push(Line::from("/status".magenta())); + lines.push("/status".magenta().into()); let config_entries = create_config_summary_entries(config); let lookup = |k: &str| -> String { @@ -885,10 +878,7 @@ pub(crate) fn new_status_output( }; // 📂 Workspace - lines.push(Line::from(vec![ - padded_emoji("📂").into(), - "Workspace".bold(), - ])); + lines.push(vec![padded_emoji("📂").into(), "Workspace".bold()].into()); // Path (home-relative, e.g., ~/code/project) let cwd_str = match relativize_to_home(&config.cwd) { Some(rel) if !rel.as_os_str().is_empty() => { @@ -898,22 +888,16 @@ pub(crate) fn new_status_output( Some(_) => "~".to_string(), None => config.cwd.display().to_string(), }; - lines.push(Line::from(vec![" • Path: ".into(), cwd_str.into()])); + lines.push(vec![" • Path: ".into(), cwd_str.into()].into()); // Approval mode (as-is) - lines.push(Line::from(vec![ - " • Approval Mode: ".into(), - lookup("approval").into(), - ])); + lines.push(vec![" • Approval Mode: ".into(), lookup("approval").into()].into()); // Sandbox (simplified name only) let sandbox_name = match &config.sandbox_policy { SandboxPolicy::DangerFullAccess => "danger-full-access", SandboxPolicy::ReadOnly => "read-only", SandboxPolicy::WorkspaceWrite { .. } => "workspace-write", }; - lines.push(Line::from(vec![ - " • Sandbox: ".into(), - sandbox_name.into(), - ])); + lines.push(vec![" • Sandbox: ".into(), sandbox_name.into()].into()); // AGENTS.md files discovered via core's project_doc logic let agents_list = { @@ -956,85 +940,62 @@ pub(crate) fn new_status_output( } }; if agents_list.is_empty() { - lines.push(Line::from(" • AGENTS files: (none)")); + lines.push(" • AGENTS files: (none)".into()); } else { - lines.push(Line::from(vec![ - " • AGENTS files: ".into(), - agents_list.join(", ").into(), - ])); + lines.push(vec![" • AGENTS files: ".into(), agents_list.join(", ").into()].into()); } - lines.push(Line::from("")); + lines.push("".into()); // 👤 Account (only if ChatGPT tokens exist), shown under the first block let auth_file = get_auth_file(&config.codex_home); if let Ok(auth) = try_read_auth_json(&auth_file) && let Some(tokens) = auth.tokens.clone() { - lines.push(Line::from(vec![ - padded_emoji("👤").into(), - "Account".bold(), - ])); - lines.push(Line::from(" • Signed in with ChatGPT")); + lines.push(vec![padded_emoji("👤").into(), "Account".bold()].into()); + lines.push(" • Signed in with ChatGPT".into()); let info = tokens.id_token; if let Some(email) = &info.email { - lines.push(Line::from(vec![" • Login: ".into(), email.clone().into()])); + lines.push(vec![" • Login: ".into(), email.clone().into()].into()); } match auth.openai_api_key.as_deref() { Some(key) if !key.is_empty() => { - lines.push(Line::from( - " • Using API key. Run codex login to use ChatGPT plan", - )); + lines.push(" • Using API key. Run codex login to use ChatGPT plan".into()); } _ => { let plan_text = info .get_chatgpt_plan_type() .map(|s| title_case(&s)) .unwrap_or_else(|| "Unknown".to_string()); - lines.push(Line::from(vec![" • Plan: ".into(), plan_text.into()])); + lines.push(vec![" • Plan: ".into(), plan_text.into()].into()); } } - lines.push(Line::from("")); + lines.push("".into()); } // 🧠 Model - lines.push(Line::from(vec![padded_emoji("🧠").into(), "Model".bold()])); - lines.push(Line::from(vec![ - " • Name: ".into(), - config.model.clone().into(), - ])); + lines.push(vec![padded_emoji("🧠").into(), "Model".bold()].into()); + lines.push(vec![" • Name: ".into(), config.model.clone().into()].into()); let provider_disp = pretty_provider_name(&config.model_provider_id); - lines.push(Line::from(vec![ - " • Provider: ".into(), - provider_disp.into(), - ])); + lines.push(vec![" • Provider: ".into(), provider_disp.into()].into()); // Only show Reasoning fields if present in config summary let reff = lookup("reasoning effort"); if !reff.is_empty() { - lines.push(Line::from(vec![ - " • Reasoning Effort: ".into(), - title_case(&reff).into(), - ])); + lines.push(vec![" • Reasoning Effort: ".into(), title_case(&reff).into()].into()); } let rsum = lookup("reasoning summaries"); if !rsum.is_empty() { - lines.push(Line::from(vec![ - " • Reasoning Summaries: ".into(), - title_case(&rsum).into(), - ])); + lines.push(vec![" • Reasoning Summaries: ".into(), title_case(&rsum).into()].into()); } - lines.push(Line::from("")); + lines.push("".into()); // 📊 Token Usage - lines.push(Line::from(vec!["📊 ".into(), "Token Usage".bold()])); + lines.push(vec!["📊 ".into(), "Token Usage".bold()].into()); if let Some(session_id) = session_id { - lines.push(Line::from(vec![ - " • Session ID: ".into(), - session_id.to_string().into(), - ])); + lines.push(vec![" • Session ID: ".into(), session_id.to_string().into()].into()); } // Input: [+ cached] let mut input_line_spans: Vec> = vec![ @@ -1064,17 +1025,14 @@ pub(crate) fn new_status_output( /// Render a summary of configured MCP servers from the current `Config`. pub(crate) fn empty_mcp_output() -> PlainHistoryCell { let lines: Vec> = vec![ - Line::from("/mcp".magenta()), - Line::from(""), - Line::from(vec!["🔌 ".into(), "MCP Tools".bold()]), - Line::from(""), - Line::from(" • No MCP servers configured.".italic()), + "/mcp".magenta().into(), + "".into(), + vec!["🔌 ".into(), "MCP Tools".bold()].into(), + "".into(), + " • No MCP servers configured.".italic().into(), Line::from(vec![ " See the ".into(), - Span::styled( - "\u{1b}]8;;https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}", - Style::default().add_modifier(Modifier::UNDERLINED), - ), + "\u{1b}]8;;https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}".underlined(), " to configure them.".into(), ]) .style(Style::default().add_modifier(Modifier::DIM)), @@ -1089,15 +1047,15 @@ pub(crate) fn new_mcp_tools_output( tools: std::collections::HashMap, ) -> PlainHistoryCell { let mut lines: Vec> = vec![ - Line::from("/mcp".magenta()), - Line::from(""), - Line::from(vec!["🔌 ".into(), "MCP Tools".bold()]), - Line::from(""), + "/mcp".magenta().into(), + "".into(), + vec!["🔌 ".into(), "MCP Tools".bold()].into(), + "".into(), ]; if tools.is_empty() { - lines.push(Line::from(" • No MCP tools available.".italic())); - lines.push(Line::from("")); + lines.push(" • No MCP tools available.".italic().into()); + lines.push("".into()); return PlainHistoryCell { lines }; } @@ -1110,18 +1068,12 @@ pub(crate) fn new_mcp_tools_output( .collect(); names.sort(); - lines.push(Line::from(vec![ - " • Server: ".into(), - server.clone().into(), - ])); + lines.push(vec![" • Server: ".into(), server.clone().into()].into()); if !cfg.command.is_empty() { let cmd_display = format!("{} {}", cfg.command, cfg.args.join(" ")); - lines.push(Line::from(vec![ - " • Command: ".into(), - cmd_display.into(), - ])); + lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); } if let Some(env) = cfg.env.as_ref() @@ -1129,19 +1081,13 @@ pub(crate) fn new_mcp_tools_output( { let mut env_pairs: Vec = env.iter().map(|(k, v)| format!("{k}={v}")).collect(); env_pairs.sort(); - lines.push(Line::from(vec![ - " • Env: ".into(), - env_pairs.join(" ").into(), - ])); + lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into()); } if names.is_empty() { - lines.push(Line::from(" • Tools: (none)")); + lines.push(" • Tools: (none)".into()); } else { - lines.push(Line::from(vec![ - " • Tools: ".into(), - names.join(", ").into(), - ])); + lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into()); } lines.push(Line::from("")); } @@ -1301,6 +1247,44 @@ pub(crate) fn new_reasoning_block( TranscriptOnlyHistoryCell { lines } } +pub(crate) fn new_reasoning_summary_block( + full_reasoning_buffer: String, + config: &Config, +) -> Vec> { + if config.use_experimental_reasoning_summary { + // Experimental format is following: + // ** header ** + // + // reasoning summary + // + // So we need to strip header from reasoning summary + if let Some(open) = full_reasoning_buffer.find("**") { + let after_open = &full_reasoning_buffer[(open + 2)..]; + if let Some(close) = after_open.find("**") { + let after_close_idx = open + 2 + close + 2; + let header_buffer = full_reasoning_buffer[..after_close_idx].to_string(); + let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string(); + + let mut header_lines: Vec> = Vec::new(); + header_lines.push(Line::from("Thinking".magenta().italic())); + append_markdown(&header_buffer, &mut header_lines, config); + + let mut summary_lines: Vec> = Vec::new(); + summary_lines.push(Line::from("Thinking".magenta().bold())); + append_markdown(&summary_buffer, &mut summary_lines, config); + + return vec![ + Box::new(TranscriptOnlyHistoryCell { + lines: header_lines, + }), + Box::new(AgentMessageCell::new(summary_lines, true)), + ]; + } + } + } + vec![Box::new(new_reasoning_block(full_reasoning_buffer, config))] +} + fn output_lines( output: Option<&CommandOutput>, only_err: bool, @@ -1346,7 +1330,7 @@ fn output_lines( let show_ellipsis = total > 2 * limit; if show_ellipsis { let omitted = total - 2 * limit; - out.push(Line::from(format!("… +{omitted} lines"))); + out.push(format!("… +{omitted} lines").into()); } let tail_start = if show_ellipsis { @@ -1379,14 +1363,14 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { .unwrap_or_default(); let invocation_spans = vec![ - Span::styled(invocation.server.clone(), Style::default().fg(Color::Cyan)), - Span::raw("."), - Span::styled(invocation.tool.clone(), Style::default().fg(Color::Cyan)), - Span::raw("("), - Span::styled(args_str, Style::default().add_modifier(Modifier::DIM)), - Span::raw(")"), + invocation.server.clone().cyan(), + ".".into(), + invocation.tool.clone().cyan(), + "(".into(), + args_str.dim(), + ")".into(), ]; - Line::from(invocation_spans) + invocation_spans.into() } #[cfg(test)] diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 2c35c877ac..1ecbd3448b 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -419,7 +419,7 @@ mod tests { #[test] fn line_height_counts_double_width_emoji() { - let line = Line::from("😀😀😀"); // each emoji ~ width 2 + let line = "😀😀😀".into(); // each emoji ~ width 2 assert_eq!(word_wrap_line(&line, 4).len(), 2); assert_eq!(word_wrap_line(&line, 2).len(), 3); assert_eq!(word_wrap_line(&line, 6).len(), 1); @@ -428,7 +428,7 @@ mod tests { #[test] fn word_wrap_does_not_split_words_simple_english() { let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them."; - let line = Line::from(sample); + let line = sample.into(); // Force small width to exercise wrapping at spaces. let wrapped = word_wrap_lines(&[line], 40); let joined: String = wrapped diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5da8e0d691..17506094fb 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -4,7 +4,9 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] #![deny(clippy::disallowed_methods)] use app::App; +use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; +use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -12,11 +14,9 @@ use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; -use codex_login::AuthManager; -use codex_login::AuthMode; -use codex_login::CodexAuth; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; +use codex_protocol::mcp_protocol::AuthMode; use std::fs::OpenOptions; use std::path::PathBuf; use tracing::error; @@ -256,7 +256,6 @@ async fn run_ratatui_app( if let Some(latest_version) = updates::get_upgrade_version(&config) { use ratatui::style::Stylize as _; use ratatui::text::Line; - use ratatui::text::Span; let current_version = env!("CARGO_PKG_VERSION"); let exe = std::env::current_exe()?; @@ -265,35 +264,35 @@ async fn run_ratatui_app( let mut lines: Vec> = Vec::new(); lines.push(Line::from(vec![ "✨⬆️ Update available!".bold().cyan(), - Span::raw(" "), - Span::raw(format!("{current_version} -> {latest_version}.")), + " ".into(), + format!("{current_version} -> {latest_version}.").into(), ])); if managed_by_npm { let npm_cmd = "npm install -g @openai/codex@latest"; lines.push(Line::from(vec![ - Span::raw("Run "), + "Run ".into(), npm_cmd.cyan(), - Span::raw(" to update."), + " to update.".into(), ])); } else if cfg!(target_os = "macos") && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) { let brew_cmd = "brew upgrade codex"; lines.push(Line::from(vec![ - Span::raw("Run "), + "Run ".into(), brew_cmd.cyan(), - Span::raw(" to update."), + " to update.".into(), ])); } else { lines.push(Line::from(vec![ - Span::raw("See "), + "See ".into(), "https://github.com/openai/codex/releases/latest".cyan(), - Span::raw(" for the latest releases and installation options."), + " for the latest releases and installation options.".into(), ])); } - lines.push(Line::from("")); + lines.push("".into()); tui.insert_history_lines(lines); } diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index fcb9e774a0..6aff205a35 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -2,7 +2,6 @@ use crate::citation_regex::CITATION_REGEX; use codex_core::config::Config; use codex_core::config_types::UriBasedFileOpener; use ratatui::text::Line; -use ratatui::text::Span; use std::borrow::Cow; use std::path::Path; @@ -44,7 +43,7 @@ fn append_markdown_with_opener_and_cwd( } else { line }; - let owned_line: Line<'static> = Line::from(Span::raw(line.to_string())); + let owned_line: Line<'static> = line.to_string().into(); lines.push(owned_line); } } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 347289d2fc..c7217ac1af 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1,7 +1,7 @@ #![allow(clippy::unwrap_used)] -use codex_login::AuthManager; -use codex_login::CLIENT_ID; +use codex_core::AuthManager; +use codex_core::auth::CLIENT_ID; use codex_login::ServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; @@ -15,12 +15,11 @@ use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use codex_login::AuthMode; +use codex_protocol::mcp_protocol::AuthMode; use std::sync::RwLock; use crate::LoginStatus; @@ -120,20 +119,14 @@ impl AuthModeWidget { fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) { let mut lines: Vec = vec![ Line::from(vec![ - Span::raw("> "), - Span::styled( - "Sign in with ChatGPT to use Codex as part of your paid plan", - Style::default().add_modifier(Modifier::BOLD), - ), + "> ".into(), + "Sign in with ChatGPT to use Codex as part of your paid plan".bold(), ]), Line::from(vec![ - Span::raw(" "), - Span::styled( - "or connect an API key for usage-based billing", - Style::default().add_modifier(Modifier::BOLD), - ), + " ".into(), + "or connect an API key for usage-based billing".bold(), ]), - Line::from(""), + "".into(), ]; // If the user is already authenticated but the method differs from their @@ -150,8 +143,8 @@ impl AuthModeWidget { to_label(current), to_label(self.preferred_auth_method) ); - lines.push(Line::from(msg).style(Style::default())); - lines.push(Line::from("")); + lines.push(msg.into()); + lines.push("".into()); } let create_mode_item = |idx: usize, @@ -168,7 +161,7 @@ impl AuthModeWidget { text.to_string().cyan(), ]) } else { - Line::from(format!(" {}. {text}", idx + 1)) + format!(" {}. {text}", idx + 1).into() }; let line2 = if is_selected { @@ -207,19 +200,15 @@ impl AuthModeWidget { api_key_label, "Pay for what you use", )); - lines.push(Line::from("")); + lines.push("".into()); lines.push( // AE: Following styles.md, this should probably be Cyan because it's a user input tip. // But leaving this for a future cleanup. - Line::from(" Press Enter to continue") - .style(Style::default().add_modifier(Modifier::DIM)), + " Press Enter to continue".dim().into(), ); if let Some(err) = &self.error { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - err.as_str(), - Style::default().fg(Color::Red), - ))); + lines.push("".into()); + lines.push(err.as_str().red().into()); } Paragraph::new(lines) @@ -228,28 +217,23 @@ impl AuthModeWidget { } fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) { - let mut spans = vec![Span::from("> ")]; + let mut spans = vec!["> ".into()]; // Schedule a follow-up frame to keep the shimmer animation going. self.request_frame .schedule_frame_in(std::time::Duration::from_millis(100)); spans.extend(shimmer_spans("Finish signing in via your browser")); - let mut lines = vec![Line::from(spans), Line::from("")]; + let mut lines = vec![spans.into(), "".into()]; let sign_in_state = self.sign_in_state.read().unwrap(); if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state && !state.auth_url.is_empty() { - lines.push(Line::from(" If the link doesn't open automatically, open the following link to authenticate:")); - lines.push(Line::from(vec![ - Span::raw(" "), - state.auth_url.as_str().cyan().underlined(), - ])); - lines.push(Line::from("")); + lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into()); + lines.push(vec![" ".into(), state.auth_url.as_str().cyan().underlined()].into()); + lines.push("".into()); } - lines.push( - Line::from(" Press Esc to cancel").style(Style::default().add_modifier(Modifier::DIM)), - ); + lines.push(" Press Esc to cancel".dim().into()); Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); @@ -257,35 +241,28 @@ impl AuthModeWidget { fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) { let lines = vec![ - Line::from("✓ Signed in with your ChatGPT account").fg(Color::Green), - Line::from(""), - Line::from("> Before you start:"), - Line::from(""), - Line::from(" Decide how much autonomy you want to grant Codex"), + "✓ Signed in with your ChatGPT account".fg(Color::Green).into(), + "".into(), + "> Before you start:".into(), + "".into(), + " Decide how much autonomy you want to grant Codex".into(), Line::from(vec![ - Span::raw(" For more details see the "), - Span::styled( - "\u{1b}]8;;https://github.com/openai/codex\u{7}Codex docs\u{1b}]8;;\u{7}", - Style::default().add_modifier(Modifier::UNDERLINED), - ), + " For more details see the ".into(), + "\u{1b}]8;;https://github.com/openai/codex\u{7}Codex docs\u{1b}]8;;\u{7}".underlined(), ]) - .style(Style::default().add_modifier(Modifier::DIM)), - Line::from(""), - Line::from(" Codex can make mistakes"), - Line::from(" Review the code it writes and commands it runs") - .style(Style::default().add_modifier(Modifier::DIM)), - Line::from(""), - Line::from(" Powered by your ChatGPT account"), + .dim(), + "".into(), + " Codex can make mistakes".into(), + " Review the code it writes and commands it runs".dim().into(), + "".into(), + " Powered by your ChatGPT account".into(), Line::from(vec![ - Span::raw(" Uses your plan's rate limits and "), - Span::styled( - "\u{1b}]8;;https://chatgpt.com/#settings\u{7}training data preferences\u{1b}]8;;\u{7}", - Style::default().add_modifier(Modifier::UNDERLINED), - ), + " Uses your plan's rate limits and ".into(), + "\u{1b}]8;;https://chatgpt.com/#settings\u{7}training data preferences\u{1b}]8;;\u{7}".underlined(), ]) - .style(Style::default().add_modifier(Modifier::DIM)), - Line::from(""), - Line::from(" Press Enter to continue").fg(Color::Cyan), + .dim(), + "".into(), + " Press Enter to continue".fg(Color::Cyan).into(), ]; Paragraph::new(lines) @@ -294,7 +271,11 @@ impl AuthModeWidget { } fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) { - let lines = vec![Line::from("✓ Signed in with your ChatGPT account").fg(Color::Green)]; + let lines = vec![ + "✓ Signed in with your ChatGPT account" + .fg(Color::Green) + .into(), + ]; Paragraph::new(lines) .wrap(Wrap { trim: false }) @@ -302,7 +283,7 @@ impl AuthModeWidget { } fn render_env_var_found(&self, area: Rect, buf: &mut Buffer) { - let lines = vec![Line::from("✓ Using OPENAI_API_KEY").fg(Color::Green)]; + let lines = vec!["✓ Using OPENAI_API_KEY".fg(Color::Green).into()]; Paragraph::new(lines) .wrap(Wrap { trim: false }) @@ -311,13 +292,11 @@ impl AuthModeWidget { fn render_env_var_missing(&self, area: Rect, buf: &mut Buffer) { let lines = vec![ - Line::from( - " To use Codex with the OpenAI API, set OPENAI_API_KEY in your environment", - ) - .style(Style::default().fg(Color::Cyan)), - Line::from(""), - Line::from(" Press Enter to return") - .style(Style::default().add_modifier(Modifier::DIM)), + " To use Codex with the OpenAI API, set OPENAI_API_KEY in your environment" + .fg(Color::Cyan) + .into(), + "".into(), + " Press Enter to return".dim().into(), ]; Paragraph::new(lines) diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 58ff5a44f5..5afd393c02 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,5 +1,5 @@ +use codex_core::AuthManager; use codex_core::git_info::get_git_repo_root; -use codex_login::AuthManager; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -9,7 +9,7 @@ use ratatui::prelude::Widget; use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; -use codex_login::AuthMode; +use codex_protocol::mcp_protocol::AuthMode; use crate::LoginStatus; use crate::onboarding::auth::AuthModeWidget; diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index e775a829df..ce3f2a3c6a 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -9,10 +9,8 @@ use ratatui::layout::Rect; use ratatui::prelude::Widget; use ratatui::style::Color; use ratatui::style::Modifier; -use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; @@ -41,30 +39,25 @@ impl WidgetRef for &TrustDirectoryWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let mut lines: Vec = vec![ Line::from(vec![ - Span::raw("> "), - Span::styled( - "You are running Codex in ", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(self.cwd.to_string_lossy().to_string()), + "> ".into(), + "You are running Codex in ".bold(), + self.cwd.to_string_lossy().to_string().into(), ]), - Line::from(""), + "".into(), ]; if self.is_git_repo { - lines.push(Line::from( - " Since this folder is version controlled, you may wish to allow Codex", - )); - lines.push(Line::from( - " to work in this folder without asking for approval.", - )); + lines.push( + " Since this folder is version controlled, you may wish to allow Codex".into(), + ); + lines.push(" to work in this folder without asking for approval.".into()); } else { - lines.push(Line::from( - " Since this folder is not version controlled, we recommend requiring", - )); - lines.push(Line::from(" approval of all edits and commands.")); + lines.push( + " Since this folder is not version controlled, we recommend requiring".into(), + ); + lines.push(" approval of all edits and commands.".into()); } - lines.push(Line::from("")); + lines.push("".into()); let create_option = |idx: usize, option: TrustDirectorySelection, text: &str| -> Line<'static> { @@ -99,10 +92,10 @@ impl WidgetRef for &TrustDirectoryWidget { "Require approval of edits and commands", )); } - lines.push(Line::from("")); + lines.push("".into()); if let Some(error) = &self.error { lines.push(Line::from(format!(" {error}")).fg(Color::Red)); - lines.push(Line::from("")); + lines.push("".into()); } // AE: Following styles.md, this should probably be Cyan because it's a user input tip. // But leaving this for a future cleanup. diff --git a/codex-rs/tui/src/onboarding/welcome.rs b/codex-rs/tui/src/onboarding/welcome.rs index bcdc5c9aae..5e6906bf95 100644 --- a/codex-rs/tui/src/onboarding/welcome.rs +++ b/codex-rs/tui/src/onboarding/welcome.rs @@ -1,10 +1,8 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; -use ratatui::style::Modifier; -use ratatui::style::Style; +use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; use ratatui::widgets::WidgetRef; use crate::onboarding::onboarding_screen::StepStateProvider; @@ -18,11 +16,8 @@ pub(crate) struct WelcomeWidget { impl WidgetRef for &WelcomeWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let line = Line::from(vec![ - Span::raw(">_ "), - Span::styled( - "Welcome to Codex, OpenAI's command-line coding agent", - Style::default().add_modifier(Modifier::BOLD), - ), + ">_ ".into(), + "Welcome to Codex, OpenAI's command-line coding agent".bold(), ]); line.render(area, buf); } diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index ed29b8fc11..c730b2fd3b 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -143,7 +143,7 @@ impl PagerView { .dim() .render_ref(area, buf); let header = format!("/ {}", self.title); - Span::from(header).dim().render_ref(area, buf); + header.dim().render_ref(area, buf); } // Removed unused render_content_page (replaced by render_content_page_prepared) @@ -545,7 +545,7 @@ mod tests { fn static_overlay_snapshot_basic() { // Prepare a static overlay with a few lines and a title let mut overlay = StaticOverlay::with_title( - vec![Line::from("one"), Line::from("two"), Line::from("three")], + vec!["one".into(), "two".into(), "three".into()], "S T A T I C".to_string(), ); let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); @@ -557,7 +557,7 @@ mod tests { #[test] fn pager_wrap_cache_reuses_for_same_width_and_rebuilds_on_change() { let long = "This is a long line that should wrap multiple times to ensure non-empty wrapped output."; - let mut pv = PagerView::new(vec![Line::from(long), Line::from(long)], "T".to_string(), 0); + let mut pv = PagerView::new(vec![long.into(), long.into()], "T".to_string(), 0); // Build cache at width 24 pv.ensure_wrapped(24); @@ -586,13 +586,13 @@ mod tests { #[test] fn pager_wrap_cache_invalidates_on_append() { let long = "Another long line for wrapping behavior verification."; - let mut pv = PagerView::new(vec![Line::from(long)], "T".to_string(), 0); + let mut pv = PagerView::new(vec![long.into()], "T".to_string(), 0); pv.ensure_wrapped(28); let (w1, _) = pv.cached(); let len1 = w1.len(); // Append new lines should cause ensure_wrapped to rebuild due to len change - pv.lines.extend([Line::from(long), Line::from(long)]); + pv.lines.extend([long.into(), long.into()]); pv.ensure_wrapped(28); let (w2, _) = pv.cached(); assert!( diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 518287716a..df957fee87 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -252,10 +252,10 @@ impl Tui { if next_deadline.is_none_or(|cur| at < cur) { next_deadline = Some(at); } - if at <= Instant::now() { - next_deadline = None; - let _ = draw_tx_clone.send(()); - } + // Do not send a draw immediately here. By continuing the loop, + // we recompute the sleep target so the draw fires once via the + // sleep branch, coalescing multiple requests into a single draw. + continue; } None => break, } diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index 200ba2c04f..9ea2c0bc46 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -115,10 +115,7 @@ fn to_command_display<'a>( cmd: String, last_line: Vec>, ) -> Vec> { - let command_lines: Vec = cmd - .lines() - .map(|line| Span::from(line.to_string()).style(Style::new().add_modifier(Modifier::DIM))) - .collect(); + let command_lines: Vec = cmd.lines().map(|line| line.to_string().dim()).collect(); let mut lines: Vec> = vec![]; @@ -128,7 +125,7 @@ fn to_command_display<'a>( first_line.extend(last_line); } else { for line in command_lines { - lines.push(Line::from(vec![Span::from(" "), line])); + lines.push(vec![" ".into(), line].into()); } let last_line = last_line.clone(); lines.push(Line::from(last_line)); diff --git a/codex-rs/tui/tests/suite/vt100_history.rs b/codex-rs/tui/tests/suite/vt100_history.rs index 1f01a6d3ba..973b0aa052 100644 --- a/codex-rs/tui/tests/suite/vt100_history.rs +++ b/codex-rs/tui/tests/suite/vt100_history.rs @@ -3,10 +3,8 @@ use ratatui::backend::TestBackend; use ratatui::layout::Rect; -use ratatui::style::Color; -use ratatui::style::Style; +use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::text::Span; // Small helper macro to assert a collection contains an item with a clearer // failure message. @@ -80,7 +78,7 @@ fn basic_insertion_no_wrap() { let area = Rect::new(0, 5, 20, 1); let mut scenario = TestScenario::new(20, 6, area); - let lines = vec![Line::from("first"), Line::from("second")]; + let lines = vec!["first".into(), "second".into()]; let buf = scenario.run_insert(lines); let rows = scenario.screen_rows_from_bytes(&buf); assert_contains!(rows, String::from("first")); @@ -102,7 +100,7 @@ fn long_token_wraps() { let mut scenario = TestScenario::new(20, 6, area); let long = "A".repeat(45); // > 2 lines at width 20 - let lines = vec![Line::from(long.clone())]; + let lines = vec![long.clone().into()]; let buf = scenario.run_insert(lines); let mut parser = vt100::Parser::new(6, 20, 0); parser.process(&buf); @@ -134,7 +132,7 @@ fn emoji_and_cjk() { let mut scenario = TestScenario::new(20, 6, area); let text = String::from("😀😀😀😀😀 你好世界"); - let lines = vec![Line::from(text.clone())]; + let lines = vec![text.clone().into()]; let buf = scenario.run_insert(lines); let rows = scenario.screen_rows_from_bytes(&buf); let reconstructed: String = rows.join("").chars().filter(|c| *c != ' ').collect(); @@ -151,10 +149,7 @@ fn mixed_ansi_spans() { let area = Rect::new(0, 5, 20, 1); let mut scenario = TestScenario::new(20, 6, area); - let line = Line::from(vec![ - Span::styled("red", Style::default().fg(Color::Red)), - Span::raw("+plain"), - ]); + let line = vec!["red".red(), "+plain".into()].into(); let buf = scenario.run_insert(vec![line]); let rows = scenario.screen_rows_from_bytes(&buf); assert_contains!(rows, String::from("red+plain")); @@ -165,7 +160,7 @@ fn cursor_restoration() { let area = Rect::new(0, 5, 20, 1); let mut scenario = TestScenario::new(20, 6, area); - let lines = vec![Line::from("x")]; + let lines = vec!["x".into()]; let buf = scenario.run_insert(lines); let s = String::from_utf8_lossy(&buf); // CUP to 1;1 (ANSI: ESC[1;1H) @@ -187,7 +182,7 @@ fn word_wrap_no_mid_word_split() { let mut scenario = TestScenario::new(40, 10, area); let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them."; - let buf = scenario.run_insert(vec![Line::from(sample)]); + let buf = scenario.run_insert(vec![sample.into()]); let rows = scenario.screen_rows_from_bytes(&buf); let joined = rows.join("\n"); assert!( @@ -203,7 +198,7 @@ fn em_dash_and_space_word_wrap() { let mut scenario = TestScenario::new(40, 10, area); let sample = "Mara found an old key on the shore. Curious, she opened a tarnished box half-buried in sand—and inside lay a single, glowing seed."; - let buf = scenario.run_insert(vec![Line::from(sample)]); + let buf = scenario.run_insert(vec![sample.into()]); let rows = scenario.screen_rows_from_bytes(&buf); let joined = rows.join("\n"); assert!( @@ -218,7 +213,7 @@ fn pre_scroll_region_down() { let area = Rect::new(0, 3, 20, 1); let mut scenario = TestScenario::new(20, 6, area); - let lines = vec![Line::from("first"), Line::from("second")]; + let lines = vec!["first".into(), "second".into()]; let buf = scenario.run_insert(lines); let s = String::from_utf8_lossy(&buf); // Expect we limited scroll region to [top+1 .. screen_height] => [4 .. 6] (1-based) diff --git a/codex-rs/tui/tests/suite/vt100_live_commit.rs b/codex-rs/tui/tests/suite/vt100_live_commit.rs index c0cfb3211a..e06f10275c 100644 --- a/codex-rs/tui/tests/suite/vt100_live_commit.rs +++ b/codex-rs/tui/tests/suite/vt100_live_commit.rs @@ -24,10 +24,7 @@ fn live_001_commit_on_overflow() { // Keep the last 3 in the live ring; commit the first 2. let commit_rows = rb.drain_commit_ready(3); - let lines: Vec> = commit_rows - .into_iter() - .map(|r| Line::from(r.text)) - .collect(); + let lines: Vec> = commit_rows.into_iter().map(|r| r.text.into()).collect(); let mut buf: Vec = Vec::new(); codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines); @@ -80,10 +77,7 @@ fn live_002_pre_scroll_and_commit() { // Keep 3, commit 1. let commit_rows = rb.drain_commit_ready(3); - let lines: Vec> = commit_rows - .into_iter() - .map(|r| Line::from(r.text)) - .collect(); + let lines: Vec> = commit_rows.into_iter().map(|r| r.text.into()).collect(); let mut buf: Vec = Vec::new(); codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines); diff --git a/codex-rs/tui/tests/suite/vt100_streaming_no_dup.rs b/codex-rs/tui/tests/suite/vt100_streaming_no_dup.rs index a359e77a08..79f9d2fb00 100644 --- a/codex-rs/tui/tests/suite/vt100_streaming_no_dup.rs +++ b/codex-rs/tui/tests/suite/vt100_streaming_no_dup.rs @@ -2,7 +2,6 @@ use ratatui::backend::TestBackend; use ratatui::layout::Rect; -use ratatui::text::Line; fn term(viewport: Rect) -> codex_tui::custom_terminal::Terminal { let backend = TestBackend::new(20, 6); @@ -23,7 +22,7 @@ fn stream_commit_trickle_no_duplication() { codex_tui::insert_history::insert_history_lines_to_writer( &mut t, &mut out1, - vec![Line::from("one")], + vec!["one".into()], ); // Step 2: later commit next row @@ -31,7 +30,7 @@ fn stream_commit_trickle_no_duplication() { codex_tui::insert_history::insert_history_lines_to_writer( &mut t, &mut out2, - vec![Line::from("two")], + vec!["two".into()], ); let combined = [out1, out2].concat(); @@ -62,7 +61,7 @@ fn live_ring_rows_not_inserted_into_history() { codex_tui::insert_history::insert_history_lines_to_writer( &mut t, &mut buf, - vec![Line::from("one"), Line::from("two")], + vec!["one".into(), "two".into()], ); // The live ring might display tail+head rows like ["two", "three"], diff --git a/docs/getting-started.md b/docs/getting-started.md index ba99743133..691caae0e7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -48,7 +48,7 @@ You can give Codex extra instructions and guidance using `AGENTS.md` files. Code 2. `AGENTS.md` at repo root - shared project notes 3. `AGENTS.md` in the current working directory - sub-folder/feature specifics -For more information on how to use AGENTS.md, see the [official AGENTS.md documentation](./agents.md). +For more information on how to use AGENTS.md, see the [official AGENTS.md documentation](https://agents.md/). ### Tips & shortcuts