Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 22 additions & 16 deletions crates/tui/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3587,30 +3587,36 @@ impl LlmClient for DeepSeekClient {
call: crate::llm_client::PreparedStreamCall,
operation_id: uuid::Uuid,
) -> Result<crate::llm_client::StreamEventBox> {
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::<PreparedOperationStream>()
.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::<PreparedOperationStream>()
.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
}
}
Expand Down
12 changes: 12 additions & 0 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2781,6 +2781,7 @@ impl Engine {
compaction_id: None,
usage: Usage::default(),
status,
error_source: None,
error,
tool_catalog: None,
base_url: None,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -6947,6 +6958,7 @@ impl Engine {
compaction_id: None,
usage: zero_usage,
status,
error_source: None,
error,
tool_catalog: None,
base_url: None,
Expand Down
97 changes: 89 additions & 8 deletions crates/tui/src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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}");

Expand Down Expand Up @@ -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}");
Expand Down Expand Up @@ -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]
Expand All @@ -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)
);
}

Expand Down
Loading
Loading