Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f1e752e
fix(client): handle unknown SSE content block and delta types gracefully
hakula139 Apr 4, 2026
3417e2f
docs(roadmap): move streaming robustness to shipped, mark PR 2.1 done
hakula139 Apr 4, 2026
da8c365
docs(research): add extended thinking research notes
hakula139 Apr 4, 2026
5c94075
feat(client): add proper thinking, redacted_thinking, and server_tool…
hakula139 Apr 4, 2026
62d1dba
docs(research): update extended thinking notes with current oxide-cod…
hakula139 Apr 4, 2026
cbc3da9
refactor(config): move ThinkingConfig from client to config module
hakula139 Apr 4, 2026
ec66915
fix(main): improve streaming pipeline edge cases
hakula139 Apr 4, 2026
7040a47
test(message): strengthen strip_trailing_thinking coverage
hakula139 Apr 4, 2026
ac7506e
docs: update roadmap and Code Review conventions
hakula139 Apr 4, 2026
a2c88b6
test(message): consolidate strip_trailing_thinking tests
hakula139 Apr 4, 2026
5dad81b
fix(main): remove empty assistant messages after thinking removal
hakula139 Apr 4, 2026
5491ba0
style(message): reorder test sections to match enum variant order
hakula139 Apr 4, 2026
03ad4a8
docs(message): update ContentBlock docstring for new variants
hakula139 Apr 4, 2026
aaeef20
feat(main): add optional dimmed thinking display via OX_SHOW_THINKING
hakula139 Apr 4, 2026
2ab3fd0
docs(roadmap): add thinking display and TOML config file
hakula139 Apr 4, 2026
0629b2e
refactor(message): reorder ContentBlock variants and narrow strip_tra…
hakula139 Apr 5, 2026
d643cba
style(client): unify catch-all doc comments on Unknown variants
hakula139 Apr 5, 2026
f332bf0
refactor(config): extract env_bool helper for boolean env var parsing
hakula139 Apr 5, 2026
e9113aa
refactor(config): remove unused ThinkingConfig::Enabled variant
hakula139 Apr 5, 2026
8483d89
perf(main): use write_all for plain text output
hakula139 Apr 5, 2026
fdef2b8
fix(message): insert placeholder for thinking-only responses instead …
hakula139 Apr 5, 2026
343c489
style(bash): use brackets for truncation marker
hakula139 Apr 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,6 +73,7 @@ ox # Start an interactive session
- 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).
- 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.

### Visibility

Expand DownExpand Up@@ -129,6 +130,7 @@ ox # Start an interactive session
- 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`).
- Use `indoc!` for multi-line string literals in tests.
- 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.
- Prefer a concise test suite with full coverage over many minimal tests. Drop tests that are subsumed by more thorough ones. Merge tests that cover the same code path when the combined test remains readable.

### Documentation Maintenance

Expand All@@ -155,5 +157,8 @@ After verification passes, review for:
- Correctness and edge cases
- Adherence to project conventions (this file)
- Conciseness — prefer the simplest idiomatic solution
- DRY — flag duplicate logic across modules; look for extraction opportunities
- Cross-file consistency — parallel types and similar patterns should use the same structure, naming, ordering, and derive traits
- Idiomatic Rust — proper use of iterators, pattern matching, type system, ownership, and standard library
- Existing crates — flag hand-written logic that an established crate already handles
- Test coverage gaps
140 changes: 135 additions & 5 deletions crates/oxide-code/src/client/anthropic.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;

use crate::config::{Auth, Config};
use crate::config::{Auth, Config, ThinkingConfig};
use crate::message::Message;
use crate::tool::ToolDefinition;

Expand DownExpand Up@@ -33,6 +33,8 @@ struct CreateMessageRequest<'a> {
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<&'a [ToolDefinition]>,
#[serde(skip_serializing_if = "Option::is_none")]
thinking: Option<&'a ThinkingConfig>,
}

// ── SSE response types ──
Expand DownExpand Up@@ -67,6 +69,10 @@ pub enum StreamEvent {
Error {
error: ApiError,
},
/// Catch-all for unrecognized event types.
/// Silently skipped during stream processing.
#[serde(other)]
Unknown,
}

#[cfg_attr(
Expand All@@ -86,15 +92,54 @@ pub struct MessageResponse {
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlockInfo {
Text { text: String },
ToolUse { id: String, name: String },
Text {
text: String,
},
ToolUse {
id: String,
name: String,
},
ServerToolUse {
id: String,
name: String,
},
Thinking {
thinking: String,
signature: String,
},
RedactedThinking {
data: String,
},
/// Catch-all for unrecognized block types.
/// Silently skipped during stream processing.
#[serde(other)]
Unknown,
}

#[expect(
clippy::enum_variant_names,
reason = "variant names mirror Anthropic API delta type values (text_delta, input_json_delta, etc.)"
)]
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Delta {
TextDelta { text: String },
InputJsonDelta { partial_json: String },
TextDelta {
text: String,
},
InputJsonDelta {
partial_json: String,
},
ThinkingDelta {
thinking: String,
},
/// Full signature value (overwrites, not appended).
SignatureDelta {
signature: String,
},
/// Catch-all for unrecognized delta types.
/// Silently skipped during stream processing.
#[serde(other)]
Unknown,
}

#[expect(
Expand DownExpand Up@@ -198,6 +243,7 @@ impl Client {
system: &system_prompt,
stream: true,
tools: (!tools.is_empty()).then_some(tools),
thinking: self.config.thinking.as_ref(),
})
.context("failed to serialize request")?;

Expand DownExpand Up@@ -285,6 +331,80 @@ mod tests {

use super::*;

// ── ContentBlockInfo ──

#[test]
fn content_block_info_thinking() {
let json = r#"{"type":"thinking","thinking":"","signature":""}"#;
let info: ContentBlockInfo = serde_json::from_str(json).unwrap();
let ContentBlockInfo::Thinking {
thinking,
signature,
} = info
else {
panic!("expected Thinking");
};
assert_eq!(thinking, "");
assert_eq!(signature, "");
}

#[test]
fn content_block_info_redacted_thinking() {
let json = r#"{"type":"redacted_thinking","data":"base64data=="}"#;
let info: ContentBlockInfo = serde_json::from_str(json).unwrap();
let ContentBlockInfo::RedactedThinking { data } = info else {
panic!("expected RedactedThinking");
};
assert_eq!(data, "base64data==");
}

#[test]
fn content_block_info_server_tool_use() {
let json = r#"{"type":"server_tool_use","id":"stu_01","name":"advisor"}"#;
let info: ContentBlockInfo = serde_json::from_str(json).unwrap();
let ContentBlockInfo::ServerToolUse { id, name } = info else {
panic!("expected ServerToolUse");
};
assert_eq!(id, "stu_01");
assert_eq!(name, "advisor");
}

#[test]
fn content_block_info_unknown_type() {
let json = r#"{"type":"some_future_block","data":"opaque"}"#;
let info: ContentBlockInfo = serde_json::from_str(json).unwrap();
assert!(matches!(info, ContentBlockInfo::Unknown));
}

// ── Delta ──

#[test]
fn delta_thinking() {
let json = r#"{"type":"thinking_delta","thinking":"partial reasoning"}"#;
let delta: Delta = serde_json::from_str(json).unwrap();
let Delta::ThinkingDelta { thinking } = delta else {
panic!("expected ThinkingDelta");
};
assert_eq!(thinking, "partial reasoning");
}

#[test]
fn delta_signature() {
let json = r#"{"type":"signature_delta","signature":"sig_abc123"}"#;
let delta: Delta = serde_json::from_str(json).unwrap();
let Delta::SignatureDelta { signature } = delta else {
panic!("expected SignatureDelta");
};
assert_eq!(signature, "sig_abc123");
}

#[test]
fn delta_unknown_type() {
let json = r#"{"type":"some_future_delta","data":"opaque"}"#;
let delta: Delta = serde_json::from_str(json).unwrap();
assert!(matches!(delta, Delta::Unknown));
}

// ── parse_sse_frame ──

#[test]
Expand DownExpand Up@@ -345,6 +465,16 @@ mod tests {
assert_eq!(error.message, "Too many requests");
}

#[test]
fn parse_sse_frame_unknown_event_type() {
let frame = indoc! {r#"
event: some_future_event
data: {"type":"some_future_event","payload":"data"}
"#};
let event = parse_sse_frame(frame).unwrap().unwrap();
assert!(matches!(event, StreamEvent::Unknown));
}

#[test]
fn parse_sse_frame_comment_only() {
let frame = ": comment line";
Expand Down
34 changes: 34 additions & 0 deletions crates/oxide-code/src/config.rs
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
mod oauth;

use anyhow::{Context, Result};
use serde::Serialize;

const DEFAULT_MODEL: &str = "claude-opus-4-6";
const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
Expand All@@ -14,12 +15,21 @@ pub enum Auth {
OAuth(String),
}

#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ThinkingConfig {
/// Model decides the thinking budget (Claude 4.6+).
Adaptive,
}

#[derive(Debug, Clone)]
pub struct Config {
pub auth: Auth,
pub model: String,
pub base_url: String,
pub max_tokens: u32,
pub thinking: Option<ThinkingConfig>,
pub show_thinking: bool,
}

impl Config {
Expand All@@ -46,15 +56,39 @@ impl Config {
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_MAX_TOKENS);

// Adaptive thinking is always enabled — the model decides the budget.
let thinking = Some(ThinkingConfig::Adaptive);

let show_thinking = env_bool("OX_SHOW_THINKING");

Ok(Self {
auth,
model,
base_url,
max_tokens,
thinking,
show_thinking,
})
}
}

fn non_empty_env(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.is_empty())
}

fn env_bool(key: &str) -> bool {
non_empty_env(key).is_some_and(|v| v == "1" || v == "true")
}

#[cfg(test)]
mod tests {
use super::*;

// ── ThinkingConfig ──

#[test]
fn thinking_config_adaptive_serializes() {
let json = serde_json::to_value(&ThinkingConfig::Adaptive).unwrap();
assert_eq!(json["type"], "adaptive");
}
}
Loading