diff --git a/CHANGELOG.md b/CHANGELOG.md index 2663c957..8feb62a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.2] - 2026-04-24 + +### Fixed +- DeepSeek V4 thinking-mode tool turns now checkpoint the engine's authoritative API transcript, including assistant `reasoning_content` on reasoning-to-tool-call turns with no visible assistant text. +- Chat Completions request building now drops stale V4 tool-call rounds that are missing required `reasoning_content`, preventing old corrupted checkpoints from triggering DeepSeek HTTP 400 replay errors. +- Web search now falls back to Bing HTML results when DuckDuckGo returns a bot challenge or otherwise yields no parseable results. + ## [0.4.1] - 2026-04-24 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index a34f14b5..d53a584f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -806,7 +806,7 @@ dependencies = [ [[package]] name = "deepseek-agent" -version = "0.4.1" +version = "0.4.2" dependencies = [ "deepseek-config", "serde", @@ -814,7 +814,7 @@ dependencies = [ [[package]] name = "deepseek-app-server" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "axum", @@ -837,7 +837,7 @@ dependencies = [ [[package]] name = "deepseek-config" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "dirs", @@ -848,7 +848,7 @@ dependencies = [ [[package]] name = "deepseek-core" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "chrono", @@ -867,7 +867,7 @@ dependencies = [ [[package]] name = "deepseek-execpolicy" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "deepseek-protocol", @@ -876,7 +876,7 @@ dependencies = [ [[package]] name = "deepseek-hooks" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "async-trait", @@ -890,7 +890,7 @@ dependencies = [ [[package]] name = "deepseek-mcp" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "deepseek-protocol", @@ -900,7 +900,7 @@ dependencies = [ [[package]] name = "deepseek-protocol" -version = "0.4.1" +version = "0.4.2" dependencies = [ "serde", "serde_json", @@ -908,7 +908,7 @@ dependencies = [ [[package]] name = "deepseek-state" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "chrono", @@ -920,7 +920,7 @@ dependencies = [ [[package]] name = "deepseek-tools" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "async-trait", @@ -933,7 +933,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "arboard", @@ -987,7 +987,7 @@ dependencies = [ [[package]] name = "deepseek-tui-cli" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "chrono", @@ -1005,7 +1005,7 @@ dependencies = [ [[package]] name = "deepseek-tui-core" -version = "0.4.1" +version = "0.4.2" [[package]] name = "deranged" diff --git a/Cargo.toml b/Cargo.toml index a88c83da..7d8ce66a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.4.1" +version = "0.4.2" edition = "2024" license = "MIT" repository = "https://github.com/Hmbown/DeepSeek-TUI" diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 45ae797f..297def42 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -deepseek-config = { path = "../config", version = "0.4.1" } +deepseek-config = { path = "../config", version = "0.4.2" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index e4a0be62..cfa48dc5 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -deepseek-agent = { path = "../agent", version = "0.4.1" } -deepseek-config = { path = "../config", version = "0.4.1" } -deepseek-core = { path = "../core", version = "0.4.1" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.4.1" } -deepseek-hooks = { path = "../hooks", version = "0.4.1" } -deepseek-mcp = { path = "../mcp", version = "0.4.1" } -deepseek-protocol = { path = "../protocol", version = "0.4.1" } -deepseek-state = { path = "../state", version = "0.4.1" } -deepseek-tools = { path = "../tools", version = "0.4.1" } +deepseek-agent = { path = "../agent", version = "0.4.2" } +deepseek-config = { path = "../config", version = "0.4.2" } +deepseek-core = { path = "../core", version = "0.4.2" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.4.2" } +deepseek-hooks = { path = "../hooks", version = "0.4.2" } +deepseek-mcp = { path = "../mcp", version = "0.4.2" } +deepseek-protocol = { path = "../protocol", version = "0.4.2" } +deepseek-state = { path = "../state", version = "0.4.2" } +deepseek-tools = { path = "../tools", version = "0.4.2" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 14e65233..608f53a6 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -14,12 +14,12 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -deepseek-agent = { path = "../agent", version = "0.4.1" } -deepseek-app-server = { path = "../app-server", version = "0.4.1" } -deepseek-config = { path = "../config", version = "0.4.1" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.4.1" } -deepseek-mcp = { path = "../mcp", version = "0.4.1" } -deepseek-state = { path = "../state", version = "0.4.1" } +deepseek-agent = { path = "../agent", version = "0.4.2" } +deepseek-app-server = { path = "../app-server", version = "0.4.2" } +deepseek-config = { path = "../config", version = "0.4.2" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.4.2" } +deepseek-mcp = { path = "../mcp", version = "0.4.2" } +deepseek-state = { path = "../state", version = "0.4.2" } chrono.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 740dce78..430c3949 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,14 +9,14 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -deepseek-agent = { path = "../agent", version = "0.4.1" } -deepseek-config = { path = "../config", version = "0.4.1" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.4.1" } -deepseek-hooks = { path = "../hooks", version = "0.4.1" } -deepseek-mcp = { path = "../mcp", version = "0.4.1" } -deepseek-protocol = { path = "../protocol", version = "0.4.1" } -deepseek-state = { path = "../state", version = "0.4.1" } -deepseek-tools = { path = "../tools", version = "0.4.1" } +deepseek-agent = { path = "../agent", version = "0.4.2" } +deepseek-config = { path = "../config", version = "0.4.2" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.4.2" } +deepseek-hooks = { path = "../hooks", version = "0.4.2" } +deepseek-mcp = { path = "../mcp", version = "0.4.2" } +deepseek-protocol = { path = "../protocol", version = "0.4.2" } +deepseek-state = { path = "../state", version = "0.4.2" } +deepseek-tools = { path = "../tools", version = "0.4.2" } serde_json.workspace = true tokio.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index c4728b4c..b1171ed4 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.4.1" } +deepseek-protocol = { path = "../protocol", version = "0.4.2" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index c8b3892b..621b2dea 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.4.1" } +deepseek-protocol = { path = "../protocol", version = "0.4.2" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index f6f6a9df..bbe1bd13 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -8,6 +8,6 @@ description = "MCP server lifecycle and tool proxy compatibility for DeepSeek wo [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.4.1" } +deepseek-protocol = { path = "../protocol", version = "0.4.2" } serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index d6af828b..80e62c72 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.4.1" } +deepseek-protocol = { path = "../protocol", version = "0.4.2" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index a2f2dcc0..0b67bb52 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -638,8 +638,7 @@ impl DeepSeekClient { } async fn create_message_chat(&self, request: &MessageRequest) -> Result { - let messages = - build_chat_messages(request.system.as_ref(), &request.messages, &request.model); + let messages = build_chat_messages_for_request(request); let mut body = json!({ "model": request.model, "messages": messages, @@ -761,8 +760,7 @@ impl LlmClient for DeepSeekClient { async fn create_message_stream(&self, request: MessageRequest) -> Result { // Try true SSE streaming via chat completions (widely supported) - let messages = - build_chat_messages(request.system.as_ref(), &request.messages, &request.model); + let messages = build_chat_messages_for_request(&request); let mut body = json!({ "model": request.model, "messages": messages, @@ -1308,13 +1306,36 @@ fn parse_responses_message(payload: &Value) -> Result { // === Chat Completions Helpers === +#[cfg(test)] fn build_chat_messages( system: Option<&SystemPrompt>, messages: &[Message], model: &str, +) -> Vec { + build_chat_messages_with_reasoning( + system, + messages, + model, + should_replay_reasoning_content(model, None), + ) +} + +fn build_chat_messages_for_request(request: &MessageRequest) -> Vec { + build_chat_messages_with_reasoning( + request.system.as_ref(), + &request.messages, + &request.model, + should_replay_reasoning_content(&request.model, request.reasoning_effort.as_deref()), + ) +} + +fn build_chat_messages_with_reasoning( + system: Option<&SystemPrompt>, + messages: &[Message], + _model: &str, + include_reasoning: bool, ) -> Vec { let mut out = Vec::new(); - let include_reasoning = requires_reasoning_content(model); let mut pending_tool_calls: HashSet = HashSet::new(); if let Some(instructions) = system_to_instructions(system.cloned()) @@ -1387,10 +1408,26 @@ fn build_chat_messages( let content = text_parts.join("\n"); let reasoning_content = thinking_parts.join("\n"); let has_text = !content.trim().is_empty(); - let has_tool_calls = !tool_calls.is_empty(); + let mut has_tool_calls = !tool_calls.is_empty(); let include_reasoning_for_turn = include_reasoning && has_tool_calls; let has_reasoning = include_reasoning_for_turn && !reasoning_content.trim().is_empty(); + // DeepSeek thinking-mode tool turns are stateful within the + // stateless Chat Completions transcript: if an assistant performed + // a tool call, its `reasoning_content` must be replayed in every + // later request. Older checkpoints could lose that field because + // the UI display stream had no visible text block. Do not forward + // those malformed tool calls; dropping the stale tool round is + // better than guaranteeing a provider-side 400. + if include_reasoning_for_turn && !has_reasoning { + logging::warn( + "Dropping DeepSeek tool_calls with missing reasoning_content from assistant message", + ); + tool_calls.clear(); + tool_call_ids.clear(); + has_tool_calls = false; + } + // DeepSeek rejects assistant messages where both `content` and // `tool_calls` are missing/null. Skip such entries even if they // carry reasoning-only metadata unless we can send a non-null @@ -1617,6 +1654,22 @@ fn requires_reasoning_content(model: &str) -> bool { || has_deepseek_r_series_marker(&lower) } +fn should_replay_reasoning_content(model: &str, effort: Option<&str>) -> bool { + if effort + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "off" | "disabled" | "none" | "false" + ) + }) + .unwrap_or(false) + { + return false; + } + + requires_reasoning_content(model) +} + /// Translate the TUI's effort-tier string into DeepSeek's request fields. /// /// The config surface accepts `off | low | medium | high | max`. DeepSeek @@ -2424,6 +2477,101 @@ mod tests { assert!(assistant.get("tool_calls").is_some()); } + #[test] + fn chat_messages_drop_v4_tool_round_missing_reasoning() { + let messages = vec![ + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "call-without-reasoning".to_string(), + name: "read_file".to_string(), + input: json!({"path": "Cargo.toml"}), + caller: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-without-reasoning".to_string(), + content: "workspace manifest".to_string(), + is_error: None, + content_blocks: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "continue".to_string(), + cache_control: None, + }], + }, + ]; + + let out = build_chat_messages(None, &messages, "deepseek-v4-pro"); + + assert!( + !out.iter() + .any(|value| value.get("role").and_then(Value::as_str) == Some("assistant")), + "malformed assistant tool round should be removed" + ); + assert!( + !out.iter() + .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")), + "tool result tied to missing reasoning should be removed" + ); + } + + #[test] + fn chat_messages_allow_tool_round_without_reasoning_when_thinking_disabled() { + let request = MessageRequest { + model: "deepseek-v4-pro".to_string(), + messages: vec![ + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "call-no-thinking".to_string(), + name: "read_file".to_string(), + input: json!({"path": "Cargo.toml"}), + caller: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-no-thinking".to_string(), + content: "workspace manifest".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ], + max_tokens: 1024, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("off".to_string()), + stream: None, + temperature: None, + top_p: None, + }; + + let out = build_chat_messages_for_request(&request); + assert!( + out.iter().any( + |value| value.get("role").and_then(Value::as_str) == Some("assistant") + && value.get("tool_calls").is_some() + ), + "tool calls remain valid when thinking mode is disabled" + ); + assert!( + out.iter() + .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")), + "matching tool result should remain" + ); + } + #[test] fn reasoning_effort_uses_deepseek_top_level_thinking_parameter() { let mut body = json!({}); @@ -2622,12 +2770,17 @@ mod tests { let messages = vec![ Message { role: "assistant".to_string(), - content: vec![ContentBlock::ToolUse { - id: "tool-1".to_string(), - name: "list_dir".to_string(), - input: json!({}), - caller: None, - }], + content: vec![ + ContentBlock::Thinking { + thinking: "Need to inspect the directory".to_string(), + }, + ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "list_dir".to_string(), + input: json!({}), + caller: None, + }, + ], }, Message { role: "user".to_string(), @@ -2657,12 +2810,17 @@ mod tests { let messages = vec![ Message { role: "assistant".to_string(), - content: vec![ContentBlock::ToolUse { - id: "tool-1".to_string(), - name: "web.run".to_string(), - input: json!({}), - caller: None, - }], + content: vec![ + ContentBlock::Thinking { + thinking: "Need to search".to_string(), + }, + ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "web.run".to_string(), + input: json!({}), + caller: None, + }, + ], }, Message { role: "user".to_string(), @@ -2741,12 +2899,17 @@ mod tests { let messages = vec![ Message { role: "assistant".to_string(), - content: vec![ContentBlock::ToolUse { - id: "tool-ok".to_string(), - name: "list_dir".to_string(), - input: json!({}), - caller: None, - }], + content: vec![ + ContentBlock::Thinking { + thinking: "Need to list files".to_string(), + }, + ContentBlock::ToolUse { + id: "tool-ok".to_string(), + name: "list_dir".to_string(), + input: json!({}), + caller: None, + }, + ], }, Message { role: "user".to_string(), diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index ae7f78fa..3571ecb8 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1542,6 +1542,7 @@ impl Engine { }; self.session.rebuild_working_set(); self.rehydrate_latest_canonical_state(); + self.emit_session_updated().await; let _ = self .tx_event .send(Event::status("Session context synced".to_string())) @@ -1557,6 +1558,23 @@ impl Engine { } } + async fn emit_session_updated(&self) { + let _ = self + .tx_event + .send(Event::SessionUpdated { + messages: self.session.messages.clone(), + system_prompt: self.session.system_prompt.clone(), + model: self.session.model.clone(), + workspace: self.session.workspace.clone(), + }) + .await; + } + + async fn add_session_message(&mut self, message: Message) { + self.session.add_message(message); + self.emit_session_updated().await; + } + /// Handle a send message operation #[allow(clippy::too_many_arguments)] async fn handle_send_message( @@ -1636,6 +1654,7 @@ impl Engine { // Update system prompt to match current mode and include persisted compaction context. self.refresh_system_prompt(mode); + self.emit_session_updated().await; // Build tool registry and tool list for the current mode let todo_list = self.config.todos.clone(); @@ -1828,6 +1847,7 @@ impl Engine { let messages_after = result.messages.len(); self.session.messages = result.messages; self.merge_compaction_summary(result.summary_prompt); + self.emit_session_updated().await; let removed = messages_before.saturating_sub(messages_after); let message = if result.retries_used > 0 { format!( @@ -1976,6 +1996,7 @@ impl Engine { self.merge_compaction_summary(summary_prompt); let trimmed = self.trim_oldest_messages_to_budget(target_budget); + self.emit_session_updated().await; let after_tokens = self.estimated_input_tokens(); let after_count = self.session.messages.len(); let recovered = after_tokens <= target_budget @@ -2361,13 +2382,14 @@ impl Engine { self.session .working_set .observe_user_message(&steer, &self.session.workspace); - self.session.add_message(Message { + self.add_session_message(Message { role: "user".to_string(), content: vec![ContentBlock::Text { text: steer.clone(), cache_control: None, }], - }); + }) + .await; let _ = self .tx_event .send(Event::status(format!( @@ -2433,6 +2455,7 @@ impl Engine { let auto_messages_after = result.messages.len(); self.session.messages = result.messages; self.merge_compaction_summary(result.summary_prompt); + self.emit_session_updated().await; let removed = auto_messages_before.saturating_sub(auto_messages_after); let status = if result.retries_used > 0 { format!( @@ -2947,10 +2970,11 @@ impl Engine { // Add assistant message to session if has_sendable_assistant_content { - self.session.add_message(Message { + self.add_session_message(Message { role: "assistant".to_string(), content: content_blocks, - }); + }) + .await; } // If no tool uses, we're done @@ -2960,13 +2984,14 @@ impl Engine { self.session .working_set .observe_user_message(&steer, &self.session.workspace); - self.session.add_message(Message { + self.add_session_message(Message { role: "user".to_string(), content: vec![ContentBlock::Text { text: steer, cache_control: None, }], - }); + }) + .await; } turn.next_step(); continue; @@ -3446,7 +3471,7 @@ impl Engine { Some(&output_for_context), &self.session.workspace, ); - self.session.add_message(Message { + self.add_session_message(Message { role: "user".to_string(), content: vec![ContentBlock::ToolResult { tool_use_id: outcome.id, @@ -3454,7 +3479,8 @@ impl Engine { is_error: None, content_blocks: None, }], - }); + }) + .await; } Err(e) => { emit_tool_audit(json!({ @@ -3473,7 +3499,7 @@ impl Engine { Some(&error), &self.session.workspace, ); - self.session.add_message(Message { + self.add_session_message(Message { role: "user".to_string(), content: vec![ContentBlock::ToolResult { tool_use_id: outcome.id, @@ -3481,7 +3507,8 @@ impl Engine { is_error: Some(true), content_blocks: None, }], - }); + }) + .await; } } @@ -3514,13 +3541,14 @@ impl Engine { self.session .working_set .observe_user_message(&steer, &self.session.workspace); - self.session.add_message(Message { + self.add_session_message(Message { role: "user".to_string(), content: vec![ContentBlock::Text { text: steer, cache_control: None, }], - }); + }) + .await; } } @@ -3903,6 +3931,7 @@ impl Engine { None, ))); self.refresh_system_prompt(mode); + self.emit_session_updated().await; let after_tokens = self.estimated_input_tokens(); self.emit_capacity_intervention( @@ -4005,7 +4034,7 @@ impl Engine { "[verification replay] tool={} pass={} details={}", candidate.name, pass, diff_summary ); - self.session.add_message(Message { + self.add_session_message(Message { role: "user".to_string(), content: vec![ContentBlock::ToolResult { tool_use_id: candidate.id.clone(), @@ -4013,7 +4042,8 @@ impl Engine { is_error: None, content_blocks: None, }], - }); + }) + .await; if !pass { self.capacity_controller @@ -4053,6 +4083,7 @@ impl Engine { Some(&verification_note), ))); self.refresh_system_prompt(mode); + self.emit_session_updated().await; let after_tokens = self.estimated_input_tokens(); self.emit_capacity_intervention( @@ -4135,6 +4166,7 @@ impl Engine { Some("Replan now from canonical state. Keep steps minimal and verifiable."), ))); self.refresh_system_prompt(mode); + self.emit_session_updated().await; let _ = self .tx_event diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index de314aaf..099a0844 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -210,6 +210,37 @@ fn agent_mode_can_build_auto_approved_tool_context() { assert!(engine.build_tool_context(AppMode::Yolo, false).auto_approve); } +#[tokio::test] +async fn session_update_preserves_reasoning_tool_only_turn() { + let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default()); + let assistant = Message { + role: "assistant".to_string(), + content: vec![ + ContentBlock::Thinking { + thinking: "Need a tool before answering.".to_string(), + }, + ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "read_file".to_string(), + input: json!({"path": "Cargo.toml"}), + caller: None, + }, + ], + }; + + engine.add_session_message(assistant.clone()).await; + + let event = { + let mut rx = handle.rx_event.write().await; + rx.recv().await.expect("session update event") + }; + let Event::SessionUpdated { messages, .. } = event else { + panic!("expected session update event"); + }; + + assert_eq!(messages, vec![assistant]); +} + #[test] fn detects_context_length_errors_from_provider_payloads() { let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#; diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 42e382d7..290ab97b 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -3,9 +3,11 @@ //! These events flow from the engine to the TUI via a channel, //! enabling non-blocking, real-time updates. +use std::path::PathBuf; + use serde_json::Value; -use crate::models::Usage; +use crate::models::{Message, SystemPrompt, Usage}; use crate::tools::spec::{ToolError, ToolResult}; use crate::tools::subagent::SubAgentResult; use crate::tools::user_input::UserInputRequest; @@ -199,6 +201,20 @@ pub enum Event { request: UserInputRequest, }, + /// Authoritative API conversation state from the engine session. + /// + /// The UI receives granular display events, but those are not always a + /// lossless representation of the API transcript. DeepSeek can emit + /// reasoning directly followed by tool calls without a visible assistant + /// text block, and that assistant message still has to be persisted for + /// later `reasoning_content` replay. + SessionUpdated { + messages: Vec, + system_prompt: Option, + model: String, + workspace: PathBuf, + }, + /// Request user decision after sandbox denial #[allow(dead_code)] ElevationRequired { diff --git a/crates/tui/src/tools/web_run.rs b/crates/tui/src/tools/web_run.rs index 67eb401c..c08d458a 100644 --- a/crates/tui/src/tools/web_run.rs +++ b/crates/tui/src/tools/web_run.rs @@ -8,6 +8,7 @@ use super::spec::{ optional_u64, required_str, }; use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose}; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -473,7 +474,7 @@ impl ToolSpec for WebRunTool { }) .unwrap_or_default(); - let (entries, warning) = + let (entries, source, warning) = run_search(&query, max_results, timeout_ms, &domains).await?; let mut warnings = Vec::new(); if recency > 0 { @@ -493,7 +494,7 @@ impl ToolSpec for WebRunTool { results.push(SearchResult { ref_id, query, - source: "duckduckgo".to_string(), + source, count: entries.len(), results: entries, warning: if warnings.is_empty() { @@ -707,7 +708,7 @@ async fn run_search( max_results: usize, timeout_ms: u64, domains: &[String], -) -> Result<(Vec, Option), ToolError> { +) -> Result<(Vec, String, Option), ToolError> { let client = reqwest::Client::builder() .timeout(Duration::from_millis(timeout_ms)) .user_agent(USER_AGENT) @@ -741,19 +742,85 @@ async fn run_search( } let mut results = parse_duckduckgo_results(&body, max_results); - let warning = if !domains.is_empty() { + let mut source = "duckduckgo".to_string(); + let mut warnings = Vec::new(); + + if results.is_empty() { + let duckduckgo_blocked = is_duckduckgo_challenge(&body); + match run_bing_search(&client, query, max_results).await { + Ok(fallback_results) if !fallback_results.is_empty() => { + results = fallback_results; + source = "bing".to_string(); + warnings.push(if duckduckgo_blocked { + "DuckDuckGo returned a bot challenge; used Bing fallback".to_string() + } else { + "DuckDuckGo returned no parseable results; used Bing fallback".to_string() + }); + } + Ok(_) if duckduckgo_blocked => { + return Err(ToolError::execution_failed( + "DuckDuckGo returned a bot challenge and Bing fallback returned no results", + )); + } + Err(err) if duckduckgo_blocked => { + return Err(ToolError::execution_failed(format!( + "DuckDuckGo returned a bot challenge and Bing fallback failed: {err}" + ))); + } + Ok(_) | Err(_) => {} + } + } + + if !domains.is_empty() { let before = results.len(); results.retain(|entry| domain_matches(&entry.url, domains)); if before != results.len() { - Some("Filtered search results by domain list".to_string()) - } else { - None + warnings.push("Filtered search results by domain list".to_string()); } - } else { - None - }; + } - Ok((results, warning)) + Ok(( + results, + source, + if warnings.is_empty() { + None + } else { + Some(warnings.join("; ")) + }, + )) +} + +async fn run_bing_search( + client: &reqwest::Client, + query: &str, + max_results: usize, +) -> Result, ToolError> { + let encoded = url_encode(query); + let url = format!("https://www.bing.com/search?q={encoded}"); + let resp = client + .get(&url) + .header( + "Accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + ) + .header("Accept-Language", "en-US,en;q=0.9") + .send() + .await + .map_err(|e| ToolError::execution_failed(format!("Bing fallback request failed: {e}")))?; + + let status = resp.status(); + let body = resp.text().await.map_err(|e| { + ToolError::execution_failed(format!("Failed to read Bing fallback response: {e}")) + })?; + + if !status.is_success() { + return Err(ToolError::execution_failed(format!( + "Bing fallback failed: HTTP {}", + status.as_u16() + ))); + } + + Ok(parse_bing_results(&body, max_results)) } fn domain_matches(url: &str, domains: &[String]) -> bool { @@ -1189,6 +1256,9 @@ static STYLE_RE: OnceLock = OnceLock::new(); static TITLE_RE: OnceLock = OnceLock::new(); static SNIPPET_RE: OnceLock = OnceLock::new(); static SEARCH_TITLE_RE: OnceLock = OnceLock::new(); +static BING_RESULT_RE: OnceLock = OnceLock::new(); +static BING_TITLE_RE: OnceLock = OnceLock::new(); +static BING_SNIPPET_RE: OnceLock = OnceLock::new(); fn get_anchor_re() -> &'static Regex { ANCHOR_RE.get_or_init(|| { @@ -1236,6 +1306,27 @@ fn get_search_snippet_re() -> &'static Regex { }) } +fn get_bing_result_re() -> &'static Regex { + BING_RESULT_RE.get_or_init(|| { + Regex::new(r#"(?is)]*class=\"[^\"]*\bb_algo\b[^\"]*\"[^>]*>(.*?)"#) + .expect("bing result regex pattern is valid") + }) +} + +fn get_bing_title_re() -> &'static Regex { + BING_TITLE_RE.get_or_init(|| { + Regex::new(r#"(?is)]*>.*?]*href=\"([^\"]+)\"[^>]*>(.*?)"#) + .expect("bing title regex pattern is valid") + }) +} + +fn get_bing_snippet_re() -> &'static Regex { + BING_SNIPPET_RE.get_or_init(|| { + Regex::new(r#"(?is)]*class=\"[^\"]*\bb_caption\b[^\"]*\"[^>]*>.*?]*>(.*?)

"#) + .expect("bing snippet regex pattern is valid") + }) +} + fn parse_html(html: &str, base_url: &str) -> (Vec, Vec, Option) { let title = extract_title(html); let without_scripts = get_script_re().replace_all(html, "").to_string(); @@ -1398,6 +1489,44 @@ fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec results } +fn is_duckduckgo_challenge(html: &str) -> bool { + html.contains("anomaly-modal") || html.contains("Unfortunately, bots use DuckDuckGo too") +} + +fn parse_bing_results(html: &str, max_results: usize) -> Vec { + let mut results = Vec::new(); + for cap in get_bing_result_re().captures_iter(html) { + if results.len() >= max_results { + break; + } + let Some(block) = cap.get(1).map(|m| m.as_str()) else { + continue; + }; + let Some(title_cap) = get_bing_title_re().captures(block) else { + continue; + }; + let href = title_cap.get(1).map(|m| m.as_str()).unwrap_or(""); + let title_raw = title_cap.get(2).map(|m| m.as_str()).unwrap_or(""); + let title = normalize_whitespace(&decode_html_entities(&strip_tags(title_raw))); + if title.is_empty() { + continue; + } + let snippet = get_bing_snippet_re() + .captures(block) + .and_then(|snippet_cap| snippet_cap.get(1)) + .map(|m| normalize_whitespace(&decode_html_entities(&strip_tags(m.as_str())))) + .filter(|s| !s.is_empty()); + + results.push(SearchEntry { + title, + url: normalize_bing_url(href), + snippet, + }); + } + + results +} + fn normalize_search_url(href: &str) -> String { if let Some(uddg) = extract_query_param(href, "uddg") { let decoded = percent_decode(&uddg); @@ -1414,6 +1543,30 @@ fn normalize_search_url(href: &str) -> String { href.to_string() } +fn normalize_bing_url(href: &str) -> String { + if let Some(encoded) = extract_query_param(href, "u") { + let decoded = percent_decode(&encoded); + let token = decoded.strip_prefix("a1").unwrap_or(&decoded); + let mut padded = token.replace('-', "+").replace('_', "/"); + while !padded.len().is_multiple_of(4) { + padded.push('='); + } + if let Ok(bytes) = general_purpose::STANDARD.decode(padded) + && let Ok(url) = String::from_utf8(bytes) + && looks_like_url(&url) + { + return url; + } + } + if href.starts_with("//") { + return format!("https:{href}"); + } + if href.starts_with('/') { + return format!("https://www.bing.com{href}"); + } + href.to_string() +} + fn extract_query_param(url: &str, key: &str) -> Option { let query_start = url.find('?')?; let query = &url[query_start + 1..]; @@ -1510,6 +1663,25 @@ mod tests { ); } + #[test] + fn parses_bing_results_and_decodes_redirect_url() { + let html = r#" +
    +
  1. +

    Example & Result

    +

    A useful snippet.

    +
  2. +
+ "#; + + let results = parse_bing_results(html, 5); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].title, "Example & Result"); + assert_eq!(results[0].url, "https://example.com/path?q=1"); + assert_eq!(results[0].snippet.as_deref(), Some("A useful snippet.")); + } + #[test] fn scoped_ref_prefix_is_session_specific() { reset_web_run_state(); diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index cde6ee67..2c35ee8f 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -8,6 +8,7 @@ use super::spec::{ optional_u64, required_str, }; use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose}; use regex::Regex; use serde::Serialize; use serde_json::{Value, json}; @@ -18,6 +19,9 @@ use std::time::Duration; static TITLE_RE: OnceLock = OnceLock::new(); static SNIPPET_RE: OnceLock = OnceLock::new(); static TAG_RE: OnceLock = OnceLock::new(); +static BING_RESULT_RE: OnceLock = OnceLock::new(); +static BING_TITLE_RE: OnceLock = OnceLock::new(); +static BING_SNIPPET_RE: OnceLock = OnceLock::new(); fn get_title_re() -> &'static Regex { TITLE_RE.get_or_init(|| { @@ -39,6 +43,27 @@ fn get_tag_re() -> &'static Regex { TAG_RE.get_or_init(|| Regex::new(r"<[^>]+>").expect("tag regex pattern is valid")) } +fn get_bing_result_re() -> &'static Regex { + BING_RESULT_RE.get_or_init(|| { + Regex::new(r#"(?is)]*class=\"[^\"]*\bb_algo\b[^\"]*\"[^>]*>(.*?)"#) + .expect("bing result regex pattern is valid") + }) +} + +fn get_bing_title_re() -> &'static Regex { + BING_TITLE_RE.get_or_init(|| { + Regex::new(r#"(?is)]*>.*?]*href=\"([^\"]+)\"[^>]*>(.*?)"#) + .expect("bing title regex pattern is valid") + }) +} + +fn get_bing_snippet_re() -> &'static Regex { + BING_SNIPPET_RE.get_or_init(|| { + Regex::new(r#"(?is)]*class=\"[^\"]*\bb_caption\b[^\"]*\"[^>]*>.*?]*>(.*?)

"#) + .expect("bing snippet regex pattern is valid") + }) +} + const DEFAULT_MAX_RESULTS: usize = 5; const MAX_RESULTS: usize = 10; const DEFAULT_TIMEOUT_MS: u64 = 15_000; @@ -149,16 +174,45 @@ impl ToolSpec for WebSearchTool { ))); } - let results = parse_duckduckgo_results(&body, max_results); + let mut results = parse_duckduckgo_results(&body, max_results); + let mut source = "duckduckgo".to_string(); + let mut message_suffix = None; + if results.is_empty() { + let duckduckgo_blocked = is_duckduckgo_challenge(&body); + match run_bing_search(&client, &query, max_results).await { + Ok(fallback_results) if !fallback_results.is_empty() => { + results = fallback_results; + source = "bing".to_string(); + message_suffix = Some(if duckduckgo_blocked { + "DuckDuckGo returned a bot challenge; used Bing fallback" + } else { + "DuckDuckGo returned no parseable results; used Bing fallback" + }); + } + Ok(_) if duckduckgo_blocked => { + return Err(ToolError::execution_failed( + "DuckDuckGo returned a bot challenge and Bing fallback returned no results", + )); + } + Err(err) if duckduckgo_blocked => { + return Err(ToolError::execution_failed(format!( + "DuckDuckGo returned a bot challenge and Bing fallback failed: {err}" + ))); + } + Ok(_) | Err(_) => {} + } + } let message = if results.is_empty() { "No results found".to_string() + } else if let Some(suffix) = message_suffix { + format!("Found {} result(s). {suffix}", results.len()) } else { format!("Found {} result(s)", results.len()) }; let response = WebSearchResponse { query, - source: "duckduckgo".to_string(), + source, count: results.len(), message, results, @@ -168,6 +222,39 @@ impl ToolSpec for WebSearchTool { } } +async fn run_bing_search( + client: &reqwest::Client, + query: &str, + max_results: usize, +) -> Result, ToolError> { + let encoded = url_encode(query); + let url = format!("https://www.bing.com/search?q={encoded}"); + let resp = client + .get(&url) + .header( + "Accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + ) + .header("Accept-Language", "en-US,en;q=0.9") + .send() + .await + .map_err(|e| ToolError::execution_failed(format!("Bing fallback request failed: {e}")))?; + + let status = resp.status(); + let body = resp.text().await.map_err(|e| { + ToolError::execution_failed(format!("Failed to read Bing fallback response: {e}")) + })?; + + if !status.is_success() { + return Err(ToolError::execution_failed(format!( + "Bing fallback failed: HTTP {}", + status.as_u16() + ))); + } + + Ok(parse_bing_results(&body, max_results)) +} + fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec { let title_re = get_title_re(); let snippet_re = get_snippet_re(); @@ -204,6 +291,44 @@ fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec bool { + html.contains("anomaly-modal") || html.contains("Unfortunately, bots use DuckDuckGo too") +} + +fn parse_bing_results(html: &str, max_results: usize) -> Vec { + let mut results = Vec::new(); + for cap in get_bing_result_re().captures_iter(html) { + if results.len() >= max_results { + break; + } + let Some(block) = cap.get(1).map(|m| m.as_str()) else { + continue; + }; + let Some(title_cap) = get_bing_title_re().captures(block) else { + continue; + }; + let href = title_cap.get(1).map(|m| m.as_str()).unwrap_or(""); + let title_raw = title_cap.get(2).map(|m| m.as_str()).unwrap_or(""); + let title = normalize_text(title_raw); + if title.is_empty() { + continue; + } + let snippet = get_bing_snippet_re() + .captures(block) + .and_then(|snippet_cap| snippet_cap.get(1)) + .map(|m| normalize_text(m.as_str())) + .filter(|s| !s.is_empty()); + + results.push(WebSearchEntry { + title, + url: normalize_bing_url(href), + snippet, + }); + } + + results +} + fn normalize_url(href: &str) -> String { if let Some(uddg) = extract_query_param(href, "uddg") { let decoded = percent_decode(&uddg); @@ -220,6 +345,30 @@ fn normalize_url(href: &str) -> String { href.to_string() } +fn normalize_bing_url(href: &str) -> String { + if let Some(encoded) = extract_query_param(href, "u") { + let decoded = percent_decode(&encoded); + let token = decoded.strip_prefix("a1").unwrap_or(&decoded); + let mut padded = token.replace('-', "+").replace('_', "/"); + while !padded.len().is_multiple_of(4) { + padded.push('='); + } + if let Ok(bytes) = general_purpose::STANDARD.decode(padded) + && let Ok(url) = String::from_utf8(bytes) + && (url.starts_with("http://") || url.starts_with("https://")) + { + return url; + } + } + if href.starts_with("//") { + return format!("https:{href}"); + } + if href.starts_with('/') { + return format!("https://www.bing.com{href}"); + } + href.to_string() +} + fn normalize_text(text: &str) -> String { let stripped = strip_html_tags(text); let decoded = decode_html_entities(&stripped); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index d96691f3..d8471f49 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -575,6 +575,21 @@ async fn run_event_loop( EngineEvent::Status { message } => { app.status_message = Some(message); } + EngineEvent::SessionUpdated { + messages, + system_prompt, + model, + workspace, + } => { + app.api_messages = messages; + app.system_prompt = system_prompt; + app.model = model; + app.update_model_compaction_budget(); + app.workspace = workspace; + if app.is_loading || app.is_compacting { + persist_checkpoint(app); + } + } EngineEvent::CompactionStarted { message, .. } => { app.is_compacting = true; app.status_message = Some(message); diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index cb92815f..c0315551 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,7 +1,7 @@ { "name": "deepseek-tui", - "version": "0.4.1", - "deepseekBinaryVersion": "0.4.1", + "version": "0.4.2", + "deepseekBinaryVersion": "0.4.2", "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.", "author": "Hmbown", "license": "MIT",