From c55bf2f06f08063d33bd2144548bbc9284fa0ce4 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Wed, 22 Jul 2026 17:28:38 +0800 Subject: [PATCH] feat(flow-chat): expose retry attempt diagnostics - Preserve original provider/transport errors, raw tool arguments, and JSON parser errors for retried attempts - Project diagnostics through events, session persistence, and history restoration - Add on-demand, copyable diagnostic details for collapsed historical attempts - Cover diagnostic merging, ordering, restoration, and event serialization --- src/apps/desktop/src/api/agentic_api.rs | 3 + .../src/agentic/coordination/coordinator.rs | 2 + .../assembly/core/src/agentic/core/message.rs | 5 + .../assembly/core/src/agentic/events/types.rs | 3 +- .../src/agentic/execution/execution_engine.rs | 2 + .../src/agentic/execution/round_executor.rs | 150 +++++++++++++- .../core/src/agentic/insights/collector.rs | 2 + .../core/src/agentic/memories/transcript.rs | 1 + .../core/src/agentic/persistence/manager.rs | 1 + .../agentic/session/compression/compressor.rs | 1 + .../session/compression/fallback/tests.rs | 3 + .../src/agentic/session/session_manager.rs | 4 + .../agentic/tools/pipeline/state_manager.rs | 1 + .../agentic/tools/pipeline/tool_pipeline.rs | 1 + .../src/agentic/tools/tool_context_runtime.rs | 1 + .../core/src/service/session_usage/service.rs | 2 + .../core/src/service_agent_runtime.rs | 1 + src/crates/contracts/events/src/agentic.rs | 38 ++++ .../events/src/frontend_projection.rs | 71 ++++++- src/crates/contracts/events/src/lib.rs | 4 +- src/crates/execution/agent-stream/src/lib.rs | 5 + .../agent-stream/src/tool_call_accumulator.rs | 35 +++- .../interfaces/acp/src/runtime/replay.rs | 1 + src/crates/services/services-core/Cargo.toml | 1 + .../services-core/src/session/lineage.rs | 1 + .../services-core/src/session/types.rs | 7 + .../tests/session_metadata_contracts.rs | 1 + .../components/modern/ModelRoundItem.scss | 97 +++++++++ .../components/modern/ModelRoundItem.tsx | 149 ++++++++++++-- .../services/AgenticEventListener.ts | 10 + .../flow-chat-manager/EventHandlerModule.ts | 52 ++++- .../flow-chat-manager/PersistenceModule.ts | 1 + .../src/flow_chat/store/FlowChatStore.test.ts | 188 +++++++++++++++++- .../src/flow_chat/store/FlowChatStore.ts | 67 ++++++- src/web-ui/src/flow_chat/types/flow-chat.ts | 4 + .../api/service-api/AgentAPI.ts | 17 +- src/web-ui/src/locales/en-US/flow-chat.json | 20 ++ src/web-ui/src/locales/zh-CN/flow-chat.json | 20 ++ src/web-ui/src/locales/zh-TW/flow-chat.json | 20 ++ .../src/shared/types/session-history.ts | 16 ++ 40 files changed, 977 insertions(+), 31 deletions(-) diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 12e8a205fa..948dca919c 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -3216,6 +3216,7 @@ mod tests { first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), @@ -3297,6 +3298,7 @@ mod tests { first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), @@ -3359,6 +3361,7 @@ mod tests { first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 78ae1750ee..af68709f13 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -1229,6 +1229,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), @@ -1305,6 +1306,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: Some("context_compression".to_string()), token_details: None, status: "error".to_string(), diff --git a/src/crates/assembly/core/src/agentic/core/message.rs b/src/crates/assembly/core/src/agentic/core/message.rs index 94e21aa7c8..d5f9053fc7 100644 --- a/src/crates/assembly/core/src/agentic/core/message.rs +++ b/src/crates/assembly/core/src/agentic/core/message.rs @@ -734,6 +734,7 @@ mod tests { arguments: json!({ "path": "src/main.rs" }), raw_arguments: Some(r#"{"path":"src/main.rs" "line_end":4}"#.to_string()), is_error: false, + parse_error: None, recovered_from_truncation: false, repair_kind: ToolArgumentRepairKind::PermissiveNormalToolJsonRepair, }); @@ -759,6 +760,9 @@ pub struct ToolCall { /// Record whether tool parameters are valid #[serde(default)] pub is_error: bool, + /// Original JSON parser error when the provider emitted invalid arguments. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parse_error: Option, /// True when the raw JSON arguments were truncated mid-stream and we /// successfully repaired them. Downstream consumers can flag this to the /// model so it understands the content may be incomplete. @@ -786,6 +790,7 @@ impl From for ToolCall { arguments: tool_call.arguments, raw_arguments: tool_call.raw_arguments, is_error: tool_call.is_error, + parse_error: tool_call.parse_error, recovered_from_truncation: tool_call.recovered_from_truncation, repair_kind: tool_call.repair_kind, } diff --git a/src/crates/assembly/core/src/agentic/events/types.rs b/src/crates/assembly/core/src/agentic/events/types.rs index 53c93d5043..4c7fa66822 100644 --- a/src/crates/assembly/core/src/agentic/events/types.rs +++ b/src/crates/assembly/core/src/agentic/events/types.rs @@ -10,7 +10,8 @@ pub use bitfun_events::agentic::ErrorCategory; pub use bitfun_events::{ AgenticEvent as BaseAgenticEvent, AgenticEventEnvelope as EventEnvelope, AgenticEventPriority as EventPriority, DeepReviewQueueReason, DeepReviewQueueState, - DeepReviewQueueStatus, SubagentParentInfo, ToolEventData, + DeepReviewQueueStatus, ModelRoundAttemptDiagnostic, ModelRoundAttemptToolDiagnostic, + SubagentParentInfo, ToolEventData, }; // ============ Core layer AgenticEvent extension ============ diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index e0efb55542..9869ff08c7 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -4428,6 +4428,7 @@ mod tests { arguments: json!({ "job_id": "job-1" }), raw_arguments: None, is_error: false, + parse_error: None, recovered_from_truncation: false, repair_kind: Default::default(), }]; @@ -4456,6 +4457,7 @@ mod tests { arguments: json!({ "path": "missing.txt" }), raw_arguments: None, is_error: false, + parse_error: None, recovered_from_truncation: false, repair_kind: Default::default(), }]; 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 23a16d4032..2958d5235a 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -6,7 +6,10 @@ use super::model_exchange_trace::prepare_model_exchange_trace; use super::stream_processor::{StreamProcessOptions, StreamProcessor, StreamResult}; use super::types::{FinishReason, RoundContext, RoundResult}; use crate::agentic::core::{Message, ToolCall}; -use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue, ToolEventData}; +use crate::agentic::events::{ + AgenticEvent, EventPriority, EventQueue, ModelRoundAttemptDiagnostic, + ModelRoundAttemptToolDiagnostic, ToolEventData, +}; use crate::agentic::memories::{ parse_bitfun_memory_citation, parse_bitfun_memory_citation_payloads, strip_bitfun_memory_citations, @@ -62,6 +65,56 @@ impl RoundExecutor { !text.trim().is_empty() } + fn retry_diagnostic( + attempt_id: String, + attempt_index: u32, + category: &str, + raw_error: Option, + tool_calls: &[ToolCall], + ) -> ModelRoundAttemptDiagnostic { + ModelRoundAttemptDiagnostic { + attempt_id, + attempt_index, + category: category.to_string(), + raw_error, + tool_calls: tool_calls + .iter() + .filter(|tool_call| !tool_call.is_valid()) + .map(|tool_call| ModelRoundAttemptToolDiagnostic { + tool_id: (!tool_call.tool_id.is_empty()).then(|| tool_call.tool_id.clone()), + tool_name: (!tool_call.tool_name.is_empty()) + .then(|| tool_call.tool_name.clone()), + raw_arguments: tool_call.raw_arguments.clone(), + validation_error: tool_call.parse_error.clone(), + }) + .collect(), + } + } + + async fn record_retry_diagnostic( + &self, + context: &RoundContext, + round_id: &str, + attempt_id: String, + attempt_index: u32, + category: &str, + raw_error: Option, + tool_calls: &[ToolCall], + ) { + let diagnostic = + Self::retry_diagnostic(attempt_id, attempt_index, category, raw_error, tool_calls); + self.emit_event( + AgenticEvent::ModelRoundAttemptSuperseded { + session_id: context.session_id.clone(), + turn_id: context.dialog_turn_id.clone(), + round_id: round_id.to_string(), + diagnostic: diagnostic.clone(), + }, + EventPriority::High, + ) + .await; + } + fn parsed_memory_citation_from_stream_result( stream_result: &StreamResult, ) -> Option { @@ -248,6 +301,16 @@ impl RoundExecutor { if Self::is_transient_network_error(&err_msg) && attempt_index < max_attempts - 1 { + self.record_retry_diagnostic( + &context, + &round_id, + attempt_id.clone(), + attempt_number, + "transient_request_error", + Some(err_msg.clone()), + &[], + ) + .await; let delay_ms = Self::retry_delay_ms_for_error(attempt_index, &err_msg); warn!( "Retrying AI request after connection failure: session_id={}, round_id={}, attempt={}/{}, delay_ms={}, error={}", @@ -347,6 +410,16 @@ impl RoundExecutor { && attempt_index < max_attempts - 1 && Self::is_transient_network_error(&err_msg) { + self.record_retry_diagnostic( + &context, + &round_id, + attempt_id.clone(), + attempt_number, + "interrupted_tool_arguments", + Some(err_msg.clone()), + &result.tool_calls, + ) + .await; Self::complete_model_exchange_trace( trace_config.as_ref(), trace_handle.as_ref(), @@ -438,6 +511,16 @@ impl RoundExecutor { && Self::is_transient_network_error(partial_recovery_reason) && attempt_index < max_attempts - 1 { + self.record_retry_diagnostic( + &context, + &round_id, + attempt_id.clone(), + attempt_number, + "partial_stream_error", + Some(partial_recovery_reason.to_string()), + &result.tool_calls, + ) + .await; Self::complete_model_exchange_trace( trace_config.as_ref(), trace_handle.as_ref(), @@ -464,6 +547,16 @@ impl RoundExecutor { if Self::is_invalid_tool_only_without_text(&result) { let err_msg = "Provider returned only invalid tool arguments".to_string(); if attempt_index < max_attempts - 1 { + self.record_retry_diagnostic( + &context, + &round_id, + attempt_id.clone(), + attempt_number, + "invalid_tool_arguments", + None, + &result.tool_calls, + ) + .await; Self::complete_model_exchange_trace( trace_config.as_ref(), trace_handle.as_ref(), @@ -513,6 +606,16 @@ impl RoundExecutor { } if no_effective_output && attempt_index < max_attempts - 1 { + self.record_retry_diagnostic( + &context, + &round_id, + attempt_id.clone(), + attempt_number, + "no_effective_output", + None, + &[], + ) + .await; Self::complete_model_exchange_trace( trace_config.as_ref(), trace_handle.as_ref(), @@ -569,6 +672,16 @@ impl RoundExecutor { ) .await; if can_retry { + self.record_retry_diagnostic( + &context, + &round_id, + attempt_id.clone(), + attempt_number, + "transient_stream_error", + Some(err_msg.clone()), + &[], + ) + .await; let delay_ms = Self::retry_delay_ms_for_error(attempt_index, &err_msg); warn!( "Retrying stream after transient error with no effective output: session_id={}, round_id={}, attempt={}/{}, delay_ms={}, error={}", @@ -1680,6 +1793,7 @@ mod tests { arguments: json!({}), raw_arguments: Some("{\"command\":".to_string()), is_error: true, + parse_error: Some("EOF while parsing an object".to_string()), recovered_from_truncation: false, repair_kind: Default::default(), }], @@ -1736,11 +1850,43 @@ mod tests { "tool_name": "Bash", "arguments": {}, "raw_arguments": "{\"command\":", - "is_error": true + "is_error": true, + "parse_error": "EOF while parsing an object" }])) ); } + #[test] + fn retry_diagnostic_preserves_invalid_tool_arguments_and_parser_error() { + let diagnostic = RoundExecutor::retry_diagnostic( + "round-1:attempt:1".to_string(), + 1, + "invalid_tool_arguments", + None, + &[ToolCall { + tool_id: "tool-1".to_string(), + tool_name: "Bash".to_string(), + arguments: json!({}), + raw_arguments: Some("{\"command\":".to_string()), + is_error: true, + parse_error: Some("EOF while parsing an object".to_string()), + recovered_from_truncation: false, + repair_kind: Default::default(), + }], + ); + + assert_eq!(diagnostic.category, "invalid_tool_arguments"); + assert_eq!(diagnostic.tool_calls.len(), 1); + assert_eq!( + diagnostic.tool_calls[0].raw_arguments.as_deref(), + Some("{\"command\":") + ); + assert_eq!( + diagnostic.tool_calls[0].validation_error.as_deref(), + Some("EOF while parsing an object") + ); + } + #[test] fn error_trace_response_without_stream_result_stays_empty() { let trace = RoundExecutor::error_trace_response("error", "request failed".to_string()); diff --git a/src/crates/assembly/core/src/agentic/insights/collector.rs b/src/crates/assembly/core/src/agentic/insights/collector.rs index f5576043c3..f466ca8de3 100644 --- a/src/crates/assembly/core/src/agentic/insights/collector.rs +++ b/src/crates/assembly/core/src/agentic/insights/collector.rs @@ -658,6 +658,7 @@ fn rebuild_messages_from_turns(turns: &[DialogTurnData]) -> Vec { arguments: ti.tool_call.input.clone(), raw_arguments: None, is_error: false, + parse_error: None, recovered_from_truncation: false, repair_kind: Default::default(), }) @@ -1129,6 +1130,7 @@ mod tests { first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), diff --git a/src/crates/assembly/core/src/agentic/memories/transcript.rs b/src/crates/assembly/core/src/agentic/memories/transcript.rs index 5f87ef8a2e..6ec58cb7c1 100644 --- a/src/crates/assembly/core/src/agentic/memories/transcript.rs +++ b/src/crates/assembly/core/src/agentic/memories/transcript.rs @@ -387,6 +387,7 @@ mod tests { first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 272964837a..2413f9e7d5 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -3438,6 +3438,7 @@ mod tests { first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), diff --git a/src/crates/assembly/core/src/agentic/session/compression/compressor.rs b/src/crates/assembly/core/src/agentic/session/compression/compressor.rs index fa622dc39c..420e26e5b4 100644 --- a/src/crates/assembly/core/src/agentic/session/compression/compressor.rs +++ b/src/crates/assembly/core/src/agentic/session/compression/compressor.rs @@ -611,6 +611,7 @@ mod tests { }), raw_arguments: None, is_error: false, + parse_error: None, recovered_from_truncation: false, repair_kind: Default::default(), }], diff --git a/src/crates/assembly/core/src/agentic/session/compression/fallback/tests.rs b/src/crates/assembly/core/src/agentic/session/compression/fallback/tests.rs index 1a117c139d..1d60861c5f 100644 --- a/src/crates/assembly/core/src/agentic/session/compression/fallback/tests.rs +++ b/src/crates/assembly/core/src/agentic/session/compression/fallback/tests.rs @@ -33,6 +33,7 @@ fn clears_tool_results_from_compressed_history() { }), raw_arguments: None, is_error: false, + parse_error: None, recovered_from_truncation: false, repair_kind: Default::default(), }], @@ -166,6 +167,7 @@ fn groups_consecutive_assistant_messages_under_single_role_header() { }), raw_arguments: None, is_error: false, + parse_error: None, recovered_from_truncation: false, repair_kind: Default::default(), }], @@ -182,6 +184,7 @@ fn groups_consecutive_assistant_messages_under_single_role_header() { }), raw_arguments: None, is_error: false, + parse_error: None, recovered_from_truncation: false, repair_kind: Default::default(), }], 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 db846d4b9c..abf42e4bdf 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -5206,6 +5206,7 @@ impl SessionManager { first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), @@ -5371,6 +5372,7 @@ impl SessionManager { first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), @@ -6176,6 +6178,7 @@ mod tests { }), raw_arguments: None, is_error: false, + parse_error: None, recovered_from_truncation: false, repair_kind: Default::default(), }], @@ -8374,6 +8377,7 @@ mod tests { first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs index 38c35e6ce4..f2bef41a47 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs @@ -300,6 +300,7 @@ mod tests { arguments: serde_json::json!({}), raw_arguments: None, is_error: false, + parse_error: None, recovered_from_truncation: false, repair_kind: Default::default(), }, diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index b4a3806804..f7443a791f 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -2639,6 +2639,7 @@ mod tests { arguments: json!({ "path": "src/main.rs" }), raw_arguments: None, is_error: false, + parse_error: None, recovered_from_truncation: false, repair_kind: Default::default(), } diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index 0a152afced..6cb879d343 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -1441,6 +1441,7 @@ mod task_context_tests { arguments: json!({ "url": "https://example.com" }), raw_arguments: None, is_error: false, + parse_error: None, recovered_from_truncation: false, repair_kind: Default::default(), }, diff --git a/src/crates/assembly/core/src/service/session_usage/service.rs b/src/crates/assembly/core/src/service/session_usage/service.rs index e394272ce2..8cb4561254 100644 --- a/src/crates/assembly/core/src/service/session_usage/service.rs +++ b/src/crates/assembly/core/src/service/session_usage/service.rs @@ -2690,6 +2690,7 @@ mod tests { first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), @@ -2730,6 +2731,7 @@ mod tests { first_visible_output_ms: Some(8), stream_duration_ms: Some(duration_ms.saturating_sub(10)), attempt_count: Some(1), + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 3e8ee435f3..ccb407375f 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -2098,6 +2098,7 @@ mod tests { first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index f9a97d2681..ba4392d1de 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -241,6 +241,14 @@ pub enum AgenticEvent { effective_model_name: String, }, + /// Emitted as soon as an automatic retry supersedes one model attempt. + ModelRoundAttemptSuperseded { + session_id: String, + turn_id: String, + round_id: String, + diagnostic: ModelRoundAttemptDiagnostic, + }, + ModelRoundCompleted { session_id: String, turn_id: String, @@ -345,6 +353,34 @@ pub enum AgenticEvent { }, } +/// Diagnostic evidence collected for an attempt that was superseded by an +/// automatic retry. Raw provider/transport text is intentionally preserved so +/// the desktop surface can expose it on demand without changing retry policy. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelRoundAttemptDiagnostic { + pub attempt_id: String, + pub attempt_index: u32, + pub category: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_error: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_calls: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelRoundAttemptToolDiagnostic { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_arguments: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub validation_error: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ToolEventIdentity { pub tool_id: String, @@ -560,6 +596,7 @@ impl AgenticEvent { | Self::DialogTurnCancelled { session_id, .. } | Self::DialogTurnFailed { session_id, .. } | Self::ModelRoundStarted { session_id, .. } + | Self::ModelRoundAttemptSuperseded { session_id, .. } | Self::TextChunk { session_id, .. } | Self::ThinkingChunk { session_id, .. } | Self::ModelRoundCompleted { session_id, .. } @@ -590,6 +627,7 @@ impl AgenticEvent { | Self::TextChunk { .. } | Self::ThinkingChunk { .. } | Self::ModelRoundStarted { .. } + | Self::ModelRoundAttemptSuperseded { .. } | Self::ModelRoundCompleted { .. } | Self::TokenUsageUpdated { .. } | Self::DialogTurnCompleted { .. } diff --git a/src/crates/contracts/events/src/frontend_projection.rs b/src/crates/contracts/events/src/frontend_projection.rs index 09d04c6813..6bdedf7a95 100644 --- a/src/crates/contracts/events/src/frontend_projection.rs +++ b/src/crates/contracts/events/src/frontend_projection.rs @@ -421,6 +421,20 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option Some(AgenticFrontendEvent::new( + "agentic://model-round-attempt-superseded", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + "diagnostic": diagnostic, + }), + )), AgenticEvent::UserSteeringInjected { session_id, turn_id, @@ -446,7 +460,10 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option, /// Record whether tool parameters are valid. pub is_error: bool, + /// Original JSON parser error when the provider emitted invalid arguments. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parse_error: Option, /// True when truncated raw JSON arguments were repaired into a partial tool call. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub recovered_from_truncation: bool, @@ -426,6 +429,7 @@ impl StreamContext { raw_arguments: (!finalized.raw_arguments.is_empty()) .then_some(finalized.raw_arguments.clone()), is_error: finalized.is_error, + parse_error: finalized.parse_error.clone(), recovered_from_truncation: finalized.recovered_from_truncation, repair_kind: finalized.repair_kind, }); @@ -1267,6 +1271,7 @@ mod tests { arguments: json!({}), raw_arguments: None, is_error: false, + parse_error: None, recovered_from_truncation: false, repair_kind: Default::default(), }], diff --git a/src/crates/execution/agent-stream/src/tool_call_accumulator.rs b/src/crates/execution/agent-stream/src/tool_call_accumulator.rs index fba7cc32c3..0c3fd748cb 100644 --- a/src/crates/execution/agent-stream/src/tool_call_accumulator.rs +++ b/src/crates/execution/agent-stream/src/tool_call_accumulator.rs @@ -107,6 +107,8 @@ pub struct FinalizedToolCall { pub raw_arguments: String, pub arguments: Value, pub is_error: bool, + /// Original JSON parser error when the provider emitted invalid arguments. + pub parse_error: Option, pub repair_kind: ToolArgumentRepairKind, /// True when the raw stream produced unparseable JSON (e.g. truncated by /// `max_tokens`) and we successfully patched the trailing brackets/strings @@ -403,9 +405,10 @@ impl PendingToolCall { self.early_detected_emitted = false; let parsed_arguments = Self::parse_arguments(&tool_name, &raw_arguments); - let (arguments, is_error, repair_kind) = match parsed_arguments { - Ok(value) => (value, false, ToolArgumentRepairKind::None), + let (arguments, is_error, repair_kind, parse_error) = match parsed_arguments { + Ok(value) => (value, false, ToolArgumentRepairKind::None, None), Err(parse_err) => { + let original_parse_error = parse_err.message.clone(); let write_tail_repair = is_write_like_tool_name(&tool_name) .then(|| repair_truncated_json(&raw_arguments)) .flatten() @@ -421,7 +424,12 @@ impl PendingToolCall { parse_err.is_eof, options.completion ); - (value, false, ToolArgumentRepairKind::WriteTailClosure) + ( + value, + false, + ToolArgumentRepairKind::WriteTailClosure, + Some(original_parse_error), + ) } else if options.allow_normal_tool_json_repair && options.completion.permits_normal_tool_json_repair() { @@ -446,6 +454,7 @@ impl PendingToolCall { value, false, ToolArgumentRepairKind::PermissiveNormalToolJsonRepair, + Some(original_parse_error), ) } None => { @@ -459,7 +468,12 @@ impl PendingToolCall { parse_err.category, parse_err.is_eof ); - (json!({}), true, ToolArgumentRepairKind::None) + ( + json!({}), + true, + ToolArgumentRepairKind::None, + Some(original_parse_error), + ) } } } else { @@ -474,7 +488,12 @@ impl PendingToolCall { parse_err.is_eof, options.completion ); - (json!({}), true, ToolArgumentRepairKind::None) + ( + json!({}), + true, + ToolArgumentRepairKind::None, + Some(original_parse_error), + ) } } }; @@ -487,6 +506,7 @@ impl PendingToolCall { is_error, repair_kind, recovered_from_truncation: repair_kind.is_write_tail_closure(), + parse_error, }) } } @@ -645,6 +665,11 @@ mod tests { assert_eq!(finalized.arguments, json!({})); assert!(finalized.is_error); + assert_eq!(finalized.raw_arguments, "{\"a\":"); + assert!(finalized + .parse_error + .as_deref() + .is_some_and(|error| error.contains("EOF"))); } #[test] diff --git a/src/crates/interfaces/acp/src/runtime/replay.rs b/src/crates/interfaces/acp/src/runtime/replay.rs index d2dc7f6e67..3f4412a35a 100644 --- a/src/crates/interfaces/acp/src/runtime/replay.rs +++ b/src/crates/interfaces/acp/src/runtime/replay.rs @@ -345,6 +345,7 @@ mod tests { first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index 39a0ccf354..62b30af328 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["rlib"] anyhow = { workspace = true, optional = true } async-trait = { workspace = true, optional = true } bitfun-core-types = { path = "../../contracts/core-types" } +bitfun-events = { path = "../../contracts/events" } bitfun-runtime-ports = { path = "../../contracts/runtime-ports", optional = true } tokio = { workspace = true } serde = { workspace = true } diff --git a/src/crates/services/services-core/src/session/lineage.rs b/src/crates/services/services-core/src/session/lineage.rs index 36ae28663e..ede951a252 100644 --- a/src/crates/services/services-core/src/session/lineage.rs +++ b/src/crates/services/services-core/src/session/lineage.rs @@ -454,6 +454,7 @@ mod tests { first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index c850079f55..a7ea7b05e7 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -2,6 +2,7 @@ use bitfun_core_types::ToolImageAttachment; use bitfun_core_types::{SessionContinuationPolicy, SessionKind}; +use bitfun_events::ModelRoundAttemptDiagnostic; use serde::{Deserialize, Serialize}; pub const SESSION_STORAGE_SCHEMA_VERSION: u32 = 2; @@ -548,6 +549,12 @@ pub struct ModelRoundData { alias = "attempt_count" )] pub attempt_count: Option, + #[serde( + default, + skip_serializing_if = "Vec::is_empty", + alias = "attempt_diagnostics" + )] + pub attempt_diagnostics: Vec, #[serde( default, skip_serializing_if = "Option::is_none", diff --git a/src/crates/services/services-core/tests/session_metadata_contracts.rs b/src/crates/services/services-core/tests/session_metadata_contracts.rs index 1c5480eb9b..3297a3981f 100644 --- a/src/crates/services/services-core/tests/session_metadata_contracts.rs +++ b/src/crates/services/services-core/tests/session_metadata_contracts.rs @@ -95,6 +95,7 @@ fn round(turn_id: &str, text_count: usize, tool_count: usize) -> ModelRoundData first_visible_output_ms: None, stream_duration_ms: None, attempt_count: None, + attempt_diagnostics: vec![], failure_category: None, token_details: None, status: "completed".to_string(), diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss index 5b9af480c7..d35bf76703 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss @@ -158,6 +158,10 @@ } .model-round-item__retry-attempt-label { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.25rem; margin-bottom: 0.35rem; color: var(--color-text-muted); font-size: 11px; @@ -166,6 +170,99 @@ letter-spacing: 0.04em; } +.model-round-item__attempt-diagnostic-toggle, +.model-round-item__attempt-diagnostic-copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + + &:hover { + background: var(--color-overlay-white-08); + color: var(--color-text-primary); + } + + &:focus-visible { + outline: 2px solid var(--border-accent); + outline-offset: 1px; + } +} + +.model-round-item__attempt-diagnostic-toggle { + color: var(--color-warning); +} + +.model-round-item__attempt-diagnostic-details { + display: block; + flex: 0 0 100%; + width: min(100%, 48rem); + margin: 0.2rem 0 0.45rem; + padding: 0.55rem 0.65rem; + border: 1px solid color-mix(in srgb, var(--border-base) 72%, transparent); + border-radius: 4px; + background: var(--color-bg-secondary); + color: var(--color-text-secondary); + font-size: 12px; + font-weight: 400; + letter-spacing: 0; + line-height: 18px; + text-transform: none; +} + +.model-round-item__attempt-diagnostic-category, +.model-round-item__attempt-diagnostic-section, +.model-round-item__attempt-diagnostic-tool-title { + display: block; +} + +.model-round-item__attempt-diagnostic-category { + margin-bottom: 0.35rem; + color: var(--color-text-primary); + font-weight: 600; +} + +.model-round-item__attempt-diagnostic-section + .model-round-item__attempt-diagnostic-section { + margin-top: 0.55rem; +} + +.model-round-item__attempt-diagnostic-tool-title { + margin-bottom: 0.2rem; + color: var(--color-text-primary); + font-weight: 600; +} + +.model-round-item__attempt-diagnostic-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + min-width: 0; + color: var(--color-text-muted); + font-size: 11px; +} + +.model-round-item__attempt-diagnostic-section pre { + max-height: 16rem; + margin: 0.2rem 0 0.35rem; + padding: 0.45rem; + overflow: auto; + border-radius: 3px; + background: var(--color-bg-tertiary); + color: var(--color-text-primary); + font-family: var(--font-family-mono); + font-size: 11px; + line-height: 16px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + .model-round-item__action-btn { display: flex; align-items: center; diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index 9831e3b583..abe8a25772 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -9,8 +9,8 @@ import React, { useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Copy, Check } from 'lucide-react'; -import type { ModelRound, ModelRoundAttempt, FlowItem, FlowTextItem, FlowToolItem, FlowThinkingItem, TokenUsage, ToolRejectOptions } from '../../types/flow-chat'; +import { Copy, Check, CircleAlert } from 'lucide-react'; +import type { ModelRound, ModelRoundAttempt, ModelRoundAttemptDiagnostic, FlowItem, FlowTextItem, FlowToolItem, FlowThinkingItem, TokenUsage, ToolRejectOptions } from '../../types/flow-chat'; import { useI18n } from '@/infrastructure/i18n'; import { FlowTextBlock } from '../FlowTextBlock'; import { FlowToolCard } from '../FlowToolCard'; @@ -155,6 +155,123 @@ function sortRoundAttempts(attempts: ModelRoundAttempt[]): ModelRoundAttempt[] { return [...attempts].sort((left, right) => left.index - right.index); } +function attemptDiagnosticCategoryLabel( + diagnostic: ModelRoundAttemptDiagnostic, + t: (key: string, options?: Record) => string, +): string { + switch (diagnostic.category) { + case 'transient_request_error': + return t('modelRound.attemptDiagnostics.categories.transientRequestError'); + case 'interrupted_tool_arguments': + return t('modelRound.attemptDiagnostics.categories.interruptedToolArguments'); + case 'partial_stream_error': + return t('modelRound.attemptDiagnostics.categories.partialStreamError'); + case 'invalid_tool_arguments': + return t('modelRound.attemptDiagnostics.categories.invalidToolArguments'); + case 'no_effective_output': + return t('modelRound.attemptDiagnostics.categories.noEffectiveOutput'); + case 'transient_stream_error': + return t('modelRound.attemptDiagnostics.categories.transientStreamError'); + default: + return t('modelRound.attemptDiagnostics.categories.unknown', { category: diagnostic.category }); + } +} + +const AttemptDiagnosticDetails: React.FC<{ diagnostic: ModelRoundAttemptDiagnostic }> = ({ diagnostic }) => { + const { t } = useTranslation('flow-chat'); + const [isOpen, setIsOpen] = useState(false); + const [copiedValue, setCopiedValue] = useState(null); + + const copyValue = useCallback(async (value: string, valueKey: string) => { + try { + await navigator.clipboard.writeText(value); + setCopiedValue(valueKey); + window.setTimeout(() => setCopiedValue(current => current === valueKey ? null : current), 2000); + } catch (error) { + log.error('Failed to copy attempt diagnostic value', error); + } + }, []); + + const renderCopyButton = (value: string, valueKey: string) => ( + + + + ); + + const detailsId = `attempt-diagnostic-${diagnostic.attemptId}`; + + return ( + <> + + + + + {isOpen && ( +
+
+ {attemptDiagnosticCategoryLabel(diagnostic, t)} +
+ + {diagnostic.rawError && ( +
+
+ {t('modelRound.attemptDiagnostics.providerError')} + {renderCopyButton(diagnostic.rawError, 'raw-error')} +
+
{diagnostic.rawError}
+
+ )} + + {(diagnostic.toolCalls ?? []).map((toolCall, index) => { + const toolLabel = toolCall.toolName || toolCall.toolId || t('modelRound.attemptDiagnostics.unknownTool'); + return ( +
+
+ {t('modelRound.attemptDiagnostics.toolArguments', { name: toolLabel })} +
+ {toolCall.rawArguments && ( + <> +
+ {t('modelRound.attemptDiagnostics.rawArguments')} + {renderCopyButton(toolCall.rawArguments, `raw-arguments:${index}`)} +
+
{toolCall.rawArguments}
+ + )} + {toolCall.validationError && ( + <> +
+ {t('modelRound.attemptDiagnostics.validationError')} + {renderCopyButton(toolCall.validationError, `validation-error:${index}`)} +
+
{toolCall.validationError}
+ + )} +
+ ); + })} +
+ )} + + ); +}; + function useTaskCollapsed(toolId: string): boolean { const [isCollapsed, setIsCollapsed] = useState(() => taskCollapseStateManager.isCollapsed(toolId) @@ -280,15 +397,15 @@ export const ModelRoundItem = React.memo( () => sortRoundAttempts(round.attempts ?? []), [round.attempts] ); - const olderAttempts = attempts.length > 1 ? attempts.slice(0, -1) : []; - const latestAttempt = attempts.length > 0 ? attempts[attempts.length - 1] : undefined; + const activeAttempt = [...attempts].reverse().find(attempt => !attempt.diagnostic); + const historicalAttempts = attempts.filter(attempt => attempt !== activeAttempt); const historyRounds = round.historyRounds ?? []; useEffect(() => { - if (olderAttempts.length === 0 && showRetryHistory) { + if (historicalAttempts.length === 0 && showRetryHistory) { setShowRetryHistory(false); } - }, [olderAttempts.length, showRetryHistory]); + }, [historicalAttempts.length, showRetryHistory]); useEffect(() => { if (historyRounds.length === 0 && showRoundHistory) { @@ -305,8 +422,8 @@ export const ModelRoundItem = React.memo( // Keep the recorded round order; FlowChatStore already applies immutable updates. const sortedItems = useMemo( - () => latestAttempt?.items ?? round.items, - [latestAttempt?.items, round.items] + () => activeAttempt?.items ?? (attempts.length === 0 ? round.items : []), + [activeAttempt?.items, attempts.length, round.items] ); const latestCompletedToolEndTime = useMemo(() => { @@ -705,7 +822,8 @@ export const ModelRoundItem = React.memo( return (
- {t('modelRound.attemptLabel', { index: attempt.index })} + {t('modelRound.attemptLabel', { index: attempt.index })} + {attempt.diagnostic && }
{renderGroupList(attemptGroups, { roundId: historyRound.id, @@ -728,7 +846,7 @@ export const ModelRoundItem = React.memo(
)} - {olderAttempts.length > 0 && ( + {historicalAttempts.length > 0 && (
- {showRetryHistory && olderAttempts.map((attempt) => { + {showRetryHistory && historicalAttempts.map((attempt) => { const attemptGroups = buildModelRoundItemGroups({ items: attempt.items, isStreaming: false, @@ -752,7 +870,8 @@ export const ModelRoundItem = React.memo( return (
- {t('modelRound.attemptLabel', { index: attempt.index })} + {t('modelRound.attemptLabel', { index: attempt.index })} + {attempt.diagnostic && }
{renderGroupList(attemptGroups, { roundId: round.id, @@ -767,7 +886,7 @@ export const ModelRoundItem = React.memo( {renderGroupList(visibleGroupedItems, { roundId: round.id, - keyPrefix: latestAttempt ? `attempt:${latestAttempt.id}` : 'round', + keyPrefix: activeAttempt ? `attempt:${activeAttempt.id}` : 'round', isFinalSection: isLastRound, })} @@ -827,6 +946,8 @@ export const ModelRoundItem = React.memo( return ( prev.round.id === next.round.id && prev.round.items === next.round.items && + prev.round.attempts === next.round.attempts && + prev.round.attemptDiagnostics === next.round.attemptDiagnostics && prev.round.historyRounds === next.round.historyRounds && prev.isLastRound === next.isLastRound && prev.isTurnComplete === next.isTurnComplete && diff --git a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts index 350a0ada8b..84f7f149ce 100644 --- a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts +++ b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts @@ -18,6 +18,7 @@ import type { ImageAnalysisEvent, ModelRoundStartedEvent, ModelRoundCompletedEvent, + ModelRoundAttemptSupersededEvent, UserSteeringInjectedEvent, DeepReviewQueueStateChangedEvent, AcpContextUsageUpdatedEvent, @@ -38,6 +39,7 @@ export interface AgenticEventCallbacks { onDialogTurnStarted?: (event: AgenticEvent) => void; onModelRoundStarted?: (event: ModelRoundStartedEvent) => void; onModelRoundCompleted?: (event: ModelRoundCompletedEvent) => void; + onModelRoundAttemptSuperseded?: (event: ModelRoundAttemptSupersededEvent) => void; onTextChunk?: (event: TextChunkEvent) => void; onToolEvent?: (event: ToolEvent) => void; onSubagentSessionLinked?: (event: SubagentSessionLinkedEvent) => void; @@ -134,6 +136,14 @@ export class AgenticEventListener { this.unlistenFunctions.push(unlisten); } + if (callbacks.onModelRoundAttemptSuperseded) { + const unlisten = agentAPI.onModelRoundAttemptSuperseded((event) => { + logger.debug('Model round attempt superseded:', event); + callbacks.onModelRoundAttemptSuperseded?.(event); + }); + this.unlistenFunctions.push(unlisten); + } + if (callbacks.onTextChunk) { const unlisten = agentAPI.onTextChunk((event) => { callbacks.onTextChunk?.(event); 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 a3ac539534..53ac056b30 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 @@ -3,7 +3,7 @@ * Initializes event listeners and handles various Agentic events */ -import { FlowChatStore } from '../../store/FlowChatStore'; +import { FlowChatStore, mergeModelRoundAttemptDiagnostics } from '../../store/FlowChatStore'; import { stateMachineManager } from '../../state-machine'; import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types'; import { agenticEventListener, type AgenticEventCallbacks } from '../AgenticEventListener'; @@ -30,6 +30,7 @@ import type { ImageAnalysisEvent, ModelRoundStartedEvent, ModelRoundCompletedEvent, + ModelRoundAttemptSupersededEvent, OpenBuiltInBrowserEvent, AcpContextUsageUpdatedEvent, SessionModelAutoMigratedEvent, @@ -751,6 +752,9 @@ export async function initializeEventListeners( onModelRoundCompleted: (event) => { handleModelRoundComplete(context, event); }, + onModelRoundAttemptSuperseded: (event) => { + handleModelRoundAttemptSuperseded(context, event); + }, onDialogTurnCompleted: (event) => { handleDialogTurnComplete(context, event, onTodoWriteResult); }, @@ -1857,6 +1861,52 @@ function optionalNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } +function handleModelRoundAttemptSuperseded( + context: FlowChatContext, + event: ModelRoundAttemptSupersededEvent, +): void { + const sessionId = event?.sessionId ?? (event as any)?.session_id; + const turnId = event?.turnId ?? (event as any)?.turn_id; + const roundId = event?.roundId ?? (event as any)?.round_id; + const diagnostic = event?.diagnostic; + + if (!sessionId || !turnId || !roundId) { + log.warn('ModelRoundAttemptSuperseded missing identity fields', { event }); + return; + } + + if ( + !diagnostic || + typeof diagnostic.attemptId !== 'string' || + typeof diagnostic.attemptIndex !== 'number' || + typeof diagnostic.category !== 'string' + ) { + log.warn('ModelRoundAttemptSuperseded has an invalid diagnostic', { sessionId, turnId, roundId }); + return; + } + + if (!shouldProcessEvent(sessionId, turnId, 'data', 'ModelRoundAttemptSuperseded')) { + return; + } + + const round = context.flowChatStore.getState().sessions.get(sessionId) + ?.dialogTurns.find(dialogTurn => dialogTurn.id === turnId) + ?.modelRounds.find(modelRound => modelRound.id === roundId); + if (!round) { + log.debug('Model round not found (attempt superseded)', { sessionId, turnId, roundId }); + return; + } + + context.flowChatStore.updateModelRound( + sessionId, + turnId, + roundId, + current => mergeModelRoundAttemptDiagnostics(current, [diagnostic], { + supersedeMatchingAttempts: true, + }), + ); +} + /** * Handle model round completed event. */ diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index 415b8275b3..10090645be 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -477,6 +477,7 @@ export function convertDialogTurnToBackendFormat(dialogTurn: DialogTurn, turnInd startTime: round.startTime, endTime: round.endTime, attemptCount: round.attemptCount, + attemptDiagnostics: round.attemptDiagnostics, status: round.status || 'completed', }; }), diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 44111567ac..1a595d9f9f 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { flowChatStore } from './FlowChatStore'; +import { flowChatStore, mergeModelRoundAttemptDiagnostics } from './FlowChatStore'; import type { FlowChatState, Session } from '../types/flow-chat'; import { startupTrace } from '@/shared/utils/startupTrace'; import { projectEffectiveToolItem } from '../utils/toolInvocationIdentity'; @@ -452,6 +452,71 @@ describe('FlowChatStore round attempts', () => { }); }); + it('immediately supersedes active items when retry diagnostics arrive before next attempt output', () => { + const session = createSession({ + dialogTurns: [{ + id: 'turn-1', + sessionId: 'session-1', + userMessage: { + id: 'user-1', + content: 'hello', + timestamp: 1000, + }, + modelRounds: [{ + id: 'round-1', + index: 0, + items: [{ + id: 'tool-1', + type: 'tool', + toolName: 'FakeTool', + timestamp: 1100, + status: 'preparing', + attemptId: 'round-1:attempt:1', + attemptIndex: 1, + toolCall: { + id: 'tool-1', + input: {}, + }, + isParamsStreaming: true, + startTime: 1100, + }], + isStreaming: true, + isComplete: false, + status: 'streaming', + startTime: 1000, + }], + status: 'processing', + startTime: 1000, + }], + }); + + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + flowChatStore.updateModelRound(session.sessionId, 'turn-1', 'round-1', round => + mergeModelRoundAttemptDiagnostics(round, [{ + attemptId: 'round-1:attempt:1', + attemptIndex: 1, + category: 'invalid_tool_arguments', + }], { supersedeMatchingAttempts: true }), + ); + + const round = flowChatStore.getState().sessions.get(session.sessionId)?.dialogTurns[0]?.modelRounds[0]; + expect(round).toMatchObject({ status: 'streaming', isStreaming: true }); + expect(round?.attempts?.[0]).toMatchObject({ + status: 'superseded', + diagnostic: { category: 'invalid_tool_arguments' }, + }); + expect(round?.attempts?.[0]?.items[0]).toMatchObject({ + type: 'tool', + status: 'cancelled', + isParamsStreaming: false, + interruptionReason: 'retry_superseded', + }); + }); + it('preserves retry superseded interruption details when restoring persisted turns', () => { const restoredTurn = (flowChatStore as any).convertToDialogTurns([{ turnId: 'turn-1', @@ -485,6 +550,17 @@ describe('FlowChatStore round attempts', () => { attemptId: 'round-1:attempt:1', attemptIndex: 1, }], + attemptDiagnostics: [{ + attemptId: 'round-1:attempt:1', + attemptIndex: 1, + category: 'invalid_tool_arguments', + toolCalls: [{ + toolId: 'ask-1', + toolName: 'AskUserQuestion', + rawArguments: '{"questions":', + validationError: 'EOF while parsing an object', + }], + }], }], status: 'completed', timestamp: 1000, @@ -499,6 +575,116 @@ describe('FlowChatStore round attempts', () => { attemptId: 'round-1:attempt:1', attemptIndex: 1, }); + expect(restoredRound.attempts?.[0]?.diagnostic?.toolCalls?.[0]).toMatchObject({ + rawArguments: '{"questions":', + validationError: 'EOF while parsing an object', + }); + }); + + it('adds a diagnostic-only retry attempt to the collapsed history', () => { + const round = mergeModelRoundAttemptDiagnostics({ + id: 'round-1', + index: 0, + items: [], + isStreaming: false, + isComplete: true, + status: 'completed', + startTime: 1000, + }, [{ + attemptId: 'round-1:attempt:1', + attemptIndex: 1, + category: 'invalid_tool_arguments', + toolCalls: [], + }]); + + expect(round.attempts).toEqual([expect.objectContaining({ + id: 'round-1:attempt:1', + index: 1, + status: 'superseded', + items: [], + diagnostic: expect.objectContaining({ category: 'invalid_tool_arguments' }), + })]); + }); + + it('attaches a diagnostic to the matching existing retry attempt', () => { + const round = mergeModelRoundAttemptDiagnostics({ + id: 'round-1', + index: 0, + items: [], + isStreaming: false, + isComplete: true, + status: 'completed', + startTime: 1000, + attempts: [{ + id: 'round-1:attempt:1', + index: 1, + status: 'superseded', + items: [], + }], + }, [{ + attemptId: 'round-1:attempt:1', + attemptIndex: 1, + category: 'transient_request_error', + rawError: 'provider connection reset', + }]); + + expect(round.attempts).toHaveLength(1); + expect(round.attempts?.[0]?.diagnostic).toMatchObject({ + category: 'transient_request_error', + rawError: 'provider connection reset', + }); + }); + + it('accumulates diagnostics emitted one retry attempt at a time', () => { + const afterFirstDiagnostic = mergeModelRoundAttemptDiagnostics({ + id: 'round-1', + index: 0, + items: [], + isStreaming: true, + isComplete: false, + status: 'streaming', + startTime: 1000, + }, [{ + attemptId: 'round-1:attempt:1', + attemptIndex: 1, + category: 'invalid_tool_arguments', + }]); + + const afterSecondDiagnostic = mergeModelRoundAttemptDiagnostics(afterFirstDiagnostic, [{ + attemptId: 'round-1:attempt:2', + attemptIndex: 2, + category: 'transient_stream_error', + rawError: 'connection reset', + }]); + + expect(afterSecondDiagnostic.attemptDiagnostics?.map(diagnostic => diagnostic.attemptIndex)).toEqual([1, 2]); + expect(afterSecondDiagnostic.attempts?.map(attempt => attempt.diagnostic?.category)).toEqual([ + 'invalid_tool_arguments', + 'transient_stream_error', + ]); + }); + + it('sorts retry diagnostics and the corresponding attempts by attempt index', () => { + const round = mergeModelRoundAttemptDiagnostics({ + id: 'round-1', + index: 0, + items: [], + isStreaming: false, + isComplete: true, + status: 'completed', + startTime: 1000, + }, [{ + attemptId: 'round-1:attempt:2', + attemptIndex: 2, + category: 'transient_stream_error', + }, { + attemptId: 'round-1:attempt:1', + attemptIndex: 1, + category: 'invalid_tool_arguments', + }]); + + expect(round.attemptDiagnostics?.map(diagnostic => diagnostic.attemptIndex)).toEqual([1, 2]); + expect(round.attempts?.map(attempt => attempt.index)).toEqual([1, 2]); }); it('restores a persisted deferred call as its canonical wire invocation', () => { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 89ae4fc803..dd6c7601da 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -9,6 +9,7 @@ import { DialogTurn, ModelRound, ModelRoundAttempt, + ModelRoundAttemptDiagnostic, FlowItem, FlowToolItem, FlowImageAnalysisItem, @@ -336,6 +337,66 @@ function synchronizeRoundAttempts(round: ModelRound): ModelRound { }; } +export function mergeModelRoundAttemptDiagnostics( + round: ModelRound, + diagnostics: ModelRoundAttemptDiagnostic[] | undefined, + options: { supersedeMatchingAttempts?: boolean } = {}, +): ModelRound { + if (!diagnostics || diagnostics.length === 0) { + return round; + } + + const attempts = round.attempts ?? deriveRoundAttemptsFromItems(round.items) ?? []; + const diagnosticByKey = new Map(); + for (const diagnostic of round.attemptDiagnostics ?? []) { + diagnosticByKey.set(`${diagnostic.attemptId}::${diagnostic.attemptIndex}`, diagnostic); + } + for (const attempt of attempts) { + if (attempt.diagnostic) { + diagnosticByKey.set(`${attempt.diagnostic.attemptId}::${attempt.diagnostic.attemptIndex}`, attempt.diagnostic); + } + } + for (const diagnostic of diagnostics) { + diagnosticByKey.set(`${diagnostic.attemptId}::${diagnostic.attemptIndex}`, diagnostic); + } + + const sortedDiagnostics = [...diagnosticByKey.values()].sort((left, right) => ( + left.attemptIndex - right.attemptIndex || left.attemptId.localeCompare(right.attemptId) + )); + const supersededKeys = new Set( + options.supersedeMatchingAttempts + ? diagnostics.map(diagnostic => `${diagnostic.attemptId}::${diagnostic.attemptIndex}`) + : [], + ); + const nextAttempts = attempts.map(attempt => { + const key = `${attempt.id}::${attempt.index}`; + const diagnostic = diagnosticByKey.get(key) ?? attempt.diagnostic; + return supersededKeys.has(key) + ? { ...attempt, status: 'superseded' as const, diagnostic } + : diagnostic ? { ...attempt, diagnostic } : attempt; + }); + const knownKeys = new Set(nextAttempts.map(attempt => `${attempt.id}::${attempt.index}`)); + + for (const diagnostic of sortedDiagnostics) { + const key = `${diagnostic.attemptId}::${diagnostic.attemptIndex}`; + if (!knownKeys.has(key)) { + nextAttempts.push({ + id: diagnostic.attemptId, + index: diagnostic.attemptIndex, + status: 'superseded', + items: [], + diagnostic, + }); + } + } + + return { + ...round, + attemptDiagnostics: sortedDiagnostics, + attempts: sortAttemptEntries(nextAttempts), + }; +} + interface FullHistoryHydrationReleaseOptions { immediate?: boolean; reason?: string; @@ -3231,6 +3292,7 @@ export class FlowChatStore { firstVisibleOutputMs: round.firstVisibleOutputMs, streamDurationMs: round.streamDurationMs, attemptCount: round.attemptCount, + attemptDiagnostics: round.attemptDiagnostics, failureCategory: round.failureCategory, tokenDetails: round.tokenDetails, status: round.status @@ -4529,7 +4591,7 @@ export class FlowChatStore { return aIndex - bIndex; }); - const hydratedRound = synchronizeRoundAttempts({ + const hydratedRound = mergeModelRoundAttemptDiagnostics(synchronizeRoundAttempts({ id: round.id, index: round.roundIndex ?? 0, roundGroupId: round.roundGroupId, @@ -4548,9 +4610,10 @@ export class FlowChatStore { firstVisibleOutputMs: round.firstVisibleOutputMs, streamDurationMs: round.streamDurationMs, attemptCount: round.attemptCount, + attemptDiagnostics: round.attemptDiagnostics, failureCategory: round.failureCategory, tokenDetails: round.tokenDetails, - }); + }), round.attemptDiagnostics); return hydratedRound; }), diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 3903d25bb0..261b0d2a48 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -10,6 +10,8 @@ import type { } from '@/shared/types/session-history'; import type { ReviewTargetEvidence, ReviewTeamRunManifest } from '@/shared/services/reviewTeamService'; +export type ModelRoundAttemptDiagnostic = import('@/shared/types/session-history').ModelRoundAttemptDiagnostic; + // Base type for streaming items. export interface FlowItem { id: string; @@ -167,6 +169,7 @@ export interface ModelRoundAttempt { index: number; status: 'streaming' | 'completed' | 'superseded' | 'failed' | 'cancelled'; items: AnyFlowItem[]; + diagnostic?: ModelRoundAttemptDiagnostic; } // Model round: output from a single model call. @@ -190,6 +193,7 @@ export interface ModelRound { firstVisibleOutputMs?: number; streamDurationMs?: number; attemptCount?: number; + attemptDiagnostics?: ModelRoundAttemptDiagnostic[]; failureCategory?: string; tokenDetails?: unknown; error?: string; diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 97554f287d..9288b6996f 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -2,7 +2,11 @@ import { api } from './ApiClient'; import { createTauriCommandError } from '../errors/TauriCommandError'; -import type { DialogTurnData, SessionRelationship } from '@/shared/types/session-history'; +import type { + DialogTurnData, + ModelRoundAttemptDiagnostic, + SessionRelationship, +} from '@/shared/types/session-history'; import type { ImageContextData as ImageInputContextData } from './ImageContextTypes'; import type { AgentSource } from './CustomAgentAPI'; import type { @@ -458,6 +462,12 @@ export interface ModelRoundCompletedEvent extends AgenticEvent { tokenDetails?: unknown; } +export interface ModelRoundAttemptSupersededEvent extends AgenticEvent { + turnId: string; + roundId: string; + diagnostic: ModelRoundAttemptDiagnostic; +} + export interface ModelRoundStartedEvent extends AgenticEvent { turnId: string; roundId: string; @@ -966,7 +976,10 @@ export class AgentAPI { return api.listen('agentic://model-round-completed', callback); } - + onModelRoundAttemptSuperseded(callback: (event: ModelRoundAttemptSupersededEvent) => void): () => void { + return api.listen('agentic://model-round-attempt-superseded', callback); + } + onTextChunk(callback: (event: TextChunkEvent) => void): () => void { return api.listen('agentic://text-chunk', callback); } 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 5a7257f53a..531e2fce38 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1256,6 +1256,26 @@ "retryHistoryHide": "Hide retry history", "attemptLabel": "Attempt {{index}}", "attemptSuperseded": "superseded", + "attemptDiagnostics": { + "show": "Show retry diagnostic details", + "hide": "Hide retry diagnostic details", + "copy": "Copy", + "copied": "Copied", + "providerError": "Provider error", + "toolArguments": "Tool arguments: {{name}}", + "rawArguments": "Raw arguments", + "validationError": "Parser or validation error", + "unknownTool": "Unknown tool", + "categories": { + "transientRequestError": "Request failed before the provider responded", + "interruptedToolArguments": "Tool arguments were interrupted", + "partialStreamError": "Stream ended with a recoverable error", + "invalidToolArguments": "Provider returned invalid tool arguments", + "noEffectiveOutput": "Provider returned no usable output", + "transientStreamError": "Stream failed before usable output", + "unknown": "Retry diagnostic: {{category}}" + } + }, "meta": { "label": "Response metadata", "completed": "Completed", 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 22cf01aabe..33e7e21a15 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1256,6 +1256,26 @@ "retryHistoryHide": "隐藏重试历史", "attemptLabel": "第 {{index}} 次尝试", "attemptSuperseded": "已被重试接替", + "attemptDiagnostics": { + "show": "显示本次重试诊断详情", + "hide": "隐藏本次重试诊断详情", + "copy": "复制", + "copied": "已复制", + "providerError": "服务商错误", + "toolArguments": "工具参数:{{name}}", + "rawArguments": "原始参数", + "validationError": "解析或校验错误", + "unknownTool": "未知工具", + "categories": { + "transientRequestError": "请求在服务商响应前失败", + "interruptedToolArguments": "工具参数输出被中断", + "partialStreamError": "流式输出以可恢复错误结束", + "invalidToolArguments": "服务商返回了不合法的工具参数", + "noEffectiveOutput": "服务商未返回可用输出", + "transientStreamError": "流式输出在产生可用内容前失败", + "unknown": "重试诊断:{{category}}" + } + }, "meta": { "label": "回应元信息", "completed": "完成", 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 ef8bd112de..ad350175a7 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1256,6 +1256,26 @@ "retryHistoryHide": "隱藏重試歷史", "attemptLabel": "第 {{index}} 次嘗試", "attemptSuperseded": "已被重試接替", + "attemptDiagnostics": { + "show": "顯示本次重試診斷詳情", + "hide": "隱藏本次重試診斷詳情", + "copy": "複製", + "copied": "已複製", + "providerError": "服務商錯誤", + "toolArguments": "工具參數:{{name}}", + "rawArguments": "原始參數", + "validationError": "解析或驗證錯誤", + "unknownTool": "未知工具", + "categories": { + "transientRequestError": "請求在服務商回應前失敗", + "interruptedToolArguments": "工具參數輸出被中斷", + "partialStreamError": "串流輸出以可復原錯誤結束", + "invalidToolArguments": "服務商回傳了不合法的工具參數", + "noEffectiveOutput": "服務商未回傳可用輸出", + "transientStreamError": "串流輸出在產生可用內容前失敗", + "unknown": "重試診斷:{{category}}" + } + }, "meta": { "label": "回應中繼資料", "completed": "完成", diff --git a/src/web-ui/src/shared/types/session-history.ts b/src/web-ui/src/shared/types/session-history.ts index a71d186cf2..36a6fc9da9 100644 --- a/src/web-ui/src/shared/types/session-history.ts +++ b/src/web-ui/src/shared/types/session-history.ts @@ -205,11 +205,27 @@ export interface ModelRoundData { firstVisibleOutputMs?: number; streamDurationMs?: number; attemptCount?: number; + attemptDiagnostics?: ModelRoundAttemptDiagnostic[]; failureCategory?: string; tokenDetails?: unknown; status: string; } +export interface ModelRoundAttemptDiagnostic { + attemptId: string; + attemptIndex: number; + category: string; + rawError?: string; + toolCalls?: ModelRoundAttemptToolDiagnostic[]; +} + +export interface ModelRoundAttemptToolDiagnostic { + toolId?: string; + toolName?: string; + rawArguments?: string; + validationError?: string; +} + export interface ModelRoundRenderHints { disableExploreGrouping?: boolean; }