mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
test(buzz-agent): cover provider failure paths end-to-end
`stdio_turn.rs` drives the real binary against a real socket, but its fake provider only ever answers 200. buzz's deleted `llm.rs` tests covered the unhappy paths; nothing on this branch did. Five tests against a scriptable provider (429/401/500/truncated stream), driving the actual binary over stdio. The property under test is the one buzz owns and a user feels: a misbehaving provider ENDS THE TURN. Silence is the worst failure available to us — `buzz-acp` waits on that response, so a turn that never answers is an agent that has visibly stopped replying with nothing in the log to explain it. Each turn runs under a deadline so a hang fails as one test rather than stalling the suite. Deliberately not a port of the 149 `llm.rs` tests: nothing here asserts backoff timing, retry counts or `Retry-After` parsing. That is goose's logic to test, and re-asserting it would rebuild the duplication this PR deletes. What is asserted is observable behaviour through goose — including that 401 keeps its distinct `-32001` code, which `buzz-acp` routes on (`acp.rs:118`) to tell a credentials problem apart from a generic provider failure. Every test verified falsifiable by mutating the provider, not by inspection: making the 429 terminal, serving 401 as 500, letting the "persistent" 500 succeed, and keeping the second turn broken each fail the intended assertion with a clear message. That exercise also showed `BUZZ_AGENT_LLM_TIMEOUT_SECS` is load-bearing rather than test impatience: with the fake provider sleeping on a half-written stream the turn still ends, and with the timeout removed the prompt never answers at all. So the projection added in the previous commit is what rescues a provider that holds a socket open — noted at the line that depends on it. Co-authored-by: Michael Neale <michael.neale@gmail.com> Signed-off-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
//! What a turn does when the provider misbehaves.
|
||||
//!
|
||||
//! `stdio_turn.rs` proves the happy path end-to-end against a real socket. It
|
||||
//! only ever answers `200`, so nothing there covers the unhappy half: a
|
||||
//! rate-limited turn, an expired key, a hard server error, a stream that dies
|
||||
//! mid-sentence.
|
||||
//!
|
||||
//! buzz used to cover that in `llm.rs`, against its own HTTP client. goose owns
|
||||
//! that client now, and these tests are deliberately **not** a port of those:
|
||||
//! they assert nothing about backoff timing, retry counts or `Retry-After`
|
||||
//! parsing, because that is goose's logic to test and re-asserting it here
|
||||
//! would rebuild the duplication this PR exists to delete.
|
||||
//!
|
||||
//! What they pin is the property buzz is responsible for and a user actually
|
||||
//! feels: **a misbehaving provider ends the turn.** Every one of these
|
||||
//! scenarios must produce a JSON-RPC response — a `stopReason` or an `error` —
|
||||
//! and never a hang, a dropped request, or a crashed agent. A turn that never
|
||||
//! answers is the worst failure mode available to us: `buzz-acp` waits on this
|
||||
//! response, so silence means an agent that has visibly stopped replying in a
|
||||
//! channel with nothing in the log to explain it.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// How a scripted provider should answer one request.
|
||||
///
|
||||
/// Named rather than a bare status code so a test reads as the scenario it
|
||||
/// describes, and so `TruncatedStream` — which is not expressible as a status —
|
||||
/// sits in the same vocabulary as the others.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Reply {
|
||||
/// A complete, well-formed SSE turn.
|
||||
Ok,
|
||||
/// Rate limited, with `Retry-After: 0` so a retrying client comes straight
|
||||
/// back and the test does not pay a real backoff.
|
||||
RateLimited,
|
||||
/// Expired or invalid credentials.
|
||||
Unauthorized,
|
||||
/// A hard server error, the non-retryable-looking kind.
|
||||
ServerError,
|
||||
/// Headers and a first chunk, then the connection dies with no
|
||||
/// `finish_reason` and no `[DONE]`. Models a provider or proxy dropping
|
||||
/// mid-stream, which is not the same as any HTTP status.
|
||||
TruncatedStream,
|
||||
}
|
||||
|
||||
/// A provider that answers from a script, one entry per request.
|
||||
///
|
||||
/// Requests past the end of the script get `Reply::Ok`, so a test states only
|
||||
/// the interesting prefix. The returned counter is the number of requests that
|
||||
/// actually arrived, which is how a test distinguishes "the client retried"
|
||||
/// from "the client gave up" without reaching into goose.
|
||||
fn spawn_scripted_provider(script: Vec<Reply>) -> (String, Arc<AtomicUsize>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let hits_for_thread = Arc::clone(&hits);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { continue };
|
||||
|
||||
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
|
||||
let mut request_line = String::new();
|
||||
if reader.read_line(&mut request_line).is_err() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut content_length = 0usize;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).is_err() {
|
||||
break;
|
||||
}
|
||||
let trimmed = line.trim_end();
|
||||
if trimmed.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(v) = trimmed
|
||||
.strip_prefix("content-length: ")
|
||||
.or_else(|| trimmed.strip_prefix("Content-Length: "))
|
||||
{
|
||||
content_length = v.parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
let mut body = vec![0u8; content_length];
|
||||
let _ = reader.read_exact(&mut body);
|
||||
|
||||
// The catalog endpoint always succeeds: these tests are about
|
||||
// completion failures, and a broken /models would fail the turn
|
||||
// earlier for an unrelated reason.
|
||||
if request_line.contains("/models") {
|
||||
let payload =
|
||||
json!({ "object": "list", "data": [{ "id": "fake-model", "object": "model" }] })
|
||||
.to_string();
|
||||
let _ = stream.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
payload.len(),
|
||||
payload
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let _ = stream.flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count only completion requests, so the number a test asserts on
|
||||
// is not inflated by catalog lookups.
|
||||
let n = hits_for_thread.fetch_add(1, Ordering::SeqCst);
|
||||
let reply = script.get(n).copied().unwrap_or(Reply::Ok);
|
||||
|
||||
let response = match reply {
|
||||
Reply::Ok => sse_ok(),
|
||||
Reply::RateLimited => error_response(
|
||||
429,
|
||||
"Too Many Requests",
|
||||
Some("retry-after: 0\r\n"),
|
||||
r#"{"error":{"message":"slow down","type":"rate_limit_exceeded"}}"#,
|
||||
),
|
||||
Reply::Unauthorized => error_response(
|
||||
401,
|
||||
"Unauthorized",
|
||||
None,
|
||||
r#"{"error":{"message":"invalid api key","type":"invalid_request_error"}}"#,
|
||||
),
|
||||
Reply::ServerError => error_response(
|
||||
500,
|
||||
"Internal Server Error",
|
||||
None,
|
||||
r#"{"error":{"message":"boom","type":"server_error"}}"#,
|
||||
),
|
||||
Reply::TruncatedStream => {
|
||||
// Deliberately no content-length: a length would let the
|
||||
// client detect a short body as a framing error rather
|
||||
// than an ended stream. Dropping the socket after one
|
||||
// chunk is what a real mid-stream death looks like.
|
||||
let chunk = json!({
|
||||
"id": "chatcmpl-truncated",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "fake-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": { "role": "assistant", "content": "I was saying" },
|
||||
"finish_reason": Value::Null,
|
||||
}],
|
||||
});
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\nconnection: close\r\n\r\ndata: {chunk}\n\n"
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
// Dropping `stream` here ends the body, which is what makes
|
||||
// `TruncatedStream` truncated.
|
||||
}
|
||||
});
|
||||
|
||||
(format!("http://{addr}"), hits)
|
||||
}
|
||||
|
||||
/// A complete SSE turn: two content chunks, then `finish_reason` with usage.
|
||||
fn sse_ok() -> String {
|
||||
let chunk = |delta: Value, finish: Value, usage: Value| {
|
||||
let mut c = json!({
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "fake-model",
|
||||
"choices": [{ "index": 0, "delta": delta, "finish_reason": finish }],
|
||||
});
|
||||
if !usage.is_null() {
|
||||
c["usage"] = usage;
|
||||
}
|
||||
format!("data: {c}\n\n")
|
||||
};
|
||||
let body = format!(
|
||||
"{}{}data: [DONE]\n\n",
|
||||
chunk(
|
||||
json!({ "role": "assistant", "content": "Recovered." }),
|
||||
Value::Null,
|
||||
Value::Null
|
||||
),
|
||||
chunk(
|
||||
json!({}),
|
||||
json!("stop"),
|
||||
json!({ "prompt_tokens": 11, "completion_tokens": 2, "total_tokens": 13 })
|
||||
),
|
||||
);
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
}
|
||||
|
||||
fn error_response(status: u16, reason: &str, extra_headers: Option<&str>, payload: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 {status} {reason}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n{}connection: close\r\n\r\n{payload}",
|
||||
payload.len(),
|
||||
extra_headers.unwrap_or(""),
|
||||
)
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
child: Child,
|
||||
stdin: std::process::ChildStdin,
|
||||
stdout: BufReader<std::process::ChildStdout>,
|
||||
next_id: i64,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn start(base_url: &str, home: &std::path::Path) -> Self {
|
||||
let exe = env!("CARGO_BIN_EXE_buzz-agent");
|
||||
let mut child = Command::new(exe)
|
||||
.env("BUZZ_AGENT_PROVIDER", "openai-compat")
|
||||
.env("BUZZ_AGENT_MODEL", "fake-model")
|
||||
.env("OPENAI_COMPAT_API_KEY", "test-key")
|
||||
.env("OPENAI_COMPAT_BASE_URL", base_url)
|
||||
.env("BUZZ_AGENT_MAX_ROUNDS", "2")
|
||||
// Load-bearing, not just impatience. A provider that holds the
|
||||
// socket open without finishing the stream is only rescued by the
|
||||
// request timeout, and this is the knob that sets it — verified by
|
||||
// making the fake provider sleep on a half-written stream: with
|
||||
// this line the turn still ends, without it the prompt never
|
||||
// answers. It also depends on the projection in `config.rs` that
|
||||
// carries this onto goose's per-provider variable; before that
|
||||
// projection existed this env var reached nothing.
|
||||
.env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "10")
|
||||
.env("HOME", home)
|
||||
.env("XDG_CONFIG_HOME", home.join("config"))
|
||||
.env("XDG_DATA_HOME", home.join("data"))
|
||||
.env("GOOSE_DISABLE_KEYRING", "1")
|
||||
.env("RUST_LOG", "warn")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.expect("spawn buzz-agent");
|
||||
|
||||
let stdin = child.stdin.take().expect("stdin");
|
||||
let stdout = BufReader::new(child.stdout.take().expect("stdout"));
|
||||
Self {
|
||||
child,
|
||||
stdin,
|
||||
stdout,
|
||||
next_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn request(&mut self, method: &str, params: Value) -> i64 {
|
||||
self.next_id += 1;
|
||||
let id = self.next_id;
|
||||
let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
|
||||
writeln!(self.stdin, "{msg}").expect("write");
|
||||
self.stdin.flush().expect("flush");
|
||||
id
|
||||
}
|
||||
|
||||
fn await_response(&mut self, id: i64) -> Value {
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let n = self.stdout.read_line(&mut line).expect("read");
|
||||
assert_ne!(n, 0, "agent closed stdout before responding to id={id}");
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else {
|
||||
continue;
|
||||
};
|
||||
if msg.get("id").and_then(Value::as_i64) == Some(id) {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `initialize` + `session/new`, returning the session id.
|
||||
fn open_session(&mut self, cwd: &std::path::Path) -> String {
|
||||
let id = self.request("initialize", json!({ "protocolVersion": 2 }));
|
||||
let _ = self.await_response(id);
|
||||
let id = self.request(
|
||||
"session/new",
|
||||
json!({ "cwd": cwd.to_str().unwrap(), "mcpServers": [] }),
|
||||
);
|
||||
let resp = self.await_response(id);
|
||||
resp["result"]["sessionId"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("no sessionId: {resp}"))
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Harness {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one prompt against a scripted provider and return the response plus the
|
||||
/// number of completion requests the provider saw.
|
||||
///
|
||||
/// The turn runs on a worker thread so the test can impose its own deadline: a
|
||||
/// hang is the failure mode most worth catching here, and without a deadline it
|
||||
/// would present as the whole suite stalling rather than as one failed test.
|
||||
fn prompt_once(script: Vec<Reply>) -> (Value, usize) {
|
||||
let (base_url, hits) = spawn_scripted_provider(script);
|
||||
let hits_for_assert = Arc::clone(&hits);
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("cwd");
|
||||
let mut h = Harness::start(&base_url, home.path());
|
||||
let session_id = h.open_session(cwd.path());
|
||||
|
||||
let id = h.request(
|
||||
"session/prompt",
|
||||
json!({
|
||||
"sessionId": session_id,
|
||||
"prompt": [{ "type": "text", "text": "say hello" }],
|
||||
}),
|
||||
);
|
||||
let resp = h.await_response(id);
|
||||
// `home`/`cwd` must outlive the child: dropping them first would pull
|
||||
// the session store out from under a still-running agent.
|
||||
let _ = tx.send(resp);
|
||||
drop(h);
|
||||
});
|
||||
|
||||
let resp = rx
|
||||
.recv_timeout(Duration::from_secs(90))
|
||||
.expect("session/prompt never answered — a turn that never returns hangs buzz-acp");
|
||||
worker.join().expect("harness thread panicked");
|
||||
(resp, hits_for_assert.load(Ordering::SeqCst))
|
||||
}
|
||||
|
||||
/// Every scenario must end the turn — with a `stopReason` or a JSON-RPC error,
|
||||
/// never silence. Returns the `stopReason` if there was one.
|
||||
fn assert_turn_ended(resp: &Value, scenario: &str) -> Option<String> {
|
||||
let has_result = resp["result"]["stopReason"].is_string();
|
||||
let has_error = resp["error"].is_object();
|
||||
assert!(
|
||||
has_result || has_error,
|
||||
"{scenario}: turn produced neither stopReason nor error, which hangs buzz-acp: {resp}"
|
||||
);
|
||||
resp["result"]["stopReason"].as_str().map(str::to_owned)
|
||||
}
|
||||
|
||||
/// A rate limit that clears must not be terminal.
|
||||
///
|
||||
/// `Retry-After: 0` keeps the test fast. The assertion that matters is the
|
||||
/// second request arriving at all: if a 429 ended the turn, the user would see
|
||||
/// an agent that stopped replying because the provider was briefly busy.
|
||||
#[test]
|
||||
fn rate_limited_then_ok_completes_the_turn() {
|
||||
let (resp, hits) = prompt_once(vec![Reply::RateLimited, Reply::Ok]);
|
||||
|
||||
let stop = assert_turn_ended(&resp, "429 then 200");
|
||||
assert!(
|
||||
stop.is_some(),
|
||||
"a cleared rate limit must still complete the turn: {resp}"
|
||||
);
|
||||
assert!(
|
||||
hits >= 2,
|
||||
"provider saw {hits} completion request(s): the 429 was treated as terminal rather than retried"
|
||||
);
|
||||
}
|
||||
|
||||
/// A bad key must fail the turn as an *auth* error specifically.
|
||||
///
|
||||
/// `-32001` is not cosmetic: `AgentError::LlmAuth` maps to it
|
||||
/// (`types.rs:102`) and `buzz-acp` routes on it (`acp.rs:118`,
|
||||
/// `agent_error_from_json`) to tell "your key is wrong" apart from a generic
|
||||
/// provider failure. Collapsing it into `-32000` would leave a user with an
|
||||
/// expired key seeing an unexplained failure instead of a credentials problem.
|
||||
///
|
||||
/// The bound matters too: 401 is not transient, so retrying it forever would
|
||||
/// turn one misconfigured agent into sustained load against the provider.
|
||||
#[test]
|
||||
fn unauthorized_ends_the_turn_as_an_auth_error() {
|
||||
let (resp, hits) = prompt_once(vec![Reply::Unauthorized; 8]);
|
||||
|
||||
assert_turn_ended(&resp, "401");
|
||||
assert_eq!(
|
||||
resp["error"]["code"], -32001,
|
||||
"401 must surface as LlmAuth (-32001), which buzz-acp routes on, not a generic provider error: {resp}"
|
||||
);
|
||||
assert!(
|
||||
hits <= 4,
|
||||
"provider saw {hits} completion requests for an invalid key — 401 is not transient and must not be retried indefinitely"
|
||||
);
|
||||
}
|
||||
|
||||
/// A persistent 500 must surface as a bounded, reported failure.
|
||||
///
|
||||
/// Two properties, both user-visible. It must *report* — a 500 is not an auth
|
||||
/// problem, so it takes the generic `-32000` arm and must not be silently
|
||||
/// converted into a successful empty turn, which would look like an agent that
|
||||
/// answered with nothing. And it must *stop*: retries are bounded, so one
|
||||
/// broken provider cannot be hammered indefinitely by a single prompt.
|
||||
#[test]
|
||||
fn server_error_ends_the_turn_and_stops_retrying() {
|
||||
let (resp, hits) = prompt_once(vec![Reply::ServerError; 8]);
|
||||
|
||||
assert_turn_ended(&resp, "500");
|
||||
assert!(
|
||||
resp["error"].is_object(),
|
||||
"a provider that never succeeded must not report a successful turn: {resp}"
|
||||
);
|
||||
assert_eq!(
|
||||
resp["error"]["code"], -32000,
|
||||
"a server error is not an auth error: {resp}"
|
||||
);
|
||||
assert!(
|
||||
hits <= 6,
|
||||
"provider saw {hits} completion requests: retries must stay bounded"
|
||||
);
|
||||
}
|
||||
|
||||
/// A stream that dies mid-sentence must still end the turn.
|
||||
///
|
||||
/// This is the scenario least likely to be handled by status-code logic: the
|
||||
/// response was a valid `200` and the failure is the *absence* of a terminator.
|
||||
/// A client waiting for `[DONE]` on a closed socket is exactly how a turn hangs
|
||||
/// forever.
|
||||
#[test]
|
||||
fn truncated_stream_ends_the_turn() {
|
||||
let (resp, hits) = prompt_once(vec![Reply::TruncatedStream; 4]);
|
||||
|
||||
let stop = assert_turn_ended(&resp, "truncated stream");
|
||||
assert!(
|
||||
stop.is_some() || resp["error"].is_object(),
|
||||
"a stream that died mid-sentence must resolve one way or the other: {resp}"
|
||||
);
|
||||
assert!(
|
||||
hits >= 1,
|
||||
"the request never reached the provider, so nothing was tested"
|
||||
);
|
||||
}
|
||||
|
||||
/// A transient failure must not poison the session.
|
||||
///
|
||||
/// Recovery within one turn (`rate_limited_then_ok_completes_the_turn`) and
|
||||
/// recovery across turns are different properties: goose holds per-session
|
||||
/// state, so a turn that failed hard could leave a session that never works
|
||||
/// again. That would present to a user as an agent permanently silent after one
|
||||
/// bad moment, which is worse than the original failure.
|
||||
#[test]
|
||||
fn a_session_survives_a_failed_turn() {
|
||||
let (base_url, hits) = spawn_scripted_provider(vec![Reply::ServerError; 4]);
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("cwd");
|
||||
let mut h = Harness::start(&base_url, home.path());
|
||||
let session_id = h.open_session(cwd.path());
|
||||
|
||||
let id = h.request(
|
||||
"session/prompt",
|
||||
json!({ "sessionId": session_id, "prompt": [{ "type": "text", "text": "first" }] }),
|
||||
);
|
||||
let first = h.await_response(id);
|
||||
|
||||
// The script is exhausted, so this turn is served `Reply::Ok`.
|
||||
let id = h.request(
|
||||
"session/prompt",
|
||||
json!({ "sessionId": session_id, "prompt": [{ "type": "text", "text": "second" }] }),
|
||||
);
|
||||
let second = h.await_response(id);
|
||||
|
||||
let _ = tx.send((first, second));
|
||||
drop(h);
|
||||
});
|
||||
|
||||
let (first, second) = rx
|
||||
.recv_timeout(Duration::from_secs(120))
|
||||
.expect("a prompt never answered after a failed turn");
|
||||
worker.join().expect("harness thread panicked");
|
||||
|
||||
assert_turn_ended(&first, "failing first turn");
|
||||
let recovered = assert_turn_ended(&second, "recovering second turn");
|
||||
assert!(
|
||||
recovered.is_some(),
|
||||
"the session did not recover once the provider did — a transient failure must not leave an agent permanently silent: {second}"
|
||||
);
|
||||
assert!(
|
||||
hits.load(Ordering::SeqCst) >= 2,
|
||||
"the second prompt never reached the provider"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user