From 14234e07cdd1f305ec63da5d2462f4a003bc4b26 Mon Sep 17 00:00:00 2001 From: coder3 Date: Mon, 16 Feb 2026 22:39:37 +1300 Subject: [PATCH 1/7] fix: relax test_basic_response assertion for providers returning reasoning_content xAI (and other providers like DeepSeek) can return reasoning_content alongside text content, producing 2+ content items. The test now accepts >= 1 items and verifies at least one is Text, instead of requiring exactly 1 item. Co-Authored-By: Claude Opus 4.6 Signed-off-by: clayarnoldg2m --- crates/goose/tests/providers.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/goose/tests/providers.rs b/crates/goose/tests/providers.rs index bf515c6a31a1..68e81de4bccc 100644 --- a/crates/goose/tests/providers.rs +++ b/crates/goose/tests/providers.rs @@ -179,15 +179,17 @@ impl ProviderTester { .complete(session_id, "You are a helpful assistant.", &[message], &[]) .await?; - assert_eq!( - response.content.len(), - 1, - "Expected single content item in response" + assert!( + !response.content.is_empty(), + "Expected at least one content item in response" ); assert!( - matches!(response.content[0], MessageContent::Text(_)), - "Expected text response" + response + .content + .iter() + .any(|c| matches!(c, MessageContent::Text(_))), + "Expected at least one text content item in response" ); println!( From f32c6dc0cfc5d3b4a56218bfdb0fa2d97feef9ee Mon Sep 17 00:00:00 2001 From: coder2 Date: Mon, 16 Feb 2026 22:41:06 +1300 Subject: [PATCH 2/7] fix(ui): preserve server config values on partial provider config save The submit filter in ProviderConfiguationModal only included fields where the user had typed a new value (entry.value), causing untouched fields like API Host to be silently dropped. Now the filter also includes fields with existing non-masked server values (entry.serverValue), preventing config reversion when only some fields are edited. Fixes #7245 Co-Authored-By: Claude Opus 4.6 Signed-off-by: clayarnoldg2m --- .../providers/modal/ProviderConfiguationModal.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx index 688d86cfc767..9a48b396e949 100644 --- a/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx +++ b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx @@ -105,8 +105,15 @@ export default function ProviderConfigurationModal({ const toSubmit = Object.fromEntries( Object.entries(configValues) - .filter(([_k, entry]) => !!entry.value) - .map(([k, entry]) => [k, entry.value || '']) + .filter( + ([_k, entry]) => + !!entry.value || + (entry.serverValue != null && typeof entry.serverValue === 'string') + ) + .map(([k, entry]) => [ + k, + entry.value ?? (typeof entry.serverValue === 'string' ? entry.serverValue : ''), + ]) ); try { From 4748f3813e7fdc90c0993f52ee4146cffd0c1fa8 Mon Sep 17 00:00:00 2001 From: coder1 Date: Mon, 16 Feb 2026 22:42:41 +1300 Subject: [PATCH 3/7] fix: use explicit max_tokens in apps extension to prevent truncation Replace provider.complete() with provider.complete_with_model() using explicit max_tokens of 16384 in both generate_new_app_content() and generate_updated_app_content(). This prevents app HTML from being truncated when the provider's default max_tokens is too low. Fixes #7239 Co-Authored-By: Claude Opus 4.6 Signed-off-by: clayarnoldg2m --- crates/goose/src/agents/platform_extensions/apps.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/goose/src/agents/platform_extensions/apps.rs b/crates/goose/src/agents/platform_extensions/apps.rs index e68c3ec8767d..86cf1b08c8a5 100644 --- a/crates/goose/src/agents/platform_extensions/apps.rs +++ b/crates/goose/src/agents/platform_extensions/apps.rs @@ -290,8 +290,11 @@ impl AppsManagerClient { let messages = vec![Message::user().with_text(&user_prompt)]; let tools = vec![Self::create_app_content_tool()]; + let mut model_config = provider.get_model_config(); + model_config.max_tokens = Some(16384); + let (response, _usage) = provider - .complete(session_id, &system_prompt, &messages, &tools) + .complete_with_model(Some(session_id), &model_config, &system_prompt, &messages, &tools) .await .map_err(|e| format!("LLM call failed: {}", e))?; @@ -321,8 +324,11 @@ impl AppsManagerClient { let messages = vec![Message::user().with_text(&user_prompt)]; let tools = vec![Self::update_app_content_tool()]; + let mut model_config = provider.get_model_config(); + model_config.max_tokens = Some(16384); + let (response, _usage) = provider - .complete(session_id, &system_prompt, &messages, &tools) + .complete_with_model(Some(session_id), &model_config, &system_prompt, &messages, &tools) .await .map_err(|e| format!("LLM call failed: {}", e))?; From 0a29b3ae3357817c4c51996a9bab747b0be739c3 Mon Sep 17 00:00:00 2001 From: tester1 Date: Mon, 16 Feb 2026 23:19:56 +1300 Subject: [PATCH 4/7] style: fix cargo fmt in apps.rs complete_with_model calls Co-Authored-By: Claude Opus 4.6 Signed-off-by: clayarnoldg2m --- .../goose/src/agents/platform_extensions/apps.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/goose/src/agents/platform_extensions/apps.rs b/crates/goose/src/agents/platform_extensions/apps.rs index 86cf1b08c8a5..46ea1cb94226 100644 --- a/crates/goose/src/agents/platform_extensions/apps.rs +++ b/crates/goose/src/agents/platform_extensions/apps.rs @@ -294,7 +294,13 @@ impl AppsManagerClient { model_config.max_tokens = Some(16384); let (response, _usage) = provider - .complete_with_model(Some(session_id), &model_config, &system_prompt, &messages, &tools) + .complete_with_model( + Some(session_id), + &model_config, + &system_prompt, + &messages, + &tools, + ) .await .map_err(|e| format!("LLM call failed: {}", e))?; @@ -328,7 +334,13 @@ impl AppsManagerClient { model_config.max_tokens = Some(16384); let (response, _usage) = provider - .complete_with_model(Some(session_id), &model_config, &system_prompt, &messages, &tools) + .complete_with_model( + Some(session_id), + &model_config, + &system_prompt, + &messages, + &tools, + ) .await .map_err(|e| format!("LLM call failed: {}", e))?; From 07a2d8ed914b36d7e1d475ef73ce3adc1fe87f22 Mon Sep 17 00:00:00 2001 From: coder1 Date: Mon, 16 Feb 2026 23:54:14 +1300 Subject: [PATCH 5/7] fix: parse thinking blocks in Gemini CLI stream responses Add a Some("thinking") arm to parse_stream_json_response() that extracts thinking content from Gemini CLI stream events and creates MessageContent::Thinking entries. Without this, thinking blocks were silently dropped, causing truncated responses. Includes tests for thinking block parsing and no-thinking fallback. Fixes #7203 Co-Authored-By: Claude Opus 4.6 Signed-off-by: clayarnoldg2m --- crates/goose/src/providers/gemini_cli.rs | 56 +++++++++++++++++++++--- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/goose/src/providers/gemini_cli.rs b/crates/goose/src/providers/gemini_cli.rs index 2a7aeac8f1ec..06fd6c777d81 100644 --- a/crates/goose/src/providers/gemini_cli.rs +++ b/crates/goose/src/providers/gemini_cli.rs @@ -221,10 +221,18 @@ impl GeminiCliProvider { fn parse_stream_json_response(events: &[Value]) -> Result<(Message, Usage), ProviderError> { let mut all_text_content = Vec::new(); + let mut all_thinking_content = Vec::new(); let mut usage = Usage::default(); for parsed in events { match parsed.get("type").and_then(|t| t.as_str()) { + Some("thinking") => { + if let Some(content) = parsed.get("content").and_then(|c| c.as_str()) { + if !content.is_empty() { + all_thinking_content.push(content.to_string()); + } + } + } Some("message") => { if parsed.get("role").and_then(|r| r.as_str()) == Some("assistant") { if let Some(content) = parsed.get("content").and_then(|c| c.as_str()) { @@ -253,11 +261,16 @@ impl GeminiCliProvider { )); } - let message = Message::new( - Role::Assistant, - chrono::Utc::now().timestamp(), - vec![MessageContent::text(combined_text)], - ); + let mut content = Vec::new(); + + let combined_thinking = all_thinking_content.join(""); + if !combined_thinking.is_empty() { + content.push(MessageContent::thinking(combined_thinking, String::new())); + } + + content.push(MessageContent::text(combined_text)); + + let message = Message::new(Role::Assistant, chrono::Utc::now().timestamp(), content); Ok((message, usage)) } @@ -395,6 +408,39 @@ mod tests { assert!(GeminiCliProvider::parse_stream_json_response(&empty).is_err()); } + #[test] + fn test_parse_thinking_blocks() { + let events = vec![ + json!({"type":"init","session_id":"abc","model":"gemini-2.5-pro"}), + json!({"type":"thinking","content":"Let me reason about this...","delta":true}), + json!({"type":"thinking","content":" Step 1: analyze the problem.","delta":true}), + json!({"type":"message","role":"assistant","content":"Here is the answer.","delta":true}), + json!({"type":"result","status":"success","stats":{"input_tokens":30,"output_tokens":15,"total_tokens":45}}), + ]; + let (message, usage) = GeminiCliProvider::parse_stream_json_response(&events).unwrap(); + assert_eq!(message.role, Role::Assistant); + + // Should have thinking content followed by text content + assert_eq!(message.content.len(), 2); + let thinking = message.content[0].as_thinking().expect("first content should be thinking"); + assert_eq!(thinking.thinking, "Let me reason about this... Step 1: analyze the problem."); + assert_eq!(message.as_concat_text(), "Here is the answer."); + assert_eq!(usage.input_tokens, Some(30)); + assert_eq!(usage.output_tokens, Some(15)); + } + + #[test] + fn test_parse_no_thinking_blocks() { + // When there's no thinking, message should only have text content + let events = vec![ + json!({"type":"message","role":"assistant","content":"Direct answer.","delta":true}), + json!({"type":"result","status":"success","stats":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}), + ]; + let (message, _usage) = GeminiCliProvider::parse_stream_json_response(&events).unwrap(); + assert_eq!(message.content.len(), 1); + assert_eq!(message.as_concat_text(), "Direct answer."); + } + #[test] fn test_build_prompt_first_and_resume() { let provider = make_provider(); From a0538e146ec7c5121e6710c795ae25b7118d7161 Mon Sep 17 00:00:00 2001 From: tester1 Date: Tue, 17 Feb 2026 00:15:11 +1300 Subject: [PATCH 6/7] style: fix cargo fmt in gemini_cli.rs test assertions Co-Authored-By: Claude Opus 4.6 Signed-off-by: clayarnoldg2m --- crates/goose/src/providers/gemini_cli.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/goose/src/providers/gemini_cli.rs b/crates/goose/src/providers/gemini_cli.rs index 06fd6c777d81..82f9b8082583 100644 --- a/crates/goose/src/providers/gemini_cli.rs +++ b/crates/goose/src/providers/gemini_cli.rs @@ -422,8 +422,13 @@ mod tests { // Should have thinking content followed by text content assert_eq!(message.content.len(), 2); - let thinking = message.content[0].as_thinking().expect("first content should be thinking"); - assert_eq!(thinking.thinking, "Let me reason about this... Step 1: analyze the problem."); + let thinking = message.content[0] + .as_thinking() + .expect("first content should be thinking"); + assert_eq!( + thinking.thinking, + "Let me reason about this... Step 1: analyze the problem." + ); assert_eq!(message.as_concat_text(), "Here is the answer."); assert_eq!(usage.input_tokens, Some(30)); assert_eq!(usage.output_tokens, Some(15)); From efb774403e8e95fee7e4aaa07bfd274a089f99c2 Mon Sep 17 00:00:00 2001 From: coder3 Date: Tue, 17 Feb 2026 00:15:51 +1300 Subject: [PATCH 7/7] fix: handle reasoning_content for Kimi/thinking models (#6902) Three fixes for reasoning_content handling: 1. agent.rs: Preserve reasoning_content when splitting parallel tool calls. Providers like Kimi require reasoning_content on all assistant messages with tool_calls when thinking mode is enabled. 2. openai.rs format_messages: Omit reasoning_content field entirely when empty instead of sending empty string. Kimi rejects empty reasoning_content (""). 3. openai.rs streaming: Properly accumulate reasoning_content chunks across streaming deltas and emit as MessageContent::reasoning(). Co-Authored-By: Claude Opus 4.6 Signed-off-by: clayarnoldg2m --- crates/goose/src/agents/agent.rs | 22 +++++++++++++-- crates/goose/src/providers/formats/openai.rs | 29 ++++++++++++-------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 3cfedeb39f45..71fcb7931359 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1346,8 +1346,9 @@ impl Agent { } } - // Preserve thinking content from the original response + // Preserve thinking/reasoning content from the original response // Gemini (and other thinking models) require thinking to be echoed back + // Kimi/DeepSeek require reasoning_content on assistant tool call messages let thinking_content: Vec = response.content.iter() .filter(|c| matches!(c, MessageContent::Thinking(_))) .cloned() @@ -1361,10 +1362,25 @@ impl Agent { messages_to_add.push(thinking_msg); } + // Collect reasoning content to attach to tool request messages + let reasoning_content: Vec = response.content.iter() + .filter(|c| matches!(c, MessageContent::Reasoning(_))) + .cloned() + .collect(); + for (idx, request) in frontend_requests.iter().chain(remaining_requests.iter()).enumerate() { if request.tool_call.is_ok() { - let request_msg = Message::assistant() - .with_id(format!("msg_{}", Uuid::new_v4())) + let mut request_msg = Message::assistant() + .with_id(format!("msg_{}", Uuid::new_v4())); + + // Attach reasoning content to EVERY split tool request message. + // Providers like Kimi require reasoning_content on all assistant + // messages with tool_calls when thinking mode is enabled. + for rc in &reasoning_content { + request_msg = request_msg.with_content(rc.clone()); + } + + request_msg = request_msg .with_tool_request_with_metadata( request.id.clone(), request.tool_call.clone(), diff --git a/crates/goose/src/providers/formats/openai.rs b/crates/goose/src/providers/formats/openai.rs index 111bd660ce68..ae5c45950d89 100644 --- a/crates/goose/src/providers/formats/openai.rs +++ b/crates/goose/src/providers/formats/openai.rs @@ -82,7 +82,7 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec< let mut output = Vec::new(); let mut content_array = Vec::new(); let mut text_array = Vec::new(); - let mut reasoning_text: Option = None; + let mut reasoning_text = String::new(); for content in &message.content { match content { @@ -116,7 +116,7 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec< continue; } MessageContent::Reasoning(r) => { - reasoning_text = Some(r.text.clone()); + reasoning_text.push_str(&r.text); } MessageContent::ToolRequest(request) => match &request.tool_call { Ok(tool_call) => { @@ -278,15 +278,11 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec< converted["content"] = json!(null); } - // DeepSeek requires reasoning_content field when tool_calls are present - // Set it to the captured reasoning text, or empty string if not present - if converted.get("tool_calls").is_some() { - let reasoning = reasoning_text.unwrap_or_default(); - converted["reasoning_content"] = json!(reasoning); - } else if let Some(reasoning) = reasoning_text { - if !reasoning.is_empty() { - converted["reasoning_content"] = json!(reasoning); - } + // Include reasoning_content only when non-empty. + // Kimi rejects empty reasoning_content (""), so we must omit it entirely + // when there's no reasoning to send. + if !reasoning_text.is_empty() { + converted["reasoning_content"] = json!(reasoning_text); } if converted.get("content").is_some() || converted.get("tool_calls").is_some() { @@ -542,6 +538,7 @@ where use futures::StreamExt; let mut accumulated_reasoning: Vec = Vec::new(); + let mut accumulated_reasoning_content = String::new(); 'outer: while let Some(response) = stream.next().await { if response.as_ref().is_ok_and(|s| s == "data: [DONE]") { @@ -562,6 +559,9 @@ where if let Some(details) = &chunk.choices[0].delta.reasoning_details { accumulated_reasoning.extend(details.iter().cloned()); } + if let Some(rc) = &chunk.choices[0].delta.reasoning_content { + accumulated_reasoning_content.push_str(rc); + } } let mut usage = extract_usage_with_output_tokens(&chunk); @@ -602,6 +602,9 @@ where if let Some(details) = &tool_chunk.choices[0].delta.reasoning_details { accumulated_reasoning.extend(details.iter().cloned()); } + if let Some(rc) = &tool_chunk.choices[0].delta.reasoning_content { + accumulated_reasoning_content.push_str(rc); + } if let Some(delta_tool_calls) = &tool_chunk.choices[0].delta.tool_calls { for delta_call in delta_tool_calls { if let Some(index) = delta_call.index { @@ -642,6 +645,10 @@ where }; let mut contents = Vec::new(); + if !accumulated_reasoning_content.is_empty() { + contents.push(MessageContent::reasoning(&accumulated_reasoning_content)); + accumulated_reasoning_content.clear(); + } let mut sorted_indices: Vec<_> = tool_call_data.keys().cloned().collect(); sorted_indices.sort();