From f1e752e2c4c015c16befcc36af8a7e25db1d9594 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 00:22:12 +0800 Subject: [PATCH 01/22] fix(client): handle unknown SSE content block and delta types gracefully Add #[serde(other)] catch-all Unknown variants to StreamEvent, ContentBlockInfo, and Delta so that unrecognized types (e.g., thinking, redacted_thinking, signature_delta) deserialize without crashing. Add a Skipped variant to BlockAccumulator that absorbs deltas silently and produces no ContentBlock, keeping the agent loop stable when the API introduces new block types. --- crates/oxide-code/src/client/anthropic.rs | 74 +++++++++++++++++++++-- crates/oxide-code/src/main.rs | 16 +++-- 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index dbc836f8..b1339708 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -67,6 +67,9 @@ pub enum StreamEvent { Error { error: ApiError, }, + /// Catch-all for unrecognized event types. Silently ignored in stream processing. + #[serde(other)] + Unknown, } #[cfg_attr( @@ -86,15 +89,36 @@ 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, + }, + /// Catch-all for unrecognized block types (e.g., `thinking`). 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)" +)] #[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, + }, + /// Catch-all for unrecognized delta types (e.g., `thinking_delta`). Silently + /// dropped during stream processing. + #[serde(other)] + Unknown, } #[expect( @@ -285,6 +309,38 @@ mod tests { use super::*; + // ── ContentBlockInfo ── + + #[test] + fn content_block_info_unknown_type() { + let json = r#"{"type":"thinking","thinking":"reasoning text"}"#; + let info: ContentBlockInfo = serde_json::from_str(json).unwrap(); + assert!(matches!(info, ContentBlockInfo::Unknown)); + } + + #[test] + fn content_block_info_unknown_redacted_thinking() { + let json = r#"{"type":"redacted_thinking","data":"[redacted]"}"#; + let info: ContentBlockInfo = serde_json::from_str(json).unwrap(); + assert!(matches!(info, ContentBlockInfo::Unknown)); + } + + // ── Delta ── + + #[test] + fn delta_unknown_type() { + let json = r#"{"type":"thinking_delta","thinking":"partial reasoning"}"#; + let delta: Delta = serde_json::from_str(json).unwrap(); + assert!(matches!(delta, Delta::Unknown)); + } + + #[test] + fn delta_unknown_signature() { + let json = r#"{"type":"signature_delta","signature":"sig_abc123"}"#; + let delta: Delta = serde_json::from_str(json).unwrap(); + assert!(matches!(delta, Delta::Unknown)); + } + // ── parse_sse_frame ── #[test] @@ -345,6 +401,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"; diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index 067b30be..1ba45a38 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -143,19 +143,23 @@ enum BlockAccumulator { name: String, json_buf: String, }, + /// Placeholder for unrecognized content block types. Absorbs deltas silently + /// and produces no [`ContentBlock`] at the end. + Skipped, } impl BlockAccumulator { - fn into_content_block(self) -> ContentBlock { + fn into_content_block(self) -> Option { match self { - Self::Text(text) => ContentBlock::Text { text }, + Self::Text(text) => Some(ContentBlock::Text { text }), Self::ToolUse { id, name, json_buf } => { let input = serde_json::from_str(&json_buf).unwrap_or_else(|e| { warn!("malformed tool input JSON: {e}"); serde_json::Value::Object(serde_json::Map::new()) }); - ContentBlock::ToolUse { id, name, input } + Some(ContentBlock::ToolUse { id, name, input }) } + Self::Skipped => None, } } } @@ -194,6 +198,10 @@ async fn stream_response( name, json_buf: String::new(), }, + ContentBlockInfo::Unknown => { + warn!("skipping unknown content block at index {index}"); + BlockAccumulator::Skipped + } }); } StreamEvent::ContentBlockDelta { index, delta } => { @@ -232,7 +240,7 @@ async fn stream_response( Ok(blocks .into_iter() .flatten() - .map(BlockAccumulator::into_content_block) + .filter_map(BlockAccumulator::into_content_block) .collect()) } From 3417e2f5d25085023c65264b1d3de7367ebf32b4 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 00:30:52 +0800 Subject: [PATCH 02/22] docs(roadmap): move streaming robustness to shipped, mark PR 2.1 done --- docs/roadmap.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index f50b68e3..4517195a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,16 +10,25 @@ The project direction is simple: ## Working Today +### Agent Loop + - Async REPL that reads user input and streams responses from the Anthropic Messages API. +- Agent loop: the LLM can request tool execution, results feed back into the conversation, looping until a text-only response. +- Streaming robustness — unknown content block types (`thinking`, `redacted_thinking`, `signature_delta`, etc.) are silently skipped instead of crashing deserialization. + +### Authentication & Configuration + - OAuth authentication via Claude Code credentials (`~/.claude/.credentials.json`). - API key authentication via `ANTHROPIC_API_KEY` environment variable. - Configurable model, base URL, and max tokens via environment variables. -- Agent loop: the LLM can request tool execution, results feed back into the conversation, looping until a text-only response. -- Bash tool — execute shell commands with timeout, head+tail output truncation, and structured metadata (exit code, description). -- File tools — read (line-numbered output, pagination, byte budget), write (with directory creation), edit (exact string replacement with CRLF handling). -- Search tools — glob-based file pattern matching, regex content search with output modes (content / files / count), context lines, and head limit. -- Tool output with structured metadata — title and tool-specific fields for TUI rendering, separate from model-facing content. + +### Tools + +- Bash — execute shell commands with timeout, head+tail output truncation, and structured metadata (exit code, description). +- File — read (line-numbered output, pagination, byte budget), write (with directory creation), edit (exact string replacement with CRLF handling). +- Search — glob-based file pattern matching, regex content search with output modes (content / files / count), context lines, and head limit. - Tool definitions sent via the Anthropic `tools` API parameter. +- Tool output with structured metadata — title and tool-specific fields for TUI rendering, separate from model-facing content. ## Current Focus @@ -30,11 +39,6 @@ The project direction is simple: - Write refreshed tokens back to both Keychain and file. - See `.claude/plans/macos-keychain-oauth.md` for full design. -### Streaming Robustness - -- Handle unknown content block types (`thinking`, `redacted_thinking`, `signature_delta`, etc.) gracefully instead of crashing on deserialization. -- Required before enabling extended thinking support. - ### System Prompt - System prompt construction with tool definitions and project context. From da8c365b364f611e6138b3cf38e66a53d7e7e013 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 00:37:08 +0800 Subject: [PATCH 03/22] docs(research): add extended thinking research notes Document how Claude Code handles thinking, redacted_thinking, server_tool_use, and signature blocks. Covers streaming lifecycle, round-tripping requirements, credential rotation constraints, and implementation implications for oxide-code. --- docs/research/extended-thinking.md | 103 +++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/research/extended-thinking.md diff --git a/docs/research/extended-thinking.md b/docs/research/extended-thinking.md new file mode 100644 index 00000000..ce256557 --- /dev/null +++ b/docs/research/extended-thinking.md @@ -0,0 +1,103 @@ +# Extended Thinking + +Research notes on how Claude Code handles extended thinking, content block types, and signature verification. Based on [`claude-code`](https://github.com/hakula139/claude-code) (v2.1.87). + +## Content Block Types + +The Anthropic Messages API streams multiple content block types beyond `text` and `tool_use`. Claude Code handles all of them: + +| Block type | Delta type | Description | +| ------------------- | ------------------------------------ | ---------------------------------------------------------- | +| `text` | `text_delta` | Regular text output | +| `tool_use` | `input_json_delta` | Client-side tool call (accumulated JSON) | +| `server_tool_use` | `input_json_delta` | Server-side tool call (same delta mechanism as `tool_use`) | +| `thinking` | `thinking_delta` + `signature_delta` | Model reasoning (extended thinking) | +| `redacted_thinking` | (none) | Safety-redacted thinking (opaque, no content) | + +### Server Tool Use + +Server tool use blocks stream identically to client tool_use — `input_json_delta` events accumulate the JSON input. The difference is execution: server tools are handled by the API, not the client. Claude Code currently handles the `advisor` tool (internal). + +## Thinking Configuration + +Extended thinking is controlled by a `thinking` field in the request body: + +```json +{ + "thinking": { "type": "enabled", "budget_tokens": 10000 } +} +``` + +Claude 4.6+ models support an `adaptive` mode where the API decides the budget: + +```json +{ + "thinking": { "type": "adaptive" } +} +``` + +When thinking is enabled, `temperature` must be omitted from the request (API rejects it). + +### Beta Headers + +- `interleaved-thinking-2025-05-14` — enables thinking blocks interleaved with text / tool_use. +- Without this header, thinking blocks appear only at the start of the response. + +## Thinking Block Lifecycle + +### Streaming + +1. `content_block_start` with `type: "thinking"` — initialize with empty `thinking: ""` and `signature: ""`. +2. `content_block_delta` with `thinking_delta` — append to `thinking` text. +3. `content_block_delta` with `signature_delta` — set `signature` (full value, not incremental). +4. `content_block_stop` — block is complete. + +### Redacted Thinking + +`redacted_thinking` blocks arrive as a single `content_block_start` with no deltas — they have no visible content. They must be preserved verbatim for round-tripping. + +### Round-Tripping + +**Critical**: Thinking and redacted_thinking blocks must be included in the conversation history sent back to the API. Stripping them causes the API to reject subsequent requests or produce degraded responses. + +Claude Code preserves these blocks through two normalization functions: + +- `normalizeContentFromAPI()` — converts SDK response blocks into storable content. +- `normalizeMessagesForAPI()` — prepares stored messages for the next API request. + +### Constraints + +- **Trailing thinking**: Assistant messages must not end with a thinking block. Claude Code strips trailing thinking blocks before sending. +- **Credential rotation**: Signatures are cryptographically bound to the API key that generated them. When credentials change (e.g., user logs in with a different account), all thinking and redacted_thinking blocks must be stripped from the conversation history — their signatures are now invalid and the API will reject them with 400. + +## Signatures + +Every `thinking` block includes a `signature` field received via `signature_delta`. Signatures are authentication markers that prove the thinking was genuinely generated under a specific API key. They are: + +- Received as a full value (not incremental like text deltas). +- Stored alongside the thinking content. +- Validated by the API on subsequent requests. +- Invalidated when API credentials change. + +Claude Code handles credential rotation in `stripSignatureBlocks()`, which removes all thinking / redacted_thinking blocks when the active credential changes. + +## Implementation Implications for oxide-code + +To properly support extended thinking, oxide-code needs: + +1. **New content block types**: `Thinking { thinking, signature }`, `RedactedThinking { data }`, `ServerToolUse { id, name, input }` in both `ContentBlockInfo` (streaming) and `ContentBlock` (message history). +2. **New delta types**: `ThinkingDelta { thinking }`, `SignatureDelta { signature }` in `Delta`. +3. **Block accumulators**: For thinking (text + signature) and server_tool_use (JSON, same as tool_use). +4. **Request parameter**: `thinking` field in `CreateMessageRequest`. +5. **Round-trip preservation**: Thinking / redacted_thinking blocks stored in `Message.content` and sent back in subsequent requests. +6. **Trailing thinking removal**: Strip thinking blocks from the end of assistant messages before sending. +7. **Credential rotation**: Strip all thinking / redacted_thinking blocks when OAuth credentials change. + +The current `#[serde(other)] Unknown` approach prevents crashes but silently drops thinking blocks, which would break conversation continuity if thinking were enabled. + +## Sources + +- `claude-code/src/services/api/claude.ts` — streaming handler, delta accumulation, request construction +- `claude-code/src/utils/messages.ts` — `normalizeContentFromAPI`, `normalizeMessagesForAPI`, `stripSignatureBlocks` +- `claude-code/src/utils/thinking.ts` — thinking config types, model support detection +- `claude-code/src/constants/betas.ts` — `INTERLEAVED_THINKING_BETA_HEADER`, `REDACT_THINKING_BETA_HEADER` From 5c9407546f64334a8ffb7030902c081712a899bc Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 00:52:35 +0800 Subject: [PATCH 04/22] feat(client): add proper thinking, redacted_thinking, and server_tool_use support Replace the Unknown catch-all with proper typed variants for thinking, redacted_thinking, and server_tool_use content blocks. Add ThinkingDelta and SignatureDelta to the Delta enum. Add block accumulators that preserve thinking text and signatures for API round-tripping. Enable adaptive thinking by default in Config. Add strip_trailing_thinking to remove thinking blocks from the end of assistant messages before sending (API constraint). Extract init_accumulator and apply_delta helpers from stream_response to keep it under the line limit. Add ThinkingConfig (adaptive / enabled) to CreateMessageRequest, driven by Config.thinking. The Unknown catch-all remains for truly unrecognized future types. --- CLAUDE.md | 1 + crates/oxide-code/src/client/anthropic.rs | 122 ++++++++++++++-- crates/oxide-code/src/config.rs | 6 + crates/oxide-code/src/main.rs | 159 +++++++++++++++------ crates/oxide-code/src/message.rs | 165 ++++++++++++++++++++++ 5 files changed, 400 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dac8e5e7..036139b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index b1339708..87b28a0a 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -24,6 +24,22 @@ const SYSTEM_PROMPT_PREFIX: &str = "You are Claude Code, Anthropic's official CL // ── Request types ── +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "variants are public API for callers to configure thinking mode" + ) +)] +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ThinkingConfig { + /// Model decides the thinking budget (Claude 4.6+). + Adaptive, + /// Fixed token budget for thinking. + Enabled { budget_tokens: u32 }, +} + #[derive(Serialize)] struct CreateMessageRequest<'a> { model: &'a str, @@ -33,6 +49,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 ── @@ -96,15 +114,25 @@ pub enum ContentBlockInfo { id: String, name: String, }, - /// Catch-all for unrecognized block types (e.g., `thinking`). Skipped during - /// stream processing. + ServerToolUse { + id: String, + name: String, + }, + Thinking { + thinking: String, + signature: String, + }, + RedactedThinking { + data: String, + }, + /// Catch-all for unrecognized block types. 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)" + 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")] @@ -115,8 +143,15 @@ pub enum Delta { InputJsonDelta { partial_json: String, }, - /// Catch-all for unrecognized delta types (e.g., `thinking_delta`). Silently - /// dropped during stream processing. + ThinkingDelta { + thinking: String, + }, + /// Full signature value (overwrites, not appended). + SignatureDelta { + signature: String, + }, + /// Catch-all for unrecognized delta types. Silently dropped during stream + /// processing. #[serde(other)] Unknown, } @@ -222,6 +257,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")?; @@ -312,15 +348,44 @@ mod tests { // ── ContentBlockInfo ── #[test] - fn content_block_info_unknown_type() { - let json = r#"{"type":"thinking","thinking":"reasoning text"}"#; + fn content_block_info_thinking() { + let json = r#"{"type":"thinking","thinking":"","signature":""}"#; let info: ContentBlockInfo = serde_json::from_str(json).unwrap(); - assert!(matches!(info, ContentBlockInfo::Unknown)); + 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_redacted_thinking() { - let json = r#"{"type":"redacted_thinking","data":"[redacted]"}"#; + 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)); } @@ -328,16 +393,29 @@ mod tests { // ── Delta ── #[test] - fn delta_unknown_type() { + fn delta_thinking() { let json = r#"{"type":"thinking_delta","thinking":"partial reasoning"}"#; let delta: Delta = serde_json::from_str(json).unwrap(); - assert!(matches!(delta, Delta::Unknown)); + let Delta::ThinkingDelta { thinking } = delta else { + panic!("expected ThinkingDelta"); + }; + assert_eq!(thinking, "partial reasoning"); } #[test] - fn delta_unknown_signature() { + 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)); } @@ -429,4 +507,22 @@ mod tests { let frame = "data: {not valid json}"; assert!(parse_sse_frame(frame).is_err()); } + + // ── ThinkingConfig ── + + #[test] + fn thinking_config_adaptive_serializes() { + let json = serde_json::to_value(&ThinkingConfig::Adaptive).unwrap(); + assert_eq!(json["type"], "adaptive"); + } + + #[test] + fn thinking_config_enabled_serializes_with_budget() { + let json = serde_json::to_value(&ThinkingConfig::Enabled { + budget_tokens: 10000, + }) + .unwrap(); + assert_eq!(json["type"], "enabled"); + assert_eq!(json["budget_tokens"], 10000); + } } diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index f0dc432a..3a388816 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -2,6 +2,8 @@ mod oauth; use anyhow::{Context, Result}; +use crate::client::anthropic::ThinkingConfig; + const DEFAULT_MODEL: &str = "claude-opus-4-6"; const DEFAULT_BASE_URL: &str = "https://api.anthropic.com"; const DEFAULT_MAX_TOKENS: u32 = 16384; @@ -20,6 +22,7 @@ pub struct Config { pub model: String, pub base_url: String, pub max_tokens: u32, + pub thinking: Option, } impl Config { @@ -46,11 +49,14 @@ impl Config { .and_then(|v| v.parse().ok()) .unwrap_or(DEFAULT_MAX_TOKENS); + let thinking = Some(ThinkingConfig::Adaptive); + Ok(Self { auth, model, base_url, max_tokens, + thinking, }) } } diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index 1ba45a38..b350626a 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -12,7 +12,7 @@ use tracing::warn; use client::anthropic::{Client, ContentBlockInfo, Delta, StreamEvent}; use config::Config; -use message::{ContentBlock, Message, Role}; +use message::{ContentBlock, Message, Role, strip_trailing_thinking}; use tool::{ ToolDefinition, ToolMetadata, ToolOutput, ToolRegistry, bash::BashTool, edit::EditTool, glob::GlobTool, grep::GrepTool, read::ReadTool, write::WriteTool, @@ -79,6 +79,7 @@ async fn agent_turn( let tool_defs = tools.definitions(); for _ in 0..MAX_TOOL_ROUNDS { + strip_trailing_thinking(messages); let blocks = stream_response(client, messages, &tool_defs).await?; let tool_uses: Vec<_> = blocks @@ -143,6 +144,18 @@ enum BlockAccumulator { name: String, json_buf: String, }, + ServerToolUse { + id: String, + name: String, + json_buf: String, + }, + Thinking { + thinking: String, + signature: String, + }, + RedactedThinking { + data: String, + }, /// Placeholder for unrecognized content block types. Absorbs deltas silently /// and produces no [`ContentBlock`] at the end. Skipped, @@ -152,18 +165,36 @@ impl BlockAccumulator { fn into_content_block(self) -> Option { match self { Self::Text(text) => Some(ContentBlock::Text { text }), - Self::ToolUse { id, name, json_buf } => { - let input = serde_json::from_str(&json_buf).unwrap_or_else(|e| { - warn!("malformed tool input JSON: {e}"); - serde_json::Value::Object(serde_json::Map::new()) - }); - Some(ContentBlock::ToolUse { id, name, input }) - } + Self::ToolUse { id, name, json_buf } => Some(ContentBlock::ToolUse { + id, + name, + input: parse_tool_json(&json_buf), + }), + Self::ServerToolUse { id, name, json_buf } => Some(ContentBlock::ServerToolUse { + id, + name, + input: parse_tool_json(&json_buf), + }), + Self::Thinking { + thinking, + signature, + } => Some(ContentBlock::Thinking { + thinking, + signature, + }), + Self::RedactedThinking { data } => Some(ContentBlock::RedactedThinking { data }), Self::Skipped => None, } } } +fn parse_tool_json(json_buf: &str) -> serde_json::Value { + serde_json::from_str(json_buf).unwrap_or_else(|e| { + warn!("malformed tool input JSON: {e}"); + serde_json::Value::Object(serde_json::Map::new()) + }) +} + async fn stream_response( client: &Client, messages: &[Message], @@ -185,41 +216,11 @@ async fn stream_response( if blocks.len() <= index { blocks.resize_with(index + 1, || None); } - blocks[index] = Some(match content_block { - ContentBlockInfo::Text { text } => { - if !text.is_empty() { - write!(stdout, "{text}")?; - stdout.flush()?; - } - BlockAccumulator::Text(text) - } - ContentBlockInfo::ToolUse { id, name } => BlockAccumulator::ToolUse { - id, - name, - json_buf: String::new(), - }, - ContentBlockInfo::Unknown => { - warn!("skipping unknown content block at index {index}"); - BlockAccumulator::Skipped - } - }); + blocks[index] = Some(init_accumulator(content_block, index, &mut stdout)?); } StreamEvent::ContentBlockDelta { index, delta } => { if let Some(Some(block)) = blocks.get_mut(index) { - match (block, delta) { - (BlockAccumulator::Text(buf), Delta::TextDelta { text }) => { - buf.push_str(&text); - write!(stdout, "{text}")?; - stdout.flush()?; - } - ( - BlockAccumulator::ToolUse { json_buf, .. }, - Delta::InputJsonDelta { partial_json }, - ) => { - json_buf.push_str(&partial_json); - } - _ => {} - } + apply_delta(block, delta, &mut stdout)?; } } StreamEvent::Error { error } => { @@ -244,6 +245,84 @@ async fn stream_response( .collect()) } +fn init_accumulator( + content_block: ContentBlockInfo, + index: usize, + stdout: &mut std::io::Stdout, +) -> Result { + Ok(match content_block { + ContentBlockInfo::Text { text } => { + if !text.is_empty() { + write!(stdout, "{text}")?; + stdout.flush()?; + } + BlockAccumulator::Text(text) + } + ContentBlockInfo::ToolUse { id, name } => BlockAccumulator::ToolUse { + id, + name, + json_buf: String::new(), + }, + ContentBlockInfo::ServerToolUse { id, name } => BlockAccumulator::ServerToolUse { + id, + name, + json_buf: String::new(), + }, + ContentBlockInfo::Thinking { + thinking, + signature, + } => BlockAccumulator::Thinking { + thinking, + signature, + }, + ContentBlockInfo::RedactedThinking { data } => BlockAccumulator::RedactedThinking { data }, + ContentBlockInfo::Unknown => { + warn!("skipping unknown content block at index {index}"); + BlockAccumulator::Skipped + } + }) +} + +fn apply_delta( + block: &mut BlockAccumulator, + delta: Delta, + stdout: &mut std::io::Stdout, +) -> Result<()> { + match (block, delta) { + (BlockAccumulator::Text(buf), Delta::TextDelta { text }) => { + buf.push_str(&text); + write!(stdout, "{text}")?; + stdout.flush()?; + } + ( + BlockAccumulator::ToolUse { json_buf, .. } + | BlockAccumulator::ServerToolUse { json_buf, .. }, + Delta::InputJsonDelta { partial_json }, + ) => { + json_buf.push_str(&partial_json); + } + ( + BlockAccumulator::Thinking { thinking, .. }, + Delta::ThinkingDelta { + thinking: thinking_delta, + }, + ) => { + thinking.push_str(&thinking_delta); + } + ( + BlockAccumulator::Thinking { signature, .. }, + Delta::SignatureDelta { + signature: sig_value, + }, + ) => { + // Signature is a full value, not incremental. + *signature = sig_value; + } + _ => {} + } + Ok(()) +} + // ── Display ── fn display_tool_call(name: &str, input: &serde_json::Value) { diff --git a/crates/oxide-code/src/message.rs b/crates/oxide-code/src/message.rs index a148407a..974f5979 100644 --- a/crates/oxide-code/src/message.rs +++ b/crates/oxide-code/src/message.rs @@ -32,6 +32,20 @@ pub enum ContentBlock { #[serde(default, skip_serializing_if = "is_default")] is_error: bool, }, + ServerToolUse { + id: String, + name: String, + input: serde_json::Value, + }, + Thinking { + thinking: String, + signature: String, + }, + /// Opaque safety-redacted thinking block. Must be preserved verbatim for + /// round-tripping — the API validates its contents. + RedactedThinking { + data: String, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -57,6 +71,23 @@ impl Message { } } +// ── Message normalization ── + +/// Strip trailing thinking / `redacted_thinking` blocks from assistant messages. +/// The API rejects assistant messages that end with thinking blocks. +pub fn strip_trailing_thinking(messages: &mut [Message]) { + for msg in messages.iter_mut().filter(|m| m.role == Role::Assistant) { + while msg.content.last().is_some_and(|b| { + matches!( + b, + ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } + ) + }) { + msg.content.pop(); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -109,6 +140,72 @@ mod tests { assert!(!is_error); } + // ── ContentBlock::Thinking ── + + #[test] + fn thinking_round_trips_through_json() { + let block = ContentBlock::Thinking { + thinking: "reasoning".to_owned(), + signature: "sig_abc".to_owned(), + }; + let json = serde_json::to_value(&block).unwrap(); + assert_eq!(json["type"], "thinking"); + assert_eq!(json["thinking"], "reasoning"); + assert_eq!(json["signature"], "sig_abc"); + + let deserialized: ContentBlock = serde_json::from_value(json).unwrap(); + let ContentBlock::Thinking { + thinking, + signature, + } = deserialized + else { + panic!("expected Thinking"); + }; + assert_eq!(thinking, "reasoning"); + assert_eq!(signature, "sig_abc"); + } + + // ── ContentBlock::RedactedThinking ── + + #[test] + fn redacted_thinking_round_trips_through_json() { + let block = ContentBlock::RedactedThinking { + data: "base64data==".to_owned(), + }; + let json = serde_json::to_value(&block).unwrap(); + assert_eq!(json["type"], "redacted_thinking"); + assert_eq!(json["data"], "base64data=="); + + let deserialized: ContentBlock = serde_json::from_value(json).unwrap(); + let ContentBlock::RedactedThinking { data } = deserialized else { + panic!("expected RedactedThinking"); + }; + assert_eq!(data, "base64data=="); + } + + // ── ContentBlock::ServerToolUse ── + + #[test] + fn server_tool_use_round_trips_through_json() { + let block = ContentBlock::ServerToolUse { + id: "stu_01".to_owned(), + name: "advisor".to_owned(), + input: serde_json::json!({"query": "test"}), + }; + let json = serde_json::to_value(&block).unwrap(); + assert_eq!(json["type"], "server_tool_use"); + assert_eq!(json["id"], "stu_01"); + assert_eq!(json["name"], "advisor"); + + let deserialized: ContentBlock = serde_json::from_value(json).unwrap(); + let ContentBlock::ServerToolUse { id, name, input } = deserialized else { + panic!("expected ServerToolUse"); + }; + assert_eq!(id, "stu_01"); + assert_eq!(name, "advisor"); + assert_eq!(input["query"], "test"); + } + // ── Message::user ── #[test] @@ -128,4 +225,72 @@ mod tests { assert_eq!(msg.content.len(), 1); assert!(matches!(&msg.content[0], ContentBlock::Text { text } if text == "hi")); } + + // ── strip_trailing_thinking ── + + #[test] + fn strip_trailing_thinking_removes_thinking_at_end() { + let mut messages = vec![Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Text { + text: "answer".to_owned(), + }, + ContentBlock::Thinking { + thinking: "reasoning".to_owned(), + signature: "sig".to_owned(), + }, + ], + }]; + strip_trailing_thinking(&mut messages); + assert_eq!(messages[0].content.len(), 1); + assert!(matches!(&messages[0].content[0], ContentBlock::Text { .. })); + } + + #[test] + fn strip_trailing_thinking_removes_redacted_at_end() { + let mut messages = vec![Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Text { + text: "answer".to_owned(), + }, + ContentBlock::RedactedThinking { + data: "opaque".to_owned(), + }, + ], + }]; + strip_trailing_thinking(&mut messages); + assert_eq!(messages[0].content.len(), 1); + } + + #[test] + fn strip_trailing_thinking_preserves_non_trailing() { + let mut messages = vec![Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "reasoning".to_owned(), + signature: "sig".to_owned(), + }, + ContentBlock::Text { + text: "answer".to_owned(), + }, + ], + }]; + strip_trailing_thinking(&mut messages); + assert_eq!(messages[0].content.len(), 2); + } + + #[test] + fn strip_trailing_thinking_skips_user_messages() { + let mut messages = vec![Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "question".to_owned(), + }], + }]; + strip_trailing_thinking(&mut messages); + assert_eq!(messages[0].content.len(), 1); + } } From 62d1dba037ee3c68d8109dc972bfa0c85975ca2c Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 00:56:36 +0800 Subject: [PATCH 05/22] docs(research): update extended thinking notes with current oxide-code status Replace the stale "Implementation Implications" planning section with a brief inline status note, matching the factual reference style of anthropic-api.md. --- docs/research/extended-thinking.md | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/docs/research/extended-thinking.md b/docs/research/extended-thinking.md index ce256557..2f0d6791 100644 --- a/docs/research/extended-thinking.md +++ b/docs/research/extended-thinking.md @@ -81,23 +81,11 @@ Every `thinking` block includes a `signature` field received via `signature_delt Claude Code handles credential rotation in `stripSignatureBlocks()`, which removes all thinking / redacted_thinking blocks when the active credential changes. -## Implementation Implications for oxide-code - -To properly support extended thinking, oxide-code needs: - -1. **New content block types**: `Thinking { thinking, signature }`, `RedactedThinking { data }`, `ServerToolUse { id, name, input }` in both `ContentBlockInfo` (streaming) and `ContentBlock` (message history). -2. **New delta types**: `ThinkingDelta { thinking }`, `SignatureDelta { signature }` in `Delta`. -3. **Block accumulators**: For thinking (text + signature) and server_tool_use (JSON, same as tool_use). -4. **Request parameter**: `thinking` field in `CreateMessageRequest`. -5. **Round-trip preservation**: Thinking / redacted_thinking blocks stored in `Message.content` and sent back in subsequent requests. -6. **Trailing thinking removal**: Strip thinking blocks from the end of assistant messages before sending. -7. **Credential rotation**: Strip all thinking / redacted_thinking blocks when OAuth credentials change. - -The current `#[serde(other)] Unknown` approach prevents crashes but silently drops thinking blocks, which would break conversation continuity if thinking were enabled. +oxide-code implements the full thinking data pipeline: typed `Thinking`, `RedactedThinking`, and `ServerToolUse` content blocks with proper streaming accumulation, signature handling, round-trip preservation, and trailing thinking removal. Adaptive thinking is enabled by default. Credential rotation stripping is not yet implemented (depends on Keychain OAuth support). ## Sources +- `claude-code/src/constants/betas.ts` — `INTERLEAVED_THINKING_BETA_HEADER`, `REDACT_THINKING_BETA_HEADER` - `claude-code/src/services/api/claude.ts` — streaming handler, delta accumulation, request construction - `claude-code/src/utils/messages.ts` — `normalizeContentFromAPI`, `normalizeMessagesForAPI`, `stripSignatureBlocks` - `claude-code/src/utils/thinking.ts` — thinking config types, model support detection -- `claude-code/src/constants/betas.ts` — `INTERLEAVED_THINKING_BETA_HEADER`, `REDACT_THINKING_BETA_HEADER` From cbc3da996bf4a84d392be7e682fb37ca860b7566 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 01:19:15 +0800 Subject: [PATCH 06/22] refactor(config): move ThinkingConfig from client to config module ThinkingConfig conceptually belongs with configuration, not the HTTP client. Moving it to config.rs fixes the inverted dependency where config imported from client::anthropic. Also fixes the #[expect(dead_code)] reason string to describe current state per convention, and adds a comment explaining the hardcoded adaptive thinking default. --- crates/oxide-code/src/client/anthropic.rs | 36 +------------------ crates/oxide-code/src/config.rs | 43 +++++++++++++++++++++-- 2 files changed, 42 insertions(+), 37 deletions(-) diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 87b28a0a..6ec61f07 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -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; @@ -24,22 +24,6 @@ const SYSTEM_PROMPT_PREFIX: &str = "You are Claude Code, Anthropic's official CL // ── Request types ── -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "variants are public API for callers to configure thinking mode" - ) -)] -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ThinkingConfig { - /// Model decides the thinking budget (Claude 4.6+). - Adaptive, - /// Fixed token budget for thinking. - Enabled { budget_tokens: u32 }, -} - #[derive(Serialize)] struct CreateMessageRequest<'a> { model: &'a str, @@ -507,22 +491,4 @@ mod tests { let frame = "data: {not valid json}"; assert!(parse_sse_frame(frame).is_err()); } - - // ── ThinkingConfig ── - - #[test] - fn thinking_config_adaptive_serializes() { - let json = serde_json::to_value(&ThinkingConfig::Adaptive).unwrap(); - assert_eq!(json["type"], "adaptive"); - } - - #[test] - fn thinking_config_enabled_serializes_with_budget() { - let json = serde_json::to_value(&ThinkingConfig::Enabled { - budget_tokens: 10000, - }) - .unwrap(); - assert_eq!(json["type"], "enabled"); - assert_eq!(json["budget_tokens"], 10000); - } } diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index 3a388816..182fb3d6 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -1,8 +1,7 @@ mod oauth; use anyhow::{Context, Result}; - -use crate::client::anthropic::ThinkingConfig; +use serde::Serialize; const DEFAULT_MODEL: &str = "claude-opus-4-6"; const DEFAULT_BASE_URL: &str = "https://api.anthropic.com"; @@ -16,6 +15,22 @@ pub enum Auth { OAuth(String), } +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "Enabled variant is constructed only in tests; Adaptive is the sole production path" + ) +)] +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ThinkingConfig { + /// Model decides the thinking budget (Claude 4.6+). + Adaptive, + /// Fixed token budget for thinking. + Enabled { budget_tokens: u32 }, +} + #[derive(Debug, Clone)] pub struct Config { pub auth: Auth, @@ -49,6 +64,7 @@ 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); Ok(Self { @@ -64,3 +80,26 @@ impl Config { fn non_empty_env(key: &str) -> Option { std::env::var(key).ok().filter(|v| !v.is_empty()) } + +#[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"); + } + + #[test] + fn thinking_config_enabled_serializes_with_budget() { + let json = serde_json::to_value(&ThinkingConfig::Enabled { + budget_tokens: 10000, + }) + .unwrap(); + assert_eq!(json["type"], "enabled"); + assert_eq!(json["budget_tokens"], 10000); + } +} From ec6691516cc2ea5b518e90cd0b85aabd13aae1d3 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 01:20:05 +0800 Subject: [PATCH 07/22] fix(main): improve streaming pipeline edge cases - Add Debug derive to BlockAccumulator for consistency with parallel enums (ContentBlockInfo, ContentBlock) and diagnostic traceability. - Log unhandled block/delta combinations at debug level instead of silently dropping them, aiding protocol issue diagnosis. - Guard trailing newline emission against empty text blocks to prevent spurious output. --- crates/oxide-code/src/main.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index b350626a..0cf8118b 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -8,7 +8,7 @@ use std::io::Write; use anyhow::{Context, Result, bail}; use clap::Parser; use tokio::io::{AsyncBufReadExt, BufReader}; -use tracing::warn; +use tracing::{debug, warn}; use client::anthropic::{Client, ContentBlockInfo, Delta, StreamEvent}; use config::Config; @@ -137,6 +137,7 @@ async fn agent_turn( // ── Stream Processing ── +#[derive(Debug)] enum BlockAccumulator { Text(String), ToolUse { @@ -233,7 +234,7 @@ async fn stream_response( // Streamed text deltas don't include a final newline. let has_text = blocks .iter() - .any(|b| matches!(b, Some(BlockAccumulator::Text(_)))); + .any(|b| matches!(b, Some(BlockAccumulator::Text(s)) if !s.is_empty())); if has_text { writeln!(stdout)?; } @@ -318,7 +319,9 @@ fn apply_delta( // Signature is a full value, not incremental. *signature = sig_value; } - _ => {} + (_, delta) => { + debug!(?delta, "ignoring unhandled delta"); + } } Ok(()) } From 7040a4734744faa6f645996767921db8281c1192 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 01:20:51 +0800 Subject: [PATCH 08/22] test(message): strengthen strip_trailing_thinking coverage - Assert the surviving block type in removes_redacted_at_end (was only checking length, which would pass even if the wrong block survived). - Add test for multiple consecutive trailing thinking blocks to exercise the while loop. - Add test for all-thinking assistant message to document the empty content vec edge case. --- crates/oxide-code/src/message.rs | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/oxide-code/src/message.rs b/crates/oxide-code/src/message.rs index 974f5979..d23ae3a2 100644 --- a/crates/oxide-code/src/message.rs +++ b/crates/oxide-code/src/message.rs @@ -262,6 +262,7 @@ mod tests { }]; strip_trailing_thinking(&mut messages); assert_eq!(messages[0].content.len(), 1); + assert!(matches!(&messages[0].content[0], ContentBlock::Text { .. })); } #[test] @@ -293,4 +294,39 @@ mod tests { strip_trailing_thinking(&mut messages); assert_eq!(messages[0].content.len(), 1); } + + #[test] + fn strip_trailing_thinking_removes_multiple_consecutive() { + let mut messages = vec![Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Text { + text: "answer".to_owned(), + }, + ContentBlock::Thinking { + thinking: "first".to_owned(), + signature: "sig1".to_owned(), + }, + ContentBlock::RedactedThinking { + data: "opaque".to_owned(), + }, + ], + }]; + strip_trailing_thinking(&mut messages); + assert_eq!(messages[0].content.len(), 1); + assert!(matches!(&messages[0].content[0], ContentBlock::Text { .. })); + } + + #[test] + fn strip_trailing_thinking_empties_all_thinking_message() { + let mut messages = vec![Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: "reasoning".to_owned(), + signature: "sig".to_owned(), + }], + }]; + strip_trailing_thinking(&mut messages); + assert!(messages[0].content.is_empty()); + } } From ac7506eaec735becaae611e4b6e637dc784f4a35 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 01:21:27 +0800 Subject: [PATCH 09/22] docs: update roadmap and Code Review conventions - Fix roadmap streaming robustness bullet to reflect that thinking, redacted_thinking, and server_tool_use are now fully handled, not just silently skipped. - Add DRY, cross-file consistency, and idiomatic Rust to the Code Review checklist in CLAUDE.md. --- CLAUDE.md | 3 +++ docs/roadmap.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 036139b7..260136bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,5 +156,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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 4517195a..6a76222f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,7 +14,7 @@ The project direction is simple: - Async REPL that reads user input and streams responses from the Anthropic Messages API. - Agent loop: the LLM can request tool execution, results feed back into the conversation, looping until a text-only response. -- Streaming robustness — unknown content block types (`thinking`, `redacted_thinking`, `signature_delta`, etc.) are silently skipped instead of crashing deserialization. +- Extended thinking — full streaming pipeline for `thinking`, `redacted_thinking`, `server_tool_use`, and signature handling with round-trip preservation. Unrecognized future content block types are silently skipped. ### Authentication & Configuration From a2c88b60de6ffcc39e28141fc3e8c30ed3336c1d Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 01:24:39 +0800 Subject: [PATCH 10/22] test(message): consolidate strip_trailing_thinking tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop removes_redacted_at_end — it was subsumed by removes_multiple_consecutive, which already exercises both Thinking and RedactedThinking removal through the while loop. Strengthen preserves_non_trailing to assert block identity and order, not just count. Add test conciseness convention to CLAUDE.md: prefer fewer thorough tests over many minimal ones; drop tests subsumed by more comprehensive ones. --- CLAUDE.md | 1 + crates/oxide-code/src/message.rs | 23 +++++------------------ 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 260136bf..136b8f7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,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 diff --git a/crates/oxide-code/src/message.rs b/crates/oxide-code/src/message.rs index d23ae3a2..3bb0cc16 100644 --- a/crates/oxide-code/src/message.rs +++ b/crates/oxide-code/src/message.rs @@ -247,24 +247,6 @@ mod tests { assert!(matches!(&messages[0].content[0], ContentBlock::Text { .. })); } - #[test] - fn strip_trailing_thinking_removes_redacted_at_end() { - let mut messages = vec![Message { - role: Role::Assistant, - content: vec![ - ContentBlock::Text { - text: "answer".to_owned(), - }, - ContentBlock::RedactedThinking { - data: "opaque".to_owned(), - }, - ], - }]; - strip_trailing_thinking(&mut messages); - assert_eq!(messages[0].content.len(), 1); - assert!(matches!(&messages[0].content[0], ContentBlock::Text { .. })); - } - #[test] fn strip_trailing_thinking_preserves_non_trailing() { let mut messages = vec![Message { @@ -281,6 +263,11 @@ mod tests { }]; strip_trailing_thinking(&mut messages); assert_eq!(messages[0].content.len(), 2); + assert!(matches!( + &messages[0].content[0], + ContentBlock::Thinking { .. } + )); + assert!(matches!(&messages[0].content[1], ContentBlock::Text { text } if text == "answer")); } #[test] From 5dad81bf734b7efdf0a2dcf198dc352407767ece Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 01:35:14 +0800 Subject: [PATCH 11/22] fix(main): remove empty assistant messages after thinking removal strip_trailing_thinking can leave an assistant message with empty content if the response contained only thinking blocks. The API rejects empty content arrays, so filter these out before sending. Also include the block type in the delta mismatch debug trace for better diagnostic context. --- crates/oxide-code/src/main.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index 0cf8118b..712e8f7f 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -80,6 +80,9 @@ async fn agent_turn( for _ in 0..MAX_TOOL_ROUNDS { strip_trailing_thinking(messages); + // The API rejects assistant messages with empty content (e.g., after + // stripping an all-thinking response). + messages.retain(|m| !(m.role == Role::Assistant && m.content.is_empty())); let blocks = stream_response(client, messages, &tool_defs).await?; let tool_uses: Vec<_> = blocks @@ -319,8 +322,8 @@ fn apply_delta( // Signature is a full value, not incremental. *signature = sig_value; } - (_, delta) => { - debug!(?delta, "ignoring unhandled delta"); + (block, delta) => { + debug!(?block, ?delta, "ignoring unhandled delta"); } } Ok(()) From 5491ba0e64e007c98bb01a41e40711118ba3fe4b Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 01:35:24 +0800 Subject: [PATCH 12/22] style(message): reorder test sections to match enum variant order Move ContentBlock::ServerToolUse tests before ContentBlock::Thinking to mirror the enum definition order (ToolResult, ServerToolUse, Thinking, RedactedThinking). --- crates/oxide-code/src/message.rs | 46 ++++++++++++++++---------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/crates/oxide-code/src/message.rs b/crates/oxide-code/src/message.rs index 3bb0cc16..d616654b 100644 --- a/crates/oxide-code/src/message.rs +++ b/crates/oxide-code/src/message.rs @@ -140,6 +140,29 @@ mod tests { assert!(!is_error); } + // ── ContentBlock::ServerToolUse ── + + #[test] + fn server_tool_use_round_trips_through_json() { + let block = ContentBlock::ServerToolUse { + id: "stu_01".to_owned(), + name: "advisor".to_owned(), + input: serde_json::json!({"query": "test"}), + }; + let json = serde_json::to_value(&block).unwrap(); + assert_eq!(json["type"], "server_tool_use"); + assert_eq!(json["id"], "stu_01"); + assert_eq!(json["name"], "advisor"); + + let deserialized: ContentBlock = serde_json::from_value(json).unwrap(); + let ContentBlock::ServerToolUse { id, name, input } = deserialized else { + panic!("expected ServerToolUse"); + }; + assert_eq!(id, "stu_01"); + assert_eq!(name, "advisor"); + assert_eq!(input["query"], "test"); + } + // ── ContentBlock::Thinking ── #[test] @@ -183,29 +206,6 @@ mod tests { assert_eq!(data, "base64data=="); } - // ── ContentBlock::ServerToolUse ── - - #[test] - fn server_tool_use_round_trips_through_json() { - let block = ContentBlock::ServerToolUse { - id: "stu_01".to_owned(), - name: "advisor".to_owned(), - input: serde_json::json!({"query": "test"}), - }; - let json = serde_json::to_value(&block).unwrap(); - assert_eq!(json["type"], "server_tool_use"); - assert_eq!(json["id"], "stu_01"); - assert_eq!(json["name"], "advisor"); - - let deserialized: ContentBlock = serde_json::from_value(json).unwrap(); - let ContentBlock::ServerToolUse { id, name, input } = deserialized else { - panic!("expected ServerToolUse"); - }; - assert_eq!(id, "stu_01"); - assert_eq!(name, "advisor"); - assert_eq!(input["query"], "test"); - } - // ── Message::user ── #[test] From 03ad4a895d08b11a6eada3aac3ad3b2f597cf940 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 02:03:44 +0800 Subject: [PATCH 13/22] docs(message): update ContentBlock docstring for new variants The docstring still listed only Text and ToolUse for assistant messages, missing ServerToolUse, Thinking, and RedactedThinking added in this PR. --- crates/oxide-code/src/message.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/oxide-code/src/message.rs b/crates/oxide-code/src/message.rs index d616654b..662ae211 100644 --- a/crates/oxide-code/src/message.rs +++ b/crates/oxide-code/src/message.rs @@ -13,8 +13,9 @@ pub enum Role { /// A content block within a message. /// -/// User messages typically contain `Text` or `ToolResult` blocks. -/// Assistant messages typically contain `Text` or `ToolUse` blocks. +/// User messages contain `Text` or `ToolResult` blocks. Assistant messages +/// contain `Text`, `ToolUse`, `ServerToolUse`, `Thinking`, and / or +/// `RedactedThinking` blocks. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentBlock { From aaeef205bc7e716a5740ceb04d5df7daf595f548 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 02:40:36 +0800 Subject: [PATCH 14/22] feat(main): add optional dimmed thinking display via OX_SHOW_THINKING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When OX_SHOW_THINKING=1, stream thinking deltas to stdout with ANSI dim styling (\x1b[2m). Off by default — thinking blocks are accumulated silently for API round-tripping as before. - Add `show_thinking` field to Config, loaded from OX_SHOW_THINKING env var - Thread the flag through repl → agent_turn → stream_response → helpers - Write dim text in init_accumulator (initial thinking) and apply_delta (thinking deltas) - Handle ContentBlockStop for thinking blocks to emit a trailing newline separating thinking from text output --- crates/oxide-code/src/config.rs | 5 +++ crates/oxide-code/src/main.rs | 54 +++++++++++++++++++++++++++------ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index 182fb3d6..ef7bcf47 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -38,6 +38,7 @@ pub struct Config { pub base_url: String, pub max_tokens: u32, pub thinking: Option, + pub show_thinking: bool, } impl Config { @@ -67,12 +68,16 @@ impl Config { // Adaptive thinking is always enabled — the model decides the budget. let thinking = Some(ThinkingConfig::Adaptive); + let show_thinking = + non_empty_env("OX_SHOW_THINKING").is_some_and(|v| v == "1" || v == "true"); + Ok(Self { auth, model, base_url, max_tokens, thinking, + show_thinking, }) } } diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index 712e8f7f..67daf2c2 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -33,6 +33,7 @@ async fn main() -> Result<()> { .init(); let config = Config::load().await?; + let show_thinking = config.show_thinking; let client = Client::new(config)?; let tools = ToolRegistry::new(vec![ Box::new(BashTool), @@ -43,10 +44,10 @@ async fn main() -> Result<()> { Box::new(GrepTool), ]); - repl(&client, &tools).await + repl(&client, &tools, show_thinking).await } -async fn repl(client: &Client, tools: &ToolRegistry) -> Result<()> { +async fn repl(client: &Client, tools: &ToolRegistry, show_thinking: bool) -> Result<()> { let stdin = BufReader::new(tokio::io::stdin()); let mut lines = stdin.lines(); let mut messages: Vec = Vec::new(); @@ -65,7 +66,7 @@ async fn repl(client: &Client, tools: &ToolRegistry) -> Result<()> { } messages.push(Message::user(&input)); - agent_turn(client, tools, &mut messages).await?; + agent_turn(client, tools, &mut messages, show_thinking).await?; } Ok(()) @@ -75,6 +76,7 @@ async fn agent_turn( client: &Client, tools: &ToolRegistry, messages: &mut Vec, + show_thinking: bool, ) -> Result<()> { let tool_defs = tools.definitions(); @@ -83,7 +85,7 @@ async fn agent_turn( // The API rejects assistant messages with empty content (e.g., after // stripping an all-thinking response). messages.retain(|m| !(m.role == Role::Assistant && m.content.is_empty())); - let blocks = stream_response(client, messages, &tool_defs).await?; + let blocks = stream_response(client, messages, &tool_defs, show_thinking).await?; let tool_uses: Vec<_> = blocks .iter() @@ -140,6 +142,9 @@ async fn agent_turn( // ── Stream Processing ── +const DIM: &str = "\x1b[2m"; +const DIM_END: &str = "\x1b[22m"; + #[derive(Debug)] enum BlockAccumulator { Text(String), @@ -203,6 +208,7 @@ async fn stream_response( client: &Client, messages: &[Message], tools: &[ToolDefinition], + show_thinking: bool, ) -> Result> { let mut rx = client.stream_message(messages, None, tools)?; @@ -220,11 +226,27 @@ async fn stream_response( if blocks.len() <= index { blocks.resize_with(index + 1, || None); } - blocks[index] = Some(init_accumulator(content_block, index, &mut stdout)?); + blocks[index] = Some(init_accumulator( + content_block, + index, + &mut stdout, + show_thinking, + )?); } StreamEvent::ContentBlockDelta { index, delta } => { if let Some(Some(block)) = blocks.get_mut(index) { - apply_delta(block, delta, &mut stdout)?; + apply_delta(block, delta, &mut stdout, show_thinking)?; + } + } + StreamEvent::ContentBlockStop { index } => { + if show_thinking + && matches!( + blocks.get(index), + Some(Some(BlockAccumulator::Thinking { .. })) + ) + { + writeln!(stdout)?; + stdout.flush()?; } } StreamEvent::Error { error } => { @@ -253,6 +275,7 @@ fn init_accumulator( content_block: ContentBlockInfo, index: usize, stdout: &mut std::io::Stdout, + show_thinking: bool, ) -> Result { Ok(match content_block { ContentBlockInfo::Text { text } => { @@ -275,10 +298,16 @@ fn init_accumulator( ContentBlockInfo::Thinking { thinking, signature, - } => BlockAccumulator::Thinking { - thinking, - signature, - }, + } => { + if show_thinking && !thinking.is_empty() { + write!(stdout, "{DIM}{thinking}{DIM_END}")?; + stdout.flush()?; + } + BlockAccumulator::Thinking { + thinking, + signature, + } + } ContentBlockInfo::RedactedThinking { data } => BlockAccumulator::RedactedThinking { data }, ContentBlockInfo::Unknown => { warn!("skipping unknown content block at index {index}"); @@ -291,6 +320,7 @@ fn apply_delta( block: &mut BlockAccumulator, delta: Delta, stdout: &mut std::io::Stdout, + show_thinking: bool, ) -> Result<()> { match (block, delta) { (BlockAccumulator::Text(buf), Delta::TextDelta { text }) => { @@ -312,6 +342,10 @@ fn apply_delta( }, ) => { thinking.push_str(&thinking_delta); + if show_thinking { + write!(stdout, "{DIM}{thinking_delta}{DIM_END}")?; + stdout.flush()?; + } } ( BlockAccumulator::Thinking { signature, .. }, From 2ab3fd0a828a3522b9418e43cb6cb11baa4af615 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 02:40:41 +0800 Subject: [PATCH 15/22] docs(roadmap): add thinking display and TOML config file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update extended thinking bullet to mention OX_SHOW_THINKING. - Add Configuration File section under Next Phase: TOML config with layered loading (global → user → project → env var overrides). --- docs/roadmap.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 6a76222f..46952955 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,7 +14,7 @@ The project direction is simple: - Async REPL that reads user input and streams responses from the Anthropic Messages API. - Agent loop: the LLM can request tool execution, results feed back into the conversation, looping until a text-only response. -- Extended thinking — full streaming pipeline for `thinking`, `redacted_thinking`, `server_tool_use`, and signature handling with round-trip preservation. Unrecognized future content block types are silently skipped. +- Extended thinking — full streaming pipeline for `thinking`, `redacted_thinking`, `server_tool_use`, and signature handling with round-trip preservation. Unrecognized future content block types are silently skipped. Optional dimmed thinking display (`OX_SHOW_THINKING`). ### Authentication & Configuration @@ -54,10 +54,16 @@ The project direction is simple: - Inline tool call / result display using `ToolMetadata::title`. - Multi-line input editor. +### Configuration File + +- TOML config file (`~/.config/ox/config.toml` or `ox.toml` in project root) to replace env-var-only configuration. +- Layered loading: global defaults → user config → project config → env var overrides. +- All current env vars (`ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`, `OX_SHOW_THINKING`, etc.) become config keys, with env vars still taking precedence. + ### Tool Enhancements - Centralized output truncation — move truncation from individual tools into the tool dispatch layer. Enables consistent behavior and large-output persistence to disk. -- File-change tracking — track read files and their mtimes. Return a stub on re-read when content hasn't changed (saves tokens). Enable read-before-write guards to prevent blind overwrites. +- File-change tracking — track read files and their modification times. Return a stub on re-read when content hasn't changed (saves tokens). Enable read-before-write guards to prevent blind overwrites. ### Session Persistence From 0629b2e67bea07d1492be6d385344890474a2273 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 19:06:43 +0800 Subject: [PATCH 16/22] refactor(message): reorder ContentBlock variants and narrow strip_trailing_thinking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move ServerToolUse before ToolResult to align variant order with ContentBlockInfo and BlockAccumulator (tool-use variants grouped). - Narrow strip_trailing_thinking to target only the last assistant message via rfind — earlier messages were already processed. - Clarify comment on empty-message removal after thinking stripping. - Reorder test sections to mirror new variant order. - Add strip_trailing_thinking_targets_only_last_assistant test. --- crates/oxide-code/src/main.rs | 5 +- crates/oxide-code/src/message.rs | 127 +++++++++++++++++++++---------- 2 files changed, 91 insertions(+), 41 deletions(-) diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index 67daf2c2..d28238b4 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -82,8 +82,9 @@ async fn agent_turn( for _ in 0..MAX_TOOL_ROUNDS { strip_trailing_thinking(messages); - // The API rejects assistant messages with empty content (e.g., after - // stripping an all-thinking response). + // Stripping can leave an all-thinking assistant message with empty + // content (thinking-only responses lose their blocks here). The API + // rejects empty assistant messages, so remove them. messages.retain(|m| !(m.role == Role::Assistant && m.content.is_empty())); let blocks = stream_response(client, messages, &tool_defs, show_thinking).await?; diff --git a/crates/oxide-code/src/message.rs b/crates/oxide-code/src/message.rs index 662ae211..e59cc5e5 100644 --- a/crates/oxide-code/src/message.rs +++ b/crates/oxide-code/src/message.rs @@ -27,17 +27,17 @@ pub enum ContentBlock { name: String, input: serde_json::Value, }, + ServerToolUse { + id: String, + name: String, + input: serde_json::Value, + }, ToolResult { tool_use_id: String, content: String, #[serde(default, skip_serializing_if = "is_default")] is_error: bool, }, - ServerToolUse { - id: String, - name: String, - input: serde_json::Value, - }, Thinking { thinking: String, signature: String, @@ -74,18 +74,22 @@ impl Message { // ── Message normalization ── -/// Strip trailing thinking / `redacted_thinking` blocks from assistant messages. -/// The API rejects assistant messages that end with thinking blocks. +/// Strip trailing thinking / `redacted_thinking` blocks from the last assistant +/// message. The API rejects assistant messages that end with thinking blocks. +/// +/// Only the most recent assistant message can have un-stripped trailing thinking +/// — earlier ones were already processed in prior iterations. pub fn strip_trailing_thinking(messages: &mut [Message]) { - for msg in messages.iter_mut().filter(|m| m.role == Role::Assistant) { - while msg.content.last().is_some_and(|b| { - matches!( - b, - ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } - ) - }) { - msg.content.pop(); - } + let Some(msg) = messages.iter_mut().rfind(|m| m.role == Role::Assistant) else { + return; + }; + while msg.content.last().is_some_and(|b| { + matches!( + b, + ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } + ) + }) { + msg.content.pop(); } } @@ -93,6 +97,29 @@ pub fn strip_trailing_thinking(messages: &mut [Message]) { mod tests { use super::*; + // ── ContentBlock::ServerToolUse ── + + #[test] + fn server_tool_use_round_trips_through_json() { + let block = ContentBlock::ServerToolUse { + id: "stu_01".to_owned(), + name: "advisor".to_owned(), + input: serde_json::json!({"query": "test"}), + }; + let json = serde_json::to_value(&block).unwrap(); + assert_eq!(json["type"], "server_tool_use"); + assert_eq!(json["id"], "stu_01"); + assert_eq!(json["name"], "advisor"); + + let deserialized: ContentBlock = serde_json::from_value(json).unwrap(); + let ContentBlock::ServerToolUse { id, name, input } = deserialized else { + panic!("expected ServerToolUse"); + }; + assert_eq!(id, "stu_01"); + assert_eq!(name, "advisor"); + assert_eq!(input["query"], "test"); + } + // ── ContentBlock::ToolResult ── #[test] @@ -141,29 +168,6 @@ mod tests { assert!(!is_error); } - // ── ContentBlock::ServerToolUse ── - - #[test] - fn server_tool_use_round_trips_through_json() { - let block = ContentBlock::ServerToolUse { - id: "stu_01".to_owned(), - name: "advisor".to_owned(), - input: serde_json::json!({"query": "test"}), - }; - let json = serde_json::to_value(&block).unwrap(); - assert_eq!(json["type"], "server_tool_use"); - assert_eq!(json["id"], "stu_01"); - assert_eq!(json["name"], "advisor"); - - let deserialized: ContentBlock = serde_json::from_value(json).unwrap(); - let ContentBlock::ServerToolUse { id, name, input } = deserialized else { - panic!("expected ServerToolUse"); - }; - assert_eq!(id, "stu_01"); - assert_eq!(name, "advisor"); - assert_eq!(input["query"], "test"); - } - // ── ContentBlock::Thinking ── #[test] @@ -305,6 +309,51 @@ mod tests { assert!(matches!(&messages[0].content[0], ContentBlock::Text { .. })); } + #[test] + fn strip_trailing_thinking_targets_only_last_assistant() { + let mut messages = vec![ + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Text { + text: "first".to_owned(), + }, + ContentBlock::Thinking { + thinking: "old".to_owned(), + signature: "sig_old".to_owned(), + }, + ], + }, + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "follow-up".to_owned(), + }], + }, + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Text { + text: "second".to_owned(), + }, + ContentBlock::Thinking { + thinking: "new".to_owned(), + signature: "sig_new".to_owned(), + }, + ], + }, + ]; + strip_trailing_thinking(&mut messages); + // Only the last assistant message is stripped. + assert_eq!(messages[0].content.len(), 2); + assert!(matches!( + &messages[0].content[1], + ContentBlock::Thinking { thinking, .. } if thinking == "old" + )); + assert_eq!(messages[2].content.len(), 1); + assert!(matches!(&messages[2].content[0], ContentBlock::Text { text } if text == "second")); + } + #[test] fn strip_trailing_thinking_empties_all_thinking_message() { let mut messages = vec![Message { From d643cba7a6f0f7404385c65e22a71b438dfdbb99 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 19:11:02 +0800 Subject: [PATCH 17/22] style(client): unify catch-all doc comments on Unknown variants Use consistent phrasing ("Silently skipped during stream processing") across all three #[serde(other)] Unknown variants: StreamEvent, ContentBlockInfo, and Delta. --- crates/oxide-code/src/client/anthropic.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 6ec61f07..99c1a458 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -69,7 +69,8 @@ pub enum StreamEvent { Error { error: ApiError, }, - /// Catch-all for unrecognized event types. Silently ignored in stream processing. + /// Catch-all for unrecognized event types. + /// Silently skipped during stream processing. #[serde(other)] Unknown, } @@ -109,7 +110,8 @@ pub enum ContentBlockInfo { RedactedThinking { data: String, }, - /// Catch-all for unrecognized block types. Skipped during stream processing. + /// Catch-all for unrecognized block types. + /// Silently skipped during stream processing. #[serde(other)] Unknown, } @@ -134,8 +136,8 @@ pub enum Delta { SignatureDelta { signature: String, }, - /// Catch-all for unrecognized delta types. Silently dropped during stream - /// processing. + /// Catch-all for unrecognized delta types. + /// Silently skipped during stream processing. #[serde(other)] Unknown, } From f332bf023a6f102cf1fbda7375ffb5bbddaea7eb Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 19:18:40 +0800 Subject: [PATCH 18/22] refactor(config): extract env_bool helper for boolean env var parsing Extract the truthiness check (`"1"` / `"true"`) into a reusable `env_bool` function, pairing with `non_empty_env` for string-valued env vars. Simplifies the `show_thinking` assignment and provides a consistent pattern for future `OX_*` boolean flags. --- crates/oxide-code/src/config.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index ef7bcf47..aa0bc294 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -68,8 +68,7 @@ impl Config { // Adaptive thinking is always enabled — the model decides the budget. let thinking = Some(ThinkingConfig::Adaptive); - let show_thinking = - non_empty_env("OX_SHOW_THINKING").is_some_and(|v| v == "1" || v == "true"); + let show_thinking = env_bool("OX_SHOW_THINKING"); Ok(Self { auth, @@ -86,6 +85,10 @@ fn non_empty_env(key: &str) -> Option { 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::*; From e9113aaad70bb7e11f7ded8a6c5e8354819eb7c7 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 19:33:27 +0800 Subject: [PATCH 19/22] refactor(config): remove unused ThinkingConfig::Enabled variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only Adaptive is used — no production or planned code path constructs Enabled. Adding it back is trivial when a fixed-budget thinking mode is actually needed. --- crates/oxide-code/src/config.rs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index aa0bc294..e95eda25 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -15,20 +15,11 @@ pub enum Auth { OAuth(String), } -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "Enabled variant is constructed only in tests; Adaptive is the sole production path" - ) -)] #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ThinkingConfig { /// Model decides the thinking budget (Claude 4.6+). Adaptive, - /// Fixed token budget for thinking. - Enabled { budget_tokens: u32 }, } #[derive(Debug, Clone)] @@ -100,14 +91,4 @@ mod tests { let json = serde_json::to_value(&ThinkingConfig::Adaptive).unwrap(); assert_eq!(json["type"], "adaptive"); } - - #[test] - fn thinking_config_enabled_serializes_with_budget() { - let json = serde_json::to_value(&ThinkingConfig::Enabled { - budget_tokens: 10000, - }) - .unwrap(); - assert_eq!(json["type"], "enabled"); - assert_eq!(json["budget_tokens"], 10000); - } } From 8483d89333de0fffa3ae0aa83f577068297e71a2 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 19:38:18 +0800 Subject: [PATCH 20/22] perf(main): use write_all for plain text output Replace write!(stdout, "{text}") with stdout.write_all(text.as_bytes()) where no format interpolation is needed. --- crates/oxide-code/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index d28238b4..01aba88d 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -281,7 +281,7 @@ fn init_accumulator( Ok(match content_block { ContentBlockInfo::Text { text } => { if !text.is_empty() { - write!(stdout, "{text}")?; + stdout.write_all(text.as_bytes())?; stdout.flush()?; } BlockAccumulator::Text(text) @@ -326,7 +326,7 @@ fn apply_delta( match (block, delta) { (BlockAccumulator::Text(buf), Delta::TextDelta { text }) => { buf.push_str(&text); - write!(stdout, "{text}")?; + stdout.write_all(text.as_bytes())?; stdout.flush()?; } ( From fdef2b87104e10b706f6f741a0616176c4bdd11c Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 19:45:00 +0800 Subject: [PATCH 21/22] fix(message): insert placeholder for thinking-only responses instead of deleting Deleting an empty-after-stripping assistant message breaks user/assistant alternation, causing consecutive user messages that the API rejects. Insert a "[No message content]" placeholder instead, matching Claude Code's filterTrailingThinkingFromLastAssistant behavior. Also update research notes with the full normalization pipeline and ordering constraints discovered in Claude Code's source. --- crates/oxide-code/src/main.rs | 4 ---- crates/oxide-code/src/message.rs | 15 +++++++++++++-- docs/research/extended-thinking.md | 16 ++++++++++------ 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index 01aba88d..346dc3ae 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -82,10 +82,6 @@ async fn agent_turn( for _ in 0..MAX_TOOL_ROUNDS { strip_trailing_thinking(messages); - // Stripping can leave an all-thinking assistant message with empty - // content (thinking-only responses lose their blocks here). The API - // rejects empty assistant messages, so remove them. - messages.retain(|m| !(m.role == Role::Assistant && m.content.is_empty())); let blocks = stream_response(client, messages, &tool_defs, show_thinking).await?; let tool_uses: Vec<_> = blocks diff --git a/crates/oxide-code/src/message.rs b/crates/oxide-code/src/message.rs index e59cc5e5..26c6f04e 100644 --- a/crates/oxide-code/src/message.rs +++ b/crates/oxide-code/src/message.rs @@ -77,6 +77,9 @@ impl Message { /// Strip trailing thinking / `redacted_thinking` blocks from the last assistant /// message. The API rejects assistant messages that end with thinking blocks. /// +/// If stripping removes all content (thinking-only response), a placeholder text +/// block is inserted to preserve user / assistant alternation. +/// /// Only the most recent assistant message can have un-stripped trailing thinking /// — earlier ones were already processed in prior iterations. pub fn strip_trailing_thinking(messages: &mut [Message]) { @@ -91,6 +94,11 @@ pub fn strip_trailing_thinking(messages: &mut [Message]) { }) { msg.content.pop(); } + if msg.content.is_empty() { + msg.content.push(ContentBlock::Text { + text: "[No message content]".to_owned(), + }); + } } #[cfg(test)] @@ -355,7 +363,7 @@ mod tests { } #[test] - fn strip_trailing_thinking_empties_all_thinking_message() { + fn strip_trailing_thinking_inserts_placeholder_for_thinking_only() { let mut messages = vec![Message { role: Role::Assistant, content: vec![ContentBlock::Thinking { @@ -364,6 +372,9 @@ mod tests { }], }]; strip_trailing_thinking(&mut messages); - assert!(messages[0].content.is_empty()); + assert_eq!(messages[0].content.len(), 1); + assert!( + matches!(&messages[0].content[0], ContentBlock::Text { text } if text == "[No message content]") + ); } } diff --git a/docs/research/extended-thinking.md b/docs/research/extended-thinking.md index 2f0d6791..afdb5931 100644 --- a/docs/research/extended-thinking.md +++ b/docs/research/extended-thinking.md @@ -60,14 +60,18 @@ When thinking is enabled, `temperature` must be omitted from the request (API re **Critical**: Thinking and redacted_thinking blocks must be included in the conversation history sent back to the API. Stripping them causes the API to reject subsequent requests or produce degraded responses. -Claude Code preserves these blocks through two normalization functions: +Claude Code preserves these blocks through normalization in `normalizeMessagesForAPI()`, which runs a multi-pass pipeline before each API request: -- `normalizeContentFromAPI()` — converts SDK response blocks into storable content. -- `normalizeMessagesForAPI()` — prepares stored messages for the next API request. +1. `filterOrphanedThinkingOnlyMessages()` — drops thinking-only assistant messages with no same-`message.id` partner carrying non-thinking content (handles resume / compaction artifacts). +2. `filterTrailingThinkingFromLastAssistant()` — strips trailing thinking / redacted_thinking from the last assistant message. If stripping removes all content, inserts a `[No message content]` placeholder to preserve user / assistant alternation. +3. `filterWhitespaceOnlyAssistantMessages()` — removes assistant messages with only whitespace text. +4. `ensureNonEmptyAssistantContent()` — safety net for empty assistant messages. + +Order matters: trailing thinking must be stripped before whitespace filtering, otherwise a message like `[text("\n\n"), thinking("...")]` survives the whitespace filter, then thinking stripping removes the thinking block, leaving `[text("\n\n")]` which the API rejects. ### Constraints -- **Trailing thinking**: Assistant messages must not end with a thinking block. Claude Code strips trailing thinking blocks before sending. +- **Trailing thinking**: Assistant messages must not end with a thinking block. Stripping can leave a thinking-only message empty — a placeholder text block must be inserted (not message deletion) to preserve user / assistant alternation. Deleting the message would create consecutive user messages, which the API rejects. - **Credential rotation**: Signatures are cryptographically bound to the API key that generated them. When credentials change (e.g., user logs in with a different account), all thinking and redacted_thinking blocks must be stripped from the conversation history — their signatures are now invalid and the API will reject them with 400. ## Signatures @@ -81,11 +85,11 @@ Every `thinking` block includes a `signature` field received via `signature_delt Claude Code handles credential rotation in `stripSignatureBlocks()`, which removes all thinking / redacted_thinking blocks when the active credential changes. -oxide-code implements the full thinking data pipeline: typed `Thinking`, `RedactedThinking`, and `ServerToolUse` content blocks with proper streaming accumulation, signature handling, round-trip preservation, and trailing thinking removal. Adaptive thinking is enabled by default. Credential rotation stripping is not yet implemented (depends on Keychain OAuth support). +oxide-code implements the full thinking data pipeline: typed `Thinking`, `RedactedThinking`, and `ServerToolUse` content blocks with proper streaming accumulation, signature handling, round-trip preservation, and trailing thinking stripping with placeholder insertion. Adaptive thinking is enabled by default. Credential rotation stripping is not yet implemented (depends on Keychain OAuth support). ## Sources - `claude-code/src/constants/betas.ts` — `INTERLEAVED_THINKING_BETA_HEADER`, `REDACT_THINKING_BETA_HEADER` - `claude-code/src/services/api/claude.ts` — streaming handler, delta accumulation, request construction -- `claude-code/src/utils/messages.ts` — `normalizeContentFromAPI`, `normalizeMessagesForAPI`, `stripSignatureBlocks` +- `claude-code/src/utils/messages.ts` — `normalizeMessagesForAPI`, `filterTrailingThinkingFromLastAssistant`, `filterOrphanedThinkingOnlyMessages`, `stripSignatureBlocks` - `claude-code/src/utils/thinking.ts` — thinking config types, model support detection From 343c489224b08d7b0682957d56efbef1c4a32015 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Sun, 5 Apr 2026 19:53:26 +0800 Subject: [PATCH 22/22] style(bash): use brackets for truncation marker Align with the editorial bracket convention and the existing [N chars] marker in truncate_line. --- crates/oxide-code/src/tool/bash.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/oxide-code/src/tool/bash.rs b/crates/oxide-code/src/tool/bash.rs index 9b9c6d41..d7e6db8a 100644 --- a/crates/oxide-code/src/tool/bash.rs +++ b/crates/oxide-code/src/tool/bash.rs @@ -172,7 +172,7 @@ fn truncate_output(content: &mut String) { let mut truncated = String::with_capacity(super::MAX_OUTPUT_BYTES + TRUNCATION_OVERHEAD); truncated.push_str(&content[..head_end]); - _ = write!(truncated, "\n... ({omitted_lines} lines truncated) ...\n"); + _ = write!(truncated, "\n... [{omitted_lines} lines truncated] ...\n"); truncated.push_str(&content[tail_start..]); *content = truncated;