From 7bd9d5a1f15bdccd7cdbea9bac9baf9a7f2fcc29 Mon Sep 17 00:00:00 2001 From: wsp Date: Thu, 20 Aug 2026 19:29:45 +0800 Subject: [PATCH] feat(reasoning): support response summaries Request reasoning summaries from OpenAI Responses and preserve their identity across streaming, event batching, persistence, replay, and frontend projection. - Keep summary parts separated by their response indexes - Distinguish summaries from ordinary reasoning throughout the pipeline - Show summaries as collapsed single-line previews with full expansion - Keep ordinary trailing reasoning auto-expanded --- src/apps/desktop/src/api/event_coalescer.rs | 75 +++++++- .../src/client/response_aggregator.rs | 1 + .../src/providers/openai/responses.rs | 63 ++++++- .../src/stream/stream_handler/responses.rs | 94 +++++++++- .../ai-adapters/src/stream/types/anthropic.rs | 1 + .../ai-adapters/src/stream/types/gemini.rs | 6 + .../ai-adapters/src/stream/types/openai.rs | 4 + .../ai-adapters/src/stream/types/responses.rs | 5 + .../assembly/core/src/agentic/core/message.rs | 52 +++++- .../src/agentic/execution/round_executor.rs | 3 + .../src/agentic/execution/stream_processor.rs | 3 + .../core/src/agentic/memories/transcript.rs | 1 + .../src/agentic/session/session_manager.rs | 1 + .../src/agentic/session/transcript_render.rs | 1 + .../core/src/service_agent_runtime.rs | 1 + src/crates/contracts/core-types/src/ai.rs | 11 ++ src/crates/contracts/core-types/src/lib.rs | 7 +- src/crates/contracts/events/src/agentic.rs | 4 +- .../events/src/frontend_projection.rs | 6 +- .../src/session_event_journal.rs | 61 ++++++- src/crates/execution/agent-stream/src/lib.rs | 164 ++++++++++++++++-- .../execution/agent-stream/src/unified.rs | 6 +- .../interfaces/acp/src/runtime/replay.rs | 1 + .../services-core/src/session/types.rs | 7 + .../tests/remote_connect_contracts.rs | 1 + .../flow_chat/services/EventBatcher.test.ts | 23 +++ .../src/flow_chat/services/EventBatcher.ts | 10 +- .../flow-chat-manager/EventHandlerModule.ts | 35 +++- .../flow-chat-manager/TextChunkModule.test.ts | 36 ++++ .../flow-chat-manager/TextChunkModule.ts | 7 +- .../services/flow-chat-manager/types.ts | 1 + .../store/modernFlowChatStore.test.ts | 39 +++++ .../flow_chat/store/modernFlowChatStore.ts | 11 +- .../tool-cards/ModelThinkingDisplay.scss | 19 ++ .../tool-cards/ModelThinkingDisplay.test.tsx | 135 ++++++++++++++ .../tool-cards/ModelThinkingDisplay.tsx | 43 ++++- src/web-ui/src/flow_chat/types/flow-chat.ts | 1 + .../reasoningSummaryPresentation.test.ts | 16 ++ .../utils/reasoningSummaryPresentation.ts | 31 ++++ src/web-ui/src/locales/en-US/flow-chat.json | 1 + src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + .../src/shared/types/session-history.ts | 5 +- 43 files changed, 943 insertions(+), 51 deletions(-) create mode 100644 src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx create mode 100644 src/web-ui/src/flow_chat/utils/reasoningSummaryPresentation.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/reasoningSummaryPresentation.ts diff --git a/src/apps/desktop/src/api/event_coalescer.rs b/src/apps/desktop/src/api/event_coalescer.rs index 6cec99e775..53c35867e7 100644 --- a/src/apps/desktop/src/api/event_coalescer.rs +++ b/src/apps/desktop/src/api/event_coalescer.rs @@ -5,7 +5,7 @@ //! WebView costs one Tauri IPC message (JSON serialization, WebView2 boundary //! crossing, JS parse + dispatch) and, when peer devices are attached, one //! end-to-end encrypted relay message. This module merges chunks of the same -//! stream (session / turn / round / attempt / contentType) within a short +//! stream (session / turn / round / attempt / contentType / reasoningKind) within a short //! window so the frontend still receives content-equivalent events at a //! fraction of the message rate. //! @@ -24,6 +24,7 @@ //! emits thinking chunks before text chunks, and `flush` therefore emits the //! merged thinking event before the merged text event. +use bitfun_core_types::ReasoningContentKind; use bitfun_events::AgenticEvent; use std::collections::HashMap; use std::time::Duration; @@ -118,7 +119,14 @@ pub fn update_rate_ema(previous: f64, flushed_chars: usize, elapsed: Duration) - pub const INITIAL_RATE_EMA_CPS: f64 = WINDOW_REF_CPS; /// Stable merge key for one streaming text/thinking stream. -type ChunkStreamKey = (String, String, String, String, bool); +type ChunkStreamKey = ( + String, + String, + String, + String, + bool, + Option, +); fn resolve_attempt_token(attempt_id: &Option, attempt_index: Option) -> String { if let Some(id) = attempt_id { @@ -148,6 +156,7 @@ enum PendingChunk { attempt_id: Option, attempt_index: Option, content: String, + reasoning_kind: Option, is_end: bool, }, } @@ -177,6 +186,7 @@ impl PendingChunk { attempt_id, attempt_index, content, + reasoning_kind, is_end, } => AgenticEvent::ThinkingChunk { session_id, @@ -185,6 +195,7 @@ impl PendingChunk { attempt_id, attempt_index, content, + reasoning_kind, is_end, }, } @@ -251,6 +262,7 @@ impl TextChunkCoalescer { round_id.clone(), resolve_attempt_token(&attempt_id, attempt_index), false, + None, ); match self.pending.get_mut(&key) { Some(PendingChunk::Text { text: pending, .. }) => { @@ -284,6 +296,7 @@ impl TextChunkCoalescer { attempt_id, attempt_index, content, + reasoning_kind, is_end, } => { let key = ( @@ -292,6 +305,7 @@ impl TextChunkCoalescer { round_id.clone(), resolve_attempt_token(&attempt_id, attempt_index), true, + reasoning_kind, ); match self.pending.get_mut(&key) { Some(PendingChunk::Thinking { @@ -315,6 +329,7 @@ impl TextChunkCoalescer { attempt_id, attempt_index, content, + reasoning_kind, is_end, }, ); @@ -389,6 +404,24 @@ mod tests { attempt_id: attempt_id.map(str::to_string), attempt_index, content: content.to_string(), + reasoning_kind: None, + is_end, + } + } + + fn typed_thinking_chunk( + content: &str, + reasoning_kind: ReasoningContentKind, + is_end: bool, + ) -> AgenticEvent { + AgenticEvent::ThinkingChunk { + session_id: "s".to_string(), + turn_id: "t".to_string(), + round_id: "r".to_string(), + attempt_id: None, + attempt_index: None, + content: content.to_string(), + reasoning_kind: Some(reasoning_kind), is_end, } } @@ -474,6 +507,44 @@ mod tests { } } + #[test] + fn keeps_reasoning_text_and_summary_streams_separate() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(typed_thinking_chunk( + "private chain", + ReasoningContentKind::Reasoning, + false, + )) + .is_empty()); + assert!(coalescer + .push(typed_thinking_chunk( + "display summary", + ReasoningContentKind::Summary, + false, + )) + .is_empty()); + + let events = coalescer.flush(); + assert_eq!(events.len(), 2); + assert!(matches!( + &events[0], + AgenticEvent::ThinkingChunk { + content, + reasoning_kind: Some(ReasoningContentKind::Reasoning), + .. + } if content == "private chain" + )); + assert!(matches!( + &events[1], + AgenticEvent::ThinkingChunk { + content, + reasoning_kind: Some(ReasoningContentKind::Summary), + .. + } if content == "display summary" + )); + } + #[test] fn flush_preserves_first_arrival_order_across_streams() { let mut coalescer = TextChunkCoalescer::new(); diff --git a/src/crates/adapters/ai-adapters/src/client/response_aggregator.rs b/src/crates/adapters/ai-adapters/src/client/response_aggregator.rs index 135a8b2a13..aeb8868ed5 100644 --- a/src/crates/adapters/ai-adapters/src/client/response_aggregator.rs +++ b/src/crates/adapters/ai-adapters/src/client/response_aggregator.rs @@ -27,6 +27,7 @@ pub(crate) async fn aggregate_stream_response( let UnifiedResponse { text, reasoning_content, + reasoning_content_kind: _, thinking_signature: _, tool_call, usage: chunk_usage, diff --git a/src/crates/adapters/ai-adapters/src/providers/openai/responses.rs b/src/crates/adapters/ai-adapters/src/providers/openai/responses.rs index dca43fb662..9d3b1a8580 100644 --- a/src/crates/adapters/ai-adapters/src/providers/openai/responses.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/responses.rs @@ -64,6 +64,18 @@ fn log_prompt_cache_diagnostics(request_body: &serde_json::Value) { ); } +fn ensure_reasoning_summary_opt_in(request_body: &mut serde_json::Value) { + let Some(reasoning) = request_body + .get_mut("reasoning") + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + reasoning + .entry("summary".to_string()) + .or_insert_with(|| serde_json::Value::String("auto".to_string())); +} + fn try_build_request_body_with_context( client: &AIClient, instructions: Option, @@ -111,7 +123,7 @@ fn try_build_request_body_with_context( if value.trim().is_empty() { return Err(anyhow!("Responses reasoning effort must not be empty")); } - body["reasoning"] = serde_json::json!({ "effort": value }); + body["reasoning"] = serde_json::json!({ "effort": value, "summary": "auto" }); Ok(true) } ReasoningPresetAction::Toggle { .. } | ReasoningPresetAction::BudgetTokens { .. } => { @@ -164,6 +176,7 @@ fn try_build_request_body_with_context( ); shared::apply_reasoning_actions(preset, &mut request_body, protected_keys, &[], compile)?; } + ensure_reasoning_summary_opt_in(&mut request_body); shared::log_request_body( TARGET, @@ -301,6 +314,9 @@ mod tests { use super::{build_request_body, build_request_body_with_context}; use crate::types::{ModelRequestContext, ToolDefinition}; use crate::{client::AIClient, types::AIConfig}; + use bitfun_core_types::{ + ReasoningPresetAction, ReasoningPresetDescriptor, ReasoningPresetSource, + }; use serde_json::json; fn test_client() -> AIClient { @@ -370,6 +386,51 @@ mod tests { assert!(request_body.get("include").is_none()); } + #[test] + fn responses_reasoning_effort_requests_auto_summary() { + let client = test_client().with_reasoning_preset(&ReasoningPresetDescriptor { + id: "high".to_string(), + label: "High".to_string(), + order: 0, + actions: vec![ReasoningPresetAction::Effort { + value: "high".to_string(), + }], + source: ReasoningPresetSource::ModelConfig, + execution_provider: None, + execution_model: None, + }); + let request_body = build_request_body(&client, None, Vec::new(), None, None); + + assert_eq!( + request_body["reasoning"], + json!({ "effort": "high", "summary": "auto" }) + ); + } + + #[test] + fn responses_reasoning_summary_preserves_explicit_override() { + let concise_request_body = build_request_body( + &test_client(), + None, + Vec::new(), + None, + Some(json!({ "reasoning": { "effort": "low", "summary": "concise" } })), + ); + let disabled_request_body = build_request_body( + &test_client(), + None, + Vec::new(), + None, + Some(json!({ "reasoning": { "effort": "low", "summary": null } })), + ); + + assert_eq!( + concise_request_body["reasoning"]["summary"], + json!("concise") + ); + assert_eq!(disabled_request_body["reasoning"]["summary"], json!(null)); + } + #[test] fn attaches_runtime_prompt_cache_key_after_custom_body_merge() { let client = test_client(); diff --git a/src/crates/adapters/ai-adapters/src/stream/stream_handler/responses.rs b/src/crates/adapters/ai-adapters/src/stream/stream_handler/responses.rs index 55a48a3524..980554d635 100644 --- a/src/crates/adapters/ai-adapters/src/stream/stream_handler/responses.rs +++ b/src/crates/adapters/ai-adapters/src/stream/stream_handler/responses.rs @@ -8,6 +8,7 @@ use crate::stream::types::unified::UnifiedResponse; use anyhow::{anyhow, Result}; use bitfun_agent_stream::ToolCallCompletion; use bitfun_core_types::errors::AiProviderError; +use bitfun_core_types::ReasoningContentKind; use eventsource_stream::Eventsource; use log::{debug, error, trace}; use reqwest::Response; @@ -371,6 +372,7 @@ pub async fn handle_responses_stream( let mut timeout_controller = StreamTimeoutController::new(ttft_timeout, idle_timeout); let mut response_created_count = 0usize; let mut response_prompt_cache_key_hash: Option = None; + let mut last_reasoning_summary_part: Option<(Option, usize)> = None; loop { let sse = match next_stream_item(&mut stream, &timeout_controller).await { @@ -518,10 +520,32 @@ pub async fn handle_responses_stream( ); } } - "response.reasoning_text.delta" | "response.reasoning_summary_text.delta" => { + "response.reasoning_text.delta" => { if let Some(delta) = event.delta.filter(|delta| !delta.is_empty()) { let unified_response = UnifiedResponse { reasoning_content: Some(delta), + reasoning_content_kind: Some(ReasoningContentKind::Reasoning), + ..Default::default() + }; + emit_unified_response( + &mut timeout_controller, + &tx_event, + &mut stats, + unified_response, + ); + } + } + "response.reasoning_summary_text.delta" => { + if let Some(delta) = event.delta.filter(|delta| !delta.is_empty()) { + let delta = separate_reasoning_summary_part( + &mut last_reasoning_summary_part, + event.output_index, + event.summary_index, + delta, + ); + let unified_response = UnifiedResponse { + reasoning_content: Some(delta), + reasoning_content_kind: Some(ReasoningContentKind::Summary), ..Default::default() }; emit_unified_response( @@ -848,13 +872,33 @@ pub async fn handle_responses_stream( } } +fn separate_reasoning_summary_part( + last_part: &mut Option<(Option, usize)>, + output_index: Option, + summary_index: Option, + delta: String, +) -> String { + let Some(summary_index) = summary_index else { + return delta; + }; + let current_part = (output_index, summary_index); + let starts_new_part = last_part.is_some_and(|previous| previous != current_part); + *last_part = Some(current_part); + + if starts_new_part { + format!("\n\n{delta}") + } else { + delta + } +} + #[cfg(test)] mod tests { use super::{ super::stream_stats::StreamStats, completed_replay_capture, extract_api_error, extract_api_error_message, handle_function_call_arguments_delta, handle_function_call_output_item_done, responses_completed_tool_call_completion, - InProgressToolCall, StreamTimeoutController, + separate_reasoning_summary_part, InProgressToolCall, StreamTimeoutController, }; use bitfun_agent_stream::ToolCallCompletion; use bitfun_core_types::errors::ErrorCategory; @@ -936,6 +980,52 @@ mod tests { ); } + #[test] + fn reasoning_summary_deltas_in_the_same_part_stay_contiguous() { + let mut last_part = None; + + assert_eq!( + separate_reasoning_summary_part(&mut last_part, Some(0), Some(0), "**First".into()), + "**First" + ); + assert_eq!( + separate_reasoning_summary_part(&mut last_part, Some(0), Some(0), " part**".into()), + " part**" + ); + } + + #[test] + fn reasoning_summary_inserts_a_paragraph_boundary_between_parts() { + let mut last_part = None; + let first = separate_reasoning_summary_part( + &mut last_part, + Some(0), + Some(0), + "**First part**".into(), + ); + let second = separate_reasoning_summary_part( + &mut last_part, + Some(0), + Some(1), + "**Second part**".into(), + ); + + assert_eq!( + format!("{first}{second}"), + "**First part**\n\n**Second part**" + ); + } + + #[test] + fn reasoning_summary_without_an_index_keeps_gateway_compatibility() { + let mut last_part = Some((Some(0), 0)); + + assert_eq!( + separate_reasoning_summary_part(&mut last_part, Some(0), None, "delta".into()), + "delta" + ); + } + #[test] fn output_item_done_falls_back_when_output_index_is_untracked() { let (tx_event, mut rx_event) = mpsc::unbounded_channel(); diff --git a/src/crates/adapters/ai-adapters/src/stream/types/anthropic.rs b/src/crates/adapters/ai-adapters/src/stream/types/anthropic.rs index 9bcebcc641..e164af51ba 100644 --- a/src/crates/adapters/ai-adapters/src/stream/types/anthropic.rs +++ b/src/crates/adapters/ai-adapters/src/stream/types/anthropic.rs @@ -97,6 +97,7 @@ impl From for UnifiedResponse { Self { text: None, reasoning_content: None, + reasoning_content_kind: None, thinking_signature: None, tool_call: None, usage: value.usage.map(UnifiedTokenUsage::from), diff --git a/src/crates/adapters/ai-adapters/src/stream/types/gemini.rs b/src/crates/adapters/ai-adapters/src/stream/types/gemini.rs index aa63ac4632..cfd699e461 100644 --- a/src/crates/adapters/ai-adapters/src/stream/types/gemini.rs +++ b/src/crates/adapters/ai-adapters/src/stream/types/gemini.rs @@ -371,6 +371,7 @@ impl GeminiSSEData { responses.push(UnifiedResponse { text: None, reasoning_content: None, + reasoning_content_kind: None, thinking_signature, tool_call: Some(UnifiedToolCall { tool_call_index: Some(part_index), @@ -393,6 +394,7 @@ impl GeminiSSEData { responses.push(UnifiedResponse { text: None, reasoning_content: Some(reasoning_content), + reasoning_content_kind: None, thinking_signature, tool_call: None, usage: usage.take(), @@ -412,6 +414,7 @@ impl GeminiSSEData { responses.push(UnifiedResponse { text: None, reasoning_content: Some(reasoning_content), + reasoning_content_kind: None, thinking_signature, tool_call: None, usage: usage.take(), @@ -428,6 +431,7 @@ impl GeminiSSEData { responses.push(UnifiedResponse { text: if is_thought { None } else { Some(text.clone()) }, reasoning_content: if is_thought { Some(text) } else { None }, + reasoning_content_kind: None, thinking_signature, tool_call: None, usage: usage.take(), @@ -443,6 +447,7 @@ impl GeminiSSEData { responses.push(UnifiedResponse { text: None, reasoning_content: None, + reasoning_content_kind: None, thinking_signature, tool_call: None, usage: usage.take(), @@ -479,6 +484,7 @@ impl GeminiSSEData { responses.push(UnifiedResponse { text: summary, reasoning_content: None, + reasoning_content_kind: None, thinking_signature: None, tool_call: None, usage: usage.take(), diff --git a/src/crates/adapters/ai-adapters/src/stream/types/openai.rs b/src/crates/adapters/ai-adapters/src/stream/types/openai.rs index 83d8aac39e..da97ed6691 100644 --- a/src/crates/adapters/ai-adapters/src/stream/types/openai.rs +++ b/src/crates/adapters/ai-adapters/src/stream/types/openai.rs @@ -215,6 +215,7 @@ impl OpenAISSEData { responses.push(UnifiedResponse { text: content, reasoning_content, + reasoning_content_kind: None, thinking_signature: None, tool_call: None, usage: usage.take(), @@ -231,6 +232,7 @@ impl OpenAISSEData { responses.push(UnifiedResponse { text: None, reasoning_content: None, + reasoning_content_kind: None, thinking_signature: None, tool_call: Some(UnifiedToolCall::from(tool_call)), usage: if is_first_event { usage.take() } else { None }, @@ -252,6 +254,7 @@ impl OpenAISSEData { responses.push(UnifiedResponse { text: None, reasoning_content: None, + reasoning_content_kind: None, thinking_signature: None, tool_call: None, usage, @@ -267,6 +270,7 @@ impl OpenAISSEData { responses.push(UnifiedResponse { text: None, reasoning_content: None, + reasoning_content_kind: None, thinking_signature: None, tool_call: None, usage, diff --git a/src/crates/adapters/ai-adapters/src/stream/types/responses.rs b/src/crates/adapters/ai-adapters/src/stream/types/responses.rs index 27282f03d1..587fed9420 100644 --- a/src/crates/adapters/ai-adapters/src/stream/types/responses.rs +++ b/src/crates/adapters/ai-adapters/src/stream/types/responses.rs @@ -17,6 +17,9 @@ pub struct ResponsesStreamEvent { #[allow(dead_code)] #[serde(default)] pub content_index: Option, + /// Summary part index within a reasoning output item. + #[serde(default)] + pub summary_index: Option, #[serde(default)] pub response: Option, #[serde(default)] @@ -84,6 +87,7 @@ pub fn parse_responses_output_item( "function_call" => Some(UnifiedResponse { text: None, reasoning_content: None, + reasoning_content_kind: None, thinking_signature: None, tool_call: Some(UnifiedToolCall { tool_call_index, @@ -125,6 +129,7 @@ pub fn parse_responses_output_item( text.map(|text| UnifiedResponse { text: Some(text), reasoning_content: None, + reasoning_content_kind: None, thinking_signature: None, tool_call: None, usage: None, diff --git a/src/crates/assembly/core/src/agentic/core/message.rs b/src/crates/assembly/core/src/agentic/core/message.rs index bbdff6cc5e..9007c2b9fe 100644 --- a/src/crates/assembly/core/src/agentic/core/message.rs +++ b/src/crates/assembly/core/src/agentic/core/message.rs @@ -2,7 +2,7 @@ use crate::agentic::image_analysis::ImageContextData; use crate::util::types::{Message as AIMessage, ToolCall as AIToolCall, ToolImageAttachment}; use crate::util::TokenCounter; use bitfun_agent_runtime::prompt_markup::is_system_reminder_only; -use bitfun_core_types::ModelResponseReplay; +use bitfun_core_types::{ModelResponseReplay, ReasoningContentKind}; pub use bitfun_runtime_ports::{CompressionContract, CompressionContractItem}; use log::warn; use serde::{Deserialize, Serialize}; @@ -63,6 +63,8 @@ pub struct MessageMetadata { /// Anthropic extended thinking signature (for passing back in multi-turn conversations) #[serde(skip_serializing_if = "Option::is_none")] pub thinking_signature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_content_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] pub semantic_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -262,6 +264,7 @@ impl From for AIMessage { MessageRole::System => "system", }; let thinking_signature = msg.metadata.thinking_signature.clone(); + let reasoning_content_kind = msg.metadata.reasoning_content_kind; let model_response_replay = msg.metadata.model_response_replay.clone(); match msg.content { @@ -330,10 +333,13 @@ impl From for AIMessage { } } MessageContent::Mixed { - reasoning_content, + mut reasoning_content, text, tool_calls, } => { + if reasoning_content_kind == Some(ReasoningContentKind::Summary) { + reasoning_content = None; + } let converted_tool_calls = if tool_calls.is_empty() { // Set to None when tool_call is empty to avoid deepseek model errors None @@ -608,6 +614,14 @@ impl Message { self } + pub fn with_reasoning_content_kind( + mut self, + reasoning_content_kind: Option, + ) -> Self { + self.metadata.reasoning_content_kind = reasoning_content_kind; + self + } + pub fn with_memory_citation(mut self, memory_citation: Option) -> Self { self.metadata.memory_citation = memory_citation; self @@ -774,7 +788,7 @@ mod tests { use super::{Message, ToolCall}; use crate::util::types::Message as AIMessage; use bitfun_agent_stream::ToolArgumentRepairKind; - use bitfun_core_types::{ModelResponseReplay, ModelResponseReplayItem}; + use bitfun_core_types::{ModelResponseReplay, ModelResponseReplayItem, ReasoningContentKind}; use serde_json::json; #[test] @@ -788,6 +802,38 @@ mod tests { assert_eq!(ai_msg.thinking_signature.as_deref(), Some("sig_1")); } + #[test] + fn reasoning_summary_is_not_sent_as_generic_reasoning_content() { + let msg = Message::assistant_with_reasoning( + Some("display summary".to_string()), + "answer".to_string(), + vec![], + ) + .with_reasoning_content_kind(Some(ReasoningContentKind::Summary)); + + let ai_msg = AIMessage::from(msg); + + assert!(ai_msg.reasoning_content.is_none()); + assert_eq!(ai_msg.content.as_deref(), Some("answer")); + } + + #[test] + fn legacy_message_without_reasoning_content_kind_still_deserializes() { + let message = Message::assistant_with_reasoning( + Some("legacy reasoning".to_string()), + "answer".to_string(), + vec![], + ); + let mut encoded = serde_json::to_value(message).expect("serialize message"); + encoded["metadata"] + .as_object_mut() + .expect("metadata object") + .remove("reasoning_content_kind"); + + let restored: Message = serde_json::from_value(encoded).expect("legacy message"); + assert!(restored.metadata.reasoning_content_kind.is_none()); + } + #[test] fn persists_and_restores_model_response_replay() { let message = Message::assistant("done".to_string()).with_model_response_replay(Some( diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index 6172f06a7b..eda9c97d2d 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -980,6 +980,7 @@ impl RoundExecutor { .with_turn_id(context.dialog_turn_id.clone()) .with_round_id(round_id.clone()) .with_thinking_signature(stream_result.thinking_signature.clone()) + .with_reasoning_content_kind(stream_result.reasoning_content_kind) .with_memory_citation(parsed_memory_citation) .with_model_response_replay(model_response_replay); @@ -1204,6 +1205,7 @@ impl RoundExecutor { .with_turn_id(context.dialog_turn_id.clone()) .with_round_id(round_id.clone()) .with_thinking_signature(stream_result.thinking_signature.clone()) + .with_reasoning_content_kind(stream_result.reasoning_content_kind) .with_memory_citation(parsed_memory_citation) .with_model_response_replay(model_response_replay); @@ -2001,6 +2003,7 @@ mod tests { fn error_trace_response_from_stream_result_preserves_structured_context() { let stream_result = StreamResult { full_thinking: "reasoning".to_string(), + reasoning_content_kind: Some(bitfun_core_types::ReasoningContentKind::Reasoning), reasoning_content_present: true, thinking_signature: Some("sig".to_string()), full_text: String::new(), diff --git a/src/crates/assembly/core/src/agentic/execution/stream_processor.rs b/src/crates/assembly/core/src/agentic/execution/stream_processor.rs index f407eafe80..38540f8cfb 100644 --- a/src/crates/assembly/core/src/agentic/execution/stream_processor.rs +++ b/src/crates/assembly/core/src/agentic/execution/stream_processor.rs @@ -4,6 +4,7 @@ use crate::agentic::core::ToolCall; use crate::agentic::events::EventQueue; use crate::util::errors::BitFunError; use crate::util::types::ai::GeminiUsage; +use bitfun_core_types::ReasoningContentKind; use futures::stream::BoxStream; use serde_json::Value; use std::sync::Arc; @@ -22,6 +23,7 @@ const MEMORY_CITATION_HIDDEN_TEXT_TAG: &str = "memory_citation"; #[derive(Debug, Clone)] pub struct StreamResult { pub full_thinking: String, + pub reasoning_content_kind: Option, pub reasoning_content_present: bool, pub thinking_signature: Option, pub full_text: String, @@ -40,6 +42,7 @@ impl From for StreamResult { fn from(result: bitfun_agent_stream::StreamResult) -> Self { Self { full_thinking: result.full_thinking, + reasoning_content_kind: result.reasoning_content_kind, reasoning_content_present: result.reasoning_content_present, thinking_signature: result.thinking_signature, full_text: result.full_text, diff --git a/src/crates/assembly/core/src/agentic/memories/transcript.rs b/src/crates/assembly/core/src/agentic/memories/transcript.rs index af2b475ffa..4af57100f0 100644 --- a/src/crates/assembly/core/src/agentic/memories/transcript.rs +++ b/src/crates/assembly/core/src/agentic/memories/transcript.rs @@ -689,6 +689,7 @@ mod tests { round.thinking_items.push(ThinkingItemData { id: "thinking_1".to_string(), content: "private reasoning".to_string(), + reasoning_kind: None, is_streaming: false, is_collapsed: true, timestamp: 1, diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 5bee32f8e8..68051e56a5 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -7265,6 +7265,7 @@ impl SessionManager { thinking_items.push(ThinkingItemData { id: format!("{}-think-{}", round_id, order_index), content: reasoning.clone(), + reasoning_kind: msg.metadata.reasoning_content_kind, is_streaming: false, is_collapsed: true, timestamp, diff --git a/src/crates/assembly/core/src/agentic/session/transcript_render.rs b/src/crates/assembly/core/src/agentic/session/transcript_render.rs index fb982c3915..428b56dc6d 100644 --- a/src/crates/assembly/core/src/agentic/session/transcript_render.rs +++ b/src/crates/assembly/core/src/agentic/session/transcript_render.rs @@ -585,6 +585,7 @@ mod search_projection_tests { thinking_items: vec![ThinkingItemData { id: "thinking".to_string(), content: "private reasoning".to_string(), + reasoning_kind: None, is_streaming: false, is_collapsed: false, timestamp: 1, diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 3de914f856..d6e1bbf3b6 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -3055,6 +3055,7 @@ mod tests { thinking_items: vec![ThinkingItemData { id: "thinking-1".to_string(), content: "visible thought".to_string(), + reasoning_kind: None, is_streaming: false, is_collapsed: false, timestamp: 1_105, diff --git a/src/crates/contracts/core-types/src/ai.rs b/src/crates/contracts/core-types/src/ai.rs index dd5bc22e14..90cc5e79de 100644 --- a/src/crates/contracts/core-types/src/ai.rs +++ b/src/crates/contracts/core-types/src/ai.rs @@ -683,6 +683,17 @@ pub struct ModelReasoningSummaryPart { pub text: String, } +/// Human-readable reasoning text exposed by a model provider. +/// +/// A summary is safe, provider-generated display content and is not equivalent +/// to either raw reasoning text or opaque reasoning state used for replay. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningContentKind { + Reasoning, + Summary, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolCall { pub id: String, diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index 8766e5b8ea..28b777bf09 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -23,9 +23,10 @@ pub use ai::{ ProviderCatalogModelLimits, ProviderCatalogModelPricing, ProviderCatalogModelSource, ProviderCatalogProvider, ProviderCatalogSource, ProviderCatalogUpstreamProvider, ProxyConfig, ReasoningCapabilityStatus, ReasoningCatalogBinding, ReasoningCatalogProjection, - ReasoningCatalogProjectionRequest, ReasoningConfig, ReasoningPreset, ReasoningPresetAction, - ReasoningPresetDescriptor, ReasoningPresetSource, RemoteModelInfo, ToolCall, - ToolCallConfirmationDetails, ToolCallRequestInfo, ToolCallResponseInfo, ToolDefinition, + ReasoningCatalogProjectionRequest, ReasoningConfig, ReasoningContentKind, ReasoningPreset, + ReasoningPresetAction, ReasoningPresetDescriptor, ReasoningPresetSource, RemoteModelInfo, + ToolCall, ToolCallConfirmationDetails, ToolCallRequestInfo, ToolCallResponseInfo, + ToolDefinition, }; pub use errors::{AiErrorDetail, ErrorCategory}; pub use model::{ diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index 933a277e53..7cb025a7f2 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -1,6 +1,6 @@ //! Agentic Events Definition pub use bitfun_core_types::errors::{AiErrorDetail, ErrorCategory}; -use bitfun_core_types::{SessionExecutionTarget, ToolImageAttachment}; +use bitfun_core_types::{ReasoningContentKind, SessionExecutionTarget, ToolImageAttachment}; use serde::{Deserialize, Serialize}; use std::time::SystemTime; @@ -343,6 +343,8 @@ pub enum AgenticEvent { #[serde(default, skip_serializing_if = "Option::is_none")] attempt_index: Option, content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_kind: Option, #[serde(default)] is_end: bool, }, diff --git a/src/crates/contracts/events/src/frontend_projection.rs b/src/crates/contracts/events/src/frontend_projection.rs index dd37697889..52b2d0e603 100644 --- a/src/crates/contracts/events/src/frontend_projection.rs +++ b/src/crates/contracts/events/src/frontend_projection.rs @@ -163,6 +163,7 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option Some(AgenticFrontendEvent::new( "agentic://text-chunk", @@ -174,6 +175,7 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option bool attempt_id, attempt_index, content, + reasoning_kind, is_end, .. } => { @@ -489,11 +490,13 @@ fn apply_event(projection: &mut SessionProjection, event: &AgenticEvent) -> bool round_id: candidate_round, attempt_id: candidate_attempt, attempt_index: candidate_attempt_index, + reasoning_kind: candidate_reasoning_kind, .. } if candidate_turn == turn_id && candidate_round == round_id && candidate_attempt == attempt_id - && candidate_attempt_index == attempt_index) + && candidate_attempt_index == attempt_index + && candidate_reasoning_kind == reasoning_kind) }) { accumulated.push_str(content); *accumulated_end |= *is_end; @@ -684,6 +687,7 @@ mod tests { SessionEventJournal, SessionEventProjectionStore, StoredSessionEvents, MAX_REPLAYABLE_TAIL_EVENTS, MAX_RETAINED_TERMINAL_SESSION_PROJECTIONS, }; + use bitfun_core_types::ReasoningContentKind; use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -743,6 +747,24 @@ mod tests { } } + fn thinking( + session_id: &str, + turn_id: &str, + value: &str, + reasoning_kind: ReasoningContentKind, + ) -> AgenticEvent { + AgenticEvent::ThinkingChunk { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + round_id: "round".to_string(), + attempt_id: None, + attempt_index: None, + content: value.to_string(), + reasoning_kind: Some(reasoning_kind), + is_end: false, + } + } + fn turn_completed(session_id: &str, turn_id: &str) -> AgenticEvent { AgenticEvent::DialogTurnCompleted { session_id: session_id.to_string(), @@ -1025,6 +1047,43 @@ mod tests { )); } + #[test] + fn compact_projection_keeps_reasoning_text_and_summary_separate() { + let journal = SessionEventJournal::with_stream_id("runtime-a".to_string()); + journal.record(&turn_started("session", "turn")); + journal.record(&thinking( + "session", + "turn", + "private chain", + ReasoningContentKind::Reasoning, + )); + journal.record(&thinking( + "session", + "turn", + "display summary", + ReasoningContentKind::Summary, + )); + + let snapshot = journal.snapshot("session"); + assert_eq!(snapshot.events.len(), 3); + assert!(matches!( + &snapshot.events[1], + AgenticEvent::ThinkingChunk { + content, + reasoning_kind: Some(ReasoningContentKind::Reasoning), + .. + } if content == "private chain" + )); + assert!(matches!( + &snapshot.events[2], + AgenticEvent::ThinkingChunk { + content, + reasoning_kind: Some(ReasoningContentKind::Summary), + .. + } if content == "display summary" + )); + } + #[test] fn a_caught_up_client_is_told_it_missed_nothing() { let journal = SessionEventJournal::with_stream_id("runtime-a".to_string()); diff --git a/src/crates/execution/agent-stream/src/lib.rs b/src/crates/execution/agent-stream/src/lib.rs index 9a90fc391f..98759187bd 100644 --- a/src/crates/execution/agent-stream/src/lib.rs +++ b/src/crates/execution/agent-stream/src/lib.rs @@ -12,7 +12,7 @@ use crate::tool_call_accumulator::{ FinalizedToolCall, PendingToolCalls, ToolCallBoundary, ToolCallFinalizeOptions, ToolCallStreamKey, }; -use bitfun_core_types::errors::AiProviderError; +use bitfun_core_types::{errors::AiProviderError, ReasoningContentKind}; use bitfun_events::{AgenticEvent, AgenticEventPriority as EventPriority, ToolEventData}; use futures::{Stream, StreamExt}; pub use hidden_text::{HiddenTextBlock, HiddenTextStreamParser, HiddenTextTag}; @@ -248,6 +248,9 @@ const UNKNOWN_TOOL_PLACEHOLDER: &str = "unknown_tool"; #[derive(Debug, Clone)] pub struct StreamResult { pub full_thinking: String, + /// Source semantics of `full_thinking`; summaries are preferred when both + /// provider reasoning text and a displayable summary were emitted. + pub reasoning_content_kind: Option, /// Whether the provider emitted a reasoning/thinking field even if its content was empty. pub reasoning_content_present: bool, /// Signature of Anthropic extended thinking (passed back in multi-turn conversations) @@ -313,6 +316,8 @@ struct StreamContext { // Accumulated results full_thinking: String, + full_reasoning_text: String, + full_reasoning_summary: String, reasoning_content_present: bool, /// Signature of Anthropic extended thinking (passed back in multi-turn conversations) thinking_signature: Option, @@ -334,7 +339,8 @@ struct StreamContext { first_visible_output_ms: Option, text_chunks_count: usize, thinking_chunks_count: usize, - thinking_completed_sent: bool, + thinking_streams: Vec>, + completed_thinking_streams: HashSet>, has_effective_output: bool, partial_recovery_reason: Option, /// Provider finish_reason indicating the response was cut by the model's @@ -360,6 +366,8 @@ impl StreamContext { attempt_id, attempt_index, full_thinking: String::new(), + full_reasoning_text: String::new(), + full_reasoning_summary: String::new(), reasoning_content_present: false, thinking_signature: None, full_text: String::new(), @@ -376,7 +384,8 @@ impl StreamContext { first_visible_output_ms: None, text_chunks_count: 0, thinking_chunks_count: 0, - thinking_completed_sent: false, + thinking_streams: Vec::new(), + completed_thinking_streams: HashSet::new(), has_effective_output: false, partial_recovery_reason: None, token_limit_finish_reason: None, @@ -386,8 +395,22 @@ impl StreamContext { } fn into_result(self) -> StreamResult { + let (full_thinking, reasoning_content_kind) = if !self.full_reasoning_summary.is_empty() { + ( + self.full_reasoning_summary, + Some(ReasoningContentKind::Summary), + ) + } else if !self.full_reasoning_text.is_empty() { + ( + self.full_reasoning_text, + Some(ReasoningContentKind::Reasoning), + ) + } else { + (self.full_thinking, None) + }; StreamResult { - full_thinking: self.full_thinking, + full_thinking, + reasoning_content_kind, reasoning_content_present: self.reasoning_content_present, thinking_signature: self.thinking_signature, full_text: self.full_text, @@ -403,6 +426,16 @@ impl StreamContext { } } + fn preferred_thinking(&self) -> &str { + if !self.full_reasoning_summary.is_empty() { + &self.full_reasoning_summary + } else if !self.full_reasoning_text.is_empty() { + &self.full_reasoning_text + } else { + &self.full_thinking + } + } + fn mark_first_stream_chunk(&mut self) { if self.first_chunk_ms.is_none() { self.first_chunk_ms = Some(elapsed_ms_u64(self.stream_started_at)); @@ -565,8 +598,10 @@ impl StreamProcessor { /// Send thinking end event (if needed) async fn send_thinking_end_if_needed(&self, ctx: &mut StreamContext) { - if ctx.thinking_chunks_count > 0 && !ctx.thinking_completed_sent { - ctx.thinking_completed_sent = true; + for reasoning_kind in ctx.thinking_streams.clone() { + if !ctx.completed_thinking_streams.insert(reasoning_kind) { + continue; + } debug!("Thinking process ended, sending ThinkingChunk end event"); let _ = self .event_sink @@ -578,6 +613,7 @@ impl StreamProcessor { attempt_id: Some(ctx.attempt_id.clone()), attempt_index: Some(ctx.attempt_index), content: String::new(), + reasoning_kind, is_end: true, }, Some(EventPriority::Normal), @@ -869,11 +905,27 @@ impl StreamProcessor { } /// Handle thinking chunk - async fn handle_thinking_chunk(&self, ctx: &mut StreamContext, thinking_content: String) { + async fn handle_thinking_chunk( + &self, + ctx: &mut StreamContext, + thinking_content: String, + reasoning_kind: Option, + ) { // Thinking-only output does NOT count as "effective" for retry purposes: // if the stream fails after producing only thinking (no text/tool calls), // it is safe to retry because the model will re-think from scratch. - ctx.full_thinking.push_str(&thinking_content); + match reasoning_kind { + Some(ReasoningContentKind::Reasoning) => { + ctx.full_reasoning_text.push_str(&thinking_content) + } + Some(ReasoningContentKind::Summary) => { + ctx.full_reasoning_summary.push_str(&thinking_content) + } + None => ctx.full_thinking.push_str(&thinking_content), + } + if !ctx.thinking_streams.contains(&reasoning_kind) { + ctx.thinking_streams.push(reasoning_kind); + } ctx.mark_first_visible_output(); ctx.thinking_chunks_count += 1; @@ -888,6 +940,7 @@ impl StreamProcessor { attempt_id: Some(ctx.attempt_id.clone()), attempt_index: Some(ctx.attempt_index), content: thinking_content, + reasoning_kind, is_end: false, }, None, @@ -912,8 +965,9 @@ impl StreamProcessor { ); if log::log_enabled!(log::Level::Debug) { - if !ctx.full_thinking.is_empty() { - debug!(target: "ai::stream_processor", "Full thinking content: \n{}", ctx.full_thinking); + let preferred_thinking = ctx.preferred_thinking(); + if !preferred_thinking.is_empty() { + debug!(target: "ai::stream_processor", "Full thinking content: \n{}", preferred_thinking); } if !ctx.full_text.is_empty() { debug!(target: "ai::stream_processor", "Full text content: \n{}", ctx.full_text); @@ -937,7 +991,7 @@ impl StreamProcessor { trace!( "Returning StreamResult: thinking_len={}, text_len={}, tool_calls={}, has_usage={}, has_effective_output={}", - ctx.full_thinking.len(), + ctx.preferred_thinking().len(), ctx.full_text.len(), ctx.tool_calls.len(), ctx.usage.is_some(), @@ -1123,6 +1177,7 @@ impl StreamProcessor { let UnifiedResponse { text, reasoning_content, + reasoning_content_kind, thinking_signature, tool_call, usage, @@ -1150,7 +1205,12 @@ impl StreamProcessor { if let Some(thinking_content) = reasoning_content { ctx.reasoning_content_present = true; if !thinking_content.is_empty() { - self.handle_thinking_chunk(&mut ctx, thinking_content).await; + self.handle_thinking_chunk( + &mut ctx, + thinking_content, + reasoning_content_kind, + ) + .await; if let Some(err) = self.check_cancellation(&mut ctx, cancellation_token, "processing thinking chunk").await { return err; } @@ -1250,7 +1310,7 @@ mod tests { }; use super::{UnifiedResponse, UnifiedTokenUsage, UnifiedToolCall}; use bitfun_core_types::errors::{AiProviderError, ErrorCategory}; - use bitfun_core_types::ModelResponseReplayItem; + use bitfun_core_types::{ModelResponseReplayItem, ReasoningContentKind}; use bitfun_events::{AgenticEvent, AgenticEventPriority as EventPriority, ToolEventData}; use futures::StreamExt; use serde_json::json; @@ -2180,6 +2240,84 @@ mod tests { assert!(!result.has_effective_output); } + #[tokio::test] + async fn keeps_reasoning_and_summary_streams_separate_and_prefers_summary() { + let sink = Arc::new(RecordingEventSink::default()); + let processor = StreamProcessor::new(sink.clone()); + let stream = iter(vec![ + Ok(UnifiedResponse { + reasoning_content: Some("private ".to_string()), + reasoning_content_kind: Some(ReasoningContentKind::Reasoning), + ..Default::default() + }), + Ok(UnifiedResponse { + reasoning_content: Some("chain".to_string()), + reasoning_content_kind: Some(ReasoningContentKind::Reasoning), + ..Default::default() + }), + Ok(UnifiedResponse { + reasoning_content: Some("display ".to_string()), + reasoning_content_kind: Some(ReasoningContentKind::Summary), + ..Default::default() + }), + Ok(UnifiedResponse { + reasoning_content: Some("summary".to_string()), + reasoning_content_kind: Some(ReasoningContentKind::Summary), + finish_reason: Some("stop".to_string()), + ..Default::default() + }), + ]) + .boxed(); + + let result = processor + .process_stream( + stream, + None, + None, + "session_1".to_string(), + "turn_1".to_string(), + "round_1".to_string(), + "round_1:attempt:1".to_string(), + 1, + &CancellationToken::new(), + ) + .await + .expect("stream result"); + + assert_eq!(result.full_thinking, "display summary"); + assert_eq!( + result.reasoning_content_kind, + Some(ReasoningContentKind::Summary) + ); + + let events = sink.events.lock().await; + let thinking_events = events + .iter() + .filter(|event| matches!(event, AgenticEvent::ThinkingChunk { .. })) + .collect::>(); + assert!(thinking_events.iter().any(|event| matches!( + event, + AgenticEvent::ThinkingChunk { + reasoning_kind: Some(ReasoningContentKind::Reasoning), + .. + } + ))); + assert!(thinking_events.iter().any(|event| matches!( + event, + AgenticEvent::ThinkingChunk { + reasoning_kind: Some(ReasoningContentKind::Summary), + .. + } + ))); + assert_eq!( + thinking_events + .iter() + .filter(|event| matches!(event, AgenticEvent::ThinkingChunk { is_end: true, .. })) + .count(), + 2 + ); + } + #[tokio::test] async fn carries_complete_model_response_replay_to_stream_result() { let processor = build_processor(); diff --git a/src/crates/execution/agent-stream/src/unified.rs b/src/crates/execution/agent-stream/src/unified.rs index 1aada37dc3..91e20e8d9a 100644 --- a/src/crates/execution/agent-stream/src/unified.rs +++ b/src/crates/execution/agent-stream/src/unified.rs @@ -1,5 +1,5 @@ use crate::tool_call_accumulator::ToolCallCompletion; -use bitfun_core_types::ModelResponseReplayItem; +use bitfun_core_types::{ModelResponseReplayItem, ReasoningContentKind}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::borrow::Cow; @@ -27,6 +27,9 @@ pub struct ModelResponseReplayCapture { pub struct UnifiedResponse { pub text: Option, pub reasoning_content: Option, + /// Distinguishes provider reasoning text from a user-displayable reasoning summary. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_content_kind: Option, /// Signature for Anthropic extended thinking (returned in multi-turn conversations) #[serde(skip_serializing_if = "Option::is_none")] pub thinking_signature: Option, @@ -64,6 +67,7 @@ impl fmt::Debug for UnifiedResponse { f.debug_struct("UnifiedResponse") .field("text", &self.text) .field("reasoning_content", &reasoning_summary) + .field("reasoning_content_kind", &self.reasoning_content_kind) .field("thinking_signature", &"") .field("tool_call", &self.tool_call) .field("usage", &self.usage) diff --git a/src/crates/interfaces/acp/src/runtime/replay.rs b/src/crates/interfaces/acp/src/runtime/replay.rs index 3f4412a35a..cdeb57ca20 100644 --- a/src/crates/interfaces/acp/src/runtime/replay.rs +++ b/src/crates/interfaces/acp/src/runtime/replay.rs @@ -277,6 +277,7 @@ mod tests { ThinkingItemData { id: id.to_string(), content: content.to_string(), + reasoning_kind: None, is_streaming: false, is_collapsed: false, timestamp: 0, diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index a910cef439..003b24bee2 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -790,6 +790,12 @@ fn default_is_markdown() -> bool { pub struct ThinkingItemData { pub id: String, pub content: String, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "reasoning_kind" + )] + pub reasoning_kind: Option, #[serde(alias = "is_streaming")] pub is_streaming: bool, #[serde(alias = "is_collapsed")] @@ -1663,6 +1669,7 @@ mod tests { }); let thinking: ThinkingItemData = serde_json::from_value(thinking_payload) .expect("thinking attempt fields should deserialize"); + assert!(thinking.reasoning_kind.is_none()); assert_eq!(thinking.attempt_id.as_deref(), Some("round-1:attempt:2")); assert_eq!(thinking.attempt_index, Some(2)); diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index 3ac2c34eab..541b904291 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -2427,6 +2427,7 @@ fn remote_connect_tracker_preserves_streaming_snapshot_contract() { attempt_id: None, attempt_index: None, content: "plan".to_string(), + reasoning_kind: None, is_end: false, }); tracker.handle_agentic_event(&AgenticEvent::TextChunk { diff --git a/src/web-ui/src/flow_chat/services/EventBatcher.test.ts b/src/web-ui/src/flow_chat/services/EventBatcher.test.ts index cabce592dd..fbb21982c4 100644 --- a/src/web-ui/src/flow_chat/services/EventBatcher.test.ts +++ b/src/web-ui/src/flow_chat/services/EventBatcher.test.ts @@ -7,6 +7,7 @@ import { generateTextChunkKey, generateToolEventKey, getBatchedEventsLogPayload, + parseEventKey, summarizeBatchedEventsForLog, type BatchedEvent, type ToolEventData, @@ -117,6 +118,28 @@ describe('generateToolEventKey', () => { contentType: 'text', })); }); + + it('separates raw reasoning from reasoning summary chunks', () => { + const common = { + sessionId: 'session-1', + turnId: 'turn-1', + roundId: 'round-1', + text: 'chunk', + contentType: 'thinking' as const, + }; + + const reasoningKey = generateTextChunkKey({ + ...common, + reasoningKind: 'reasoning', + }); + const summaryKey = generateTextChunkKey({ + ...common, + reasoningKind: 'summary', + }); + + expect(reasoningKey).not.toEqual(summaryKey); + expect(parseEventKey(summaryKey)?.ids.reasoningKind).toBe('summary'); + }); }); describe('EventBatcher dual latency', () => { diff --git a/src/web-ui/src/flow_chat/services/EventBatcher.ts b/src/web-ui/src/flow_chat/services/EventBatcher.ts index 8b45e97c1a..4d1f8d6e70 100644 --- a/src/web-ui/src/flow_chat/services/EventBatcher.ts +++ b/src/web-ui/src/flow_chat/services/EventBatcher.ts @@ -413,6 +413,7 @@ export interface TextChunkEventData { attemptIndex?: number; text: string; contentType: 'text' | 'thinking'; + reasoningKind?: 'reasoning' | 'summary'; isThinkingEnd?: boolean; } @@ -439,11 +440,11 @@ function resolveAttemptMergeToken(data: { attemptId?: string; attemptIndex?: num * Generate merge key for TextChunk events * * Key structure: - * - Text chunk: text:{sessionId}:{roundId}:{contentType}:{attemptToken} + * - Text chunk: text:{sessionId}:{roundId}:{contentType}:{reasoningKind}:{attemptToken} */ export function generateTextChunkKey(data: TextChunkEventData): string { - const { sessionId, roundId, contentType } = data; - return `text:${sessionId}:${roundId}:${contentType}:${resolveAttemptMergeToken(data)}`; + const { sessionId, roundId, contentType, reasoningKind = 'none' } = data; + return `text:${sessionId}:${roundId}:${contentType}:${reasoningKind}:${resolveAttemptMergeToken(data)}`; } /** @@ -497,7 +498,8 @@ export function parseEventKey(key: string): { ids: { sessionId: parts[1], roundId: parts[2], - contentType: parts[3] + contentType: parts[3], + reasoningKind: parts[4] } }; } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 481eac1b29..6552fa5648 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -1929,7 +1929,15 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { * Handle text chunk event */ function handleTextChunk(context: FlowChatContext, event: any): void { - const { sessionId, turnId, roundId, text, contentType = 'text', isThinkingEnd = false } = event; + const { + sessionId, + turnId, + roundId, + text, + contentType = 'text', + reasoningKind, + isThinkingEnd = false, + } = event; if (!shouldProcessEvent(sessionId, turnId, 'data', 'TextChunk')) { return; } @@ -1970,6 +1978,7 @@ function handleTextChunk(context: FlowChatContext, event: any): void { attemptIndex: event.attemptIndex, text, contentType: contentType as 'text' | 'thinking', + reasoningKind, isThinkingEnd, }; @@ -2008,9 +2017,29 @@ export function processBatchedEvents( const { eventType } = parsed; if (eventType === 'text') { - const { sessionId, turnId, roundId, attemptId, attemptIndex, text, contentType, isThinkingEnd } = payload; + const { + sessionId, + turnId, + roundId, + attemptId, + attemptIndex, + text, + contentType, + reasoningKind, + isThinkingEnd, + } = payload; if (contentType === 'thinking') { - processThinkingChunkInternal(context, sessionId, turnId, roundId, text, isThinkingEnd, attemptId, attemptIndex); + processThinkingChunkInternal( + context, + sessionId, + turnId, + roundId, + text, + isThinkingEnd, + attemptId, + attemptIndex, + reasoningKind, + ); } else { processNormalTextChunkInternal(context, sessionId, turnId, roundId, text, attemptId, attemptIndex); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts index 856219c86f..e6099fa8f5 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts @@ -273,4 +273,40 @@ describe('processNormalTextChunkInternal', () => { expect((thinkingItems[0] as any).content).toBe('Initial reasoning plus late reasoning'); expect((thinkingItems[0] as any).status).toBe('completed'); }); + + it('keeps raw reasoning and its display summary in separate thinking items', () => { + const session = makeSession(); + const context = makeContext(session); + + processThinkingChunkInternal( + context, + 'session-1', + 'turn-1', + 'round-1', + 'private chain', + false, + undefined, + undefined, + 'reasoning', + ); + processThinkingChunkInternal( + context, + 'session-1', + 'turn-1', + 'round-1', + 'display summary', + false, + undefined, + undefined, + 'summary', + ); + + const thinkingItems = session.dialogTurns[0].modelRounds[0].items + .filter(item => item.type === 'thinking'); + expect(thinkingItems).toHaveLength(2); + expect(thinkingItems.map(item => (item as any).reasoningKind)).toEqual([ + 'reasoning', + 'summary', + ]); + }); }); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts index af751f2e65..155a64a862 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts @@ -179,6 +179,7 @@ export function processThinkingChunkInternal( isThinkingEnd = false, attemptId?: string, attemptIndex?: number, + reasoningKind?: 'reasoning' | 'summary', ): void { clearRuntimeStatus(context, sessionId, turnId, { roundId }); @@ -194,7 +195,7 @@ export function processThinkingChunkInternal( const streamKey = resolveAttemptStreamKey(roundId, attemptId, attemptIndex); // Store thinking content under a separate key. - const thinkingKey = `thinking_${streamKey}`; + const thinkingKey = `thinking_${reasoningKind ?? 'none'}_${streamKey}`; const round = findRound(context, sessionId, turnId, roundId); let thinkingItemId = sessionActiveTextItems.get(thinkingKey); @@ -206,6 +207,7 @@ export function processThinkingChunkInternal( item.type === 'thinking' && item.attemptId === attemptId && item.attemptIndex === attemptIndex && + item.reasoningKind === reasoningKind && (item.isStreaming || isRoundClosed(round)) ); @@ -230,6 +232,7 @@ export function processThinkingChunkInternal( id: thinkingItemId, type: 'thinking', content: cleanedContent, + reasoningKind, isStreaming: !isThinkingEnd, isCollapsed: isThinkingEnd, timestamp: Date.now(), @@ -249,6 +252,7 @@ export function processThinkingChunkInternal( if (isThinkingEnd) { context.flowChatStore.updateModelRoundItemSilent(sessionId, turnId, thinkingItemId, { content: cleanedContent, + reasoningKind, isStreaming: false, isCollapsed: true, status: 'completed', @@ -262,6 +266,7 @@ export function processThinkingChunkInternal( } else { context.flowChatStore.updateModelRoundItemSilent(sessionId, turnId, thinkingItemId, { content: cleanedContent, + reasoningKind, isStreaming: true, isCollapsed: false, status: 'streaming', diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts index 39a9cb22ec..146117701e 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts @@ -101,6 +101,7 @@ export interface SubagentTextChunkData { attemptIndex?: number; text: string; contentType: string; + reasoningKind?: 'reasoning' | 'summary'; isThinkingEnd?: boolean; } diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts index a69a41f416..b3e0dccb49 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts @@ -526,6 +526,45 @@ describe('sessionToVirtualItems explore grouping', () => { ]); }); + it('keeps a trailing reasoning summary collapsed in layout hints', () => { + const session = makeSession({ + sessionId: 'summary-layout-session', + dialogTurns: [{ + id: 'turn-1', + sessionId: 'summary-layout-session', + userMessage: { + id: 'user-1', + content: 'Help', + timestamp: 900, + }, + modelRounds: [makeRound({ + id: 'active-summary', + items: [{ + id: 'summary-1', + type: 'thinking', + content: 'Inspecting the implementation', + reasoningKind: 'summary', + isStreaming: true, + isCollapsed: true, + timestamp: 1000, + status: 'streaming', + }], + isStreaming: true, + isComplete: false, + status: 'streaming', + renderHints: { disableExploreGrouping: true }, + })], + status: 'processing', + startTime: 900, + }], + }); + + const modelRound = sessionToVirtualItems(session) + .find((item): item is ModelRoundVirtualItem => item.type === 'model-round'); + + expect(modelRound?.layoutHints?.expandedThinkingItemIds).toEqual([]); + }); + it('appends a completion notice for abnormal completed turns', () => { const session = makeSession({ dialogTurns: [{ diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index 95675c4548..98dfeec988 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -7,7 +7,7 @@ import { create } from 'zustand'; import { useShallow } from 'zustand/react/shallow'; import { immer } from 'zustand/middleware/immer'; -import type { Session, DialogTurn, ModelRound, FlowItem, FlowToolItem, FlowUserSteeringItem, AnyFlowItem, TokenUsage } from '../types/flow-chat'; +import type { Session, DialogTurn, ModelRound, FlowItem, FlowThinkingItem, FlowToolItem, FlowUserSteeringItem, AnyFlowItem, TokenUsage } from '../types/flow-chat'; import { isCollapsibleTool, READ_TOOL_NAMES, @@ -486,6 +486,10 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { // One round is always exactly one virtual item. Splitting a completed // round into segments swaps a single virtual-item key for N new keys, // which remounts the visible assistant message and flashes the pane. + const trailingItem = round.items.at(-1); + const shouldExpandTrailingThinking = roundIndex === rounds.length - 1 + && trailingItem?.type === 'thinking' + && (trailingItem as FlowThinkingItem).reasoningKind !== 'summary'; items.push({ type: 'model-round', data: round, @@ -493,9 +497,8 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { isLastRound: roundIndex === rounds.length - 1, isTurnComplete, layoutHints: { - expandedThinkingItemIds: roundIndex === rounds.length - 1 - && round.items.at(-1)?.type === 'thinking' - ? [round.items.at(-1)!.id] + expandedThinkingItemIds: shouldExpandTrailingThinking + ? [trailingItem.id] : [], }, turnStartedAt: turn.startTime, diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss index 7c75cb4ef0..f8ec579809 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss @@ -63,6 +63,7 @@ } .thinking-label { + min-width: 0; font-size: var(--bf-appearance-token-flowchat-font-size-sm); font-weight: normal; color: var(--bf-appearance-token-color-text-muted); @@ -71,6 +72,24 @@ } } +/* Reasoning summaries use their latest provider part as a compact live status. */ +.flow-thinking-item.summary.collapsed { + .thinking-collapsed-header { + min-width: 0; + } + + .thinking-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.flow-thinking-item.summary .thinking-label { + color: var(--bf-appearance-token-color-text-secondary); + opacity: 0.9; +} + /* Rotate chevron when expanded */ .flow-thinking-item.expanded .thinking-chevron { transform: rotate(90deg); diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx new file mode 100644 index 0000000000..425ba68332 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.test.tsx @@ -0,0 +1,135 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FlowThinkingItem } from '../types/flow-chat'; +import { ModelThinkingDisplay } from './ModelThinkingDisplay'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: { count?: number }) => ({ + 'toolCards.think.thinking': 'Thinking...', + 'toolCards.think.thinkingProcess': 'Thinking Process', + 'toolCards.think.thinkingSummary': 'Thinking Summary', + 'toolCards.think.thinkingComplete': 'Thinking complete', + 'toolCards.think.thinkingCharacters': `Thought ${values?.count ?? 0} characters`, + })[key] ?? key, + }), +})); + +vi.mock('../hooks/useTypewriter', () => ({ + useTypewriter: (content: string) => ({ displayText: content, isRevealing: false }), +})); + +vi.mock('../hooks/typewriterRevealGateContext', () => ({ + useReportTypewriterReveal: () => {}, +})); + +vi.mock('./useToolCardHeightContract', () => ({ + useToolCardHeightContract: () => ({ + cardRootRef: { current: null }, + applyExpandedState: ( + current: boolean, + next: boolean, + setExpanded: (value: boolean) => void, + ) => { + if (current !== next) setExpanded(next); + }, + }), +})); + +vi.mock('@/component-library/components/Markdown/Markdown', () => ({ + Markdown: ({ content }: { content: string }) => ( +
{content}
+ ), +})); + +function summaryItem(content: string): FlowThinkingItem { + return { + id: 'summary-1', + type: 'thinking', + reasoningKind: 'summary', + content, + isStreaming: true, + isCollapsed: false, + timestamp: 1, + status: 'streaming', + }; +} + +describe('ModelThinkingDisplay reasoning summary', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal('ResizeObserver', class { + observe() {} + disconnect() {} + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it('defaults to a collapsed single-line preview of the latest summary part', async () => { + await act(async () => { + root.render(); + }); + + const panel = container.querySelector('[data-testid="chat-thinking-panel"]'); + const label = container.querySelector('[data-bf-part="label"]'); + expect(panel?.getAttribute('data-expanded')).toBe('false'); + expect(label?.textContent).toBe('Preparing the repair'); + expect(label?.textContent).not.toContain('characters'); + }); + + it('replaces the collapsed preview when a new summary part arrives', async () => { + await act(async () => { + root.render(); + }); + expect(container.querySelector('[data-bf-part="label"]')?.textContent).toBe('First part'); + + await act(async () => { + root.render(); + }); + expect(container.querySelector('[data-bf-part="label"]')?.textContent).toBe('Second part'); + }); + + it('keeps user expansion and renders the complete summary Markdown', async () => { + const content = '**First part**\n\n**Second part**'; + await act(async () => { + root.render(); + }); + + await act(async () => { + (container.querySelector('[data-testid="chat-thinking-toggle"]') as HTMLElement).click(); + }); + expect(container.querySelector('[data-testid="chat-thinking-panel"]') + ?.getAttribute('data-expanded')).toBe('true'); + expect(container.querySelector('[data-bf-part="label"]')?.textContent) + .toBe('Thinking Summary'); + expect(container.querySelector('[data-testid="thinking-markdown"]')?.textContent) + .toBe(content); + + await act(async () => { + root.render(); + }); + expect(container.querySelector('[data-testid="chat-thinking-panel"]') + ?.getAttribute('data-expanded')).toBe('true'); + }); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx index 3c39981dbc..2e8e7cd737 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx @@ -1,6 +1,8 @@ /** * Model thinking display component. - * Default expanded while this is still the active last step. + * Ordinary reasoning defaults expanded while this is still the active last + * step; reasoning summaries use their compact collapsed presentation by + * default. * If the component mounts after later content already appeared * (for example after a parent remount), start collapsed directly * to avoid a visible expand-then-collapse flash. @@ -22,6 +24,7 @@ import { isTailFollowDiagnosticsEnabled, noteTailFollowStep, } from '@/infrastructure/diagnostics/flowChatTailFollowDiagnostics'; +import { latestReasoningSummaryPreview } from '../utils/reasoningSummaryPresentation'; import { Markdown } from '@/component-library/components/Markdown/Markdown'; import './ModelThinkingDisplay.scss'; @@ -41,6 +44,7 @@ export const ModelThinkingDisplay: React.FC = ({ }) => { const { t } = useTranslation('flow-chat'); const { content, isStreaming, status } = thinkingItem; + const isSummary = thinkingItem.reasoningKind === 'summary'; const contentRef = useRef(null); const shouldFollowTailRef = useRef(true); const tailFollowPauseVersionRef = useRef(0); @@ -50,13 +54,16 @@ export const ModelThinkingDisplay: React.FC = ({ const touchScrollStartYRef = useRef(null); const isActive = isStreaming || status === 'streaming'; - const { displayText: displayContent, isRevealing } = useTypewriter(content, isActive); + const { displayText: displayContent, isRevealing } = useTypewriter( + isSummary ? '' : content, + isActive && !isSummary, + ); useReportTypewriterReveal(thinkingItem.id, isRevealing); - const shouldDefaultExpanded = forceExpanded || ( + const shouldDefaultExpanded = forceExpanded || (!isSummary && ( displayContext === 'subagent-projection' ? isActive || isLastItem : isLastItem - ); + )); const [isExpanded, setIsExpanded] = useState(shouldDefaultExpanded); const userToggledRef = useRef(false); @@ -76,7 +83,7 @@ export const ModelThinkingDisplay: React.FC = ({ // ends. Snapping to full `content` here would make the drain invisible // while `isRevealing` still holds the reveal gate, delaying the round // footer for no visible reason. - const renderedContent = isRevealing ? displayContent : content; + const renderedContent = !isSummary && isRevealing ? displayContent : content; // Cover the whole reveal with Markdown streaming mode so the Prism upgrade // does not land mid-drain. const isVisuallyStreaming = isActive || isRevealing; @@ -256,6 +263,11 @@ export const ModelThinkingDisplay: React.FC = ({ return t('toolCards.think.thinkingCharacters', { count: content.length }); }, [content, t]); + const summaryPreview = useMemo( + () => latestReasoningSummaryPreview(content), + [content], + ); + const handleToggleClick = () => { const nextExpanded = !isExpanded; userToggledRef.current = true; @@ -300,12 +312,17 @@ export const ModelThinkingDisplay: React.FC = ({ } }, [pauseTailFollowForUserScroll]); - const headerLabel = (isExpanded - ? (isActive ? t('toolCards.think.thinking') : t('toolCards.think.thinkingProcess')) - : contentLengthText).replace(/ /g, '\u00A0'); + const headerLabel = isSummary + ? (isExpanded + ? t('toolCards.think.thinkingSummary') + : summaryPreview || t('toolCards.think.thinkingSummary')) + : (isExpanded + ? (isActive ? t('toolCards.think.thinking') : t('toolCards.think.thinkingProcess')) + : contentLengthText).replace(/ /g, '\u00A0'); const wrapperClassName = [ 'flow-thinking-item', + isSummary ? 'summary' : 'reasoning', isExpanded ? 'expanded' : 'collapsed', ].filter(Boolean).join(' '); @@ -317,6 +334,7 @@ export const ModelThinkingDisplay: React.FC = ({ data-status={status} data-streaming={isActive ? 'true' : 'false'} data-expanded={isExpanded ? 'true' : 'false'} + data-reasoning-kind={thinkingItem.reasoningKind ?? 'reasoning'} className={wrapperClassName} data-bf-component="model-thinking-display" data-bf-part="root" data-bf-context={displayContext} data-bf-state={[isExpanded && 'expanded', isVisuallyStreaming && 'streaming'].filter(Boolean).join(' ')}>
= ({ onClick={handleToggleClick} > - {headerLabel} + + {headerLabel} +
{ + it('shows only the latest summary part without Markdown markers', () => { + expect(latestReasoningSummaryPreview( + '**Inspecting the Responses stream**\n\n**Preparing the focused repair**', + )).toBe('Preparing the focused repair'); + }); + + it('collapses multiline Markdown in the latest part to one line', () => { + expect(latestReasoningSummaryPreview( + '**Earlier**\n\n### Latest\n- first detail\n- second detail', + )).toBe('Latest first detail second detail'); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/reasoningSummaryPresentation.ts b/src/web-ui/src/flow_chat/utils/reasoningSummaryPresentation.ts new file mode 100644 index 0000000000..c9362e134b --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/reasoningSummaryPresentation.ts @@ -0,0 +1,31 @@ +function markdownToSingleLine(markdown: string): string { + return markdown + .replace(/```[\s\S]*?```/g, match => match.replace(/```[^\n]*\n?|```/g, ' ')) + .replace(/`([^`]*)`/g, '$1') + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/^\s{0,3}#{1,6}\s+/gm, '') + .replace(/^\s{0,3}>\s?/gm, '') + .replace(/^\s*[-*+]\s+/gm, '') + .replace(/^\s*\d+\.\s+/gm, '') + .replace(/[*_~]{1,3}/g, '') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Return the latest OpenAI reasoning-summary part for the collapsed card. + * The Responses adapter restores `summary_index` boundaries as blank lines; + * the expanded card keeps the original Markdown while this preview is plain, + * single-line text. + */ +export function latestReasoningSummaryPreview(content: string): string { + const latestPart = content + .split(/\n\s*\n/) + .map(part => part.trim()) + .filter(Boolean) + .at(-1); + + return latestPart ? markdownToSingleLine(latestPart) : ''; +} diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index e06904ba0e..7f2607b58c 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1868,6 +1868,7 @@ "thinking": "Thinking...", "preparing": "Preparing to think", "thinkingProcess": "Thinking Process", + "thinkingSummary": "Thinking Summary", "thinkingComplete": "Thinking complete", "thinkingCharacters": "Thought {{count}} characters" }, diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index e1d53a636c..81a66360b2 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1868,6 +1868,7 @@ "thinking": "思考中...", "preparing": "准备思考", "thinkingProcess": "思考过程", + "thinkingSummary": "思考总结", "thinkingComplete": "已完成思考", "thinkingCharacters": "思考了 {{count}} 字符" }, diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 5e5eb244a1..ceb4e86114 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1868,6 +1868,7 @@ "thinking": "思考中...", "preparing": "準備思考", "thinkingProcess": "思考過程", + "thinkingSummary": "思考總結", "thinkingComplete": "已完成思考", "thinkingCharacters": "思考了 {{count}} 字符" }, diff --git a/src/web-ui/src/shared/types/session-history.ts b/src/web-ui/src/shared/types/session-history.ts index a56379d82a..cb1e9c67b8 100644 --- a/src/web-ui/src/shared/types/session-history.ts +++ b/src/web-ui/src/shared/types/session-history.ts @@ -289,8 +289,9 @@ export interface TextItemData { } export interface ThinkingItemData { - id: string; - content: string; + id: string; + content: string; + reasoningKind?: 'reasoning' | 'summary'; isStreaming: boolean; isCollapsed: boolean; timestamp: number;