Browse Source

Merge remote-tracking branch 'origin/rcc/thinking' into dev/rust

# Conflicts:
#	rust/crates/commands/src/lib.rs
#	rust/crates/claw-cli/src/main.rs
Yeachan-Heo 2 months ago
parent
commit
a2a4a3435b

+ 1 - 0
rust/crates/api/src/client.rs

@@ -912,6 +912,7 @@ mod tests {
             system: None,
             tools: None,
             tool_choice: None,
+            thinking: None,
             stream: false,
         };
 

+ 1 - 1
rust/crates/api/src/lib.rs

@@ -13,5 +13,5 @@ pub use types::{
     ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
     ImageSource, InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
     MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
-    ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
+    ThinkingConfig, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
 };

+ 26 - 0
rust/crates/api/src/types.rs

@@ -12,6 +12,8 @@ pub struct MessageRequest {
     pub tools: Option<Vec<ToolDefinition>>,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub tool_choice: Option<ToolChoice>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub thinking: Option<ThinkingConfig>,
     #[serde(default, skip_serializing_if = "std::ops::Not::not")]
     pub stream: bool,
 }
@@ -24,6 +26,23 @@ impl MessageRequest {
     }
 }
 
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ThinkingConfig {
+    #[serde(rename = "type")]
+    pub kind: String,
+    pub budget_tokens: u32,
+}
+
+impl ThinkingConfig {
+    #[must_use]
+    pub fn enabled(budget_tokens: u32) -> Self {
+        Self {
+            kind: "enabled".to_string(),
+            budget_tokens,
+        }
+    }
+}
+
 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 pub struct InputMessage {
     pub role: String,
@@ -141,6 +160,11 @@ pub enum OutputContentBlock {
     Text {
         text: String,
     },
+    Thinking {
+        thinking: String,
+        #[serde(default, skip_serializing_if = "Option::is_none")]
+        signature: Option<String>,
+    },
     ToolUse {
         id: String,
         name: String,
@@ -200,6 +224,8 @@ pub struct ContentBlockDeltaEvent {
 #[serde(tag = "type", rename_all = "snake_case")]
 pub enum ContentBlockDelta {
     TextDelta { text: String },
+    ThinkingDelta { thinking: String },
+    SignatureDelta { signature: String },
     InputJsonDelta { partial_json: String },
 }
 

+ 2 - 0
rust/crates/api/tests/client_integration.rs

@@ -291,6 +291,7 @@ async fn live_stream_smoke_test() {
             system: None,
             tools: None,
             tool_choice: None,
+            thinking: None,
             stream: false,
         })
         .await
@@ -471,6 +472,7 @@ fn sample_request(stream: bool) -> MessageRequest {
             }),
         }]),
         tool_choice: Some(ToolChoice::Auto),
+        thinking: None,
         stream,
     }
 }

+ 39 - 18
rust/crates/commands/src/lib.rs

@@ -57,6 +57,12 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
         argument_hint: None,
         resume_supported: true,
     },
+    SlashCommandSpec {
+        name: "thinking",
+        summary: "Show or toggle extended thinking",
+        argument_hint: Some("[on|off]"),
+        resume_supported: false,
+    },
     SlashCommandSpec {
         name: "model",
         summary: "Show or switch the active model",
@@ -84,7 +90,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
     SlashCommandSpec {
         name: "resume",
         summary: "Load a saved session into the REPL",
-        argument_hint: Some("<session-id-or-path>"),
+        argument_hint: Some("<session-path>"),
         resume_supported: false,
     },
     SlashCommandSpec {
@@ -129,12 +135,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
         argument_hint: Some("[list|switch <session-id>]"),
         resume_supported: false,
     },
-    SlashCommandSpec {
-        name: "sessions",
-        summary: "List recent managed local sessions",
-        argument_hint: None,
-        resume_supported: false,
-    },
 ];
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -142,6 +142,9 @@ pub enum SlashCommand {
     Help,
     Status,
     Compact,
+    Thinking {
+        enabled: Option<bool>,
+    },
     Model {
         model: Option<String>,
     },
@@ -169,7 +172,6 @@ pub enum SlashCommand {
         action: Option<String>,
         target: Option<String>,
     },
-    Sessions,
     Unknown(String),
 }
 
@@ -187,6 +189,13 @@ impl SlashCommand {
             "help" => Self::Help,
             "status" => Self::Status,
             "compact" => Self::Compact,
+            "thinking" => Self::Thinking {
+                enabled: match parts.next() {
+                    Some("on") => Some(true),
+                    Some("off") => Some(false),
+                    Some(_) | None => None,
+                },
+            },
             "model" => Self::Model {
                 model: parts.next().map(ToOwned::to_owned),
             },
@@ -214,7 +223,6 @@ impl SlashCommand {
                 action: parts.next().map(ToOwned::to_owned),
                 target: parts.next().map(ToOwned::to_owned),
             },
-            "sessions" => Self::Sessions,
             other => Self::Unknown(other.to_string()),
         })
     }
@@ -287,6 +295,7 @@ pub fn handle_slash_command(
             session: session.clone(),
         }),
         SlashCommand::Status
+        | SlashCommand::Thinking { .. }
         | SlashCommand::Model { .. }
         | SlashCommand::Permissions { .. }
         | SlashCommand::Clear { .. }
@@ -299,7 +308,6 @@ pub fn handle_slash_command(
         | SlashCommand::Version
         | SlashCommand::Export { .. }
         | SlashCommand::Session { .. }
-        | SlashCommand::Sessions
         | SlashCommand::Unknown(_) => None,
     }
 }
@@ -316,6 +324,22 @@ mod tests {
     fn parses_supported_slash_commands() {
         assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
         assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
+        assert_eq!(
+            SlashCommand::parse("/thinking on"),
+            Some(SlashCommand::Thinking {
+                enabled: Some(true),
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/thinking off"),
+            Some(SlashCommand::Thinking {
+                enabled: Some(false),
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/thinking"),
+            Some(SlashCommand::Thinking { enabled: None })
+        );
         assert_eq!(
             SlashCommand::parse("/model claude-opus"),
             Some(SlashCommand::Model {
@@ -374,10 +398,6 @@ mod tests {
                 target: Some("abc123".to_string())
             })
         );
-        assert_eq!(
-            SlashCommand::parse("/sessions"),
-            Some(SlashCommand::Sessions)
-        );
     }
 
     #[test]
@@ -387,11 +407,12 @@ mod tests {
         assert!(help.contains("/help"));
         assert!(help.contains("/status"));
         assert!(help.contains("/compact"));
+        assert!(help.contains("/thinking [on|off]"));
         assert!(help.contains("/model [model]"));
         assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
         assert!(help.contains("/clear [--confirm]"));
         assert!(help.contains("/cost"));
-        assert!(help.contains("/resume <session-id-or-path>"));
+        assert!(help.contains("/resume <session-path>"));
         assert!(help.contains("/config [env|hooks|model]"));
         assert!(help.contains("/memory"));
         assert!(help.contains("/init"));
@@ -399,7 +420,6 @@ mod tests {
         assert!(help.contains("/version"));
         assert!(help.contains("/export [file]"));
         assert!(help.contains("/session [list|switch <session-id>]"));
-        assert!(help.contains("/sessions"));
         assert_eq!(slash_command_specs().len(), 16);
         assert_eq!(resume_supported_slash_commands().len(), 11);
     }
@@ -418,7 +438,6 @@ mod tests {
                     text: "recent".to_string(),
                 }]),
             ],
-            metadata: None,
         };
 
         let result = handle_slash_command(
@@ -449,6 +468,9 @@ mod tests {
         let session = Session::new();
         assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
         assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
+        assert!(
+            handle_slash_command("/thinking on", &session, CompactionConfig::default()).is_none()
+        );
         assert!(
             handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
         );
@@ -483,6 +505,5 @@ mod tests {
         assert!(
             handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
         );
-        assert!(handle_slash_command("/sessions", &session, CompactionConfig::default()).is_none());
     }
 }

+ 11 - 5
rust/crates/runtime/src/compact.rs

@@ -164,7 +164,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
         .filter_map(|block| match block {
             ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
             ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
-            ContentBlock::Text { .. } => None,
+            ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => None,
         })
         .collect::<Vec<_>>();
     tool_names.sort_unstable();
@@ -234,6 +234,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
 fn summarize_block(block: &ContentBlock) -> String {
     let raw = match block {
         ContentBlock::Text { text } => text.clone(),
+        ContentBlock::Thinking { text, .. } => format!("thinking: {text}"),
         ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
         ContentBlock::ToolResult {
             tool_name,
@@ -292,7 +293,7 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
         .iter()
         .flat_map(|message| message.blocks.iter())
         .map(|block| match block {
-            ContentBlock::Text { text } => text.as_str(),
+            ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.as_str(),
             ContentBlock::ToolUse { input, .. } => input.as_str(),
             ContentBlock::ToolResult { output, .. } => output.as_str(),
         })
@@ -314,10 +315,15 @@ fn infer_current_work(messages: &[ConversationMessage]) -> Option<String> {
 
 fn first_text_block(message: &ConversationMessage) -> Option<&str> {
     message.blocks.iter().find_map(|block| match block {
-        ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
+        ContentBlock::Text { text } | ContentBlock::Thinking { text, .. }
+            if !text.trim().is_empty() =>
+        {
+            Some(text.as_str())
+        }
         ContentBlock::ToolUse { .. }
         | ContentBlock::ToolResult { .. }
-        | ContentBlock::Text { .. } => None,
+        | ContentBlock::Text { .. }
+        | ContentBlock::Thinking { .. } => None,
     })
 }
 
@@ -362,7 +368,7 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize {
         .blocks
         .iter()
         .map(|block| match block {
-            ContentBlock::Text { text } => text.len() / 4 + 1,
+            ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.len() / 4 + 1,
             ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
             ContentBlock::ToolResult {
                 tool_name, output, ..

+ 53 - 3
rust/crates/runtime/src/conversation.rs

@@ -17,6 +17,8 @@ pub struct ApiRequest {
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub enum AssistantEvent {
     TextDelta(String),
+    ThinkingDelta(String),
+    ThinkingSignature(String),
     ToolUse {
         id: String,
         name: String,
@@ -247,15 +249,26 @@ fn build_assistant_message(
     events: Vec<AssistantEvent>,
 ) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> {
     let mut text = String::new();
+    let mut thinking = String::new();
+    let mut thinking_signature: Option<String> = None;
     let mut blocks = Vec::new();
     let mut finished = false;
     let mut usage = None;
 
     for event in events {
         match event {
-            AssistantEvent::TextDelta(delta) => text.push_str(&delta),
+            AssistantEvent::TextDelta(delta) => {
+                flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
+                text.push_str(&delta);
+            }
+            AssistantEvent::ThinkingDelta(delta) => {
+                flush_text_block(&mut text, &mut blocks);
+                thinking.push_str(&delta);
+            }
+            AssistantEvent::ThinkingSignature(signature) => thinking_signature = Some(signature),
             AssistantEvent::ToolUse { id, name, input } => {
                 flush_text_block(&mut text, &mut blocks);
+                flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
                 blocks.push(ContentBlock::ToolUse { id, name, input });
             }
             AssistantEvent::Usage(value) => usage = Some(value),
@@ -266,6 +279,7 @@ fn build_assistant_message(
     }
 
     flush_text_block(&mut text, &mut blocks);
+    flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
 
     if !finished {
         return Err(RuntimeError::new(
@@ -290,6 +304,19 @@ fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
     }
 }
 
+fn flush_thinking_block(
+    thinking: &mut String,
+    signature: &mut Option<String>,
+    blocks: &mut Vec<ContentBlock>,
+) {
+    if !thinking.is_empty() || signature.is_some() {
+        blocks.push(ContentBlock::Thinking {
+            text: std::mem::take(thinking),
+            signature: signature.take(),
+        });
+    }
+}
+
 type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
 
 #[derive(Default)]
@@ -325,8 +352,8 @@ impl ToolExecutor for StaticToolExecutor {
 #[cfg(test)]
 mod tests {
     use super::{
-        ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError,
-        StaticToolExecutor,
+        build_assistant_message, ApiClient, ApiRequest, AssistantEvent, ConversationRuntime,
+        RuntimeError, StaticToolExecutor,
     };
     use crate::compact::CompactionConfig;
     use crate::permissions::{
@@ -503,6 +530,29 @@ mod tests {
         ));
     }
 
+    #[test]
+    fn thinking_blocks_are_preserved_separately_from_text() {
+        let (message, usage) = build_assistant_message(vec![
+            AssistantEvent::ThinkingDelta("first ".to_string()),
+            AssistantEvent::ThinkingDelta("second".to_string()),
+            AssistantEvent::ThinkingSignature("sig-1".to_string()),
+            AssistantEvent::TextDelta("final".to_string()),
+            AssistantEvent::MessageStop,
+        ])
+        .expect("assistant message should build");
+
+        assert_eq!(usage, None);
+        assert!(matches!(
+            &message.blocks[0],
+            ContentBlock::Thinking { text, signature }
+                if text == "first second" && signature.as_deref() == Some("sig-1")
+        ));
+        assert!(matches!(
+            &message.blocks[1],
+            ContentBlock::Text { text } if text == "final"
+        ));
+    }
+
     #[test]
     fn reconstructs_usage_tracker_from_restored_session() {
         struct SimpleApi;

+ 24 - 0
rust/crates/runtime/src/session.rs

@@ -19,6 +19,10 @@ pub enum ContentBlock {
     Text {
         text: String,
     },
+    Thinking {
+        text: String,
+        signature: Option<String>,
+    },
     ToolUse {
         id: String,
         name: String,
@@ -313,6 +317,19 @@ impl ContentBlock {
                 object.insert("type".to_string(), JsonValue::String("text".to_string()));
                 object.insert("text".to_string(), JsonValue::String(text.clone()));
             }
+            Self::Thinking { text, signature } => {
+                object.insert(
+                    "type".to_string(),
+                    JsonValue::String("thinking".to_string()),
+                );
+                object.insert("text".to_string(), JsonValue::String(text.clone()));
+                if let Some(signature) = signature {
+                    object.insert(
+                        "signature".to_string(),
+                        JsonValue::String(signature.clone()),
+                    );
+                }
+            }
             Self::ToolUse { id, name, input } => {
                 object.insert(
                     "type".to_string(),
@@ -359,6 +376,13 @@ impl ContentBlock {
             "text" => Ok(Self::Text {
                 text: required_string(object, "text")?,
             }),
+            "thinking" => Ok(Self::Thinking {
+                text: required_string(object, "text")?,
+                signature: object
+                    .get("signature")
+                    .and_then(JsonValue::as_str)
+                    .map(ToOwned::to_owned),
+            }),
             "tool_use" => Ok(Self::ToolUse {
                 id: required_string(object, "id")?,
                 name: required_string(object, "name")?,

File diff suppressed because it is too large
+ 214 - 231
rust/crates/rusty-claude-cli/src/main.rs


Some files were not shown because too many files changed in this diff