diff --git a/CLAUDE.md b/CLAUDE.md index dac8e5e7..136b8f7b 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 @@ -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 @@ -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 diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index dbc836f8..99c1a458 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; @@ -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 ── @@ -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( @@ -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( @@ -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")?; @@ -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] @@ -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"; diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index f0dc432a..e95eda25 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -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"; @@ -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, + pub show_thinking: bool, } impl Config { @@ -46,11 +56,18 @@ 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, }) } } @@ -58,3 +75,20 @@ impl Config { 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::*; + + // ── ThinkingConfig ── + + #[test] + fn thinking_config_adaptive_serializes() { + let json = serde_json::to_value(&ThinkingConfig::Adaptive).unwrap(); + assert_eq!(json["type"], "adaptive"); + } +} diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index 067b30be..346dc3ae 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -8,11 +8,11 @@ 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; -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, @@ -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,11 +76,13 @@ async fn agent_turn( client: &Client, tools: &ToolRegistry, messages: &mut Vec, + show_thinking: bool, ) -> Result<()> { let tool_defs = tools.definitions(); for _ in 0..MAX_TOOL_ROUNDS { - let blocks = stream_response(client, messages, &tool_defs).await?; + strip_trailing_thinking(messages); + let blocks = stream_response(client, messages, &tool_defs, show_thinking).await?; let tool_uses: Vec<_> = blocks .iter() @@ -136,6 +139,10 @@ async fn agent_turn( // ── Stream Processing ── +const DIM: &str = "\x1b[2m"; +const DIM_END: &str = "\x1b[22m"; + +#[derive(Debug)] enum BlockAccumulator { Text(String), ToolUse { @@ -143,27 +150,62 @@ 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, } impl BlockAccumulator { - fn into_content_block(self) -> ContentBlock { + fn into_content_block(self) -> Option { match self { - Self::Text(text) => 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 } - } + Self::Text(text) => Some(ContentBlock::Text { text }), + 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], tools: &[ToolDefinition], + show_thinking: bool, ) -> Result> { let mut rx = client.stream_message(messages, None, tools)?; @@ -181,37 +223,27 @@ 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(), - }, - }); + 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) { - 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, show_thinking)?; + } + } + StreamEvent::ContentBlockStop { index } => { + if show_thinking + && matches!( + blocks.get(index), + Some(Some(BlockAccumulator::Thinking { .. })) + ) + { + writeln!(stdout)?; + stdout.flush()?; } } StreamEvent::Error { error } => { @@ -224,7 +256,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)?; } @@ -232,10 +264,102 @@ async fn stream_response( Ok(blocks .into_iter() .flatten() - .map(BlockAccumulator::into_content_block) + .filter_map(BlockAccumulator::into_content_block) .collect()) } +fn init_accumulator( + content_block: ContentBlockInfo, + index: usize, + stdout: &mut std::io::Stdout, + show_thinking: bool, +) -> Result { + Ok(match content_block { + ContentBlockInfo::Text { text } => { + if !text.is_empty() { + stdout.write_all(text.as_bytes())?; + 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, + } => { + 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}"); + BlockAccumulator::Skipped + } + }) +} + +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 }) => { + buf.push_str(&text); + stdout.write_all(text.as_bytes())?; + 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); + if show_thinking { + write!(stdout, "{DIM}{thinking_delta}{DIM_END}")?; + stdout.flush()?; + } + } + ( + BlockAccumulator::Thinking { signature, .. }, + Delta::SignatureDelta { + signature: sig_value, + }, + ) => { + // Signature is a full value, not incremental. + *signature = sig_value; + } + (block, delta) => { + debug!(?block, ?delta, "ignoring unhandled delta"); + } + } + 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..26c6f04e 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 { @@ -26,12 +27,26 @@ 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, }, + 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,10 +72,62 @@ impl Message { } } +// ── Message normalization ── + +/// 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]) { + 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(); + } + if msg.content.is_empty() { + msg.content.push(ContentBlock::Text { + text: "[No message content]".to_owned(), + }); + } +} + #[cfg(test)] 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] @@ -109,6 +176,49 @@ 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=="); + } + // ── Message::user ── #[test] @@ -128,4 +238,143 @@ 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_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); + assert!(matches!( + &messages[0].content[0], + ContentBlock::Thinking { .. } + )); + assert!(matches!(&messages[0].content[1], ContentBlock::Text { text } if text == "answer")); + } + + #[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); + } + + #[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_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_inserts_placeholder_for_thinking_only() { + 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_eq!(messages[0].content.len(), 1); + assert!( + matches!(&messages[0].content[0], ContentBlock::Text { text } if text == "[No message content]") + ); + } } 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; diff --git a/docs/research/extended-thinking.md b/docs/research/extended-thinking.md new file mode 100644 index 00000000..afdb5931 --- /dev/null +++ b/docs/research/extended-thinking.md @@ -0,0 +1,95 @@ +# 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 normalization in `normalizeMessagesForAPI()`, which runs a multi-pass pipeline before each 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. 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 + +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. + +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` — `normalizeMessagesForAPI`, `filterTrailingThinkingFromLastAssistant`, `filterOrphanedThinkingOnlyMessages`, `stripSignatureBlocks` +- `claude-code/src/utils/thinking.ts` — thinking config types, model support detection diff --git a/docs/roadmap.md b/docs/roadmap.md index f50b68e3..46952955 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. +- 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 + - 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. @@ -50,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