use open_agent::retry::is_retryable_error;
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::llm::client::{Extracted, LlmClient};
use crate::llm::error::LlmError;
use crate::test_support::{
cfg_for, fast_retry_client, mount_sse, request_count, server_returning, sse,
};
#[tokio::test]
async fn fenced_json_response_yields_complete() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(sse(&["{\"findings\":[]}"]), "text/event-stream"),
)
.mount(&server)
.await;
let client = LlmClient::new(&cfg_for(&server, "m", 3)).expect("client");
let extracted = client.complete_json("sys", "user").await.expect("ok");
assert!(
matches!(extracted, Extracted::Complete(_)),
"fenced JSON must be Complete, got {extracted:?}"
);
assert_eq!(extracted, Extracted::Complete(json!({"findings": []})));
}
#[tokio::test]
async fn prose_without_json_yields_unparseable() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
sse(&["Here is some prose without any JSON in it."]),
"text/event-stream",
))
.mount(&server)
.await;
let client = LlmClient::new(&cfg_for(&server, "m", 3)).expect("client");
let err = client
.complete_json("sys", "user")
.await
.expect_err("unparseable");
assert!(
matches!(err, LlmError::Unparseable(_)),
"prose with no JSON must be Unparseable, got {err:?}"
);
}
#[tokio::test]
async fn an_empty_body_is_retried_and_becomes_transport() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_raw(sse(&[""]), "text/event-stream"))
.mount(&server)
.await;
let cfg = cfg_for(&server, "m", 3);
let err = fast_retry_client(&cfg)
.complete_json("sys", "user")
.await
.expect_err("an endlessly empty response must fail the file");
assert!(
matches!(err, LlmError::Transport { .. }),
"an empty body is a transport failure, not a parse failure, got {err:?}"
);
assert!(
request_count(&server).await > 1,
"and it must have been retried; the endpoint saw only one request"
);
}
#[tokio::test]
async fn a_whitespace_only_body_is_treated_as_empty() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(sse(&["\n \n"]), "text/event-stream"),
)
.mount(&server)
.await;
let cfg = cfg_for(&server, "m", 3);
let err = fast_retry_client(&cfg)
.complete_json("sys", "user")
.await
.expect_err("whitespace is not an answer");
assert!(matches!(err, LlmError::Transport { .. }), "got {err:?}");
assert!(request_count(&server).await > 1, "must retry");
}
#[tokio::test]
async fn a_non_empty_unparseable_body_stays_unparseable_rather_than_transport() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
sse(&["I am afraid I cannot help with that."]),
"text/event-stream",
))
.mount(&server)
.await;
let cfg = cfg_for(&server, "m", 3);
let err = fast_retry_client(&cfg)
.complete_json("sys", "user")
.await
.expect_err("prose is not JSON");
assert!(
matches!(err, LlmError::Unparseable(_)),
"prose says nothing about the endpoint, so it must not be classified \
as a transport failure, got {err:?}"
);
assert_eq!(
request_count(&server).await,
crate::llm::client::NO_JSON_ATTEMPTS as usize,
"asked again, but a bounded number of times"
);
}
#[tokio::test]
async fn persistent_500_yields_transport_after_retrying() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let mut cfg = cfg_for(&server, "m", 3);
cfg.timeout_secs = 30;
let client = fast_retry_client(&cfg);
let err = client
.complete_json("sys", "user")
.await
.expect_err("500 must error");
assert!(
matches!(err, LlmError::Transport { .. }),
"persistent 500 must be Transport, got {err:?}"
);
let calls = request_count(&server).await;
assert!(
calls > 1,
"the SDK must retry on 500; mock was called {calls} time(s)"
);
}
#[tokio::test]
async fn error_400_is_not_retried() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(400).set_body_string("bad request"))
.mount(&server)
.await;
let mut cfg = cfg_for(&server, "m", 3);
cfg.timeout_secs = 30;
let client = fast_retry_client(&cfg);
let err = client
.complete_json("sys", "user")
.await
.expect_err("400 must error");
assert!(
matches!(err, LlmError::Transport { .. }),
"non-retryable error must still surface as Transport, got {err:?}"
);
let calls = request_count(&server).await;
assert_eq!(
calls, 1,
"400 must not retry; mock was called {calls} time(s)"
);
}
#[tokio::test]
async fn transient_500_followed_by_200_succeeds() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(500).set_body_string("transient"))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(
ResponseTemplate::new(200)
.set_body_raw(sse(&["{\"findings\":[]}"]), "text/event-stream"),
)
.mount(&server)
.await;
let mut cfg = cfg_for(&server, "m", 3);
cfg.timeout_secs = 30;
let client = fast_retry_client(&cfg);
let extracted = client
.complete_json("sys", "user")
.await
.expect("transient 500 should recover");
assert!(
matches!(extracted, Extracted::Complete(_)),
"recovered response must be Complete, got {extracted:?}"
);
let calls = request_count(&server).await;
assert!(
calls >= 2,
"recovery requires the retry to actually fire; mock was called {calls} time(s)"
);
}
#[tokio::test]
async fn zero_max_retries_still_performs_one_attempt() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let mut cfg = cfg_for(&server, "m", 0);
cfg.timeout_secs = 30;
let client = fast_retry_client(&cfg);
let _ = client
.complete_json("sys", "user")
.await
.expect_err("500 must error");
assert_eq!(
request_count(&server).await,
1,
"max_retries=0 must floor at 1 attempt, not skip the request entirely"
);
}
#[test]
fn sdk_classifies_400_as_non_retryable() {
let e400 = open_agent::Error::api_status(400, "Bad Request");
assert!(
!is_retryable_error(&e400),
"the SDK must keep 400 in the non-retryable class"
);
}
#[tokio::test]
async fn a_response_with_no_json_is_retried_and_a_later_answer_is_accepted() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
sse(&["Let me take a look at this file..."]),
"text/event-stream",
))
.up_to_n_times(1)
.mount(&server)
.await;
mount_sse(
&server,
ResponseTemplate::new(200).set_body_raw(sse(&[r#"{"issues": []}"#]), "text/event-stream"),
)
.await;
let client = fast_retry_client(&cfg_for(&server, "m", 1));
let extracted = client
.complete_json("sys", "content")
.await
.expect("the second attempt parses");
assert!(matches!(extracted, Extracted::Complete(_)));
assert_eq!(
request_count(&server).await,
2,
"the prose answer must have been asked again"
);
}
#[tokio::test]
async fn a_persistently_unparseable_response_is_bounded_and_reports_the_body() {
let server = server_returning(&["I am afraid I cannot help with that."]).await;
let client = fast_retry_client(&cfg_for(&server, "m", 1));
let err = client
.complete_json("sys", "content")
.await
.expect_err("prose never parses");
match err {
LlmError::Unparseable(message) => {
assert!(
message.contains("I am afraid I cannot help"),
"the message must carry what came back, got {message:?}"
);
}
other => panic!("expected Unparseable, got {other:?}"),
}
assert_eq!(
request_count(&server).await,
crate::llm::client::NO_JSON_ATTEMPTS as usize,
"bounded: each attempt is a full reasoning call"
);
}
#[tokio::test]
async fn a_truncated_response_is_not_retried() {
let server = server_returning(&[r#"{"issues": [{"line": 1,"#]).await;
let client = fast_retry_client(&cfg_for(&server, "m", 1));
let extracted = client
.complete_json("sys", "content")
.await
.expect("brace-balancing recovers a prefix");
assert!(
matches!(extracted, Extracted::Truncated(_)),
"got {extracted:?}"
);
assert_eq!(
request_count(&server).await,
1,
"a truncated answer is deterministic - asking again buys nothing"
);
}
#[tokio::test]
async fn the_reported_body_is_stripped_of_control_characters() {
let server = server_returning(&["prose \u{1b}[31mred\u{1b}[0m and\nnewlines"]).await;
let client = fast_retry_client(&cfg_for(&server, "m", 1));
let err = client
.complete_json("sys", "content")
.await
.expect_err("prose never parses");
let message = err.to_string();
assert!(
!message.contains('\u{1b}'),
"an escape sequence must not survive into the report: {message:?}"
);
assert!(
!message.contains('\n'),
"the excerpt is one line: {message:?}"
);
assert!(
message.contains("red"),
"the text itself survives: {message}"
);
}