diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index 025d11d0..b8d5d6cf 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -314,6 +314,8 @@ struct StreamState { tool_inputs: HashMap, /// The tool_use id of the block currently being streamed. current_tool_id: Option, + /// Tracks whether response text has already been emitted for this run. + emitted_text: bool, } /// Core subprocess runner shared by chat and agent modes. @@ -339,6 +341,7 @@ where session_id: String::new(), tool_inputs: HashMap::new(), current_tool_id: None, + emitted_text: false, }; for line in reader.lines() { @@ -473,7 +476,15 @@ where if !sid.is_empty() { state.session_id = sid.clone(); } - let text = json["result"].as_str().unwrap_or("").to_string(); + let text = if state.emitted_text { + String::new() + } else { + let text = json["result"].as_str().unwrap_or("").to_string(); + if !text.is_empty() { + state.emitted_text = true; + } + text + }; emit(ClaudeStreamEvent::Result { text, session_id: sid, @@ -483,19 +494,9 @@ where // --- Complete assistant message (fallback for text when no partials) --- "assistant" => { if let Some(content) = json["message"]["content"].as_array() { + let emit_text = !state.emitted_text; for block in content { - if block["type"].as_str() == Some("tool_use") { - if let (Some(id), Some(name)) = - (block["id"].as_str(), block["name"].as_str()) - { - let input = format_tool_input(&block["input"], state, id); - emit(ClaudeStreamEvent::ToolStart { - tool_name: name.to_string(), - tool_id: id.to_string(), - input, - }); - } - } + dispatch_assistant_content_block(block, emit_text, state, emit); } } } @@ -518,9 +519,7 @@ where match delta["type"].as_str() { Some("text_delta") => { if let Some(text) = delta["text"].as_str() { - emit(ClaudeStreamEvent::TextDelta { - text: text.to_string(), - }); + emit_text_delta(text, state, emit); } } Some("thinking_delta") => { @@ -565,6 +564,48 @@ where } } +fn dispatch_assistant_content_block( + block: &serde_json::Value, + emit_text: bool, + state: &mut StreamState, + emit: &mut F, +) where + F: FnMut(ClaudeStreamEvent), +{ + match block["type"].as_str() { + Some("text") => { + if emit_text { + if let Some(text) = block["text"].as_str() { + emit_text_delta(text, state, emit); + } + } + } + Some("tool_use") => { + if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) { + let input = format_tool_input(&block["input"], state, id); + emit(ClaudeStreamEvent::ToolStart { + tool_name: name.to_string(), + tool_id: id.to_string(), + input, + }); + } + } + _ => {} + } +} + +fn emit_text_delta(text: &str, state: &mut StreamState, emit: &mut F) +where + F: FnMut(ClaudeStreamEvent), +{ + if !text.is_empty() { + state.emitted_text = true; + } + emit(ClaudeStreamEvent::TextDelta { + text: text.to_string(), + }); +} + /// Build the tool input string, preferring accumulated delta chunks over the /// block's `input` field (which may be empty at stream start). fn format_tool_input( @@ -636,6 +677,7 @@ mod tests { session_id: String::new(), tool_inputs: HashMap::new(), current_tool_id: None, + emitted_text: false, } } @@ -765,10 +807,35 @@ mod tests { { "type": "tool_use", "id": "tu_1", "name": "search_notes", "input": {} } ] } })); - assert_eq!(events.len(), 1); + assert_eq!(events.len(), 2); assert!( - matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1") + matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Let me search.") ); + assert!( + matches!(&events[1], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1") + ); + } + + #[test] + fn dispatch_event_result_after_text_delta_does_not_duplicate_response_text() { + let (state, events) = run_dispatch_sequence(vec![ + serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "Visible reply" } } + }), + serde_json::json!({ + "type": "result", + "session_id": "session-1", + "result": "Visible reply" + }), + ]); + + assert_eq!(state.session_id, "session-1"); + assert!(matches!(&events[..], + [ + ClaudeStreamEvent::TextDelta { text }, + ClaudeStreamEvent::Result { text: result_text, session_id }, + ] if text == "Visible reply" && result_text.is_empty() && session_id == "session-1")); } #[test] diff --git a/src-tauri/src/opencode_events.rs b/src-tauri/src/opencode_events.rs index d6060cda..200ba71d 100644 --- a/src-tauri/src/opencode_events.rs +++ b/src-tauri/src/opencode_events.rs @@ -101,12 +101,8 @@ where F: FnMut(AiAgentStreamEvent), { let tool_id = tool_id(json).unwrap_or("tool").to_string(); - let tool_name = json["name"] - .as_str() - .or_else(|| json["tool"].as_str()) - .unwrap_or("tool") - .to_string(); - let input = json.get("input").map(|input| input.to_string()); + let tool_name = tool_name(json).unwrap_or("tool").to_string(); + let input = tool_input(json); emit(AiAgentStreamEvent::ToolStart { tool_name, @@ -120,10 +116,7 @@ where F: FnMut(AiAgentStreamEvent), { let tool_id = tool_id(json).unwrap_or("tool").to_string(); - let output = json["output"] - .as_str() - .map(|output| output.to_string()) - .or_else(|| json.get("result").map(|result| result.to_string())); + let output = tool_output(json); emit(AiAgentStreamEvent::ToolDone { tool_id, output }); } @@ -140,24 +133,50 @@ where } fn tool_id(json: &serde_json::Value) -> Option<&str> { - json["id"] - .as_str() - .or_else(|| json["toolID"].as_str()) - .or_else(|| json["tool_id"].as_str()) + first_string_field( + json, + &["id", "toolID", "tool_id", "toolCallID", "toolCallId"], + ) } fn text_value(json: &serde_json::Value) -> Option<&str> { - json["text"] - .as_str() - .or_else(|| json["content"].as_str()) - .or_else(|| json["message"].as_str()) + first_string_field(json, &["text", "content", "message"]) } fn message_value(json: &serde_json::Value) -> Option<&str> { - json["message"] + first_string_field(json, &["message", "error", "text"]) +} + +fn tool_name(json: &serde_json::Value) -> Option<&str> { + first_string_field(json, &["name", "tool", "toolName"]) +} + +fn tool_input(json: &serde_json::Value) -> Option { + first_json_field(json, &["input", "args"]).map(|input| input.to_string()) +} + +fn tool_output(json: &serde_json::Value) -> Option { + first_json_field(json, &["output", "result"]).map(display_json_value) +} + +fn first_string_field<'a>(json: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> { + keys.iter() + .find_map(|key| json[*key].as_str().or_else(|| json["part"][*key].as_str())) +} + +fn first_json_field<'a>( + json: &'a serde_json::Value, + keys: &[&str], +) -> Option<&'a serde_json::Value> { + keys.iter() + .find_map(|key| json.get(*key).or_else(|| json["part"].get(*key))) +} + +fn display_json_value(value: &serde_json::Value) -> String { + value .as_str() - .or_else(|| json["error"].as_str()) - .or_else(|| json["text"].as_str()) + .map(|output| output.to_string()) + .unwrap_or_else(|| value.to_string()) } fn is_auth_error(lower: &str) -> bool { diff --git a/src-tauri/src/opencode_events_tests.rs b/src-tauri/src/opencode_events_tests.rs index 7c721628..4fd0c632 100644 --- a/src-tauri/src/opencode_events_tests.rs +++ b/src-tauri/src/opencode_events_tests.rs @@ -25,6 +25,32 @@ fn dispatch_maps_session_reasoning_and_text() { )); } +#[test] +fn dispatch_maps_part_backed_reasoning_and_text() { + let mut events = Vec::new(); + let reasoning = serde_json::json!({ + "type": "reasoning", + "part": { "type": "reasoning", "text": "Checking links" } + }); + let text = serde_json::json!({ + "type": "text", + "part": { "type": "text", "text": "Done from OpenCode" } + }); + + for event in [reasoning, text] { + dispatch_event(&event, &mut |event| events.push(event)); + } + + assert!(matches!( + &events[0], + AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links" + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::TextDelta { text } if text == "Done from OpenCode" + )); +} + #[test] fn dispatch_maps_tool_events() { let mut events = Vec::new(); @@ -51,6 +77,40 @@ fn dispatch_maps_tool_events() { )); } +#[test] +fn dispatch_maps_part_backed_tool_events() { + let mut events = Vec::new(); + let tool_start = serde_json::json!({ + "type": "tool_use", + "part": { + "id": "prt_tool_1", + "tool": "read", + "input": { "path": "Note.md" } + } + }); + let tool_done = serde_json::json!({ + "type": "tool_result", + "part": { + "id": "prt_tool_1", + "output": "ok" + } + }); + + dispatch_event(&tool_start, &mut |event| events.push(event)); + dispatch_event(&tool_done, &mut |event| events.push(event)); + + assert!(matches!( + &events[0], + AiAgentStreamEvent::ToolStart { tool_name, tool_id, input } + if tool_name == "read" && tool_id == "prt_tool_1" && input.as_deref() == Some(r#"{"path":"Note.md"}"#) + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::ToolDone { tool_id, output } + if tool_id == "prt_tool_1" && output.as_deref() == Some("ok") + )); +} + #[test] fn format_error_explains_missing_auth_or_provider_setup() { let message = format_error(