diff --git a/.gitignore b/.gitignore index 5e131793..ff7eb792 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ .claude/agent-memory-local/ .claude/plans/ .claude/settings.local.json +.claude/worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index 7ebbcb66..6bbdee74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,8 @@ ox # Start an interactive session . ├── agent.rs # Agent turn loop, stream accumulation, tool dispatch ├── agent/ -│ └── event.rs # AgentEvent, UserAction, AgentSink trait, StdioSink +│ ├── event.rs # AgentEvent, UserAction, AgentSink trait, StdioSink +│ └── pending_calls.rs # PendingCall / PendingCalls correlation state shared by live streaming and transcript resume ├── client.rs # Client module root ├── client/ │ ├── anthropic.rs # Anthropic Messages API streaming client @@ -121,8 +122,8 @@ ox # Start an interactive session ### Blank Lines -- One blank line between top-level items (functions, structs, enums, impls, constants). -- One blank line before and after section dividers (`// ── Name ──`). +- One blank line between top-level items (functions, structs, enums, impls, constants). Exception: runs of closely-related one-line `const` / `static` declarations that share a theme (e.g., all the OAuth client constants, all the beta-header names) may sit together without blanks, then take one blank before unrelated items. +- One blank line before and after section dividers (`// ── Name ──`). This applies inside `#[cfg(test)]` modules too — the first divider takes a blank line after the `use super::*;` block. - Inside function bodies, use blank lines to separate logical phases (e.g., setup → validation → execution → result). - Group a single-line computation with its immediate validation guard (early-return `if`) — no blank between them. Multi-line `let` bindings (async chains, builder patterns) keep the blank before their guard. @@ -132,7 +133,7 @@ ox # Start an interactive session - Keep files focused: one primary type or concern per file. When a file or function grows large, split it into smaller units proactively rather than letting it accumulate. - Place functions and types in the module that reflects their conceptual domain — import paths should not mislead about what the item does. Create new modules when needed for clean organization. - Avoid `pub use` re-exports that obscure where items are defined. Prefer consistent import paths — if some items are re-exported, re-export all related items so callers never mix paths. -- Order helper functions after their caller (top-down reading order). +- Order helper functions after their caller (top-down reading order) _within each section_. Whole trait impls or unrelated feature sections don't need to be reshuffled to satisfy this — the rule is about local readability, not cross-section call graphs. - When adding new fields to structs or variants to enums, place them at the most semantically appropriate position among existing members, not simply appended at the bottom. - A type used by N callers across M modules belongs in the module that names the **contract**, not the module of the first **implementation**. If `tui::event::AgentSink` is implemented by both a TUI channel and a stdio writer, the trait belongs in `agent::` (the contract), not `tui::` (one implementation). @@ -143,7 +144,7 @@ ox # Start an interactive session ### Imports -- Group `use` statements in three blocks separated by blank lines: std → external crates → internal modules. +- Group `use` statements in three blocks separated by blank lines: std → external crates → internal modules. `super::` and `crate::` paths belong together in the internal block — do not split them. - Within each block, sort alphabetically. ### String Literals @@ -180,13 +181,14 @@ ox # Start an interactive session - Prose intro summarizing what and why. - Per-file Changes table (for non-trivial PRs). - Test plan checklist. +- PR descriptions are review-facing and must not reference gitignored working docs (e.g., `.claude/plans/*`, `.claude/agent-memory-local/*`). Those are internal collaboration notes, not reader context. When deferring follow-ups, describe them inline in the PR body — a reader should not need a file they can't see to understand the PR. ### Testing - Unit tests in the same file as the code they test (`#[cfg(test)]` module). - Integration tests in `tests/` directory for cross-module behavior. - Group tests by function under `// ── function_name ──` section headers. Section order must mirror the production function order in the same file. Within each section, order: happy path → variants → edge / error cases. -- Name tests after the scenario they cover, not the return type. Prefix with the function name being tested (e.g., `parse_sse_frame_missing_data`, `load_oauth_expired_token`). For parameterless single-behavior functions where the value IS the test, use property form (`icon_is_dollar_sign`), not mechanism form (`icon_returns_dollar_sign`). +- Name tests after the scenario they cover, not the return type. Prefix with the function name being tested (e.g., `parse_sse_frame_missing_data`, `load_oauth_expired_token`). When the scenario and the return value are synonyms (unset env var → `None`), phrase the scenario side (`string_unset_is_absent`), not the mechanism (`string_unset_returns_none`). For parameterless single-behavior functions where the value IS the test, use property form (`icon_is_dollar_sign`), not mechanism form (`icon_returns_dollar_sign`). - Use `indoc!` for multi-line string literals in tests. - Reach for the established test infrastructure before hand-rolling: `wiremock` for HTTP round-trips, `temp-env` for environment-variable isolation, `ratatui::backend::TestBackend` + `insta` for TUI render snapshots (review with `cargo insta review`), and an extracted trait with an in-process fake (see `agent::AgentClient`) when a dependency is hard to mock at the network boundary. - Write assertions that verify actual behavior, not just surface properties. Avoid uniform test data that makes `starts_with` / `ends_with` unfalsifiable, wildcard struct matches (`..`) that discard field values, and loose bounds that accept nearly any output. Each assertion should fail if the code under test has a plausible bug. diff --git a/crates/oxide-code/src/agent.rs b/crates/oxide-code/src/agent.rs index e826bc4c..9e91866c 100644 --- a/crates/oxide-code/src/agent.rs +++ b/crates/oxide-code/src/agent.rs @@ -6,6 +6,7 @@ //! cap [`MAX_TOOL_ROUNDS`] trips. pub(crate) mod event; +pub(crate) mod pending_calls; use anyhow::{Context, Result, bail}; use tokio::sync::{Mutex, mpsc}; @@ -770,6 +771,7 @@ data: {"type":"message_stop"} assert_eq!(messages.len(), 2); assert!(matches!(&messages[1].content[0], ContentBlock::Text { text } if text == "hello"),); } + // ── BlockAccumulator::into_content_block ── #[test] diff --git a/crates/oxide-code/src/agent/event.rs b/crates/oxide-code/src/agent/event.rs index 5b076d0e..8e8c3261 100644 --- a/crates/oxide-code/src/agent/event.rs +++ b/crates/oxide-code/src/agent/event.rs @@ -18,7 +18,7 @@ pub(crate) enum AgentEvent { /// A chunk of thinking text (streamed incrementally). ThinkingToken(String), /// A tool call has started execution. `id` is the call's - /// correlation handle — [`PendingCalls`](crate::tui::pending_calls::PendingCalls) + /// correlation handle — [`PendingCalls`](crate::agent::pending_calls::PendingCalls) /// stashes the tool name + input under it so the paired /// [`Self::ToolCallEnd`] can build a structured /// [`ToolResultView`](crate::tool::ToolResultView). diff --git a/crates/oxide-code/src/tui/pending_calls.rs b/crates/oxide-code/src/agent/pending_calls.rs similarity index 95% rename from crates/oxide-code/src/tui/pending_calls.rs rename to crates/oxide-code/src/agent/pending_calls.rs index 89e49eb1..9359372e 100644 --- a/crates/oxide-code/src/tui/pending_calls.rs +++ b/crates/oxide-code/src/agent/pending_calls.rs @@ -1,11 +1,11 @@ //! Pending tool-call bookkeeping shared between live streaming and //! transcript resume. //! -//! Both the live event path ([`super::app::App::handle_agent_event`]) -//! and the resumed-history walk -//! ([`super::components::chat::ChatView::load_history`]) need to -//! bridge a tool-call observation to its later matching result so they -//! can render the result with: +//! Both the live event path +//! ([`crate::tui::app::App::handle_agent_event`]) and the resumed-history +//! walk ([`crate::tui::components::chat::ChatView::load_history`]) need +//! to bridge a tool-call observation to its later matching result so +//! they can render the result with: //! //! - The call's label as the status-line fallback when the tool //! emits `title: None`. @@ -172,7 +172,7 @@ mod tests { } #[test] - fn remove_unknown_id_returns_none() { + fn remove_unknown_id_is_absent() { let mut calls = PendingCalls::new(); assert!(calls.remove("orphan").is_none()); } diff --git a/crates/oxide-code/src/client.rs b/crates/oxide-code/src/client.rs index 364cd647..26d8e1c7 100644 --- a/crates/oxide-code/src/client.rs +++ b/crates/oxide-code/src/client.rs @@ -1,4 +1,4 @@ //! Anthropic Messages API client. -pub mod anthropic; +pub(crate) mod anthropic; mod billing; diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 1e20c99d..68f4bdeb 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -116,16 +116,18 @@ impl ContextManagement { /// we need to diverge from the default. fn clear_thinking_keep_all() -> Self { Self { - edits: [ContextEdit::ClearThinking20251015 { keep: "all" }], + edits: [ContextEdit { + r#type: "clear_thinking_20251015", + keep: "all", + }], } } } #[derive(Serialize)] -#[serde(tag = "type")] -enum ContextEdit { - #[serde(rename = "clear_thinking_20251015")] - ClearThinking20251015 { keep: &'static str }, +struct ContextEdit { + r#type: &'static str, + keep: &'static str, } /// JSON-schema-constrained completion format. Constructed via @@ -203,7 +205,7 @@ struct CacheControl { )] #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] -pub enum StreamEvent { +pub(crate) enum StreamEvent { MessageStart { message: MessageResponse, }, @@ -241,15 +243,15 @@ pub enum StreamEvent { ) )] #[derive(Debug, Clone, Deserialize)] -pub struct MessageResponse { - pub id: String, - pub model: String, - pub usage: Option, +pub(crate) struct MessageResponse { + pub(crate) id: String, + pub(crate) model: String, + pub(crate) usage: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] -pub enum ContentBlockInfo { +pub(crate) enum ContentBlockInfo { Text { text: String, }, @@ -280,7 +282,7 @@ pub enum ContentBlockInfo { )] #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] -pub enum Delta { +pub(crate) enum Delta { TextDelta { text: String, }, @@ -308,8 +310,8 @@ pub enum Delta { ) )] #[derive(Debug, Clone, Deserialize)] -pub struct MessageDeltaBody { - pub stop_reason: Option, +pub(crate) struct MessageDeltaBody { + pub(crate) stop_reason: Option, } #[cfg_attr( @@ -320,31 +322,31 @@ pub struct MessageDeltaBody { ) )] #[derive(Debug, Clone, Deserialize)] -pub struct Usage { +pub(crate) struct Usage { #[serde(default)] - pub input_tokens: u32, + pub(crate) input_tokens: u32, #[serde(default)] - pub output_tokens: u32, + pub(crate) output_tokens: u32, } #[derive(Debug, Clone, Deserialize)] -pub struct ApiError { +pub(crate) struct ApiError { #[serde(rename = "type")] - pub error_type: String, - pub message: String, + pub(crate) error_type: String, + pub(crate) message: String, } // ── Client ── #[derive(Clone)] -pub struct Client { +pub(crate) struct Client { http: reqwest::Client, config: Config, session_id: String, } impl Client { - pub fn new(config: Config, session_id: Option) -> Result { + pub(crate) fn new(config: Config, session_id: Option) -> Result { let session_id = session_id.unwrap_or_else(|| Uuid::new_v4().to_string()); let mut headers = HeaderMap::new(); @@ -411,7 +413,7 @@ impl Client { } /// Returns the model name for use in the system prompt. - pub fn model(&self) -> &str { + pub(crate) fn model(&self) -> &str { &self.config.model } @@ -429,8 +431,16 @@ impl Client { /// is prepended as a synthetic user message (keeping dynamic content /// like CLAUDE.md out of the cacheable `system` parameter). /// + /// System-block assembly order (the boundary marker is filtered out): + /// + /// 1. Billing header (OAuth only; no `cache_control`). + /// 2. Identity prefix (no `cache_control`). + /// 3. Static sections joined (ephemeral cache; `scope=global` on 1P, + /// default org-scoped on 3P). + /// 4. Dynamic sections joined (no `cache_control`). + /// /// Returns an mpsc receiver of [`StreamEvent`]s. - pub fn stream_message( + pub(crate) fn stream_message( &self, messages: &[Message], system_sections: &[&str], @@ -462,13 +472,6 @@ impl Client { // ephemeral cache, which every gateway accepts. let is_first_party = is_first_party_base_url(&self.config.base_url); - // System-block order (boundary marker filtered): - // - // 1. Billing header (OAuth only; no cache_control). - // 2. Identity prefix (no cache_control). - // 3. Static sections joined (ephemeral cache; scope=global on 1P, - // default org-scoped on 3P). - // 4. Dynamic sections joined (no cache_control). let (static_sections, dynamic_sections) = split_at_boundary(system_sections); let static_joined = static_sections.join("\n\n"); let dynamic_joined = dynamic_sections.join("\n\n"); @@ -564,7 +567,7 @@ impl Client { /// [`Capabilities::structured_outputs`][crate::model::Capabilities::structured_outputs] /// is `false`, both the body field and the beta are silently /// dropped — the caller must tolerate free-form text in that case. - pub async fn complete( + pub(crate) async fn complete( &self, model: &str, system: &str, @@ -608,8 +611,16 @@ impl Client { .await?; let status = response.status(); if !status.is_success() { + let retry_after = response + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); let body = response.text().await.unwrap_or_default(); - bail!("API error (HTTP {status}): {body}"); + bail!( + "{}", + format_api_error(status, retry_after.as_deref(), &body) + ); } let CompletionResponse { content } = response @@ -652,7 +663,9 @@ fn compute_betas( is_first_party: bool, ) -> Vec<&'static str> { let caps = crate::model::capabilities_for(model); - let is_haiku = model.to_lowercase().contains("haiku"); + let is_haiku = model + .split('-') + .any(|tok| tok.eq_ignore_ascii_case("haiku")); // Order mirrors `docs/research/anthropic-api.md` → Per-model beta // sets: identity / auth → universal agentic → capability-gated. @@ -756,10 +769,13 @@ fn has_1m_tag(model: &str) -> bool { /// Byte offset of the `[1m]` tag, case-insensitive. Shared by /// [`has_1m_tag`] and [`api_model_id`] so the two agree on every -/// accepted spelling. Model IDs are ASCII, so lowercased byte indices -/// line up with the original string. +/// accepted spelling. Model IDs are ASCII, so byte-window scanning +/// lines up with character boundaries. fn tag_offset(model: &str) -> Option { - model.to_lowercase().find("[1m]") + model + .as_bytes() + .windows(4) + .position(|w| w.eq_ignore_ascii_case(b"[1m]")) } /// Serializes the JSON request body for [`Client::complete`]. @@ -940,8 +956,16 @@ async fn stream_sse( let status = response.status(); if !status.is_success() { + let retry_after = response + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); let body = response.text().await.unwrap_or_default(); - bail!("API error (HTTP {status}): {body}"); + bail!( + "{}", + format_api_error(status, retry_after.as_deref(), &body) + ); } let mut stream = response.bytes_stream(); @@ -989,6 +1013,25 @@ async fn stream_sse( Ok(()) } +/// Builds an actionable error message for a non-2xx Anthropic API +/// response. The raw body is always appended as `details: {body}` so +/// debug context is preserved on every branch. +fn format_api_error(status: reqwest::StatusCode, retry_after: Option<&str>, body: &str) -> String { + let prefix = match status.as_u16() { + 401 => "Anthropic API rejected credentials (HTTP 401). Check ANTHROPIC_API_KEY, or run `claude` to refresh OAuth.".to_owned(), + 429 => match retry_after { + Some(after) => format!("Anthropic API rate limited (HTTP 429); retry after {after}."), + None => "Anthropic API rate limited (HTTP 429); retry after a short delay.".to_owned(), + }, + 529 => "Anthropic API overloaded (HTTP 529); this is transient — retry in a few seconds.".to_owned(), + s if (500..600).contains(&s) => { + format!("Anthropic API server error (HTTP {status}). Usually transient; retry.") + } + _ => format!("API error (HTTP {status})"), + }; + format!("{prefix} details: {body}") +} + /// Parses a single SSE frame into a [`StreamEvent`]. /// /// Per the SSE spec, multiple `data:` lines concatenate with `\n`. @@ -1149,6 +1192,19 @@ mod tests { Arc::new(Mutex::new(None)) } + // ── ContextManagement ── + + #[test] + fn context_management_clear_thinking_keep_all_serializes_tagged_shape() { + let v = serde_json::to_value(ContextManagement::clear_thinking_keep_all()).unwrap(); + assert_eq!( + v, + serde_json::json!({ + "edits": [{"type": "clear_thinking_20251015", "keep": "all"}], + }), + ); + } + // ── OutputFormat ── #[test] @@ -1439,10 +1495,7 @@ mod tests { panic!("expected text delta, got {:?}", events[2]); }; assert_eq!(text, "Hi"); - assert!(matches!( - events[5], - StreamEvent::MessageStop | StreamEvent::Unknown, - )); + assert!(matches!(events[5], StreamEvent::MessageStop)); } #[tokio::test] @@ -2648,6 +2701,76 @@ mod tests { assert_eq!(first_user_text(&tool_only), ""); } + // ── format_api_error ── + + #[test] + fn format_api_error_401_names_both_auth_paths() { + let msg = format_api_error( + reqwest::StatusCode::UNAUTHORIZED, + None, + r#"{"error":"invalid_api_key"}"#, + ); + assert!( + msg.starts_with( + "Anthropic API rejected credentials (HTTP 401). Check ANTHROPIC_API_KEY, or run `claude` to refresh OAuth." + ), + "401 prefix: {msg}", + ); + assert!(msg.contains(r#"details: {"error":"invalid_api_key"}"#)); + } + + #[test] + fn format_api_error_429_mentions_retry_after_when_present() { + let with = format_api_error(reqwest::StatusCode::TOO_MANY_REQUESTS, Some("42"), "rl"); + assert!( + with.starts_with("Anthropic API rate limited (HTTP 429); retry after 42."), + "429 with retry-after: {with}", + ); + let without = format_api_error(reqwest::StatusCode::TOO_MANY_REQUESTS, None, "rl"); + assert!( + without + .starts_with("Anthropic API rate limited (HTTP 429); retry after a short delay."), + "429 without retry-after: {without}", + ); + assert!(with.contains("details: rl")); + assert!(without.contains("details: rl")); + } + + #[test] + fn format_api_error_529_flags_overload_as_transient() { + let status = reqwest::StatusCode::from_u16(529).unwrap(); + let msg = format_api_error(status, None, "overloaded"); + assert!( + msg.starts_with( + "Anthropic API overloaded (HTTP 529); this is transient — retry in a few seconds." + ), + "529 prefix: {msg}", + ); + assert!(msg.contains("details: overloaded")); + } + + #[test] + fn format_api_error_5xx_uses_generic_server_branch() { + let msg = format_api_error(reqwest::StatusCode::BAD_GATEWAY, None, "bad gw"); + assert!( + msg.starts_with( + "Anthropic API server error (HTTP 502 Bad Gateway). Usually transient; retry." + ), + "5xx prefix: {msg}", + ); + assert!(msg.contains("details: bad gw")); + } + + #[test] + fn format_api_error_other_falls_back_to_generic_shape() { + let msg = format_api_error(reqwest::StatusCode::BAD_REQUEST, None, "invalid"); + assert!( + msg.starts_with("API error (HTTP 400 Bad Request)"), + "generic prefix: {msg}", + ); + assert!(msg.contains("details: invalid")); + } + // ── parse_sse_frame ── #[test] diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index f17e53bf..30e7f2c8 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -20,7 +20,7 @@ const DEFAULT_MODEL: &str = "claude-opus-4-7"; const DEFAULT_BASE_URL: &str = "https://api.anthropic.com"; #[derive(Debug, Clone)] -pub enum Auth { +pub(crate) enum Auth { /// Explicit API key (`x-api-key` header). ApiKey(String), /// OAuth access token from Claude Code (`Authorization: Bearer` header). @@ -29,7 +29,7 @@ pub enum Auth { #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] -pub enum ThinkingConfig { +pub(crate) enum ThinkingConfig { /// Model decides the thinking budget (Claude 4.6+). `display` /// controls what the API streams back: `Omitted` (4.7 default, /// empty `thinking` field) or `Summarized` (the 4.6 default, and @@ -45,7 +45,7 @@ pub enum ThinkingConfig { /// `display: None`) already yields the `omitted` default on 4.7. #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "snake_case")] -pub enum ThinkingDisplay { +pub(crate) enum ThinkingDisplay { Summarized, } @@ -54,7 +54,7 @@ pub enum ThinkingDisplay { /// [`crate::model::Capabilities`]. #[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] -pub enum Effort { +pub(crate) enum Effort { Low, Medium, High, @@ -99,7 +99,7 @@ impl FromStr for Effort { /// dropped the default from 1h to 5m on 2026-03-06, so `OneHour` is /// explicit opt-in. oxide-code defaults to `OneHour`. #[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] -pub enum PromptCacheTtl { +pub(crate) enum PromptCacheTtl { #[serde(rename = "5m")] FiveMin, #[serde(rename = "1h")] @@ -144,22 +144,22 @@ impl FromStr for PromptCacheTtl { /// Resolved configuration. #[derive(Debug, Clone)] -pub struct Config { - pub auth: Auth, - pub base_url: String, - pub model: String, +pub(crate) struct Config { + pub(crate) auth: Auth, + pub(crate) base_url: String, + pub(crate) model: String, /// `output_config.effort` for the streaming path. `None` means /// the model doesn't accept the parameter and the field is /// omitted. Resolved once at [`Config::load`] — callers forward. - pub effort: Option, - pub max_tokens: u32, + pub(crate) effort: Option, + pub(crate) max_tokens: u32, /// `cache_control.ttl` for every cacheable block. Default is /// [`PromptCacheTtl::OneHour`] since Anthropic's 2026-03 TTL /// drop made the server default (5 m) a silent cost regression /// on long sessions. - pub prompt_cache_ttl: PromptCacheTtl, - pub thinking: Option, - pub show_thinking: bool, + pub(crate) prompt_cache_ttl: PromptCacheTtl, + pub(crate) thinking: Option, + pub(crate) show_thinking: bool, } impl Config { @@ -170,7 +170,7 @@ impl Config { /// /// Auth priority: `ANTHROPIC_API_KEY` env var > `api_key` in config /// file > Claude Code OAuth credentials. - pub async fn load() -> Result { + pub(crate) async fn load() -> Result { let fc = file::load()?; let client = fc.client.unwrap_or_default(); let tui = fc.tui.unwrap_or_default(); diff --git a/crates/oxide-code/src/config/file.rs b/crates/oxide-code/src/config/file.rs index bdc45b32..bcfe9bda 100644 --- a/crates/oxide-code/src/config/file.rs +++ b/crates/oxide-code/src/config/file.rs @@ -28,8 +28,8 @@ const PROJECT_CONFIG_FILENAME: &str = "ox.toml"; #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct FileConfig { - pub client: Option, - pub tui: Option, + pub(super) client: Option, + pub(super) tui: Option, } /// API client settings (`[client]` section). @@ -40,19 +40,19 @@ pub(super) struct FileConfig { #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct ClientConfig { - pub api_key: Option, - pub base_url: Option, - pub model: Option, - pub effort: Option, - pub max_tokens: Option, - pub prompt_cache_ttl: Option, + pub(super) api_key: Option, + pub(super) base_url: Option, + pub(super) model: Option, + pub(super) effort: Option, + pub(super) max_tokens: Option, + pub(super) prompt_cache_ttl: Option, } /// Terminal UI settings (`[tui]` section). #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct TuiConfig { - pub show_thinking: Option, + pub(super) show_thinking: Option, } // ── Merge ── @@ -376,7 +376,7 @@ mod tests { } #[test] - fn load_file_missing_file_returns_none() { + fn load_file_missing_is_absent() { let result = load_file(Path::new("/nonexistent/config.toml")).expect("missing file is not an error"); assert!(result.is_none()); @@ -491,7 +491,7 @@ mod tests { } #[test] - fn find_project_config_from_returns_none_when_absent() { + fn find_project_config_from_is_absent_when_not_found() { let dir = tempfile::tempdir().unwrap(); assert!(find_project_config_from(dir.path().to_path_buf()).is_none()); } diff --git a/crates/oxide-code/src/config/oauth.rs b/crates/oxide-code/src/config/oauth.rs index 751a5755..1986725a 100644 --- a/crates/oxide-code/src/config/oauth.rs +++ b/crates/oxide-code/src/config/oauth.rs @@ -66,7 +66,7 @@ impl OAuthCredential { /// Loads an OAuth access token from Claude Code credentials, refreshing /// proactively if the token is within 5 minutes of expiry. -pub async fn load_token() -> Result { +pub(super) async fn load_token() -> Result { let file_path = credentials_path().context("could not determine home directory")?; let lock_path = lock_path().context("could not determine home directory")?; load_token_from(&file_path, &lock_path, OAUTH_TOKEN_URL).await @@ -203,11 +203,14 @@ fn enforce_private_mode(path: &Path) { fn enforce_private_mode(_path: &Path) {} fn is_near_expiry(expires_at_ms: u64) -> bool { - now_millis() + TOKEN_EXPIRY_BUFFER_MS >= expires_at_ms + // A broken host clock can't disprove expiry: if we can't tell, treat as + // expiring so the caller refreshes. A stale access token fails server-side; + // a needless refresh is cheap. + now_millis().is_none_or(|now| now.saturating_add(TOKEN_EXPIRY_BUFFER_MS) >= expires_at_ms) } fn is_expired(expires_at_ms: u64) -> bool { - now_millis() >= expires_at_ms + now_millis().is_none_or(|now| now >= expires_at_ms) } // ── Token Refresh ── @@ -277,10 +280,11 @@ fn write_refreshed_credentials(path: &Path, response: &RefreshResponse) -> Resul oauth["accessToken"] = serde_json::Value::String(response.access_token.clone()); oauth["refreshToken"] = serde_json::Value::String(response.refresh_token.clone()); - // Computed from local clock — will be wrong if the machine clock is skewed, - // but the refresh endpoint only returns a relative `expires_in`, not an - // absolute timestamp. - oauth["expiresAt"] = serde_json::json!(now_millis() + response.expires_in * 1000); + // Computed from local clock — derived from relative `expires_in`, the + // refresh endpoint does not return an absolute timestamp. + let now = now_millis().context("system clock before UNIX epoch; cannot record token expiry")?; + oauth["expiresAt"] = + serde_json::json!(now.saturating_add(response.expires_in.saturating_mul(1000))); if let Some(scope) = &response.scope { // `split_whitespace` tolerates extra or leading/trailing spaces in the @@ -356,14 +360,18 @@ fn write_keychain(json: &str) -> Result<()> { .context("failed to write to Keychain") } -fn now_millis() -> u64 { +/// Current wall clock in milliseconds since UNIX epoch. Returns `None` if the +/// host clock is set before 1970 — in that case OAuth expiry math is +/// meaningless, so callers should treat the credential as expired and let the +/// refresh path (or server-side rejection) take over. +fn now_millis() -> Option { u64::try_from( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .expect("system clock before epoch") + .ok()? .as_millis(), ) - .expect("current time fits in u64") + .ok() } fn credentials_path() -> Option { @@ -503,7 +511,7 @@ mod tests { } #[tokio::test] - async fn load_token_from_returns_existing_when_far_from_expiry() { + async fn load_token_from_keeps_existing_when_far_from_expiry() { let dir = tempfile::tempdir().unwrap(); let creds = dir.path().join("creds.json"); let lock = dir.path().join("lock"); @@ -516,11 +524,11 @@ mod tests { } #[tokio::test] - async fn load_token_from_without_refresh_token_returns_nonexpired_as_is() { + async fn load_token_from_without_refresh_token_keeps_nonexpired_as_is() { let dir = tempfile::tempdir().unwrap(); let creds = dir.path().join("creds.json"); let lock = dir.path().join("lock"); - write_creds(&creds, "tok", None, now_millis() + 60_000); + write_creds(&creds, "tok", None, now_millis().unwrap() + 60_000); let token = load_token_from(&creds, &lock, "http://should-not-be-called") .await @@ -558,7 +566,12 @@ mod tests { let creds = dir.path().join("creds.json"); let lock = dir.path().join("lock"); // Under the 5-min refresh buffer so load_token_from must refresh. - write_creds(&creds, "old", Some("old-refresh"), now_millis() + 1_000); + write_creds( + &creds, + "old", + Some("old-refresh"), + now_millis().unwrap() + 1_000, + ); let token = load_token_from(&creds, &lock, &server.uri()).await.unwrap(); assert_eq!(token, "fresh-access"); @@ -568,7 +581,7 @@ mod tests { assert_eq!(json["claudeAiOauth"]["accessToken"], "fresh-access"); assert_eq!(json["claudeAiOauth"]["refreshToken"], "fresh-refresh"); let expires_at = json["claudeAiOauth"]["expiresAt"].as_u64().unwrap(); - let now = now_millis(); + let now = now_millis().unwrap(); assert!( expires_at >= now + 3_500_000 && expires_at <= now + 3_700_000, "expiresAt out of band: {expires_at}", @@ -588,7 +601,12 @@ mod tests { let creds = dir.path().join("creds.json"); let lock = dir.path().join("lock"); // Near-expiry but not expired — refresh failure warns + keeps token. - write_creds(&creds, "stale", Some("old-refresh"), now_millis() + 60_000); + write_creds( + &creds, + "stale", + Some("old-refresh"), + now_millis().unwrap() + 60_000, + ); let token = load_token_from(&creds, &lock, &server.uri()).await.unwrap(); assert_eq!(token, "stale"); @@ -614,6 +632,7 @@ mod tests { let msg = format!("{err:#}"); assert!(msg.contains("expired OAuth"), "wrapped context: {msg}"); } + // ── read_credentials ── #[test] @@ -832,7 +851,7 @@ mod tests { assert_eq!(oauth["accessToken"], "new-access"); assert_eq!(oauth["refreshToken"], "new-refresh"); let expires_at = oauth["expiresAt"].as_u64().unwrap(); - let now = now_millis(); + let now = now_millis().unwrap(); // expires_in is 3600s → 3_600_000ms from now, with tolerance for test execution time assert!( expires_at >= now + 3_500_000, diff --git a/crates/oxide-code/src/message.rs b/crates/oxide-code/src/message.rs index 20c58960..cf2ad0eb 100644 --- a/crates/oxide-code/src/message.rs +++ b/crates/oxide-code/src/message.rs @@ -13,7 +13,7 @@ fn is_default(v: &T) -> bool { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] -pub enum Role { +pub(crate) enum Role { User, Assistant, } @@ -25,7 +25,7 @@ pub enum Role { /// `RedactedThinking` blocks. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] -pub enum ContentBlock { +pub(crate) enum ContentBlock { Text { text: String, }, @@ -57,20 +57,20 @@ pub enum ContentBlock { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Message { - pub role: Role, - pub content: Vec, +pub(crate) struct Message { + pub(crate) role: Role, + pub(crate) content: Vec, } impl Message { - pub fn user(text: impl Into) -> Self { + pub(crate) fn user(text: impl Into) -> Self { Self { role: Role::User, content: vec![ContentBlock::Text { text: text.into() }], } } - pub fn assistant(text: impl Into) -> Self { + pub(crate) fn assistant(text: impl Into) -> Self { Self { role: Role::Assistant, content: vec![ContentBlock::Text { text: text.into() }], @@ -88,7 +88,7 @@ impl Message { /// /// Only the most recent assistant message can have un-stripped trailing thinking /// — earlier ones were already processed in prior iterations. -pub fn strip_trailing_thinking(messages: &mut [Message]) { +pub(crate) fn strip_trailing_thinking(messages: &mut [Message]) { let Some(msg) = messages.iter_mut().rfind(|m| m.role == Role::Assistant) else { return; }; diff --git a/crates/oxide-code/src/model.rs b/crates/oxide-code/src/model.rs index 8d21c4fd..ce0447d0 100644 --- a/crates/oxide-code/src/model.rs +++ b/crates/oxide-code/src/model.rs @@ -351,7 +351,7 @@ mod tests { } #[test] - fn lookup_returns_none_for_unknown_model_family() { + fn lookup_unknown_model_family_is_absent() { // A hypothetical future family with no entry should miss entirely // so callers can opt into conservative defaults. assert!(lookup("claude-opus-5-0").is_none()); diff --git a/crates/oxide-code/src/prompt.rs b/crates/oxide-code/src/prompt.rs index 00c57cfa..0732e56d 100644 --- a/crates/oxide-code/src/prompt.rs +++ b/crates/oxide-code/src/prompt.rs @@ -340,7 +340,7 @@ mod tests { } #[test] - fn build_user_context_empty_claude_md_returns_none() { + fn build_user_context_empty_claude_md_is_absent() { assert!(build_user_context("", "Today's date is 2026-04-12.").is_none()); } diff --git a/crates/oxide-code/src/prompt/environment.rs b/crates/oxide-code/src/prompt/environment.rs index 3270eb07..290dba3c 100644 --- a/crates/oxide-code/src/prompt/environment.rs +++ b/crates/oxide-code/src/prompt/environment.rs @@ -357,7 +357,7 @@ mod tests { // ── detect_os_version ── #[test] - fn detect_os_version_returns_nonempty() { + fn detect_os_version_is_nonempty() { let version = detect_os_version(); assert!(!version.is_empty()); } diff --git a/crates/oxide-code/src/prompt/instructions.rs b/crates/oxide-code/src/prompt/instructions.rs index db3a65cf..90edc0e3 100644 --- a/crates/oxide-code/src/prompt/instructions.rs +++ b/crates/oxide-code/src/prompt/instructions.rs @@ -399,7 +399,7 @@ mod tests { } #[tokio::test] - async fn load_files_returns_empty_when_no_files_exist() { + async fn load_files_absent_yields_empty() { let slots = vec![Slot { candidates: vec![ PathBuf::from("/nonexistent/CLAUDE.md"), diff --git a/crates/oxide-code/src/session/history.rs b/crates/oxide-code/src/session/history.rs index ba84dc0d..f24c0e7a 100644 --- a/crates/oxide-code/src/session/history.rs +++ b/crates/oxide-code/src/session/history.rs @@ -144,7 +144,7 @@ mod tests { // ── walk_transcript ── #[test] - fn walk_transcript_empty_returns_empty() { + fn walk_transcript_empty_yields_no_interactions() { assert!(walk_transcript(&[]).is_empty()); } diff --git a/crates/oxide-code/src/session/list_view.rs b/crates/oxide-code/src/session/list_view.rs index 41af0189..e1d7d6ab 100644 --- a/crates/oxide-code/src/session/list_view.rs +++ b/crates/oxide-code/src/session/list_view.rs @@ -241,6 +241,7 @@ mod tests { render_list(&mut buf, &store, true, UtcOffset::UTC, None).unwrap(); assert_eq!(String::from_utf8(buf).unwrap(), "No sessions found.\n",); } + // ── render_sessions ── #[test] @@ -429,7 +430,7 @@ mod tests { } #[test] - fn truncate_to_width_returns_empty_when_max_width_is_zero() { + fn truncate_to_width_zero_produces_empty() { assert_eq!(truncate_to_width("anything", 0), ""); } diff --git a/crates/oxide-code/src/session/manager.rs b/crates/oxide-code/src/session/manager.rs index aa4e81b1..ed60a567 100644 --- a/crates/oxide-code/src/session/manager.rs +++ b/crates/oxide-code/src/session/manager.rs @@ -417,14 +417,20 @@ fn extract_user_text(message: &Message) -> Option<&str> { } /// Truncates a title to `max_len` characters, adding "..." if truncated. +/// +/// `max_len` must be at least 4 (three for the ellipsis, one for at least one +/// character of the title). Only internal callers drive this with +/// [`MAX_TITLE_LEN`] = 80, so the precondition is a sanity check, not user +/// input handling. fn truncate_title(s: &str, max_len: usize) -> String { + debug_assert!(max_len >= 4, "truncate_title: max_len must be >= 4"); let trimmed = s.lines().next().unwrap_or(s).trim(); if trimmed.chars().count() <= max_len { trimmed.to_owned() } else { let boundary = trimmed .char_indices() - .nth(max_len.saturating_sub(3)) + .nth(max_len - 3) .map_or(trimmed.len(), |(i, _)| i); format!("{}...", &trimmed[..boundary]) } diff --git a/crates/oxide-code/src/session/store.rs b/crates/oxide-code/src/session/store.rs index 3173ea99..51b55253 100644 --- a/crates/oxide-code/src/session/store.rs +++ b/crates/oxide-code/src/session/store.rs @@ -1099,7 +1099,7 @@ mod tests { } #[test] - fn load_session_data_nonexistent_session_returns_error() { + fn load_session_data_nonexistent_session_errors() { let dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); assert!(store.load_session_data("nonexistent").is_err()); diff --git a/crates/oxide-code/src/session/writer.rs b/crates/oxide-code/src/session/writer.rs index f4de39ed..a37ac856 100644 --- a/crates/oxide-code/src/session/writer.rs +++ b/crates/oxide-code/src/session/writer.rs @@ -107,9 +107,17 @@ mod tests { record_session_message(&session, &Message::user("hello"), None).await; + // Assert against the wire shape rather than the internal `Entry` + // variant — parsing + destructuring would leave an unreachable + // "got unexpected variant" arm that can't be driven by this code + // path. The JSON assertion is just as strict and has no dead arm. let content = std::fs::read_to_string(test_session_file(dir.path(), &sid)).unwrap(); - assert!(content.contains(r#""type":"message""#)); - assert!(content.contains("hello")); + let last_line = content.lines().last().expect("session file has no lines"); + let json: serde_json::Value = serde_json::from_str(last_line).expect("valid JSONL"); + assert_eq!(json["type"], "message"); + assert_eq!(json["message"]["role"], "user"); + assert_eq!(json["message"]["content"][0]["type"], "text"); + assert_eq!(json["message"]["content"][0]["text"], "hello"); } // ── log_session_err ── diff --git a/crates/oxide-code/src/tool.rs b/crates/oxide-code/src/tool.rs index 8c75347e..f51a0e72 100644 --- a/crates/oxide-code/src/tool.rs +++ b/crates/oxide-code/src/tool.rs @@ -441,6 +441,18 @@ pub(crate) fn entry_mtime(entry: &ignore::DirEntry) -> SystemTime { // ── Formatting ── +/// Converts a byte count to megabytes for display. Centralizes the +/// `clippy::cast_precision_loss` suppression — every file-size cap in +/// this crate is well below 2^53 bytes, so the f64 cast is exact. +pub(crate) fn bytes_to_mb(bytes: u64) -> f64 { + #[expect( + clippy::cast_precision_loss, + reason = "MB display tolerates minor precision loss at > 2^53 bytes; file size caps are nowhere near that" + )] + let mb = bytes as f64 / (1024.0 * 1024.0); + mb +} + /// Cap on tool output size. Prevents flooding the LLM context window. /// Roughly 32K tokens at ~4 chars / token. pub(crate) const MAX_OUTPUT_BYTES: usize = 128 * 1024; @@ -454,20 +466,19 @@ pub(crate) const MAX_LINE_LENGTH: usize = 500; /// Truncates a line beyond [`MAX_LINE_LENGTH`] characters, appending a /// `[N chars]` suffix. Returns a borrowed slice when no truncation is needed. pub(crate) fn truncate_line(line: &str) -> Cow<'_, str> { + // Fast path: fewer bytes than the char cap means we can't exceed the cap. if line.len() <= MAX_LINE_LENGTH { return Cow::Borrowed(line); } - - // Single pass: find both the truncation boundary and the total char count. - let mut boundary = 0; - let mut total_chars = 0; - for (i, (byte_idx, _)) in line.char_indices().enumerate() { - if i == MAX_LINE_LENGTH { - boundary = byte_idx; - } - total_chars = i + 1; + let total_chars = line.chars().count(); + if total_chars <= MAX_LINE_LENGTH { + return Cow::Borrowed(line); } - + // `nth(MAX_LINE_LENGTH)` is Some: we just proved `total_chars > MAX_LINE_LENGTH`. + let boundary = line + .char_indices() + .nth(MAX_LINE_LENGTH) + .map_or(line.len(), |(i, _)| i); Cow::Owned(format!("{}... [{total_chars} chars]", &line[..boundary])) } @@ -981,4 +992,17 @@ mod tests { assert_eq!(prefix.chars().count(), MAX_LINE_LENGTH); assert_eq!(suffix, format!("{} chars]", MAX_LINE_LENGTH + 99)); } + + #[test] + fn truncate_line_multibyte_under_char_cap_unchanged() { + // Regression: MAX_LINE_LENGTH é's take 2*MAX_LINE_LENGTH bytes, + // tripping a byte-length gate but staying within the char cap. + // A bad implementation returns `"... [N chars]"` with an empty + // prefix (silent data loss); the correct behavior is a no-op + // borrow. + let line = "é".repeat(MAX_LINE_LENGTH); + let result = truncate_line(&line); + assert!(matches!(result, Cow::Borrowed(_))); + assert_eq!(result.as_ref(), line); + } } diff --git a/crates/oxide-code/src/tool/bash.rs b/crates/oxide-code/src/tool/bash.rs index 58d22b84..f4510de6 100644 --- a/crates/oxide-code/src/tool/bash.rs +++ b/crates/oxide-code/src/tool/bash.rs @@ -210,13 +210,16 @@ fn kill_process_group(pgid: Option) { // ── Output Truncation ── +/// Upper bound on the bytes inserted by [`truncate_output`] between the +/// head and tail halves. The separator line is ~35 bytes; 50 gives +/// headroom for large line counts without slipping truncated output +/// past [`MAX_OUTPUT_BYTES`](super::MAX_OUTPUT_BYTES) by more than this. +const TRUNCATION_OVERHEAD: usize = 50; + /// Truncates output that exceeds [`MAX_OUTPUT_BYTES`](super::MAX_OUTPUT_BYTES), /// keeping the first and last halves so the LLM sees both the beginning of the /// output and the end (where error messages and summaries usually appear). fn truncate_output(content: &mut String) { - // The separator line is ~35 bytes; 50 gives headroom for large line counts. - const TRUNCATION_OVERHEAD: usize = 50; - if content.len() <= super::MAX_OUTPUT_BYTES { return; } @@ -378,7 +381,7 @@ mod tests { assert!(content.starts_with("HEAD_SENTINEL\n")); assert!(content.ends_with("TAIL_SENTINEL\n")); assert!(content.contains("lines truncated")); - assert!(content.len() <= MAX_OUTPUT_BYTES + 100); + assert!(content.len() <= MAX_OUTPUT_BYTES + TRUNCATION_OVERHEAD); assert!(content.len() >= MAX_OUTPUT_BYTES / 2); // Separator sits between head and tail, not at the edges. let sep_pos = content.find("lines truncated").unwrap(); @@ -404,6 +407,13 @@ mod tests { assert!(content.contains("lines truncated")); assert!(content.starts_with("aaaa")); assert!(content.ends_with('b')); + // The emoji straddles the byte boundary at `MAX_OUTPUT_BYTES / 2`; + // `floor_char_boundary` must drop it from the head and the tail + // both starts past it — so the 4-byte sequence must not appear + // anywhere in the truncated output. `starts_with("aaaa")` / + // `ends_with('b')` alone would pass even if a partial emoji + // leaked into the middle of the string. + assert!(!content.contains(emoji)); } #[test] diff --git a/crates/oxide-code/src/tool/edit.rs b/crates/oxide-code/src/tool/edit.rs index 4b3b283c..e5e6a938 100644 --- a/crates/oxide-code/src/tool/edit.rs +++ b/crates/oxide-code/src/tool/edit.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::future::Future; use std::pin::Pin; @@ -174,11 +175,7 @@ async fn edit_file( .map_err(|e| format!("Error reading {path}: {e}"))?; if metadata.len() > MAX_EDIT_FILE_SIZE { - #[expect( - clippy::cast_precision_loss, - reason = "file sizes are well within f64 range" - )] - let mb = metadata.len() as f64 / (1024.0 * 1024.0); + let mb = super::bytes_to_mb(metadata.len()); let limit_mb = MAX_EDIT_FILE_SIZE / (1024 * 1024); return Err(format!( "File is too large ({mb:.1} MB, max {limit_mb} MB). \ @@ -191,11 +188,11 @@ async fn edit_file( .map_err(|e| format!("Error reading {path}: {e}"))?; let eol = dominant_eol(&content); - let content = normalize_eol(content); - let old_string = &normalize_eol(old_string.to_owned()); - let new_string = &normalize_eol(new_string.to_owned()); + let content = normalize_eol(&content); + let old_string = normalize_eol(old_string); + let new_string = normalize_eol(new_string); - let match_count = content.matches(old_string.as_str()).count(); + let match_count = content.matches(old_string.as_ref()).count(); if match_count == 0 { return Err(format!( "old_string not found in {path}. Make sure the string matches exactly, \ @@ -212,9 +209,9 @@ async fn edit_file( } let updated = if replace_all { - content.replace(old_string, new_string) + content.replace(old_string.as_ref(), new_string.as_ref()) } else { - content.replacen(old_string, new_string, 1) + content.replacen(old_string.as_ref(), new_string.as_ref(), 1) }; let updated = apply_eol(updated, eol); @@ -241,11 +238,11 @@ fn dominant_eol(content: &str) -> &'static str { if crlf > lf_only { "\r\n" } else { "\n" } } -fn normalize_eol(content: String) -> String { +fn normalize_eol(content: &str) -> Cow<'_, str> { if content.contains("\r\n") { - content.replace("\r\n", "\n") + Cow::Owned(content.replace("\r\n", "\n")) } else { - content + Cow::Borrowed(content) } } @@ -766,12 +763,19 @@ mod tests { #[test] fn normalize_eol_converts_crlf_to_lf() { - assert_eq!(normalize_eol("a\r\nb\r\n".into()), "a\nb\n"); + let out = normalize_eol("a\r\nb\r\n"); + assert_eq!(out, "a\nb\n"); + assert!(matches!(out, Cow::Owned(_))); } #[test] - fn normalize_eol_lf_unchanged() { - assert_eq!(normalize_eol("a\nb\n".into()), "a\nb\n"); + fn normalize_eol_lf_input_borrows() { + // Pure-LF input must not allocate — the Cow lets the caller + // skip a copy on the common case. `Cow::Borrowed` also locks + // in that the returned reference ties back to the input. + let out = normalize_eol("a\nb\n"); + assert_eq!(out, "a\nb\n"); + assert!(matches!(out, Cow::Borrowed(_))); } // ── apply_eol ── diff --git a/crates/oxide-code/src/tool/glob.rs b/crates/oxide-code/src/tool/glob.rs index 35f0390b..06324d8f 100644 --- a/crates/oxide-code/src/tool/glob.rs +++ b/crates/oxide-code/src/tool/glob.rs @@ -147,6 +147,7 @@ fn glob_files(pattern: &str, search_path: Option<&str>) -> Result MAX_READ_FILE_SIZE { - #[expect( - clippy::cast_precision_loss, - reason = "file sizes are well within f64 range" - )] - let mb = metadata.len() as f64 / (1024.0 * 1024.0); + let mb = super::bytes_to_mb(metadata.len()); let limit_mb = MAX_READ_FILE_SIZE / (1024 * 1024); return Err(format!( "File is too large ({mb:.1} MB, max {limit_mb} MB). \ @@ -255,6 +251,7 @@ mod tests { use super::super::MAX_OUTPUT_BYTES; use super::*; + // ── run ── #[tokio::test] diff --git a/crates/oxide-code/src/tool/write.rs b/crates/oxide-code/src/tool/write.rs index c276fe34..ab0adce8 100644 --- a/crates/oxide-code/src/tool/write.rs +++ b/crates/oxide-code/src/tool/write.rs @@ -103,6 +103,7 @@ async fn write_file(path: &str, content: &str) -> (Result, bool) #[cfg(test)] mod tests { use super::*; + // ── run ── #[tokio::test] diff --git a/crates/oxide-code/src/tui.rs b/crates/oxide-code/src/tui.rs index 1dcfd059..1c36dc36 100644 --- a/crates/oxide-code/src/tui.rs +++ b/crates/oxide-code/src/tui.rs @@ -10,7 +10,6 @@ pub(crate) mod component; pub(crate) mod components; pub(crate) mod event; pub(crate) mod markdown; -pub(crate) mod pending_calls; pub(crate) mod terminal; pub(crate) mod theme; pub(crate) mod wrap; diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index eeb54512..ab059dca 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -21,10 +21,10 @@ use super::component::Component; use super::components::chat::ChatView; use super::components::input::InputArea; use super::components::status::{Status, StatusBar}; -use super::pending_calls::{PendingCall, PendingCalls, result_header}; use super::terminal::{Tui, draw_sync}; use super::theme::Theme; use crate::agent::event::{AgentEvent, UserAction}; +use crate::agent::pending_calls::{PendingCall, PendingCalls, result_header}; use crate::message::Message; use crate::tool::{ToolMetadata, ToolRegistry, ToolResultView}; @@ -154,9 +154,11 @@ impl App { } /// Translate a user action into UI state changes, then forward it to the - /// agent loop over the bounded channel. `try_send` would only fail if the - /// agent task has died; in that case `should_quit` tears down the TUI on - /// the next iteration so nothing is lost. + /// agent loop over the bounded channel. A `Closed` error means the agent + /// task has died; surface that so the user isn't left staring at a + /// wedged "Streaming" status. `Full` is implausible (input is disabled + /// while streaming, so at most one in-flight action at a time), but + /// worth treating symmetrically if it ever trips. fn dispatch_user_action(&mut self, action: UserAction) { match &action { UserAction::SubmitPrompt(text) => { @@ -168,7 +170,20 @@ impl App { self.should_quit = true; } } - _ = self.user_tx.try_send(action); + if let Err(e) = self.user_tx.try_send(action) { + match e { + mpsc::error::TrySendError::Closed(_) => { + self.chat + .push_error("agent task exited unexpectedly; restart `ox` to recover"); + self.input.set_enabled(false); + self.should_quit = true; + } + mpsc::error::TrySendError::Full(_) => { + self.chat + .push_error("user-action channel full; prompt dropped (this is a bug)"); + } + } + } } fn handle_agent_event(&mut self, event: AgentEvent) { @@ -462,6 +477,48 @@ mod tests { assert_eq!(app.status_bar.status(), Status::Idle); } + #[test] + fn dispatch_closed_channel_surfaces_error_and_quits() { + // Dropping `user_rx` simulates the agent task exiting — try_send + // returns `Closed`. The UI must announce the failure and tear + // itself down so the user isn't left staring at a stuck spinner. + let (mut app, rx, _agent_tx) = test_app(None); + drop(rx); + + app.dispatch_user_action(UserAction::SubmitPrompt("hi".to_owned())); + + assert!(app.should_quit, "closed channel must trigger teardown"); + assert!( + !app.input.is_enabled(), + "input stays disabled during teardown" + ); + // User message pushed before try_send, error block after — two entries. + assert_eq!(app.chat.entry_count(), 2); + assert!( + app.chat.last_is_error(), + "closed-channel error should be the final block" + ); + } + + #[test] + fn dispatch_full_channel_surfaces_error_but_keeps_app_alive() { + // Fill the 8-slot channel without draining, then overflow. Full is + // implausible in production (input disables during streaming) but + // if it ever trips, the app must NOT tear down — just warn. + let (mut app, _rx, _agent_tx) = test_app(None); + for _ in 0..8 { + app.dispatch_user_action(UserAction::SubmitPrompt("fill".to_owned())); + } + let before_overflow = app.chat.entry_count(); + + app.dispatch_user_action(UserAction::SubmitPrompt("overflow".to_owned())); + + assert!(!app.should_quit, "Full is not fatal"); + // One more user message (pushed unconditionally) plus an error block. + assert_eq!(app.chat.entry_count(), before_overflow + 2); + assert!(app.chat.last_is_error()); + } + // ── handle_agent_event ── #[test] diff --git a/crates/oxide-code/src/tui/components/chat.rs b/crates/oxide-code/src/tui/components/chat.rs index 6145c2f7..ced11d27 100644 --- a/crates/oxide-code/src/tui/components/chat.rs +++ b/crates/oxide-code/src/tui/components/chat.rs @@ -27,11 +27,13 @@ use self::blocks::{ ToolCallBlock, ToolResultBlock, UserMessage, last_has_width, }; use crate::agent::event::UserAction; +use crate::agent::pending_calls::{ + FALLBACK_RESULT_HEADER, PendingCall, PendingCalls, result_header, +}; use crate::message::Message; use crate::session::history::{Interaction, walk_transcript}; use crate::tool::{ToolMetadata, ToolRegistry, ToolResultView}; use crate::tui::component::Component; -use crate::tui::pending_calls::{FALLBACK_RESULT_HEADER, PendingCall, PendingCalls, result_header}; use crate::tui::theme::Theme; /// Scrollable chat message list with markdown rendering, tool call @@ -1989,7 +1991,7 @@ mod tests { } #[test] - fn handle_event_unhandled_key_returns_none() { + fn handle_event_unhandled_key_produces_no_action() { let mut chat = test_chat(); let action = chat.handle_event(&key_event(KeyCode::Char('a'))); assert!(action.is_none()); diff --git a/crates/oxide-code/src/tui/components/input.rs b/crates/oxide-code/src/tui/components/input.rs index 76fe60d3..55b3cdd1 100644 --- a/crates/oxide-code/src/tui/components/input.rs +++ b/crates/oxide-code/src/tui/components/input.rs @@ -453,6 +453,7 @@ mod tests { ); insta::assert_snapshot!(render_to_backend(&input, 30, 5)); } + // ── visual_line_count ── #[test] @@ -519,7 +520,7 @@ mod tests { // ── submit ── #[test] - fn submit_empty_returns_none() { + fn submit_empty_produces_no_action() { let mut input = test_input(); let action = input.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)); assert!(action.is_none()); diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 76c2b421..7f9784d0 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -418,7 +418,10 @@ mod tests { terminal.backend().clone() } - /// Returns row 0 as a plain string for substring assertions. + /// Returns row 0 as a plain string for substring assertions about + /// relative ordering, presence of ellipsis, or conditional slot + /// drops — cases where a full snapshot would be more brittle than + /// informative. fn render_top_row(bar: &mut StatusBar, width: u16) -> String { let backend = render_status(bar, width); let buf = backend.buffer(); @@ -441,45 +444,6 @@ mod tests { bar } - #[test] - fn render_idle_shows_ready() { - let mut bar = test_bar(); - let output = render_top_row(&mut bar, 80); - assert!(output.contains("ox")); - assert!(output.contains("test-model")); - assert!(output.contains("ready")); - } - - #[test] - fn render_streaming_shows_spinner() { - let mut bar = test_bar(); - bar.set_status(Status::Streaming); - let output = render_top_row(&mut bar, 80); - assert!(output.contains("streaming...")); - } - - #[test] - fn render_tool_running_shows_spinner() { - let mut bar = test_bar(); - bar.set_status(Status::ToolRunning); - let output = render_top_row(&mut bar, 80); - assert!(output.contains("running tool...")); - } - - #[test] - fn render_wide_shows_cwd() { - let mut bar = test_bar(); - let output = render_top_row(&mut bar, 120); - assert!(output.contains("~/test")); - } - - #[test] - fn render_narrow_omits_cwd() { - let mut bar = test_bar(); - let output = render_top_row(&mut bar, 30); - assert!(!output.contains("~/test")); - } - #[test] fn render_idle_with_title_shows_model_title_and_cwd() { let mut bar = bar_idle(Some("Fix login flow"), "~/projects/demo"); diff --git a/crates/oxide-code/src/tui/markdown/highlight.rs b/crates/oxide-code/src/tui/markdown/highlight.rs index c8ac0992..4f43e14a 100644 --- a/crates/oxide-code/src/tui/markdown/highlight.rs +++ b/crates/oxide-code/src/tui/markdown/highlight.rs @@ -133,7 +133,7 @@ mod tests { } #[test] - fn highlight_code_empty_code_returns_empty() { + fn highlight_code_empty_input_yields_empty() { let lines = highlight_code("rust", "", fallback()); assert!(lines.is_empty(), "empty code should produce no lines"); } diff --git a/crates/oxide-code/src/tui/markdown/render.rs b/crates/oxide-code/src/tui/markdown/render.rs index 5505b45c..c2fad98e 100644 --- a/crates/oxide-code/src/tui/markdown/render.rs +++ b/crates/oxide-code/src/tui/markdown/render.rs @@ -1523,7 +1523,7 @@ mod tests { } #[test] - fn fit_column_widths_empty_input_returns_empty() { + fn fit_column_widths_empty_input_yields_empty() { assert_eq!(fit_column_widths(&[], 80), Vec::::new()); } diff --git a/crates/oxide-code/src/tui/wrap.rs b/crates/oxide-code/src/tui/wrap.rs index 8fb6f2c5..20995ee0 100644 --- a/crates/oxide-code/src/tui/wrap.rs +++ b/crates/oxide-code/src/tui/wrap.rs @@ -231,11 +231,16 @@ mod tests { #[test] fn continuation_indent_applied() { - // " Hello world foo bar" (23 chars), width 16, indent 4 + // " Hello world foo bar" (23 chars), width 16, indent 4. + // First line fits " Hello world "; the break lands there and + // the continuation carries the 4-space indent plus "foo bar". let line = Line::from(vec![Span::raw(" "), Span::raw("Hello world foo bar")]); let result = wrap_line(line, 16, 4, None); - assert!(result.len() >= 2, "should wrap: {result:?}"); - // Continuation lines start with 4-space indent. + assert_eq!( + result.len(), + 2, + "should wrap to exactly two lines: {result:?}" + ); let cont = &result[1]; assert!( cont.spans[0].content.starts_with(" "), @@ -251,8 +256,13 @@ mod tests { Span::raw("normal text that is long enough to wrap"), ]); let result = wrap_line(line, 20, 0, None); - assert!(result.len() >= 2, "should wrap: {result:?}"); - // First span on first line should preserve bold. + // 44 chars at width 20 wraps at the two word boundaries: + // "Bold normal text " | "that is long enough " | "to wrap". + assert_eq!( + result.len(), + 3, + "should wrap to exactly three lines: {result:?}" + ); let first_span = &result[0].spans[0]; assert!( first_span.style.add_modifier.contains(Modifier::BOLD), @@ -262,9 +272,16 @@ mod tests { #[test] fn force_break_on_long_word() { - let line = Line::from("abcdefghijklmnopqrstuvwxyz"); - let result = wrap_line(line, 10, 0, None); - assert!(result.len() >= 2, "should force-break: {result:?}"); + let input = "abcdefghijklmnopqrstuvwxyz"; + let result = wrap_line(Line::from(input), 10, 0, None); + // 26 chars at width 10 must produce three segments: 10 + 10 + 6. + assert_eq!( + result.len(), + 3, + "should force-break into three lines: {result:?}" + ); + let texts: Vec<&str> = result.iter().map(|l| l.spans[0].content.as_ref()).collect(); + assert_eq!(texts, [&input[..10], &input[10..20], &input[20..]]); } #[test] diff --git a/crates/oxide-code/src/util/env.rs b/crates/oxide-code/src/util/env.rs index e8146c09..bada1436 100644 --- a/crates/oxide-code/src/util/env.rs +++ b/crates/oxide-code/src/util/env.rs @@ -24,21 +24,21 @@ mod tests { // ── string ── #[test] - fn string_unset_returns_none() { + fn string_unset_is_absent() { temp_env::with_var_unset(KEY, || { assert_eq!(string(KEY), None); }); } #[test] - fn string_empty_returns_none() { + fn string_empty_is_absent() { temp_env::with_var(KEY, Some(""), || { assert_eq!(string(KEY), None); }); } #[test] - fn string_non_empty_returns_owned_value() { + fn string_non_empty_reads_value() { temp_env::with_var(KEY, Some("hello"), || { assert_eq!(string(KEY).as_deref(), Some("hello")); }); @@ -65,7 +65,7 @@ mod tests { } #[test] - fn bool_unset_and_empty_both_return_none() { + fn bool_unset_and_empty_are_absent() { temp_env::with_var_unset(KEY, || { assert_eq!(bool(KEY), None); });