From f61c7e548e20dd32177ce6afb4dda80986c46393 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Wed, 13 May 2026 19:00:08 +0800 Subject: [PATCH 1/5] test: phrase test names by scenario rather than mechanism Partial pass over the sweep identified by two reviewers against CLAUDE.md's naming rule ("name tests by the scenario they cover, prefixed by the function name"). Touches only name changes where the current wording described internal operations (appends/routes/returns none/sets field) instead of observable outcomes; every rename has a sibling test or adjacent scenario-phrased neighbour to anchor the style. Safety-net commit: this branch will be rebased onto main after PR #84 lands and the remaining comment / test-ordering priorities are addressed in follow-up commits. --- crates/oxide-code/src/client/anthropic.rs | 2 +- crates/oxide-code/src/config/oauth.rs | 2 +- crates/oxide-code/src/session/resolver.rs | 2 +- crates/oxide-code/src/session/state.rs | 4 ++-- crates/oxide-code/src/tui/components/chat.rs | 8 ++++---- crates/oxide-code/src/tui/components/input.rs | 6 +++--- crates/oxide-code/src/tui/components/input/popup.rs | 4 ++-- crates/oxide-code/src/tui/components/status.rs | 2 +- crates/oxide-code/src/tui/modal/kv_overview.rs | 2 +- crates/oxide-code/src/tui/theme/loader.rs | 2 +- 10 files changed, 17 insertions(+), 17 deletions(-) diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 3602e865..de86385f 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -1132,7 +1132,7 @@ mod tests { } #[tokio::test] - async fn stream_message_prepends_user_context_as_synthetic_user_message() { + async fn stream_message_with_user_context_emits_leading_synthetic_user_message() { let server = MockServer::start().await; let body_sink: Captured = captured(); let sink_clone = std::sync::Arc::clone(&body_sink); diff --git a/crates/oxide-code/src/config/oauth.rs b/crates/oxide-code/src/config/oauth.rs index 30821ee2..2ed2fb3a 100644 --- a/crates/oxide-code/src/config/oauth.rs +++ b/crates/oxide-code/src/config/oauth.rs @@ -485,7 +485,7 @@ mod tests { } #[tokio::test] - async fn load_token_from_refreshes_near_expiry_and_writes_back() { + async fn load_token_from_near_expiry_refreshes_and_persists_new_credentials() { let server = MockServer::start().await; Mock::given(method("POST")) .and(wm_path("/")) diff --git a/crates/oxide-code/src/session/resolver.rs b/crates/oxide-code/src/session/resolver.rs index 79b1dde8..ba442fc4 100644 --- a/crates/oxide-code/src/session/resolver.rs +++ b/crates/oxide-code/src/session/resolver.rs @@ -461,7 +461,7 @@ mod tests { } #[test] - fn resolve_prefix_to_info_no_match_returns_ok_none() { + fn resolve_prefix_to_info_no_match_is_ok_absent() { let dir = tempfile::tempdir().unwrap(); let store = test_store(dir.path()); assert!( diff --git a/crates/oxide-code/src/session/state.rs b/crates/oxide-code/src/session/state.rs index ca084d7d..8788c642 100644 --- a/crates/oxide-code/src/session/state.rs +++ b/crates/oxide-code/src/session/state.rs @@ -817,7 +817,7 @@ mod tests { // ── current_git_branch ── #[test] - fn current_git_branch_in_a_real_repo_returns_the_branch_name() { + fn current_git_branch_in_a_real_repo_yields_the_branch_name() { // Skipped silently if `git` isn't on PATH so CI without git doesn't fail. let dir = tempfile::tempdir().unwrap(); let cwd = dir.path().to_str().unwrap(); @@ -851,7 +851,7 @@ mod tests { } #[test] - fn current_git_branch_outside_a_repo_returns_none() { + fn current_git_branch_outside_a_repo_is_absent() { let dir = tempfile::tempdir().unwrap(); assert_eq!(current_git_branch(dir.path().to_str().unwrap()), None); } diff --git a/crates/oxide-code/src/tui/components/chat.rs b/crates/oxide-code/src/tui/components/chat.rs index 52ddd1b3..ac5bb51a 100644 --- a/crates/oxide-code/src/tui/components/chat.rs +++ b/crates/oxide-code/src/tui/components/chat.rs @@ -2136,7 +2136,7 @@ mod tests { // ── update_layout ── #[test] - fn update_layout_sets_viewport_height() { + fn update_layout_adopts_area_height_as_viewport() { let mut chat = test_chat(); _ = chat.update_layout(Rect::new(0, 0, 80, 30)); assert_eq!(chat.viewport_height, 30); @@ -2342,7 +2342,7 @@ mod tests { // ── render ── #[test] - fn render_updates_content_height() { + fn render_measures_content_height_from_layout() { let mut chat = test_chat(); chat.push_user_message("hi".to_owned()); render_chat(&mut chat, 80, 24); @@ -2520,7 +2520,7 @@ mod tests { // ── scroll_to_bottom / scroll_up / scroll_down ── #[test] - fn scroll_to_bottom_sets_offset_correctly() { + fn scroll_to_bottom_aligns_last_row_with_viewport_bottom() { let mut chat = test_chat(); chat.content_height.set(100); chat.viewport_height = 20; @@ -2591,7 +2591,7 @@ mod tests { // ── build_text ── #[test] - fn build_text_empty_returns_no_lines() { + fn build_text_empty_chat_has_no_lines() { let chat = test_chat(); assert!(chat.build_text(60).lines.is_empty()); } diff --git a/crates/oxide-code/src/tui/components/input.rs b/crates/oxide-code/src/tui/components/input.rs index 8fc84c98..8d08d64b 100644 --- a/crates/oxide-code/src/tui/components/input.rs +++ b/crates/oxide-code/src/tui/components/input.rs @@ -1086,7 +1086,7 @@ mod tests { } #[test] - fn ghost_text_from_state_non_empty_prefix_returns_none() { + fn ghost_text_from_state_non_empty_prefix_is_absent() { let state = PopupState::Arg { name: "model", prefix: "claude-", @@ -1095,7 +1095,7 @@ mod tests { } #[test] - fn ghost_text_from_state_arg_without_usage_returns_none() { + fn ghost_text_from_state_arg_without_usage_is_absent() { let state = PopupState::Arg { name: "init", prefix: "", @@ -1104,7 +1104,7 @@ mod tests { } #[test] - fn ghost_text_from_state_name_mode_returns_none_even_with_usage() { + fn ghost_text_from_state_name_mode_is_absent_even_with_usage() { let state = PopupState::Name("mo"); assert_eq!(ghost_text_from_state(&state, Some("[]")), None); } diff --git a/crates/oxide-code/src/tui/components/input/popup.rs b/crates/oxide-code/src/tui/components/input/popup.rs index 4497ee2a..bac977a5 100644 --- a/crates/oxide-code/src/tui/components/input/popup.rs +++ b/crates/oxide-code/src/tui/components/input/popup.rs @@ -408,7 +408,7 @@ mod tests { } #[test] - fn select_prev_decrements_when_not_at_top() { + fn select_prev_when_not_at_top_moves_up_one_row() { // Pin the non-wrap branch — the decrement path is otherwise dead because select_prev() // from row 0 always wraps. let mut popup = name_popup(""); @@ -604,7 +604,7 @@ mod tests { } #[test] - fn scroll_offset_at_exactly_cap_returns_zero_for_last_row() { + fn scroll_offset_at_exactly_cap_does_not_scroll_last_row() { // Boundary: total == MAX_VISIBLE_ROWS hits the `<=` early-return. Tightening the boundary // pins the invariant for the only case where the edge matters. let mut p = long_popup(MAX_VISIBLE_ROWS); diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 9279a0ac..7c02526a 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -330,7 +330,7 @@ mod tests { } #[test] - fn tick_streaming_increments_counter_before_threshold() { + fn tick_streaming_before_threshold_does_not_advance_spinner_frame() { let mut bar = test_bar(); bar.set_status(Status::Streaming); diff --git a/crates/oxide-code/src/tui/modal/kv_overview.rs b/crates/oxide-code/src/tui/modal/kv_overview.rs index 3c2d7267..38e77fc8 100644 --- a/crates/oxide-code/src/tui/modal/kv_overview.rs +++ b/crates/oxide-code/src/tui/modal/kv_overview.rs @@ -187,7 +187,7 @@ mod tests { } #[test] - fn label_width_empty_sections_returns_zero() { + fn label_width_empty_sections_is_zero() { let m = KvOverview::new("T", vec![]); assert_eq!(m.label_width(), 0); } diff --git a/crates/oxide-code/src/tui/theme/loader.rs b/crates/oxide-code/src/tui/theme/loader.rs index fb5e2f40..70d7bece 100644 --- a/crates/oxide-code/src/tui/theme/loader.rs +++ b/crates/oxide-code/src/tui/theme/loader.rs @@ -552,7 +552,7 @@ mod tests { /// pass a happy-path override test, but this assertion fails /// because patching `tool_icon` would visibly alter `tool_border`. #[test] - fn slot_for_name_routes_each_name_to_a_unique_slot() { + fn slot_for_name_each_name_resolves_to_a_unique_slot() { let sentinel = Slot { fg: Some(Color::Rgb(0xde, 0xad, 0xbe)), bg: None, From cfc30012c6059f7f2a8a9257e71485e814ccb8b8 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 11:10:53 +0800 Subject: [PATCH 2/5] docs: tighten guide prose --- CLAUDE.md | 2 +- docs/design/slash/compact.md | 2 +- docs/guide/configuration.md | 12 ++++++------ docs/guide/slash-commands.md | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9a7fa3b5..a9a4e765 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,7 +193,7 @@ ox # Start an interactive session - Use `#[expect(lint)]` instead of `#[allow(lint)]`. `#[expect]` warns when the suppressed lint is no longer triggered, preventing stale suppressions from accumulating. - `#[expect]` reason strings must describe the current state rather than future plans. -- For complexity / size lints (`clippy::too_many_lines`, `clippy::cognitive_complexity`, etc.), the default response is to **extract a helper**. Reach for `#[expect]` only when the function is irreducibly cohesive: and say so in the reason string. +- For complexity / size lints (`clippy::too_many_lines`, `clippy::cognitive_complexity`, etc.), the default response is to **extract a helper**. Reach for `#[expect]` only when the function is irreducibly cohesive, and explain that in the reason string. ### Section Dividers diff --git a/docs/design/slash/compact.md b/docs/design/slash/compact.md index 64ff2515..2afe21a1 100644 --- a/docs/design/slash/compact.md +++ b/docs/design/slash/compact.md @@ -34,7 +34,7 @@ The TUI's `App::apply_session_compacted` clears the chat, pushes a single `Compa 8. **`Entry::Compact` JSONL boundary.** Carries `summary`, `pre_message_count`, optional `instructions`, and `timestamp`. Position: written immediately before the synthetic post-compact `Entry::Message`. Loader treats the compact entry as a chain reset, keeps `CompactInfo` for resume display, and only accepts messages that belong to the new tail. -9. **Same session id, do not roll.** All three reference CLIs converged on this. `/clear` rolls (intent reset), while `/compact` preserves (intent retained, context compressed). The JSONL file, session id, project, and title all carry through unchanged. The chain reset is purely an in-memory or replay concern. +9. **Preserve session identity.** All three reference CLIs converged on this. `/clear` rolls because intent resets, while `/compact` preserves because intent is retained and context is compressed. The JSONL file, session id, project, and title all carry through unchanged. The chain reset is purely an in-memory or replay concern. 10. **Reset the file tracker.** `/compact` discards the read history because Edit and Write contracts depend on a Read having happened _in the visible transcript_. Since the visible transcript is now the summary, the previous `Read`s are no longer "in scope" from a user-visible standpoint. The reset forces a fresh `Read` before any `Edit`, matching the post-`/clear` behavior. The trade-off (extra Reads after compact) is the right side of the safety / convenience line. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index dee6487d..186455a9 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -32,9 +32,9 @@ show_thinking = true | Key | Type | Default | Description | | ------------------ | ------- | --------------------------- | ---------------------------------------------------------- | -| `api_key` | string | - | Anthropic API key; user config only | -| `base_url` | string | `https://api.anthropic.com` | API base URL; user config only | -| `extra_ca_certs` | string | - | PEM bundle appended to the trust store; user config only | +| `api_key` | string | - | Anthropic API key (user config only) | +| `base_url` | string | `https://api.anthropic.com` | API base URL (user config only) | +| `extra_ca_certs` | string | - | PEM bundle appended to the trust store (user config only) | | `model` | string | `claude-opus-4-7[1m]` | Model to use | | `effort` | string | per-model (see below) | Intelligence-vs-latency tier | | `max_tokens` | integer | effort-derived (see below) | Max tokens per response | @@ -76,7 +76,7 @@ Use `base_url` only in `~/.config/ox/config.toml` or `ANTHROPIC_BASE_URL`. Proje #### `extra_ca_certs`: corporate trust anchors -oxide-code uses `rustls` with the built-in Mozilla CA bundle, so self-signed or private-CA endpoints like a corporate gateway fail with `invalid peer certificate: UnknownIssuer`. Point `extra_ca_certs` at a PEM bundle (one or more `-----BEGIN CERTIFICATE-----` blocks in one file) to append those roots to the trust store: +For corporate gateways or other private-CA endpoints, point `extra_ca_certs` at a PEM bundle to append those roots to oxide-code's trust store: ```toml [client] @@ -84,7 +84,7 @@ base_url = "https://gw.llm.corp.example/anthropic" extra_ca_certs = "~/.config/ox/corp-cachain.pem" ``` -The path accepts `~/` / `~` for `$HOME`. Use an absolute or `~`-rooted path: a relative value resolves against the process working directory at read time, so it will break whenever `ox` is launched from a different folder. The field is user-config only (and rejected in project `ox.toml`) because a checked-in trust-anchor path could widen TLS trust for the process. Equivalent env var: `OX_EXTRA_CA_CERTS`. +Use an absolute or `~`-rooted path. The field is user-config only because a checked-in trust-anchor path could widen TLS trust for the process. Equivalent env var: `OX_EXTRA_CA_CERTS`. #### `prompt_cache_ttl`: cache duration @@ -156,7 +156,7 @@ oxide-code checks three credential sources in order: Expired tokens are refreshed automatically. No configuration needed. -Prefer the environment variable (or OAuth) over `api_key` in a config file. `ox.toml` resolves by walking up from the current directory, so oxide-code rejects project-level `api_key` and `base_url`; user-level `~/.config/ox/config.toml` is safer but still plaintext on disk. +Prefer the environment variable (or OAuth) over `api_key` in a config file. `ox.toml` resolves by walking up from the current directory, so oxide-code rejects project-level `api_key` and `base_url`. User-level `~/.config/ox/config.toml` is safer, but still plaintext on disk. ## Environment variables diff --git a/docs/guide/slash-commands.md b/docs/guide/slash-commands.md index bfb500da..b1bd3430 100644 --- a/docs/guide/slash-commands.md +++ b/docs/guide/slash-commands.md @@ -38,11 +38,11 @@ Bare `/model` and `/effort` open pickers. Both apply on Enter and cancel on Esc. ## Compaction -`/compact` streams a one-shot summarization request through the live model, then replaces the in-memory transcript with a single boundary block: a header (`Compacted N messages → 1 summary`) plus the rendered summary. The next prompt continues from the summary rather than the full prior chat. +`/compact` summarizes the visible conversation into a single `Compacted N messages → 1 summary` block. The next prompt continues from that summary rather than the full prior chat. `/compact ` appends free-text focus instructions to the rubric (e.g., `/compact focus on the build error and how we fixed it`). Useful when only a slice of the work matters going forward. -The summary lands in the JSONL as a `compact` boundary entry plus a synthetic continuation message. Resuming via `ox -c` shows only the post-compact tail. The pre-compact transcript stays on disk for archival but is not replayed in chat. The file-change tracker resets on compact, so any `Edit` after `/compact` requires a fresh `Read`. Queued prompts survive the compaction. +Resuming via `ox -c` starts from the compacted summary and post-compact tail. The file-change tracker resets on compact, so any `Edit` after `/compact` requires a fresh `Read`. Queued prompts survive the compaction. `/compact` refuses on sessions with fewer than 4 messages, when the model returns an empty summary, or while a turn is in flight. From 803fa62e927839120194ece3e3dffa48eab4d830 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 11:11:02 +0800 Subject: [PATCH 3/5] docs: normalize rust comment punctuation --- crates/oxide-code/src/agent/event.rs | 24 +++++++++---------- .../src/client/anthropic/identity.rs | 2 +- .../oxide-code/src/client/anthropic/wire.rs | 4 ++-- crates/oxide-code/src/session/chain.rs | 2 +- crates/oxide-code/src/session/handle.rs | 18 +++++++------- crates/oxide-code/src/session/history.rs | 2 +- crates/oxide-code/src/session/path.rs | 2 +- crates/oxide-code/src/slash.rs | 6 ++--- crates/oxide-code/src/slash/clear.rs | 2 +- crates/oxide-code/src/slash/compact.rs | 6 ++--- crates/oxide-code/src/slash/delete.rs | 6 ++--- crates/oxide-code/src/slash/effort.rs | 4 ++-- crates/oxide-code/src/slash/model.rs | 2 +- crates/oxide-code/src/slash/rename.rs | 4 ++-- crates/oxide-code/src/slash/theme.rs | 2 +- crates/oxide-code/src/tui.rs | 4 ++-- .../src/tui/components/input/popup.rs | 10 ++++---- crates/oxide-code/src/tui/theme/color.rs | 6 ++--- crates/oxide-code/src/tui/theme/loader.rs | 18 +++++++------- 19 files changed, 62 insertions(+), 62 deletions(-) diff --git a/crates/oxide-code/src/agent/event.rs b/crates/oxide-code/src/agent/event.rs index d2e63e2b..86533070 100644 --- a/crates/oxide-code/src/agent/event.rs +++ b/crates/oxide-code/src/agent/event.rs @@ -23,38 +23,38 @@ pub(crate) const INTERRUPTED_MARKER: &str = "(interrupted)"; pub(crate) enum AgentEvent { /// Assistant text chunk to append to the in-flight reply. StreamToken(String), - /// Extended-thinking chunk; rendered separately from `StreamToken` and only when the user + /// Extended-thinking chunk. Rendered separately from `StreamToken` and only when the user /// has thinking display enabled. ThinkingToken(String), - /// The model invoked a tool — render the call row and prepare the result slot. + /// The model invoked a tool. Render the call row and prepare the result slot. ToolCallStart { id: String, name: String, input: serde_json::Value, }, - /// Tool finished — fill the slot opened by the matching [`Self::ToolCallStart`]. + /// Tool finished. Fill the slot opened by the matching [`Self::ToolCallStart`]. ToolCallEnd { id: String, content: String, is_error: bool, metadata: crate::tool::ToolMetadata, }, - /// A queued mid-turn submit was just spliced into the live transcript; the UI clears its + /// A queued mid-turn submit was just spliced into the live transcript. The UI clears its /// queued-prompt indicator. PromptDrained(String), /// Turn ended cleanly with a final assistant reply (no further tool rounds). TurnComplete, - /// User cancelled mid-turn ([`UserAction::Cancel`]); the in-flight reply is truncated and the + /// User cancelled mid-turn ([`UserAction::Cancel`]). The in-flight reply is truncated and the /// inline [`INTERRUPTED_MARKER`] is rendered. Cancelled, /// Automatic compaction started before the submitted prompt runs. TUI switches to compacting /// status while the summarizer request streams. AutoCompactionStarted, - /// Background title generator finished; UI updates the chrome label. + /// Background title generator finished. UI updates the chrome label. SessionTitleUpdated { session_id: String, title: String }, - /// `/clear` rolled the session — a new session UUID is now active. + /// `/clear` rolled the session. A new session UUID is now active. SessionRolled { id: String }, - /// `/resume` swapped to an existing session in place — payload carries the target's + /// `/resume` swapped to an existing session in place. Payload carries the target's /// transcript so the UI can rebuild chat without restarting the process. SessionResumed { id: String, @@ -89,7 +89,7 @@ pub(crate) enum AgentEvent { // ── User Actions ── -/// UI → agent-loop commands. Multiplexed onto a single `mpsc` so the loop can race them +/// UI to agent-loop commands. Multiplexed onto a single `mpsc` so the loop can race them /// against in-flight stream / tool futures with one biased `select!`. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum UserAction { @@ -108,14 +108,14 @@ pub(crate) enum UserAction { Rename { title: String, }, - /// Symmetric model + effort swap. At least one field must be `Some`; `None` leaves that axis + /// Symmetric model + effort swap. At least one field must be `Some`. `None` leaves that axis /// as-is. Modal pickers and typed-arg `/model ` / `/effort ` both flow through here. SwapConfig { model: Option, effort: Option, }, /// TUI-only: live-preview the picker's highlighted theme without committing it. Cursor moves - /// in the `/theme` modal emit this; cancelling the modal restores the snapshot taken on open. + /// in the `/theme` modal emit this. Cancelling the modal restores the snapshot taken on open. PreviewTheme { name: String, }, @@ -124,7 +124,7 @@ pub(crate) enum UserAction { name: String, }, Cancel, - /// TUI-only; agent loop ignores this. + /// TUI-only. Agent loop ignores this. ConfirmExit, Quit, } diff --git a/crates/oxide-code/src/client/anthropic/identity.rs b/crates/oxide-code/src/client/anthropic/identity.rs index 3c23a76a..3e4e66a3 100644 --- a/crates/oxide-code/src/client/anthropic/identity.rs +++ b/crates/oxide-code/src/client/anthropic/identity.rs @@ -1,4 +1,4 @@ -//! Per-machine `device_id` (64 lowercase hex chars) at `$XDG_DATA_HOME/ox/user-id`. Lazily minted; +//! Per-machine `device_id` (64 lowercase hex chars) at `$XDG_DATA_HOME/ox/user-id`. Lazily minted. //! filesystem failure falls back to an ephemeral id rather than blocking client construction. use std::fmt::Write as _; diff --git a/crates/oxide-code/src/client/anthropic/wire.rs b/crates/oxide-code/src/client/anthropic/wire.rs index 0a5d3e99..316f8dcc 100644 --- a/crates/oxide-code/src/client/anthropic/wire.rs +++ b/crates/oxide-code/src/client/anthropic/wire.rs @@ -1,4 +1,4 @@ -//! Anthropic Messages API wire types. Pure data; builders / interpreters live in sibling modules. +//! Anthropic Messages API wire types. Pure data. Builders / interpreters live in sibling modules. use serde::{Deserialize, Serialize}; @@ -9,7 +9,7 @@ use crate::tool::ToolDefinition; // ── Request types ── /// Body for `POST /v1/messages`. Field declaration order is the wire JSON order and is -/// load-bearing — the billing `cch=00000` placeholder must appear before user-controlled +/// load-bearing. The billing `cch=00000` placeholder must appear before user-controlled /// `messages` content so [`super::billing::inject_cch`]'s single-occurrence replacement targets /// the system block, not a user echo. #[derive(Serialize)] diff --git a/crates/oxide-code/src/session/chain.rs b/crates/oxide-code/src/session/chain.rs index e485bd01..6f4b1c89 100644 --- a/crates/oxide-code/src/session/chain.rs +++ b/crates/oxide-code/src/session/chain.rs @@ -1,5 +1,5 @@ //! UUID-DAG message chain reconstruction. Forks form when concurrent resumes append in -//! parallel; [`ChainBuilder::resolve`] picks the newest non-sidechain linear chain. +//! parallel. [`ChainBuilder::resolve`] picks the newest non-sidechain linear chain. use std::collections::{HashMap, HashSet}; diff --git a/crates/oxide-code/src/session/handle.rs b/crates/oxide-code/src/session/handle.rs index 42fcbcd8..78819b0f 100644 --- a/crates/oxide-code/src/session/handle.rs +++ b/crates/oxide-code/src/session/handle.rs @@ -1,5 +1,5 @@ //! Public session API. Every write method sends a [`super::actor::SessionCmd`] and awaits the -//! actor's ack; callers never hold a lock across `await`. +//! actor's ack. Callers never hold a lock across `await`. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -30,14 +30,14 @@ const CHANNEL_CAPACITY: usize = 1024; // ── SessionHandle ── -/// Cheap to clone — clones share one actor task. All methods are async +/// Cheap to clone. Clones share one actor task. All methods are async /// and return after the batch flush this cmd lands in. #[derive(Clone)] pub(crate) struct SessionHandle { cmd_tx: mpsc::Sender, session_id: Arc, shared: Arc, - /// First `shutdown` takes the join; subsequent calls no-op. + /// First `shutdown` takes the join. Subsequent calls no-op. actor_join: Arc>>>, } @@ -46,11 +46,11 @@ pub(crate) struct SessionHandle { pub(super) struct SharedState { flush_failure_surfaced: AtomicBool, actor_gone_surfaced: AtomicBool, - /// Set when the user has run `/rename` — suppresses AI titles and the next `FirstPrompt`. + /// Set when the user has run `/rename`. Suppresses AI titles and the next `FirstPrompt`. /// Latched by [`SessionHandle::set_manual_title`] before dispatch so the title generator's /// pre-check observes it without an actor round-trip. manual_title_set: AtomicBool, - /// Most recent flush error — threaded into actor-gone messages so the user sees the I/O cause. + /// Most recent flush error, threaded into actor-gone messages so the user sees the I/O cause. last_flush_failure: std::sync::Mutex>, } @@ -65,7 +65,7 @@ impl SharedState { } } - /// `true` on the first call after [`Self::record_flush_failure`]; sticky `false` afterwards. + /// `true` on the first call after [`Self::record_flush_failure`]. Sticky `false` afterwards. pub(super) fn surface_first_flush_failure(&self) -> bool { !self.flush_failure_surfaced.swap(true, Ordering::AcqRel) } @@ -99,18 +99,18 @@ impl SharedState { pub(crate) struct RecordOutcome { /// `Some` only on the first user-text message of a fresh session. pub(crate) ai_title_seed: Option, - /// First I/O failure once per session; subsequent failures stay + /// First I/O failure once per session. Subsequent failures stay /// silent (warn-logged instead). `None` on healthy writes. pub(crate) failure: Option, } -/// Plain ack from non-record cmds; carries the same first-failure surface as [`RecordOutcome`]. +/// Plain ack from non-record cmds. Carries the same first-failure surface as [`RecordOutcome`]. pub(crate) struct Outcome { pub(crate) failure: Option, } /// Ack from `SessionHandle::compact`. `pre_count` is the message count at the moment the -/// compact request landed in the actor — the post-compact UI line uses it for the +/// compact request landed in the actor. The post-compact UI line uses it for the /// "compacted N messages → 1 summary" header. pub(crate) struct CompactOutcome { pub(crate) pre_count: u32, diff --git a/crates/oxide-code/src/session/history.rs b/crates/oxide-code/src/session/history.rs index 1338d6ae..e1d652fb 100644 --- a/crates/oxide-code/src/session/history.rs +++ b/crates/oxide-code/src/session/history.rs @@ -1,5 +1,5 @@ //! Display-oriented reshaping of a loaded transcript. JSONL batches all `ToolUse` blocks then -//! all `ToolResult` blocks per turn; live rendering pairs each call with its result inline. +//! all `ToolResult` blocks per turn. Live rendering pairs each call with its result inline. //! [`walk_transcript`] reorders into the live shape and merges adjacent text blocks. use std::collections::HashMap; diff --git a/crates/oxide-code/src/session/path.rs b/crates/oxide-code/src/session/path.rs index c555af95..fdef23b3 100644 --- a/crates/oxide-code/src/session/path.rs +++ b/crates/oxide-code/src/session/path.rs @@ -1,5 +1,5 @@ //! Filesystem-safe project subdir derivation. Sessions live under -//! `$XDG_DATA_HOME/ox/sessions/{sanitized-cwd}/`; reserved chars become `-` and long names get +//! `$XDG_DATA_HOME/ox/sessions/{sanitized-cwd}/`. Reserved chars become `-` and long names get //! a hash suffix to prevent post-truncation collisions. use std::path::Path; diff --git a/crates/oxide-code/src/slash.rs b/crates/oxide-code/src/slash.rs index 899f85f1..8daa7c16 100644 --- a/crates/oxide-code/src/slash.rs +++ b/crates/oxide-code/src/slash.rs @@ -1,10 +1,10 @@ //! Slash-command surface. //! -//! [`parse_slash`] detects commands; [`dispatch`] resolves them via the registry. Each command is -//! a [`registry::SlashCommand`] impl in its own submodule — adding one is one file plus an entry +//! [`parse_slash`] detects commands. [`dispatch`] resolves them via the registry. Each command is +//! a [`registry::SlashCommand`] impl in its own submodule. Adding one is one file plus an entry //! in [`registry::BUILT_INS`]. //! -//! Persistence: commands never write config. Mutations are session-local; restart returns to the +//! Persistence: commands never write config. Mutations are session-local. Restart returns to the //! user-declared config (see `docs/design/slash/commands.md` § Design Decisions 6). mod clear; diff --git a/crates/oxide-code/src/slash/clear.rs b/crates/oxide-code/src/slash/clear.rs index 63a134b2..ab3bf083 100644 --- a/crates/oxide-code/src/slash/clear.rs +++ b/crates/oxide-code/src/slash/clear.rs @@ -1,4 +1,4 @@ -//! `/clear` — forwards [`UserAction::Clear`]; the agent loop rolls the session. +//! `/clear` forwards [`UserAction::Clear`]. The agent loop rolls the session. use super::context::SlashContext; use super::registry::{SlashCommand, SlashKind, SlashOutcome}; diff --git a/crates/oxide-code/src/slash/compact.rs b/crates/oxide-code/src/slash/compact.rs index bafbf18e..2617b220 100644 --- a/crates/oxide-code/src/slash/compact.rs +++ b/crates/oxide-code/src/slash/compact.rs @@ -1,6 +1,6 @@ -//! `/compact [instructions]` — compress the conversation into a summary. Bare runs the default -//! rubric; trailing text becomes user-supplied focus instructions appended to the rubric. -//! Always [`SlashKind::Mutating`] — refused mid-turn so the in-flight reply finishes first. +//! `/compact [instructions]` compresses the conversation into a summary. Bare runs the default +//! rubric. Trailing text becomes user-supplied focus instructions appended to the rubric. +//! Always [`SlashKind::Mutating`], so it is refused mid-turn while the in-flight reply finishes. use super::context::SlashContext; use super::registry::{SlashCommand, SlashKind, SlashOutcome}; diff --git a/crates/oxide-code/src/slash/delete.rs b/crates/oxide-code/src/slash/delete.rs index 42108edc..a7a30cbe 100644 --- a/crates/oxide-code/src/slash/delete.rs +++ b/crates/oxide-code/src/slash/delete.rs @@ -1,6 +1,6 @@ -//! `/delete ` — direct typed-arg form for session deletion. Bare `/delete` is rejected -//! because "what gets deleted?" is ambiguous; the picker form (`/resume` then Ctrl+D on a row) is -//! the discoverable path. Both forms share [`super::confirm::ConfirmDeleteSessionModal`]. +//! `/delete ` is the direct typed-arg form for session deletion. Bare `/delete` is +//! rejected because "what gets deleted?" is ambiguous. The picker form (`/resume` then Ctrl+D on +//! a row) is the discoverable path. Both forms share [`super::confirm::ConfirmDeleteSessionModal`]. use std::path::Path; diff --git a/crates/oxide-code/src/slash/effort.rs b/crates/oxide-code/src/slash/effort.rs index f9de89a8..551fa158 100644 --- a/crates/oxide-code/src/slash/effort.rs +++ b/crates/oxide-code/src/slash/effort.rs @@ -1,5 +1,5 @@ -//! `/effort` — open the slider, or swap with `/effort `. Bare form opens the -//! Speed ↔ Intelligence slider (see [`super::effort_slider`]); typed arg shortcuts the picker. +//! `/effort` opens the slider, or swaps with `/effort `. Bare form opens the +//! Speed ↔ Intelligence slider (see [`super::effort_slider`]). Typed arg shortcuts the picker. use std::borrow::Cow; diff --git a/crates/oxide-code/src/slash/model.rs b/crates/oxide-code/src/slash/model.rs index 77e43bbf..d9cee29d 100644 --- a/crates/oxide-code/src/slash/model.rs +++ b/crates/oxide-code/src/slash/model.rs @@ -1,4 +1,4 @@ -//! `/model` — open the picker, or swap with `/model `. Resolution: alias → exact / dated-id → +//! `/model` opens the picker, or swaps with `/model `. Resolution: alias → exact / dated-id → //! unique suffix → unique substring. `[1m]` rejected on models lacking `context_1m`. use std::borrow::Cow; diff --git a/crates/oxide-code/src/slash/rename.rs b/crates/oxide-code/src/slash/rename.rs index 98ec56c9..1469d721 100644 --- a/crates/oxide-code/src/slash/rename.rs +++ b/crates/oxide-code/src/slash/rename.rs @@ -1,5 +1,5 @@ -//! `/rename` — set the session title manually. Bare opens a modal pre-filled with the current -//! title; `/rename ` applies immediately. Suppresses AI title generation for the rest of +//! `/rename` sets the session title manually. Bare opens a modal pre-filled with the current +//! title. `/rename <title>` applies immediately. Suppresses AI title generation for the rest of //! the session so a slow Haiku response can't overwrite the user's pick. use crossterm::event::{KeyCode, KeyEvent}; diff --git a/crates/oxide-code/src/slash/theme.rs b/crates/oxide-code/src/slash/theme.rs index bf10fd19..2eac84db 100644 --- a/crates/oxide-code/src/slash/theme.rs +++ b/crates/oxide-code/src/slash/theme.rs @@ -1,4 +1,4 @@ -//! `/theme` — open the picker, or swap directly with `/theme <name>`. Picker live-previews on +//! `/theme` opens the picker, or swaps directly with `/theme <name>`. Picker live-previews on //! cursor moves and reverts on cancel. use std::borrow::Cow; diff --git a/crates/oxide-code/src/tui.rs b/crates/oxide-code/src/tui.rs index 82aee969..d8389272 100644 --- a/crates/oxide-code/src/tui.rs +++ b/crates/oxide-code/src/tui.rs @@ -1,7 +1,7 @@ //! Terminal UI. //! -//! ratatui + crossterm with a `tokio::select!` event loop. [`app::App`] is the root state; -//! [`components`] owns the chat / input / status regions; [`markdown`] renders assistant text; +//! ratatui + crossterm with a `tokio::select!` event loop. [`app::App`] is the root state. +//! [`components`] owns the chat / input / status regions. [`markdown`] renders assistant text. //! [`theme`] centralizes all styling. pub(crate) mod app; diff --git a/crates/oxide-code/src/tui/components/input/popup.rs b/crates/oxide-code/src/tui/components/input/popup.rs index bac977a5..3e495325 100644 --- a/crates/oxide-code/src/tui/components/input/popup.rs +++ b/crates/oxide-code/src/tui/components/input/popup.rs @@ -1,11 +1,11 @@ //! Slash-command autocomplete popup. Two modes: //! -//! - **Name** — typing `/cmd`. Rows are matched commands; aliases parenthesize only the typed +//! - **Name**: typing `/cmd`. Rows are matched commands. Aliases parenthesize only the typed //! alias (`/clear (new)`). Tab inserts `/{name} ` into the buffer. -//! - **Arg** — typing `/cmd <prefix>`. Rows are arg completions from the command's curated +//! - **Arg**: typing `/cmd <prefix>`. Rows are arg completions from the command's curated //! roster (`/model`, `/effort`, `/theme`). Tab replaces the prefix with `/{cmd} {value} `. //! -//! Selected row paints in `text` + BOLD; others dim — contrast stands in for a prefix glyph +//! Selected row paints in `text` + BOLD. Other rows dim so contrast stands in for a prefix glyph //! or fill. Lists past [`MAX_VISIBLE_ROWS`] scroll with a centered cursor (Claude Code //! typeahead style). @@ -26,7 +26,7 @@ const MAX_VISIBLE_ROWS: usize = 8; const COLUMN_GAP: usize = 2; -/// Rows of chrome above the row list — one dim horizontal rule that frames the popup as a +/// Rows of chrome above the row list. One dim horizontal rule frames the popup as a /// separate overlay so it doesn't bleed into whatever sits above (welcome, chat, preview). const CHROME_ROWS: u16 = 1; @@ -40,7 +40,7 @@ pub(super) enum PopupMode { }, } -/// One popup row. `value` is the bare token (command name or arg value); the renderer adds +/// One popup row. `value` is the bare token (command name or arg value). The renderer adds /// `/` and any matched-alias suffix in name mode. #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct PopupRow { diff --git a/crates/oxide-code/src/tui/theme/color.rs b/crates/oxide-code/src/tui/theme/color.rs index 5d615d19..6b55715c 100644 --- a/crates/oxide-code/src/tui/theme/color.rs +++ b/crates/oxide-code/src/tui/theme/color.rs @@ -1,10 +1,10 @@ //! Color string parsing for theme TOML values. Case-insensitive. //! -//! - **6-digit hex**: `"#rrggbb"` — true color (24-bit RGB). +//! - **6-digit hex**: `"#rrggbb"` for true color (24-bit RGB). //! - **ANSI 16 named**: `"red"`, `"bright_blue"`, `"dark_gray"`, ... (`light_X` aliases -//! `bright_X`; `grey` aliases `gray`). +//! `bright_X`, `grey` aliases `gray`). //! - **Indexed 256-color**: `"ansi:N"` where `N` is 0–255. -//! - **Terminal default**: `"reset"` — follows the user's terminal palette. +//! - **Terminal default**: `"reset"` follows the user's terminal palette. //! //! Three-digit hex shorthand (`#fff`) is rejected to keep the format unambiguous. diff --git a/crates/oxide-code/src/tui/theme/loader.rs b/crates/oxide-code/src/tui/theme/loader.rs index 70d7bece..0214128c 100644 --- a/crates/oxide-code/src/tui/theme/loader.rs +++ b/crates/oxide-code/src/tui/theme/loader.rs @@ -2,15 +2,15 @@ //! //! A theme document is a flat TOML body, one entry per slot. Each value is either: //! -//! - a **bare color string** (`text = "#cdd6f4"`) — sets `fg` only; or -//! - an **inline table** (`accent = { fg = "#89b4fa", bold = true }`) — explicit `fg` / `bg` plus +//! - a **bare color string** (`text = "#cdd6f4"`): sets `fg` only. +//! - an **inline table** (`accent = { fg = "#89b4fa", bold = true }`): explicit `fg` / `bg` plus //! modifiers (`bold`, `italic`, `underlined`, `dim`, `reversed`). //! -//! Every slot must be present; `deny_unknown_fields` catches typos. Per-slot parse errors are +//! Every slot must be present. `deny_unknown_fields` catches typos. Per-slot parse errors are //! wrapped with the slot name. //! //! [`resolve_theme`] applies a base + per-slot overrides from `[tui.theme]` config. Selection -//! errors (unknown name, missing file) hard-fail; per-slot value errors warn and fall back to base +//! errors (unknown name, missing file) hard-fail. Per-slot value errors warn and fall back to base //! so the TUI still launches. use std::collections::HashMap; @@ -47,7 +47,7 @@ pub(crate) fn resolve_theme( Ok(theme) } -/// Resolves a `base` to a TOML body — built-in catalogue first, then a filesystem path with +/// Resolves a `base` to a TOML body. Built-in catalogue first, then a filesystem path with /// `~/` expanded to `$HOME`. fn load_base_body(name: &str) -> Result<String> { if let Some(body) = builtin::lookup(name) { @@ -62,7 +62,7 @@ fn load_base_body(name: &str) -> Result<String> { }) } -/// Applies one override patch; errors on unknown slot name or bad color value. +/// Applies one override patch. Errors on unknown slot name or bad color value. fn patch_slot(theme: &mut Theme, slot_name: &str, patch: &SlotPatch) -> Result<()> { let slot = slot_for_name(theme, slot_name) .ok_or_else(|| anyhow::anyhow!("unknown slot {slot_name:?}"))?; @@ -70,7 +70,7 @@ fn patch_slot(theme: &mut Theme, slot_name: &str, patch: &SlotPatch) -> Result<( Ok(()) } -/// Generated mutable slot lookup; the mapping can't drift from [`Theme`]'s fields. +/// Generated mutable slot lookup. The mapping can't drift from [`Theme`]'s fields. macro_rules! define_slot_for_name { ( $( ($name:ident, $doc:literal), )* ) => { fn slot_for_name<'a>(theme: &'a mut Theme, name: &str) -> Option<&'a mut Slot> { @@ -96,9 +96,9 @@ pub(crate) enum SlotPatch { Inline(InlinePatch), } -/// Inline TOML patch — every field optional. `Option<bool>` distinguishes "no change" / "set" / +/// Inline TOML patch with every field optional. `Option<bool>` distinguishes "no change" / "set" / /// "clear". Empty patches (`error = {}`) are rejected by [`apply`](Self::apply) so the slot warns -/// and falls back rather than silently re-writing the base with itself; serde's untagged enum +/// and falls back rather than silently re-writing the base with itself. Serde's untagged enum /// dispatcher would swallow a `Deserialize`-time message, so the warn path is clearer. #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] From 90e65112b30bcc0a0f01dbbd83447b72a8767579 Mon Sep 17 00:00:00 2001 From: Hakula Chen <i@hakula.xyz> Date: Thu, 14 May 2026 11:18:27 +0800 Subject: [PATCH 4/5] test: align test order with implementation --- crates/oxide-code/src/client/anthropic.rs | 356 +++++++++--------- crates/oxide-code/src/config/oauth.rs | 64 ++-- crates/oxide-code/src/tui/components/chat.rs | 32 +- crates/oxide-code/src/tui/components/input.rs | 67 ++-- .../oxide-code/src/tui/modal/kv_overview.rs | 31 +- 5 files changed, 272 insertions(+), 278 deletions(-) diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index de86385f..624d5a1c 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -844,184 +844,6 @@ mod tests { assert_eq!(got, Some("🦀rust")); } - #[tokio::test] - async fn stream_message_malformed_frame_is_skipped_without_poisoning_stream() { - // One bad frame must not poison the rest of the turn. - let server = MockServer::start().await; - let body = sse_body(&[ - ( - "content_block_start", - r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, - ), - ("content_block_delta", "{not valid json"), - ( - "content_block_delta", - r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}"#, - ), - ("message_stop", r#"{"type":"message_stop"}"#), - ]); - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let client = Client::new( - test_config(server.uri(), api_key(), "claude-sonnet-4-6"), - Some("sid".to_owned()), - ) - .unwrap(); - let events = collect_events( - client - .stream_message(&[Message::user("hi")], &[], None, &[]) - .unwrap(), - ) - .await - .unwrap(); - let delta = events.iter().find_map(|e| match e { - StreamEvent::ContentBlockDelta { - delta: Delta::TextDelta { text }, - .. - } => Some(text.as_str()), - _ => None, - }); - assert_eq!(delta, Some("Hi")); - } - - #[tokio::test] - async fn stream_message_mid_stream_error_event_is_delivered_with_api_payload() { - // `StreamEvent::Error` flows as `Ok(Error { .. })`; `agent.rs` converts to bail!. - let server = MockServer::start().await; - let body = sse_body(&[( - "error", - r#"{"type":"error","error":{"type":"overloaded_error","message":"Servers overloaded"}}"#, - )]); - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body), - ) - .mount(&server) - .await; - - let client = Client::new( - test_config(server.uri(), api_key(), "claude-sonnet-4-6"), - Some("sid".to_owned()), - ) - .unwrap(); - let events = collect_events( - client - .stream_message(&[Message::user("hi")], &[], None, &[]) - .unwrap(), - ) - .await - .unwrap(); - let err = events - .iter() - .find_map(|e| match e { - StreamEvent::Error { error } => Some(error), - _ => None, - }) - .expect("error event must be delivered"); - assert_eq!(err.error_type, "overloaded_error"); - assert_eq!(err.message, "Servers overloaded"); - } - - #[tokio::test] - async fn stream_message_http_error_propagates_status_and_body() { - for (status, body) in [ - (429_u16, r#"{"error":{"type":"rate_limit_error"}}"#), - (529, "overloaded"), - ] { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with(ResponseTemplate::new(status).set_body_string(body)) - .mount(&server) - .await; - - let client = Client::new( - test_config(server.uri(), api_key(), "claude-sonnet-4-6"), - Some("sid".to_owned()), - ) - .unwrap(); - let rx = client - .stream_message(&[Message::user("hi")], &[], None, &[]) - .unwrap(); - let err = collect_events(rx).await.expect_err("expected HTTP error"); - let msg = format!("{err:#}"); - assert!( - msg.contains(&status.to_string()), - "status {status} in error: {msg}", - ); - assert!(msg.contains(body), "body surfaced in error: {msg}"); - } - } - - #[tokio::test] - async fn stream_message_429_threads_retry_after_header_into_error() { - // Retry-after extraction lives in stream_sse (not format_api_error); pin that the - // header is read off the response *before* the body is consumed. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with( - ResponseTemplate::new(429) - .insert_header("retry-after", "60") - .set_body_string(r#"{"error":{"type":"rate_limit_error"}}"#), - ) - .mount(&server) - .await; - - let client = Client::new( - test_config(server.uri(), api_key(), "claude-sonnet-4-6"), - Some("sid".to_owned()), - ) - .unwrap(); - let rx = client - .stream_message(&[Message::user("hi")], &[], None, &[]) - .unwrap(); - let err = collect_events(rx).await.expect_err("expected 429"); - assert!( - format!("{err:#}").contains("retry after 60"), - "retry-after threaded through stream_sse: {err:#}", - ); - } - - #[tokio::test] - async fn stream_message_receiver_dropped_mid_stream_does_not_deadlock() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/messages")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(text_stream_body()) - .set_delay(Duration::from_millis(50)), - ) - .mount(&server) - .await; - - let client = Client::new( - test_config(server.uri(), api_key(), "claude-sonnet-4-6"), - Some("sid".to_owned()), - ) - .unwrap(); - let mut rx = client - .stream_message(&[Message::user("hi")], &[], None, &[]) - .unwrap(); - _ = rx.recv().await; - drop(rx); - // Lets the background task observe the closed channel; any panic surfaces in test output. - tokio::time::sleep(Duration::from_millis(80)).await; - } - #[tokio::test] async fn stream_message_api_key_sends_x_api_key_and_session_id() { let server = MockServer::start().await; @@ -1242,6 +1064,184 @@ mod tests { assert_eq!(cc["ttl"], "1h", "default 1h ttl survives on 3P: {body}"); } + #[tokio::test] + async fn stream_message_malformed_frame_is_skipped_without_poisoning_stream() { + // One bad frame must not poison the rest of the turn. + let server = MockServer::start().await; + let body = sse_body(&[ + ( + "content_block_start", + r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, + ), + ("content_block_delta", "{not valid json"), + ( + "content_block_delta", + r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}"#, + ), + ("message_stop", r#"{"type":"message_stop"}"#), + ]); + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(body), + ) + .mount(&server) + .await; + + let client = Client::new( + test_config(server.uri(), api_key(), "claude-sonnet-4-6"), + Some("sid".to_owned()), + ) + .unwrap(); + let events = collect_events( + client + .stream_message(&[Message::user("hi")], &[], None, &[]) + .unwrap(), + ) + .await + .unwrap(); + let delta = events.iter().find_map(|e| match e { + StreamEvent::ContentBlockDelta { + delta: Delta::TextDelta { text }, + .. + } => Some(text.as_str()), + _ => None, + }); + assert_eq!(delta, Some("Hi")); + } + + #[tokio::test] + async fn stream_message_mid_stream_error_event_is_delivered_with_api_payload() { + // `StreamEvent::Error` flows as `Ok(Error { .. })`; `agent.rs` converts to bail!. + let server = MockServer::start().await; + let body = sse_body(&[( + "error", + r#"{"type":"error","error":{"type":"overloaded_error","message":"Servers overloaded"}}"#, + )]); + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(body), + ) + .mount(&server) + .await; + + let client = Client::new( + test_config(server.uri(), api_key(), "claude-sonnet-4-6"), + Some("sid".to_owned()), + ) + .unwrap(); + let events = collect_events( + client + .stream_message(&[Message::user("hi")], &[], None, &[]) + .unwrap(), + ) + .await + .unwrap(); + let err = events + .iter() + .find_map(|e| match e { + StreamEvent::Error { error } => Some(error), + _ => None, + }) + .expect("error event must be delivered"); + assert_eq!(err.error_type, "overloaded_error"); + assert_eq!(err.message, "Servers overloaded"); + } + + #[tokio::test] + async fn stream_message_http_error_propagates_status_and_body() { + for (status, body) in [ + (429_u16, r#"{"error":{"type":"rate_limit_error"}}"#), + (529, "overloaded"), + ] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with(ResponseTemplate::new(status).set_body_string(body)) + .mount(&server) + .await; + + let client = Client::new( + test_config(server.uri(), api_key(), "claude-sonnet-4-6"), + Some("sid".to_owned()), + ) + .unwrap(); + let rx = client + .stream_message(&[Message::user("hi")], &[], None, &[]) + .unwrap(); + let err = collect_events(rx).await.expect_err("expected HTTP error"); + let msg = format!("{err:#}"); + assert!( + msg.contains(&status.to_string()), + "status {status} in error: {msg}", + ); + assert!(msg.contains(body), "body surfaced in error: {msg}"); + } + } + + #[tokio::test] + async fn stream_message_429_threads_retry_after_header_into_error() { + // Retry-after extraction lives in stream_sse (not format_api_error); pin that the + // header is read off the response *before* the body is consumed. + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with( + ResponseTemplate::new(429) + .insert_header("retry-after", "60") + .set_body_string(r#"{"error":{"type":"rate_limit_error"}}"#), + ) + .mount(&server) + .await; + + let client = Client::new( + test_config(server.uri(), api_key(), "claude-sonnet-4-6"), + Some("sid".to_owned()), + ) + .unwrap(); + let rx = client + .stream_message(&[Message::user("hi")], &[], None, &[]) + .unwrap(); + let err = collect_events(rx).await.expect_err("expected 429"); + assert!( + format!("{err:#}").contains("retry after 60"), + "retry-after threaded through stream_sse: {err:#}", + ); + } + + #[tokio::test] + async fn stream_message_receiver_dropped_mid_stream_does_not_deadlock() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(text_stream_body()) + .set_delay(Duration::from_millis(50)), + ) + .mount(&server) + .await; + + let client = Client::new( + test_config(server.uri(), api_key(), "claude-sonnet-4-6"), + Some("sid".to_owned()), + ) + .unwrap(); + let mut rx = client + .stream_message(&[Message::user("hi")], &[], None, &[]) + .unwrap(); + _ = rx.recv().await; + drop(rx); + // Lets the background task observe the closed channel; any panic surfaces in test output. + tokio::time::sleep(Duration::from_millis(80)).await; + } + // ── Client::stream_message / agentic body fields ── /// Captures the serialized body of a single streaming request. diff --git a/crates/oxide-code/src/config/oauth.rs b/crates/oxide-code/src/config/oauth.rs index 2ed2fb3a..28ea47db 100644 --- a/crates/oxide-code/src/config/oauth.rs +++ b/crates/oxide-code/src/config/oauth.rs @@ -452,38 +452,6 @@ mod tests { assert_eq!(token, "tok"); } - #[tokio::test] - 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().unwrap() + 60_000); - - let token = load_token_from( - &creds, - &lock, - "http://should-not-be-called", - None, - read_credentials, - ) - .await - .unwrap(); - assert_eq!(token, "tok"); - } - - #[tokio::test] - async fn load_token_from_without_refresh_token_bails_when_expired() { - let dir = tempfile::tempdir().unwrap(); - let creds = dir.path().join("creds.json"); - let lock = dir.path().join("lock"); - write_creds(&creds, "tok", None, 0); - - let err = load_token_from(&creds, &lock, "http://unused", None, read_credentials) - .await - .expect_err("expired without refresh must bail"); - assert!(format!("{err:#}").contains("expired")); - } - #[tokio::test] async fn load_token_from_near_expiry_refreshes_and_persists_new_credentials() { let server = MockServer::start().await; @@ -524,6 +492,25 @@ mod tests { ); } + #[tokio::test] + 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().unwrap() + 60_000); + + let token = load_token_from( + &creds, + &lock, + "http://should-not-be-called", + None, + read_credentials, + ) + .await + .unwrap(); + assert_eq!(token, "tok"); + } + #[tokio::test] async fn load_token_from_refresh_endpoint_down_keeps_existing_token_if_unexpired() { let server = MockServer::start().await; @@ -549,6 +536,19 @@ mod tests { assert_eq!(token, "stale"); } + #[tokio::test] + async fn load_token_from_without_refresh_token_bails_when_expired() { + let dir = tempfile::tempdir().unwrap(); + let creds = dir.path().join("creds.json"); + let lock = dir.path().join("lock"); + write_creds(&creds, "tok", None, 0); + + let err = load_token_from(&creds, &lock, "http://unused", None, read_credentials) + .await + .expect_err("expired without refresh must bail"); + assert!(format!("{err:#}").contains("expired")); + } + #[tokio::test] async fn load_token_from_refresh_endpoint_down_bails_if_expired() { let server = MockServer::start().await; diff --git a/crates/oxide-code/src/tui/components/chat.rs b/crates/oxide-code/src/tui/components/chat.rs index ac5bb51a..bf2bd1db 100644 --- a/crates/oxide-code/src/tui/components/chat.rs +++ b/crates/oxide-code/src/tui/components/chat.rs @@ -2175,6 +2175,22 @@ mod tests { assert_eq!(chat.viewport_height, 20); } + #[test] + fn update_layout_invalidates_streaming_cache_on_width_change() { + let mut chat = test_chat(); + _ = chat.update_layout(Rect::new(0, 0, 80, 24)); + chat.append_stream_token("a complete paragraph\n\n"); + let s = chat.streaming.as_ref().unwrap(); + assert_ne!(s.rendered_len(), 0); + assert_eq!(s.cached_width(), 80); + + _ = chat.update_layout(Rect::new(0, 0, 40, 24)); + let s = chat.streaming.as_ref().unwrap(); + assert_eq!(s.rendered_len(), 0); + assert_eq!(s.rendered_boundary(), 0); + assert_eq!(s.cached_width(), 0); + } + // ── bump_paused_counter ── #[test] @@ -2213,22 +2229,6 @@ mod tests { assert_eq!(chat.new_content_since_pause(), u32::MAX); } - #[test] - fn update_layout_invalidates_streaming_cache_on_width_change() { - let mut chat = test_chat(); - _ = chat.update_layout(Rect::new(0, 0, 80, 24)); - chat.append_stream_token("a complete paragraph\n\n"); - let s = chat.streaming.as_ref().unwrap(); - assert_ne!(s.rendered_len(), 0); - assert_eq!(s.cached_width(), 80); - - _ = chat.update_layout(Rect::new(0, 0, 40, 24)); - let s = chat.streaming.as_ref().unwrap(); - assert_eq!(s.rendered_len(), 0); - assert_eq!(s.rendered_boundary(), 0); - assert_eq!(s.cached_width(), 0); - } - // ── handle_event ── #[test] diff --git a/crates/oxide-code/src/tui/components/input.rs b/crates/oxide-code/src/tui/components/input.rs index 8d08d64b..86366ea9 100644 --- a/crates/oxide-code/src/tui/components/input.rs +++ b/crates/oxide-code/src/tui/components/input.rs @@ -457,6 +457,19 @@ mod tests { assert_eq!(input.height(), 5); // 3 content + 2 borders } + #[test] + fn height_accounts_for_visual_wrapping() { + let mut input = test_input(); + input.last_width.set(10); + for ch in "abcdefghijklmnopqrstuvwxy".chars() { + input.textarea.input(Event::Key(KeyEvent::new( + KeyCode::Char(ch), + KeyModifiers::NONE, + ))); + } + assert_eq!(input.height(), 5); + } + #[test] fn height_capped_at_max() { let mut input = test_input(); @@ -466,6 +479,27 @@ mod tests { assert_eq!(input.height(), MAX_VISIBLE_LINES + 2); } + // ── render_popup ── + + #[test] + fn render_popup_paints_when_visible() { + let mut input = test_input(); + input.handle_event(&key(KeyCode::Char('/'), KeyModifiers::NONE)); + assert!(input.popup_visible(), "typing `/` opens the popup"); + + let backend = TestBackend::new(40, 10); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + let area = Rect::new(0, 0, 40, input.popup_height()); + input.render_popup(frame, area); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + let first = buffer.cell(Position::new(0, 0)).unwrap().symbol(); + assert!(!first.is_empty(), "popup row paints something at (0,0)"); + } + // ── handle_event ── #[test] @@ -847,21 +881,6 @@ mod tests { assert_eq!(input.visual_line_count(), 3); } - #[test] - fn height_accounts_for_visual_wrapping() { - let mut input = test_input(); - input.last_width.set(10); - // Single logical line, 25 chars -> 3 visual lines. - for ch in "abcdefghijklmnopqrstuvwxy".chars() { - input.textarea.input(Event::Key(KeyEvent::new( - KeyCode::Char(ch), - KeyModifiers::NONE, - ))); - } - // 3 content + 2 borders - assert_eq!(input.height(), 5); - } - // ── submit ── #[test] @@ -1127,22 +1146,4 @@ mod tests { fn normalize_placeholder_trims_whitespace_inside_brackets() { assert_eq!(normalize_placeholder("[ <id> ]"), "[id]"); } - - // ── render_popup ── - - #[test] - fn render_popup_paints_when_visible() { - let input = input_with_popup(); - let backend = TestBackend::new(40, 10); - let mut terminal = Terminal::new(backend).unwrap(); - terminal - .draw(|frame| { - let area = Rect::new(0, 0, 40, input.popup_height()); - input.render_popup(frame, area); - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - let first = buffer.cell(Position::new(0, 0)).unwrap().symbol(); - assert!(!first.is_empty(), "popup row paints something at (0,0)"); - } } diff --git a/crates/oxide-code/src/tui/modal/kv_overview.rs b/crates/oxide-code/src/tui/modal/kv_overview.rs index 38e77fc8..944a0a8e 100644 --- a/crates/oxide-code/src/tui/modal/kv_overview.rs +++ b/crates/oxide-code/src/tui/modal/kv_overview.rs @@ -153,25 +153,6 @@ mod tests { ) } - // ── KvOverview::height ── - - #[test] - fn height_for_single_section_without_heading_counts_title_blank_rows_blank_footer() { - // 2 rows + (title + blank) + (blank + footer) = 6. - assert_eq!(flat_overview().height(80), 6); - } - - #[test] - fn height_for_two_headed_sections_adds_heading_blanks_and_inter_section_blank() { - // title + blank - // + heading + blank + 1 row - // + blank - // + heading + blank + 1 row - // + blank + footer - // = 2 + 3 + 1 + 3 + 2 = 11. - assert_eq!(sectioned_overview().height(80), 11); - } - // ── KvOverview::label_width ── #[test] @@ -192,6 +173,18 @@ mod tests { assert_eq!(m.label_width(), 0); } + // ── KvOverview::height ── + + #[test] + fn height_for_single_section_without_heading_counts_title_blank_rows_blank_footer() { + assert_eq!(flat_overview().height(80), 6); + } + + #[test] + fn height_for_two_headed_sections_adds_heading_blanks_and_inter_section_blank() { + assert_eq!(sectioned_overview().height(80), 11); + } + // ── KvOverview::render ── #[test] From 48e1cdc284017068c62f8f1525e636c4b4774eee Mon Sep 17 00:00:00 2001 From: Hakula Chen <i@hakula.xyz> Date: Thu, 14 May 2026 11:35:03 +0800 Subject: [PATCH 5/5] docs: refine prose convention sweep --- crates/oxide-code/src/agent/event.rs | 18 ++--- crates/oxide-code/src/client/anthropic.rs | 75 +++++++++---------- .../src/client/anthropic/identity.rs | 7 +- .../oxide-code/src/client/anthropic/wire.rs | 13 ++-- crates/oxide-code/src/config/file.rs | 3 +- crates/oxide-code/src/session/handle.rs | 18 ++--- crates/oxide-code/src/slash/delete.rs | 5 +- crates/oxide-code/src/slash/model.rs | 3 +- crates/oxide-code/src/tui/app.rs | 2 +- .../oxide-code/src/tui/modal/kv_overview.rs | 4 +- crates/oxide-code/src/tui/theme/loader.rs | 4 +- docs/guide/configuration.md | 4 +- docs/guide/slash-commands.md | 2 +- 13 files changed, 78 insertions(+), 80 deletions(-) diff --git a/crates/oxide-code/src/agent/event.rs b/crates/oxide-code/src/agent/event.rs index 86533070..03d6d098 100644 --- a/crates/oxide-code/src/agent/event.rs +++ b/crates/oxide-code/src/agent/event.rs @@ -23,23 +23,23 @@ pub(crate) const INTERRUPTED_MARKER: &str = "(interrupted)"; pub(crate) enum AgentEvent { /// Assistant text chunk to append to the in-flight reply. StreamToken(String), - /// Extended-thinking chunk. Rendered separately from `StreamToken` and only when the user + /// Extended-thinking chunk rendered separately from `StreamToken` and only when the user /// has thinking display enabled. ThinkingToken(String), - /// The model invoked a tool. Render the call row and prepare the result slot. + /// Tool call emitted by the model, used to render the call row and prepare the result slot. ToolCallStart { id: String, name: String, input: serde_json::Value, }, - /// Tool finished. Fill the slot opened by the matching [`Self::ToolCallStart`]. + /// Tool result for the slot opened by the matching [`Self::ToolCallStart`]. ToolCallEnd { id: String, content: String, is_error: bool, metadata: crate::tool::ToolMetadata, }, - /// A queued mid-turn submit was just spliced into the live transcript. The UI clears its + /// A queued mid-turn submit was just spliced into the live transcript so the UI clears its /// queued-prompt indicator. PromptDrained(String), /// Turn ended cleanly with a final assistant reply (no further tool rounds). @@ -50,9 +50,9 @@ pub(crate) enum AgentEvent { /// Automatic compaction started before the submitted prompt runs. TUI switches to compacting /// status while the summarizer request streams. AutoCompactionStarted, - /// Background title generator finished. UI updates the chrome label. + /// Background title generator finished, so the UI updates the chrome label. SessionTitleUpdated { session_id: String, title: String }, - /// `/clear` rolled the session. A new session UUID is now active. + /// `/clear` rolled the session, making a new session UUID active. SessionRolled { id: String }, /// `/resume` swapped to an existing session in place. Payload carries the target's /// transcript so the UI can rebuild chat without restarting the process. @@ -89,8 +89,8 @@ pub(crate) enum AgentEvent { // ── User Actions ── -/// UI to agent-loop commands. Multiplexed onto a single `mpsc` so the loop can race them -/// against in-flight stream / tool futures with one biased `select!`. +/// Commands from the UI to the agent loop. Multiplexed onto a single `mpsc` so the loop can race +/// them against in-flight stream / tool futures with one biased `select!`. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum UserAction { SubmitPrompt(String), @@ -115,7 +115,7 @@ pub(crate) enum UserAction { effort: Option<Effort>, }, /// TUI-only: live-preview the picker's highlighted theme without committing it. Cursor moves - /// in the `/theme` modal emit this. Cancelling the modal restores the snapshot taken on open. + /// in the `/theme` modal emit this, and cancelling the modal restores the open-time snapshot. PreviewTheme { name: String, }, diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 624d5a1c..8189e82a 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -1,4 +1,4 @@ -//! Anthropic Messages API client. [`Client::stream_message`] drives the agent loop; +//! Anthropic Messages API client. [`Client::stream_message`] drives the agent loop. //! [`Client::complete`] handles one-shots (title generation, structured-output probes). //! //! The wire shape mirrors the official `claude-code` CLI: pinned User-Agent and Stainless SDK @@ -40,27 +40,27 @@ use wire::{ const API_VERSION: &str = "2023-06-01"; -/// Pinned to the latest claude-code release; gateways reject pre-allowlist versions. +/// Pinned to the claude-code version accepted by gateways because pre-allowlist versions fail. const CLAUDE_CLI_VERSION: &str = "2.1.121"; const STAINLESS_PACKAGE_VERSION: &str = "0.81.0"; const STAINLESS_RUNTIME_VERSION: &str = "v24.3.0"; const STAINLESS_TIMEOUT_SECS: &str = "600"; -/// Required as its own text block; non-Haiku OAuth requests 429 without it. +/// Required as its own text block because non-Haiku OAuth requests 429 without it. const SYSTEM_PROMPT_PREFIX: &str = "You are Claude Code, Anthropic's official CLI for Claude."; // ── Client ── -/// Shareable HTTP client for the Anthropic Messages API. `Clone` is cheap — `reqwest::Client` -/// shares its connection pool across clones, so callers may stash one per session and roll -/// `session_id` / `model` / `effort` in place via the `set_*` methods. +/// Shareable HTTP client for the Anthropic Messages API. `Clone` is cheap because +/// `reqwest::Client` shares its connection pool. Callers can keep one per session and update +/// `session_id` / `model` / `effort` via the `set_*` methods. #[derive(Clone)] pub(crate) struct Client { http: reqwest::Client, config: Config, session_id: String, device_id: String, - /// Gates `scope: "global"` in `cache_control` (1P-only — 3P gateways reject it). + /// Gates `scope: "global"` in `cache_control`, which 3P gateways reject. is_first_party: bool, } @@ -122,7 +122,7 @@ impl Client { ); headers.insert("x-stainless-retry-count", HeaderValue::from_static("0")); - // No whole-request timeout — responses can run for minutes. The 60 s read timeout + // No whole-request timeout because responses can run for minutes. The 60 s read timeout // catches slowloris dribble. Anthropic sends keepalives every ~15 s on healthy streams. let builder = reqwest::Client::builder() .default_headers(headers) @@ -251,7 +251,7 @@ impl Client { tools: (!tools.is_empty()).then_some(tools), thinking: self.config.thinking.as_ref(), output_config: OutputConfig::new(None, self.config.effort), - // Body must accompany the beta header; claude-code 2.1.119 ships both on every 4.6+. + // Body must accompany the beta header. claude-code 2.1.119 ships both on every 4.6+. context_management: caps .context_management .then(ContextManagement::clear_thinking_keep_all), @@ -281,7 +281,7 @@ impl Client { // ── Request Building ── -/// Field order is load-bearing — gateways validate the JSON shape of `metadata.user_id`. +/// Field order is load-bearing because gateways validate the JSON shape of `metadata.user_id`. fn build_metadata(device_id: &str, session_id: &str) -> RequestMetadata { #[derive(serde::Serialize)] struct UserId<'a> { @@ -358,7 +358,7 @@ fn normalize_arch(arch: &str) -> &'static str { // ── System Prompt Helpers ── -/// Splits at the boundary marker; marker itself is dropped. +/// Splits at the boundary marker, dropping the marker itself. fn split_at_boundary<'a>(sections: &[&'a str]) -> (Vec<&'a str>, Vec<&'a str>) { let boundary_pos = sections .iter() @@ -413,7 +413,6 @@ mod tests { const OFFLINE_URL: &str = "https://example.invalid"; const TEST_MODEL: &str = "claude-sonnet-4-6"; - /// Builds an SSE response body from `(event, data)` pairs. fn sse_body(frames: &[(&str, &str)]) -> String { use std::fmt::Write; let mut body = String::new(); @@ -525,11 +524,9 @@ mod tests { } #[test] - fn new_with_extra_ca_certs_trusts_them() { - // The assertion only confirms that `Client::new` returns Ok. A client that forgot to - // reassign the builder back after `add_root_certificate` would still pass. Keeping the - // loose check here since a stronger test would need a TLS-terminating mock server - // signed by `TEST_CA_PEM`, which is heavy machinery for one line of wiring. + fn new_with_extra_ca_certs_accepts_valid_bundle() { + // This pins Client wiring only. End-to-end trust would need a TLS mock server signed by + // `TEST_CA_PEM`. PEM parsing and builder application are covered in `util::tls`. let pem = tempfile::NamedTempFile::new().unwrap(); std::io::Write::write_all(&mut pem.as_file(), crate::util::tls::TEST_CA_PEM.as_bytes()) .unwrap(); @@ -540,12 +537,12 @@ mod tests { #[test] fn new_rejects_auth_values_containing_invalid_header_bytes() { - // `HeaderValue::from_str` rejects control chars (\n, \r); both auth arms must propagate. + // `HeaderValue::from_str` rejects control chars (\n, \r). Both auth arms must propagate. for auth in [ Auth::ApiKey("bad\nkey".to_owned()), Auth::OAuth("bad\rtoken".to_owned()), ] { - // `Client` has no Debug, so .unwrap_err() doesn't compile — use .err().unwrap(). + // `Client` has no Debug, so `.unwrap_err()` does not compile. let err = Client::new( test_config(OFFLINE_URL, auth, TEST_MODEL), Some("sid".to_owned()), @@ -562,7 +559,7 @@ mod tests { #[test] fn new_surfaces_extra_ca_certs_error_with_path() { // An unreadable trust anchor must fail the client build and cite the configured path so - // the user can debug without having to spelunk into TLS internals. + // the user can debug it without inspecting TLS internals. let mut cfg = test_config(OFFLINE_URL, api_key(), TEST_MODEL); cfg.extra_ca_certs = Some(std::path::PathBuf::from("/no/such/bundle.pem")); let err = Client::new(cfg, None).err().expect("missing CA must error"); @@ -575,8 +572,8 @@ mod tests { #[tokio::test] async fn set_session_id_propagates_to_header_and_metadata_user_id() { - // Pins both wire surfaces: the mock matches the rolled id in the header (wrong value 404s) - // and the assertion below pins the embedded JSON in `metadata.user_id`. + // Pin both wire surfaces: the mock matches the rolled id in the header, and the assertion + // below pins the embedded JSON in `metadata.user_id`. let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/messages")) @@ -630,8 +627,8 @@ mod tests { #[test] fn set_model_resolves_effort_and_persists_full_state() { - // Rows pin each resolution arm: clamp-down, pass-through, no-tier clear, model-default - // fallback, unknown-id. Asserting both returned + stored effort catches the + // Rows cover each resolution arm: clamp-down, pass-through, no-tier clear, model-default + // fallback, and unknown-id. Checking both returned and stored effort catches the // "returned but not persisted" mutation. for (from_model, from_effort, swap_to, expect) in [ ( @@ -678,7 +675,7 @@ mod tests { #[test] fn set_model_preserves_1m_tag_round_trip() { - // `[1m]` is a client-side opt-in; the swap must store it verbatim so `compute_betas` keeps + // `[1m]` is a client-side opt-in. The swap must store it verbatim so `compute_betas` keeps // sending the 1M context beta. Regressing this drops 1M context silently. let mut client = client_with("claude-opus-4-6", Some(Effort::Max)); client.set_model("claude-opus-4-7[1m]".to_owned()).unwrap(); @@ -724,7 +721,7 @@ mod tests { #[test] fn set_effort_resolves_pick_against_active_model_caps() { - // Rows: pass-through, clamp-down, explicit-on-no-tier → None. + // Rows cover pass-through, clamp-down, and explicit effort on a no-tier model. for (model, initial, pick, expect) in [ ( "claude-opus-4-7", @@ -1003,8 +1000,8 @@ mod tests { #[tokio::test] async fn stream_message_third_party_base_url_drops_global_scope_keeps_its_beta() { - // On 3P, the `prompt-caching-scope` beta still ships (gateway fingerprints absence) but - // the body-side `scope: "global"` is dropped (gateway rejects it downstream of tools). + // On 3P, the beta still ships for gateway fingerprint parity, but the body-side + // `scope: "global"` is dropped because gateways reject it downstream of tools. let server = MockServer::start().await; let sink: Captured<(String, String)> = captured(); let sink_clone = std::sync::Arc::clone(&sink); @@ -1060,13 +1057,12 @@ mod tests { cc.get("scope").is_none(), "scope field omitted entirely on 3P (not null): {body}", ); - // TTL rides through on 3P — only `scope` is gated on 1P. + // TTL rides through on 3P. Only `scope` is gated on 1P. assert_eq!(cc["ttl"], "1h", "default 1h ttl survives on 3P: {body}"); } #[tokio::test] async fn stream_message_malformed_frame_is_skipped_without_poisoning_stream() { - // One bad frame must not poison the rest of the turn. let server = MockServer::start().await; let body = sse_body(&[ ( @@ -1114,7 +1110,8 @@ mod tests { #[tokio::test] async fn stream_message_mid_stream_error_event_is_delivered_with_api_payload() { - // `StreamEvent::Error` flows as `Ok(Error { .. })`; `agent.rs` converts to bail!. + // `StreamEvent::Error` flows as `Ok(Error { .. })`. `agent.rs` still owns conversion to + // `bail!`. let server = MockServer::start().await; let body = sse_body(&[( "error", @@ -1186,8 +1183,8 @@ mod tests { #[tokio::test] async fn stream_message_429_threads_retry_after_header_into_error() { - // Retry-after extraction lives in stream_sse (not format_api_error); pin that the - // header is read off the response *before* the body is consumed. + // Retry-after extraction lives in stream_sse rather than format_api_error. This pins that + // the header is read before the body is consumed. let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/messages")) @@ -1238,13 +1235,12 @@ mod tests { .unwrap(); _ = rx.recv().await; drop(rx); - // Lets the background task observe the closed channel; any panic surfaces in test output. + // Give the background task time to observe the closed channel so any panic surfaces here. tokio::time::sleep(Duration::from_millis(80)).await; } // ── Client::stream_message / agentic body fields ── - /// Captures the serialized body of a single streaming request. async fn capture_stream_body(config: Config) -> serde_json::Value { let server = MockServer::start().await; let sink: Captured<String> = captured(); @@ -1284,7 +1280,8 @@ mod tests { #[tokio::test] async fn stream_message_omits_output_config_when_effort_is_none() { - // Non-effort-capable model → `Config.effort == None` → `output_config` absent (not `{}`). + // Non-effort-capable models resolve to `Config.effort == None`, so `output_config` is + // absent rather than `{}`. let cfg = test_config( "https://placeholder.invalid", api_key(), @@ -1321,8 +1318,8 @@ mod tests { #[tokio::test] async fn stream_message_context_management_absent_on_unknown_model() { - // Unknown ids fall back to all-false `Capabilities::default()` — no beta, no body. Keeps - // "beta sent ⇒ body populated" an invariant. + // Unknown ids fall back to all-false `Capabilities::default()`, preserving the + // "beta sent ⇒ body populated" invariant. let cfg = test_config("https://placeholder.invalid", api_key(), "claude-opus-5-0"); let body = capture_stream_body(cfg).await; assert!( @@ -1359,7 +1356,7 @@ mod tests { #[test] fn build_metadata_wraps_ids_in_stringified_json_with_canonical_field_order() { - // Field order is `device_id, account_uuid, session_id` — `serde_json::json!` would + // Field order is `device_id, account_uuid, session_id`. `serde_json::json!` would // alphabetize and trip 3P validation. let meta = build_metadata("dev-1", "abc-123"); assert_eq!( diff --git a/crates/oxide-code/src/client/anthropic/identity.rs b/crates/oxide-code/src/client/anthropic/identity.rs index 3e4e66a3..d548a01c 100644 --- a/crates/oxide-code/src/client/anthropic/identity.rs +++ b/crates/oxide-code/src/client/anthropic/identity.rs @@ -1,5 +1,6 @@ -//! Per-machine `device_id` (64 lowercase hex chars) at `$XDG_DATA_HOME/ox/user-id`. Lazily minted. -//! filesystem failure falls back to an ephemeral id rather than blocking client construction. +//! Per-machine `device_id` (64 lowercase hex chars) stored at `$XDG_DATA_HOME/ox/user-id`. +//! The id is minted lazily. Filesystem failures fall back to an ephemeral id and do not block +//! client construction. use std::fmt::Write as _; use std::fs; @@ -17,7 +18,7 @@ const DATA_DIR: &str = "ox"; const FILE_NAME: &str = "user-id"; const ID_LEN: usize = 64; -/// Loads the persisted id, minting one if absent; falls back to ephemeral on filesystem failure. +/// Loads the persisted id, mints one if absent, and uses an ephemeral id on filesystem failure. pub(super) fn load_or_create_device_id() -> String { fallback_to_ephemeral(try_load_or_create()) } diff --git a/crates/oxide-code/src/client/anthropic/wire.rs b/crates/oxide-code/src/client/anthropic/wire.rs index 316f8dcc..66b1c97f 100644 --- a/crates/oxide-code/src/client/anthropic/wire.rs +++ b/crates/oxide-code/src/client/anthropic/wire.rs @@ -1,4 +1,5 @@ -//! Anthropic Messages API wire types. Pure data. Builders / interpreters live in sibling modules. +//! Pure data wire types for the Anthropic Messages API. Builders / interpreters live in sibling +//! modules. use serde::{Deserialize, Serialize}; @@ -9,7 +10,7 @@ use crate::tool::ToolDefinition; // ── Request types ── /// Body for `POST /v1/messages`. Field declaration order is the wire JSON order and is -/// load-bearing. The billing `cch=00000` placeholder must appear before user-controlled +/// load-bearing: the billing `cch=00000` placeholder must appear before user-controlled /// `messages` content so [`super::billing::inject_cch`]'s single-occurrence replacement targets /// the system block, not a user echo. #[derive(Serialize)] @@ -18,7 +19,7 @@ pub(super) struct CreateMessageRequest<'a> { pub(super) max_tokens: u32, pub(super) stream: bool, pub(super) metadata: RequestMetadata, - /// Before `messages` so the billing `cch=00000` placeholder appears first; required by + /// Before `messages` so the billing `cch=00000` placeholder appears first. Required by /// [`super::billing::inject_cch`]'s single-occurrence replacement. pub(super) system: Vec<SystemBlock<'a>>, #[serde(skip_serializing_if = "Option::is_none")] @@ -44,7 +45,7 @@ pub(super) struct OutputConfig<'a> { } impl<'a> OutputConfig<'a> { - /// Returns `None` when both fields are empty; `Some(_)` otherwise. + /// Returns `None` when both fields are empty and `Some(_)` otherwise. pub(super) fn new(format: Option<&'a OutputFormat>, effort: Option<Effort>) -> Option<Self> { (format.is_some() || effort.is_some()).then_some(Self { format, effort }) } @@ -89,7 +90,7 @@ impl OutputFormat { } } -/// `user_id` is a stringified JSON object `{device_id, account_uuid, session_id}`; field order is +/// `user_id` is a stringified JSON object `{device_id, account_uuid, session_id}`. Field order is /// part of the wire fingerprint. #[derive(Serialize)] pub(super) struct RequestMetadata { @@ -105,7 +106,7 @@ pub(super) struct SystemBlock<'a> { pub(super) cache_control: Option<CacheControl>, } -/// `scope: "global"` shares cache across sessions (1P only). Default ttl is `"1h"`; opt out via +/// `scope: "global"` shares cache across sessions (1P only). Default ttl is `"1h"`. Opt out via /// `prompt_cache_ttl = "5m"` to use the server-side 5-minute default. #[derive(Serialize)] pub(super) struct CacheControl { diff --git a/crates/oxide-code/src/config/file.rs b/crates/oxide-code/src/config/file.rs index 78cd73e3..f54cf2d5 100644 --- a/crates/oxide-code/src/config/file.rs +++ b/crates/oxide-code/src/config/file.rs @@ -684,7 +684,8 @@ mod tests { assert!(msg.contains("unknown field `model`"), "{msg}"); } - /// Covers section-level `deny_unknown_fields` (top-level: `load_file_rejects_unknown_top_level_key`). + /// Covers section-level `deny_unknown_fields`. + /// Top-level coverage lives in `load_file_rejects_unknown_top_level_key`. #[test] fn load_file_rejects_misplaced_field() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/oxide-code/src/session/handle.rs b/crates/oxide-code/src/session/handle.rs index 78819b0f..fe893371 100644 --- a/crates/oxide-code/src/session/handle.rs +++ b/crates/oxide-code/src/session/handle.rs @@ -1,5 +1,5 @@ //! Public session API. Every write method sends a [`super::actor::SessionCmd`] and awaits the -//! actor's ack. Callers never hold a lock across `await`. +//! actor's ack without holding a lock across `await`. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -30,7 +30,7 @@ const CHANNEL_CAPACITY: usize = 1024; // ── SessionHandle ── -/// Cheap to clone. Clones share one actor task. All methods are async +/// Cheap to clone because clones share one actor task. All methods are async /// and return after the batch flush this cmd lands in. #[derive(Clone)] pub(crate) struct SessionHandle { @@ -46,7 +46,7 @@ pub(crate) struct SessionHandle { pub(super) struct SharedState { flush_failure_surfaced: AtomicBool, actor_gone_surfaced: AtomicBool, - /// Set when the user has run `/rename`. Suppresses AI titles and the next `FirstPrompt`. + /// Set when the user has run `/rename`, suppressing AI titles and the next `FirstPrompt`. /// Latched by [`SessionHandle::set_manual_title`] before dispatch so the title generator's /// pre-check observes it without an actor round-trip. manual_title_set: AtomicBool, @@ -65,7 +65,7 @@ impl SharedState { } } - /// `true` on the first call after [`Self::record_flush_failure`]. Sticky `false` afterwards. + /// `true` on the first call after [`Self::record_flush_failure`], then sticky `false`. pub(super) fn surface_first_flush_failure(&self) -> bool { !self.flush_failure_surfaced.swap(true, Ordering::AcqRel) } @@ -104,7 +104,7 @@ pub(crate) struct RecordOutcome { pub(crate) failure: Option<String>, } -/// Plain ack from non-record cmds. Carries the same first-failure surface as [`RecordOutcome`]. +/// Plain ack from non-record cmds, carrying the same first-failure surface as [`RecordOutcome`]. pub(crate) struct Outcome { pub(crate) failure: Option<String>, } @@ -210,7 +210,7 @@ impl SessionHandle { }) } - /// Write the session summary and finalize. Idempotent; no-op on fresh sessions that never + /// Write the session summary and finalize. Idempotent no-op on fresh sessions that never /// recorded anything. `snapshots` are written as `Entry::FileSnapshot` ahead of the summary. pub(crate) async fn finish(&self, snapshots: Vec<FileSnapshot>) -> Outcome { let (ack, rx) = oneshot::channel(); @@ -257,7 +257,7 @@ impl SessionHandle { } } - /// Sends a cmd and awaits the `Outcome` ack; falls back to actor-gone on failure. + /// Sends a cmd and awaits the `Outcome` ack, falling back to actor-gone on failure. async fn dispatch_outcome(&self, cmd: SessionCmd, rx: oneshot::Receiver<Outcome>) -> Outcome { if self.cmd_tx.send(cmd).await.is_err() { return Outcome { @@ -298,7 +298,7 @@ pub(crate) struct ResumedSession { pub(crate) compact: Option<CompactInfo>, pub(crate) title: Option<String>, pub(crate) tool_result_metadata: HashMap<String, ToolMetadata>, - /// Persisted tracker snapshots; passed to `FileTracker::restore_verified` so the + /// Persisted tracker snapshots passed to `FileTracker::restore_verified` so the /// Read-before-Edit gate clears for files that still match disk. pub(crate) file_snapshots: Vec<FileSnapshot>, } @@ -338,7 +338,7 @@ pub(crate) async fn roll( } /// Resumed transcript + finalize failure from the old session. The handle swap happens via -/// `&mut SessionHandle`; this struct only carries the UI-rebuild payload. +/// `&mut SessionHandle`. This struct only carries the UI-rebuild payload. #[derive(Debug)] pub(crate) struct RollIntoOutcome { pub(crate) messages: Vec<Message>, diff --git a/crates/oxide-code/src/slash/delete.rs b/crates/oxide-code/src/slash/delete.rs index a7a30cbe..f633e7c9 100644 --- a/crates/oxide-code/src/slash/delete.rs +++ b/crates/oxide-code/src/slash/delete.rs @@ -67,9 +67,8 @@ impl SlashCommand for DeleteCmd { // ── Resolution ── -/// Wrap the shared resolver with `/delete`'s live-id surface: matches against the live id surface -/// a dedicated error so the user sees the real reason rather than the generic "no session -/// matching" they'd get for a typo. +/// Wraps the shared resolver with `/delete`'s live-id behavior: prefixes that match only the +/// live id return a dedicated error so typos keep the generic "no session matching" message. fn resolve_for_delete( store: &SessionStore, prefix: &str, diff --git a/crates/oxide-code/src/slash/model.rs b/crates/oxide-code/src/slash/model.rs index d9cee29d..7665a888 100644 --- a/crates/oxide-code/src/slash/model.rs +++ b/crates/oxide-code/src/slash/model.rs @@ -427,10 +427,9 @@ mod tests { #[test] fn execute_ambiguous_listing_falls_back_to_full_curated_set_when_filter_empty() { - // `claude-opus` matches the listed Opus 4.7 entries; older non-listed rows must not surface. let (_, outcome) = run_execute("claude-opus"); let msg = outcome.expect_err("ambiguous arg must error"); - // `claude-opus` matches the listed Opus 4.7 entries; the listing surfaces those. + // `claude-opus` matches the listed Opus 4.7 entries, so the listing surfaces those. for id in ["claude-opus-4-7", "claude-opus-4-7[1m]"] { assert!(msg.contains(id), "{id} should be listed: {msg}"); } diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index 81082d08..0fb1f1cc 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -1627,7 +1627,7 @@ mod tests { async fn dispatch_resume_forwards_to_agent_and_disables_input_until_event() { // Pin: between forwarding `Resume` and the SessionResumed event landing, input must be // gated so a typed prompt doesn't push into chat just before `apply_session_resumed`'s - // `clear_history` wipes it. Re-enable comes from `finalize_idle` inside the resumed handler. + // `clear_history` wipes it. `finalize_idle` inside the resumed handler re-enables input. let (mut app, mut rx, _agent_tx) = test_app(None); let action = UserAction::Resume { session_id: "resume-target".to_owned(), diff --git a/crates/oxide-code/src/tui/modal/kv_overview.rs b/crates/oxide-code/src/tui/modal/kv_overview.rs index 944a0a8e..7573ea48 100644 --- a/crates/oxide-code/src/tui/modal/kv_overview.rs +++ b/crates/oxide-code/src/tui/modal/kv_overview.rs @@ -1,5 +1,5 @@ -//! Read-only key-value overview modal — title, multi-section body, fixed footer. -//! Used by `/status`, `/config`, `/help`. Compose `KvSection` fixtures; the modal owns layout. +//! Read-only key-value overview modal with a title, multi-section body, and fixed footer. +//! Used by `/status`, `/config`, and `/help`. The modal owns layout for `KvSection` fixtures. use crossterm::event::KeyEvent; use ratatui::Frame; diff --git a/crates/oxide-code/src/tui/theme/loader.rs b/crates/oxide-code/src/tui/theme/loader.rs index 0214128c..061477a0 100644 --- a/crates/oxide-code/src/tui/theme/loader.rs +++ b/crates/oxide-code/src/tui/theme/loader.rs @@ -24,8 +24,8 @@ use super::{Slot, Theme, builtin, color::parse_color}; // ── Resolution ── -/// Resolves a theme from an optional base + per-slot overrides. Unknown / unreadable base errors -/// hard-fail; per-slot override errors warn via `tracing` and keep the base value. +/// Resolves a theme from an optional base + per-slot overrides. Unknown / unreadable bases +/// hard-fail. Per-slot override errors warn via `tracing` and keep the base value. pub(crate) fn resolve_theme( base: Option<&str>, overrides: &HashMap<String, SlotPatch>, diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 186455a9..c4f0f8df 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -84,7 +84,7 @@ base_url = "https://gw.llm.corp.example/anthropic" extra_ca_certs = "~/.config/ox/corp-cachain.pem" ``` -Use an absolute or `~`-rooted path. The field is user-config only because a checked-in trust-anchor path could widen TLS trust for the process. Equivalent env var: `OX_EXTRA_CA_CERTS`. +Use an absolute or `~`-rooted path. Relative paths resolve from the launch cwd. The field is user-config only because a checked-in trust-anchor path could widen TLS trust for the process. Equivalent env var: `OX_EXTRA_CA_CERTS`. #### `prompt_cache_ttl`: cache duration @@ -156,7 +156,7 @@ oxide-code checks three credential sources in order: Expired tokens are refreshed automatically. No configuration needed. -Prefer the environment variable (or OAuth) over `api_key` in a config file. `ox.toml` resolves by walking up from the current directory, so oxide-code rejects project-level `api_key` and `base_url`. User-level `~/.config/ox/config.toml` is safer, but still plaintext on disk. +Prefer the environment variable (or OAuth) over `api_key` in a config file. `ox.toml` resolves by walking up from the current directory, so oxide-code rejects project-level `api_key`, `base_url`, and `extra_ca_certs`. User-level `~/.config/ox/config.toml` is safer, but still plaintext on disk. ## Environment variables diff --git a/docs/guide/slash-commands.md b/docs/guide/slash-commands.md index b1bd3430..a10912bb 100644 --- a/docs/guide/slash-commands.md +++ b/docs/guide/slash-commands.md @@ -42,7 +42,7 @@ Bare `/model` and `/effort` open pickers. Both apply on Enter and cancel on Esc. `/compact <instructions>` appends free-text focus instructions to the rubric (e.g., `/compact focus on the build error and how we fixed it`). Useful when only a slice of the work matters going forward. -Resuming via `ox -c` starts from the compacted summary and post-compact tail. The file-change tracker resets on compact, so any `Edit` after `/compact` requires a fresh `Read`. Queued prompts survive the compaction. +The pre-compact transcript stays on disk, but resuming via `ox -c` starts from the compacted summary and post-compact tail. The file-change tracker resets on compact, so any `Edit` after `/compact` requires a fresh `Read`. Queued prompts survive the compaction. `/compact` refuses on sessions with fewer than 4 messages, when the model returns an empty summary, or while a turn is in flight.