Skip to main content

agent_engine/runtime/openai/
stream.rs

1//! Streaming path for OpenAI-compatible providers.
2//!
3//! Mirrors `ApiMethods::call_api_stream_inner` but speaks OpenAI chat/completions
4//! and translates back to Anthropic-shaped events for the rest of the runtime.
5
6use super::translate;
7use super::types::{
8    ChatContentPart, ChatMessage, OaiEvent, ProviderConfig, StreamOptions, ToolCall,
9};
10use super::wire::StreamDecoder;
11use crate::runtime::trace::openai as tr;
12use crate::runtime::types::StreamEvent;
13use futures::StreamExt;
14use serde_json::{json, Value};
15use tokio::sync::mpsc;
16
17/// Trace capture for one batch of decoded Chat Completions events: marks the
18/// first parsed model event and records provider-reported usage. Metadata
19/// only — token counts, no content.
20fn capture_oai_trace_signals(
21    events: &[OaiEvent],
22    attempt: &mut tr::StreamAttempt,
23    usage: &mut Option<crate::runtime::trace::UsageMeta>,
24) {
25    for ev in events {
26        if !matches!(ev, OaiEvent::Warning(_)) {
27            attempt.mark_first_model_event();
28        }
29        if let OaiEvent::Usage {
30            prompt_tokens,
31            completion_tokens,
32            cached_tokens,
33        } = ev
34        {
35            *usage = Some(tr::provider_usage(
36                u64::from(*prompt_tokens),
37                u64::from(*completion_tokens),
38                u64::from(*cached_tokens),
39            ));
40        }
41    }
42}
43
44/// Persistent retry posture for the Codex transport, mirroring the Anthropic
45/// OAuth overload budget (`OAUTH_OVERLOAD_RETRIES` in `runtime/api.rs`).
46///
47/// chatgpt.com edge turbulence — 503 upstream connect errors, Cloudflare
48/// 520s, raw TCP connect/read timeouts (incident: 2026-07-16 log bursts at
49/// 14:19, 18:52, 20:21, 20:27, 20:55, 21:47) — clears on its own within a
50/// burst; a three-attempt budget risks aborting an autonomous turn mid-burst.
51pub(crate) const CODEX_PERSISTENT_RETRIES: u32 = 10;
52
53/// Exponential-backoff exponent cap shared with the Anthropic stream retry
54/// path (`stream_retry_policy`): delay = base·2^min(n−1, CAP). Without the
55/// cap a 10-deep budget would sleep 1s·2⁹ = 512s on its final attempt.
56const RETRY_BACKOFF_EXP_CAP: u32 = 6;
57
58/// Effective Codex transport retry budget: the persistent floor, unless the
59/// user configured something even larger. Pure — decided at the dispatch
60/// seam (`try_route`), never inside `send_with_retries`, so tests can still
61/// inject exact budgets.
62pub(crate) fn codex_retry_budget(configured_retries: u32) -> u32 {
63    configured_retries.max(CODEX_PERSISTENT_RETRIES)
64}
65
66/// Backoff for retry attempt *n* (1-based): 1s·2^(n−1), capped at 64s.
67fn retry_delay(attempt: u32) -> std::time::Duration {
68    std::time::Duration::from_millis(
69        1000 * 2u64.pow(attempt.saturating_sub(1).min(RETRY_BACKOFF_EXP_CAP)),
70    )
71}
72
73/// Retrying a response never replays tools: only a complete Ok response reaches
74/// the agent tool loop. Reset uncommitted previews, not prior rounds or usage.
75async fn wait_stream_retry(
76    tx: &mpsc::UnboundedSender<StreamEvent>,
77    cancel: &tokio_util::sync::CancellationToken,
78    retry: u32,
79    max_retries: u32,
80    suppress_stream_deltas: bool,
81) -> Result<(), super::net::BoxedProviderError> {
82    use crate::{LlmEvent, SessionEvent};
83    if cancel.is_cancelled() {
84        return Err("request canceled".into());
85    }
86    if !suppress_stream_deltas {
87        let _ = tx.send(StreamEvent::Llm(LlmEvent::ResponseReset));
88    }
89    let _ = tx.send(StreamEvent::Session(SessionEvent::Retry(format!(
90        "Response incomplete — retrying response ({retry}/{max_retries}); incomplete output discarded, no tools replayed."
91    ))));
92    tokio::select! {
93        biased;
94        _ = cancel.cancelled() => Err("request canceled".into()),
95        _ = tokio::time::sleep(retry_delay(retry)) => Ok(()),
96    }
97}
98
99pub(crate) const STREAM_INTERRUPTED: &str = "openai request failed: connection interrupted before response completed (stream retry budget exhausted)";
100
101/// The endpoint one `send_with_retries` call talks to: its log label, the
102/// URL (for the localhost-refusal fast-fail) and whether its 429 responses
103/// carry Codex quota semantics worth probing. Static per call site; the
104/// retry budget and trace clock are threaded separately because they mutate.
105#[derive(Clone, Copy)]
106struct SendTarget<'a> {
107    label: &'a str,
108    url: &'a str,
109    /// Codex only: read a bounded slice of a 429 body to distinguish proven
110    /// quota exhaustion (fail fast → account failover) from a generic 429
111    /// (normal transport retry). Every other endpoint drops the body unread.
112    quota_probe: bool,
113}
114
115/// Send a provider streaming request, retrying transient failures.
116///
117/// Parity fix: the Anthropic path retries transient errors with backoff
118/// (`call_api_stream_inner`) but the provider routes were single-shot — one
119/// transport blip against e.g. `chatgpt.com` aborted an entire autonomous
120/// turn (incident: session 20260714-025948-3dab). Attempt *n* sleeps
121/// 1s·2^(n−1) capped at 2^`RETRY_BACKOFF_EXP_CAP`, mirroring the Anthropic
122/// budget semantics.
123///
124/// Retryable: transport-level send failures (timeout / connect / request)
125/// and 408 / 429 / 5xx responses. Deterministic client errors (other 4xx)
126/// and localhost connection refusals (Ollama/LM Studio not running — a
127/// setup problem, not a blip) fail fast. Backoff sleeps are cancel-aware.
128///
129/// Trace (Task 10A): one record per actual HTTP send — every retried
130/// failure is recorded via `attempt_failed` (status + retry class, never
131/// provider text) and the per-attempt clock restarts right before each
132/// re-send. Terminal failures emit their final record here; on success the
133/// caller finishes the attempt after consuming the stream.
134async fn send_with_retries(
135    target: SendTarget<'_>,
136    build: impl Fn() -> reqwest::RequestBuilder,
137    cancel: &tokio_util::sync::CancellationToken,
138    max_retries: u32,
139    retries_used: &mut u32,
140    trace_attempt: &mut tr::StreamAttempt,
141) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
142    use super::account_routing::{
143        classify_codex_429, read_body_for_classification, Codex429, CodexQuotaExhausted,
144        QUOTA_PROBE_BODY_CAP,
145    };
146    let SendTarget {
147        label,
148        url,
149        quota_probe,
150    } = target;
151    loop {
152        if cancel.is_cancelled() {
153            return Err("request canceled".into());
154        }
155        let sent = tokio::select! {
156            biased;
157            _ = cancel.cancelled() => { trace_attempt.finish_canceled(None, None); return Err("request canceled".into()); }
158            sent = build().send() => sent,
159        };
160        match sent {
161            Ok(resp) if resp.status().is_success() => {
162                trace_attempt.mark_headers();
163                return Ok(resp);
164            }
165            Ok(resp) => {
166                // A complete HTTP response was observed even when its status
167                // is non-success. Preserve that timing for retried and
168                // terminal failure records just as the success branch does.
169                trace_attempt.mark_headers();
170                let status = resp.status();
171                // Provider-assigned request id from validated headers only.
172                let trace_rid = tr::provider_request_id_from_headers(resp.headers());
173                let mut retryable =
174                    status.as_u16() == 408 || status.as_u16() == 429 || status.is_server_error();
175                if quota_probe && status.as_u16() == 429 {
176                    // Codex only: a 429 that PROVES the account's usage
177                    // window is spent must not burn the transport retry
178                    // budget (minutes of backoff that cannot succeed). The
179                    // body is read into a bounded buffer purely to produce
180                    // the classification enum and is dropped unread beyond
181                    // that — never stored, surfaced, or logged.
182                    let headers = resp.headers().clone();
183                    let body = tokio::select! {
184                        biased;
185                        _ = cancel.cancelled() => {
186                            trace_attempt.finish_canceled(Some(429), None);
187                            return Err("request canceled".into());
188                        }
189                        body = read_body_for_classification(resp, QUOTA_PROBE_BODY_CAP) => body,
190                    };
191                    match classify_codex_429(&headers, &body) {
192                        Codex429::QuotaExhausted(evidence) => {
193                            tracing::warn!(
194                                signal = ?evidence.signal,
195                                reset_after_secs = ?evidence.reset_after_secs,
196                                "{label} 429 is provider-declared quota exhaustion (no transport retry)"
197                            );
198                            return Err(Box::new(CodexQuotaExhausted {
199                                evidence,
200                                request_id: trace_rid,
201                            }));
202                        }
203                        Codex429::UsageNotIncluded => {
204                            // Deterministic for this account: retrying never helps.
205                            tracing::warn!("{label} 429: account has no Codex usage entitlement");
206                            retryable = false;
207                        }
208                        Codex429::Generic => {}
209                    }
210                } else if quota_probe && !retryable && status.is_client_error() {
211                    // Codex only: a terminal 4xx is undiagnosable without its
212                    // body. Read a bounded slice for classification; keep only
213                    // identifier-shaped type/code/param plus Synaps-authored
214                    // text (codex_wire). Provider prose is never kept (§5.1).
215                    let body = tokio::select! {
216                        biased;
217                        _ = cancel.cancelled() => {
218                            trace_attempt.finish_canceled(Some(status.as_u16()), None);
219                            return Err("request canceled".into());
220                        }
221                        body = read_body_for_classification(resp, super::codex_wire::CODEX_ERROR_BODY_CAP) => body,
222                    };
223                    let detail = super::codex_wire::CodexHttpErrorDetail::from_body(&body);
224                    let message = detail.user_message(label, status);
225                    tracing::warn!(error = %message, "{label} request rejected (identifiers only)");
226                    trace_attempt.finish_failed(
227                        &detail.trace_code(status.as_u16()),
228                        Some(status.as_u16()),
229                        trace_rid,
230                    );
231                    return Err(Box::new(super::codex_wire::CodexRequestRejected {
232                        message,
233                        detail,
234                    }));
235                } else {
236                    // Privacy (spec §5.1): the response body is provider-controlled
237                    // and may echo the full request (prompts, system text, tool
238                    // schemas, credentials). Drop it unread — it must never be
239                    // stored, surfaced, or logged at any level. `status` Display
240                    // uses the canonical reason phrase, never server bytes. (The
241                    // Codex 4xx arm above reads it for identifiers only.)
242                    drop(resp);
243                }
244                let code = format!("http_{}", status.as_u16());
245                if !retryable || *retries_used >= max_retries {
246                    trace_attempt.finish_failed(&code, Some(status.as_u16()), trace_rid);
247                    return Err(format!("{label} request failed: {status}").into());
248                }
249                *retries_used += 1;
250                let delay = retry_delay(*retries_used);
251                trace_attempt.attempt_failed(
252                    tr::retry_class_for_status(status.as_u16()),
253                    delay,
254                    Some(status.as_u16()),
255                    trace_rid,
256                    &code,
257                );
258                tracing::warn!(
259                    "{label} API retry {retries_used}/{max_retries} after {delay:?}: {status}"
260                );
261                tokio::select! {
262                    _ = tokio::time::sleep(delay) => {}
263                    _ = cancel.cancelled() => {
264                        trace_attempt.finish_canceled(None, None);
265                        return Err("request canceled".into());
266                    }
267                }
268                // Retry clocks reset per attempt, right before the re-send.
269                trace_attempt.restart_clock();
270            }
271            Err(e) => {
272                let localhost_refusal = e.is_connect() && url.contains("localhost");
273                let transient = e.is_timeout() || e.is_connect() || e.is_request();
274                if localhost_refusal || !transient || *retries_used >= max_retries {
275                    tracing::warn!(
276                        "{label} request failed (no retry): {}",
277                        crate::core::error::error_chain_string(&e)
278                    );
279                    trace_attempt.finish_failed("transport_error", None, None);
280                    return Err(e.into());
281                }
282                *retries_used += 1;
283                let delay = retry_delay(*retries_used);
284                trace_attempt.attempt_failed(
285                    if e.is_timeout() {
286                        crate::runtime::trace::RetryClass::Timeout
287                    } else {
288                        crate::runtime::trace::RetryClass::Other
289                    },
290                    delay,
291                    None,
292                    None,
293                    "transport_error",
294                );
295                tracing::warn!(
296                    "{label} transport retry {retries_used}/{max_retries} after {delay:?}: {}",
297                    crate::core::error::error_chain_string(&e)
298                );
299                tokio::select! {
300                    _ = tokio::time::sleep(delay) => {}
301                    _ = cancel.cancelled() => {
302                        trace_attempt.finish_canceled(None, None);
303                        return Err("request canceled".into());
304                    }
305                }
306                // Retry clocks reset per attempt, right before the re-send.
307                trace_attempt.restart_clock();
308            }
309        }
310    }
311}
312
313/// Run a single streaming request against an OpenAI-compatible endpoint.
314///
315/// Returns the final assistant response as an Anthropic-shaped content Value
316/// (`{"content": [..text.., ..tool_use..]}`) so the outer agent loop can keep
317/// using the same handling as the native Anthropic path.
318#[allow(clippy::too_many_arguments)]
319pub(crate) async fn call_oai_stream_inner(
320    cfg: &ProviderConfig,
321    broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
322    tools_schema: &[Value],
323    system_prompt: &Option<String>,
324    messages: &[crate::SharedMessage],
325    tx: &mpsc::UnboundedSender<StreamEvent>,
326    temperature: Option<f32>,
327    max_tokens: Option<u32>,
328    thinking_budget: u32,
329    reasoning_level: agent_core::reasoning::ReasoningLevel,
330    cancel: &tokio_util::sync::CancellationToken,
331    trace: &crate::runtime::trace::TraceContext,
332    exact_wire_bytes: bool,
333    suppress_stream_deltas: bool,
334    max_retries: u32,
335) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
336    let (oai_tools, name_map) = translate::tools_to_oai(tools_schema);
337    let oai_messages = translate::messages_to_oai(messages, system_prompt, &name_map);
338    let tools_opt = if oai_tools.is_empty() {
339        None
340    } else {
341        Some(oai_tools)
342    };
343
344    // Google's OpenAI-compat endpoint rejects stream_options
345    let stream_options = if cfg.base_url.contains("googleapis.com") {
346        None
347    } else {
348        Some(StreamOptions {
349            include_usage: true,
350        })
351    };
352
353    let mut body = serde_json::Map::new();
354    body.insert("model".to_string(), json!(cfg.model.clone()));
355    body.insert("messages".to_string(), serde_json::to_value(oai_messages)?);
356    body.insert("stream".to_string(), json!(true));
357    // ZDR: OpenAI's Zero Data Retention requires `store:false` on the wire.
358    // Chat Completions already defaults to false, but we assert it explicitly so the
359    // guarantee is provable in request logs (parity with the Codex Responses path,
360    // build_codex_body). Gated to api.openai.com (covers eu.api.openai.com) so we
361    // never send an unknown field to OpenAI-compatible providers that strict-validate
362    // the body (Groq/xAI/Google), mirroring the googleapis stream_options carve-out.
363    if cfg.base_url.contains("api.openai.com") {
364        body.insert("store".to_string(), json!(false));
365    }
366    if let Some(stream_options) = stream_options {
367        body.insert(
368            "stream_options".to_string(),
369            serde_json::to_value(stream_options)?,
370        );
371    }
372    if let Some(max_tokens) = max_tokens {
373        body.insert("max_tokens".to_string(), json!(max_tokens));
374    }
375    if let Some(temperature) = temperature {
376        body.insert("temperature".to_string(), json!(temperature));
377    }
378    if let Some(tools) = tools_opt {
379        body.insert("tools".to_string(), serde_json::to_value(tools)?);
380    }
381    super::reasoning::apply_openai_reasoning_params(
382        &mut body,
383        super::reasoning::provider_for_key(&cfg.provider),
384        &cfg.model,
385        thinking_budget,
386        reasoning_level,
387    );
388    let body = Value::Object(body);
389
390    tracing::debug!(provider=%cfg.provider, model=%cfg.model, "openai stream request via broker proxy");
391
392    // Serialize ONCE via the sanctioned constructor: the returned bytes are
393    // both digested for the trace and the very buffer stored on the request
394    // (`body_bytes`), which `LocalBroker` sends verbatim — the digest is
395    // never computed over a re-serialization, and `body`/`body_bytes`
396    // cannot diverge.
397    let (proxy_request, body_bytes) = crate::auth::ProxyRequest::post_json_exact(
398        cfg.provider.clone(),
399        "/chat/completions",
400        body,
401        true,
402    )?;
403    // ═══ TRACE (Task 10A): one record per actual attempt ═══════════════════
404    // A remote broker re-serializes the JSON out of process: honest kind
405    // `CloudProxy`, no wire-byte claim. Rule documented in `trace::emit`.
406    let tracer = tr::begin_openai_tracer(
407        trace,
408        &cfg.provider,
409        &cfg.model,
410        if exact_wire_bytes {
411            crate::runtime::trace::TransportKind::OpenAiChatCompletions
412        } else {
413            crate::runtime::trace::TransportKind::CloudProxy
414        },
415        &format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')),
416        exact_wire_bytes.then_some(body_bytes.as_ref()),
417        messages,
418        system_prompt.as_deref(),
419        tools_schema,
420        tr::renamed_tool_losses(&name_map),
421    )
422    .await;
423    // One-shot explicit content capture (`/trace next content`): a no-op in
424    // every context without the arm. `body_bytes` is the serialized request
425    // body this process built pre-send (exact wire bytes on the local
426    // broker; the same body a remote broker re-serializes) — body only,
427    // headers and credentials structurally never reach this seam.
428    if let Some(tracer) = &tracer {
429        tr::capture_request_content(trace, tracer.request_id(), body_bytes.as_ref(), messages);
430    }
431    let mut attempt = tr::StreamAttempt::new(tracer);
432
433    let mut stream_retry = 0u32;
434    loop {
435        if cancel.is_cancelled() {
436            return Err("request canceled".into());
437        }
438        // The broker owns the API key and executes/signs the request; this path
439        // never resolves or attaches a credential.
440        let stream = crate::runtime::api::await_or_cancel(
441            cancel,
442            broker.proxy_stream(proxy_request.clone()),
443        )
444        .await
445        .map_err(|_| "request canceled")?
446        // Privacy (spec §5.1): a broker proxy error may carry an upstream
447        // response-body snippet; redact it to status-only before surfacing.
448        .map_err(|e| {
449            format!(
450                "openai request failed: {}",
451                super::net::redact_provider_proxy_error(&e.to_string())
452            )
453        });
454        let mut stream = match stream {
455            Ok(stream) => {
456                attempt.mark_headers();
457                stream
458            }
459            Err(msg) => {
460                // Status parsed from the redacted static-prefix message only;
461                // codes are `http_<status>` or the static `broker_error`.
462                let status = tr::broker_error_status(&msg);
463                let code =
464                    status.map_or_else(|| "broker_error".to_string(), |s| format!("http_{s}"));
465                attempt.finish_failed(&code, status, None);
466                return Err(msg.into());
467            }
468        };
469
470        if !suppress_stream_deltas {
471            let _ = tx.send(StreamEvent::Llm(crate::LlmEvent::ResponseStart));
472        }
473        let mut interrupted = false;
474        let mut saw_done = false;
475        let mut decoder = StreamDecoder::new();
476        let mut accumulated_text = String::new();
477        let mut tool_use_blocks: Vec<Value> = Vec::new();
478        let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
479        let mut sink: Vec<OaiEvent> = Vec::with_capacity(4);
480        let mut trace_usage: Option<crate::runtime::trace::UsageMeta> = None;
481
482        while let Some(chunk) = tokio::select! {
483            biased;
484            _ = cancel.cancelled() => {
485                attempt.finish_canceled(None, trace_usage);
486                return Err("request canceled".into());
487            }
488            chunk = stream.next() => chunk,
489        } {
490            let chunk = match chunk {
491                Ok(chunk) => {
492                    attempt.mark_first_byte();
493                    chunk
494                }
495                Err(e) => {
496                    if !matches!(e, crate::auth::BrokerError::Transport(_)) {
497                        attempt.finish_failed("broker_error", None, None);
498                        return Err(e.into());
499                    }
500                    interrupted = !saw_done && decoder.finish_reason.is_none();
501                    break;
502                }
503            };
504            buf.extend_from_slice(&chunk);
505
506            // Scan for newline-delimited SSE lines (SIMD-accelerated via memchr)
507            while let Some(nl) = memchr::memchr(b'\n', &buf) {
508                let line_bytes = buf.split_to(nl + 1); // O(1) — ref-counted split
509                let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
510
511                sink.clear();
512                decoder.push_line(line, &mut sink);
513                saw_done |= sink.iter().any(|e| matches!(e, OaiEvent::Done));
514                capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
515                handle_events(
516                    &sink,
517                    tx,
518                    &mut accumulated_text,
519                    &mut tool_use_blocks,
520                    &name_map,
521                    suppress_stream_deltas,
522                );
523            }
524        }
525
526        // Flush any remaining buffered line + final Done
527        if !interrupted && !buf.is_empty() {
528            let line = std::str::from_utf8(&buf).unwrap_or("");
529            sink.clear();
530            decoder.push_line(line, &mut sink);
531            saw_done |= sink.iter().any(|e| matches!(e, OaiEvent::Done));
532            capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
533            handle_events(
534                &sink,
535                tx,
536                &mut accumulated_text,
537                &mut tool_use_blocks,
538                &name_map,
539                suppress_stream_deltas,
540            );
541        }
542        if interrupted || (!saw_done && decoder.finish_reason.is_none()) {
543            if stream_retry >= max_retries {
544                attempt.finish_failed("stream_error", None, None);
545                return Err(STREAM_INTERRUPTED.into());
546            }
547            stream_retry += 1;
548            attempt.attempt_failed(
549                crate::runtime::trace::RetryClass::Network,
550                retry_delay(stream_retry),
551                None,
552                None,
553                "stream_error",
554            );
555            wait_stream_retry(
556                tx,
557                cancel,
558                stream_retry,
559                max_retries,
560                suppress_stream_deltas,
561            )
562            .await
563            .inspect_err(|_| {
564                attempt.finish_canceled(None, None);
565            })?;
566            attempt.restart_clock();
567            continue;
568        }
569        sink.clear();
570        decoder.finish(&mut sink);
571        capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
572        handle_events(
573            &sink,
574            tx,
575            &mut accumulated_text,
576            &mut tool_use_blocks,
577            &name_map,
578            suppress_stream_deltas,
579        );
580
581        // Normalized stop reason: only when a finish_reason was actually
582        // observed on the wire — never inferred.
583        let stop_reason = decoder
584            .finish_reason
585            .as_deref()
586            .map(tr::stop_reason_from_finish_reason);
587        // Broker paths never observe the upstream HTTP status: honest `None`.
588        attempt.finish_success(None, None, stop_reason, trace_usage);
589
590        // Build Anthropic-shaped final response
591        let mut content: Vec<Value> = Vec::new();
592        if !accumulated_text.is_empty() {
593            content.push(json!({"type": "text", "text": accumulated_text}));
594        }
595        content.extend(tool_use_blocks);
596
597        return Ok(json!({
598            "role": "assistant",
599            "content": content,
600        }));
601    }
602}
603
604#[allow(clippy::too_many_arguments)]
605pub(crate) async fn call_codex_stream_inner(
606    cfg: &ProviderConfig,
607    client: &reqwest::Client,
608    broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
609    tools_schema: &[Value],
610    system_prompt: &Option<String>,
611    messages: &[crate::SharedMessage],
612    tx: &mpsc::UnboundedSender<StreamEvent>,
613    temperature: Option<f32>,
614    max_tokens: Option<u32>,
615    reasoning_level: agent_core::reasoning::ReasoningLevel,
616    codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
617    cancel: &tokio_util::sync::CancellationToken,
618    max_retries: u32,
619    trace: &crate::runtime::trace::TraceContext,
620) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
621    // Account selection crosses the broker boundary through the router seam:
622    // the broker vends one (credential, access token) pair, never a refresh
623    // token, and this path never opens auth.json.
624    let router = super::account_routing::BrokerCodexRouter::new(std::sync::Arc::clone(broker));
625    call_codex_stream_with_router(
626        cfg,
627        client,
628        &router,
629        tools_schema,
630        system_prompt,
631        messages,
632        tx,
633        temperature,
634        max_tokens,
635        reasoning_level,
636        codex_request_role,
637        cancel,
638        max_retries,
639        trace,
640    )
641    .await
642}
643
644/// What the failover gate judges: the recognized exhaustion on the current
645/// attempt and the two runtime facts that can veto a switch. Built at each
646/// of the two detection sites (429 probe, in-stream terminal error) so both
647/// go through the identical invariant check.
648#[derive(Clone, Copy)]
649struct FailoverRequest<'a> {
650    evidence: &'a super::account_routing::CodexQuotaEvidence,
651    /// Any content-bearing model event reached the decoder on this attempt
652    /// (text, tool, reasoning — forwarded or not). Always false on the 429
653    /// path: no response body was streamed.
654    output_started: bool,
655    /// Account switches already spent on this logical request.
656    prior_failovers: u32,
657}
658
659/// Attempt a single bounded account failover after recognized quota
660/// exhaustion on `failed`. Always reports the exhaustion to the router
661/// (cooldown sink, best effort) — even when the runtime then refuses to
662/// switch — so the broker stops advertising the seat. Returns the new pinned
663/// account only when every runtime invariant holds: `Auto` selection, no
664/// output produced, budget unspent, a candidate the router deems fresh and
665/// eligible, and that candidate is a DIFFERENT account than the one that
666/// failed. Anything else → `Ok(None)` and the caller ends the turn with the
667/// static quota message. Never loops.
668async fn try_codex_account_failover(
669    router: &dyn super::account_routing::CodexAccountRouter,
670    model: &str,
671    failed: &super::account_routing::PinnedAccount,
672    request: FailoverRequest<'_>,
673    tx: &mpsc::UnboundedSender<StreamEvent>,
674    cancel: &tokio_util::sync::CancellationToken,
675) -> Result<Option<super::account_routing::PinnedAccount>, super::net::BoxedProviderError> {
676    use super::account_routing::failover_gate;
677    use crate::runtime::api::await_or_cancel;
678    let FailoverRequest {
679        evidence,
680        output_started,
681        prior_failovers,
682    } = request;
683    await_or_cancel(cancel, router.report_exhausted(failed, evidence))
684        .await
685        .map_err(|_| "request canceled")?;
686    if let Err(blocked) = failover_gate(failed.auto, output_started, prior_failovers) {
687        tracing::info!(
688            account = %failed.credential,
689            reason = blocked.as_str(),
690            "codex quota exhausted; account failover not permitted"
691        );
692        return Ok(None);
693    }
694    let candidate = await_or_cancel(
695        cancel,
696        router.failover(model, failed, evidence, prior_failovers),
697    )
698    .await
699    .map_err(|_| "request canceled")?;
700    match candidate {
701        Ok(Some(next)) if next.credential != failed.credential => {
702            tracing::warn!(
703                from = %failed.credential,
704                to = %next.credential,
705                "codex quota exhausted; switching account (one-time failover, no output replayed)"
706            );
707            let _ = tx.send(StreamEvent::Session(crate::SessionEvent::Notice(format!(
708                "Codex usage quota exhausted on account '{}' — switched to account '{}' (one-time failover; no output was replayed).",
709                failed.label(),
710                next.label()
711            ))));
712            Ok(Some(next))
713        }
714        Ok(Some(_same)) => {
715            tracing::warn!(
716                account = %failed.credential,
717                "codex failover candidate is the exhausted account itself; refusing to retry"
718            );
719            Ok(None)
720        }
721        Ok(None) => {
722            tracing::info!(
723                account = %failed.credential,
724                "codex quota exhausted; no distinct account with fresh capacity"
725            );
726            Ok(None)
727        }
728        Err(e) => {
729            tracing::warn!(
730                account = %failed.credential,
731                error = %e,
732                "codex failover candidate resolution failed; not retrying"
733            );
734            Ok(None)
735        }
736    }
737}
738
739#[allow(clippy::too_many_arguments)]
740pub(crate) async fn call_codex_stream_with_router(
741    cfg: &ProviderConfig,
742    client: &reqwest::Client,
743    router: &dyn super::account_routing::CodexAccountRouter,
744    tools_schema: &[Value],
745    system_prompt: &Option<String>,
746    messages: &[crate::SharedMessage],
747    tx: &mpsc::UnboundedSender<StreamEvent>,
748    temperature: Option<f32>,
749    max_tokens: Option<u32>,
750    reasoning_level: agent_core::reasoning::ReasoningLevel,
751    codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
752    cancel: &tokio_util::sync::CancellationToken,
753    max_retries: u32,
754    trace: &crate::runtime::trace::TraceContext,
755) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
756    // Build the exact provider-qualified plan before any credential or network
757    // access. Logical Ultra is lowered here, never in the generic level enum.
758    use crate::runtime::openai::catalog::plan_codex_execution;
759    let qualified_model = format!("{}/{}", cfg.provider, cfg.model);
760    let plan =
761        match plan_codex_execution(&qualified_model, reasoning_level, codex_request_role, None) {
762            Ok(plan) => plan,
763            Err(error) => {
764                tracing::debug!(
765                    event = "codex_mode_plan",
766                    qualified_model = %qualified_model,
767                    requested_level = %reasoning_level,
768                    runtime_role = codex_request_role.as_str(),
769                    decision = "deny",
770                    deny_code = error.code().as_str(),
771                    network_attempted = false,
772                    "Codex execution plan denied"
773                );
774                return Err(Box::new(error));
775            }
776        };
777    if plan.automatic_delegation() && !codex_has_required_delegation_tools(tools_schema) {
778        tracing::debug!(
779            event = "codex_mode_plan",
780            qualified_model = %qualified_model,
781            requested_level = %reasoning_level,
782            runtime_role = codex_request_role.as_str(),
783            decision = "deny",
784            deny_code = "ultra_requires_subagent_tools",
785            network_attempted = false,
786            "Codex execution plan denied"
787        );
788        return Err(
789            "Ultra requires subagent_start, subagent_status, and subagent_collect tools".into(),
790        );
791    }
792    tracing::debug!(
793        event = "codex_mode_plan",
794        qualified_model = %plan.qualified_model,
795        requested_level = %plan.selected_level,
796        execution_mode = plan.mode.as_str(),
797        wire_effort = plan.wire_effort_label(),
798        capability_source = plan.capability_source.map_or("none", |source| source.as_str()),
799        multi_agent_version = plan.multi_agent_version_label(),
800        runtime_role = plan.request_role.as_str(),
801        multi_agent_mode = plan.multi_agent_mode_label(),
802        automatic_delegation = plan.automatic_delegation(),
803        decision = "allow",
804        network_attempted = false,
805        "Codex execution plan allowed"
806    );
807    // Exactly one pinned (account, token) pair per attempt. The
808    // `chatgpt-account-id` header is derived from THIS token's JWT claims, so
809    // bearer and header always name the same credential — a second lookup
810    // could race a policy change and pair account A's header with account
811    // B's token.
812    let mut pinned = crate::runtime::api::await_or_cancel(cancel, router.pin(&cfg.model))
813        .await
814        .map_err(|_| "request canceled")??;
815    let mut account_id = codex_account_id_for(&pinned)?;
816
817    let (tools, name_map) = translate::tools_to_responses(tools_schema);
818    // Opt-in OpenAI-hosted web search (SYNAPS_CODEX_WEB_SEARCH, read from THIS
819    // process's env — the daemon's when attached). Runs server-side, so hooks
820    // can't gate it; gated by role and model (codex_wire). The session actor
821    // announces the state; each call is announced by the decoder.
822    let mut tools = tools;
823    let web_search = super::codex_wire::codex_web_search_for_request(
824        &cfg.model,
825        codex_request_role,
826        super::codex_wire::engine_env,
827    );
828    // web_search is always the last tool. Cleared on the one resend without
829    // it, so that resend can't loop; the rejection is learned only if the
830    // resend then succeeds (`web_search_pending`).
831    let mut web_search_index = web_search.as_ref().map(|_| tools.len());
832    let mut web_search_pending = false;
833    tools.extend(web_search);
834    // Codex already receives the effective system prompt in `instructions`.
835    // Do not also prepend it to `input`: that doubles prompt tokens and repeats
836    // context/checkpoint guidance. Pass history to the translator unchanged;
837    // only the synthetic prepend is suppressed. Other providers keep their placement.
838    let oai_messages = translate::messages_to_oai(messages, &None, &name_map);
839
840    // Use the shared pure helper so production and unit tests exercise identical
841    // body construction — no duplication between call_codex_stream_inner and tests.
842    let instructions = codex_instructions(system_prompt);
843    let mut input_items = codex_input_messages(oai_messages);
844    // Key on the pre-insertion head: the mode item varies with the execution
845    // plan (level/role switches) and must not churn the routing key.
846    let prompt_cache_key = codex_prompt_cache_key(&instructions, input_items.first());
847    insert_codex_multi_agent_mode(&mut input_items, &plan);
848    let input = serde_json::Value::Array(input_items);
849    let mut body = build_codex_body(
850        &cfg.model,
851        &plan,
852        input,
853        instructions,
854        tools,
855        temperature,
856        max_tokens,
857        &prompt_cache_key,
858    );
859
860    let url = format!(
861        "{}/codex/responses",
862        cfg.base_url
863            .trim_end_matches('/')
864            .trim_end_matches("/codex")
865    );
866    tracing::debug!(url=%url, model=%cfg.model, "codex stream request");
867
868    // Serialize ONCE. These exact bytes are digested for the trace AND are
869    // the request body of every attempt — retries resend the identical
870    // buffer, so one wire digest is honest for all attempt records. The one
871    // exception (hosted web_search rejected) re-serializes and opens a new
872    // trace record for the new bytes.
873    let mut body_bytes = bytes::Bytes::from(serde_json::to_vec(&body)?);
874    // ═══ TRACE (Task 10A): one record per actual attempt ═══════════════════
875    let tracer = tr::begin_openai_tracer(
876        trace,
877        &cfg.provider,
878        &cfg.model,
879        crate::runtime::trace::TransportKind::OpenAiResponses,
880        &url,
881        Some(body_bytes.as_ref()),
882        messages,
883        system_prompt.as_deref(),
884        tools_schema,
885        tr::renamed_tool_losses(&name_map),
886    )
887    .await;
888    // One-shot explicit content capture (`/trace next content`): a no-op in
889    // every context without the arm. `body_bytes` is the serialized request
890    // body this process built pre-send (exact wire bytes on the local
891    // broker; the same body a remote broker re-serializes) — body only,
892    // headers and credentials structurally never reach this seam.
893    if let Some(tracer) = &tracer {
894        tr::capture_request_content(trace, tracer.request_id(), body_bytes.as_ref(), messages);
895    }
896    // Whether this request holds a tracer (and, on a `/trace next` context,
897    // the one-shot gate): only then may a web_search resend reopen it.
898    let traced = tracer.is_some();
899    let mut attempt = tr::StreamAttempt::new(tracer);
900    let mut stream_retry = 0u32;
901    // Account switches spent on this logical request. Bounded by
902    // `MAX_CODEX_ACCOUNT_FAILOVERS`; independent of the transport retry
903    // budget (a failover neither consumes nor resets `stream_retry`).
904    let mut failovers = 0u32;
905
906    loop {
907        if cancel.is_cancelled() {
908            return Err("request canceled".into());
909        }
910        let sent = send_with_retries(
911            SendTarget {
912                label: "codex",
913                url: &url,
914                quota_probe: true,
915            },
916            || {
917                client
918                    .post(&url)
919                    .bearer_auth(&pinned.token)
920                    .header("chatgpt-account-id", account_id.as_str())
921                    .header("originator", "synaps")
922                    .header("OpenAI-Beta", "responses=experimental")
923                    // Cache affinity: the ChatGPT backend routes prompt-cache
924                    // lookups by the `session-id` header (Codex CLI sends its
925                    // conversation id as both headers and as
926                    // `prompt_cache_key`). Without it, every request reported
927                    // cached_tokens = 0, even with a byte-identical prefix.
928                    .header("session-id", prompt_cache_key.as_str())
929                    .header("thread-id", prompt_cache_key.as_str())
930                    .header("content-type", "application/json")
931                    .header("accept", "text/event-stream")
932                    .body(body_bytes.clone())
933            },
934            cancel,
935            max_retries,
936            &mut stream_retry,
937            &mut attempt,
938        )
939        .await;
940        let resp = match sent {
941            Ok(resp) => {
942                if std::mem::take(&mut web_search_pending) {
943                    super::codex_wire::confirm_web_search_rejected(&cfg.model, tx);
944                }
945                resp
946            }
947            Err(error) => match error.downcast::<super::account_routing::CodexQuotaExhausted>() {
948                // A 429 that proved exhaustion arrives before any response
949                // body was streamed: nothing was produced on this attempt, so
950                // the pre-output invariant holds by construction.
951                Ok(quota) => {
952                    match try_codex_account_failover(
953                        router,
954                        &cfg.model,
955                        &pinned,
956                        FailoverRequest {
957                            evidence: &quota.evidence,
958                            output_started: false,
959                            prior_failovers: failovers,
960                        },
961                        tx,
962                        cancel,
963                    )
964                    .await?
965                    {
966                        Some(next) => {
967                            attempt.attempt_failed(
968                                crate::runtime::trace::RetryClass::RateLimited,
969                                std::time::Duration::ZERO,
970                                Some(429),
971                                quota.request_id.clone(),
972                                "account_failover",
973                            );
974                            account_id = codex_account_id_for(&next)?;
975                            pinned = next;
976                            failovers += 1;
977                            attempt.restart_clock();
978                            continue;
979                        }
980                        None => {
981                            let message = quota.to_string();
982                            attempt.finish_failed("quota_exhausted", Some(429), quota.request_id);
983                            return Err(message.into());
984                        }
985                    }
986                }
987                Err(other) => {
988                    // A pre-inference 400 pointing at hosted web_search: drop
989                    // it, re-serialize, and resend once under a fresh trace
990                    // record (one-shot `/trace next` gates are reopened for
991                    // it; content capture is not redone).
992                    super::codex_wire::hosted_search_retry(
993                        other,
994                        &mut web_search_index,
995                        &cfg.model,
996                    )?;
997                    web_search_pending = true;
998                    super::codex_wire::strip_web_search_tool(&mut body);
999                    body_bytes = bytes::Bytes::from(serde_json::to_vec(&body)?);
1000                    if traced {
1001                        trace.reopen_one_shot_for_resend();
1002                    }
1003                    attempt = tr::StreamAttempt::new(
1004                        tr::begin_openai_tracer(
1005                            trace,
1006                            &cfg.provider,
1007                            &cfg.model,
1008                            crate::runtime::trace::TransportKind::OpenAiResponses,
1009                            &url,
1010                            Some(body_bytes.as_ref()),
1011                            messages,
1012                            system_prompt.as_deref(),
1013                            tools_schema,
1014                            tr::renamed_tool_losses(&name_map),
1015                        )
1016                        .await,
1017                    );
1018                    continue;
1019                }
1020            },
1021        };
1022        // Direct HTTP: upstream status and provider request id are observed.
1023        let http_status = Some(resp.status().as_u16());
1024        let trace_rid = tr::provider_request_id_from_headers(resp.headers());
1025
1026        let _ = tx.send(StreamEvent::Llm(crate::LlmEvent::ResponseStart));
1027        let mut interrupted = false;
1028        let mut accumulated_text = String::new();
1029        let mut parser = CodexSseDecoder::default();
1030        let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
1031        let mut stream = resp.bytes_stream();
1032
1033        while let Some(chunk) = tokio::select! {
1034            biased;
1035            _ = cancel.cancelled() => {
1036                attempt.finish_canceled(http_status, parser.trace_usage());
1037                return Err("request canceled".into());
1038            }
1039            chunk = stream.next() => chunk,
1040        } {
1041            let chunk = match chunk {
1042                Ok(chunk) => {
1043                    attempt.mark_first_byte();
1044                    chunk
1045                }
1046                Err(_) => {
1047                    interrupted = !parser.terminal_success && parser.terminal_failure.is_none();
1048                    break;
1049                }
1050            };
1051            buf.extend_from_slice(&chunk);
1052            while let Some(nl) = memchr::memchr(b'\n', &buf) {
1053                let line_bytes = buf.split_to(nl + 1);
1054                let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
1055                parser.push_line(line, tx, &mut accumulated_text);
1056            }
1057            if parser.saw_model_event {
1058                attempt.mark_first_model_event();
1059            }
1060        }
1061        if !interrupted && !buf.is_empty() {
1062            let line = std::str::from_utf8(&buf).unwrap_or("");
1063            parser.push_line(line, tx, &mut accumulated_text);
1064        }
1065        if interrupted || (!parser.terminal_success && parser.terminal_failure.is_none()) {
1066            if stream_retry >= max_retries {
1067                attempt.finish_failed("stream_error", http_status, trace_rid);
1068                return Err(format!("Codex{RESPONSES_MISSING_TERMINAL_SUFFIX}").into());
1069            }
1070            stream_retry += 1;
1071            attempt.attempt_failed(
1072                crate::runtime::trace::RetryClass::Network,
1073                retry_delay(stream_retry),
1074                http_status,
1075                trace_rid,
1076                "stream_error",
1077            );
1078            wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
1079                .await
1080                .inspect_err(|_| {
1081                    attempt.finish_canceled(None, None);
1082                })?;
1083            attempt.restart_clock();
1084            continue;
1085        }
1086        parser.finish();
1087        // Trailing-buffer flush and finish() can surface the first (or only)
1088        // model event — e.g. a payload dispatched by the tail line. Mark it so
1089        // first_model_event_ms is honest even for tail-only streams.
1090        if parser.saw_model_event {
1091            attempt.mark_first_model_event();
1092        }
1093        if let Err(failure) = parser.terminal_result() {
1094            if failure.code == "responses_quota" {
1095                // In-stream quota exhaustion (HTTP 200 + terminal quota
1096                // error). Failover only if the model produced nothing on this
1097                // attempt — text, tool, reasoning or any content-bearing
1098                // event makes the failure terminal (no cross-account replay).
1099                let output_started =
1100                    !accumulated_text.is_empty() || parser.model_produced_output();
1101                let evidence = super::account_routing::CodexQuotaEvidence {
1102                    signal: super::account_routing::QuotaSignal::StreamQuotaEvent,
1103                    reset_after_secs: None,
1104                };
1105                match try_codex_account_failover(
1106                    router,
1107                    &cfg.model,
1108                    &pinned,
1109                    FailoverRequest {
1110                        evidence: &evidence,
1111                        output_started,
1112                        prior_failovers: failovers,
1113                    },
1114                    tx,
1115                    cancel,
1116                )
1117                .await?
1118                {
1119                    Some(next) => {
1120                        attempt.attempt_failed(
1121                            crate::runtime::trace::RetryClass::RateLimited,
1122                            std::time::Duration::ZERO,
1123                            http_status,
1124                            trace_rid,
1125                            "account_failover",
1126                        );
1127                        account_id = codex_account_id_for(&next)?;
1128                        pinned = next;
1129                        failovers += 1;
1130                        attempt.restart_clock();
1131                        continue;
1132                    }
1133                    None => {
1134                        attempt.finish_failed(failure.code, http_status, trace_rid);
1135                        return Err(failure.message.into());
1136                    }
1137                }
1138            }
1139            if failure.code == "responses_empty" && stream_retry < max_retries {
1140                stream_retry += 1;
1141                let delay = retry_delay(stream_retry);
1142                attempt.attempt_failed(
1143                    crate::runtime::trace::RetryClass::Other,
1144                    delay,
1145                    http_status,
1146                    trace_rid,
1147                    failure.code,
1148                );
1149                tracing::warn!(
1150                    "codex stream retry {stream_retry}/{max_retries} after {delay:?}: completed without usable output"
1151                );
1152                wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
1153                    .await
1154                    .inspect_err(|_| {
1155                        attempt.finish_canceled(None, None);
1156                    })?;
1157                attempt.restart_clock();
1158                continue;
1159            }
1160            attempt.finish_failed(failure.code, http_status, trace_rid);
1161            return Err(failure.message.into());
1162        }
1163        attempt.finish_success(http_status, trace_rid, None, parser.trace_usage());
1164
1165        let mut content: Vec<Value> = Vec::new();
1166        if !accumulated_text.is_empty() {
1167            content.push(json!({"type": "text", "text": accumulated_text}));
1168        }
1169        content.extend(translate::tool_calls_to_content_blocks(
1170            &parser.completed_tools,
1171            &name_map,
1172        ));
1173
1174        return Ok(json!({
1175            "role": "assistant",
1176            "content": content,
1177        }));
1178    }
1179}
1180
1181/// The `chatgpt-account-id` header value for a pinned account: provider-owned
1182/// metadata carried inside THAT token's JWT. Deriving it from the same bearer
1183/// the request will present is what keeps header and token paired.
1184fn codex_account_id_for(
1185    pinned: &super::account_routing::PinnedAccount,
1186) -> Result<String, super::net::BoxedProviderError> {
1187    crate::auth::extract_codex_account_id(&pinned.token)
1188        .ok_or_else(|| {
1189            "Failed to extract ChatGPT account id from Codex token — run `synaps login --provider openai-codex`".into()
1190        })
1191}
1192
1193/// Stable prompt-cache routing key for this conversation.
1194///
1195/// Sent as `prompt_cache_key` in the body AND as the `session-id` /
1196/// `thread-id` headers: the ChatGPT backend derives cache affinity from the
1197/// `session-id` header (upstream codex-rs sends its conversation UUID in all
1198/// three places). The body key alone yielded zero cache hits. Synaps
1199/// doesn't thread a session id down to the transport, so derive a
1200/// deterministic key from the stable head of the prompt: the instructions
1201/// plus the first input item. Identical heads hash to the same key — which
1202/// is exactly right, since those requests share the very prefix the cache
1203/// stores — and a /compact rewrite changes the key together with the prefix
1204/// it invalidates. Without this key, cache lookups are at the mercy of
1205/// load-balancer routing and long conversations re-pay their full prefix.
1206pub(crate) fn codex_prompt_cache_key(instructions: &str, first_item: Option<&Value>) -> String {
1207    use sha2::{Digest, Sha256};
1208    let mut hasher = Sha256::new();
1209    hasher.update(instructions.as_bytes());
1210    if let Some(item) = first_item {
1211        hasher.update(item.to_string().as_bytes());
1212    }
1213    format!("synaps-{:x}", hasher.finalize())
1214}
1215
1216/// Pure body construction for Codex Responses-API requests.
1217///
1218/// Separated from the async function so it can be unit-tested without
1219/// any credential access or network I/O.
1220///
1221/// # Arguments
1222/// - `model`: the Codex model id (already validated by the caller)
1223/// - `plan`: reasoning plan (must already be validated against model capability)
1224/// - `input`: pre-built input items (from `codex_input_messages`)
1225/// - `instructions`: pre-built instructions string (from `codex_instructions`)
1226/// - `tools`: pre-built tool array
1227/// - `temperature`: optional temperature override
1228/// - `max_tokens`: optional max_output_tokens override
1229/// - `prompt_cache_key`: pre-computed routing key; computed on the
1230///   pre-insertion input head by the caller, so it cannot be derived here
1231#[allow(clippy::too_many_arguments)] // pure builder mirroring the wire body; grouping would obscure the 1:1 field mapping
1232pub(crate) fn build_codex_body(
1233    model: &str,
1234    plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
1235    input: serde_json::Value,
1236    instructions: String,
1237    tools: Vec<serde_json::Value>,
1238    temperature: Option<f32>,
1239    max_tokens: Option<u32>,
1240    prompt_cache_key: &str,
1241) -> serde_json::Value {
1242    let mut body = json!({
1243        "model": model,
1244        "store": false,
1245        "stream": true,
1246        "instructions": instructions,
1247        "input": input,
1248        "tool_choice": "auto",
1249        "parallel_tool_calls": true,
1250        "include": ["reasoning.encrypted_content"],
1251        "text": { "verbosity": "medium" },
1252        "prompt_cache_key": prompt_cache_key,
1253    });
1254    if let Some(effort) = plan.wire_effort {
1255        body["reasoning"] = json!({ "effort": effort.as_str() });
1256    }
1257    if !tools.is_empty() {
1258        body["tools"] = serde_json::Value::Array(tools);
1259    }
1260    if let Some(temp) = temperature {
1261        body["temperature"] = json!(temp);
1262    }
1263    if let Some(max) = max_tokens {
1264        body["max_output_tokens"] = json!(max);
1265    }
1266    body
1267}
1268
1269const CODEX_MULTI_AGENT_MODE_OPEN: &str = "<multi_agent_mode>";
1270const CODEX_MULTI_AGENT_MODE_CLOSE: &str = "</multi_agent_mode>";
1271const CODEX_EXPLICIT_REQUEST_ONLY_TEXT: &str = "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.";
1272const CODEX_PROACTIVE_MULTI_AGENT_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.";
1273
1274/// Environment switch for the OpenAI-hosted `web_search` tool on Codex
1275/// Responses requests.
1276pub(crate) const CODEX_WEB_SEARCH_ENV: &str = "SYNAPS_CODEX_WEB_SEARCH";
1277
1278/// Map the `SYNAPS_CODEX_WEB_SEARCH` value to the hosted tool spec:
1279/// - `live` / `on` / `true` / `1` → `{"type":"web_search"}` (live web access)
1280/// - `cached` → `{"type":"web_search","external_web_access":false}`
1281/// - anything else (unset, `off`, typos) → no hosted tool.
1282///
1283/// The hosted tool's `web_search_call` output items are not function calls:
1284/// they never become tool uses; each completed one is announced as a notice
1285/// with its query (`codex_wire::announce_web_search_call`).
1286pub(crate) fn codex_web_search_tool(value: Option<&str>) -> Option<Value> {
1287    match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
1288        Some("live" | "on" | "true" | "1" | "yes") => Some(json!({ "type": "web_search" })),
1289        Some("cached") => Some(json!({ "type": "web_search", "external_web_access": false })),
1290        _ => None,
1291    }
1292}
1293
1294fn codex_multi_agent_mode_item(
1295    plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
1296) -> Option<Value> {
1297    use crate::runtime::openai::catalog::CodexMultiAgentMode;
1298    let body = match plan.multi_agent_mode? {
1299        CodexMultiAgentMode::ExplicitRequestOnly => CODEX_EXPLICIT_REQUEST_ONLY_TEXT,
1300        CodexMultiAgentMode::Proactive => CODEX_PROACTIVE_MULTI_AGENT_TEXT,
1301    };
1302    Some(json!({
1303        "type": "message",
1304        "role": "developer",
1305        "content": [{
1306            "type": "input_text",
1307            "text": format!("{CODEX_MULTI_AGENT_MODE_OPEN}{body}{CODEX_MULTI_AGENT_MODE_CLOSE}"),
1308        }],
1309    }))
1310}
1311
1312fn is_codex_multi_agent_mode_item(item: &Value) -> bool {
1313    item.pointer("/content/0/text")
1314        .and_then(Value::as_str)
1315        .is_some_and(|text| {
1316            text.trim_start().starts_with(CODEX_MULTI_AGENT_MODE_OPEN)
1317                && text.trim_end().ends_with(CODEX_MULTI_AGENT_MODE_CLOSE)
1318        })
1319}
1320
1321fn insert_codex_multi_agent_mode(
1322    input: &mut Vec<Value>,
1323    plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
1324) {
1325    input.retain(|item| !is_codex_multi_agent_mode_item(item));
1326    let Some(item) = codex_multi_agent_mode_item(plan) else {
1327        return;
1328    };
1329    // Prefix-cache stability: the mode item goes at a FIXED position (head of
1330    // input), never relative to the latest user message. The previous
1331    // placement (before the last user item) moved every user turn, so the
1332    // prompt prefix diverged at the prior turn's user message and the entire
1333    // preceding agentic turn — often the bulk of the context — was re-billed
1334    // uncached (incident: 2026-07-16 codex cache investigation). At index 0
1335    // the item is byte-stable across turns; it only changes when the
1336    // execution plan's mode changes, which legitimately invalidates the
1337    // prefix once.
1338    input.insert(0, item);
1339}
1340
1341fn codex_has_required_delegation_tools(tools_schema: &[Value]) -> bool {
1342    ["subagent_start", "subagent_status", "subagent_collect"]
1343        .into_iter()
1344        .all(|required| {
1345            tools_schema.iter().any(|tool| {
1346                tool.get("name").and_then(Value::as_str) == Some(required)
1347                    || tool.pointer("/function/name").and_then(Value::as_str) == Some(required)
1348            })
1349        })
1350}
1351
1352const CODEX_AUTONOMOUS_LOOP_POLICY: &str = "\n\n[Synaps autonomous harness policy]\nThis harness is non-interactive after the user has provided the task/spec. Do not stop at phase boundaries, milestones, checkpoints, or after presenting a plan unless the full requested job is complete. Do not ask the user whether to continue. When a phase/checkpoint is reached, run any relevant verification and continue autonomously until the full requested job is complete, blocked by an unrecoverable error, or explicit user instructions require stopping.\n[End Synaps autonomous harness policy]";
1353
1354fn codex_instructions(system_prompt: &Option<String>) -> String {
1355    let mut instructions = system_prompt.clone().unwrap_or_default();
1356    if instructions.contains("[Synaps autonomous harness policy]") {
1357        return instructions;
1358    }
1359    instructions.push_str(CODEX_AUTONOMOUS_LOOP_POLICY);
1360    instructions
1361}
1362
1363fn codex_input_messages(messages: Vec<ChatMessage>) -> Vec<Value> {
1364    responses_input_messages(messages, false)
1365}
1366
1367/// Meta Muse input: like [`codex_input_messages`], but replayed assistant
1368/// text carries the Codex-style message `phase` Muse itself emits.
1369///
1370/// Muse labels a preamble before tool calls `phase:"commentary"` and a
1371/// turn-ending answer `phase:"final_answer"` (or no phase). Replaying every
1372/// past assistant message as a phase-less string taught the model that its
1373/// "final answers" go on to call tools, and it then ended live turns on a
1374/// bare preamble (A/B replay of the failing history: 2/16 runs reproduced
1375/// with phase-less replay, 0/16 with phase restored).
1376///
1377/// Session storage keeps no phase, so it is inferred from structure, which
1378/// matches what Muse emits: `messages_to_oai` folds an assistant turn's text
1379/// into ONE message that also carries the turn's calls, so text on a message
1380/// WITH tool calls is commentary, text on a message without them is the
1381/// final answer. Tool-only messages and the whitespace separator
1382/// `messages_to_oai` inserts between tool results and plain user text are
1383/// harness wire artefacts, not model text: they keep the legacy shape.
1384fn muse_input_messages(messages: Vec<ChatMessage>) -> Vec<Value> {
1385    responses_input_messages(messages, true)
1386}
1387
1388/// Wire phase Muse uses for a replayed assistant message's text.
1389fn muse_assistant_phase(has_tool_calls: bool) -> &'static str {
1390    if has_tool_calls {
1391        "commentary"
1392    } else {
1393        "final_answer"
1394    }
1395}
1396
1397fn responses_input_messages(messages: Vec<ChatMessage>, assistant_phase: bool) -> Vec<Value> {
1398    let mut out = Vec::new();
1399    for msg in messages {
1400        if msg.role == "tool" {
1401            // Skip tool results with no call_id — sending an empty call_id
1402            // to the Codex API would cause a 400 with a confusing error.
1403            if let Some(call_id) = msg.tool_call_id {
1404                out.push(json!({
1405                    "type": "function_call_output",
1406                    "call_id": call_id,
1407                    "output": msg.content.unwrap_or_default(),
1408                }));
1409            }
1410            continue;
1411        }
1412        // A mixed assistant message carries BOTH text and calls. Emit its
1413        // text first rather than continuing after the calls and losing it.
1414        // Text-only messages retain the existing string wire representation.
1415        let phased_text =
1416            if assistant_phase && msg.role == "assistant" && msg.content_parts.is_none() {
1417                msg.content
1418                    .as_deref()
1419                    .filter(|text| !text.trim().is_empty())
1420            } else {
1421                None
1422            };
1423        if let Some(text) = phased_text {
1424            out.push(json!({
1425                "type": "message",
1426                "role": "assistant",
1427                "phase": muse_assistant_phase(msg.tool_calls.as_ref().is_some_and(|c| !c.is_empty())),
1428                "content": [{"type": "output_text", "text": text}],
1429            }));
1430        } else if msg.content_parts.is_some() || msg.content.is_some() || msg.tool_calls.is_none() {
1431            let content = if let Some(parts) = msg.content_parts {
1432                Value::Array(parts.into_iter().map(|part| match part {
1433                    ChatContentPart::Text { text } => json!({
1434                        "type": if msg.role == "assistant" { "output_text" } else { "input_text" },
1435                        "text": text,
1436                    }),
1437                    ChatContentPart::ImageUrl { image_url } => {
1438                        let mut part = json!({"type": "input_image", "image_url": image_url.url});
1439                        if let Some(detail) = image_url.detail {
1440                            part["detail"] = json!(detail);
1441                        }
1442                        part
1443                    }
1444                    ChatContentPart::File { file } => json!({
1445                        "type": "input_file",
1446                        "filename": file.filename,
1447                        "file_data": file.file_data,
1448                    }),
1449                }).collect())
1450            } else {
1451                Value::String(msg.content.unwrap_or_default())
1452            };
1453            out.push(json!({"role": msg.role, "content": content}));
1454        }
1455        if let Some(tool_calls) = msg.tool_calls {
1456            for call in tool_calls {
1457                // The Responses API rejects `id` values that are not the
1458                // original `fc_…` output-item id. We only carry the
1459                // `call_…` correlation id today (see types::ToolCall),
1460                // so emit `id` *only* when the value actually starts
1461                // with `fc`. `call_id` is sufficient on its own to
1462                // correlate the eventual `function_call_output`.
1463                let mut item = json!({
1464                    "type": "function_call",
1465                    "call_id": call.id,
1466                    "name": call.function.name,
1467                    "arguments": call.function.arguments,
1468                });
1469                if call.id.starts_with("fc") {
1470                    item["id"] = Value::from(call.id.clone());
1471                }
1472                out.push(item);
1473            }
1474        }
1475    }
1476    out
1477}
1478
1479#[derive(Clone, Debug, Eq, PartialEq)]
1480struct ResponsesStreamFailure {
1481    code: &'static str,
1482    /// User-facing text built from OUR static templates plus the provider
1483    /// display label — never provider free-text.
1484    message: String,
1485}
1486
1487impl ResponsesStreamFailure {
1488    /// Terminal failures that are transient by nature and safe to re-send
1489    /// with identical request bytes: provider capacity exhaustion, an empty
1490    /// completion, or a stream that ended without any terminal event.
1491    fn is_retryable(&self) -> bool {
1492        matches!(
1493            self.code,
1494            "responses_capacity" | "responses_empty" | "responses_missing_terminal"
1495        )
1496    }
1497}
1498
1499/// Sanitized provider error codes that mean "try again later" on the
1500/// Responses wire. Classification-only: the code is matched against this
1501/// fixed set, never echoed beyond the local sanitized log line.
1502const CAPACITY_ERROR_CODES: &[&str] = &[
1503    "rate_limit_exceeded",
1504    "server_overloaded",
1505    "overloaded",
1506    "capacity",
1507    "server_error",
1508];
1509
1510/// Fixed substrings that mark a provider free-text error message as a
1511/// transient capacity condition (xAI: "The model is currently at capacity
1512/// due to high demand. Please try again in a few minutes…"). The message is
1513/// consulted ONLY to produce a boolean — it is never logged, stored, or
1514/// surfaced (privacy invariant: provider bodies can echo request content).
1515const CAPACITY_MESSAGE_PATTERNS: &[&str] = &[
1516    "at capacity",
1517    "high demand",
1518    "try again in a few minutes",
1519    "overloaded",
1520];
1521
1522/// Classification-only capacity check. `error_code` is already sanitized;
1523/// `message` is raw provider text used solely as a boolean signal.
1524fn is_capacity_failure(error_code: &str, message: Option<&str>) -> bool {
1525    if CAPACITY_ERROR_CODES.contains(&error_code) {
1526        return true;
1527    }
1528    message.is_some_and(|m| {
1529        let lower = m.to_ascii_lowercase();
1530        CAPACITY_MESSAGE_PATTERNS.iter().any(|p| lower.contains(p))
1531    })
1532}
1533
1534struct CodexSseDecoder {
1535    /// Provider display label used in user-facing terminal messages
1536    /// ("Codex", "xAI"). Static, ours — never provider-supplied.
1537    provider_label: &'static str,
1538    buffer: String,
1539    active_tools: Vec<CodexToolAccumulator>,
1540    completed_tools: Vec<ToolCall>,
1541    /// Trace (Task 10A): set once the decoder parses any model event —
1542    /// feeds the first-model-event timing bucket.
1543    saw_model_event: bool,
1544    /// A terminal Responses event was observed. Failure/incomplete events
1545    /// take precedence over completion and are surfaced after the stream is
1546    /// drained; provider-controlled text is never retained.
1547    terminal_success: bool,
1548    terminal_failure: Option<ResponsesStreamFailure>,
1549    emitted_output: bool,
1550    /// The model produced ANY content on this attempt — including events the
1551    /// decoder does not forward to the UI (reasoning / reasoning-summary
1552    /// deltas, output items, content parts). Replaying such a request on a
1553    /// second account would double-spend, so account failover keys on this,
1554    /// not merely on what reached the UI. Metadata-only events
1555    /// (`response.created`, `response.in_progress`) never set it.
1556    saw_content_output: bool,
1557    /// Trace (Task 10A): provider-reported usage from `response.completed`
1558    /// (input including cached, output, cached slice). `None` until observed.
1559    observed_usage: Option<(u64, u64, u64)>,
1560    /// `phase` of the most recent `message` output item (`None` when that
1561    /// item had no phase, or no message item was seen). Muse marks an
1562    /// unfinished preamble `commentary`; see [`muse_dangling_preamble`].
1563    last_message_phase: Option<String>,
1564    /// `output_index` of the most recent `message` output item.
1565    last_message_index: Option<usize>,
1566    /// Text streamed via `output_text.delta`, per `output_index`. Bounded by
1567    /// what the UI already received (it duplicates `text_acc`, split by item).
1568    message_text: std::collections::BTreeMap<usize, String>,
1569    /// Muse only: when a completed message item carries text that extends
1570    /// what was streamed for it, emit the missing suffix as a text delta.
1571    /// Off for every other provider so their decoding stays unchanged.
1572    backfill_message_text: bool,
1573}
1574
1575impl Default for CodexSseDecoder {
1576    fn default() -> Self {
1577        Self::for_provider("Codex")
1578    }
1579}
1580
1581#[derive(Default)]
1582struct CodexToolAccumulator {
1583    id: String,
1584    name: String,
1585    arguments: String,
1586    started: bool,
1587}
1588
1589/// Parse a function-call arguments string into a JSON `Value`, mirroring
1590/// `runtime::api::parse_tool_input` so the chat UI's `LlmEvent::ToolUse`
1591/// handling sees the same shape regardless of provider.
1592///
1593/// Empty / whitespace input becomes `{}`. Invalid JSON becomes
1594/// `{"__parse_error": "..."}` — the agent loop already understands that
1595/// shape and converts it into an `is_error: true` tool_result.
1596fn parse_tool_arguments(raw: &str) -> Value {
1597    if raw.trim().is_empty() {
1598        return json!({});
1599    }
1600    match serde_json::from_str(raw) {
1601        Ok(v) => v,
1602        Err(e) => json!({ "__parse_error": format!("invalid tool input JSON: {}", e) }),
1603    }
1604}
1605
1606/// Reduce a provider-supplied error identifier (`error.type`, `error.code`,
1607/// `incomplete_details.reason`) to a token safe for local logs. Lowercase
1608/// snake-case identifiers (`server_error`, `rate_limit_exceeded`,
1609/// `context_length_exceeded`, `max_output_tokens`) pass through verbatim —
1610/// that shape cannot carry prose or credential material. Anything with
1611/// uppercase, whitespace, or symbols outside the identifier charset — or
1612/// longer than 64 bytes — is replaced so free-text (which can echo request
1613/// content) and secret-shaped tokens can never leak into the log stream.
1614pub(crate) fn sanitize_error_identifier(raw: Option<&str>) -> &str {
1615    match raw {
1616        None => "absent",
1617        Some(s) => {
1618            let identifier_shaped = !s.is_empty()
1619                && s.len() <= 64
1620                && s.bytes()
1621                    .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.');
1622            if identifier_shaped
1623                && s.bytes()
1624                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
1625            {
1626                // Lowercase snake_case enum value: safe to record verbatim.
1627                s
1628            } else if identifier_shaped {
1629                // Mixed-case / dashed identifiers can be secret-shaped
1630                // (API keys, tokens): report shape only.
1631                "unlisted_identifier"
1632            } else {
1633                "unsafe_or_freeform"
1634            }
1635        }
1636    }
1637}
1638
1639/// Static message suffixes for Responses terminal failures. The provider
1640/// display label is prefixed at construction; `net.rs` classifies on these
1641/// label-independent suffixes so every label maps to `ApiStatus`.
1642pub(crate) const RESPONSES_AUTH_SUFFIX: &str =
1643    " authentication rejected in stream. Sign in again or switch to an available account.";
1644pub(crate) const RESPONSES_QUOTA_SUFFIX: &str =
1645    " usage quota exhausted in stream. Add credit, wait for reset, or switch models.";
1646pub(crate) const RESPONSES_FAILED_SUFFIX: &str =
1647    " response failed in stream. Provider error details withheld because they can echo request content.";
1648pub(crate) const RESPONSES_CONTEXT_SUFFIX: &str = " rejected the request: the conversation exceeds this model's context window. Run /compact or start a fresh session to continue.";
1649pub(crate) const RESPONSES_CAPACITY_SUFFIX: &str = " reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model.";
1650pub(crate) const RESPONSES_INCOMPLETE_SUFFIX: &str =
1651    " response was incomplete. Retry the request or reduce the requested output/context size.";
1652pub(crate) const RESPONSES_EMPTY_SUFFIX: &str =
1653    " completed without text or tool output. Retry the request.";
1654pub(crate) const RESPONSES_MISSING_TERMINAL_SUFFIX: &str =
1655    " response stream ended without a terminal event. Retry the request.";
1656
1657/// Map a Responses terminal failure to a user-facing message. Only reacts to
1658/// the already-sanitized enum identifiers (`error.type` / `error.code`) and
1659/// the boolean capacity classification — never to provider free-text — so no
1660/// request content can leak. Known, user-actionable conditions get a specific
1661/// remedy; everything else keeps the neutral, details-withheld default.
1662fn user_message_for_terminal_failure(
1663    label: &str,
1664    error_kind: &str,
1665    error_code: &str,
1666    capacity: bool,
1667) -> String {
1668    // Context-window overflow is deterministic and user-fixable: the turn
1669    // cannot succeed until the conversation is smaller.
1670    if error_code == "context_length_exceeded" || error_kind == "context_length_exceeded" {
1671        return format!("{label}{RESPONSES_CONTEXT_SUFFIX}");
1672    }
1673    // Both explicit identifiers must agree on a retry class. Unknown or policy
1674    // classes (even with a capacity-looking message) are never retry signals.
1675    let classify = |value: &str| match value {
1676        "absent" | "error" => None,
1677        "invalid_api_key" | "authentication_error" | "invalid_authentication_error" => Some("auth"),
1678        "insufficient_quota" | "quota_exceeded" | "billing_error" | "usage_limit_reached" => {
1679            Some("quota")
1680        }
1681        "rate_limit_exceeded"
1682        | "rate_limit_error"
1683        | "server_overloaded"
1684        | "overloaded"
1685        | "capacity"
1686        | "server_error"
1687        | "api_error"
1688        | "overloaded_error" => Some("capacity"),
1689        _ => Some("blocked"),
1690    };
1691    let (kind, code) = (classify(error_kind), classify(error_code));
1692    if kind == Some("blocked")
1693        || code == Some("blocked")
1694        || (kind.is_some() && code.is_some() && kind != code)
1695    {
1696        return format!("{label}{RESPONSES_FAILED_SUFFIX}");
1697    }
1698    match code.or(kind) {
1699        Some("auth") => return format!("{label}{RESPONSES_AUTH_SUFFIX}"),
1700        Some("quota") => return format!("{label}{RESPONSES_QUOTA_SUFFIX}"),
1701        Some("capacity") => return format!("{label}{RESPONSES_CAPACITY_SUFFIX}"),
1702        // Preserve legacy flat xAI capacity handling only with no explicit
1703        // failure identifier to contradict it. No new free-text classifiers.
1704        None if capacity => return format!("{label}{RESPONSES_CAPACITY_SUFFIX}"),
1705        _ => {}
1706    }
1707    format!("{label}{RESPONSES_FAILED_SUFFIX}")
1708}
1709
1710impl CodexSseDecoder {
1711    /// Decoder whose user-facing messages name `label` as the provider.
1712    fn for_provider(label: &'static str) -> Self {
1713        Self {
1714            provider_label: label,
1715            buffer: String::new(),
1716            active_tools: Vec::new(),
1717            completed_tools: Vec::new(),
1718            saw_model_event: false,
1719            terminal_success: false,
1720            terminal_failure: None,
1721            emitted_output: false,
1722            saw_content_output: false,
1723            observed_usage: None,
1724            last_message_phase: None,
1725            last_message_index: None,
1726            message_text: std::collections::BTreeMap::new(),
1727            backfill_message_text: false,
1728        }
1729    }
1730
1731    /// Record a `message` output item's phase/index (added or done event).
1732    fn observe_message_item(&mut self, idx: usize, item: &Value) {
1733        if item.get("type").and_then(Value::as_str) != Some("message") {
1734            return;
1735        }
1736        self.last_message_index = Some(idx);
1737        self.last_message_phase = item
1738            .get("phase")
1739            .and_then(Value::as_str)
1740            .map(str::to_string);
1741    }
1742
1743    /// Text of the most recent message item as streamed, if any was.
1744    fn last_message_text(&self) -> Option<&str> {
1745        self.last_message_index
1746            .and_then(|idx| self.message_text.get(&idx))
1747            .map(String::as_str)
1748    }
1749
1750    /// Muse-only (C): a completed message item whose text extends what was
1751    /// streamed for it (and starts with it) gets the missing suffix emitted
1752    /// as a normal text delta. Never rewrites or duplicates streamed text.
1753    fn backfill_message_suffix(
1754        &mut self,
1755        idx: usize,
1756        item: &Value,
1757        tx: &mpsc::UnboundedSender<StreamEvent>,
1758        text_acc: &mut String,
1759    ) {
1760        if !self.backfill_message_text
1761            || item.get("type").and_then(Value::as_str) != Some("message")
1762        {
1763            return;
1764        }
1765        let Some(parts) = item.get("content").and_then(Value::as_array) else {
1766            return;
1767        };
1768        let full: String = parts
1769            .iter()
1770            .filter(|part| part.get("type").and_then(Value::as_str) == Some("output_text"))
1771            .filter_map(|part| part.get("text").and_then(Value::as_str))
1772            .collect();
1773        let streamed = self.message_text.entry(idx).or_default();
1774        if full.len() <= streamed.len() || !full.starts_with(streamed.as_str()) {
1775            return;
1776        }
1777        let suffix = full[streamed.len()..].to_string();
1778        tracing::debug!(
1779            provider = self.provider_label,
1780            suffix_bytes = suffix.len(),
1781            "backfilling message text missing from the delta stream"
1782        );
1783        streamed.push_str(&suffix);
1784        self.emitted_output = true;
1785        text_acc.push_str(&suffix);
1786        let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(
1787            suffix,
1788        )));
1789    }
1790
1791    /// True once anything was streamed to the UI for this attempt (text
1792    /// deltas, tool starts, or completed tools). A retry after this point
1793    /// would duplicate output, so callers must treat the failure as terminal.
1794    fn emitted_any_output(&self) -> bool {
1795        self.emitted_output
1796            || !self.completed_tools.is_empty()
1797            || self.active_tools.iter().any(|tool| tool.started)
1798    }
1799
1800    /// True once the model produced any content on this attempt, forwarded
1801    /// or not. The account-failover gate uses this (stricter than
1802    /// `emitted_any_output`): a request that already cost output on seat A
1803    /// is never replayed on seat B.
1804    fn model_produced_output(&self) -> bool {
1805        self.saw_content_output || self.emitted_any_output()
1806    }
1807
1808    fn push_line(
1809        &mut self,
1810        line: &str,
1811        tx: &mpsc::UnboundedSender<StreamEvent>,
1812        text_acc: &mut String,
1813    ) {
1814        let line = line.trim_end_matches('\r');
1815        if line.is_empty() {
1816            if !self.buffer.is_empty() {
1817                let payload = std::mem::take(&mut self.buffer);
1818                self.push_payload(&payload, tx, text_acc);
1819            }
1820            return;
1821        }
1822        let Some(data) = line.strip_prefix("data:").map(str::trim_start) else {
1823            return;
1824        };
1825        if data == "[DONE]" {
1826            self.terminal_success = true;
1827            self.finish();
1828            return;
1829        }
1830        self.buffer.push_str(data);
1831    }
1832
1833    fn push_payload(
1834        &mut self,
1835        payload: &str,
1836        tx: &mpsc::UnboundedSender<StreamEvent>,
1837        text_acc: &mut String,
1838    ) {
1839        let Ok(event) = serde_json::from_str::<Value>(payload) else {
1840            tracing::debug!(
1841                payload_bytes = payload.len(),
1842                "discarding malformed Responses SSE payload"
1843            );
1844            return;
1845        };
1846        let event_type = event
1847            .get("type")
1848            .and_then(Value::as_str)
1849            .unwrap_or_default();
1850        if matches!(
1851            event_type,
1852            "response.output_text.delta"
1853                | "response.output_item.added"
1854                | "response.function_call_arguments.delta"
1855                | "response.output_item.done"
1856                | "response.completed"
1857                | "response.done"
1858                | "response.failed"
1859                | "response.incomplete"
1860                | "error"
1861        ) {
1862            self.saw_model_event = true;
1863        }
1864        // Content-bearing event families. Anything the model emitted as
1865        // output — text, tool arguments, reasoning, content parts, output
1866        // items — counts, even when this decoder ignores the event below.
1867        if matches!(
1868            event_type,
1869            "response.output_text.delta"
1870                | "response.output_text.done"
1871                | "response.output_item.added"
1872                | "response.output_item.done"
1873                | "response.function_call_arguments.delta"
1874                | "response.function_call_arguments.done"
1875        ) || event_type.starts_with("response.reasoning")
1876            || event_type.starts_with("response.content_part")
1877            || event_type.starts_with("response.refusal")
1878        {
1879            self.saw_content_output = true;
1880        }
1881        match event_type {
1882            "response.output_text.delta" => {
1883                if let Some(delta) = event.get("delta").and_then(Value::as_str) {
1884                    if !delta.is_empty() {
1885                        self.emitted_output = true;
1886                    }
1887                    let idx = event
1888                        .get("output_index")
1889                        .and_then(Value::as_u64)
1890                        .unwrap_or(0) as usize;
1891                    self.message_text.entry(idx).or_default().push_str(delta);
1892                    text_acc.push_str(delta);
1893                    let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(
1894                        delta.to_string(),
1895                    )));
1896                }
1897            }
1898            "response.output_item.added" => {
1899                if let Some(item) = event.get("item") {
1900                    let idx = event
1901                        .get("output_index")
1902                        .and_then(Value::as_u64)
1903                        .unwrap_or(0) as usize;
1904                    self.observe_message_item(idx, item);
1905                    self.add_tool_from_item(idx, item, tx);
1906                }
1907            }
1908            "response.function_call_arguments.delta" => {
1909                let idx = event
1910                    .get("output_index")
1911                    .and_then(Value::as_u64)
1912                    .unwrap_or(0) as usize;
1913                let delta = event
1914                    .get("delta")
1915                    .and_then(Value::as_str)
1916                    .unwrap_or_default();
1917                if !delta.is_empty() {
1918                    let tool = self.ensure_tool(idx);
1919                    tool.arguments.push_str(delta);
1920                    let tool_id = tool.id.clone();
1921                    let _ = tx.send(StreamEvent::Llm(
1922                        crate::runtime::types::LlmEvent::ToolUseDelta {
1923                            tool_id,
1924                            delta: delta.to_string(),
1925                        },
1926                    ));
1927                }
1928            }
1929            "response.output_item.done" => {
1930                if let Some(item) = event.get("item") {
1931                    let idx = event
1932                        .get("output_index")
1933                        .and_then(Value::as_u64)
1934                        .unwrap_or(0) as usize;
1935                    self.observe_message_item(idx, item);
1936                    self.backfill_message_suffix(idx, item, tx, text_acc);
1937                    super::codex_wire::announce_web_search_call(self.provider_label, item, tx);
1938                    self.complete_tool_from_item(idx, item, tx);
1939                }
1940            }
1941            "response.completed" | "response.done" => {
1942                self.push_usage(&event, tx);
1943                self.terminal_success = true;
1944                self.finish();
1945            }
1946            "response.failed" | "error" => {
1947                self.push_usage(&event, tx);
1948                // Local-only diagnostics: log the provider's enum-like error
1949                // identifiers (`type` / `code`) after sanitizing them down to a
1950                // short identifier charset. The free-text `message` field is
1951                // never logged or surfaced — it can echo request content.
1952                //
1953                // Two envelope shapes are accepted: the nested Responses form
1954                // (`response.error.{type,code,message}` / `error.{…}`) and
1955                // xAI's flat form (`{"type":"error","code":…,"message":…}`)
1956                // where the event's own `type` is just "error", so only the
1957                // top-level `code` is a meaningful identifier there.
1958                let error_obj = event
1959                    .get("response")
1960                    .and_then(|r| r.get("error"))
1961                    .or_else(|| event.get("error"))
1962                    .filter(|e| e.is_object());
1963                let error_kind = sanitize_error_identifier(
1964                    error_obj
1965                        .and_then(|e| e.get("type"))
1966                        .and_then(Value::as_str),
1967                );
1968                let error_code = sanitize_error_identifier(
1969                    error_obj
1970                        .and_then(|e| e.get("code"))
1971                        .or_else(|| event.get("code"))
1972                        .and_then(Value::as_str),
1973                );
1974                // Provider free-text: classification-only boolean, never
1975                // retained past this expression.
1976                let capacity = is_capacity_failure(
1977                    error_code,
1978                    error_obj
1979                        .and_then(|e| e.get("message"))
1980                        .or_else(|| event.get("message"))
1981                        .and_then(Value::as_str),
1982                );
1983                tracing::warn!(
1984                    provider = self.provider_label,
1985                    event_type,
1986                    error_kind,
1987                    error_code,
1988                    capacity,
1989                    "responses stream terminal failure (provider message withheld)"
1990                );
1991                let message = user_message_for_terminal_failure(
1992                    self.provider_label,
1993                    error_kind,
1994                    error_code,
1995                    capacity,
1996                );
1997                self.terminal_failure = Some(ResponsesStreamFailure {
1998                    // Exact account/context failures outrank a free-text capacity
1999                    // hint: do not spend local retries before allowing failover.
2000                    code: if message
2001                        == format!("{}{RESPONSES_CAPACITY_SUFFIX}", self.provider_label)
2002                    {
2003                        "responses_capacity"
2004                    } else if super::account_routing::stream_failure_is_quota(
2005                        error_kind, error_code,
2006                    ) {
2007                        // Vetted quota identifiers only: the account's
2008                        // allowance is spent. Not retried here; the Codex
2009                        // path may fail over once if nothing was produced.
2010                        "responses_quota"
2011                    } else {
2012                        "responses_failed"
2013                    },
2014                    message,
2015                });
2016                self.finish();
2017            }
2018            "response.incomplete" => {
2019                self.push_usage(&event, tx);
2020                let reason = sanitize_error_identifier(
2021                    event
2022                        .get("response")
2023                        .and_then(|r| r.get("incomplete_details"))
2024                        .and_then(|d| d.get("reason"))
2025                        .and_then(Value::as_str),
2026                );
2027                tracing::warn!(
2028                    provider = self.provider_label,
2029                    reason,
2030                    "responses stream terminal incomplete"
2031                );
2032                self.terminal_failure = Some(ResponsesStreamFailure {
2033                    code: "responses_incomplete",
2034                    message: format!("{}{RESPONSES_INCOMPLETE_SUFFIX}", self.provider_label),
2035                });
2036                self.finish();
2037            }
2038            _ => {
2039                if !event_type.is_empty() {
2040                    tracing::debug!(event_type, "ignoring unknown Responses SSE event");
2041                }
2042            }
2043        }
2044    }
2045
2046    fn terminal_result(&self) -> Result<(), ResponsesStreamFailure> {
2047        if let Some(failure) = &self.terminal_failure {
2048            return Err(failure.clone());
2049        }
2050        if self.terminal_success {
2051            if self.emitted_output || !self.completed_tools.is_empty() {
2052                Ok(())
2053            } else {
2054                Err(ResponsesStreamFailure {
2055                    code: "responses_empty",
2056                    message: format!("{}{RESPONSES_EMPTY_SUFFIX}", self.provider_label),
2057                })
2058            }
2059        } else {
2060            Err(ResponsesStreamFailure {
2061                code: "responses_missing_terminal",
2062                message: format!("{}{RESPONSES_MISSING_TERMINAL_SUFFIX}", self.provider_label),
2063            })
2064        }
2065    }
2066
2067    fn ensure_tool(&mut self, idx: usize) -> &mut CodexToolAccumulator {
2068        // `idx` is the provider-supplied `output_index`; a hostile or buggy
2069        // provider sending a huge value would otherwise grow this Vec unboundedly
2070        // (OOM/DoS). No legitimate response has thousands of concurrent tool
2071        // calls, so clamp absurd indices to the last slot instead of allocating.
2072        const MAX_TOOLS: usize = 4096;
2073        let idx = idx.min(MAX_TOOLS - 1);
2074        while self.active_tools.len() <= idx {
2075            self.active_tools.push(CodexToolAccumulator::default());
2076        }
2077        &mut self.active_tools[idx]
2078    }
2079
2080    fn add_tool_from_item(
2081        &mut self,
2082        idx: usize,
2083        item: &Value,
2084        tx: &mpsc::UnboundedSender<StreamEvent>,
2085    ) {
2086        if item.get("type").and_then(Value::as_str) != Some("function_call") {
2087            return;
2088        }
2089        let tool = self.ensure_tool(idx);
2090        if let Some(id) = item
2091            .get("call_id")
2092            .or_else(|| item.get("id"))
2093            .and_then(Value::as_str)
2094        {
2095            tool.id = id.to_string();
2096        }
2097        if let Some(name) = item.get("name").and_then(Value::as_str) {
2098            tool.name = name.to_string();
2099        }
2100        if !tool.started && !tool.name.is_empty() {
2101            tool.started = true;
2102            let _ = tx.send(StreamEvent::Llm(
2103                crate::runtime::types::LlmEvent::ToolUseStart {
2104                    tool_name: tool.name.clone(),
2105                    tool_id: tool.id.clone(),
2106                },
2107            ));
2108        }
2109    }
2110
2111    fn complete_tool_from_item(
2112        &mut self,
2113        idx: usize,
2114        item: &Value,
2115        tx: &mpsc::UnboundedSender<StreamEvent>,
2116    ) {
2117        if item.get("type").and_then(Value::as_str) != Some("function_call") {
2118            return;
2119        }
2120        let tool = self.ensure_tool(idx);
2121        if let Some(id) = item
2122            .get("call_id")
2123            .or_else(|| item.get("id"))
2124            .and_then(Value::as_str)
2125        {
2126            tool.id = id.to_string();
2127        }
2128        if let Some(name) = item.get("name").and_then(Value::as_str) {
2129            tool.name = name.to_string();
2130        }
2131        if let Some(arguments) = item.get("arguments").and_then(Value::as_str) {
2132            tool.arguments = arguments.to_string();
2133        }
2134        if !tool.started && !tool.name.is_empty() {
2135            tool.started = true;
2136            let _ = tx.send(StreamEvent::Llm(
2137                crate::runtime::types::LlmEvent::ToolUseStart {
2138                    tool_name: tool.name.clone(),
2139                    tool_id: tool.id.clone(),
2140                },
2141            ));
2142        }
2143        let completed = if !tool.id.is_empty() && !tool.name.is_empty() {
2144            Some(ToolCall {
2145                id: tool.id.clone(),
2146                kind: "function".to_string(),
2147                function: super::types::FunctionCall {
2148                    name: tool.name.clone(),
2149                    arguments: tool.arguments.clone(),
2150                },
2151            })
2152        } else {
2153            None
2154        };
2155        if let Some(call) = completed {
2156            if self.completed_tools.iter().any(|done| done.id == call.id) {
2157                return;
2158            }
2159            self.emitted_output = true;
2160            // Emit the finalized `ToolUse` event so the chat UI can collapse
2161            // the streaming `ToolUseStart` (animated) into a stable
2162            // `ToolUse` block. Without this the bash-trace animation
2163            // persists forever and parallel tool blocks render as "still
2164            // running" even after they've completed. Mirrors the
2165            // Anthropic path in `runtime/api.rs` which emits the same
2166            // event on tool-use content_block_stop.
2167            let input = parse_tool_arguments(&call.function.arguments);
2168            let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::ToolUse {
2169                tool_name: call.function.name.clone(),
2170                tool_id: call.id.clone(),
2171                input,
2172            }));
2173            self.completed_tools.push(ToolCall {
2174                id: call.id,
2175                kind: call.kind,
2176                function: call.function,
2177            });
2178        }
2179    }
2180
2181    fn push_usage(&mut self, event: &Value, tx: &mpsc::UnboundedSender<StreamEvent>) {
2182        let usage = event
2183            .get("response")
2184            .and_then(|r| r.get("usage"))
2185            .or_else(|| event.get("usage"));
2186        let input = usage
2187            .and_then(|u| u.get("input_tokens"))
2188            .and_then(Value::as_u64)
2189            .unwrap_or(0);
2190        let output = usage
2191            .and_then(|u| u.get("output_tokens"))
2192            .and_then(Value::as_u64)
2193            .unwrap_or(0);
2194        // Responses-API usage nests cache hits under input_tokens_details;
2195        // input_tokens INCLUDES them. Downstream accounting uses Anthropic
2196        // semantics (context = input + cache_read + cache_creation, hit% =
2197        // cache_read/total), so report the cached slice separately and
2198        // subtract it from input — otherwise cache hits are invisible
2199        // (reported 0) and the context total double-counts.
2200        let cached = usage
2201            .and_then(|u| u.pointer("/input_tokens_details/cached_tokens"))
2202            .and_then(Value::as_u64)
2203            .unwrap_or(0)
2204            .min(input);
2205        if input > 0 || output > 0 {
2206            // Trace capture (metadata only): the provider-reported totals as
2207            // sent — input including the cached slice.
2208            if self.observed_usage.is_none() {
2209                self.observed_usage = Some((input, output, cached));
2210            }
2211            let _ = tx.send(StreamEvent::Session(
2212                crate::runtime::types::SessionEvent::Usage {
2213                    input_tokens: input - cached,
2214                    output_tokens: output,
2215                    cache_read_input_tokens: cached,
2216                    cache_creation_input_tokens: 0,
2217                    cache_creation_5m: None,
2218                    cache_creation_1h: None,
2219                    model: None,
2220                },
2221            ));
2222        }
2223    }
2224
2225    /// Provider-reported usage for the trace record; `None` until observed.
2226    fn trace_usage(&self) -> Option<crate::runtime::trace::UsageMeta> {
2227        self.observed_usage
2228            .map(|(input, output, cached)| tr::provider_usage(input, output, cached))
2229    }
2230
2231    fn finish(&mut self) {
2232        for tool in self.active_tools.drain(..) {
2233            if !tool.id.is_empty()
2234                && !tool.name.is_empty()
2235                && !self.completed_tools.iter().any(|done| done.id == tool.id)
2236            {
2237                self.emitted_output = true;
2238                self.completed_tools.push(ToolCall {
2239                    id: tool.id,
2240                    kind: "function".to_string(),
2241                    function: super::types::FunctionCall {
2242                        name: tool.name,
2243                        arguments: tool.arguments,
2244                    },
2245                });
2246            }
2247        }
2248    }
2249}
2250
2251fn handle_events(
2252    events: &[OaiEvent],
2253    tx: &mpsc::UnboundedSender<StreamEvent>,
2254    text_acc: &mut String,
2255    tool_blocks: &mut Vec<Value>,
2256    name_map: &translate::ToolNameMap,
2257    suppress_stream_deltas: bool,
2258) {
2259    for ev in events {
2260        if let OaiEvent::TextDelta(t) = ev {
2261            text_acc.push_str(t);
2262        }
2263        if let OaiEvent::ToolCallsComplete { calls, .. } = ev {
2264            tool_blocks.extend(translate::tool_calls_to_content_blocks(calls, name_map));
2265        }
2266        if !suppress_stream_deltas {
2267            if let Some(se) = translate::oai_event_to_llm(ev) {
2268                let _ = tx.send(se);
2269            }
2270        }
2271    }
2272}
2273
2274#[cfg(test)]
2275mod codex_input_messages_tests {
2276    //! Regression tests for the Codex Responses-API `input` shape.
2277    //!
2278    //! Background: the Responses API distinguishes two ids per tool
2279    //! invocation — `id` (the *output item id*, prefix `fc_…`) and
2280    //! `call_id` (the *function call id*, prefix `call_…`). When echoing
2281    //! a previous `function_call` back as an input item, supplying an
2282    //! `id` whose value is *not* a `fc_…` triggers
2283    //!
2284    //!   400 Bad Request: Invalid 'input[N].id': 'call_…'.
2285    //!   Expected an ID that begins with 'fc'.
2286    //!
2287    //! `id` is *optional* on input items — only `call_id` is required to
2288    //! correlate the eventual `function_call_output`. We elect not to
2289    //! emit `id` unless we actually have a real `fc_…` value to send.
2290
2291    use super::super::types::{ChatMessage, FunctionCall, ToolCall};
2292    use super::*;
2293
2294    fn sample_tool_call() -> ToolCall {
2295        ToolCall {
2296            id: "call_nZYquCuGUh8Qs9H51dwHMDgs".to_string(),
2297            kind: "function".to_string(),
2298            function: FunctionCall {
2299                name: "bash".to_string(),
2300                arguments: r#"{"command":"ls"}"#.to_string(),
2301            },
2302        }
2303    }
2304
2305    fn translated_input(messages: Vec<Value>) -> Vec<Value> {
2306        let messages: Vec<crate::SharedMessage> =
2307            messages.into_iter().map(std::sync::Arc::new).collect();
2308        codex_input_messages(translate::messages_to_oai(
2309            &messages,
2310            &None,
2311            &translate::ToolNameMap::default(),
2312        ))
2313    }
2314
2315    #[test]
2316    fn image_text_document_and_pdf_exact_responses_wire() {
2317        let out = translated_input(vec![json!({"role":"user","content":[
2318            {"type":"text","text":"Compare these."},
2319            {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aW1hZ2U="}},
2320            {"type":"document","title":"notes.txt","source":{"type":"text","media_type":"text/plain","data":"Text document\ncontents"}},
2321            {"type":"document","title":"report.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}},
2322            {"type":"text","text":"Then summarize."}
2323        ]})]);
2324        assert_eq!(
2325            out,
2326            vec![json!({"role":"user","content":[
2327                {"type":"input_text","text":"Compare these."},
2328                {"type":"input_text","text":"[attachment origin=user type=image media_type=\"image/png\"; lower-authority data]\n"},
2329                {"type":"input_image","image_url":"data:image/png;base64,aW1hZ2U="},
2330                {"type":"input_text","text":"[/attachment]\n"},
2331                {"type":"input_text","text":"[attachment origin=user type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data]\n"},
2332                {"type":"input_text","text":"Text document\ncontents"},
2333                {"type":"input_text","text":"[/attachment]\n"},
2334                {"type":"input_text","text":"[attachment origin=user type=document media_type=\"application/pdf\" filename=\"report.pdf\"; lower-authority data]\n"},
2335                {"type":"input_file","filename":"report.pdf","file_data":"data:application/pdf;base64,JVBERi0="},
2336                {"type":"input_text","text":"[/attachment]\n"},
2337                {"type":"input_text","text":"Then summarize."}
2338            ]})]
2339        );
2340    }
2341
2342    #[test]
2343    fn mixed_assistant_text_and_all_tool_outputs_precede_lifted_responses_media() {
2344        let out = translated_input(vec![
2345            json!({"role":"assistant","content":[
2346                {"type":"text","text":"Reading both."},
2347                {"type":"tool_use","id":"t1","name":"read","input":{}},
2348                {"type":"tool_use","id":"t2","name":"read","input":{}}
2349            ]}),
2350            json!({"role":"user","content":[
2351                {"type":"tool_result","tool_use_id":"t1","content":[
2352                    {"type":"text","text":"First result"},
2353                    {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aW1hZ2U="}},
2354                    {"type":"document","title":"notes.txt","source":{"type":"text","media_type":"text/plain","data":"DOCUMENT_BODY_SENTINEL"}}
2355                ]},
2356                {"type":"tool_result","tool_use_id":"t2","content":[
2357                    {"type":"text","text":"Second result"},
2358                    {"type":"document","title":"report.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}}
2359                ]}
2360            ]}),
2361        ]);
2362        assert_eq!(
2363            out,
2364            vec![
2365                json!({"role":"assistant","content":"Reading both."}),
2366                json!({"type":"function_call","call_id":"t1","name":"read","arguments":"{}"}),
2367                json!({"type":"function_call","call_id":"t2","name":"read","arguments":"{}"}),
2368                json!({"type":"function_call_output","call_id":"t1","output":"First result\n[attachment origin=tool_result tool_use_id=\"t1\" tool_name=\"read\" type=image media_type=\"image/png\"; lower-authority data; supplied in following user content]\n\n[attachment origin=tool_result tool_use_id=\"t1\" tool_name=\"read\" type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data; supplied in following user content]\n"}),
2369                json!({"type":"function_call_output","call_id":"t2","output":"Second result\n[attachment origin=tool_result tool_use_id=\"t2\" tool_name=\"read\" type=document media_type=\"application/pdf\" filename=\"report.pdf\"; lower-authority data; supplied in following user content]\n"}),
2370                json!({"role":"user","content":[
2371                    {"type":"input_text","text":"[attachment origin=tool_result tool_use_id=\"t1\" tool_name=\"read\" type=image media_type=\"image/png\"; lower-authority data]\n"},
2372                    {"type":"input_image","image_url":"data:image/png;base64,aW1hZ2U="},
2373                    {"type":"input_text","text":"[/attachment]\n"},
2374                    {"type":"input_text","text":"[attachment origin=tool_result tool_use_id=\"t1\" tool_name=\"read\" type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data]\n"},
2375                    {"type":"input_text","text":"DOCUMENT_BODY_SENTINEL"},
2376                    {"type":"input_text","text":"[/attachment]\n"},
2377                    {"type":"input_text","text":"[attachment origin=tool_result tool_use_id=\"t2\" tool_name=\"read\" type=document media_type=\"application/pdf\" filename=\"report.pdf\"; lower-authority data]\n"},
2378                    {"type":"input_file","filename":"report.pdf","file_data":"data:application/pdf;base64,JVBERi0="},
2379                    {"type":"input_text","text":"[/attachment]\n"}
2380                ]})
2381            ]
2382        );
2383        for output in out
2384            .iter()
2385            .filter(|item| item["type"] == "function_call_output")
2386        {
2387            let text = output["output"].as_str().unwrap();
2388            for sentinel in [
2389                "aW1hZ2U=",
2390                "JVBERi0=",
2391                "DOCUMENT_BODY_SENTINEL",
2392                "\"source\"",
2393            ] {
2394                assert!(
2395                    !text.contains(sentinel),
2396                    "tool output must contain only text and metadata"
2397                );
2398            }
2399        }
2400    }
2401
2402    #[test]
2403    fn text_only_responses_input_keeps_strings_and_mixed_assistant_text() {
2404        let mut assistant = ChatMessage::assistant_tool_calls(vec![sample_tool_call()]);
2405        assistant.content = Some("Checking.".into());
2406        let out = codex_input_messages(vec![
2407            ChatMessage::system("rules"),
2408            ChatMessage::user("question"),
2409            assistant,
2410            ChatMessage::tool_result("call_nZYquCuGUh8Qs9H51dwHMDgs", "bash", "done"),
2411            ChatMessage::assistant("answer"),
2412        ]);
2413        assert_eq!(
2414            out,
2415            vec![
2416                json!({"role":"system","content":"rules"}),
2417                json!({"role":"user","content":"question"}),
2418                json!({"role":"assistant","content":"Checking."}),
2419                json!({"type":"function_call","call_id":"call_nZYquCuGUh8Qs9H51dwHMDgs","name":"bash","arguments":"{\"command\":\"ls\"}"}),
2420                json!({"type":"function_call_output","call_id":"call_nZYquCuGUh8Qs9H51dwHMDgs","output":"done"}),
2421                json!({"role":"assistant","content":"answer"}),
2422            ]
2423        );
2424    }
2425
2426    /// Anthropic-shaped stored history covering every assistant shape:
2427    /// mixed text+tool_use, tool-only, text-only, plus a plain user message
2428    /// right after tool results (forces the `" "` separator).
2429    fn phase_history() -> Vec<Value> {
2430        vec![
2431            json!({"role":"user","content":"build it"}),
2432            json!({"role":"assistant","content":[
2433                {"type":"thinking","thinking":"hidden","signature":"s"},
2434                {"type":"text","text":"Mapping the repo first."},
2435                {"type":"tool_use","id":"call_a","name":"bash","input":{"command":"ls"}},
2436            ]}),
2437            json!({"role":"user","content":[
2438                {"type":"tool_result","tool_use_id":"call_a","content":"src"},
2439            ]}),
2440            json!({"role":"assistant","content":[
2441                {"type":"tool_use","id":"call_b","name":"bash","input":{"command":"pwd"}},
2442            ]}),
2443            json!({"role":"user","content":[
2444                {"type":"tool_result","tool_use_id":"call_b","content":"/w"},
2445            ]}),
2446            json!({"role":"user","content":"also check logs"}),
2447            json!({"role":"assistant","content":[{"type":"text","text":"Done: all good."}]}),
2448            json!({"role":"user","content":"thanks"}),
2449            json!({"role":"assistant","content":"Plain string answer."}),
2450        ]
2451    }
2452
2453    fn oai(messages: Vec<Value>) -> Vec<ChatMessage> {
2454        let messages: Vec<crate::SharedMessage> =
2455            messages.into_iter().map(std::sync::Arc::new).collect();
2456        translate::messages_to_oai(&messages, &None, &translate::ToolNameMap::default())
2457    }
2458
2459    #[test]
2460    fn muse_input_infers_phase_from_message_structure() {
2461        let out = muse_input_messages(oai(phase_history()));
2462        assert_eq!(
2463            out,
2464            vec![
2465                json!({"role":"user","content":"build it"}),
2466                // Mixed message: its text is commentary and precedes its call.
2467                json!({"type":"message","role":"assistant","phase":"commentary",
2468                       "content":[{"type":"output_text","text":"Mapping the repo first."}]}),
2469                json!({"type":"function_call","call_id":"call_a","name":"bash","arguments":"{\"command\":\"ls\"}"}),
2470                json!({"type":"function_call_output","call_id":"call_a","output":"src"}),
2471                // Tool-only message: unchanged, no message item invented.
2472                json!({"type":"function_call","call_id":"call_b","name":"bash","arguments":"{\"command\":\"pwd\"}"}),
2473                json!({"type":"function_call_output","call_id":"call_b","output":"/w"}),
2474                // Harness separator keeps the legacy shape (not model text).
2475                json!({"role":"assistant","content":" "}),
2476                json!({"role":"user","content":"also check logs"}),
2477                // Text-only messages (block and string storage) are final.
2478                json!({"type":"message","role":"assistant","phase":"final_answer",
2479                       "content":[{"type":"output_text","text":"Done: all good."}]}),
2480                json!({"role":"user","content":"thanks"}),
2481                json!({"type":"message","role":"assistant","phase":"final_answer",
2482                       "content":[{"type":"output_text","text":"Plain string answer."}]}),
2483            ]
2484        );
2485    }
2486
2487    #[test]
2488    fn codex_and_xai_input_stay_phase_less_for_the_same_history() {
2489        // Codex and xAI both build input with `codex_input_messages`; the
2490        // Muse change must not alter a byte of it.
2491        let out = codex_input_messages(oai(phase_history()));
2492        assert_eq!(
2493            out,
2494            vec![
2495                json!({"role":"user","content":"build it"}),
2496                json!({"role":"assistant","content":"Mapping the repo first."}),
2497                json!({"type":"function_call","call_id":"call_a","name":"bash","arguments":"{\"command\":\"ls\"}"}),
2498                json!({"type":"function_call_output","call_id":"call_a","output":"src"}),
2499                json!({"type":"function_call","call_id":"call_b","name":"bash","arguments":"{\"command\":\"pwd\"}"}),
2500                json!({"type":"function_call_output","call_id":"call_b","output":"/w"}),
2501                json!({"role":"assistant","content":" "}),
2502                json!({"role":"user","content":"also check logs"}),
2503                json!({"role":"assistant","content":"Done: all good."}),
2504                json!({"role":"user","content":"thanks"}),
2505                json!({"role":"assistant","content":"Plain string answer."}),
2506            ]
2507        );
2508        assert!(!serde_json::to_string(&out).unwrap().contains("phase"));
2509    }
2510
2511    #[test]
2512    fn muse_input_leaves_non_assistant_and_tool_only_messages_identical() {
2513        let messages = || {
2514            vec![
2515                ChatMessage::system("rules"),
2516                ChatMessage::user("question"),
2517                ChatMessage::assistant_tool_calls(vec![sample_tool_call()]),
2518                ChatMessage::tool_result("call_nZYquCuGUh8Qs9H51dwHMDgs", "bash", "done"),
2519            ]
2520        };
2521        assert_eq!(
2522            muse_input_messages(messages()),
2523            codex_input_messages(messages())
2524        );
2525    }
2526
2527    #[test]
2528    fn codex_instructions_appends_autonomous_loop_policy() {
2529        let instructions = codex_instructions(&Some("Project-specific rules.".to_string()));
2530        assert!(instructions.contains("Project-specific rules."));
2531        assert!(instructions.contains("Do not stop at phase boundaries"));
2532        assert!(instructions.contains("Do not ask the user whether to continue"));
2533        assert!(
2534            instructions.contains("continue autonomously until the full requested job is complete")
2535        );
2536    }
2537
2538    #[test]
2539    fn function_call_input_omits_non_fc_id() {
2540        let messages = vec![ChatMessage::assistant_tool_calls(vec![sample_tool_call()])];
2541        let out = codex_input_messages(messages);
2542        assert_eq!(out.len(), 1, "one tool_call → one input item");
2543        let item = &out[0];
2544        assert_eq!(
2545            item.get("type").and_then(Value::as_str),
2546            Some("function_call")
2547        );
2548        assert!(
2549            item.get("id").is_none(),
2550            "must not echo a non-`fc_` id back; got {:?}",
2551            item.get("id"),
2552        );
2553        assert_eq!(
2554            item.get("call_id").and_then(Value::as_str),
2555            Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
2556        );
2557        assert_eq!(item.get("name").and_then(Value::as_str), Some("bash"));
2558    }
2559
2560    #[test]
2561    fn function_call_input_keeps_real_fc_id() {
2562        // If we ever do have a genuine `fc_…` id (round-tripped from the
2563        // Responses API), we *should* echo it.
2564        let mut call = sample_tool_call();
2565        call.id = "fc_abc123".to_string();
2566        let messages = vec![ChatMessage::assistant_tool_calls(vec![call])];
2567        let out = codex_input_messages(messages);
2568        let item = &out[0];
2569        assert_eq!(item.get("id").and_then(Value::as_str), Some("fc_abc123"));
2570        assert_eq!(
2571            item.get("call_id").and_then(Value::as_str),
2572            Some("fc_abc123")
2573        );
2574    }
2575
2576    #[test]
2577    fn function_call_output_round_trips_call_id() {
2578        // The follow-up tool message must reference the original call_id.
2579        let messages = vec![ChatMessage::tool_result(
2580            "call_nZYquCuGUh8Qs9H51dwHMDgs",
2581            "bash",
2582            "total 0",
2583        )];
2584        let out = codex_input_messages(messages);
2585        let item = &out[0];
2586        assert_eq!(
2587            item.get("type").and_then(Value::as_str),
2588            Some("function_call_output"),
2589        );
2590        assert_eq!(
2591            item.get("call_id").and_then(Value::as_str),
2592            Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
2593        );
2594        assert_eq!(item.get("output").and_then(Value::as_str), Some("total 0"));
2595    }
2596}
2597
2598#[cfg(test)]
2599mod codex_decoder_tests {
2600    //! Regression tests for `CodexSseDecoder`.
2601    //!
2602    //! The decoder is sync — we drive it via `push_line` and capture
2603    //! emitted `StreamEvent`s from an `unbounded_channel` using
2604    //! `try_recv`, no async runtime needed.
2605
2606    use super::*;
2607    use crate::runtime::types::{LlmEvent, SessionEvent, StreamEvent};
2608
2609    fn collect_events(rx: &mut mpsc::UnboundedReceiver<StreamEvent>) -> Vec<StreamEvent> {
2610        let mut out = Vec::new();
2611        while let Ok(ev) = rx.try_recv() {
2612            out.push(ev);
2613        }
2614        out
2615    }
2616
2617    fn drive(lines: &[&str]) -> (CodexSseDecoder, String, Vec<StreamEvent>) {
2618        let (tx, mut rx) = mpsc::unbounded_channel();
2619        let mut decoder = CodexSseDecoder::default();
2620        let mut text_acc = String::new();
2621        for line in lines {
2622            decoder.push_line(line, &tx, &mut text_acc);
2623        }
2624        let events = collect_events(&mut rx);
2625        (decoder, text_acc, events)
2626    }
2627
2628    fn drive_with(
2629        mut decoder: CodexSseDecoder,
2630        lines: &[&str],
2631    ) -> (CodexSseDecoder, String, Vec<StreamEvent>) {
2632        let (tx, mut rx) = mpsc::unbounded_channel();
2633        let mut text_acc = String::new();
2634        for line in lines {
2635            decoder.push_line(line, &tx, &mut text_acc);
2636        }
2637        let events = collect_events(&mut rx);
2638        (decoder, text_acc, events)
2639    }
2640
2641    #[test]
2642    fn decoder_captures_phase_of_the_last_message_item() {
2643        // Muse shape (ask1.sse): reasoning, commentary message, function_call.
2644        let (decoder, text, _) = drive(&[
2645            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_0"}}"#,
2646            "",
2647            r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"message","role":"assistant","phase":"commentary","content":[]}}"#,
2648            "",
2649            r#"data: {"type":"response.output_text.delta","output_index":1,"delta":"Mapping first."}"#,
2650            "",
2651            r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"Mapping first."}]}}"#,
2652            "",
2653            r#"data: {"type":"response.output_item.added","output_index":2,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
2654            "",
2655        ]);
2656        assert_eq!(decoder.last_message_phase.as_deref(), Some("commentary"));
2657        assert_eq!(decoder.last_message_index, Some(1));
2658        assert_eq!(decoder.last_message_text(), Some("Mapping first."));
2659        assert_eq!(text, "Mapping first.");
2660
2661        // A later phase-less (final) message replaces it.
2662        let (decoder, _, _) = drive(&[
2663            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","phase":"commentary","content":[]}}"#,
2664            "",
2665            r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"message","role":"assistant","content":[]}}"#,
2666            "",
2667            r#"data: {"type":"response.output_text.delta","output_index":1,"delta":"Answer"}"#,
2668            "",
2669        ]);
2670        assert_eq!(decoder.last_message_phase, None);
2671        assert_eq!(decoder.last_message_text(), Some("Answer"));
2672    }
2673
2674    const TRUNCATED_STREAM: [&str; 6] = [
2675        r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","content":[]}}"#,
2676        "",
2677        r#"data: {"type":"response.output_text.delta","output_index":0,"delta":"Key facts:"}"#,
2678        "",
2679        r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Key facts:\nWhich format?"}]}}"#,
2680        "",
2681    ];
2682
2683    #[test]
2684    fn muse_decoder_backfills_unstreamed_message_suffix_once() {
2685        let mut decoder = CodexSseDecoder::for_provider("Meta Muse");
2686        decoder.backfill_message_text = true;
2687        let mut lines = TRUNCATED_STREAM.to_vec();
2688        // A repeated done event must not emit the suffix twice.
2689        lines.extend_from_slice(&TRUNCATED_STREAM[4..]);
2690        let (decoder, text, events) = drive_with(decoder, &lines);
2691        assert_eq!(text, "Key facts:\nWhich format?");
2692        let texts: Vec<_> = events
2693            .iter()
2694            .filter_map(|e| match e {
2695                StreamEvent::Llm(LlmEvent::Text(t)) => Some(t.as_str()),
2696                _ => None,
2697            })
2698            .collect();
2699        assert_eq!(texts, vec!["Key facts:", "\nWhich format?"]);
2700        assert_eq!(
2701            decoder.last_message_text(),
2702            Some("Key facts:\nWhich format?")
2703        );
2704    }
2705
2706    #[test]
2707    fn backfill_is_off_by_default_and_never_rewrites_divergent_text() {
2708        // Default (Codex) decoder: decoding unchanged.
2709        let (_, text, _) = drive(&TRUNCATED_STREAM);
2710        assert_eq!(text, "Key facts:");
2711        // Muse decoder: final text that does not extend the stream is ignored.
2712        let mut decoder = CodexSseDecoder::for_provider("Meta Muse");
2713        decoder.backfill_message_text = true;
2714        let (_, text, _) = drive_with(
2715            decoder,
2716            &[
2717                r#"data: {"type":"response.output_text.delta","output_index":0,"delta":"Hello"}"#,
2718                "",
2719                r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","content":[{"type":"output_text","text":"Goodbye world"}]}}"#,
2720                "",
2721            ],
2722        );
2723        assert_eq!(text, "Hello");
2724    }
2725
2726    #[test]
2727    fn hostile_output_index_does_not_grow_active_tools_unbounded() {
2728        // A provider (or wire corruption) sending an absurd `output_index` must
2729        // not drive an unbounded `active_tools` grow (OOM/DoS). ensure_tool clamps
2730        // to MAX_TOOLS. Without the clamp this test would attempt to allocate ~1e9
2731        // accumulators and hang/OOM.
2732        let lines = [
2733            r#"data: {"type":"response.function_call_arguments.delta","output_index":1000000000,"delta":"x"}"#,
2734            "",
2735        ];
2736        let (decoder, _text, _events) = drive(&lines);
2737        assert!(
2738            decoder.active_tools.len() <= 4096,
2739            "active_tools grew to {} — output_index not bounded",
2740            decoder.active_tools.len()
2741        );
2742    }
2743
2744    #[test]
2745    fn text_delta_aggregates_into_text_acc_and_emits_text_events() {
2746        let lines = [
2747            r#"data: {"type":"response.output_text.delta","delta":"Hello, "}"#,
2748            "",
2749            r#"data: {"type":"response.output_text.delta","delta":"world!"}"#,
2750            "",
2751        ];
2752        let (_decoder, text_acc, events) = drive(&lines);
2753        assert_eq!(text_acc, "Hello, world!");
2754        let texts: Vec<_> = events
2755            .iter()
2756            .filter_map(|e| match e {
2757                StreamEvent::Llm(LlmEvent::Text(t)) => Some(t.as_str()),
2758                _ => None,
2759            })
2760            .collect();
2761        assert_eq!(texts, vec!["Hello, ", "world!"]);
2762    }
2763
2764    #[test]
2765    fn hosted_web_search_items_are_not_tool_uses() {
2766        // OpenAI-hosted web search (SYNAPS_CODEX_WEB_SEARCH) streams
2767        // `web_search_call` output items. They run server-side, so the decoder
2768        // must not surface them as local tool uses — only the answer text and
2769        // real function calls come through.
2770        let lines = [
2771            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"web_search_call","id":"ws_1","status":"in_progress"}}"#,
2772            "",
2773            r#"data: {"type":"response.web_search_call.searching","output_index":0,"item_id":"ws_1"}"#,
2774            "",
2775            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"ram dass puja chat youtube"}}}"#,
2776            "",
2777            r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_1","name":"get_asset"}}"#,
2778            "",
2779            r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_1","name":"get_asset","arguments":"{}"}}"#,
2780            "",
2781            r#"data: {"type":"response.output_text.delta","output_index":2,"delta":"Found it."}"#,
2782            "",
2783            r#"data: {"type":"response.output_text.annotation.added","output_index":2,"annotation":{"type":"url_citation","url":"https://www.youtube.com/watch?v=sH-u96rpYSw"}}"#,
2784            "",
2785        ];
2786        let (decoder, text, events) = drive(&lines);
2787        assert_eq!(text, "Found it.");
2788        assert_eq!(decoder.completed_tools.len(), 1);
2789        assert_eq!(decoder.completed_tools[0].function.name, "get_asset");
2790        let starts = events
2791            .iter()
2792            .filter(|e| matches!(e, StreamEvent::Llm(LlmEvent::ToolUseStart { .. })))
2793            .count();
2794        assert_eq!(starts, 1, "web_search_call must not emit a ToolUseStart");
2795    }
2796
2797    #[test]
2798    fn web_search_env_value_maps_to_hosted_tool() {
2799        use super::codex_web_search_tool;
2800        assert_eq!(codex_web_search_tool(None), None);
2801        assert_eq!(codex_web_search_tool(Some("")), None);
2802        assert_eq!(codex_web_search_tool(Some("off")), None);
2803        assert_eq!(codex_web_search_tool(Some("bogus")), None);
2804        for on in ["live", "LIVE", " on ", "true", "1", "yes"] {
2805            assert_eq!(
2806                codex_web_search_tool(Some(on)),
2807                Some(serde_json::json!({ "type": "web_search" })),
2808                "{on:?}"
2809            );
2810        }
2811        assert_eq!(
2812            codex_web_search_tool(Some("cached")),
2813            Some(serde_json::json!({ "type": "web_search", "external_web_access": false }))
2814        );
2815    }
2816
2817    #[test]
2818    fn single_function_call_completes_via_output_item_done() {
2819        let lines = [
2820            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
2821            "",
2822            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\""}"#,
2823            "",
2824            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":":\"ls\"}"}"#,
2825            "",
2826            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
2827            "",
2828        ];
2829        let (decoder, _text, events) = drive(&lines);
2830
2831        assert_eq!(decoder.completed_tools.len(), 1);
2832        let tool = &decoder.completed_tools[0];
2833        assert_eq!(tool.id, "call_abc");
2834        assert_eq!(tool.function.name, "bash");
2835        assert_eq!(tool.function.arguments, r#"{"cmd":"ls"}"#);
2836
2837        // Exactly one ToolUseStart for the tool.
2838        let starts: Vec<_> = events
2839            .iter()
2840            .filter_map(|e| match e {
2841                StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => {
2842                    Some((tool_name.as_str(), tool_id.as_str()))
2843                }
2844                _ => None,
2845            })
2846            .collect();
2847        assert_eq!(
2848            starts,
2849            vec![("bash", "call_abc")],
2850            "exactly one ToolUseStart with correct tool_id"
2851        );
2852
2853        // Two argument deltas streamed (each carrying the tool_id so
2854        // parallel calls can be routed correctly by the chat UI).
2855        let deltas: Vec<_> = events
2856            .iter()
2857            .filter_map(|e| match e {
2858                StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id, delta }) => {
2859                    Some((tool_id.as_str(), delta.as_str()))
2860                }
2861                _ => None,
2862            })
2863            .collect();
2864        assert_eq!(
2865            deltas,
2866            vec![("call_abc", r#"{"cmd""#), ("call_abc", r#":"ls"}"#)]
2867        );
2868    }
2869
2870    #[test]
2871    fn parallel_tool_calls_indexed_separately() {
2872        let lines = [
2873            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
2874            "",
2875            r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
2876            "",
2877            r#"data: {"type":"response.function_call_arguments.delta","output_index":1,"delta":"{\"path\":\"a\"}"}"#,
2878            "",
2879            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\":\"ls\"}"}"#,
2880            "",
2881            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
2882            "",
2883            r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
2884            "",
2885        ];
2886        let (decoder, _text, _events) = drive(&lines);
2887
2888        assert_eq!(decoder.completed_tools.len(), 2);
2889        let mut by_id: std::collections::BTreeMap<&str, &ToolCall> =
2890            std::collections::BTreeMap::new();
2891        for tool in &decoder.completed_tools {
2892            by_id.insert(tool.id.as_str(), tool);
2893        }
2894        assert_eq!(by_id["call_1"].function.name, "bash");
2895        assert_eq!(by_id["call_1"].function.arguments, r#"{"cmd":"ls"}"#);
2896        assert_eq!(by_id["call_2"].function.name, "read");
2897        assert_eq!(by_id["call_2"].function.arguments, r#"{"path":"a"}"#);
2898    }
2899
2900    #[test]
2901    fn output_item_done_emits_tool_use_event() {
2902        // Regression: the codex decoder must emit `LlmEvent::ToolUse` once a
2903        // function_call's `output_item.done` arrives so the chat UI can
2904        // collapse `ChatMessage::ToolUseStart` (animated) into the finalized
2905        // `ChatMessage::ToolUse`. Without this the bash-trace animation
2906        // persists forever and parallel tool blocks render as "still
2907        // running" even after they've completed.
2908        let lines = [
2909            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
2910            "",
2911            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"command\":\"ls\"}"}"#,
2912            "",
2913            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
2914            "",
2915        ];
2916        let (_decoder, _text, events) = drive(&lines);
2917
2918        let tool_uses: Vec<_> = events
2919            .iter()
2920            .filter_map(|e| match e {
2921                StreamEvent::Llm(LlmEvent::ToolUse {
2922                    tool_name,
2923                    tool_id,
2924                    input,
2925                }) => Some((tool_name.as_str(), tool_id.as_str(), input.clone())),
2926                _ => None,
2927            })
2928            .collect();
2929        assert_eq!(
2930            tool_uses.len(),
2931            1,
2932            "expected exactly one ToolUse finalize event"
2933        );
2934        assert_eq!(tool_uses[0].0, "bash");
2935        assert_eq!(tool_uses[0].1, "call_abc");
2936        assert_eq!(
2937            tool_uses[0].2,
2938            serde_json::json!({"command": "ls"}),
2939            "input must be parsed as a JSON Value, not a string"
2940        );
2941    }
2942
2943    #[test]
2944    fn parallel_tool_calls_emit_tool_use_per_index() {
2945        // Regression: parallel tool calls must each get their own ToolUse
2946        // finalize event with the correct tool_id, so the chat UI can route
2947        // their results back to the right block by id.
2948        let lines = [
2949            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
2950            "",
2951            r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
2952            "",
2953            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
2954            "",
2955            r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
2956            "",
2957        ];
2958        let (_decoder, _text, events) = drive(&lines);
2959
2960        let tool_uses: Vec<_> = events
2961            .iter()
2962            .filter_map(|e| match e {
2963                StreamEvent::Llm(LlmEvent::ToolUse {
2964                    tool_name,
2965                    tool_id,
2966                    input,
2967                }) => Some((tool_name.clone(), tool_id.clone(), input.clone())),
2968                _ => None,
2969            })
2970            .collect();
2971
2972        assert_eq!(tool_uses.len(), 2, "one ToolUse finalize per parallel call");
2973        let by_id: std::collections::BTreeMap<&str, &(String, String, serde_json::Value)> =
2974            tool_uses.iter().map(|t| (t.1.as_str(), t)).collect();
2975        assert_eq!(by_id["call_1"].0, "bash");
2976        assert_eq!(by_id["call_1"].2, serde_json::json!({"command": "ls"}));
2977        assert_eq!(by_id["call_2"].0, "read");
2978        assert_eq!(by_id["call_2"].2, serde_json::json!({"path": "a"}));
2979    }
2980
2981    #[test]
2982    fn malformed_arguments_emit_tool_use_with_parse_error() {
2983        // If the model produces invalid JSON arguments, surface a structured
2984        // parse error in the `input` (matching how the Anthropic path
2985        // handles it via parse_tool_input) so the agent loop can return an
2986        // error tool_result instead of silently dropping the tool.
2987        let lines = [
2988            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash"}}"#,
2989            "",
2990            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash","arguments":"{not json"}}"#,
2991            "",
2992        ];
2993        let (_decoder, _text, events) = drive(&lines);
2994
2995        let tool_use = events.iter().find_map(|e| match e {
2996            StreamEvent::Llm(LlmEvent::ToolUse { input, .. }) => Some(input.clone()),
2997            _ => None,
2998        });
2999        let input = tool_use.expect("ToolUse event missing");
3000        assert!(
3001            input.get("__parse_error").and_then(Value::as_str).is_some(),
3002            "malformed arguments must surface __parse_error, got {input}"
3003        );
3004    }
3005
3006    #[test]
3007    fn response_completed_emits_usage_event() {
3008        let lines = [
3009            r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":42,"output_tokens":17}}}"#,
3010            "",
3011        ];
3012        let (_decoder, _text, events) = drive(&lines);
3013        let usage = events.iter().find_map(|e| match e {
3014            StreamEvent::Session(SessionEvent::Usage {
3015                input_tokens,
3016                output_tokens,
3017                ..
3018            }) => Some((*input_tokens, *output_tokens)),
3019            _ => None,
3020        });
3021        assert_eq!(usage, Some((42, 17)));
3022    }
3023
3024    /// Responses-API usage nests cache hits under input_tokens_details, and
3025    /// input_tokens INCLUDES them. The decoder must surface the cached slice
3026    /// as cache_read (Anthropic semantics) and subtract it from input —
3027    /// dropping it (the old hardcoded 0) made every codex turn look 100%
3028    /// uncached (incident: 2026-07-16 codex cache investigation).
3029    #[test]
3030    fn response_completed_surfaces_cached_tokens_without_double_count() {
3031        let lines = [
3032            r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":1000,"input_tokens_details":{"cached_tokens":900},"output_tokens":17}}}"#,
3033            "",
3034        ];
3035        let (_decoder, _text, events) = drive(&lines);
3036        let usage = events.iter().find_map(|e| match e {
3037            StreamEvent::Session(SessionEvent::Usage {
3038                input_tokens,
3039                output_tokens,
3040                cache_read_input_tokens,
3041                ..
3042            }) => Some((*input_tokens, *output_tokens, *cache_read_input_tokens)),
3043            _ => None,
3044        });
3045        assert_eq!(
3046            usage,
3047            Some((100, 17, 900)),
3048            "input must exclude the cached slice; cache_read must carry it"
3049        );
3050    }
3051
3052    #[test]
3053    fn response_completed_with_zero_usage_emits_nothing() {
3054        let lines = [
3055            r#"data: {"type":"response.output_text.delta","delta":"ok"}"#,
3056            "",
3057            r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":0,"output_tokens":0}}}"#,
3058            "",
3059        ];
3060        let (decoder, _text, events) = drive(&lines);
3061        let any_usage = events
3062            .iter()
3063            .any(|e| matches!(e, StreamEvent::Session(SessionEvent::Usage { .. })));
3064        assert!(!any_usage, "zero-token usage should be suppressed");
3065        assert_eq!(decoder.terminal_result(), Ok(()));
3066    }
3067
3068    #[test]
3069    fn response_failed_is_a_terminal_failure_without_retaining_provider_text() {
3070        let lines = [
3071            r#"data: {"type":"response.failed","response":{"error":{"type":"server_error","message":"ECHOED:secret prompt"}}}"#,
3072            "",
3073        ];
3074        let (decoder, text, events) = drive(&lines);
3075        let failure = decoder.terminal_result().expect_err("must fail");
3076        // Exact server_error is now preserved as a transient capacity class.
3077        assert_eq!(failure.code, "responses_capacity");
3078        assert!(failure.is_retryable());
3079        assert_eq!(failure.message, format!("Codex{RESPONSES_CAPACITY_SUFFIX}"));
3080        assert!(!failure.message.contains("ECHOED"));
3081        assert!(text.is_empty());
3082        assert!(events.is_empty());
3083    }
3084
3085    #[test]
3086    fn top_level_error_event_is_a_terminal_failure() {
3087        let lines = [
3088            r#"data: {"type":"error","error":{"type":"server_error","message":"private"}}"#,
3089            "",
3090        ];
3091        let (decoder, _text, _events) = drive(&lines);
3092        assert_eq!(
3093            decoder.terminal_result().expect_err("must fail").code,
3094            "responses_capacity"
3095        );
3096    }
3097
3098    #[test]
3099    fn response_incomplete_is_not_misreported_as_empty_success() {
3100        let lines = [
3101            r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
3102            "",
3103        ];
3104        let (decoder, _text, _events) = drive(&lines);
3105        let failure = decoder.terminal_result().expect_err("must fail");
3106        assert_eq!(failure.code, "responses_incomplete");
3107        assert!(failure.message.contains("incomplete"));
3108        assert!(!failure.message.contains("max_output_tokens"));
3109    }
3110
3111    #[test]
3112    fn completed_without_text_or_tools_fails_at_transport_boundary() {
3113        let lines = [
3114            r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":7,"output_tokens":0}}}"#,
3115            "",
3116        ];
3117        let (decoder, _text, _events) = drive(&lines);
3118        assert_eq!(
3119            decoder.terminal_result().expect_err("must fail").code,
3120            "responses_empty"
3121        );
3122    }
3123
3124    #[test]
3125    fn eof_without_terminal_event_fails_closed() {
3126        let lines = [
3127            r#"data: {"type":"response.created","response":{"id":"resp_123"}}"#,
3128            "",
3129        ];
3130        let (decoder, _text, _events) = drive(&lines);
3131        assert_eq!(
3132            decoder.terminal_result().expect_err("must fail").code,
3133            "responses_missing_terminal"
3134        );
3135    }
3136
3137    /// The local-log sanitizer must pass lowercase snake-case identifiers
3138    /// verbatim, collapse secret-shaped identifiers to a shape marker, and
3139    /// never emit free-form provider text.
3140    #[test]
3141    fn error_identifier_sanitizer_never_passes_freeform_text() {
3142        assert_eq!(sanitize_error_identifier(None), "absent");
3143        assert_eq!(
3144            sanitize_error_identifier(Some("rate_limit_exceeded")),
3145            "rate_limit_exceeded"
3146        );
3147        assert_eq!(
3148            sanitize_error_identifier(Some("context_length_exceeded")),
3149            "context_length_exceeded"
3150        );
3151        assert_eq!(
3152            sanitize_error_identifier(Some("some_new_code2")),
3153            "some_new_code2"
3154        );
3155        // Mixed-case / dashed tokens can be secret-shaped (API keys).
3156        assert_eq!(
3157            sanitize_error_identifier(Some("sk-Abc123XYZ")),
3158            "unlisted_identifier"
3159        );
3160        assert_eq!(
3161            sanitize_error_identifier(Some("Server.Error")),
3162            "unlisted_identifier"
3163        );
3164        assert_eq!(
3165            sanitize_error_identifier(Some("ECHOED: user secret prompt")),
3166            "unsafe_or_freeform"
3167        );
3168        assert_eq!(sanitize_error_identifier(Some("")), "unsafe_or_freeform");
3169        let long = "a".repeat(65);
3170        assert_eq!(sanitize_error_identifier(Some(&long)), "unsafe_or_freeform");
3171    }
3172
3173    /// Context-window overflow maps to a specific, actionable message; every
3174    /// other terminal failure keeps the neutral details-withheld default. The
3175    /// mapping only ever reads sanitized enum identifiers, never free-text.
3176    #[test]
3177    fn terminal_failure_user_message_is_specific_only_for_known_codes() {
3178        assert!(user_message_for_terminal_failure(
3179            "Codex",
3180            "invalid_request_error",
3181            "context_length_exceeded",
3182            false
3183        )
3184        .contains("/compact"));
3185        // Code carried on `type` rather than `code` still maps.
3186        assert!(user_message_for_terminal_failure(
3187            "Codex",
3188            "context_length_exceeded",
3189            "absent",
3190            false
3191        )
3192        .contains("context window"));
3193        // Unknown/other codes keep the neutral default (no remedy claim).
3194        let default = user_message_for_terminal_failure(
3195            "Codex",
3196            "server_error",
3197            "unlisted_identifier",
3198            false,
3199        );
3200        assert!(default.starts_with("Codex response failed in stream."));
3201        assert!(!default.contains("/compact"));
3202        // Capacity gets the specific remedy, labelled for the provider.
3203        let capacity = user_message_for_terminal_failure("xAI", "absent", "absent", true);
3204        assert_eq!(
3205            capacity,
3206            "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
3207        );
3208    }
3209
3210    /// The exact flat envelope xAI emits (HTTP 200, one SSE event, close):
3211    /// no nested `error` object, `code` null, free-text `message`. It must
3212    /// classify as retryable capacity — labelled for the provider, and the
3213    /// provider prose must never reach the surfaced message.
3214    #[test]
3215    fn xai_flat_capacity_error_is_retryable_capacity_failure() {
3216        let lines = [
3217            r#"data: {"sequence_number":0,"type":"error","code":null,"message":"The model is currently at capacity due to high demand. Please try again in a few minutes, or use a higher service tier for priority processing: https://docs.x.ai/developers/advanced-api-usage/priority-processing","param":null}"#,
3218            "",
3219        ];
3220        let (tx, mut rx) = mpsc::unbounded_channel();
3221        let mut decoder = CodexSseDecoder::for_provider("xAI");
3222        let mut text = String::new();
3223        for line in lines {
3224            decoder.push_line(line, &tx, &mut text);
3225        }
3226        decoder.finish();
3227        let failure = decoder.terminal_result().expect_err("must fail");
3228        assert_eq!(failure.code, "responses_capacity");
3229        assert!(failure.is_retryable());
3230        assert_eq!(
3231            failure.message,
3232            "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
3233        );
3234        for banned in ["high demand", "docs.x.ai", "service tier", "Codex"] {
3235            assert!(
3236                !failure.message.contains(banned),
3237                "provider text `{banned}` leaked: {}",
3238                failure.message
3239            );
3240        }
3241        assert!(text.is_empty());
3242        assert!(!decoder.emitted_any_output());
3243        assert!(rx.try_recv().is_err(), "no events for an error-only stream");
3244    }
3245
3246    /// Flat `code` identifiers are honoured when no nested error object is
3247    /// present; only vetted codes classify as capacity.
3248    #[test]
3249    fn flat_code_classifies_capacity_only_for_vetted_codes() {
3250        let capacity = [
3251            r#"data: {"type":"error","code":"rate_limit_exceeded","message":"private"}"#,
3252            "",
3253        ];
3254        let (decoder, _, _) = drive(&capacity);
3255        let failure = decoder.terminal_result().expect_err("must fail");
3256        assert_eq!(failure.code, "responses_capacity");
3257        assert!(failure.is_retryable());
3258
3259        let other = [
3260            r#"data: {"type":"error","code":"invalid_api_key","message":"private"}"#,
3261            "",
3262        ];
3263        let (decoder, _, _) = drive(&other);
3264        let failure = decoder.terminal_result().expect_err("must fail");
3265        assert_eq!(failure.code, "responses_failed");
3266        assert!(!failure.is_retryable());
3267        assert!(!failure.message.contains("private"));
3268    }
3269
3270    #[test]
3271    fn provider_label_is_carried_into_every_terminal_message() {
3272        let (tx, _rx) = mpsc::unbounded_channel();
3273        let mut text = String::new();
3274        let mut decoder = CodexSseDecoder::for_provider("xAI");
3275        decoder.push_line(
3276            r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":0}}}"#,
3277            &tx,
3278            &mut text,
3279        );
3280        decoder.push_line("", &tx, &mut text);
3281        let failure = decoder.terminal_result().expect_err("empty");
3282        assert_eq!(failure.code, "responses_empty");
3283        assert!(failure.is_retryable());
3284        assert_eq!(
3285            failure.message,
3286            "xAI completed without text or tool output. Retry the request."
3287        );
3288
3289        let decoder = CodexSseDecoder::for_provider("xAI");
3290        let failure = decoder.terminal_result().expect_err("missing terminal");
3291        assert_eq!(failure.code, "responses_missing_terminal");
3292        assert!(failure.is_retryable());
3293        assert!(failure.message.starts_with("xAI response stream ended"));
3294
3295        let mut decoder = CodexSseDecoder::for_provider("xAI");
3296        decoder.push_line(
3297            r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
3298            &tx,
3299            &mut text,
3300        );
3301        decoder.push_line("", &tx, &mut text);
3302        let failure = decoder.terminal_result().expect_err("incomplete");
3303        assert_eq!(failure.code, "responses_incomplete");
3304        assert!(!failure.is_retryable());
3305        assert!(failure.message.starts_with("xAI response was incomplete."));
3306    }
3307
3308    #[test]
3309    fn done_marker_finishes_decoder() {
3310        let lines = [
3311            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_x","name":"bash"}}"#,
3312            "",
3313            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{}"}"#,
3314            "",
3315            "data: [DONE]",
3316            "",
3317        ];
3318        let (decoder, _text, _events) = drive(&lines);
3319        // active_tools promoted to completed_tools by finish().
3320        assert_eq!(decoder.completed_tools.len(), 1);
3321        assert_eq!(decoder.completed_tools[0].id, "call_x");
3322        assert_eq!(decoder.completed_tools[0].function.arguments, "{}");
3323    }
3324
3325    #[test]
3326    fn finish_is_idempotent_no_double_emit() {
3327        let lines = [
3328            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_y","name":"bash","arguments":"{}"}}"#,
3329            "",
3330        ];
3331        let (mut decoder, _text, _events) = drive(&lines);
3332        assert_eq!(decoder.completed_tools.len(), 1);
3333
3334        // Calling finish() again must not duplicate the tool.
3335        decoder.finish();
3336        assert_eq!(
3337            decoder.completed_tools.len(),
3338            1,
3339            "finish() called twice must not double-emit"
3340        );
3341    }
3342
3343    #[test]
3344    fn finish_drains_active_tools_for_state_hygiene() {
3345        // After [DONE], any leftover active tool entries should have been
3346        // promoted *and* drained from `active_tools`. This guards against
3347        // future code paths that re-call finish() (or new event types
3348        // that would otherwise re-iterate the old buffer).
3349        let lines = [
3350            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_z","name":"bash"}}"#,
3351            "",
3352            "data: [DONE]",
3353            "",
3354        ];
3355        let (decoder, _text, _events) = drive(&lines);
3356        assert_eq!(decoder.completed_tools.len(), 1);
3357        assert!(
3358            decoder.active_tools.is_empty(),
3359            "active_tools must be drained after finish()"
3360        );
3361    }
3362
3363    #[test]
3364    fn unknown_event_types_are_ignored() {
3365        let lines = [
3366            r#"data: {"type":"response.future_unknown_event","payload":{"x":1}}"#,
3367            "",
3368            r#"data: {"type":"response.output_text.delta","delta":"hi"}"#,
3369            "",
3370        ];
3371        let (_decoder, text_acc, _events) = drive(&lines);
3372        assert_eq!(text_acc, "hi");
3373    }
3374}
3375
3376#[cfg(test)]
3377mod broker_stream_tests {
3378    //! Broker-boundary streaming tests: the OpenAI-compatible stream path is
3379    //! driven end-to-end through an in-process `LocalBroker` against a fake
3380    //! upstream. The runtime side supplies NO credential — the broker applies
3381    //! it — and SSE deltas are forwarded to the event channel in real time.
3382
3383    use super::*;
3384    use crate::auth::{CredentialBroker, LocalBroker};
3385    use std::sync::Arc;
3386
3387    async fn spawn_fake_openai_sse() -> (String, Arc<std::sync::Mutex<String>>) {
3388        use axum::routing::post;
3389        let seen_auth = Arc::new(std::sync::Mutex::new(String::new()));
3390        let seen = seen_auth.clone();
3391        let app = axum::Router::new().route(
3392            "/chat/completions",
3393            post(move |headers: axum::http::HeaderMap| {
3394                let seen = seen.clone();
3395                async move {
3396                    *seen.lock().unwrap() = headers
3397                        .get("authorization")
3398                        .and_then(|v| v.to_str().ok())
3399                        .unwrap_or("")
3400                        .to_string();
3401                    let body = concat!(
3402                        "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n",
3403                        "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n",
3404                        "data: [DONE]\n\n",
3405                    );
3406                    ([("content-type", "text/event-stream")], body)
3407                }
3408            }),
3409        );
3410        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3411        let addr = listener.local_addr().unwrap();
3412        tokio::spawn(async move {
3413            axum::serve(listener, app).await.unwrap();
3414        });
3415        (format!("http://{addr}"), seen_auth)
3416    }
3417
3418    #[tokio::test]
3419    async fn oai_stream_flows_through_broker_and_forwards_deltas() {
3420        let (upstream, seen_auth) = spawn_fake_openai_sse().await;
3421        let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
3422            reqwest::Client::new(),
3423            upstream,
3424        ));
3425        let cfg = ProviderConfig {
3426            base_url: "unused-broker-derives-the-url".to_string(),
3427            model: "test-model".to_string(),
3428            provider: "local".to_string(),
3429        };
3430        let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
3431        let cancel = tokio_util::sync::CancellationToken::new();
3432
3433        let result = call_oai_stream_inner(
3434            &cfg,
3435            &broker,
3436            &[],
3437            &None,
3438            &[],
3439            &tx,
3440            None,
3441            None,
3442            0,
3443            agent_core::reasoning::ReasoningLevel::Medium,
3444            &cancel,
3445            &crate::runtime::trace::TraceContext::disabled(),
3446            true,
3447            false,
3448            0,
3449        )
3450        .await
3451        .expect("stream must complete");
3452
3453        // Final Anthropic-shaped value carries the accumulated text.
3454        assert_eq!(result["content"][0]["text"], "Hello");
3455
3456        // Deltas were forwarded in real time on the event channel.
3457        drop(tx);
3458        let mut streamed = String::new();
3459        while let Ok(ev) = rx.try_recv() {
3460            if let StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(t)) = ev {
3461                streamed.push_str(&t);
3462            }
3463        }
3464        assert_eq!(streamed, "Hello");
3465
3466        // The credential was applied by the broker, not by this call site.
3467        assert_eq!(&*seen_auth.lock().unwrap(), "Bearer local");
3468    }
3469
3470    /// Broker errors surface as typed failures without opening a stream and
3471    /// without any credential material in the message.
3472    #[tokio::test]
3473    async fn suppressed_sync_route_does_not_enqueue_display_deltas() {
3474        let (upstream, _seen_auth) = spawn_fake_openai_sse().await;
3475        let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
3476            reqwest::Client::new(),
3477            upstream,
3478        ));
3479        let cfg = ProviderConfig {
3480            base_url: "unused-broker-derives-the-url".to_string(),
3481            model: "test-model".to_string(),
3482            provider: "local".to_string(),
3483        };
3484        let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
3485        let result = call_oai_stream_inner(
3486            &cfg,
3487            &broker,
3488            &[],
3489            &None,
3490            &[],
3491            &tx,
3492            None,
3493            None,
3494            0,
3495            agent_core::reasoning::ReasoningLevel::Medium,
3496            &tokio_util::sync::CancellationToken::new(),
3497            &crate::runtime::trace::TraceContext::disabled(),
3498            true,
3499            true,
3500            0,
3501        )
3502        .await
3503        .expect("stream must complete");
3504        assert_eq!(result["content"][0]["text"], "Hello");
3505        assert!(
3506            rx.try_recv().is_err(),
3507            "sync route must suppress display deltas at production"
3508        );
3509    }
3510
3511    #[tokio::test]
3512    async fn oai_stream_broker_error_fails_closed() {
3513        // Unconfigured static provider → NotConfigured, no upstream contact.
3514        let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::new(reqwest::Client::new()));
3515        let cfg = ProviderConfig {
3516            base_url: String::new(),
3517            model: "m".to_string(),
3518            provider: "definitely-not-a-provider".to_string(),
3519        };
3520        let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
3521        let cancel = tokio_util::sync::CancellationToken::new();
3522        let err = call_oai_stream_inner(
3523            &cfg,
3524            &broker,
3525            &[],
3526            &None,
3527            &[],
3528            &tx,
3529            None,
3530            None,
3531            0,
3532            agent_core::reasoning::ReasoningLevel::Medium,
3533            &cancel,
3534            &crate::runtime::trace::TraceContext::disabled(),
3535            true,
3536            false,
3537            0,
3538        )
3539        .await
3540        .unwrap_err()
3541        .to_string();
3542        assert!(err.contains("unknown provider"), "got: {err}");
3543        assert!(!err.to_lowercase().contains("bearer"));
3544    }
3545
3546    /// Phase 1 privacy (spec §5.1): a hostile upstream answers the streaming
3547    /// open with HTTP 500 whose body echoes the full request. The broker's
3548    /// transport error carries a body snippet; the runtime must not surface
3549    /// or log it — status + provider label only.
3550    #[tokio::test]
3551    async fn oai_stream_upstream_error_body_never_surfaces() {
3552        use axum::response::IntoResponse;
3553        const SENTINEL: &str = "PH1-OAI-BROKER-SENTINEL-91d2-RAW";
3554        let app = axum::Router::new().route(
3555            "/chat/completions",
3556            axum::routing::post(move |body: String| async move {
3557                (
3558                    axum::http::StatusCode::INTERNAL_SERVER_ERROR,
3559                    [("content-type", "application/json")],
3560                    format!("{{\"error\":{{\"message\":\"ECHOED:{body}\"}}}}"),
3561                )
3562                    .into_response()
3563            }),
3564        );
3565        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3566        let addr = listener.local_addr().unwrap();
3567        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3568
3569        let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
3570            reqwest::Client::new(),
3571            format!("http://{addr}"),
3572        ));
3573        let cfg = ProviderConfig {
3574            base_url: "unused-broker-derives-the-url".to_string(),
3575            model: "test-model".to_string(),
3576            provider: "local".to_string(),
3577        };
3578        let tools = vec![serde_json::json!({
3579            "name": "ph1_secret_tool_zz",
3580            "description": "internal tool schema",
3581            "input_schema": {"type": "object", "properties": {}}
3582        })];
3583        let msgs: Vec<crate::SharedMessage> = vec![Arc::new(
3584            serde_json::json!({"role": "user", "content": SENTINEL}),
3585        )];
3586        let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
3587        let cancel = tokio_util::sync::CancellationToken::new();
3588
3589        let err = call_oai_stream_inner(
3590            &cfg,
3591            &broker,
3592            &tools,
3593            &Some(format!("system secret {SENTINEL}")),
3594            &msgs,
3595            &tx,
3596            None,
3597            None,
3598            0,
3599            agent_core::reasoning::ReasoningLevel::Medium,
3600            &cancel,
3601            &crate::runtime::trace::TraceContext::disabled(),
3602            true,
3603            false,
3604            0,
3605        )
3606        .await
3607        .expect_err("500 must fail")
3608        .to_string();
3609
3610        assert!(err.contains("500"), "status must survive: {err}");
3611        for banned in ["ECHOED", SENTINEL, "ph1_secret_tool_zz", "system secret"] {
3612            assert!(
3613                !err.contains(banned),
3614                "provider body content `{banned}` leaked into the surfaced error: {err}"
3615            );
3616        }
3617    }
3618}
3619
3620/// Provider display label for the xai-auth Responses route.
3621const XAI_PROVIDER_LABEL: &str = "xAI";
3622
3623/// Broker identity + display label for one OpenAI-Responses-wire provider
3624/// routed through the shared streaming loop
3625/// ([`stream_broker_responses`]).
3626#[derive(Debug, Clone, Copy)]
3627pub(crate) struct ResponsesProfile {
3628    /// Broker provider key (credential + pinned origin + path allowlist).
3629    pub broker_provider: &'static str,
3630    /// Allowlisted broker path for the Responses endpoint.
3631    pub path: &'static str,
3632    /// User-facing provider name in stream errors and logs.
3633    pub label: &'static str,
3634}
3635
3636pub(crate) const XAI_RESPONSES: ResponsesProfile = ResponsesProfile {
3637    broker_provider: "xai-auth",
3638    path: "/responses",
3639    label: XAI_PROVIDER_LABEL,
3640};
3641
3642/// Meta Muse: `https://api.meta.ai` origin, Responses at `/v1/responses`.
3643pub(crate) const MUSE_RESPONSES: ResponsesProfile = ResponsesProfile {
3644    broker_provider: "muse",
3645    path: "/v1/responses",
3646    label: "Meta Muse",
3647};
3648
3649#[allow(clippy::too_many_arguments)]
3650pub(crate) async fn call_xai_responses_stream_inner(
3651    cfg: &ProviderConfig,
3652    broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
3653    tools_schema: &[Value],
3654    system_prompt: &Option<String>,
3655    messages: &[crate::SharedMessage],
3656    tx: &mpsc::UnboundedSender<StreamEvent>,
3657    max_tokens: Option<u32>,
3658    reasoning_level: agent_core::reasoning::ReasoningLevel,
3659    cancel: &tokio_util::sync::CancellationToken,
3660    max_retries: u32,
3661    trace: &crate::runtime::trace::TraceContext,
3662    exact_wire_bytes: bool,
3663) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
3664    let (tools, names) = translate::tools_to_responses(tools_schema);
3665    let input = codex_input_messages(translate::messages_to_oai(messages, system_prompt, &names));
3666    // Pure, validated body construction — rejects unsupported reasoning
3667    // combinations BEFORE any broker credential access or network I/O.
3668    let body = build_xai_body(
3669        &cfg.model,
3670        reasoning_level,
3671        serde_json::to_value(input)?,
3672        tools,
3673        max_tokens,
3674    )
3675    .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
3676    stream_broker_responses(
3677        XAI_RESPONSES,
3678        cfg,
3679        broker,
3680        body,
3681        &names,
3682        tools_schema,
3683        system_prompt,
3684        messages,
3685        tx,
3686        cancel,
3687        max_retries,
3688        trace,
3689        exact_wire_bytes,
3690    )
3691    .await
3692}
3693
3694/// Dispatch a resolved `OpenAiResponses` route to its provider body builder:
3695/// `muse` → [`call_muse_responses_stream_inner`], otherwise xAI.
3696#[allow(clippy::too_many_arguments)]
3697pub(crate) async fn call_responses_route_inner(
3698    route_provider: &str,
3699    cfg: &ProviderConfig,
3700    broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
3701    tools_schema: &[Value],
3702    system_prompt: &Option<String>,
3703    messages: &[crate::SharedMessage],
3704    tx: &mpsc::UnboundedSender<StreamEvent>,
3705    max_tokens: Option<u32>,
3706    reasoning_level: agent_core::reasoning::ReasoningLevel,
3707    cancel: &tokio_util::sync::CancellationToken,
3708    max_retries: u32,
3709    trace: &crate::runtime::trace::TraceContext,
3710    exact_wire_bytes: bool,
3711) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
3712    if route_provider == MUSE_RESPONSES.broker_provider {
3713        call_muse_responses_stream_inner(
3714            cfg,
3715            broker,
3716            tools_schema,
3717            system_prompt,
3718            messages,
3719            tx,
3720            max_tokens,
3721            reasoning_level,
3722            cancel,
3723            max_retries,
3724            trace,
3725            exact_wire_bytes,
3726        )
3727        .await
3728    } else {
3729        call_xai_responses_stream_inner(
3730            cfg,
3731            broker,
3732            tools_schema,
3733            system_prompt,
3734            messages,
3735            tx,
3736            max_tokens,
3737            reasoning_level,
3738            cancel,
3739            max_retries,
3740            trace,
3741            exact_wire_bytes,
3742        )
3743        .await
3744    }
3745}
3746
3747/// Meta Muse Responses route (`muse/<id>`). Mirrors the official Muse Code
3748/// request shape: system prompt as `instructions`, conversation as typed
3749/// input items, `store:false` (stateless), and a `prompt_cache_key` so the
3750/// server-side prompt cache routes repeated prefixes to the same cache.
3751#[allow(clippy::too_many_arguments)]
3752pub(crate) async fn call_muse_responses_stream_inner(
3753    cfg: &ProviderConfig,
3754    broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
3755    tools_schema: &[Value],
3756    system_prompt: &Option<String>,
3757    messages: &[crate::SharedMessage],
3758    tx: &mpsc::UnboundedSender<StreamEvent>,
3759    max_tokens: Option<u32>,
3760    reasoning_level: agent_core::reasoning::ReasoningLevel,
3761    cancel: &tokio_util::sync::CancellationToken,
3762    max_retries: u32,
3763    trace: &crate::runtime::trace::TraceContext,
3764    exact_wire_bytes: bool,
3765) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
3766    let (tools, names) = translate::tools_to_responses(tools_schema);
3767    let instructions = system_prompt.clone().unwrap_or_default();
3768    // The system prompt travels as `instructions`, never duplicated in input.
3769    let input = muse_input_messages(translate::messages_to_oai(messages, &None, &names));
3770    let cache_key = muse_prompt_cache_key(&instructions, input.first());
3771    // Validated BEFORE any broker credential access or network I/O.
3772    let body = build_muse_body(
3773        &cfg.model,
3774        reasoning_level,
3775        Value::Array(input),
3776        &instructions,
3777        tools,
3778        max_tokens,
3779        &cache_key,
3780    )
3781    .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
3782    stream_broker_responses(
3783        MUSE_RESPONSES,
3784        cfg,
3785        broker,
3786        body,
3787        &names,
3788        tools_schema,
3789        system_prompt,
3790        messages,
3791        tx,
3792        cancel,
3793        max_retries,
3794        trace,
3795        exact_wire_bytes,
3796    )
3797    .await
3798}
3799
3800/// Deterministic Muse cache-routing key derived from the stable prompt head
3801/// (instructions + first input item), like the Codex key: identical heads
3802/// share the prefix the server caches, and a /compact rewrite changes the
3803/// key together with the prefix it invalidates. Namespaced so it can never
3804/// collide with Codex keys.
3805pub(crate) fn muse_prompt_cache_key(instructions: &str, first_item: Option<&Value>) -> String {
3806    codex_prompt_cache_key(instructions, first_item).replacen("synaps-", "synaps-muse-", 1)
3807}
3808
3809/// Pure, validated body construction for the Meta Muse Responses API.
3810///
3811/// Shape mirrors the official Muse Code client (verified by capture):
3812/// `model`, `instructions`, `input`, `store:false`, `stream:true`, `tools`,
3813/// `max_output_tokens`, `reasoning.effort`, `prompt_cache_key`. Reasoning is
3814/// validated against the exact model's capability first; `Adaptive` omits
3815/// the field (provider default). Encrypted reasoning is not requested:
3816/// Synaps does not replay reasoning items across turns.
3817#[allow(clippy::too_many_arguments)] // pure builder mirroring the wire body
3818pub(crate) fn build_muse_body(
3819    model: &str,
3820    level: agent_core::reasoning::ReasoningLevel,
3821    input: Value,
3822    instructions: &str,
3823    tools: Vec<Value>,
3824    max_tokens: Option<u32>,
3825    prompt_cache_key: &str,
3826) -> Result<Value, String> {
3827    crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
3828        &format!("muse/{model}"),
3829        level,
3830    )?;
3831    let mut body = serde_json::Map::new();
3832    body.insert("model".into(), json!(model));
3833    if !instructions.is_empty() {
3834        body.insert("instructions".into(), json!(instructions));
3835    }
3836    body.insert("input".into(), input);
3837    body.insert("store".into(), json!(false));
3838    body.insert("stream".into(), json!(true));
3839    if !tools.is_empty() {
3840        body.insert("tools".into(), Value::Array(tools));
3841    }
3842    if let Some(max) = max_tokens {
3843        body.insert("max_output_tokens".into(), json!(max));
3844    }
3845    if let Some(effort) = crate::runtime::openai::catalog::muse_effort_label(level) {
3846        body.insert("reasoning".into(), json!({ "effort": effort }));
3847    }
3848    body.insert("prompt_cache_key".into(), json!(prompt_cache_key));
3849    Ok(Value::Object(body))
3850}
3851
3852/// Shared streaming loop for broker-proxied OpenAI Responses providers
3853/// (xAI, Meta Muse): one exact-bytes request, SSE decode, bounded retries
3854/// that never replay after output reached the UI.
3855#[allow(clippy::too_many_arguments)]
3856async fn stream_broker_responses(
3857    profile: ResponsesProfile,
3858    cfg: &ProviderConfig,
3859    broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
3860    body: Value,
3861    names: &translate::ToolNameMap,
3862    tools_schema: &[Value],
3863    system_prompt: &Option<String>,
3864    messages: &[crate::SharedMessage],
3865    tx: &mpsc::UnboundedSender<StreamEvent>,
3866    cancel: &tokio_util::sync::CancellationToken,
3867    max_retries: u32,
3868    trace: &crate::runtime::trace::TraceContext,
3869    exact_wire_bytes: bool,
3870) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
3871    // Serialize ONCE via the sanctioned constructor: the digested bytes are
3872    // the very bytes the broker sends upstream (`body_bytes` handoff,
3873    // LocalBroker verbatim) — `body`/`body_bytes` cannot diverge. On a
3874    // remote broker the daemon re-serializes out of process → honest
3875    // `CloudProxy` kind and no wire-byte claim. Every retry clones this
3876    // same request (identical `body` and `body_bytes`), so one wire digest
3877    // is honest for all attempt records.
3878    let (proxy_request, body_bytes) = crate::auth::ProxyRequest::post_json_exact(
3879        profile.broker_provider,
3880        profile.path,
3881        body,
3882        true,
3883    )?;
3884    let tracer = tr::begin_openai_tracer(
3885        trace,
3886        &cfg.provider,
3887        &cfg.model,
3888        if exact_wire_bytes {
3889            crate::runtime::trace::TransportKind::OpenAiResponses
3890        } else {
3891            crate::runtime::trace::TransportKind::CloudProxy
3892        },
3893        &format!("{}/responses", cfg.base_url.trim_end_matches('/')),
3894        exact_wire_bytes.then_some(body_bytes.as_ref()),
3895        messages,
3896        system_prompt.as_deref(),
3897        tools_schema,
3898        tr::renamed_tool_losses(names),
3899    )
3900    .await;
3901    // One-shot explicit content capture (`/trace next content`): a no-op in
3902    // every context without the arm. `body_bytes` is the serialized request
3903    // body this process built pre-send (exact wire bytes on the local
3904    // broker; the same body a remote broker re-serializes) — body only,
3905    // headers and credentials structurally never reach this seam.
3906    if let Some(tracer) = &tracer {
3907        tr::capture_request_content(trace, tracer.request_id(), body_bytes.as_ref(), messages);
3908    }
3909    let mut attempt = tr::StreamAttempt::new(tracer);
3910    let mut stream_retry = 0u32;
3911
3912    loop {
3913        if cancel.is_cancelled() {
3914            return Err("request canceled".into());
3915        }
3916        let stream = crate::runtime::api::await_or_cancel(
3917            cancel,
3918            broker.proxy_stream(proxy_request.clone()),
3919        )
3920        .await
3921        .map_err(|_| "request canceled")?;
3922        // A credential refresh failure keeps its kind as a type (retryable /
3923        // action-required vs credential); its text is ours, no upstream bytes.
3924        let stream = match stream {
3925            Err(crate::auth::BrokerError::Refresh(f)) => {
3926                attempt.finish_failed("broker_error", None, None);
3927                return Err(Box::new(super::net::BrokerRefreshError::new(f)));
3928            }
3929            // Privacy (spec §5.1): a broker proxy error may carry an upstream
3930            // response-body snippet; redact it to status-only before surfacing.
3931            other => other.map_err(|e| super::net::redact_provider_proxy_error(&e.to_string())),
3932        };
3933        let mut stream = match stream {
3934            Ok(stream) => {
3935                attempt.mark_headers();
3936                stream
3937            }
3938            Err(msg) => {
3939                let status = tr::broker_error_status(&msg);
3940                let code =
3941                    status.map_or_else(|| "broker_error".to_string(), |s| format!("http_{s}"));
3942                attempt.finish_failed(&code, status, None);
3943                return Err(super::net::normalize_broker_http_error(msg).into());
3944            }
3945        };
3946        let _ = tx.send(StreamEvent::Llm(crate::LlmEvent::ResponseStart));
3947        let mut interrupted = false;
3948        let mut text = String::new();
3949        let mut parser = CodexSseDecoder::for_provider(profile.label);
3950        parser.backfill_message_text = profile.broker_provider == MUSE_RESPONSES.broker_provider;
3951        let mut buf = bytes::BytesMut::new();
3952        while let Some(chunk) = tokio::select! {
3953            biased;
3954            _ = cancel.cancelled() => {
3955                attempt.finish_canceled(None, parser.trace_usage());
3956                return Err("request canceled".into());
3957            }
3958            c = stream.next() => c,
3959        } {
3960            let chunk = match chunk {
3961                Ok(chunk) => {
3962                    attempt.mark_first_byte();
3963                    chunk
3964                }
3965                Err(e) => {
3966                    if !matches!(e, crate::auth::BrokerError::Transport(_)) {
3967                        attempt.finish_failed("broker_error", None, None);
3968                        return Err(e.into());
3969                    }
3970                    interrupted = !parser.terminal_success && parser.terminal_failure.is_none();
3971                    break;
3972                }
3973            };
3974            buf.extend_from_slice(&chunk);
3975            while let Some(n) = memchr::memchr(b'\n', &buf) {
3976                let line = buf.split_to(n + 1);
3977                parser.push_line(std::str::from_utf8(&line[..n]).unwrap_or(""), tx, &mut text);
3978            }
3979            if parser.saw_model_event {
3980                attempt.mark_first_model_event();
3981            }
3982        }
3983        if !interrupted && !buf.is_empty() {
3984            parser.push_line(std::str::from_utf8(&buf).unwrap_or(""), tx, &mut text);
3985        }
3986        if interrupted || (!parser.terminal_success && parser.terminal_failure.is_none()) {
3987            if stream_retry >= max_retries {
3988                attempt.finish_failed("stream_error", None, None);
3989                return Err(STREAM_INTERRUPTED.into());
3990            }
3991            stream_retry += 1;
3992            attempt.attempt_failed(
3993                crate::runtime::trace::RetryClass::Network,
3994                retry_delay(stream_retry),
3995                None,
3996                None,
3997                "stream_error",
3998            );
3999            wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
4000                .await
4001                .inspect_err(|_| {
4002                    attempt.finish_canceled(None, None);
4003                })?;
4004            attempt.restart_clock();
4005            continue;
4006        }
4007        parser.finish();
4008        // Trailing-buffer flush and finish() can surface the first (or only)
4009        // model event; mark it so first_model_event_ms is honest for tail-only
4010        // streams (mirrors the Codex direct path above).
4011        if parser.saw_model_event {
4012            attempt.mark_first_model_event();
4013        }
4014        // Broker paths never observe the upstream HTTP status; the Responses
4015        // stream does not yet expose a normalized stop reason — both stay
4016        // honest `None` rather than guessed values.
4017        if let Err(failure) = parser.terminal_result() {
4018            // xAI answers HTTP 200 + a lone `{"type":"error",…}` capacity
4019            // event while the model is saturated; a re-send seconds later
4020            // usually succeeds. Retry only when NOTHING reached the UI on
4021            // this attempt — text or tool events already streamed would be
4022            // emitted twice on a re-send, so those failures stay terminal.
4023            let output_already_streamed = !text.is_empty() || parser.emitted_any_output();
4024            if failure.is_retryable() && !output_already_streamed && stream_retry < max_retries {
4025                stream_retry += 1;
4026                let delay = retry_delay(stream_retry);
4027                attempt.attempt_failed(
4028                    crate::runtime::trace::RetryClass::Other,
4029                    delay,
4030                    None,
4031                    None,
4032                    failure.code,
4033                );
4034                tracing::warn!(
4035                    code = failure.code,
4036                    provider = profile.broker_provider,
4037                    "responses stream retry {stream_retry}/{max_retries} after {delay:?}"
4038                );
4039                tokio::select! {
4040                    _ = tokio::time::sleep(delay) => {}
4041                    _ = cancel.cancelled() => {
4042                        attempt.finish_canceled(None, None);
4043                        return Err("request canceled".into());
4044                    }
4045                }
4046                attempt.restart_clock();
4047                continue;
4048            }
4049            attempt.finish_failed(failure.code, None, None);
4050            return Err(failure.message.into());
4051        }
4052        attempt.finish_success(None, None, None, parser.trace_usage());
4053        let mut content = Vec::new();
4054        if !text.is_empty() {
4055            content.push(json!({"type":"text","text":text}));
4056        }
4057        content.extend(translate::tool_calls_to_content_blocks(
4058            &parser.completed_tools,
4059            names,
4060        ));
4061        let mut response = json!({"role":"assistant","content":content});
4062        if profile.broker_provider == MUSE_RESPONSES.broker_provider {
4063            if let Some(reason) = muse_dangling_preamble(
4064                !parser.completed_tools.is_empty(),
4065                parser.last_message_phase.as_deref(),
4066                parser.last_message_text().unwrap_or(&text),
4067            ) {
4068                tracing::info!(
4069                    provider = profile.broker_provider,
4070                    reason,
4071                    "muse response ended on a preamble without acting"
4072                );
4073                // Top-level sidecar, never inside `content`: the turn loop
4074                // persists only `content`, so nothing reaches stored history.
4075                response[DANGLING_PREAMBLE_KEY] = json!(reason);
4076            }
4077        }
4078        return Ok(response);
4079    }
4080}
4081
4082/// Response sidecar key (top level, outside `content`) set by the Muse route
4083/// when a completed response ended on a preamble without acting. Read by the
4084/// turn loop's one-shot continuation; never persisted.
4085pub(crate) const DANGLING_PREAMBLE_KEY: &str = "synaps_dangling_preamble";
4086
4087/// Upper bound (chars, trimmed) for the text heuristic. The observed failures
4088/// were 32–36 output tokens (~70–130 chars); 300 leaves room for a long
4089/// sentence of preamble while excluding real answers that merely end in a
4090/// colon after substantive content.
4091pub(crate) const PREAMBLE_MAX_CHARS: usize = 300;
4092
4093/// Decide whether a completed Muse response ended on an unfinished preamble.
4094/// Returns the trigger reason, or `None`. Never fires when the response
4095/// called a tool.
4096///
4097/// - `commentary_phase`: the last message item was labelled
4098///   `phase:"commentary"` by Muse itself, yet no tool call followed — by the
4099///   provider's own contract the turn is unfinished.
4100/// - `colon_preamble`: the last message's trimmed text is short
4101///   (≤ [`PREAMBLE_MAX_CHARS`]) and ends with ':' — "Blocking question
4102///   before I build anything:" with nothing after it.
4103pub(crate) fn muse_dangling_preamble(
4104    has_tool_calls: bool,
4105    last_message_phase: Option<&str>,
4106    last_message_text: &str,
4107) -> Option<&'static str> {
4108    if has_tool_calls {
4109        return None;
4110    }
4111    if last_message_phase == Some("commentary") {
4112        return Some("commentary_phase");
4113    }
4114    let text = last_message_text.trim();
4115    if (text.ends_with(':') || text.ends_with('\u{ff1a}'))
4116        && text.chars().count() <= PREAMBLE_MAX_CHARS
4117    {
4118        return Some("colon_preamble");
4119    }
4120    None
4121}
4122
4123/// Harness-authored nudge appended (once per user turn) after a dangling
4124/// preamble. Framed like the context advisories so the model does not read
4125/// it as a new user request.
4126pub(crate) const PREAMBLE_NUDGE: &str = "[Host harness note, not a new user request] You ended your turn on a preamble without acting. Continue now: run the tool call you announced, or ask your question in full.";
4127
4128/// Visible system note shown when the nudge fires.
4129pub(crate) const PREAMBLE_NUDGE_NOTICE: &str = "Muse ended its turn on a preamble without acting; continuing once automatically (disable: SYNAPS_MUSE_PREAMBLE_NUDGE=0).";
4130
4131/// `SYNAPS_MUSE_PREAMBLE_NUDGE=0|false|off|no` disables the one-shot
4132/// continuation. Enabled by default.
4133pub(crate) fn preamble_nudge_enabled() -> bool {
4134    preamble_nudge_enabled_value(std::env::var("SYNAPS_MUSE_PREAMBLE_NUDGE").ok().as_deref())
4135}
4136
4137fn preamble_nudge_enabled_value(value: Option<&str>) -> bool {
4138    !matches!(
4139        value.map(|v| v.trim().to_ascii_lowercase()).as_deref(),
4140        Some("0" | "false" | "off" | "no")
4141    )
4142}
4143
4144/// Turn-loop gate for the one-shot preamble continuation. Returns the reason
4145/// and arms `already_nudged` when the response carries the Muse sidecar, the
4146/// round was not cancelled, the feature is enabled, and this user turn has
4147/// not been nudged yet. At most one nudge per user turn: it cannot loop.
4148pub(crate) fn take_preamble_nudge(
4149    response: &Value,
4150    already_nudged: &mut bool,
4151    cancelled: bool,
4152    enabled: bool,
4153) -> Option<String> {
4154    if *already_nudged || cancelled || !enabled {
4155        return None;
4156    }
4157    let reason = response.get(DANGLING_PREAMBLE_KEY)?.as_str()?.to_string();
4158    *already_nudged = true;
4159    Some(reason)
4160}
4161
4162/// Pure, validated body construction for the xAI Responses API.
4163///
4164/// Enforces the exact-model reasoning matrix from
4165/// `docs/specs/anthropic-xai-reasoning-modes-spec.md` BEFORE any credential or
4166/// network access, via the shared per-provider validator:
4167/// - `Off` is rejected (never silently omitted) on models whose reasoning
4168///   cannot be disabled; on the non-reasoning model it is trivially omission.
4169/// - `Adaptive` omits the `reasoning` field → documented provider default.
4170/// - Named levels are emitted as exact `reasoning:{effort:"..."}` only when
4171///   the exact model id documents them; otherwise `Err` (fail closed).
4172pub(crate) fn build_xai_body(
4173    model: &str,
4174    level: agent_core::reasoning::ReasoningLevel,
4175    input: Value,
4176    tools: Vec<Value>,
4177    max_tokens: Option<u32>,
4178) -> Result<Value, String> {
4179    use agent_core::reasoning::ReasoningLevel;
4180    crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
4181        &format!("xai-auth/{model}"),
4182        level,
4183    )?;
4184    let mut body = serde_json::Map::new();
4185    body.insert("model".into(), json!(model));
4186    body.insert("input".into(), input);
4187    body.insert("stream".into(), json!(true));
4188    if !tools.is_empty() {
4189        body.insert("tools".into(), Value::Array(tools));
4190    }
4191    if let Some(max) = max_tokens {
4192        body.insert("max_output_tokens".into(), json!(max));
4193    }
4194    match level {
4195        // Off (validated: only reachable where reasoning is absent/disableable)
4196        // and Adaptive: omit `reasoning` — provider default applies.
4197        ReasoningLevel::Off | ReasoningLevel::Adaptive => {}
4198        l => {
4199            body.insert("reasoning".into(), json!({ "effort": l.as_str() }));
4200        }
4201    }
4202    Ok(Value::Object(body))
4203}
4204
4205#[cfg(test)]
4206mod xai_tests {
4207    use super::*;
4208    use agent_core::reasoning::ReasoningLevel;
4209
4210    #[test]
4211    fn xai_fixture_is_public_responses_shape() {
4212        let fixture = serde_json::json!({"type":"response.output_text.delta","delta":"hello"});
4213        assert_eq!(fixture["type"], "response.output_text.delta");
4214        assert_eq!(fixture["delta"], "hello");
4215    }
4216
4217    fn body_for(model: &str, level: ReasoningLevel) -> Result<Value, String> {
4218        build_xai_body(model, level, json!([]), Vec::new(), Some(1024))
4219    }
4220
4221    // ── Exact Responses wire (spec: anthropic-xai-reasoning-modes) ───────────
4222
4223    #[test]
4224    fn grok45_emits_exact_documented_efforts() {
4225        for (level, effort) in [
4226            (ReasoningLevel::Low, "low"),
4227            (ReasoningLevel::Medium, "medium"),
4228            (ReasoningLevel::High, "high"),
4229        ] {
4230            let body = body_for("grok-4.5", level).expect("supported effort");
4231            assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
4232            assert_eq!(body["model"], "grok-4.5");
4233            assert_eq!(body["stream"], json!(true));
4234            assert_eq!(body["max_output_tokens"], json!(1024));
4235        }
4236    }
4237
4238    #[test]
4239    fn grok47_responses_wire_uses_exact_efforts_and_preserves_tool_shape() {
4240        for (level, effort) in [
4241            (ReasoningLevel::Low, "low"),
4242            (ReasoningLevel::Medium, "medium"),
4243            (ReasoningLevel::High, "high"),
4244            (ReasoningLevel::XHigh, "xhigh"),
4245        ] {
4246            let input = json!([{"role": "user", "content": "hello"}]);
4247            let tool = json!({"type": "function", "name": "inspect", "parameters": {"type": "object"}});
4248            let body = build_xai_body("grok-4.7", level, input.clone(), vec![tool.clone()], Some(1024)).unwrap();
4249            assert_eq!(body["model"], "grok-4.7");
4250            assert_eq!(body["reasoning"], json!({"effort": effort}));
4251            assert_eq!(body["input"], input);
4252            assert_eq!(body["tools"], json!([tool]));
4253            assert_eq!(body["stream"], true);
4254            assert_eq!(body["max_output_tokens"], 1024);
4255            assert!(body.get("messages").is_none());
4256            assert!(body.get("reasoning_effort").is_none());
4257            // Default ciphertext does not require opting into include or
4258            // changing storage policy (official docs say ignoring it works).
4259            assert!(body.get("include").is_none());
4260            assert!(body.get("store").is_none());
4261        }
4262        assert!(body_for("grok-4.7", ReasoningLevel::Adaptive).unwrap().get("reasoning").is_none());
4263        for level in [ReasoningLevel::Off, ReasoningLevel::Max, ReasoningLevel::Ultra, ReasoningLevel::UltraCode] {
4264            assert!(body_for("grok-4.7", level).is_err(), "{level}");
4265        }
4266    }
4267
4268    #[test]
4269    fn grok46_emits_exact_documented_efforts() {
4270        for (level, effort) in [
4271            (ReasoningLevel::Low, "low"),
4272            (ReasoningLevel::Medium, "medium"),
4273            (ReasoningLevel::High, "high"),
4274        ] {
4275            let body = body_for("grok-4.6", level).expect("supported effort");
4276            assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
4277            assert_eq!(body["model"], "grok-4.6");
4278            assert_eq!(body["stream"], json!(true));
4279            assert_eq!(body["max_output_tokens"], json!(1024));
4280        }
4281    }
4282
4283    #[test]
4284    fn adaptive_omits_reasoning_field_provider_default() {
4285        for model in [
4286            "grok-4.5",
4287            "grok-4.5-latest",
4288            "grok-4.6",
4289            "grok-4.3",
4290            "grok-4.20-0309-non-reasoning",
4291        ] {
4292            let body = body_for(model, ReasoningLevel::Adaptive).expect("adaptive is omission");
4293            assert!(body.get("reasoning").is_none(), "{model}");
4294        }
4295    }
4296
4297    #[test]
4298    fn off_is_rejected_pre_network_when_reasoning_cannot_be_disabled() {
4299        for model in [
4300            "grok-4.5",
4301            "grok-4.5-latest",
4302            "grok-4.6",
4303            "grok-4.20-multi-agent-0309",
4304            "grok-4.3",
4305        ] {
4306            let err = body_for(model, ReasoningLevel::Off).unwrap_err();
4307            assert!(err.contains(model), "{model}: {err}");
4308        }
4309        // Non-reasoning model: Off is trivially satisfied by omission.
4310        let body = body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Off).unwrap();
4311        assert!(body.get("reasoning").is_none());
4312    }
4313
4314    #[test]
4315    fn unsupported_named_efforts_are_rejected_pre_network() {
4316        // 4.5/4.6 have no documented xhigh; max/ultra never exist on xAI.
4317        for level in [
4318            ReasoningLevel::XHigh,
4319            ReasoningLevel::Max,
4320            ReasoningLevel::Ultra,
4321        ] {
4322            assert!(body_for("grok-4.5", level).is_err(), "{level}");
4323            assert!(body_for("grok-4.6", level).is_err(), "{level}");
4324        }
4325        // Intrinsic-reasoning models have no documented effort control.
4326        assert!(body_for("grok-4.3", ReasoningLevel::Medium).is_err());
4327        // Non-reasoning models reject named reasoning outright.
4328        assert!(body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Medium).is_err());
4329        // Unknown exact ids fail closed.
4330        assert!(body_for("grok-9000", ReasoningLevel::Medium).is_err());
4331    }
4332
4333    #[test]
4334    fn multi_agent_xhigh_is_exact_agent_count_control() {
4335        let body = body_for("grok-4.20-multi-agent-0309", ReasoningLevel::XHigh).unwrap();
4336        assert_eq!(body["reasoning"], json!({"effort": "xhigh"}));
4337    }
4338
4339    #[test]
4340    fn responses_wire_shape_tools_flat_and_no_chat_completions_fields() {
4341        let tools = vec![json!({
4342            "type": "function",
4343            "name": "get_weather",
4344            "description": "d",
4345            "parameters": {"type": "object"}
4346        })];
4347        let body =
4348            build_xai_body("grok-4.5", ReasoningLevel::High, json!([]), tools, None).unwrap();
4349        // Responses API: flat tool objects + `input`, never chat-completions
4350        // `messages`/nested function wrappers.
4351        assert_eq!(body["tools"][0]["name"], "get_weather");
4352        assert!(body["tools"][0].get("function").is_none());
4353        assert!(body.get("input").is_some());
4354        assert!(body.get("messages").is_none());
4355        assert!(body.get("max_output_tokens").is_none());
4356    }
4357}
4358
4359#[cfg(test)]
4360mod xai_capacity_retry_tests {
4361    //! Regression tests for the xai-auth Responses route: xAI answers HTTP
4362    //! 200 + a lone flat `{"type":"error",…}` capacity event while the model
4363    //! is saturated (reproduced live: grok-4.6 failed 3 of 4 turns, a re-send
4364    //! seconds later succeeded). The route must retry with identical request
4365    //! bytes, label its messages "xAI" (not "Codex"), and never surface the
4366    //! provider prose. A scripted fake broker serves the SSE bodies directly
4367    //! through `proxy_stream` — no HTTP server involved.
4368
4369    use super::*;
4370    use agent_core::auth::{
4371        AccessToken, BrokerError, ProxyByteStream, ProxyRequest, ProxyResponse,
4372    };
4373    use async_trait::async_trait;
4374    use std::sync::{
4375        atomic::{AtomicUsize, Ordering},
4376        Arc,
4377    };
4378
4379    /// The exact envelope observed on the wire (flat: `type` is the event
4380    /// type, `code` null, `message` top-level, no nested `error` object).
4381    const XAI_CAPACITY_SSE: &str = "data: {\"sequence_number\":0,\"type\":\"error\",\"code\":null,\"message\":\"The model is currently at capacity due to high demand. Please try again in a few minutes, or use a higher service tier for priority processing: https://docs.x.ai/developers/advanced-api-usage/priority-processing\",\"param\":null}\n\n";
4382
4383    const XAI_SUCCESS_SSE: &str = concat!(
4384        "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
4385        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"GROK_OK\"}\n\n",
4386        "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":2}}}\n\n",
4387    );
4388
4389    /// Serves `bodies[n]` for the n-th `proxy_stream` call (the last body
4390    /// repeats once exhausted) and records every request body it saw.
4391    struct ScriptedXaiBroker {
4392        bodies: Vec<&'static str>,
4393        calls: Arc<AtomicUsize>,
4394        seen: Arc<std::sync::Mutex<Vec<ProxyRequest>>>,
4395    }
4396
4397    #[async_trait]
4398    impl crate::auth::CredentialBroker for ScriptedXaiBroker {
4399        async fn access_token(
4400            &self,
4401            _p: agent_core::auth::OAuthProviderId,
4402        ) -> Result<AccessToken, BrokerError> {
4403            Err(BrokerError::Denied(
4404                "xai route must use proxy_stream".into(),
4405            ))
4406        }
4407        async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
4408            Err(BrokerError::Denied("not implemented in stub".into()))
4409        }
4410        async fn proxy_stream(
4411            &self,
4412            request: ProxyRequest,
4413        ) -> Result<ProxyByteStream, BrokerError> {
4414            request.validate()?;
4415            let n = self.calls.fetch_add(1, Ordering::SeqCst);
4416            self.seen.lock().unwrap().push(request);
4417            let body = self.bodies[n.min(self.bodies.len() - 1)];
4418            Ok(Box::pin(futures::stream::iter(vec![Ok(
4419                bytes::Bytes::from(body),
4420            )])))
4421        }
4422        async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
4423            Err(BrokerError::Denied("not implemented in stub".into()))
4424        }
4425        async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
4426            Ok(vec![])
4427        }
4428    }
4429
4430    /// Broker whose `proxy_stream` fails with a typed refresh failure (a
4431    /// Muse re-mint that hit the network, a corrupt store).
4432    struct RefreshFailingBroker(agent_core::auth::RefreshFailureKind);
4433
4434    #[async_trait]
4435    impl crate::auth::CredentialBroker for RefreshFailingBroker {
4436        async fn access_token(
4437            &self,
4438            _p: agent_core::auth::OAuthProviderId,
4439        ) -> Result<AccessToken, BrokerError> {
4440            Err(BrokerError::Denied("stub".into()))
4441        }
4442        async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
4443            Err(BrokerError::Denied("stub".into()))
4444        }
4445        async fn proxy_stream(&self, _r: ProxyRequest) -> Result<ProxyByteStream, BrokerError> {
4446            Err(BrokerError::Refresh(agent_core::auth::RefreshFailure::new(
4447                self.0,
4448                "transport error",
4449            )))
4450        }
4451        async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
4452            Err(BrokerError::Denied("stub".into()))
4453        }
4454        async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
4455            Ok(vec![])
4456        }
4457    }
4458
4459    /// Round-2 m-a: the retryable kind is carried as a type through the real
4460    /// pipeline (redaction, normalization, classification), not recovered
4461    /// from our hint text. Retryable and action-required refresh failures
4462    /// are API-side, never "Config error" and never "sign in again".
4463    #[tokio::test]
4464    async fn broker_refresh_failure_kind_survives_the_stream_pipeline() {
4465        use agent_core::auth::RefreshFailureKind as K;
4466        for (kind, api) in [
4467            (K::Transient, true),
4468            (K::LockBusy, true),
4469            (K::Other, true),
4470            (K::ActionRequired, true),
4471            (K::Credential, false),
4472        ] {
4473            let broker: Arc<dyn crate::auth::CredentialBroker> =
4474                Arc::new(RefreshFailingBroker(kind));
4475            let cfg = ProviderConfig {
4476                base_url: "https://api.meta.ai/v1".to_string(),
4477                model: "muse-x".to_string(),
4478                provider: "muse".to_string(),
4479            };
4480            let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
4481            let err = call_xai_responses_stream_inner(
4482                &cfg,
4483                &broker,
4484                &[],
4485                &None,
4486                &[],
4487                &tx,
4488                None,
4489                agent_core::reasoning::ReasoningLevel::Adaptive,
4490                &tokio_util::sync::CancellationToken::new(),
4491                0,
4492                &crate::runtime::trace::TraceContext::disabled(),
4493                true,
4494            )
4495            .await
4496            .unwrap_err();
4497            let runtime = super::super::net::provider_error_to_runtime_for("muse/muse-x", err);
4498            match (&runtime, api) {
4499                (crate::RuntimeError::ApiStatus(m), true) => {
4500                    assert!(m.contains("credential refresh failed"), "{kind:?}: {m}");
4501                    assert!(!m.contains("Sign in again"), "{kind:?}: {m}");
4502                }
4503                (crate::RuntimeError::Config(_), false) => {}
4504                other => panic!("{kind:?}: {other:?}"),
4505            }
4506        }
4507    }
4508
4509    struct Run {
4510        result: Result<Value, Box<dyn std::error::Error + Send + Sync>>,
4511        calls: usize,
4512        seen: Vec<ProxyRequest>,
4513        events: Vec<StreamEvent>,
4514    }
4515
4516    async fn run_xai(bodies: Vec<&'static str>, max_retries: u32) -> Run {
4517        let calls = Arc::new(AtomicUsize::new(0));
4518        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
4519        let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(ScriptedXaiBroker {
4520            bodies,
4521            calls: Arc::clone(&calls),
4522            seen: Arc::clone(&seen),
4523        });
4524        let cfg = ProviderConfig {
4525            base_url: "https://api.x.ai/v1".to_string(),
4526            model: "grok-4.5".to_string(),
4527            provider: "xai-auth".to_string(),
4528        };
4529        let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
4530        let result = call_xai_responses_stream_inner(
4531            &cfg,
4532            &broker,
4533            &[],
4534            &Some("system".to_string()),
4535            &[],
4536            &tx,
4537            None,
4538            agent_core::reasoning::ReasoningLevel::Adaptive,
4539            &tokio_util::sync::CancellationToken::new(),
4540            max_retries,
4541            &crate::runtime::trace::TraceContext::disabled(),
4542            true,
4543        )
4544        .await;
4545        drop(tx);
4546        let mut events = Vec::new();
4547        while let Ok(event) = rx.try_recv() {
4548            events.push(event);
4549        }
4550        let seen = std::mem::take(&mut *seen.lock().unwrap());
4551        Run {
4552            result,
4553            calls: calls.load(Ordering::SeqCst),
4554            seen,
4555            events,
4556        }
4557    }
4558
4559    fn text_events(events: &[StreamEvent]) -> Vec<String> {
4560        events
4561            .iter()
4562            .filter_map(|e| match e {
4563                StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(t)) => Some(t.clone()),
4564                _ => None,
4565            })
4566            .collect()
4567    }
4568
4569    #[tokio::test(start_paused = true)]
4570    async fn xai_capacity_error_is_retried_with_identical_bytes_then_succeeds() {
4571        let run = run_xai(vec![XAI_CAPACITY_SSE, XAI_SUCCESS_SSE], 3).await;
4572        let value = run.result.expect("second attempt must succeed");
4573        assert_eq!(value["content"][0]["text"], "GROK_OK");
4574        assert_eq!(run.calls, 2, "exactly one retry");
4575        assert_eq!(run.seen.len(), 2);
4576        assert_eq!(
4577            run.seen[0].body_bytes, run.seen[1].body_bytes,
4578            "retry must re-send the identical serialized body"
4579        );
4580        assert_eq!(run.seen[0].body, run.seen[1].body);
4581        assert_eq!(run.seen[0].provider, "xai-auth");
4582        assert_eq!(run.seen[0].path, "/responses");
4583        // No output was streamed twice: the failed attempt emitted nothing.
4584        assert_eq!(text_events(&run.events), vec!["GROK_OK".to_string()]);
4585    }
4586
4587    #[tokio::test(start_paused = true)]
4588    async fn xai_capacity_error_with_zero_retries_surfaces_xai_capacity_message() {
4589        let run = run_xai(vec![XAI_CAPACITY_SSE], 0).await;
4590        let err = run.result.expect_err("no retry budget → terminal");
4591        let msg = err.to_string();
4592        assert_eq!(
4593            msg,
4594            "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
4595        );
4596        assert_eq!(run.calls, 1);
4597        for banned in ["Codex", "high demand", "docs.x.ai", "service tier"] {
4598            assert!(!msg.contains(banned), "`{banned}` leaked: {msg}");
4599        }
4600        // And the runtime classifies it as an API failure, not a config one.
4601        assert!(matches!(
4602            super::super::net::provider_error_to_runtime(err),
4603            crate::RuntimeError::ApiStatus(_)
4604        ));
4605    }
4606
4607    #[tokio::test(start_paused = true)]
4608    async fn xai_capacity_error_exhausting_budget_counts_every_attempt() {
4609        let run = run_xai(vec![XAI_CAPACITY_SSE], 2).await;
4610        let err = run.result.expect_err("persistent capacity → terminal");
4611        assert!(err
4612            .to_string()
4613            .starts_with("xAI reports the model is at capacity"));
4614        assert_eq!(run.calls, 3, "initial attempt + 2 retries");
4615        assert!(text_events(&run.events).is_empty());
4616    }
4617
4618    /// If the failed attempt already streamed text, a re-send would duplicate
4619    /// it in the UI — the failure must stay terminal even with budget left.
4620    #[tokio::test(start_paused = true)]
4621    async fn xai_does_not_retry_after_partial_text_was_streamed() {
4622        const PARTIAL_THEN_ERROR: &str = concat!(
4623            "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
4624            "data: {\"type\":\"error\",\"code\":null,\"message\":\"at capacity\"}\n\n",
4625        );
4626        let run = run_xai(vec![PARTIAL_THEN_ERROR, XAI_SUCCESS_SSE], 3).await;
4627        let err = run.result.expect_err("must not retry after partial output");
4628        assert!(err
4629            .to_string()
4630            .starts_with("xAI reports the model is at capacity"));
4631        assert_eq!(run.calls, 1, "no retry once output reached the UI");
4632        assert_eq!(text_events(&run.events), vec!["partial".to_string()]);
4633    }
4634
4635    /// An unfinished tool preview is not execution; reset it before retrying.
4636    #[tokio::test(start_paused = true)]
4637    async fn xai_retries_eof_after_tool_preview_with_reset() {
4638        const TOOL_THEN_EOF: &str = "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"bash\"}}\n\n";
4639        let run = run_xai(vec![TOOL_THEN_EOF, XAI_SUCCESS_SSE], 3).await;
4640        assert_eq!(run.result.unwrap()["content"][0]["text"], "GROK_OK");
4641        assert_eq!(run.calls, 2);
4642        assert_eq!(run.seen[0].body_bytes, run.seen[1].body_bytes);
4643        assert!(run
4644            .events
4645            .iter()
4646            .any(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset))));
4647    }
4648
4649    #[test]
4650    fn responses_exact_account_errors_survive_decoder_net_and_driver() {
4651        for (code, suffix, expected) in [
4652            ("invalid_api_key", RESPONSES_AUTH_SUFFIX, "auth"),
4653            ("authentication_error", RESPONSES_AUTH_SUFFIX, "auth"),
4654            (
4655                "invalid_authentication_error",
4656                RESPONSES_AUTH_SUFFIX,
4657                "auth",
4658            ),
4659            ("insufficient_quota", RESPONSES_QUOTA_SUFFIX, "quota"),
4660            ("quota_exceeded", RESPONSES_QUOTA_SUFFIX, "quota"),
4661            ("billing_error", RESPONSES_QUOTA_SUFFIX, "quota"),
4662        ] {
4663            for label in ["Codex", "OpenAI", "xAI"] {
4664                let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4665                let mut decoder = CodexSseDecoder::for_provider(label);
4666                let frame = format!(
4667                    "data: {}\n\n",
4668                    json!({"type":"response.failed", "response":{
4669                        "error":{"code":code,"message":"SECRET at capacity"}
4670                    }})
4671                );
4672                let mut text = String::new();
4673                for line in frame.lines() {
4674                    decoder.push_line(line, &tx, &mut text);
4675                }
4676                let failure = decoder.terminal_result().unwrap_err();
4677                assert_eq!(failure.message, format!("{label}{suffix}"));
4678                assert!(!failure.is_retryable());
4679                let runtime = super::super::net::provider_error_to_runtime(failure.message.into());
4680                assert!(matches!(runtime, crate::RuntimeError::ApiStatus(_)));
4681                let typed = crate::runtime::helpers::turn_error_for(&runtime, "test");
4682                assert_eq!(
4683                    crate::extensions::session_driver::classify_turn_error(&typed),
4684                    (
4685                        crate::extensions::session_driver::Outcome::ProviderError,
4686                        expected.into()
4687                    )
4688                );
4689                assert!(!typed.message.contains("SECRET"));
4690            }
4691        }
4692        for code in [
4693            "permission_denied",
4694            "context_length_exceeded",
4695            "invalid_request_error",
4696            "unknown",
4697        ] {
4698            let message = user_message_for_terminal_failure("Codex", "absent", code, false);
4699            assert_eq!(
4700                crate::extensions::session_driver::classify_error(&message).0,
4701                crate::extensions::session_driver::Outcome::Blocked
4702            );
4703        }
4704    }
4705
4706    #[test]
4707    fn explicit_policy_and_conflicting_error_classes_outrank_capacity_prose() {
4708        for (kind, code) in [
4709            ("absent", "permission_denied"),
4710            ("permission_error", "rate_limit_exceeded"),
4711            ("invalid_request_error", "insufficient_quota"),
4712            ("authentication_error", "quota_exceeded"),
4713            ("server_error", "invalid_api_key"),
4714            ("unknown", "absent"),
4715        ] {
4716            let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4717            let mut decoder = CodexSseDecoder::for_provider("Codex");
4718            let frame = format!(
4719                "data: {}",
4720                json!({"type":"response.failed", "response":{
4721                    "error":{"type":kind, "code":code, "message":"private policy at capacity"}
4722                }})
4723            );
4724            let mut text = String::new();
4725            decoder.push_line(&frame, &tx, &mut text);
4726            decoder.push_line("", &tx, &mut text);
4727            let failure = decoder.terminal_result().unwrap_err();
4728            assert!(!failure.is_retryable());
4729            assert_eq!(
4730                crate::extensions::session_driver::classify_error(&failure.message).0,
4731                crate::extensions::session_driver::Outcome::Blocked
4732            );
4733            assert!(!failure.message.contains("private"));
4734        }
4735    }
4736
4737    /// Deterministic (non-capacity) failures are terminal on the first try
4738    /// and keep the neutral, xAI-labelled, details-withheld message.
4739    #[tokio::test(start_paused = true)]
4740    async fn xai_non_capacity_error_is_terminal_and_labelled_xai() {
4741        const OTHER_ERROR: &str = "data: {\"type\":\"error\",\"code\":\"invalid_api_key\",\"message\":\"ECHOED:secret prompt\"}\n\n";
4742        let run = run_xai(vec![OTHER_ERROR, XAI_SUCCESS_SSE], 3).await;
4743        let err = run.result.expect_err("must fail");
4744        let msg = err.to_string();
4745        assert_eq!(msg, format!("xAI{RESPONSES_AUTH_SUFFIX}"));
4746        assert!(!msg.contains("ECHOED"));
4747        assert_eq!(run.calls, 1);
4748    }
4749}
4750
4751// ─── Codex wire body tests ────────────────────────────────────────────────────
4752
4753#[cfg(test)]
4754mod codex_wire_tests {
4755    use crate::runtime::openai::catalog::{
4756        plan_codex_execution, CodexMultiAgentMode, CodexRequestRole,
4757    };
4758    use agent_core::reasoning::ReasoningLevel;
4759
4760    fn plan(
4761        level: ReasoningLevel,
4762        role: CodexRequestRole,
4763    ) -> crate::runtime::openai::catalog::CodexExecutionPlan {
4764        plan_codex_execution("openai-codex/gpt-5.6-sol", level, role, None)
4765            .expect("Sol request plan")
4766    }
4767
4768    #[allow(clippy::too_many_arguments)]
4769    fn body_for_level(
4770        model: &str,
4771        level: ReasoningLevel,
4772        input: serde_json::Value,
4773        instructions: String,
4774        tools: Vec<serde_json::Value>,
4775        temperature: Option<f32>,
4776        max_tokens: Option<u32>,
4777    ) -> serde_json::Value {
4778        let qualified = format!("openai-codex/{model}");
4779        let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
4780            .expect("request plan");
4781        super::build_codex_body(
4782            model,
4783            &plan,
4784            input,
4785            instructions,
4786            tools,
4787            temperature,
4788            max_tokens,
4789            "synaps-test-cache-key",
4790        )
4791    }
4792
4793    /// Validate → emit exact reasoning.effort shape for known levels.
4794    fn codex_effort_for(model_id: &str, level: ReasoningLevel) -> Result<String, String> {
4795        let qualified = format!("openai-codex/{model_id}");
4796        let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
4797            .map_err(|error| error.to_string())?;
4798        plan.wire_effort
4799            .map(|effort| effort.as_str().to_string())
4800            .ok_or_else(|| "reasoning effort omitted".to_string())
4801    }
4802
4803    #[test]
4804    fn sol_lowers_ultra_to_max_for_requests() {
4805        let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Ultra).unwrap();
4806        assert_eq!(effort, "max");
4807    }
4808
4809    #[test]
4810    fn sol_emits_max_exact() {
4811        let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Max).unwrap();
4812        assert_eq!(effort, "max");
4813    }
4814
4815    #[test]
4816    fn sol_emits_xhigh_exact() {
4817        let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::XHigh).unwrap();
4818        assert_eq!(effort, "xhigh");
4819    }
4820
4821    #[test]
4822    fn luna_emits_max_exact() {
4823        let effort = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Max).unwrap();
4824        assert_eq!(effort, "max");
4825    }
4826
4827    #[test]
4828    fn luna_rejects_ultra_before_network() {
4829        // Validation must fail before any credential/network access.
4830        let err = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Ultra).unwrap_err();
4831        assert!(err.contains("ultra"), "{err}");
4832        assert!(err.contains("gpt-5.6-luna"), "{err}");
4833    }
4834
4835    #[test]
4836    fn gpt55_emits_xhigh_exact() {
4837        let effort = codex_effort_for("gpt-5.5", ReasoningLevel::XHigh).unwrap();
4838        assert_eq!(effort, "xhigh");
4839    }
4840
4841    #[test]
4842    fn gpt55_rejects_max_before_network() {
4843        assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Max).is_err());
4844        assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Ultra).is_err());
4845    }
4846
4847    #[test]
4848    fn off_and_adaptive_omit_reasoning_field() {
4849        // Off/Adaptive: build_codex_body must NOT emit a "reasoning" key.
4850        for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
4851            let body = body_for_level(
4852                "gpt-5.6-sol",
4853                level,
4854                serde_json::json!([]),
4855                "sys".to_string(),
4856                vec![],
4857                None,
4858                None,
4859            );
4860            assert!(
4861                body.get("reasoning").is_none(),
4862                "{level:?} must not emit reasoning field, body={body}"
4863            );
4864        }
4865    }
4866
4867    #[test]
4868    fn max_as_str_is_not_xhigh() {
4869        assert_ne!(ReasoningLevel::Max.as_str(), ReasoningLevel::XHigh.as_str());
4870        assert_eq!(ReasoningLevel::Max.as_str(), "max");
4871    }
4872
4873    #[test]
4874    fn ultra_mode_context_is_exact_proactive_developer_item() {
4875        let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
4876        assert_eq!(plan.multi_agent_mode, Some(CodexMultiAgentMode::Proactive));
4877        let item = super::codex_multi_agent_mode_item(&plan).expect("Ultra context item");
4878        assert_eq!(
4879            item.get("type").and_then(serde_json::Value::as_str),
4880            Some("message")
4881        );
4882        assert_eq!(
4883            item.get("role").and_then(serde_json::Value::as_str),
4884            Some("developer")
4885        );
4886        assert_eq!(
4887            item.pointer("/content/0/text")
4888                .and_then(serde_json::Value::as_str),
4889            Some(concat!(
4890                "<multi_agent_mode>",
4891                "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.",
4892                "</multi_agent_mode>"
4893            ))
4894        );
4895    }
4896
4897    #[test]
4898    fn max_and_xhigh_mode_context_is_exact_explicit_only_item() {
4899        for level in [ReasoningLevel::Max, ReasoningLevel::XHigh] {
4900            let plan = plan(level, CodexRequestRole::Foreground);
4901            assert_eq!(
4902                plan.multi_agent_mode,
4903                Some(CodexMultiAgentMode::ExplicitRequestOnly)
4904            );
4905            let item = super::codex_multi_agent_mode_item(&plan).expect("v2 context item");
4906            assert_eq!(
4907                item.pointer("/content/0/text")
4908                    .and_then(serde_json::Value::as_str),
4909                Some(concat!(
4910                    "<multi_agent_mode>",
4911                    "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.",
4912                    "</multi_agent_mode>"
4913                )),
4914                "{level:?}"
4915            );
4916        }
4917    }
4918
4919    #[test]
4920    fn mode_context_is_inserted_once_at_stable_head_position() {
4921        let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
4922        let mut input = vec![
4923            serde_json::json!({"role":"user","content":"first"}),
4924            serde_json::json!({"role":"assistant","content":"answer"}),
4925            serde_json::json!({"role":"user","content":"current"}),
4926        ];
4927        super::insert_codex_multi_agent_mode(&mut input, &plan);
4928        let mode_positions = input
4929            .iter()
4930            .enumerate()
4931            .filter(|(_, item)| {
4932                item.pointer("/content/0/text")
4933                    .and_then(serde_json::Value::as_str)
4934                    .is_some_and(|text| text.starts_with("<multi_agent_mode>"))
4935            })
4936            .map(|(index, _)| index)
4937            .collect::<Vec<_>>();
4938        assert_eq!(mode_positions, vec![0]);
4939        assert_eq!(
4940            input[3].get("content").and_then(serde_json::Value::as_str),
4941            Some("current")
4942        );
4943    }
4944
4945    /// Prefix-cache stability: as the conversation grows across user turns,
4946    /// the already-sent items must remain a byte-identical prefix of the next
4947    /// request's input. The old before-last-user placement moved the mode
4948    /// item every turn, invalidating the cached prefix at the prior user
4949    /// message and re-billing the entire previous agentic turn uncached.
4950    #[test]
4951    fn mode_context_placement_preserves_prompt_prefix_across_turns() {
4952        let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
4953        let turn1 = vec![
4954            serde_json::json!({"role":"user","content":"first"}),
4955            serde_json::json!({"role":"assistant","content":"answer"}),
4956        ];
4957        let mut turn2 = turn1.clone();
4958        turn2.push(serde_json::json!({"role":"user","content":"follow-up"}));
4959        let mut input1 = turn1;
4960        let mut input2 = turn2;
4961        super::insert_codex_multi_agent_mode(&mut input1, &plan);
4962        super::insert_codex_multi_agent_mode(&mut input2, &plan);
4963        assert_eq!(
4964            &input2[..input1.len()],
4965            &input1[..],
4966            "turn N's input must be a strict prefix of turn N+1's input"
4967        );
4968    }
4969
4970    #[test]
4971    fn prompt_cache_key_is_stable_for_the_same_conversation_head() {
4972        let first = serde_json::json!({"role":"user","content":"hello"});
4973        let a = super::codex_prompt_cache_key("sys", Some(&first));
4974        let b = super::codex_prompt_cache_key("sys", Some(&first));
4975        assert_eq!(a, b);
4976        assert!(a.starts_with("synaps-"), "{a}");
4977    }
4978
4979    #[test]
4980    fn prompt_cache_key_differs_across_conversations() {
4981        let first = serde_json::json!({"role":"user","content":"hello"});
4982        let other = serde_json::json!({"role":"user","content":"different task"});
4983        assert_ne!(
4984            super::codex_prompt_cache_key("sys", Some(&first)),
4985            super::codex_prompt_cache_key("sys", Some(&other)),
4986        );
4987        assert_ne!(
4988            super::codex_prompt_cache_key("sys", Some(&first)),
4989            super::codex_prompt_cache_key("other-sys", Some(&first)),
4990        );
4991    }
4992
4993    #[test]
4994    fn body_carries_prompt_cache_key() {
4995        let body = body_for_level(
4996            "gpt-5.6-sol",
4997            ReasoningLevel::Medium,
4998            serde_json::json!([]),
4999            "sys".to_string(),
5000            vec![],
5001            None,
5002            None,
5003        );
5004        assert_eq!(
5005            body.get("prompt_cache_key")
5006                .and_then(serde_json::Value::as_str),
5007            Some("synaps-test-cache-key")
5008        );
5009    }
5010
5011    #[test]
5012    fn worker_ultra_has_no_mode_context_item() {
5013        let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Worker);
5014        assert!(super::codex_multi_agent_mode_item(&plan).is_none());
5015    }
5016
5017    // ── Pure body construction tests (zero network) ──────────────────────────
5018
5019    #[test]
5020    fn body_lowers_ultra_reasoning_effort_to_max() {
5021        let body = body_for_level(
5022            "gpt-5.6-sol",
5023            ReasoningLevel::Ultra,
5024            serde_json::json!([]),
5025            "sys".to_string(),
5026            vec![],
5027            None,
5028            None,
5029        );
5030        let effort = body
5031            .get("reasoning")
5032            .and_then(|r| r.get("effort"))
5033            .and_then(serde_json::Value::as_str)
5034            .expect("reasoning.effort must be present for Ultra");
5035        assert_eq!(effort, "max");
5036    }
5037
5038    #[test]
5039    fn body_contains_reasoning_effort_for_max() {
5040        let body = body_for_level(
5041            "gpt-5.6-sol",
5042            ReasoningLevel::Max,
5043            serde_json::json!([]),
5044            "sys".to_string(),
5045            vec![],
5046            None,
5047            None,
5048        );
5049        let effort = body
5050            .get("reasoning")
5051            .and_then(|r| r.get("effort"))
5052            .and_then(serde_json::Value::as_str)
5053            .expect("reasoning.effort must be present for Max");
5054        assert_eq!(effort, "max");
5055        // max must NOT be xhigh (critical invariant)
5056        assert_ne!(effort, "xhigh");
5057    }
5058
5059    #[test]
5060    fn body_contains_reasoning_effort_for_xhigh() {
5061        let body = body_for_level(
5062            "gpt-5.6-sol",
5063            ReasoningLevel::XHigh,
5064            serde_json::json!([]),
5065            "sys".to_string(),
5066            vec![],
5067            None,
5068            None,
5069        );
5070        let effort = body
5071            .get("reasoning")
5072            .and_then(|r| r.get("effort"))
5073            .and_then(serde_json::Value::as_str)
5074            .expect("reasoning.effort must be present for XHigh");
5075        assert_eq!(effort, "xhigh");
5076    }
5077
5078    #[test]
5079    fn body_contains_reasoning_effort_for_low_medium_high() {
5080        for (level, expected) in [
5081            (ReasoningLevel::Low, "low"),
5082            (ReasoningLevel::Medium, "medium"),
5083            (ReasoningLevel::High, "high"),
5084        ] {
5085            let body = body_for_level(
5086                "gpt-5.6-sol",
5087                level,
5088                serde_json::json!([]),
5089                "sys".to_string(),
5090                vec![],
5091                None,
5092                None,
5093            );
5094            let effort = body
5095                .get("reasoning")
5096                .and_then(|r| r.get("effort"))
5097                .and_then(serde_json::Value::as_str)
5098                .unwrap_or_else(|| panic!("{level:?} must emit reasoning.effort"));
5099            assert_eq!(effort, expected, "{level:?}");
5100        }
5101    }
5102
5103    #[test]
5104    fn body_always_sets_model_and_stream_and_include() {
5105        let body = body_for_level(
5106            "gpt-5.5",
5107            ReasoningLevel::XHigh,
5108            serde_json::json!([]),
5109            "instructions".to_string(),
5110            vec![],
5111            None,
5112            None,
5113        );
5114        assert_eq!(
5115            body.get("model").and_then(serde_json::Value::as_str),
5116            Some("gpt-5.5")
5117        );
5118        assert_eq!(
5119            body.get("stream").and_then(serde_json::Value::as_bool),
5120            Some(true)
5121        );
5122        let includes = body.get("include").expect("include must be present");
5123        assert!(
5124            includes.as_array().is_some_and(|a| {
5125                a.iter()
5126                    .any(|v| v.as_str() == Some("reasoning.encrypted_content"))
5127            }),
5128            "include must contain reasoning.encrypted_content"
5129        );
5130        assert_eq!(
5131            body.get("store").and_then(serde_json::Value::as_bool),
5132            Some(false)
5133        );
5134    }
5135
5136    #[test]
5137    fn body_omits_temperature_and_max_tokens_when_none() {
5138        let body = body_for_level(
5139            "gpt-5.6-sol",
5140            ReasoningLevel::Medium,
5141            serde_json::json!([]),
5142            "sys".to_string(),
5143            vec![],
5144            None,
5145            None,
5146        );
5147        assert!(body.get("temperature").is_none());
5148        assert!(body.get("max_output_tokens").is_none());
5149    }
5150
5151    #[test]
5152    fn body_includes_temperature_and_max_tokens_when_some() {
5153        let body = body_for_level(
5154            "gpt-5.6-sol",
5155            ReasoningLevel::High,
5156            serde_json::json!([]),
5157            "sys".to_string(),
5158            vec![],
5159            Some(0.7),
5160            Some(2048),
5161        );
5162        let temp = body
5163            .get("temperature")
5164            .and_then(serde_json::Value::as_f64)
5165            .unwrap();
5166        assert!((temp - 0.7f64).abs() < 0.01, "temperature={temp}");
5167        assert_eq!(
5168            body.get("max_output_tokens")
5169                .and_then(serde_json::Value::as_u64),
5170            Some(2048)
5171        );
5172    }
5173}
5174
5175#[cfg(test)]
5176mod send_retry_tests {
5177    //! Regression tests for transient-failure retry on the codex send path
5178    //! (incident: session 20260714-025948-3dab — a single transport failure
5179    //! against chatgpt.com aborted a whole autonomous turn because the codex
5180    //! route was single-shot while the Anthropic route retried).
5181    //!
5182    //! Mirrors the `on401_tests` mock-server pattern in `runtime/api.rs`.
5183    //! Credentials cross the broker boundary: a stub broker vends a
5184    //! JWT-shaped token carrying the ChatGPT account-id claim.
5185
5186    use super::*;
5187    use agent_core::auth::{
5188        AccessToken, BrokerError, ProxyByteStream, ProxyRequest, ProxyResponse,
5189    };
5190    use agent_core::reasoning::ReasoningLevel;
5191    use async_trait::async_trait;
5192    use axum::{http::StatusCode, response::IntoResponse, routing::post as axum_post, Router};
5193    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
5194    use std::sync::{
5195        atomic::{AtomicUsize, Ordering},
5196        Arc,
5197    };
5198    use tokio::sync::mpsc;
5199
5200    /// Minimal Responses-API SSE success body: one text delta + completed.
5201    const CODEX_SSE_SUCCESS: &str = concat!(
5202        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n",
5203        "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n",
5204        "data: [DONE]\n\n",
5205    );
5206
5207    /// JWT-shaped token whose payload carries the ChatGPT account-id claim
5208    /// `extract_codex_account_id` looks for. Signature is irrelevant — the
5209    /// mock server never validates it.
5210    fn fake_codex_token() -> String {
5211        let payload = serde_json::json!({
5212            "https://api.openai.com/auth": { "chatgpt_account_id": "acct_test" }
5213        });
5214        format!("h.{}.s", URL_SAFE_NO_PAD.encode(payload.to_string()))
5215    }
5216
5217    /// Stub broker that vends the fake codex token. Everything else is
5218    /// unreachable in these tests and fails closed.
5219    struct TokenOnlyBroker;
5220
5221    #[async_trait]
5222    impl crate::auth::CredentialBroker for TokenOnlyBroker {
5223        async fn access_token(
5224            &self,
5225            _p: agent_core::auth::OAuthProviderId,
5226        ) -> Result<AccessToken, BrokerError> {
5227            Ok(AccessToken {
5228                token: fake_codex_token(),
5229                expires: u64::MAX,
5230            })
5231        }
5232        async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
5233            Err(BrokerError::Denied("not implemented in stub".into()))
5234        }
5235        async fn proxy_stream(
5236            &self,
5237            _request: ProxyRequest,
5238        ) -> Result<ProxyByteStream, BrokerError> {
5239            Err(BrokerError::Denied("not implemented in stub".into()))
5240        }
5241        async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
5242            Err(BrokerError::Denied("not implemented in stub".into()))
5243        }
5244        async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
5245            Ok(vec![])
5246        }
5247    }
5248
5249    struct DenyingCountingBroker {
5250        access_calls: Arc<AtomicUsize>,
5251    }
5252
5253    #[async_trait]
5254    impl crate::auth::CredentialBroker for DenyingCountingBroker {
5255        async fn access_token(
5256            &self,
5257            _provider: agent_core::auth::OAuthProviderId,
5258        ) -> Result<AccessToken, BrokerError> {
5259            self.access_calls.fetch_add(1, Ordering::SeqCst);
5260            Err(BrokerError::Denied(
5261                "credential access must not occur".into(),
5262            ))
5263        }
5264
5265        async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
5266            Err(BrokerError::Denied("not implemented in stub".into()))
5267        }
5268
5269        async fn proxy_stream(
5270            &self,
5271            _request: ProxyRequest,
5272        ) -> Result<ProxyByteStream, BrokerError> {
5273            Err(BrokerError::Denied("not implemented in stub".into()))
5274        }
5275
5276        async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
5277            Err(BrokerError::Denied("not implemented in stub".into()))
5278        }
5279
5280        async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
5281            Ok(vec![])
5282        }
5283    }
5284
5285    /// Spawn a mock Codex endpoint. First `fail_count` POSTs → `fail_status`;
5286    /// subsequent → SSE success. Returns (base_url, call_counter).
5287    async fn spawn_mock_codex(
5288        fail_count: usize,
5289        fail_status: StatusCode,
5290    ) -> (String, Arc<AtomicUsize>) {
5291        let counter = Arc::new(AtomicUsize::new(0));
5292        let counter_clone = Arc::clone(&counter);
5293        let app = Router::new().route(
5294            "/codex/responses",
5295            axum_post(move || {
5296                let counter = Arc::clone(&counter_clone);
5297                async move {
5298                    let n = counter.fetch_add(1, Ordering::SeqCst);
5299                    if n < fail_count {
5300                        (
5301                            fail_status,
5302                            [("content-type", "application/json")],
5303                            "{\"error\":{\"message\":\"transient upstream sadness\"}}".to_string(),
5304                        )
5305                            .into_response()
5306                    } else {
5307                        (
5308                            StatusCode::OK,
5309                            [("content-type", "text/event-stream")],
5310                            CODEX_SSE_SUCCESS.to_string(),
5311                        )
5312                            .into_response()
5313                    }
5314                }
5315            }),
5316        );
5317        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5318        let addr = listener.local_addr().unwrap();
5319        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
5320        (format!("http://{addr}"), counter)
5321    }
5322
5323    async fn spawn_codex_sse(body: &'static str) -> String {
5324        let app = Router::new().route(
5325            "/codex/responses",
5326            axum_post(move || async move {
5327                (
5328                    StatusCode::OK,
5329                    [("content-type", "text/event-stream")],
5330                    body,
5331                )
5332            }),
5333        );
5334        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5335        let addr = listener.local_addr().unwrap();
5336        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
5337        format!("http://{addr}")
5338    }
5339
5340    async fn spawn_empty_then_success_codex() -> (
5341        String,
5342        Arc<AtomicUsize>,
5343        Arc<std::sync::Mutex<Vec<bytes::Bytes>>>,
5344    ) {
5345        let counter = Arc::new(AtomicUsize::new(0));
5346        let bodies = Arc::new(std::sync::Mutex::new(Vec::new()));
5347        let app = Router::new().route(
5348            "/codex/responses",
5349            axum_post({
5350                let counter = Arc::clone(&counter);
5351                let bodies = Arc::clone(&bodies);
5352                move |body: bytes::Bytes| {
5353                    let counter = Arc::clone(&counter);
5354                    let bodies = Arc::clone(&bodies);
5355                    async move {
5356                        bodies.lock().unwrap().push(body);
5357                        let n = counter.fetch_add(1, Ordering::SeqCst);
5358                        let response = if n == 0 {
5359                            concat!(
5360                                "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":0}}}\n\n",
5361                                "data: [DONE]\n\n",
5362                            )
5363                        } else {
5364                            CODEX_SSE_SUCCESS
5365                        };
5366                        (
5367                            StatusCode::OK,
5368                            [("content-type", "text/event-stream")],
5369                            response,
5370                        )
5371                    }
5372                }
5373            }),
5374        );
5375        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5376        let addr = listener.local_addr().unwrap();
5377        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
5378        (format!("http://{addr}"), counter, bodies)
5379    }
5380
5381    async fn run_codex(
5382        base_url: &str,
5383        max_retries: u32,
5384    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
5385        let cfg = ProviderConfig {
5386            base_url: base_url.to_string(),
5387            // The incident model — validated against the static catalog ladder.
5388            model: "gpt-5.6-sol".to_string(),
5389            provider: "openai-codex".to_string(),
5390        };
5391        let client = reqwest::Client::new();
5392        let broker: std::sync::Arc<dyn crate::auth::CredentialBroker> =
5393            std::sync::Arc::new(TokenOnlyBroker);
5394        let (tx, _rx) = mpsc::unbounded_channel();
5395        call_codex_stream_inner(
5396            &cfg,
5397            &client,
5398            &broker,
5399            &[],
5400            &Some("test".to_string()),
5401            &[],
5402            &tx,
5403            None,
5404            None,
5405            agent_core::reasoning::ReasoningLevel::Medium,
5406            crate::runtime::openai::catalog::CodexRequestRole::Foreground,
5407            &tokio_util::sync::CancellationToken::new(),
5408            max_retries,
5409            &crate::runtime::trace::TraceContext::disabled(),
5410        )
5411        .await
5412    }
5413
5414    // Raw HTTP is intentional: axum's complete bodies cannot reproduce the
5415    // reported EOF in an HTTP/1.1 chunk-size line.
5416    async fn broken_chunk_server(
5417        scripts: Vec<(&'static str, bool)>,
5418    ) -> (
5419        String,
5420        Arc<std::sync::Mutex<Vec<Vec<u8>>>>,
5421        tokio::task::JoinHandle<()>,
5422    ) {
5423        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5424        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5425        let address = listener.local_addr().unwrap();
5426        let requests = Arc::new(std::sync::Mutex::new(Vec::new()));
5427        let seen = requests.clone();
5428        let task = tokio::spawn(async move {
5429            let mut attempt = 0;
5430            loop {
5431                let (mut socket, _) = listener.accept().await.unwrap();
5432                let mut request = Vec::new();
5433                let end = loop {
5434                    let mut chunk = [0; 8192];
5435                    let n = socket.read(&mut chunk).await.unwrap();
5436                    if n == 0 {
5437                        return;
5438                    }
5439                    request.extend_from_slice(&chunk[..n]);
5440                    if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") {
5441                        break end + 4;
5442                    }
5443                };
5444                let length: usize = String::from_utf8_lossy(&request[..end])
5445                    .lines()
5446                    .find_map(|line| {
5447                        line.to_ascii_lowercase()
5448                            .strip_prefix("content-length: ")
5449                            .map(str::to_owned)
5450                    })
5451                    .unwrap()
5452                    .parse()
5453                    .unwrap();
5454                while request.len() < end + length {
5455                    let mut chunk = [0; 8192];
5456                    let n = socket.read(&mut chunk).await.unwrap();
5457                    if n == 0 {
5458                        return;
5459                    }
5460                    request.extend_from_slice(&chunk[..n]);
5461                }
5462                seen.lock()
5463                    .unwrap()
5464                    .push(request[end..end + length].to_vec());
5465                let (body, broken) = scripts[attempt.min(scripts.len() - 1)];
5466                attempt += 1;
5467                socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await.unwrap();
5468                socket
5469                    .write_all(format!("{:x}\r\n{}\r\n", body.len(), body).as_bytes())
5470                    .await
5471                    .unwrap();
5472                // Omitting the 0 chunk produces precisely the user's failure.
5473                if !broken {
5474                    socket.write_all(b"0\r\n\r\n").await.unwrap();
5475                }
5476                socket.shutdown().await.unwrap();
5477            }
5478        });
5479        (format!("http://{address}"), requests, task)
5480    }
5481
5482    async fn codex_with_events(
5483        base: &str,
5484        retries: u32,
5485        cancel: &tokio_util::sync::CancellationToken,
5486        tx: &mpsc::UnboundedSender<StreamEvent>,
5487    ) -> Result<Value, super::super::net::BoxedProviderError> {
5488        let cfg = ProviderConfig {
5489            base_url: base.into(),
5490            model: "gpt-5.6-sol".into(),
5491            provider: "openai-codex".into(),
5492        };
5493        let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(TokenOnlyBroker);
5494        call_codex_stream_inner(
5495            &cfg,
5496            &reqwest::Client::new(),
5497            &broker,
5498            &[],
5499            &Some("test".into()),
5500            &[],
5501            tx,
5502            None,
5503            None,
5504            agent_core::reasoning::ReasoningLevel::Medium,
5505            crate::runtime::openai::catalog::CodexRequestRole::Foreground,
5506            cancel,
5507            retries,
5508            &crate::runtime::trace::TraceContext::disabled(),
5509        )
5510        .await
5511    }
5512
5513    const PARTIAL_TOOL: &str = concat!(
5514        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"discard me\"}\n\n",
5515        "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"old_call\",\"name\":\"write\",\"arguments\":\"\"}}\n\n",
5516        "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"{\\\"path\\\":\"}\n\n",
5517    );
5518    const COMPLETE_TOOL: &str = concat!(
5519        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"accepted\"}\n\n",
5520        "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"new_call\",\"name\":\"write\",\"arguments\":\"{\\\"path\\\":\\\"ok\\\"}\"}}\n\n",
5521        "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}}\n\n",
5522    );
5523
5524    #[tokio::test]
5525    async fn interrupted_partial_tool_retries_identical_bytes_and_commits_only_success() {
5526        use crate::LlmEvent;
5527        let (url, requests, server) =
5528            broken_chunk_server(vec![(PARTIAL_TOOL, true), (COMPLETE_TOOL, false)]).await;
5529        let (tx, mut rx) = mpsc::unbounded_channel();
5530        let result = codex_with_events(&url, 1, &tokio_util::sync::CancellationToken::new(), &tx)
5531            .await
5532            .unwrap();
5533        server.abort();
5534        let requests = requests.lock().unwrap();
5535        assert_eq!(requests.len(), 2);
5536        assert_eq!(requests[0], requests[1]);
5537        let tools: Vec<_> = result["content"]
5538            .as_array()
5539            .unwrap()
5540            .iter()
5541            .filter(|v| v["type"] == "tool_use")
5542            .collect();
5543        assert_eq!(tools.len(), 1);
5544        assert_eq!(tools[0]["id"], "new_call");
5545        assert_eq!(result["content"][0]["text"], "accepted");
5546        let mut preview = String::from("prior completed round");
5547        let mut baseline = 0;
5548        let mut resets = 0;
5549        let mut input_tokens = 0;
5550        while let Ok(event) = rx.try_recv() {
5551            match event {
5552                StreamEvent::Llm(LlmEvent::ResponseStart) => baseline = preview.len(),
5553                StreamEvent::Llm(LlmEvent::ResponseReset) => {
5554                    preview.truncate(baseline);
5555                    resets += 1;
5556                }
5557                StreamEvent::Llm(LlmEvent::Text(t)) => preview.push_str(&t),
5558                StreamEvent::Session(crate::SessionEvent::Usage {
5559                    input_tokens: n, ..
5560                }) => input_tokens += n,
5561                _ => {}
5562            }
5563        }
5564        assert_eq!(resets, 1);
5565        assert_eq!(preview, "prior completed roundaccepted");
5566        assert_eq!(input_tokens, 3);
5567    }
5568
5569    #[tokio::test]
5570    async fn completed_response_followed_by_chunk_error_is_not_replayed() {
5571        let (url, requests, server) = broken_chunk_server(vec![(CODEX_SSE_SUCCESS, true)]).await;
5572        assert!(run_codex(&url, 2).await.is_ok());
5573        server.abort();
5574        assert_eq!(requests.lock().unwrap().len(), 1);
5575    }
5576
5577    #[tokio::test]
5578    async fn clean_truncated_eof_retries_but_never_accepts_partial_tools() {
5579        let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, false)]).await;
5580        assert!(run_codex(&url, 1).await.is_err());
5581        server.abort();
5582        assert_eq!(requests.lock().unwrap().len(), 2);
5583    }
5584
5585    #[tokio::test]
5586    async fn broken_chunk_retry_budget_is_finite() {
5587        let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, true)]).await;
5588        assert!(run_codex(&url, 1).await.is_err());
5589        server.abort();
5590        assert_eq!(requests.lock().unwrap().len(), 2);
5591    }
5592
5593    #[tokio::test]
5594    async fn cancellation_during_stream_retry_prevents_second_dispatch() {
5595        let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, true)]).await;
5596        let (tx, mut rx) = mpsc::unbounded_channel();
5597        let cancel = tokio_util::sync::CancellationToken::new();
5598        let waiter = cancel.clone();
5599        let task = tokio::spawn(async move {
5600            while let Some(event) = rx.recv().await {
5601                if matches!(event, StreamEvent::Session(crate::SessionEvent::Retry(_))) {
5602                    waiter.cancel();
5603                    break;
5604                }
5605            }
5606        });
5607        let error = codex_with_events(&url, 10, &cancel, &tx).await.unwrap_err();
5608        task.await.unwrap();
5609        server.abort();
5610        assert_eq!(error.to_string(), "request canceled");
5611        assert_eq!(requests.lock().unwrap().len(), 1);
5612    }
5613
5614    #[tokio::test]
5615    async fn direct_ultra_tool_guard_precedes_broker_access() {
5616        let access_calls = Arc::new(AtomicUsize::new(0));
5617        let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
5618            access_calls: Arc::clone(&access_calls),
5619        });
5620        let cfg = ProviderConfig {
5621            base_url: "http://127.0.0.1:1".to_string(),
5622            model: "gpt-5.6-sol".to_string(),
5623            provider: "openai-codex".to_string(),
5624        };
5625        let (tx, _rx) = mpsc::unbounded_channel();
5626
5627        let error = call_codex_stream_inner(
5628            &cfg,
5629            &reqwest::Client::new(),
5630            &broker,
5631            &[],
5632            &Some("test".to_string()),
5633            &[],
5634            &tx,
5635            None,
5636            None,
5637            ReasoningLevel::Ultra,
5638            crate::runtime::openai::catalog::CodexRequestRole::Foreground,
5639            &tokio_util::sync::CancellationToken::new(),
5640            0,
5641            &crate::runtime::trace::TraceContext::disabled(),
5642        )
5643        .await
5644        .expect_err("foreground Ultra without delegation tools must fail closed");
5645
5646        assert!(error.to_string().contains("subagent_start"), "{error}");
5647        assert_eq!(
5648            access_calls.load(Ordering::SeqCst),
5649            0,
5650            "preflight denial must happen before broker credential access"
5651        );
5652    }
5653
5654    #[tokio::test]
5655    async fn direct_codex_path_rejects_provider_identity_before_broker_access() {
5656        let access_calls = Arc::new(AtomicUsize::new(0));
5657        let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
5658            access_calls: Arc::clone(&access_calls),
5659        });
5660        let cfg = ProviderConfig {
5661            base_url: "http://127.0.0.1:1".to_string(),
5662            model: "gpt-5.6-sol".to_string(),
5663            provider: "openrouter".to_string(),
5664        };
5665        let (tx, _rx) = mpsc::unbounded_channel();
5666
5667        let error = call_codex_stream_inner(
5668            &cfg,
5669            &reqwest::Client::new(),
5670            &broker,
5671            &[],
5672            &Some("test".to_string()),
5673            &[],
5674            &tx,
5675            None,
5676            None,
5677            ReasoningLevel::Medium,
5678            crate::runtime::openai::catalog::CodexRequestRole::Foreground,
5679            &tokio_util::sync::CancellationToken::new(),
5680            0,
5681            &crate::runtime::trace::TraceContext::disabled(),
5682        )
5683        .await
5684        .expect_err("the Codex seam must validate provider-qualified identity");
5685
5686        assert!(error.to_string().contains("openrouter"), "{error}");
5687        assert_eq!(
5688            access_calls.load(Ordering::SeqCst),
5689            0,
5690            "provider identity denial must precede broker credential access"
5691        );
5692    }
5693
5694    /// A stream whose terminal event is `response.failed` must surface a
5695    /// typed API failure at the transport boundary — never `Ok` with empty
5696    /// content (the "model returned an empty response" misdiagnosis,
5697    /// incident: sessions 20260720-022603-6c2a / turn-2629074-0). The
5698    /// provider-controlled error body must not leak into the message.
5699    #[tokio::test]
5700    async fn codex_response_failed_event_is_a_terminal_error_not_empty_success() {
5701        let base_url = spawn_codex_sse(concat!(
5702            "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:secret prompt\"}}}\n\n",
5703            "data: [DONE]\n\n",
5704        ))
5705        .await;
5706        let err = run_codex(&base_url, 0)
5707            .await
5708            .expect_err("response.failed must fail the request");
5709        let msg = err.to_string();
5710        assert!(
5711            msg == format!("Codex{RESPONSES_CAPACITY_SUFFIX}"),
5712            "static transient failure message expected: {msg}"
5713        );
5714        assert!(
5715            !msg.contains("ECHOED") && !msg.contains("secret prompt"),
5716            "provider error body must never surface: {msg}"
5717        );
5718    }
5719
5720    /// `response.incomplete` is a terminal non-success: surfacing it as an
5721    /// empty `Ok` poisoned the turn with the generic empty-response error.
5722    #[tokio::test]
5723    async fn codex_response_incomplete_event_is_a_terminal_error() {
5724        let base_url = spawn_codex_sse(concat!(
5725            "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
5726            "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
5727            "data: [DONE]\n\n",
5728        ))
5729        .await;
5730        let err = run_codex(&base_url, 0)
5731            .await
5732            .expect_err("response.incomplete must fail the request");
5733        assert!(
5734            err.to_string()
5735                .starts_with("Codex response was incomplete."),
5736            "got: {err}"
5737        );
5738    }
5739
5740    /// EOF without any terminal Responses event (mid-stream connection drop
5741    /// after HTTP 200) must fail closed instead of returning empty content.
5742    #[tokio::test]
5743    async fn codex_eof_without_terminal_event_fails_closed() {
5744        let base_url = spawn_codex_sse(
5745            "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
5746        )
5747        .await;
5748        let err = run_codex(&base_url, 0)
5749            .await
5750            .expect_err("EOF without terminal event must fail");
5751        assert!(
5752            err.to_string()
5753                .starts_with("Codex response stream ended without a terminal event."),
5754            "got: {err}"
5755        );
5756    }
5757
5758    #[tokio::test]
5759    async fn codex_completed_without_output_retries_identical_bytes_then_succeeds() {
5760        let (base_url, counter, bodies) = spawn_empty_then_success_codex().await;
5761        let result = run_codex(&base_url, 1)
5762            .await
5763            .expect("one empty-success stream with retries available must not abort the turn");
5764
5765        assert_eq!(counter.load(Ordering::SeqCst), 2, "expected one retry");
5766        let received = bodies.lock().unwrap();
5767        assert_eq!(received.len(), 2, "both attempts must reach the endpoint");
5768        assert_eq!(
5769            received[0], received[1],
5770            "the logical request must resend byte-identical content"
5771        );
5772        assert_eq!(result["content"][0]["text"], "hello");
5773    }
5774
5775    /// Mock Codex endpoint that records every request's (session-id,
5776    /// thread-id, prompt_cache_key). The first `fail_count` POSTs fail 500.
5777    async fn spawn_header_recording_codex(
5778        fail_count: usize,
5779    ) -> (String, Arc<std::sync::Mutex<Vec<(String, String, String)>>>) {
5780        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
5781        let counter = Arc::new(AtomicUsize::new(0));
5782        let app = Router::new().route(
5783            "/codex/responses",
5784            axum_post({
5785                let seen = Arc::clone(&seen);
5786                move |headers: axum::http::HeaderMap, body: bytes::Bytes| {
5787                    let seen = Arc::clone(&seen);
5788                    let counter = Arc::clone(&counter);
5789                    async move {
5790                        let get = |name: &str| {
5791                            headers
5792                                .get(name)
5793                                .and_then(|v| v.to_str().ok())
5794                                .unwrap_or("")
5795                                .to_string()
5796                        };
5797                        let key = serde_json::from_slice::<Value>(&body)
5798                            .ok()
5799                            .and_then(|b| b["prompt_cache_key"].as_str().map(str::to_string))
5800                            .unwrap_or_default();
5801                        seen.lock()
5802                            .unwrap()
5803                            .push((get("session-id"), get("thread-id"), key));
5804                        if counter.fetch_add(1, Ordering::SeqCst) < fail_count {
5805                            (StatusCode::INTERNAL_SERVER_ERROR, "{}".to_string()).into_response()
5806                        } else {
5807                            (
5808                                StatusCode::OK,
5809                                [("content-type", "text/event-stream")],
5810                                CODEX_SSE_SUCCESS.to_string(),
5811                            )
5812                                .into_response()
5813                        }
5814                    }
5815                }
5816            }),
5817        );
5818        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5819        let addr = listener.local_addr().unwrap();
5820        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
5821        (format!("http://{addr}"), seen)
5822    }
5823
5824    /// The ChatGPT backend derives prompt-cache affinity from the `session-id`
5825    /// header (Codex CLI `responses_session_id`); without it every request
5826    /// reported `cached_tokens: 0`. Measured A/B: no header 0/0/0 cached;
5827    /// with `session-id`/`thread-id` = prompt_cache_key, 0 then 95%, 95%.
5828    #[tokio::test]
5829    async fn codex_requests_carry_session_and_thread_id_equal_to_the_cache_key() {
5830        let (base_url, seen) = spawn_header_recording_codex(1).await;
5831        run_codex(&base_url, 2)
5832            .await
5833            .expect("one transient 500 then success");
5834        let seen = seen.lock().unwrap();
5835        assert_eq!(seen.len(), 2, "first attempt + one retry");
5836        for (session_id, thread_id, key) in seen.iter() {
5837            assert!(
5838                key.starts_with("synaps-"),
5839                "prompt_cache_key in body: {key:?}"
5840            );
5841            assert_eq!(session_id, key, "session-id header = prompt_cache_key");
5842            assert_eq!(thread_id, key, "thread-id header = prompt_cache_key");
5843        }
5844        assert_eq!(seen[0], seen[1], "a retry keeps the same cache routing");
5845    }
5846
5847    #[tokio::test]
5848    async fn codex_retries_transient_500_then_succeeds() {
5849        let (base_url, counter) = spawn_mock_codex(1, StatusCode::INTERNAL_SERVER_ERROR).await;
5850        let result = run_codex(&base_url, 2)
5851            .await
5852            .expect("one transient 500 with retries available must not abort the turn");
5853        assert_eq!(
5854            counter.load(Ordering::SeqCst),
5855            2,
5856            "expected exactly one retry"
5857        );
5858        let text = result["content"][0]["text"].as_str().unwrap_or_default();
5859        assert_eq!(text, "hello");
5860    }
5861
5862    #[tokio::test]
5863    async fn codex_response_failed_surfaces_typed_provider_error_not_empty_response() {
5864        const BODY: &str = concat!(
5865            "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:private prompt\"}}}\n\n",
5866            "data: [DONE]\n\n",
5867        );
5868        let base_url = spawn_codex_sse(BODY).await;
5869        let err = run_codex(&base_url, 0).await.expect_err("must fail");
5870        let msg = err.to_string();
5871        assert_eq!(msg, format!("Codex{RESPONSES_CAPACITY_SUFFIX}"));
5872        assert!(!msg.contains("empty response"), "{msg}");
5873        assert!(!msg.contains("ECHOED"), "provider text leaked: {msg}");
5874        assert!(
5875            !msg.contains("private prompt"),
5876            "provider text leaked: {msg}"
5877        );
5878    }
5879
5880    #[tokio::test]
5881    async fn codex_response_incomplete_surfaces_typed_provider_error_not_empty_response() {
5882        const BODY: &str = concat!(
5883            "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
5884            "data: [DONE]\n\n",
5885        );
5886        let base_url = spawn_codex_sse(BODY).await;
5887        let err = run_codex(&base_url, 0).await.expect_err("must fail");
5888        let msg = err.to_string();
5889        assert!(msg.starts_with("Codex response was incomplete."), "{msg}");
5890        assert!(!msg.contains("empty response"), "{msg}");
5891        assert!(
5892            !msg.contains("max_output_tokens"),
5893            "provider text leaked: {msg}"
5894        );
5895    }
5896
5897    #[tokio::test]
5898    async fn codex_clean_eof_without_terminal_fails_closed() {
5899        let base_url = spawn_codex_sse(
5900            "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\"}}\n\n",
5901        )
5902        .await;
5903        let err = run_codex(&base_url, 0).await.expect_err("must fail");
5904        assert!(
5905            err.to_string()
5906                .starts_with("Codex response stream ended without a terminal event."),
5907            "{err}"
5908        );
5909    }
5910
5911    #[tokio::test]
5912    async fn codex_retries_429_then_succeeds() {
5913        let (base_url, counter) = spawn_mock_codex(1, StatusCode::TOO_MANY_REQUESTS).await;
5914        run_codex(&base_url, 2)
5915            .await
5916            .expect("429 is transient — must retry");
5917        assert_eq!(counter.load(Ordering::SeqCst), 2);
5918    }
5919
5920    #[tokio::test]
5921    async fn codex_zero_retries_fails_fast_with_status_error() {
5922        let (base_url, counter) =
5923            spawn_mock_codex(usize::MAX, StatusCode::INTERNAL_SERVER_ERROR).await;
5924        let err = run_codex(&base_url, 0).await.expect_err("must fail");
5925        assert_eq!(counter.load(Ordering::SeqCst), 1, "no retries budgeted");
5926        let msg = err.to_string();
5927        assert!(
5928            msg.starts_with("codex request failed: 500"),
5929            "status must survive for classification: {msg}"
5930        );
5931    }
5932
5933    #[tokio::test]
5934    async fn codex_does_not_retry_client_errors() {
5935        let (base_url, counter) = spawn_mock_codex(usize::MAX, StatusCode::BAD_REQUEST).await;
5936        let err = run_codex(&base_url, 3).await.expect_err("must fail");
5937        assert_eq!(
5938            counter.load(Ordering::SeqCst),
5939            1,
5940            "400 is deterministic — retrying it never helps"
5941        );
5942        assert!(err.to_string().starts_with("codex request failed: 400"));
5943    }
5944
5945    #[tokio::test]
5946    async fn codex_exhausted_retries_reports_last_status() {
5947        let (base_url, counter) =
5948            spawn_mock_codex(usize::MAX, StatusCode::SERVICE_UNAVAILABLE).await;
5949        let err = run_codex(&base_url, 1).await.expect_err("must fail");
5950        assert_eq!(
5951            counter.load(Ordering::SeqCst),
5952            2,
5953            "initial attempt + 1 retry"
5954        );
5955        assert!(err.to_string().starts_with("codex request failed: 503"));
5956    }
5957
5958    // ─── G5: account-aware selection + bounded failover ──────────────────────
5959
5960    use crate::runtime::openai::account_routing::{
5961        CodexAccountRouter, CodexQuotaEvidence, PinnedAccount,
5962    };
5963    use agent_core::auth::{Account, CredentialRef, OAuthProviderId};
5964
5965    /// JWT-shaped token carrying `account_id` as the ChatGPT account claim.
5966    fn fake_codex_token_for(account_id: &str) -> String {
5967        let payload = serde_json::json!({
5968            "https://api.openai.com/auth": { "chatgpt_account_id": account_id }
5969        });
5970        format!("h.{}.s", URL_SAFE_NO_PAD.encode(payload.to_string()))
5971    }
5972
5973    fn pinned(label: &str, account_id: &str, auto: bool) -> PinnedAccount {
5974        PinnedAccount {
5975            credential: CredentialRef::new(
5976                OAuthProviderId::OpenAiCodex,
5977                Account::parse(label).unwrap(),
5978            ),
5979            token: fake_codex_token_for(account_id),
5980            auto,
5981        }
5982    }
5983
5984    /// Scripted router: `pin` returns `first`; `failover` returns the next
5985    /// scripted candidate (or `None`). Records every call.
5986    struct ScriptedRouter {
5987        first: PinnedAccount,
5988        candidates: std::sync::Mutex<std::collections::VecDeque<Option<PinnedAccount>>>,
5989        failover_calls: AtomicUsize,
5990        reported: std::sync::Mutex<Vec<(String, CodexQuotaEvidence)>>,
5991    }
5992
5993    impl ScriptedRouter {
5994        fn new(first: PinnedAccount, candidates: Vec<Option<PinnedAccount>>) -> Self {
5995            Self {
5996                first,
5997                candidates: std::sync::Mutex::new(candidates.into()),
5998                failover_calls: AtomicUsize::new(0),
5999                reported: std::sync::Mutex::new(Vec::new()),
6000            }
6001        }
6002        fn reports(&self) -> Vec<String> {
6003            self.reported
6004                .lock()
6005                .unwrap()
6006                .iter()
6007                .map(|(k, _)| k.clone())
6008                .collect()
6009        }
6010    }
6011
6012    #[async_trait]
6013    impl CodexAccountRouter for ScriptedRouter {
6014        async fn pin(&self, _model: &str) -> Result<PinnedAccount, String> {
6015            Ok(self.first.clone())
6016        }
6017        async fn failover(
6018            &self,
6019            _model: &str,
6020            _failed: &PinnedAccount,
6021            _evidence: &CodexQuotaEvidence,
6022            _failovers_so_far: u32,
6023        ) -> Result<Option<PinnedAccount>, String> {
6024            self.failover_calls.fetch_add(1, Ordering::SeqCst);
6025            Ok(self.candidates.lock().unwrap().pop_front().flatten())
6026        }
6027        async fn report_exhausted(&self, failed: &PinnedAccount, evidence: &CodexQuotaEvidence) {
6028            self.reported
6029                .lock()
6030                .unwrap()
6031                .push((failed.credential.storage_key(), *evidence));
6032        }
6033    }
6034
6035    /// One observed request: `chatgpt-account-id` header, bearer token, body.
6036    #[derive(Clone, Debug)]
6037    struct Seen {
6038        account: String,
6039        bearer: String,
6040        body: bytes::Bytes,
6041    }
6042
6043    /// Mock Codex endpoint scripted per request: each entry is
6044    /// `(status, headers, body)`; the last entry repeats. Records the
6045    /// `chatgpt-account-id` header, the bearer and the body of every request.
6046    type Script = Vec<(StatusCode, Vec<(&'static str, &'static str)>, &'static str)>;
6047    async fn spawn_scripted_codex(script: Script) -> (String, Arc<std::sync::Mutex<Vec<Seen>>>) {
6048        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
6049        let counter = Arc::new(AtomicUsize::new(0));
6050        let script = Arc::new(script);
6051        let app = Router::new().route(
6052            "/codex/responses",
6053            axum_post({
6054                let seen = Arc::clone(&seen);
6055                let counter = Arc::clone(&counter);
6056                let script = Arc::clone(&script);
6057                move |headers: axum::http::HeaderMap, body: bytes::Bytes| {
6058                    let seen = Arc::clone(&seen);
6059                    let counter = Arc::clone(&counter);
6060                    let script = Arc::clone(&script);
6061                    async move {
6062                        let header = |name: &str| {
6063                            headers
6064                                .get(name)
6065                                .and_then(|v| v.to_str().ok())
6066                                .unwrap_or("")
6067                                .to_string()
6068                        };
6069                        let bearer = header("authorization")
6070                            .strip_prefix("Bearer ")
6071                            .unwrap_or("")
6072                            .to_string();
6073                        seen.lock().unwrap().push(Seen {
6074                            account: header("chatgpt-account-id"),
6075                            bearer,
6076                            body,
6077                        });
6078                        let n = counter.fetch_add(1, Ordering::SeqCst);
6079                        let (status, hdrs, body) = &script[n.min(script.len() - 1)];
6080                        let mut resp = axum::response::Response::builder().status(*status);
6081                        for (k, v) in hdrs {
6082                            resp = resp.header(*k, *v);
6083                        }
6084                        let ct = if status.is_success() {
6085                            "text/event-stream"
6086                        } else {
6087                            "application/json"
6088                        };
6089                        resp.header("content-type", ct)
6090                            .body(axum::body::Body::from(*body))
6091                            .unwrap()
6092                    }
6093                }
6094            }),
6095        );
6096        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6097        let addr = listener.local_addr().unwrap();
6098        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
6099        (format!("http://{addr}"), seen)
6100    }
6101
6102    async fn run_codex_with_router(
6103        base_url: &str,
6104        router: &dyn CodexAccountRouter,
6105        max_retries: u32,
6106        tx: &mpsc::UnboundedSender<StreamEvent>,
6107    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
6108        let cfg = ProviderConfig {
6109            base_url: base_url.to_string(),
6110            model: "gpt-5.6-sol".to_string(),
6111            provider: "openai-codex".to_string(),
6112        };
6113        call_codex_stream_with_router(
6114            &cfg,
6115            &reqwest::Client::new(),
6116            router,
6117            &[],
6118            &Some("test".to_string()),
6119            &[],
6120            tx,
6121            None,
6122            None,
6123            ReasoningLevel::Medium,
6124            crate::runtime::openai::catalog::CodexRequestRole::Foreground,
6125            &tokio_util::sync::CancellationToken::new(),
6126            max_retries,
6127            &crate::runtime::trace::TraceContext::disabled(),
6128        )
6129        .await
6130    }
6131
6132    const QUOTA_429: &str = r#"{"error":{"type":"usage_limit_reached","message":"ECHOED prompt","plan_type":"plus","resets_in_seconds":3600}}"#;
6133    const GENERIC_429: &str = r#"{"error":{"type":"rate_limit_exceeded","message":"slow down"}}"#;
6134
6135    fn notices(rx: &mut mpsc::UnboundedReceiver<StreamEvent>) -> Vec<String> {
6136        let mut out = Vec::new();
6137        while let Ok(ev) = rx.try_recv() {
6138            if let StreamEvent::Session(
6139                crate::SessionEvent::Notice(n) | crate::SessionEvent::Retry(n),
6140            ) = ev
6141            {
6142                out.push(n);
6143            }
6144        }
6145        out
6146    }
6147
6148    /// Auto policy, exhausted 429 on seat A before any output, seat B fresh:
6149    /// exactly one switch, the retry carries B's header (derived from B's
6150    /// token), identical body bytes, and the cooldown was reported for A.
6151    #[tokio::test]
6152    async fn quota_429_fails_over_once_to_distinct_account_with_paired_header() {
6153        let (url, seen) = spawn_scripted_codex(vec![
6154            (StatusCode::TOO_MANY_REQUESTS, vec![], QUOTA_429),
6155            (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6156        ])
6157        .await;
6158        let router = ScriptedRouter::new(
6159            pinned("astra1", "acct_A", true),
6160            vec![Some(pinned("astra2", "acct_B", true))],
6161        );
6162        let (tx, mut rx) = mpsc::unbounded_channel();
6163        let result = run_codex_with_router(&url, &router, 3, &tx).await.unwrap();
6164        assert_eq!(result["content"][0]["text"], "hello");
6165        let seen = seen.lock().unwrap();
6166        assert_eq!(seen.len(), 2, "one failed attempt on A, one success on B");
6167        assert_eq!(seen[0].account, "acct_A");
6168        assert_eq!(seen[1].account, "acct_B", "header must follow the pinned token");
6169        // Bearer and header are derived from the SAME credential on every
6170        // attempt: A's token with A's id, then B's token with B's id — never
6171        // a mixed pair.
6172        assert_eq!(seen[0].bearer, fake_codex_token_for("acct_A"));
6173        assert_eq!(seen[1].bearer, fake_codex_token_for("acct_B"));
6174        assert_ne!(seen[0].bearer, seen[1].bearer);
6175        assert_eq!(seen[0].body, seen[1].body, "same request bytes, no rebuild");
6176        assert_eq!(router.failover_calls.load(Ordering::SeqCst), 1);
6177        assert_eq!(router.reports(), vec!["openai-codex@astra1".to_string()]);
6178        let notes = notices(&mut rx);
6179        assert!(
6180            notes.iter().any(|n| n.contains("'astra1'") && n.contains("'astra2'")),
6181            "operator must see the switch: {notes:?}"
6182        );
6183        assert!(
6184            notes.iter().all(|n| !n.contains("acct_") && !n.contains("h.")),
6185            "notices carry labels only, never ids/tokens: {notes:?}"
6186        );
6187    }
6188
6189    /// Explicitly selected account: never switched, even with an eligible
6190    /// alternative. The turn ends with the static quota message and no
6191    /// transport retries are burned on a provably exhausted seat.
6192    #[tokio::test]
6193    async fn quota_429_on_explicit_account_never_fails_over() {
6194        let (url, seen) = spawn_scripted_codex(vec![(
6195            StatusCode::TOO_MANY_REQUESTS,
6196            vec![],
6197            QUOTA_429,
6198        )])
6199        .await;
6200        let router = ScriptedRouter::new(
6201            pinned("astra1", "acct_A", false),
6202            vec![Some(pinned("astra2", "acct_B", true))],
6203        );
6204        let (tx, _rx) = mpsc::unbounded_channel();
6205        let err = run_codex_with_router(&url, &router, 5, &tx)
6206            .await
6207            .expect_err("explicit account must fail, not switch");
6208        assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6209        assert!(!err.to_string().contains("ECHOED"));
6210        assert_eq!(seen.lock().unwrap().len(), 1, "no retry on exhausted seat");
6211        assert_eq!(router.failover_calls.load(Ordering::SeqCst), 0);
6212        // The cooldown is still reported so the broker stops advertising A.
6213        assert_eq!(router.reports(), vec!["openai-codex@astra1".to_string()]);
6214    }
6215
6216    /// A generic 429 (no exhaustion evidence) keeps the ordinary transport
6217    /// retry on the SAME account — it is not proof of a spent window.
6218    #[tokio::test]
6219    async fn generic_429_retries_same_account_and_never_switches() {
6220        let (url, seen) = spawn_scripted_codex(vec![
6221            (StatusCode::TOO_MANY_REQUESTS, vec![], GENERIC_429),
6222            (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6223        ])
6224        .await;
6225        let router = ScriptedRouter::new(
6226            pinned("astra1", "acct_A", true),
6227            vec![Some(pinned("astra2", "acct_B", true))],
6228        );
6229        let (tx, _rx) = mpsc::unbounded_channel();
6230        run_codex_with_router(&url, &router, 2, &tx).await.unwrap();
6231        let seen = seen.lock().unwrap();
6232        assert_eq!(seen.len(), 2);
6233        assert!(seen.iter().all(|r| r.account == "acct_A"));
6234        assert!(seen.iter().all(|r| r.bearer == fake_codex_token_for("acct_A")));
6235        assert_eq!(router.failover_calls.load(Ordering::SeqCst), 0);
6236        assert!(router.reports().is_empty());
6237    }
6238
6239    /// Exhausted-window headers on a 429 are provider-declared evidence too.
6240    #[tokio::test]
6241    async fn quota_429_by_headers_fails_over() {
6242        let (url, seen) = spawn_scripted_codex(vec![
6243            (
6244                StatusCode::TOO_MANY_REQUESTS,
6245                vec![
6246                    ("x-codex-primary-used-percent", "100"),
6247                    ("x-codex-primary-reset-after-seconds", "500"),
6248                ],
6249                "{}",
6250            ),
6251            (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6252        ])
6253        .await;
6254        let router = ScriptedRouter::new(
6255            pinned("astra1", "acct_A", true),
6256            vec![Some(pinned("astra2", "acct_B", true))],
6257        );
6258        let (tx, _rx) = mpsc::unbounded_channel();
6259        run_codex_with_router(&url, &router, 2, &tx).await.unwrap();
6260        assert_eq!(seen.lock().unwrap()[1].account, "acct_B");
6261        let reported = router.reported.lock().unwrap();
6262        assert_eq!(reported[0].1.reset_after_secs, Some(500));
6263    }
6264
6265    /// No eligible seat: one quota failure ends the turn — no loop, no
6266    /// second attempt on the exhausted account.
6267    #[tokio::test]
6268    async fn quota_429_with_no_eligible_candidate_fails_without_looping() {
6269        let (url, seen) = spawn_scripted_codex(vec![(
6270            StatusCode::TOO_MANY_REQUESTS,
6271            vec![],
6272            QUOTA_429,
6273        )])
6274        .await;
6275        let router = ScriptedRouter::new(pinned("astra1", "acct_A", true), vec![None]);
6276        let (tx, _rx) = mpsc::unbounded_channel();
6277        let err = run_codex_with_router(&url, &router, 10, &tx)
6278            .await
6279            .expect_err("must fail");
6280        assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6281        assert_eq!(seen.lock().unwrap().len(), 1);
6282        assert_eq!(router.failover_calls.load(Ordering::SeqCst), 1);
6283    }
6284
6285    /// The router hands back the very account that failed (e.g. a broker
6286    /// whose cooldown sink is a no-op): refused, never retried.
6287    #[tokio::test]
6288    async fn failover_to_same_account_is_refused() {
6289        let (url, seen) = spawn_scripted_codex(vec![(
6290            StatusCode::TOO_MANY_REQUESTS,
6291            vec![],
6292            QUOTA_429,
6293        )])
6294        .await;
6295        let router = ScriptedRouter::new(
6296            pinned("astra1", "acct_A", true),
6297            vec![Some(pinned("astra1", "acct_A", true))],
6298        );
6299        let (tx, _rx) = mpsc::unbounded_channel();
6300        let err = run_codex_with_router(&url, &router, 10, &tx)
6301            .await
6302            .expect_err("must fail");
6303        assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6304        assert_eq!(seen.lock().unwrap().len(), 1);
6305    }
6306
6307    /// Both seats exhausted: exactly one switch, then stop (bounded to
6308    /// `MAX_CODEX_ACCOUNT_FAILOVERS`), even with a third candidate scripted.
6309    #[tokio::test]
6310    async fn all_accounts_exhausted_stops_after_one_failover() {
6311        let (url, seen) = spawn_scripted_codex(vec![(
6312            StatusCode::TOO_MANY_REQUESTS,
6313            vec![],
6314            QUOTA_429,
6315        )])
6316        .await;
6317        let router = ScriptedRouter::new(
6318            pinned("astra1", "acct_A", true),
6319            vec![
6320                Some(pinned("astra2", "acct_B", true)),
6321                Some(pinned("astra3", "acct_C", true)),
6322            ],
6323        );
6324        let (tx, _rx) = mpsc::unbounded_channel();
6325        let err = run_codex_with_router(&url, &router, 10, &tx)
6326            .await
6327            .expect_err("must fail");
6328        assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6329        let seen = seen.lock().unwrap();
6330        assert_eq!(seen.len(), 2, "A then B, never C");
6331        assert_eq!(seen[0].account, "acct_A");
6332        assert_eq!(seen[1].account, "acct_B");
6333        assert_eq!(router.failover_calls.load(Ordering::SeqCst), 1);
6334        assert_eq!(
6335            router.reports(),
6336            vec![
6337                "openai-codex@astra1".to_string(),
6338                "openai-codex@astra2".to_string()
6339            ],
6340            "both exhausted seats reported for cooldown"
6341        );
6342    }
6343
6344    /// In-stream quota failure (HTTP 200 + terminal `insufficient_quota`)
6345    /// with NO output produced: one failover.
6346    #[tokio::test]
6347    async fn in_stream_quota_before_output_fails_over() {
6348        const QUOTA_SSE: &str = concat!(
6349            "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r\"}}\n\n",
6350            "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"insufficient_quota\",\"message\":\"ECHOED\"}}}\n\n",
6351            "data: [DONE]\n\n",
6352        );
6353        let (url, seen) = spawn_scripted_codex(vec![
6354            (StatusCode::OK, vec![], QUOTA_SSE),
6355            (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6356        ])
6357        .await;
6358        let router = ScriptedRouter::new(
6359            pinned("astra1", "acct_A", true),
6360            vec![Some(pinned("astra2", "acct_B", true))],
6361        );
6362        let (tx, _rx) = mpsc::unbounded_channel();
6363        let result = run_codex_with_router(&url, &router, 2, &tx).await.unwrap();
6364        assert_eq!(result["content"][0]["text"], "hello");
6365        let seen = seen.lock().unwrap();
6366        assert_eq!(seen.len(), 2);
6367        assert_eq!(seen[1].account, "acct_B");
6368        assert_eq!(seen[1].bearer, fake_codex_token_for("acct_B"));
6369    }
6370
6371    /// In-stream quota failure AFTER the model produced content — here only
6372    /// a reasoning-summary delta, which this decoder does not even forward —
6373    /// is terminal: no replay on another account.
6374    #[tokio::test]
6375    async fn in_stream_quota_after_any_output_never_replays() {
6376        const QUOTA_AFTER_REASONING: &str = concat!(
6377            "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"thinking…\"}\n\n",
6378            "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"quota_exceeded\",\"message\":\"x\"}}}\n\n",
6379            "data: [DONE]\n\n",
6380        );
6381        let (url, seen) = spawn_scripted_codex(vec![
6382            (StatusCode::OK, vec![], QUOTA_AFTER_REASONING),
6383            (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6384        ])
6385        .await;
6386        let router = ScriptedRouter::new(
6387            pinned("astra1", "acct_A", true),
6388            vec![Some(pinned("astra2", "acct_B", true))],
6389        );
6390        let (tx, _rx) = mpsc::unbounded_channel();
6391        let err = run_codex_with_router(&url, &router, 5, &tx)
6392            .await
6393            .expect_err("must not replay after output");
6394        assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6395        assert_eq!(seen.lock().unwrap().len(), 1);
6396        assert_eq!(router.failover_calls.load(Ordering::SeqCst), 0);
6397        // Still reported: the seat IS exhausted.
6398        assert_eq!(router.reports(), vec!["openai-codex@astra1".to_string()]);
6399    }
6400
6401    /// Text already streamed to the UI, then quota: terminal.
6402    #[tokio::test]
6403    async fn in_stream_quota_after_text_never_replays() {
6404        const QUOTA_AFTER_TEXT: &str = concat!(
6405            "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
6406            "data: {\"type\":\"error\",\"code\":\"usage_limit_reached\",\"message\":\"x\"}\n\n",
6407        );
6408        let (url, seen) = spawn_scripted_codex(vec![
6409            (StatusCode::OK, vec![], QUOTA_AFTER_TEXT),
6410            (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6411        ])
6412        .await;
6413        let router = ScriptedRouter::new(
6414            pinned("astra1", "acct_A", true),
6415            vec![Some(pinned("astra2", "acct_B", true))],
6416        );
6417        let (tx, _rx) = mpsc::unbounded_channel();
6418        let err = run_codex_with_router(&url, &router, 5, &tx)
6419            .await
6420            .expect_err("must not replay after output");
6421        assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6422        assert_eq!(seen.lock().unwrap().len(), 1);
6423        assert_eq!(router.failover_calls.load(Ordering::SeqCst), 0);
6424    }
6425
6426    /// `usage_not_included` is deterministic for the account: no transport
6427    /// retry, no failover, status error surfaced.
6428    #[tokio::test]
6429    async fn usage_not_included_fails_fast_without_retry_or_failover() {
6430        let (url, seen) = spawn_scripted_codex(vec![(
6431            StatusCode::TOO_MANY_REQUESTS,
6432            vec![],
6433            r#"{"error":{"type":"usage_not_included","message":"no codex here"}}"#,
6434        )])
6435        .await;
6436        let router = ScriptedRouter::new(
6437            pinned("astra1", "acct_A", true),
6438            vec![Some(pinned("astra2", "acct_B", true))],
6439        );
6440        let (tx, _rx) = mpsc::unbounded_channel();
6441        let err = run_codex_with_router(&url, &router, 5, &tx)
6442            .await
6443            .expect_err("must fail");
6444        assert!(err.to_string().starts_with("codex request failed: 429"), "{err}");
6445        assert!(!err.to_string().contains("no codex here"));
6446        assert_eq!(seen.lock().unwrap().len(), 1);
6447        assert_eq!(router.failover_calls.load(Ordering::SeqCst), 0);
6448        assert!(router.reports().is_empty());
6449    }
6450
6451    /// A transient 503 (transport retry on A) followed by a quota 429 on A:
6452    /// still pre-output, so one failover to B is allowed; B then needs its
6453    /// own transient retry, which the shared budget still covers — a
6454    /// failover neither consumes nor resets transport retries.
6455    #[tokio::test]
6456    async fn transport_retry_then_quota_then_failover_keeps_one_retry_budget() {
6457        let (url, seen) = spawn_scripted_codex(vec![
6458            (StatusCode::SERVICE_UNAVAILABLE, vec![], "{}"),
6459            (StatusCode::TOO_MANY_REQUESTS, vec![], QUOTA_429),
6460            (StatusCode::SERVICE_UNAVAILABLE, vec![], "{}"),
6461            (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6462        ])
6463        .await;
6464        let router = ScriptedRouter::new(
6465            pinned("astra1", "acct_A", true),
6466            vec![Some(pinned("astra2", "acct_B", true))],
6467        );
6468        let (tx, _rx) = mpsc::unbounded_channel();
6469        run_codex_with_router(&url, &router, 2, &tx).await.unwrap();
6470        let seen = seen.lock().unwrap();
6471        let accounts: Vec<&str> = seen.iter().map(|r| r.account.as_str()).collect();
6472        assert_eq!(accounts, ["acct_A", "acct_A", "acct_B", "acct_B"]);
6473        assert_eq!(router.failover_calls.load(Ordering::SeqCst), 1);
6474    }
6475
6476    /// Transport budget already spent on A when the quota 429 arrives: the
6477    /// failover is still permitted (it is not a transport retry), but B gets
6478    /// no further transport retries — bounded on both axes.
6479    #[tokio::test]
6480    async fn failover_does_not_reset_transport_budget() {
6481        let (url, seen) = spawn_scripted_codex(vec![
6482            (StatusCode::SERVICE_UNAVAILABLE, vec![], "{}"),
6483            (StatusCode::TOO_MANY_REQUESTS, vec![], QUOTA_429),
6484            (StatusCode::SERVICE_UNAVAILABLE, vec![], "{}"),
6485        ])
6486        .await;
6487        let router = ScriptedRouter::new(
6488            pinned("astra1", "acct_A", true),
6489            vec![Some(pinned("astra2", "acct_B", true))],
6490        );
6491        let (tx, _rx) = mpsc::unbounded_channel();
6492        let err = run_codex_with_router(&url, &router, 1, &tx)
6493            .await
6494            .expect_err("B's 503 exceeds the shared budget");
6495        assert!(err.to_string().starts_with("codex request failed: 503"), "{err}");
6496        let accounts: Vec<String> = seen.lock().unwrap().iter().map(|r| r.account.clone()).collect();
6497        assert_eq!(accounts, ["acct_A", "acct_A", "acct_B"]);
6498    }
6499
6500    /// Explicit NAMED account (`openai-codex@astra1`) with an in-stream quota
6501    /// failure and an eligible alternative scripted: never rotates.
6502    #[tokio::test]
6503    async fn explicit_named_account_in_stream_quota_never_rotates() {
6504        const QUOTA_SSE: &str = concat!(
6505            "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"insufficient_quota\",\"message\":\"x\"}}}\n\n",
6506            "data: [DONE]\n\n",
6507        );
6508        let (url, seen) = spawn_scripted_codex(vec![
6509            (StatusCode::OK, vec![], QUOTA_SSE),
6510            (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6511        ])
6512        .await;
6513        let router = ScriptedRouter::new(
6514            pinned("astra1", "acct_A", false),
6515            vec![Some(pinned("astra2", "acct_B", true))],
6516        );
6517        let (tx, mut rx) = mpsc::unbounded_channel();
6518        let err = run_codex_with_router(&url, &router, 5, &tx)
6519            .await
6520            .expect_err("explicit named account must not rotate");
6521        assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6522        assert_eq!(seen.lock().unwrap().len(), 1);
6523        assert_eq!(router.failover_calls.load(Ordering::SeqCst), 0);
6524        assert!(notices(&mut rx).iter().all(|n| !n.contains("switched")));
6525    }
6526
6527    /// Explicit DEFAULT slot behaves the same as a named one: no rotation.
6528    #[tokio::test]
6529    async fn explicit_default_account_quota_429_never_rotates() {
6530        let (url, seen) = spawn_scripted_codex(vec![
6531            (StatusCode::TOO_MANY_REQUESTS, vec![], QUOTA_429),
6532            (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6533        ])
6534        .await;
6535        let router = ScriptedRouter::new(
6536            pinned("default", "acct_D", false),
6537            vec![Some(pinned("astra2", "acct_B", true))],
6538        );
6539        let (tx, _rx) = mpsc::unbounded_channel();
6540        let err = run_codex_with_router(&url, &router, 5, &tx)
6541            .await
6542            .expect_err("explicit default must not rotate");
6543        assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6544        assert_eq!(seen.lock().unwrap().len(), 1);
6545        assert_eq!(router.reports(), vec!["openai-codex".to_string()]);
6546    }
6547
6548    /// Reasoning-only output as an output ITEM (no text delta, nothing
6549    /// forwarded to the UI) followed by a quota failure: terminal.
6550    #[tokio::test]
6551    async fn in_stream_quota_after_reasoning_item_never_replays() {
6552        const REASONING_ITEM_THEN_QUOTA: &str = concat!(
6553            "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r\"}}\n\n",
6554            "data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"r\"}}\n\n",
6555            "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"id\":\"rs_1\",\"summary\":[]}}\n\n",
6556            "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"insufficient_quota\",\"message\":\"x\"}}}\n\n",
6557            "data: [DONE]\n\n",
6558        );
6559        let (url, seen) = spawn_scripted_codex(vec![
6560            (StatusCode::OK, vec![], REASONING_ITEM_THEN_QUOTA),
6561            (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6562        ])
6563        .await;
6564        let router = ScriptedRouter::new(
6565            pinned("astra1", "acct_A", true),
6566            vec![Some(pinned("astra2", "acct_B", true))],
6567        );
6568        let (tx, mut rx) = mpsc::unbounded_channel();
6569        let err = run_codex_with_router(&url, &router, 5, &tx)
6570            .await
6571            .expect_err("reasoning output already produced");
6572        assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6573        assert_eq!(seen.lock().unwrap().len(), 1);
6574        assert_eq!(router.failover_calls.load(Ordering::SeqCst), 0);
6575        // Nothing reached the UI — the gate is stricter than "was forwarded".
6576        while let Ok(ev) = rx.try_recv() {
6577            assert!(
6578                !matches!(ev, StreamEvent::Llm(crate::LlmEvent::Text(_))),
6579                "no text was forwarded"
6580            );
6581        }
6582    }
6583
6584    /// A metadata-only prelude (`response.created` / `in_progress`) does not
6585    /// count as output: failover stays permitted.
6586    #[tokio::test]
6587    async fn metadata_only_prelude_does_not_block_failover() {
6588        const METADATA_THEN_QUOTA: &str = concat!(
6589            "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r\"}}\n\n",
6590            "data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"r\"}}\n\n",
6591            "data: {\"type\":\"error\",\"code\":\"usage_limit_reached\",\"message\":\"x\"}\n\n",
6592        );
6593        let (url, seen) = spawn_scripted_codex(vec![
6594            (StatusCode::OK, vec![], METADATA_THEN_QUOTA),
6595            (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6596        ])
6597        .await;
6598        let router = ScriptedRouter::new(
6599            pinned("astra1", "acct_A", true),
6600            vec![Some(pinned("astra2", "acct_B", true))],
6601        );
6602        let (tx, _rx) = mpsc::unbounded_channel();
6603        run_codex_with_router(&url, &router, 2, &tx).await.unwrap();
6604        assert_eq!(seen.lock().unwrap()[1].account, "acct_B");
6605    }
6606
6607    /// A failover candidate whose token carries no ChatGPT account claim
6608    /// cannot be paired with a header: the turn fails at the runtime, and
6609    /// no request is ever sent with a header borrowed from the failed seat.
6610    #[tokio::test]
6611    async fn failover_candidate_without_account_claim_never_sends_mixed_headers() {
6612        let (url, seen) = spawn_scripted_codex(vec![
6613            (StatusCode::TOO_MANY_REQUESTS, vec![], QUOTA_429),
6614            (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6615        ])
6616        .await;
6617        let claimless = PinnedAccount {
6618            credential: CredentialRef::new(
6619                OAuthProviderId::OpenAiCodex,
6620                Account::parse("astra2").unwrap(),
6621            ),
6622            token: format!(
6623                "h.{}.s",
6624                URL_SAFE_NO_PAD.encode(serde_json::json!({"sub": "nobody"}).to_string())
6625            ),
6626            auto: true,
6627        };
6628        let router = ScriptedRouter::new(pinned("astra1", "acct_A", true), vec![Some(claimless)]);
6629        let (tx, _rx) = mpsc::unbounded_channel();
6630        let err = run_codex_with_router(&url, &router, 5, &tx)
6631            .await
6632            .expect_err("claimless token cannot be paired");
6633        assert!(err.to_string().contains("account id"), "{err}");
6634        let seen = seen.lock().unwrap();
6635        assert_eq!(seen.len(), 1, "only A's attempt was sent");
6636        assert_eq!(seen[0].account, "acct_A");
6637    }
6638
6639    /// The router itself failing during failover is not a loop: report,
6640    /// fail the turn with the quota message.
6641    #[tokio::test]
6642    async fn router_failover_error_ends_turn() {
6643        struct FailingRouter(PinnedAccount);
6644        #[async_trait]
6645        impl CodexAccountRouter for FailingRouter {
6646            async fn pin(&self, _m: &str) -> Result<PinnedAccount, String> {
6647                Ok(self.0.clone())
6648            }
6649            async fn failover(
6650                &self,
6651                _m: &str,
6652                _f: &PinnedAccount,
6653                _e: &CodexQuotaEvidence,
6654                _n: u32,
6655            ) -> Result<Option<PinnedAccount>, String> {
6656                Err("broker unreachable".into())
6657            }
6658            async fn report_exhausted(&self, _f: &PinnedAccount, _e: &CodexQuotaEvidence) {}
6659        }
6660        let (url, seen) = spawn_scripted_codex(vec![(
6661            StatusCode::TOO_MANY_REQUESTS,
6662            vec![],
6663            QUOTA_429,
6664        )])
6665        .await;
6666        let router = FailingRouter(pinned("astra1", "acct_A", true));
6667        let (tx, _rx) = mpsc::unbounded_channel();
6668        let err = run_codex_with_router(&url, &router, 5, &tx)
6669            .await
6670            .expect_err("must fail");
6671        assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6672        assert_eq!(seen.lock().unwrap().len(), 1);
6673    }
6674
6675    /// The broker adapter with the default trait surface pins the policy
6676    /// account as an explicit selection and reports no candidates: the old
6677    /// `TokenOnlyBroker` mock keeps working and a quota 429 simply fails.
6678    #[tokio::test]
6679    async fn broker_adapter_default_trait_surface_is_explicit_and_fails_closed() {
6680        let (url, seen) = spawn_scripted_codex(vec![(
6681            StatusCode::TOO_MANY_REQUESTS,
6682            vec![],
6683            QUOTA_429,
6684        )])
6685        .await;
6686        let err = run_codex(&url, 5).await.expect_err("must fail");
6687        assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6688        assert_eq!(seen.lock().unwrap().len(), 1, "no retry, no failover");
6689    }
6690
6691    /// The dispatch seam lifts the generic three-attempt budget to the
6692    /// persistent posture adopted for Anthropic OAuth overloads (10 retries)
6693    /// — incident: 2026-07-16 chatgpt.com 503/520/timeout bursts.
6694    #[test]
6695    fn codex_retry_budget_lifts_configured_default_to_persistent_floor() {
6696        assert_eq!(codex_retry_budget(3), CODEX_PERSISTENT_RETRIES);
6697        assert_eq!(codex_retry_budget(0), CODEX_PERSISTENT_RETRIES);
6698        assert_eq!(codex_retry_budget(10), 10);
6699    }
6700
6701    /// A user who explicitly configured an even larger budget keeps it —
6702    /// the floor only ever raises, never lowers.
6703    #[test]
6704    fn codex_retry_budget_honors_larger_user_configuration() {
6705        assert_eq!(codex_retry_budget(15), 15);
6706    }
6707
6708    /// Backoff doubles per attempt but caps at 2^6 — a 10-deep budget must
6709    /// never sleep 512s on its final attempt.
6710    /// 04-tui I4: the incomplete-response retry is a typed retry (the
6711    /// header's "retrying"), not a notice the client has to recognise.
6712    #[tokio::test]
6713    async fn a_stream_retry_is_a_typed_retry_event() {
6714        let (tx, mut rx) = mpsc::unbounded_channel();
6715        let cancel = tokio_util::sync::CancellationToken::new();
6716        let waiting = cancel.clone();
6717        let task = tokio::spawn(async move { wait_stream_retry(&tx, &waiting, 1, 3, true).await.is_ok() });
6718        let ev = rx.recv().await.expect("the retry is announced");
6719        cancel.cancel();
6720        assert!(!task.await.unwrap(), "cancel ends the wait");
6721        assert!(
6722            matches!(&ev, StreamEvent::Session(crate::SessionEvent::Retry(t)) if t.starts_with("Response incomplete — retrying")),
6723            "{ev:?}"
6724        );
6725    }
6726
6727    #[test]
6728    fn retry_delay_is_exponential_with_a_capped_tail() {
6729        assert_eq!(retry_delay(1).as_secs(), 1);
6730        assert_eq!(retry_delay(2).as_secs(), 2);
6731        assert_eq!(retry_delay(4).as_secs(), 8);
6732        assert_eq!(retry_delay(7).as_secs(), 64);
6733        assert_eq!(retry_delay(10).as_secs(), 64);
6734    }
6735
6736    // ─── Phase 1 privacy: hostile provider echoes the request ───────────────
6737    // spec §5.1: provider error bodies are untrusted and may echo the full
6738    // request (prompts, system text, tool schemas, credentials). Neither the
6739    // surfaced error nor ANY log line may contain response-body content —
6740    // status + provider label only.
6741
6742    /// Unique raw-content sentinel placed in the outgoing request body.
6743    const ECHO_SENTINEL: &str = "PH1-OAI-SENTINEL-2b8c4d-RAW-CONTENT";
6744
6745    /// `std::io::Write` sink capturing formatted tracing output in-process.
6746    struct CaptureWriter(Arc<std::sync::Mutex<Vec<u8>>>);
6747
6748    impl std::io::Write for CaptureWriter {
6749        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
6750            self.0.lock().unwrap().extend_from_slice(buf);
6751            Ok(buf.len())
6752        }
6753        fn flush(&mut self) -> std::io::Result<()> {
6754            Ok(())
6755        }
6756    }
6757
6758    fn capture_subscriber(
6759        buf: &Arc<std::sync::Mutex<Vec<u8>>>,
6760    ) -> impl tracing::Subscriber + Send + Sync {
6761        let sink = buf.clone();
6762        tracing_subscriber::fmt()
6763            .with_max_level(tracing::Level::TRACE)
6764            .with_writer(move || CaptureWriter(sink.clone()))
6765            .with_ansi(false)
6766            .finish()
6767    }
6768
6769    /// Hostile loopback provider: every request is answered with `status` and
6770    /// a JSON error envelope whose `message` is `"ECHOED:" + the full request
6771    /// body` — the preserved holdout probe shape, aimed at the OpenAI routes.
6772    async fn spawn_hostile_echo_provider(status: StatusCode) -> String {
6773        let app = Router::new().fallback(move |body: String| async move {
6774            let envelope = serde_json::json!({
6775                "error": { "message": format!("ECHOED:{body}") }
6776            });
6777            (
6778                status,
6779                [("content-type", "application/json")],
6780                envelope.to_string(),
6781            )
6782                .into_response()
6783        });
6784        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6785        let addr = listener.local_addr().unwrap();
6786        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
6787        format!("http://{addr}")
6788    }
6789
6790    /// Request body shaped like a real provider request: prompt sentinel plus
6791    /// a distinctive tool schema. If the provider echo survives anywhere,
6792    /// these markers betray it.
6793    fn sentinel_request_body() -> Value {
6794        json!({
6795            "model": "gpt-test",
6796            "messages": [
6797                {"role": "system", "content": format!("system secret {ECHO_SENTINEL}")},
6798                {"role": "user", "content": ECHO_SENTINEL},
6799            ],
6800            "tools": [{
6801                "type": "function",
6802                "function": {
6803                    "name": "ph1_secret_tool_zz",
6804                    "description": "internal tool schema",
6805                    "parameters": {"type": "object", "properties": {}}
6806                }
6807            }],
6808        })
6809    }
6810
6811    /// Markers that must never surface in errors or logs.
6812    const BANNED_MARKERS: &[&str] = &[
6813        "ECHOED",
6814        ECHO_SENTINEL,
6815        "ph1_secret_tool_zz",
6816        "\"messages\"",
6817        "system secret",
6818    ];
6819
6820    fn assert_no_banned(haystack: &str, context: &str) {
6821        for banned in BANNED_MARKERS {
6822            assert!(
6823                !haystack.contains(banned),
6824                "provider body content `{banned}` leaked into {context}: {haystack}"
6825            );
6826        }
6827    }
6828
6829    #[tokio::test]
6830    async fn send_with_retries_non_retryable_error_never_surfaces_provider_body() {
6831        let url = spawn_hostile_echo_provider(StatusCode::BAD_REQUEST).await;
6832        let client = reqwest::Client::new();
6833        let body = sentinel_request_body();
6834
6835        let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
6836        let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
6837
6838        let err = send_with_retries(
6839            SendTarget {
6840                label: "codex",
6841                url: &url,
6842                quota_probe: true,
6843            },
6844            || client.post(&url).json(&body),
6845            &tokio_util::sync::CancellationToken::new(),
6846            3,
6847            &mut 0,
6848            &mut tr::StreamAttempt::new(None),
6849        )
6850        .await
6851        .expect_err("400 must fail fast");
6852
6853        let msg = err.to_string();
6854        assert!(
6855            msg.starts_with("codex request failed: 400"),
6856            "status + label must survive for classification/usability: {msg}"
6857        );
6858        assert_no_banned(&msg, "the surfaced error");
6859        let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
6860        assert_no_banned(&logs, "tracing output");
6861    }
6862
6863    /// Answer every request with `status` and a fixed body.
6864    async fn spawn_fixed_body_provider(status: StatusCode, body: String) -> String {
6865        let app = Router::new().fallback(move || {
6866            let body = body.clone();
6867            async move { (status, [("content-type", "application/json")], body).into_response() }
6868        });
6869        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6870        let addr = listener.local_addr().unwrap();
6871        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
6872        format!("http://{addr}")
6873    }
6874
6875    /// Codex 4xx: identifiers and Synaps-authored text only. Every probe is
6876    /// plain prose that a quote/length/charset sanitizer lets through; none
6877    /// of it may reach the error string, the net.rs classification log, or
6878    /// any captured tracing output.
6879    #[tokio::test]
6880    async fn codex_4xx_never_surfaces_provider_prose_in_error_or_logs() {
6881        const PROSE: &str = "ZZPROSE";
6882        let probes = [
6883            // Apostrophe flips naive quote parsing; the echo follows it.
6884            "The model can't accept 'ZZPROSE echo of the user prompt, it's long' here".to_string(),
6885            // Short quoted span with a secret.
6886            format!("Invalid 'input[0]': '{PROSE} AKIAIOSFODNN7EXAMPLE pls'"),
6887            // Unquoted prose echo.
6888            format!("Your request about {PROSE} my private quarterly notes was rejected"),
6889            // Curly / guillemet quotes.
6890            format!("Bad value \u{2018}{PROSE} one\u{2019} and \u{201c}{PROSE} two\u{201d} \u{ab}x\u{bb}"),
6891            // Secret glued to punctuation.
6892            format!("{PROSE} key:sk-proj-abc123 password=hunter2 https://h/x?token=abc"),
6893            // Bidi override and zero-width characters.
6894            format!("{PROSE}\u{202e}gnirts\u{202c} s\u{200b}k-\u{2066}live\u{2069}"),
6895        ];
6896        for prose in probes {
6897            let body = serde_json::json!({"error": {
6898                "message": prose,
6899                "type": "invalid_request_error",
6900                "code": "unsupported_value",
6901                "param": "reasoning.effort",
6902            }})
6903            .to_string();
6904            let url = spawn_fixed_body_provider(StatusCode::BAD_REQUEST, body).await;
6905            let client = reqwest::Client::new();
6906            let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
6907            let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
6908            let err = send_with_retries(
6909                SendTarget {
6910                    label: "codex",
6911                    url: &url,
6912                    quota_probe: true,
6913                },
6914                || client.post(&url).body("{}"),
6915                &tokio_util::sync::CancellationToken::new(),
6916                3,
6917                &mut 0,
6918                &mut tr::StreamAttempt::new(None),
6919            )
6920            .await
6921            .expect_err("400 must fail fast");
6922            // Run the error through the route classifier too: it logs.
6923            let runtime =
6924                super::super::net::provider_error_to_runtime_for("openai-codex/gpt-5.6-sol", err);
6925            let msg = runtime.to_string();
6926            let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
6927            for (where_, text) in [("error", &msg), ("logs", &logs)] {
6928                for banned in [
6929                    PROSE, "can't", "AKIA", "sk-", "hunter2", "token=", "private", "gnirts",
6930                    "\u{202e}", "\u{200b}", "\u{2066}", "\u{2018}", "\u{201c}",
6931                ] {
6932                    assert!(
6933                        !text.contains(banned),
6934                        "provider prose `{banned}` leaked into {where_} for probe {prose:?}: {text}"
6935                    );
6936                }
6937            }
6938            // Identifiers and our own explanation do surface.
6939            assert!(
6940                msg.contains("[invalid_request_error/unsupported_value, param reasoning.effort]"),
6941                "{msg}"
6942            );
6943            assert!(msg.contains("/effort"), "static explanation missing: {msg}");
6944        }
6945    }
6946
6947    #[tokio::test(start_paused = true)]
6948    async fn send_with_retries_retry_logs_never_contain_provider_body() {
6949        let url = spawn_hostile_echo_provider(StatusCode::SERVICE_UNAVAILABLE).await;
6950        let client = reqwest::Client::new();
6951        let body = sentinel_request_body();
6952
6953        let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
6954        let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
6955
6956        let err = send_with_retries(
6957            SendTarget {
6958                label: "codex",
6959                url: &url,
6960                quota_probe: true,
6961            },
6962            || client.post(&url).json(&body),
6963            &tokio_util::sync::CancellationToken::new(),
6964            1,
6965            &mut 0,
6966            &mut tr::StreamAttempt::new(None),
6967        )
6968        .await
6969        .expect_err("persistent 503 must exhaust the budget");
6970
6971        let msg = err.to_string();
6972        assert!(
6973            msg.starts_with("codex request failed: 503"),
6974            "status + label must survive after exhaustion: {msg}"
6975        );
6976        assert_no_banned(&msg, "the surfaced error");
6977
6978        let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
6979        assert!(
6980            logs.contains("retry 1/1"),
6981            "retry accounting must stay observable: {logs}"
6982        );
6983        assert!(
6984            logs.contains("503"),
6985            "retry log must keep the status for diagnosis: {logs}"
6986        );
6987        assert_no_banned(&logs, "retry tracing output");
6988    }
6989}
6990
6991#[cfg(test)]
6992mod interrupted_broker_tests {
6993    use super::*;
6994    use crate::auth::{
6995        AccessToken, BrokerError, CredentialBroker, ProxyByteStream, ProxyRequest, ProxyResponse,
6996    };
6997    use std::sync::{Arc, Mutex};
6998    use tokio_util::sync::CancellationToken;
6999
7000    struct Broker {
7001        bodies: Vec<(&'static str, Option<BrokerError>)>,
7002        seen: Mutex<Vec<ProxyRequest>>,
7003    }
7004    #[async_trait::async_trait]
7005    impl CredentialBroker for Broker {
7006        async fn access_token(
7007            &self,
7008            _: crate::auth::OAuthProviderId,
7009        ) -> Result<AccessToken, BrokerError> {
7010            unreachable!()
7011        }
7012        async fn proxy(&self, _: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
7013            unreachable!()
7014        }
7015        async fn proxy_stream(
7016            &self,
7017            request: ProxyRequest,
7018        ) -> Result<ProxyByteStream, BrokerError> {
7019            let mut seen = self.seen.lock().unwrap();
7020            let (body, error) = &self.bodies[seen.len().min(self.bodies.len() - 1)];
7021            seen.push(request);
7022            let mut chunks = vec![Ok(bytes::Bytes::from_static(body.as_bytes()))];
7023            if let Some(error) = error {
7024                chunks.push(Err(error.clone()));
7025            }
7026            Ok(Box::pin(futures::stream::iter(chunks)))
7027        }
7028        async fn anthropic_usage(&self) -> Result<Value, BrokerError> {
7029            unreachable!()
7030        }
7031        async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
7032            Ok(vec![])
7033        }
7034    }
7035    const CHAT_PARTIAL: &str = concat!(
7036        "data: {\"choices\":[{\"delta\":{\"content\":\"discard\"}}]}\n\n",
7037        "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":1}}\n\n"
7038    );
7039    const CHAT_OK: &str =
7040        "data: {\"choices\":[{\"delta\":{\"content\":\"accepted\"}}]}\n\ndata: [DONE]\n\n";
7041    const XAI_PARTIAL: &str =
7042        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"discard\"}\n\n";
7043    const XAI_OK: &str = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"accepted\"}\n\ndata: {\"type\":\"response.completed\"}\n\n";
7044
7045    async fn drive(
7046        broker: Arc<Broker>,
7047        xai: bool,
7048        retries: u32,
7049        suppressed: bool,
7050        cancel: &CancellationToken,
7051    ) -> (
7052        Result<Value, super::super::net::BoxedProviderError>,
7053        Vec<StreamEvent>,
7054    ) {
7055        let cfg = ProviderConfig {
7056            base_url: "http://unused.invalid".into(),
7057            model: if xai { "grok-4.5" } else { "test" }.into(),
7058            provider: if xai { "xai-auth" } else { "local" }.into(),
7059        };
7060        let broker: Arc<dyn CredentialBroker> = broker;
7061        let (tx, mut rx) = mpsc::unbounded_channel();
7062        let trace = crate::runtime::trace::TraceContext::disabled();
7063        let result = if xai {
7064            call_xai_responses_stream_inner(
7065                &cfg,
7066                &broker,
7067                &[],
7068                &None,
7069                &[],
7070                &tx,
7071                None,
7072                agent_core::reasoning::ReasoningLevel::Adaptive,
7073                cancel,
7074                retries,
7075                &trace,
7076                true,
7077            )
7078            .await
7079        } else {
7080            call_oai_stream_inner(
7081                &cfg,
7082                &broker,
7083                &[],
7084                &None,
7085                &[],
7086                &tx,
7087                None,
7088                None,
7089                0,
7090                agent_core::reasoning::ReasoningLevel::Medium,
7091                cancel,
7092                &trace,
7093                true,
7094                suppressed,
7095                retries,
7096            )
7097            .await
7098        };
7099        let mut events = vec![];
7100        while let Ok(event) = rx.try_recv() {
7101            events.push(event);
7102        }
7103        (result, events)
7104    }
7105
7106    #[tokio::test(start_paused = true)]
7107    async fn broker_transport_recovery_preserves_usage_and_identical_bytes() {
7108        for xai in [false, true] {
7109            let broker = Arc::new(Broker {
7110                bodies: vec![
7111                    (
7112                        if xai { XAI_PARTIAL } else { CHAT_PARTIAL },
7113                        Some(BrokerError::Transport(
7114                            "unexpected EOF during chunk size line".into(),
7115                        )),
7116                    ),
7117                    (if xai { XAI_OK } else { CHAT_OK }, None),
7118                ],
7119                seen: Mutex::new(vec![]),
7120            });
7121            let (result, events) =
7122                drive(broker.clone(), xai, 1, false, &CancellationToken::new()).await;
7123            assert_eq!(result.unwrap()["content"][0]["text"], "accepted");
7124            let seen = broker.seen.lock().unwrap();
7125            assert_eq!(seen.len(), 2);
7126            assert_eq!(seen[0].body_bytes, seen[1].body_bytes);
7127            assert_eq!(
7128                events
7129                    .iter()
7130                    .filter(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset)))
7131                    .count(),
7132                1
7133            );
7134            if !xai {
7135                assert_eq!(
7136                    events
7137                        .iter()
7138                        .filter_map(|e| match e {
7139                            StreamEvent::Session(crate::SessionEvent::Usage {
7140                                input_tokens,
7141                                ..
7142                            }) => Some(*input_tokens),
7143                            _ => None,
7144                        })
7145                        .sum::<u64>(),
7146                    7
7147                );
7148            }
7149        }
7150    }
7151    #[tokio::test(start_paused = true)]
7152    async fn broker_nontransport_denial_never_retries_and_precancel_never_dispatches() {
7153        for xai in [false, true] {
7154            let broker = Arc::new(Broker {
7155                bodies: vec![(
7156                    if xai { XAI_PARTIAL } else { CHAT_PARTIAL },
7157                    Some(BrokerError::Denied("blocked".into())),
7158                )],
7159                seen: Mutex::new(vec![]),
7160            });
7161            let (result, events) =
7162                drive(broker.clone(), xai, 10, false, &CancellationToken::new()).await;
7163            assert!(result.is_err());
7164            assert_eq!(broker.seen.lock().unwrap().len(), 1);
7165            assert!(!events
7166                .iter()
7167                .any(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset))));
7168            let canceled = CancellationToken::new();
7169            canceled.cancel();
7170            assert!(drive(broker.clone(), xai, 10, false, &canceled)
7171                .await
7172                .0
7173                .is_err());
7174            assert_eq!(broker.seen.lock().unwrap().len(), 1);
7175        }
7176    }
7177    #[tokio::test(start_paused = true)]
7178    async fn suppressed_retry_emits_no_llm_previews() {
7179        let broker = Arc::new(Broker {
7180            bodies: vec![
7181                (CHAT_PARTIAL, Some(BrokerError::Transport("eof".into()))),
7182                (CHAT_OK, None),
7183            ],
7184            seen: Mutex::new(vec![]),
7185        });
7186        let (result, events) = drive(broker, false, 1, true, &CancellationToken::new()).await;
7187        assert!(result.is_ok());
7188        assert!(!events.iter().any(|e| matches!(e, StreamEvent::Llm(_))));
7189    }
7190    #[test]
7191    fn exhaustion_is_transient_for_auto_driver_but_not_arbitrary_error_prose() {
7192        use crate::extensions::session_driver::{classify_error, Outcome};
7193        assert_eq!(
7194            classify_error(STREAM_INTERRUPTED),
7195            (Outcome::ProviderError, "transient".into())
7196        );
7197        assert_eq!(
7198            classify_error(&format!("API error: {STREAM_INTERRUPTED}")),
7199            (Outcome::ProviderError, "transient".into())
7200        );
7201        assert_eq!(
7202            classify_error(&format!("denied: {STREAM_INTERRUPTED}")).0,
7203            Outcome::Blocked
7204        );
7205    }
7206}
7207
7208#[cfg(test)]
7209mod muse_tests {
7210    //! Meta Muse Responses route: exact body shape (mirrors the official
7211    //! Muse Code client) and an end-to-end turn through a capturing broker.
7212    use super::*;
7213    use crate::auth::{
7214        AccessToken, BrokerError, CredentialBroker, ProxyByteStream, ProxyMethod, ProxyRequest,
7215        ProxyResponse,
7216    };
7217    use agent_core::reasoning::ReasoningLevel;
7218    use std::sync::{Arc, Mutex};
7219
7220    #[test]
7221    fn body_mirrors_official_client_and_adaptive_omits_reasoning() {
7222        let input = json!([{"role":"user","content":[{"type":"input_text","text":"hi"}]}]);
7223        let tool = json!({"type":"function","name":"bash","parameters":{"type":"object"}});
7224        let key = muse_prompt_cache_key("sys", input.as_array().unwrap().first());
7225        let body = build_muse_body(
7226            "muse-wonder-1.0",
7227            ReasoningLevel::Adaptive,
7228            input.clone(),
7229            "sys",
7230            vec![tool.clone()],
7231            Some(2048),
7232            &key,
7233        )
7234        .unwrap();
7235        assert_eq!(body["model"], "muse-wonder-1.0");
7236        assert_eq!(body["instructions"], "sys");
7237        assert_eq!(body["input"], input);
7238        assert_eq!(body["store"], false);
7239        assert_eq!(body["stream"], true);
7240        assert_eq!(body["tools"], json!([tool]));
7241        assert_eq!(body["max_output_tokens"], 2048);
7242        assert_eq!(body["prompt_cache_key"], key.as_str());
7243        assert!(
7244            body.get("reasoning").is_none(),
7245            "adaptive = provider default"
7246        );
7247        assert!(body.get("messages").is_none());
7248        assert!(body.get("temperature").is_none());
7249
7250        // Empty prompt/tools/limit are omitted, not sent empty.
7251        let bare = build_muse_body(
7252            "muse-wonder-1.0",
7253            ReasoningLevel::Adaptive,
7254            json!([]),
7255            "",
7256            Vec::new(),
7257            None,
7258            &key,
7259        )
7260        .unwrap();
7261        for absent in ["instructions", "tools", "max_output_tokens", "reasoning"] {
7262            assert!(bare.get(absent).is_none(), "{absent}");
7263        }
7264    }
7265
7266    #[test]
7267    fn named_effort_requires_exact_live_evidence_before_any_io() {
7268        // Held for the whole test: the seed assertions read the shared
7269        // process-global `muse/*` cache rows too.
7270        let _guard = crate::runtime::openai::catalog::muse::test_cache_guard();
7271        crate::runtime::openai::catalog::capability_cache::replace_provider("muse", &[]);
7272        // The static seed advertises no effort set: named levels are refused
7273        // in the pure builder, i.e. before credentials or network.
7274        for level in [
7275            ReasoningLevel::Off,
7276            ReasoningLevel::High,
7277            ReasoningLevel::Max,
7278        ] {
7279            let err = build_muse_body(
7280                "muse-wonder-1.0",
7281                level,
7282                json!([]),
7283                "",
7284                Vec::new(),
7285                None,
7286                "k",
7287            )
7288            .unwrap_err();
7289            assert!(err.contains("adaptive"), "{level}: {err}");
7290        }
7291        // Uncataloged-but-routable id: provider default only; malformed: none.
7292        assert!(build_muse_body(
7293            "never-cataloged-zz9",
7294            ReasoningLevel::Adaptive,
7295            json!([]),
7296            "",
7297            Vec::new(),
7298            None,
7299            "k"
7300        )
7301        .is_ok());
7302        assert!(build_muse_body(
7303            "Bad Id",
7304            ReasoningLevel::Adaptive,
7305            json!([]),
7306            "",
7307            Vec::new(),
7308            None,
7309            "k"
7310        )
7311        .is_err());
7312
7313        let models = crate::runtime::openai::catalog::parse_muse_catalog_models(
7314            r#"{"models":[{"id":"muse-body-probe-zz2","reasoning_effort_variants":
7315                [{"effort":"none"},{"effort":"xhigh"}]}]}"#,
7316        )
7317        .unwrap();
7318        crate::runtime::openai::catalog::capability_cache::replace_provider("muse", &models);
7319        for (level, effort) in [
7320            (ReasoningLevel::Off, "none"),
7321            (ReasoningLevel::XHigh, "xhigh"),
7322        ] {
7323            let body = build_muse_body(
7324                "muse-body-probe-zz2",
7325                level,
7326                json!([]),
7327                "",
7328                Vec::new(),
7329                None,
7330                "k",
7331            )
7332            .unwrap();
7333            assert_eq!(body["reasoning"], json!({"effort": effort}));
7334        }
7335        assert!(build_muse_body(
7336            "muse-body-probe-zz2",
7337            ReasoningLevel::High,
7338            json!([]),
7339            "",
7340            Vec::new(),
7341            None,
7342            "k"
7343        )
7344        .is_err());
7345        crate::runtime::openai::catalog::capability_cache::replace_provider("muse", &[]);
7346    }
7347
7348    #[test]
7349    fn cache_key_is_deterministic_namespaced_and_prefix_sensitive() {
7350        let first = json!({"role":"user","content":"a"});
7351        let k1 = muse_prompt_cache_key("sys", Some(&first));
7352        assert_eq!(k1, muse_prompt_cache_key("sys", Some(&first)));
7353        assert!(k1.starts_with("synaps-muse-"), "{k1}");
7354        assert_ne!(k1, codex_prompt_cache_key("sys", Some(&first)));
7355        assert_ne!(k1, muse_prompt_cache_key("sys2", Some(&first)));
7356        assert_ne!(
7357            k1,
7358            muse_prompt_cache_key("sys", Some(&json!({"role":"user","content":"b"})))
7359        );
7360    }
7361
7362    struct CaptureBroker {
7363        sse: &'static str,
7364        seen: Mutex<Vec<ProxyRequest>>,
7365    }
7366
7367    #[async_trait::async_trait]
7368    impl CredentialBroker for CaptureBroker {
7369        async fn access_token(
7370            &self,
7371            _: crate::auth::OAuthProviderId,
7372        ) -> Result<AccessToken, BrokerError> {
7373            unreachable!("the runtime never holds the Muse key")
7374        }
7375        async fn proxy(&self, _: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
7376            unreachable!()
7377        }
7378        async fn proxy_stream(
7379            &self,
7380            request: ProxyRequest,
7381        ) -> Result<ProxyByteStream, BrokerError> {
7382            self.seen.lock().unwrap().push(request);
7383            Ok(Box::pin(futures::stream::iter(vec![Ok(
7384                bytes::Bytes::from_static(self.sse.as_bytes()),
7385            )])))
7386        }
7387        async fn anthropic_usage(&self) -> Result<Value, BrokerError> {
7388            unreachable!()
7389        }
7390        async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
7391            Ok(vec![])
7392        }
7393    }
7394
7395    const SSE: &str = concat!(
7396        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hel\"}\n\n",
7397        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"lo\"}\n\n",
7398        "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",",
7399        "\"call_id\":\"call_1\",\"name\":\"bash\",\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\"}}\n\n",
7400        "data: {\"type\":\"response.completed\",\"response\":{\"usage\":",
7401        "{\"input_tokens\":12,\"output_tokens\":3}}}\n\n",
7402    );
7403
7404    #[tokio::test]
7405    async fn turn_streams_through_broker_with_system_prompt_as_instructions() {
7406        let broker = Arc::new(CaptureBroker {
7407            sse: SSE,
7408            seen: Mutex::new(Vec::new()),
7409        });
7410        let dyn_broker: Arc<dyn CredentialBroker> = broker.clone();
7411        let cfg = ProviderConfig {
7412            base_url: crate::auth::muse::API_BASE_URL.into(),
7413            model: "muse-wonder-1.0".into(),
7414            provider: "muse".into(),
7415        };
7416        let tools = vec![json!({
7417            "name":"bash","description":"run","input_schema":{"type":"object","properties":{}}
7418        })];
7419        let messages: Vec<crate::SharedMessage> =
7420            vec![Arc::new(json!({"role":"user","content":"hi muse"}))];
7421        let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
7422        let cancel = tokio_util::sync::CancellationToken::new();
7423
7424        let result = call_responses_route_inner(
7425            "muse",
7426            &cfg,
7427            &dyn_broker,
7428            &tools,
7429            &Some("You are Synaps.".into()),
7430            &messages,
7431            &tx,
7432            Some(4096),
7433            ReasoningLevel::Adaptive,
7434            &cancel,
7435            0,
7436            &crate::runtime::trace::TraceContext::disabled(),
7437            true,
7438        )
7439        .await
7440        .expect("muse turn completes");
7441
7442        // Assistant text + translated tool call come back Anthropic-shaped.
7443        let content = result["content"].as_array().unwrap();
7444        assert_eq!(content[0]["text"], "Hello");
7445        let tool_use = content
7446            .iter()
7447            .find(|block| block["type"] == "tool_use")
7448            .expect("tool call surfaced");
7449        assert_eq!(tool_use["name"], "bash");
7450        assert_eq!(tool_use["input"]["command"], "ls");
7451
7452        // Exactly one broker-proxied POST to the pinned Responses path.
7453        let seen = broker.seen.lock().unwrap();
7454        assert_eq!(seen.len(), 1);
7455        let request = &seen[0];
7456        assert_eq!(request.provider, "muse");
7457        assert_eq!(request.method, ProxyMethod::Post);
7458        assert_eq!(request.path, "/v1/responses");
7459        assert!(request.stream);
7460        let body = request.body.as_ref().unwrap();
7461        assert_eq!(body["model"], "muse-wonder-1.0");
7462        assert_eq!(body["instructions"], "You are Synaps.");
7463        assert_eq!(body["store"], false);
7464        assert_eq!(body["max_output_tokens"], 4096);
7465        assert!(body["prompt_cache_key"]
7466            .as_str()
7467            .unwrap()
7468            .starts_with("synaps-muse-"));
7469        // System prompt is NOT duplicated into the input items.
7470        let wire = body["input"].to_string();
7471        assert!(!wire.contains("You are Synaps."), "{wire}");
7472        assert!(wire.contains("hi muse"), "{wire}");
7473        assert_eq!(body["tools"][0]["name"], "bash");
7474        // Exact-bytes handoff matches the structured body.
7475        let bytes = request.body_bytes.as_ref().expect("exact wire bytes");
7476        assert_eq!(
7477            serde_json::from_slice::<Value>(bytes).unwrap(),
7478            body.clone()
7479        );
7480
7481        drop(tx);
7482        let mut streamed = String::new();
7483        while let Ok(event) = rx.try_recv() {
7484            if let StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(text)) = event {
7485                streamed.push_str(&text);
7486            }
7487        }
7488        assert_eq!(streamed, "Hello");
7489    }
7490
7491    #[test]
7492    fn dangling_preamble_trigger_cases() {
7493        // (i) Muse's own commentary label with no tool call.
7494        assert_eq!(
7495            muse_dangling_preamble(false, Some("commentary"), "Now checking prod logs."),
7496            Some("commentary_phase")
7497        );
7498        // (ii) short final text ending in ':' (the observed failures).
7499        assert_eq!(
7500            muse_dangling_preamble(
7501                false,
7502                None,
7503                "Key facts assembled. Blocking question before I build anything:\n"
7504            ),
7505            Some("colon_preamble")
7506        );
7507        assert_eq!(
7508            muse_dangling_preamble(
7509                false,
7510                Some("final_answer"),
7511                "Now checking whether prod logs show the error:"
7512            ),
7513            Some("colon_preamble")
7514        );
7515        // Never when the response acted.
7516        assert_eq!(
7517            muse_dangling_preamble(true, Some("commentary"), "Running:"),
7518            None
7519        );
7520        // Real answers: no colon, empty, or long content that ends in ':'.
7521        assert_eq!(muse_dangling_preamble(false, None, "All done."), None);
7522        assert_eq!(muse_dangling_preamble(false, None, "   "), None);
7523        assert_eq!(
7524            muse_dangling_preamble(false, Some("final_answer"), "Which format?"),
7525            None
7526        );
7527        let long = format!("{}:", "x".repeat(PREAMBLE_MAX_CHARS));
7528        assert_eq!(muse_dangling_preamble(false, None, &long), None);
7529        let edge = format!("{}:", "x".repeat(PREAMBLE_MAX_CHARS - 1));
7530        assert_eq!(
7531            muse_dangling_preamble(false, None, &edge),
7532            Some("colon_preamble")
7533        );
7534    }
7535
7536    #[test]
7537    fn preamble_nudge_fires_at_most_once_per_turn() {
7538        let flagged =
7539            json!({"role":"assistant","content":[],DANGLING_PREAMBLE_KEY:"colon_preamble"});
7540        let clean = json!({"role":"assistant","content":[]});
7541        let mut nudged = false;
7542        assert_eq!(take_preamble_nudge(&clean, &mut nudged, false, true), None);
7543        assert!(!nudged, "a clean response must not consume the one nudge");
7544        assert_eq!(
7545            take_preamble_nudge(&flagged, &mut nudged, false, true).as_deref(),
7546            Some("colon_preamble")
7547        );
7548        assert!(nudged);
7549        // Second dangling preamble in the same turn: the turn ends normally.
7550        assert_eq!(
7551            take_preamble_nudge(&flagged, &mut nudged, false, true),
7552            None
7553        );
7554        // Cancelled rounds and the disable switch never nudge.
7555        let mut fresh = false;
7556        assert_eq!(take_preamble_nudge(&flagged, &mut fresh, true, true), None);
7557        assert_eq!(
7558            take_preamble_nudge(&flagged, &mut fresh, false, false),
7559            None
7560        );
7561        assert!(!fresh);
7562    }
7563
7564    #[test]
7565    fn preamble_nudge_env_switch_parses_off_values_only() {
7566        assert!(preamble_nudge_enabled_value(None));
7567        assert!(preamble_nudge_enabled_value(Some("1")));
7568        assert!(preamble_nudge_enabled_value(Some("")));
7569        for off in ["0", "false", "OFF", " no "] {
7570            assert!(!preamble_nudge_enabled_value(Some(off)), "{off}");
7571        }
7572    }
7573
7574    async fn muse_turn(sse: &'static str, messages: Vec<Value>) -> (Value, Value) {
7575        let broker = Arc::new(CaptureBroker {
7576            sse,
7577            seen: Mutex::new(Vec::new()),
7578        });
7579        let dyn_broker: Arc<dyn CredentialBroker> = broker.clone();
7580        let cfg = ProviderConfig {
7581            base_url: crate::auth::muse::API_BASE_URL.into(),
7582            model: "muse-wonder-1.0".into(),
7583            provider: "muse".into(),
7584        };
7585        let tools = vec![json!({
7586            "name":"bash","description":"run","input_schema":{"type":"object","properties":{}}
7587        })];
7588        let messages: Vec<crate::SharedMessage> = messages.into_iter().map(Arc::new).collect();
7589        let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
7590        let result = call_responses_route_inner(
7591            "muse",
7592            &cfg,
7593            &dyn_broker,
7594            &tools,
7595            &None,
7596            &messages,
7597            &tx,
7598            None,
7599            ReasoningLevel::Adaptive,
7600            &tokio_util::sync::CancellationToken::new(),
7601            0,
7602            &crate::runtime::trace::TraceContext::disabled(),
7603            true,
7604        )
7605        .await
7606        .expect("muse turn completes");
7607        let body = broker.seen.lock().unwrap()[0].body.clone().unwrap();
7608        (result, body)
7609    }
7610
7611    const PREAMBLE_SSE: &str = concat!(
7612        "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":",
7613        "{\"type\":\"message\",\"role\":\"assistant\",\"content\":[]}}\n\n",
7614        "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,",
7615        "\"delta\":\"Blocking question before I build anything:\"}\n\n",
7616        "data: {\"type\":\"response.completed\",\"response\":{\"usage\":",
7617        "{\"input_tokens\":12,\"output_tokens\":3}}}\n\n",
7618    );
7619
7620    #[tokio::test]
7621    async fn muse_route_flags_dangling_preamble_outside_content() {
7622        let (result, _) =
7623            muse_turn(PREAMBLE_SSE, vec![json!({"role":"user","content":"go"})]).await;
7624        assert_eq!(result[DANGLING_PREAMBLE_KEY], "colon_preamble");
7625        // Stored content stays Anthropic-shaped and carries no sidecar/phase.
7626        assert_eq!(
7627            result["content"],
7628            json!([{"type":"text","text":"Blocking question before I build anything:"}])
7629        );
7630        // A response that acted is not flagged.
7631        let (result, _) = muse_turn(SSE, vec![json!({"role":"user","content":"go"})]).await;
7632        assert!(result.get(DANGLING_PREAMBLE_KEY).is_none(), "{result}");
7633    }
7634
7635    #[tokio::test]
7636    async fn muse_request_replays_assistant_text_with_phase() {
7637        let (_, body) = muse_turn(
7638            SSE,
7639            vec![
7640                json!({"role":"user","content":"go"}),
7641                json!({"role":"assistant","content":[
7642                    {"type":"text","text":"Checking:"},
7643                    {"type":"tool_use","id":"call_a","name":"bash","input":{}},
7644                ]}),
7645                json!({"role":"user","content":[
7646                    {"type":"tool_result","tool_use_id":"call_a","content":"ok"},
7647                ]}),
7648                json!({"role":"assistant","content":[{"type":"text","text":"Done."}]}),
7649                json!({"role":"user","content":"next"}),
7650            ],
7651        )
7652        .await;
7653        let input = body["input"].as_array().unwrap();
7654        assert_eq!(input[1]["phase"], "commentary");
7655        assert_eq!(input[1]["content"][0]["text"], "Checking:");
7656        assert_eq!(input[2]["type"], "function_call");
7657        assert_eq!(input[4]["phase"], "final_answer");
7658        assert_eq!(input[4]["content"][0]["type"], "output_text");
7659    }
7660}