diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 5051141363..4bdb1e64e6 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -3587,30 +3587,36 @@ impl LlmClient for DeepSeekClient { call: crate::llm_client::PreparedStreamCall, operation_id: uuid::Uuid, ) -> Result { - if self.request_idempotency_header.is_none() { + let payload = if self.request_idempotency_header.is_none() { anyhow::ensure!( call.operation_identity.is_none(), "Prepared operation belongs to another route" ); - return self - .create_message_stream_for_operation(call.request, operation_id) - .await; - } - let payload = call - .transport::() - .context("Missing prepared operation transport")?; - anyhow::ensure!( - payload.route_identity == self.operation_route_identity - && payload.wire_format == self.wire_format - && payload.auth_disabled == self.auth_disabled - && payload.path_suffix == self.path_suffix, - "Prepared operation belongs to another route" - ); + None + } else { + let payload = call + .transport::() + .context("Missing prepared operation transport")?; + anyhow::ensure!( + payload.route_identity == self.operation_route_identity + && payload.wire_format == self.wire_format + && payload.auth_disabled == self.auth_disabled + && payload.path_suffix == self.path_suffix, + "Prepared operation belongs to another route" + ); + Some(payload) + }; let client = self.for_operation(operation_id); let inference = client.acquire_remote_control_inference_permit().await; let permit = client.acquire_provider_request_permit().await; + let prepared = match payload { + Some(payload) => payload.prepared.clone(), + None => client + .prepare_outbound_request(call.request, true) + .map_err(crate::llm_client::DeferredStreamPreparationError)?, + }; client - .finish_prepared_stream(payload.prepared.clone(), inference, permit) + .finish_prepared_stream(prepared, inference, permit) .await } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index f9d0c55661..b8fff2ce90 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -2781,6 +2781,7 @@ impl Engine { compaction_id: None, usage: Usage::default(), status, + error_source: None, error, tool_catalog: None, base_url: None, @@ -5121,6 +5122,7 @@ impl Engine { compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Failed, + error_source: None, error: Some(message), tool_catalog: None, base_url: None, @@ -6140,6 +6142,7 @@ impl Engine { compaction_id: None, usage: turn.usage.clone(), status: TurnOutcomeStatus::Failed, + error_source: None, error: Some(message.clone()), tool_catalog: None, base_url: None, @@ -6488,6 +6491,7 @@ impl Engine { compaction_id: None, usage: turn.usage, status, + error_source: turn.terminal_error_source, error: error.clone(), tool_catalog: tool_catalog_for_event, base_url: base_url_for_event, @@ -6629,6 +6633,7 @@ impl Engine { compaction_id, usage: Usage::default(), status: TurnOutcomeStatus::Interrupted, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -6654,6 +6659,7 @@ impl Engine { compaction_id, usage: Usage::default(), status: TurnOutcomeStatus::Failed, + error_source: None, error: Some(message), tool_catalog: None, base_url: None, @@ -6689,6 +6695,7 @@ impl Engine { compaction_id, usage: zero_usage, status: TurnOutcomeStatus::Failed, + error_source: None, error: Some(message), tool_catalog: None, base_url: None, @@ -6734,6 +6741,7 @@ impl Engine { compaction_id, usage: zero_usage, status: TurnOutcomeStatus::Interrupted, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -6776,6 +6784,7 @@ impl Engine { compaction_id, usage: zero_usage, status: TurnOutcomeStatus::Interrupted, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -6849,6 +6858,7 @@ impl Engine { compaction_id, usage: zero_usage, status: turn_status, + error_source: None, error: turn_error, tool_catalog: None, base_url: None, @@ -6877,6 +6887,7 @@ impl Engine { compaction_id: None, usage: zero_usage, status: TurnOutcomeStatus::Failed, + error_source: None, error: Some(message), tool_catalog: None, base_url: None, @@ -6947,6 +6958,7 @@ impl Engine { compaction_id: None, usage: zero_usage, status, + error_source: None, error, tool_catalog: None, base_url: None, diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 35ff93980d..d0eecaf5ea 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -8900,13 +8900,19 @@ async fn max_steps_exhaustion_fails_as_budget_never_completed() { .expect("send step-budget trajectory"); let mut rx = handle.rx_event.write().await; - let (status, error) = loop { + let (status, error, error_source) = loop { let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv()) .await .expect("timed out waiting for step-budget trajectory") .expect("engine event"); - if let Event::TurnComplete { status, error, .. } = event { - break (status, error); + if let Event::TurnComplete { + status, + error, + error_source, + .. + } = event + { + break (status, error, error_source); } }; drop(rx); @@ -8916,6 +8922,10 @@ async fn max_steps_exhaustion_fails_as_budget_never_completed() { TurnOutcomeStatus::Failed, "step-budget exhaustion must never report Completed" ); + assert_eq!( + error_source, + Some(crate::core::events::TurnErrorSource::Runtime) + ); let error = error.expect("step-budget exhaustion must carry a terminal error"); assert!(error.contains("Maximum model steps"), "{error}"); @@ -9260,8 +9270,17 @@ async fn injected_chat_content_filter_never_becomes_a_completed_answer() { Event::MessageComplete { .. } => { panic!("content-filtered text must not be marked completed") } - Event::TurnComplete { status, error, .. } => { + Event::TurnComplete { + status, + error, + error_source, + .. + } => { assert_eq!(status, TurnOutcomeStatus::Failed); + assert_eq!( + error_source, + Some(crate::core::events::TurnErrorSource::Model) + ); let error = error.expect("content-filtered turn needs a terminal error"); assert!(error.contains("Model response incomplete"), "{error}"); assert!(error.contains("content_filter"), "{error}"); @@ -31830,6 +31849,59 @@ async fn model_step_ceiling_fires_and_reports_the_limit() { ); } +#[tokio::test] +async fn ordinary_route_preparation_failure_is_runtime_without_sending_http() { + let workspace = tempdir().expect("tempdir"); + let server = wiremock::MockServer::start().await; + let api_config = Config { + provider: Some("anthropic".into()), + providers: Some(crate::config::ProvidersConfig { + anthropic: crate::config::ProviderConfig { + api_key: Some("test-key".into()), + base_url: Some(server.uri()), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + let client = std::sync::Arc::new(DeepSeekClient::new(&api_config).expect("anthropic client")); + let engine_config = EngineConfig { + model: "claude-sonnet-4-6".into(), + ..deterministic_engine_config(workspace.path()) + }; + let (mut engine, _handle) = Engine::new_with_model_client(engine_config, &api_config, client); + engine.session.messages.push(Message { + role: Role::Unrecognized("unexpected eof".into()), + content: vec![ContentBlock::Text { + text: "retained message".into(), + cache_control: None, + }], + }); + let context = crate::tools::ToolContext::new(workspace.path().to_path_buf()); + let surface = test_tool_surface( + &engine, + crate::tools::ToolRegistry::new(context), + None, + AppMode::Agent, + ); + let mut turn = crate::core::turn::TurnContext::new(engine.config.max_steps); + + let (status, error) = engine.run_turn(&mut turn, surface, None, None).await; + + assert_eq!(status, TurnOutcomeStatus::Failed); + assert_eq!( + turn.terminal_error_source, + Some(crate::core::events::TurnErrorSource::Runtime) + ); + let error = error.expect("local rejection"); + assert!( + error.contains("unexpected eof") && error.contains("refused before it was sent"), + "{error}" + ); + assert!(server.received_requests().await.unwrap().is_empty()); +} + /// R1: the per-step stream cap is overridable, and a tiny cap actually cuts /// the stream off instead of accumulating without bound. #[tokio::test] @@ -31855,12 +31927,21 @@ async fn per_step_stream_content_cap_is_overridable_and_fires() { let surface = test_tool_surface(&engine, registry, None, AppMode::Agent); let mut turn = crate::core::turn::TurnContext::new(engine.config.max_steps); - let (status, _error) = engine.run_turn(&mut turn, surface, None, None).await; + let (status, error) = engine.run_turn(&mut turn, surface, None, None).await; - assert_ne!( + assert_eq!( status, - TurnOutcomeStatus::Completed, - "a stream cut off by the content cap must not report a clean completion" + TurnOutcomeStatus::Failed, + "a stream cut off by the content cap must report failure" + ); + assert!( + error + .expect("content cap failure") + .contains("maximum content size of 64 bytes") + ); + assert_eq!( + turn.terminal_error_source, + Some(crate::core::events::TurnErrorSource::Runtime) ); } diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index c01e675186..4db39596f3 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -23,6 +23,44 @@ use codewhale_core::request::{PrimaryTurnRequest, prepare_primary_turn_request}; const MAX_APPROVAL_INTENT_SUMMARY_CHARS: usize = 2_000; +/// Keep the chosen error and its producer together through first-error wins, +/// retries, and local-error replacement. No source survives a cleared error. +struct TurnFailure { + message: String, + source: crate::core::events::TurnErrorSource, +} + +impl TurnFailure { + fn model(message: String) -> Self { + Self { + message, + source: crate::core::events::TurnErrorSource::Model, + } + } + + fn runtime(message: String) -> Self { + Self { + message, + source: crate::core::events::TurnErrorSource::Runtime, + } + } + + fn finish(self, turn: &mut TurnContext) -> (TurnOutcomeStatus, Option) { + turn.terminal_error_source = Some(self.source); + (TurnOutcomeStatus::Failed, Some(self.message)) + } +} + +fn split_stream_open_error( + error: anyhow::Error, + default_source: crate::core::events::TurnErrorSource, +) -> (anyhow::Error, crate::core::events::TurnErrorSource) { + match error.downcast::() { + Ok(error) => (error.0, crate::core::events::TurnErrorSource::Runtime), + Err(error) => (error, default_source), + } +} + /// Monotonic counter for rewritten text-fallback tool-call ids. The wall-clock /// millis alone can repeat within one process, so the counter disambiguates /// incarnations; the millis keep ids unique across engine restarts within the @@ -787,7 +825,7 @@ struct StreamOutcome { stream_start: Instant, first_token_at: Option, request_dispatched_at: Instant, - stream_error: Option, + stream_error: Option, } pub(super) fn initial_stream_error_user_message( @@ -1543,7 +1581,7 @@ impl Engine { .clone() .expect("model client should be configured"); - let mut turn_error: Option = None; + let mut turn_error: Option = None; // While the spawn-time MCP boot is still settling, the pool fills // incrementally and a new turn's snapshot can differ from the pinned // prefix the moment any server stores its tools (#588). Declare the @@ -1684,7 +1722,7 @@ impl Engine { self.turn_wall_clock.budget().as_secs(), ); let _ = self.tx_event.send(Event::status(error.clone())).await; - return (TurnOutcomeStatus::Failed, Some(error)); + return TurnFailure::runtime(error).finish(turn); } if self.apply_pending_runtime_authority().await { @@ -1805,7 +1843,7 @@ impl Engine { self.config.max_steps ); let _ = self.tx_event.send(Event::status(error.clone())).await; - return (TurnOutcomeStatus::Failed, Some(error)); + return TurnFailure::runtime(error).finish(turn); } } @@ -2086,12 +2124,12 @@ impl Engine { "Context remains above model limit after {MAX_CONTEXT_RECOVERY_ATTEMPTS} recovery attempts \ (~{estimated_input} token estimate, ~{input_budget} budget). Please run /compact or /clear." ); - turn_error = Some(message.clone()); + turn_error = Some(TurnFailure::runtime(message.clone())); let _ = self .tx_event .send(Event::error(ErrorEnvelope::context_overflow(message))) .await; - return (TurnOutcomeStatus::Failed, turn_error); + return turn_error.expect("failure was assigned").finish(turn); } if self @@ -2379,6 +2417,11 @@ impl Engine { // instant (connection setup included), and time-to-first-token is // the gap to the first content-bearing stream event. let request_dispatched_at = Instant::now(); + let request_error_source = if preparation.is_ok() { + crate::core::events::TurnErrorSource::Model + } else { + crate::core::events::TurnErrorSource::Runtime + }; let stream_result = tokio::select! { biased; () = self.cancel_token.cancelled() => { @@ -2397,6 +2440,8 @@ impl Engine { s } Err(e) => { + let (e, request_error_source) = + split_stream_open_error(e, request_error_source); let message = self.decorate_auth_error_message( initial_stream_error_user_message(&self.config.locale_tag, &e), ); @@ -2431,14 +2476,17 @@ impl Engine { let _ = self.tx_event.send(Event::status(status)).await; continue; } - turn_error = Some(message.clone()); + turn_error = Some(TurnFailure { + message: message.clone(), + source: request_error_source, + }); let _ = self .tx_event .send(Event::error(crate::error_taxonomy::envelope_for_llm_error( e, message, ))) .await; - return (TurnOutcomeStatus::Failed, turn_error); + return turn_error.expect("failure was assigned").finish(turn); } }; let StreamOutcome { @@ -2556,7 +2604,7 @@ impl Engine { "Model response incomplete: provider stop reason `{reason}`; no complete response or tool call was accepted." ); crate::logging::warn(&error); - return (TurnOutcomeStatus::Failed, Some(error)); + return TurnFailure::model(error).finish(turn); } } @@ -2855,7 +2903,7 @@ impl Engine { "benchmark final-only mode produced repeated tool-only responses" .to_string(); let _ = self.tx_event.send(Event::status(reason.clone())).await; - return (TurnOutcomeStatus::Failed, Some(reason)); + return TurnFailure::runtime(reason).finish(turn); } self.add_session_message(self.runtime_text_message_with_turn_metadata( "All tools are disabled. The previous tool markers were ignored. Respond now with only the requested final answer in the exact required format." @@ -3048,7 +3096,8 @@ impl Engine { .tx_event .send(Event::status(format!("REPL init failed: {e}"))) .await; - turn_error = Some(format!("REPL init failed: {e}")); + turn_error = + Some(TurnFailure::runtime(format!("REPL init failed: {e}"))); break; } }; @@ -3071,7 +3120,9 @@ impl Engine { .tx_event .send(Event::status(format!("REPL context refresh failed: {e}"))) .await; - turn_error = Some(format!("REPL context refresh failed: {e}")); + turn_error = Some(TurnFailure::runtime(format!( + "REPL context refresh failed: {e}" + ))); break; } @@ -3480,7 +3531,7 @@ impl Engine { "Model stream ended with no answer or tool call.".to_string() }; crate::logging::warn(&message); - turn_error = Some(message.clone()); + turn_error = Some(TurnFailure::model(message.clone())); let _ = self .tx_event .send(Event::error(ErrorEnvelope::classify(message, true))) @@ -3703,7 +3754,7 @@ impl Engine { ))) .await; } - return (TurnOutcomeStatus::Failed, Some(err)); + return err.finish(turn); } let running = foreground_children .as_ref() @@ -5745,7 +5796,7 @@ impl Engine { // is `Unpin`, so we can rebind it on a transparent retry without // breaking the existing pin invariants. let mut stream = stream; - let mut stream_error: Option = None; + let mut stream_error: Option = None; let mut current_text_raw = String::new(); let mut current_text_visible = String::new(); @@ -5847,7 +5898,7 @@ impl Engine { // fails the turn with the real reason instead // of ending "Completed" over a frozen block. stream_errors = stream_errors.saturating_add(1); - stream_error.get_or_insert(envelope.message.clone()); + stream_error.get_or_insert_with(|| TurnFailure::model(envelope.message.clone())); let _ = self.tx_event.send(Event::error(envelope)).await; None } @@ -5872,7 +5923,7 @@ impl Engine { } .into_envelope(); crate::logging::warn(&envelope.message); - stream_error.get_or_insert(envelope.message.clone()); + stream_error.get_or_insert_with(|| TurnFailure::runtime(envelope.message.clone())); let _ = self.tx_event.send(Event::error(envelope)).await; break; } @@ -5884,7 +5935,7 @@ impl Engine { } .into_envelope(); crate::logging::warn(&envelope.message); - stream_error.get_or_insert(envelope.message.clone()); + stream_error.get_or_insert_with(|| TurnFailure::runtime(envelope.message.clone())); let _ = self.tx_event.send(Event::error(envelope)).await; break; } @@ -5963,10 +6014,17 @@ impl Engine { continue; } Err(retry_err) => { + let (retry_err, source) = split_stream_open_error( + retry_err, + crate::core::events::TurnErrorSource::Model, + ); let retry_msg = self.decorate_auth_error_message(format!( "Stream retry failed: {retry_err}" )); - stream_error.get_or_insert(retry_msg.clone()); + stream_error.get_or_insert_with(|| TurnFailure { + message: retry_msg.clone(), + source, + }); let _ = self .tx_event .send(Event::error( @@ -6012,10 +6070,12 @@ impl Engine { // outcome; the post-loop retry clears it, and if // the turn still fails the last attempt surfaces // it through the normal path below. - stream_error.get_or_insert(stream_read_error_user_message( - &message, - any_content_received, - )); + stream_error.get_or_insert_with(|| { + TurnFailure::model(stream_read_error_user_message( + &message, + any_content_received, + )) + }); pending_resume = Some(StreamResume::HeadlessNetworkDrop); break; } @@ -6037,16 +6097,18 @@ impl Engine { crate::logging::warn(format!( "Interactive stream resume: network drop after partial content; scheduling typed resume: {message}" )); - stream_error.get_or_insert(stream_read_error_user_message( - &message, - any_content_received, - )); + stream_error.get_or_insert_with(|| { + TurnFailure::model(stream_read_error_user_message( + &message, + any_content_received, + )) + }); pending_resume = Some(StreamResume::InteractiveNetworkDrop); break; } let user_message = stream_read_error_user_message(&message, any_content_received); - stream_error.get_or_insert(user_message.clone()); + stream_error.get_or_insert_with(|| TurnFailure::model(user_message.clone())); let envelope = crate::error_taxonomy::envelope_for_llm_error(e, user_message); // A terminal (non-recoverable) stream failure must stop // consumption immediately: re-issuing a wrong-model or @@ -6489,7 +6551,7 @@ impl Engine { crate::logging::warn(format!("Provider stream error event: {message}")); } let _ = self.tx_event.send(Event::error(envelope)).await; - stream_error.get_or_insert(message.to_string()); + stream_error.get_or_insert_with(|| TurnFailure::model(message.to_string())); break; } } diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 55b9ffcf95..4115b64b1a 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -24,6 +24,14 @@ pub enum TurnOutcomeStatus { Failed, } +/// Producer of the error selected for a failed turn. This is not inferred +/// from advisory errors, the selected route, or the error's display text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TurnErrorSource { + Model, + Runtime, +} + /// Provider/model route resolved for a model-backed turn. /// /// Emitted at `RouteDispatched` so hosts retain provenance until the matching @@ -256,6 +264,8 @@ pub enum Event { usage: Usage, status: TurnOutcomeStatus, error: Option, + /// Source of this exact terminal error, when known by its producer. + error_source: Option, /// Tool catalog sent with this turn's model request. tool_catalog: Option>, /// API base URL used by this turn's client. diff --git a/crates/tui/src/core/protocol_parity.rs b/crates/tui/src/core/protocol_parity.rs index 374eb40ff6..742b8ebb6a 100644 --- a/crates/tui/src/core/protocol_parity.rs +++ b/crates/tui/src/core/protocol_parity.rs @@ -501,6 +501,8 @@ pub fn event_to_protocol(event: &Event, ids: &ProtocolIds) -> wire::EventMsg { error, tool_catalog, base_url, + // Internal producer evidence is not part of the wire protocol. + error_source: _, } => wire::EventMsg::TurnComplete { thread_id, session_id, @@ -1225,6 +1227,7 @@ mod tests { compaction_id: Some("compact-1".into()), usage: Usage::default(), status: TurnOutcomeStatus::Failed, + error_source: None, error: Some("route rejected".into()), tool_catalog: None, base_url: None, @@ -1283,6 +1286,7 @@ mod tests { compaction_id: None, usage: usage.clone(), status: TurnOutcomeStatus::Interrupted, + error_source: None, error: Some("stopped".into()), tool_catalog: None, base_url: Some("https://example.invalid".into()), @@ -1377,6 +1381,29 @@ mod tests { assert_eq!(error["severity"], "warning"); } + #[test] + fn terminal_error_source_is_not_projected() { + for source in [ + crate::core::events::TurnErrorSource::Model, + crate::core::events::TurnErrorSource::Runtime, + ] { + let event = Event::TurnComplete { + turn_id: Some("turn-1".into()), + submission_id: None, + compaction_id: None, + usage: Usage::default(), + status: TurnOutcomeStatus::Failed, + error_source: Some(source), + error: Some("terminal failure".into()), + tool_catalog: None, + base_url: None, + }; + let terminal = serde_json::to_value(event.to_protocol(&ids())).unwrap(); + assert_eq!(terminal["status"], "failed"); + assert!(terminal.get("error_source").is_none()); + } + } + #[test] fn protocol_covers_engine_ops() { let ops = vec![ diff --git a/crates/tui/src/core/turn.rs b/crates/tui/src/core/turn.rs index 08e1339023..a3f624910a 100644 --- a/crates/tui/src/core/turn.rs +++ b/crates/tui/src/core/turn.rs @@ -13,7 +13,7 @@ //! `/restore N` and the `revert_turn` tool both consume these //! snapshots. -use crate::core::events::TurnRoute; +use crate::core::events::{TurnErrorSource, TurnRoute}; use crate::models::Usage; use crate::snapshot::SnapshotRepo; use std::path::Path; @@ -57,6 +57,10 @@ pub struct TurnContext { /// Route facts resolved for this turn but not timestamped until the first /// provider request is actually dispatched. pub(crate) pending_route: Option, + + /// Written only when run_turn selects its final failure, never by an + /// advisory event or an attempt which may still recover. + pub(crate) terminal_error_source: Option, } impl TurnContext { @@ -76,6 +80,7 @@ impl TurnContext { latest_parent_input_tokens: None, compaction_refusal_notified: false, pending_route: None, + terminal_error_source: None, } } diff --git a/crates/tui/src/llm_client/mod.rs b/crates/tui/src/llm_client/mod.rs index c38ac3e112..45a6dd1697 100644 --- a/crates/tui/src/llm_client/mod.rs +++ b/crates/tui/src/llm_client/mod.rs @@ -42,6 +42,11 @@ pub mod mock; pub type StreamEventBox = Pin> + Send + 'static>>; +/// Marks local preparation performed during dispatch on an ordinary route. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub(crate) struct DeferredStreamPreparationError(pub anyhow::Error); + /// Request-local preparation reused by transport retries, never a global cache. /// Provider payloads remain opaque to the Engine and are not logged or serialized. #[derive(Clone)] diff --git a/crates/tui/src/remote_control.rs b/crates/tui/src/remote_control.rs index 3b961ddf5d..83611b15a4 100644 --- a/crates/tui/src/remote_control.rs +++ b/crates/tui/src/remote_control.rs @@ -7581,6 +7581,7 @@ mod tests { compaction_id: None, usage: crate::models::Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -8070,6 +8071,7 @@ mod tests { compaction_id: None, usage: usage.clone(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 2f60d620dd..514d5d532f 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -2587,6 +2587,7 @@ async fn compatibility_stream_closes_losslessly_across_replay_live_handoff() -> }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -2838,6 +2839,7 @@ async fn compatibility_stream_exposes_and_resolves_user_input_without_answer_ech compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -3206,6 +3208,7 @@ async fn thread_endpoints_expose_lifecycle_contract() -> Result<()> { }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -3224,6 +3227,7 @@ async fn thread_endpoints_expose_lifecycle_contract() -> Result<()> { }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -3363,6 +3367,7 @@ async fn turn_endpoint_operation_key_returns_original_and_conflicts_on_mismatch( compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -3476,6 +3481,7 @@ async fn events_endpoint_respects_since_seq_cursor() -> Result<()> { }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -3699,6 +3705,7 @@ async fn steer_and_interrupt_endpoints_work_on_active_turn() -> Result<()> { }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -4123,6 +4130,7 @@ async fn stream_endpoint_remains_backward_compatible() -> Result<()> { }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -5816,6 +5824,7 @@ async fn session_create_from_thread_rejects_active_turn() -> Result<()> { }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) diff --git a/crates/tui/src/runtime_threads/tests.rs b/crates/tui/src/runtime_threads/tests.rs index 75f80764dd..18dc8e2d8c 100644 --- a/crates/tui/src/runtime_threads/tests.rs +++ b/crates/tui/src/runtime_threads/tests.rs @@ -1430,6 +1430,7 @@ async fn caller_cancellation_after_engine_acceptance_keeps_owned_turn_lifecycle( compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -1545,6 +1546,7 @@ async fn operation_key_replays_torn_response_survives_restart_and_rejects_mismat compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -1759,6 +1761,7 @@ async fn thread_updates_while_start_waits_for_capacity_survive_latest_turn_write compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -1924,6 +1927,7 @@ async fn compact_lifecycle_outlives_caller_and_preserves_concurrent_thread_updat compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -5287,6 +5291,7 @@ async fn thread_lifecycle_persists_across_restart() -> Result<()> { }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -5429,6 +5434,7 @@ async fn monitor_separates_lifecycle_start_from_billing_dispatch_and_child_usage }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, // Completion-time endpoint evidence must be ignored. base_url: Some("https://api.stepfun.com/v1/coding".to_string()), @@ -5530,6 +5536,7 @@ async fn monitor_separates_lifecycle_start_from_billing_dispatch_and_child_usage compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -5584,6 +5591,7 @@ async fn completed_turn_without_engine_output_fails() -> Result<()> { }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -5670,6 +5678,7 @@ async fn preturn_control_status_does_not_make_empty_turn_succeed() -> Result<()> compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -5734,6 +5743,7 @@ async fn engine_error_remains_failed_after_nominal_turn_complete() -> Result<()> compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -6848,6 +6858,7 @@ async fn compact_interrupt_persists_canceled_item_for_the_exact_request() -> Res compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Interrupted, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -7090,6 +7101,7 @@ async fn multi_turn_continuity_same_thread() -> Result<()> { }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -7325,6 +7337,7 @@ async fn host_goal_loop_kickoff_arms_one_continuation_and_parks_at_engine_cap() }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: Some(vec![catalog_tool("update_goal")]), base_url: None, }) @@ -7441,6 +7454,7 @@ async fn host_goal_loop_skips_rearm_without_update_goal_and_after_failed_pass() }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: Some(vec![catalog_tool("read")]), base_url: None, }) @@ -7517,6 +7531,7 @@ async fn host_goal_loop_skips_rearm_without_update_goal_and_after_failed_pass() }, status: TurnOutcomeStatus::Failed, error: Some("provider exploded".to_string()), + error_source: None, tool_catalog: Some(vec![catalog_tool("update_goal")]), base_url: None, }) @@ -7612,6 +7627,7 @@ async fn host_goal_loop_mirrors_terminal_snapshot_and_does_not_rearm() -> Result }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: Some(vec![catalog_tool("update_goal")]), base_url: None, }) @@ -7900,6 +7916,7 @@ async fn approval_required_with_stale_active_turn_is_denied() -> Result<()> { }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -8017,6 +8034,7 @@ async fn approval_required_awaits_external_decision_allow() -> Result<()> { compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -8132,6 +8150,7 @@ async fn user_input_snapshot_survives_reload_and_clears_after_submission() -> Re compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -8483,6 +8502,7 @@ async fn thread_detail_cursor_precedes_projection_reads_at_terminal_boundary() - compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -8737,6 +8757,7 @@ async fn thread_detail_materializes_stream_prefixes_before_their_delta_cursor() compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Interrupted, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -8879,6 +8900,7 @@ async fn thread_detail_delta_boundary_is_replay_idempotent() -> Result<()> { compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Interrupted, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -8951,6 +8973,7 @@ async fn terminal_turn_cancels_pending_user_input_and_clears_snapshot() -> Resul compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -9143,6 +9166,7 @@ async fn dynamic_tool_result_settles_snapshot_and_emits_one_safe_resolution() -> compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -9331,6 +9355,7 @@ async fn dynamic_tool_result_receipt_outlives_canceled_delivery_future() -> Resu compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -10163,6 +10188,7 @@ async fn dynamic_tool_timeout_clears_snapshot_and_emits_once() -> Result<()> { compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -10239,6 +10265,7 @@ async fn terminal_turn_cancels_pending_dynamic_tool_exactly_once() -> Result<()> compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Interrupted, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -10356,6 +10383,7 @@ async fn approval_required_external_deny_is_denied() -> Result<()> { compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -10429,6 +10457,7 @@ async fn auto_review_force_prompt_is_denied_without_opening_a_modal() -> Result< compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -10545,6 +10574,7 @@ async fn forkguard_pump_never_auto_approves_a_force_prompt_hold() -> Result<()> usage: Usage::default(), status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -10624,6 +10654,7 @@ async fn forkguard_pump_never_auto_approves_a_force_prompt_hold() -> Result<()> usage: Usage::default(), status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -10743,6 +10774,7 @@ async fn approval_pends_until_interrupt_and_next_turn_can_start() -> Result<()> compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -11192,6 +11224,7 @@ async fn thinking_delta_emits_agent_reasoning_item() -> Result<()> { compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -11332,6 +11365,7 @@ async fn approval_required_remember_flips_thread_auto_approve() -> Result<()> { compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -11420,6 +11454,7 @@ async fn elevation_required_with_stale_active_turn_is_denied() -> Result<()> { }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -11490,6 +11525,7 @@ async fn steer_turn_on_active_turn_records_item_and_event() -> Result<()> { }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -11651,6 +11687,7 @@ async fn steer_receipts_outlive_caller_cancellation_after_engine_acceptance() -> compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -11741,6 +11778,7 @@ async fn steer_rejects_a_terminal_durable_turn_without_dispatch_or_item() -> Res compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -12019,6 +12057,7 @@ async fn compaction_lifecycle_emits_item_events_with_compaction_counts() -> Resu }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -12059,6 +12098,7 @@ async fn compaction_lifecycle_emits_item_events_with_compaction_counts() -> Resu }, status: TurnOutcomeStatus::Completed, error: None, + error_source: None, tool_catalog: None, base_url: None, }) @@ -13077,6 +13117,7 @@ async fn agent_mail_release_acceptance_two_task_matrix() -> Result<()> { compaction_id: None, usage: Usage::default(), status: TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 9265a0963a..603fa53d84 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -13819,6 +13819,7 @@ fn local_cancel_marks_late_stream_events_for_suppression() { compaction_id: None, usage: Usage::default(), status: crate::core::events::TurnOutcomeStatus::Interrupted, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -13842,6 +13843,7 @@ fn turn_end_outbox_uses_the_terminal_events_owner() { compaction_id: None, usage: Usage::default(), status: crate::core::events::TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None, @@ -13861,6 +13863,7 @@ fn turn_end_outbox_uses_the_terminal_events_owner() { compaction_id: None, usage: Usage::default(), status: crate::core::events::TurnOutcomeStatus::Failed, + error_source: None, error: Some("route rejected".to_string()), tool_catalog: None, base_url: None, @@ -13880,6 +13883,7 @@ fn turn_end_outbox_uses_the_terminal_events_owner() { compaction_id: Some("compact-manual".to_string()), usage: Usage::default(), status: crate::core::events::TurnOutcomeStatus::Completed, + error_source: None, error: None, tool_catalog: None, base_url: None,