From 3a45b0a4ad3b1204af1566c82bc838ecc4e3dc3b Mon Sep 17 00:00:00 2001 From: Gerard-Devlin Date: Mon, 8 Jun 2026 02:45:55 +0800 Subject: [PATCH] Surface compatible provider reasoning content --- apps/desktop/src-tauri/src/claude.rs | 146 +++++++++++++++++- .../__tests__/stores/multi-tab-merge.test.ts | 33 ++++ apps/desktop/src/stores/claude-chat-store.ts | 48 ++++-- 3 files changed, 208 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src-tauri/src/claude.rs b/apps/desktop/src-tauri/src/claude.rs index 82cc925..54749fb 100644 --- a/apps/desktop/src-tauri/src/claude.rs +++ b/apps/desktop/src-tauri/src/claude.rs @@ -1996,6 +1996,7 @@ struct DirectToolOutput { struct DirectChatResponse { message: serde_json::Value, content: String, + reasoning: String, tool_calls: Vec, usage: serde_json::Value, streamed_text: bool, @@ -2827,8 +2828,41 @@ fn direct_assistant_content(response: &serde_json::Value) -> String { String::new() } +fn direct_assistant_reasoning(response: &serde_json::Value) -> String { + let Some(message) = response.pointer("/choices/0/message") else { + return String::new(); + }; + + for key in ["reasoning_content", "reasoning", "reasoning_text"] { + if let Some(text) = message.get(key).and_then(|v| v.as_str()) { + return text.to_string(); + } + } + + if let Some(parts) = message.get("content").and_then(|v| v.as_array()) { + return parts + .iter() + .filter(|part| { + matches!( + part.get("type").and_then(|v| v.as_str()), + Some("reasoning") | Some("thinking") | Some("reasoning_text") + ) + }) + .filter_map(|part| { + part.get("text") + .and_then(|v| v.as_str()) + .or_else(|| part.get("content").and_then(|v| v.as_str())) + }) + .collect::>() + .join("\n\n"); + } + + String::new() +} + fn direct_chat_response_from_value(response: serde_json::Value) -> DirectChatResponse { let content = direct_assistant_content(&response); + let reasoning = direct_assistant_reasoning(&response); let tool_calls = parse_direct_tool_calls(&response); let usage = json_usage(&response); let message = response @@ -2838,6 +2872,7 @@ fn direct_chat_response_from_value(response: serde_json::Value) -> DirectChatRes DirectChatResponse { message, content, + reasoning, tool_calls, usage, streamed_text: false, @@ -2865,10 +2900,50 @@ fn direct_stream_text_delta(delta: &serde_json::Value) -> String { String::new() } -fn emit_direct_streaming_delta(window: &WebviewWindow, tab_id: &str, text: &str) { - if text.is_empty() { +fn direct_stream_reasoning_delta(delta: &serde_json::Value) -> String { + for key in ["reasoning_content", "reasoning", "reasoning_text"] { + if let Some(text) = delta.get(key).and_then(|v| v.as_str()) { + return text.to_string(); + } + } + + if let Some(parts) = delta.get("content").and_then(|v| v.as_array()) { + return parts + .iter() + .filter(|part| { + matches!( + part.get("type").and_then(|v| v.as_str()), + Some("reasoning") | Some("thinking") | Some("reasoning_text") + ) + }) + .filter_map(|part| { + part.get("text") + .and_then(|v| v.as_str()) + .or_else(|| part.get("content").and_then(|v| v.as_str())) + }) + .collect::>() + .join(""); + } + + String::new() +} + +fn emit_direct_streaming_delta( + window: &WebviewWindow, + tab_id: &str, + text: &str, + reasoning: &str, +) { + if text.is_empty() && reasoning.is_empty() { return; } + let mut blocks = Vec::new(); + if !reasoning.is_empty() { + blocks.push(json!({ "type": "thinking", "thinking": reasoning })); + } + if !text.is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } emit_direct_output( window, tab_id, @@ -2876,7 +2951,7 @@ fn emit_direct_streaming_delta(window: &WebviewWindow, tab_id: &str, text: &str) "type": "assistant", "subtype": "streaming_delta", "message": { - "content": [{ "type": "text", "text": text }], + "content": blocks, }, }), ); @@ -2905,7 +2980,11 @@ fn direct_tool_calls_from_stream( .collect() } -fn direct_message_from_parts(content: &str, tool_calls: &[DirectToolCall]) -> serde_json::Value { +fn direct_message_from_parts( + content: &str, + reasoning: &str, + tool_calls: &[DirectToolCall], +) -> serde_json::Value { let mut message = json!({ "role": "assistant", "content": if content.trim().is_empty() { @@ -2914,6 +2993,11 @@ fn direct_message_from_parts(content: &str, tool_calls: &[DirectToolCall]) -> se json!(content) }, }); + if !reasoning.trim().is_empty() { + if let Some(object) = message.as_object_mut() { + object.insert("reasoning_content".to_string(), json!(reasoning)); + } + } if !tool_calls.is_empty() { if let Some(object) = message.as_object_mut() { object.insert( @@ -3220,6 +3304,7 @@ async fn send_openai_compatible_streaming_chat_request( let mut buffer = String::new(); let mut content = String::new(); + let mut reasoning = String::new(); let mut tool_calls: HashMap = HashMap::new(); let mut usage = json!({ "input_tokens": 0, "output_tokens": 0 }); let mut streamed_text = false; @@ -3246,10 +3331,11 @@ async fn send_openai_compatible_streaming_chat_request( let data = line.trim_start_matches("data:").trim(); if data == "[DONE]" { let tool_calls = direct_tool_calls_from_stream(tool_calls); - let message = direct_message_from_parts(&content, &tool_calls); + let message = direct_message_from_parts(&content, &reasoning, &tool_calls); return Ok(DirectChatResponse { message, content, + reasoning, tool_calls, usage, streamed_text, @@ -3271,11 +3357,16 @@ async fn send_openai_compatible_streaming_chat_request( }; let text_delta = direct_stream_text_delta(delta); + let reasoning_delta = direct_stream_reasoning_delta(delta); if !text_delta.is_empty() { content.push_str(&text_delta); streamed_text = true; - emit_direct_streaming_delta(window, tab_id, &text_delta); } + if !reasoning_delta.is_empty() { + reasoning.push_str(&reasoning_delta); + streamed_text = true; + } + emit_direct_streaming_delta(window, tab_id, &text_delta, &reasoning_delta); if let Some(calls) = delta.get("tool_calls").and_then(|v| v.as_array()) { for (fallback_idx, call) in calls.iter().enumerate() { @@ -3304,10 +3395,11 @@ async fn send_openai_compatible_streaming_chat_request( } let tool_calls = direct_tool_calls_from_stream(tool_calls); - let message = direct_message_from_parts(&content, &tool_calls); + let message = direct_message_from_parts(&content, &reasoning, &tool_calls); Ok(DirectChatResponse { message, content, + reasoning, tool_calls, usage, streamed_text, @@ -3430,11 +3522,18 @@ async fn execute_openai_compatible_provider( } let content = response.content; + let reasoning = response.reasoning; let tool_calls = response.tool_calls; let usage = response.usage; final_usage = usage.clone(); let mut content_blocks = Vec::new(); + if !reasoning.trim().is_empty() { + content_blocks.push(json!({ + "type": "thinking", + "thinking": reasoning, + })); + } if !content.trim().is_empty() { content_blocks.push(json!({ "type": "text", "text": content.clone() })); } @@ -4465,15 +4564,46 @@ mod tests { ); let calls = direct_tool_calls_from_stream(calls); - let message = direct_message_from_parts("Checking the file", &calls); + let message = direct_message_from_parts("Checking the file", "Thinking aloud", &calls); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "Read"); assert_eq!(calls[0].input["file_path"], "main.tex"); assert_eq!(message["content"], "Checking the file"); + assert_eq!(message["reasoning_content"], "Thinking aloud"); assert_eq!(message["tool_calls"][0]["function"]["name"], "Read"); } + #[test] + fn test_direct_provider_extracts_reasoning_content() { + let response = json!({ + "choices": [{ + "message": { + "role": "assistant", + "reasoning_content": "First reason about structure.", + "content": "Then answer." + } + }] + }); + + assert_eq!( + direct_assistant_reasoning(&response), + "First reason about structure." + ); + assert_eq!(direct_assistant_content(&response), "Then answer."); + } + + #[test] + fn test_direct_provider_extracts_stream_reasoning_delta() { + let delta = json!({ + "reasoning_content": "Step one.", + "content": "Answer part." + }); + + assert_eq!(direct_stream_reasoning_delta(&delta), "Step one."); + assert_eq!(direct_stream_text_delta(&delta), "Answer part."); + } + #[test] fn test_direct_provider_detects_tool_unsupported_errors() { assert!(provider_error_allows_toolless_retry( diff --git a/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts b/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts index 650b587..e0a6b61 100644 --- a/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts +++ b/apps/desktop/src/__tests__/stores/multi-tab-merge.test.ts @@ -290,6 +290,39 @@ describe("Multi-tab merge triggers", () => { expect(messages[0].message?.content?.[0].text).toBe("Hello world"); }); + it("_appendMessage merges direct provider streaming thinking deltas", () => { + const chat = useClaudeChatStore.getState(); + + chat._appendMessage("tab-default", { + type: "assistant", + subtype: "streaming_delta", + message: { + content: [ + { type: "thinking", thinking: "Reason A. " }, + { type: "text", text: "Hello" }, + ], + }, + }); + chat._appendMessage("tab-default", { + type: "assistant", + subtype: "streaming_delta", + message: { + content: [ + { type: "thinking", thinking: "Reason B." }, + { type: "text", text: " world" }, + ], + }, + }); + + const messages = useClaudeChatStore.getState().messages; + expect(messages).toHaveLength(1); + expect(messages[0].message?.content?.[0].type).toBe("thinking"); + expect(messages[0].message?.content?.[0].thinking).toBe( + "Reason A. Reason B.", + ); + expect(messages[0].message?.content?.[1].text).toBe("Hello world"); + }); + it("_appendMessage replaces streaming deltas with final direct provider message", () => { const chat = useClaudeChatStore.getState(); diff --git a/apps/desktop/src/stores/claude-chat-store.ts b/apps/desktop/src/stores/claude-chat-store.ts index 36414bf..99a1b95 100644 --- a/apps/desktop/src/stores/claude-chat-store.ts +++ b/apps/desktop/src/stores/claude-chat-store.ts @@ -133,6 +133,41 @@ function applyTabUpdate( // ─── State Interface ─── +function mergeStreamingContent( + existing: ContentBlock[], + incoming: ContentBlock[], +): ContentBlock[] { + let merged = [...existing]; + for (const block of incoming) { + if (block.type === "text" && block.text) { + const idx = merged.findIndex((item) => item.type === "text"); + if (idx >= 0) { + merged = merged.map((item, itemIdx) => + itemIdx === idx + ? { ...item, text: `${item.text ?? ""}${block.text}` } + : item, + ); + } else { + merged.push(block); + } + } else if (block.type === "thinking" && block.thinking) { + const idx = merged.findIndex((item) => item.type === "thinking"); + if (idx >= 0) { + merged = merged.map((item, itemIdx) => + itemIdx === idx + ? { ...item, thinking: `${item.thinking ?? ""}${block.thinking}` } + : item, + ); + } else { + merged.unshift(block); + } + } else { + merged.push(block); + } + } + return merged; +} + const DEFAULT_TAB_ID = nextTabId(); interface ClaudeChatState { @@ -594,21 +629,12 @@ export const useClaudeChatStore = create()((set, get) => ({ if (last?.type === "assistant" && last.subtype === "streaming_delta") { const existing = last.message?.content ?? []; const incoming = msg.message?.content ?? []; - const existingText = existing.find((block) => block.type === "text"); - const incomingText = incoming.find((block) => block.type === "text"); - if (existingText && incomingText?.text) { + if (incoming.length > 0) { const merged: ClaudeStreamMessage = { ...last, message: { ...last.message, - content: existing.map((block) => - block === existingText - ? { - ...block, - text: `${block.text ?? ""}${incomingText.text}`, - } - : block, - ), + content: mergeStreamingContent(existing, incoming), }, }; return applyTabUpdate(state, tabId, {