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::Notice(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/// Send a provider streaming request, retrying transient failures.
102///
103/// Parity fix: the Anthropic path retries transient errors with backoff
104/// (`call_api_stream_inner`) but the provider routes were single-shot — one
105/// transport blip against e.g. `chatgpt.com` aborted an entire autonomous
106/// turn (incident: session 20260714-025948-3dab). Attempt *n* sleeps
107/// 1s·2^(n−1) capped at 2^`RETRY_BACKOFF_EXP_CAP`, mirroring the Anthropic
108/// budget semantics.
109///
110/// Retryable: transport-level send failures (timeout / connect / request)
111/// and 408 / 429 / 5xx responses. Deterministic client errors (other 4xx)
112/// and localhost connection refusals (Ollama/LM Studio not running — a
113/// setup problem, not a blip) fail fast. Backoff sleeps are cancel-aware.
114///
115/// Trace (Task 10A): one record per actual HTTP send — every retried
116/// failure is recorded via `attempt_failed` (status + retry class, never
117/// provider text) and the per-attempt clock restarts right before each
118/// re-send. Terminal failures emit their final record here; on success the
119/// caller finishes the attempt after consuming the stream.
120async fn send_with_retries(
121    label: &str,
122    url: &str,
123    build: impl Fn() -> reqwest::RequestBuilder,
124    cancel: &tokio_util::sync::CancellationToken,
125    max_retries: u32,
126    retries_used: &mut u32,
127    trace_attempt: &mut tr::StreamAttempt,
128) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
129    loop {
130        if cancel.is_cancelled() {
131            return Err("request canceled".into());
132        }
133        let sent = tokio::select! {
134            biased;
135            _ = cancel.cancelled() => { trace_attempt.finish_canceled(None, None); return Err("request canceled".into()); }
136            sent = build().send() => sent,
137        };
138        match sent {
139            Ok(resp) if resp.status().is_success() => {
140                trace_attempt.mark_headers();
141                return Ok(resp);
142            }
143            Ok(resp) => {
144                // A complete HTTP response was observed even when its status
145                // is non-success. Preserve that timing for retried and
146                // terminal failure records just as the success branch does.
147                trace_attempt.mark_headers();
148                let status = resp.status();
149                // Provider-assigned request id from validated headers only.
150                let trace_rid = tr::provider_request_id_from_headers(resp.headers());
151                let retryable =
152                    status.as_u16() == 408 || status.as_u16() == 429 || status.is_server_error();
153                // Privacy (spec §5.1): the response body is provider-controlled
154                // and may echo the full request (prompts, system text, tool
155                // schemas, credentials). Drop it unread — it must never be
156                // stored, surfaced, or logged at any level. `status` Display
157                // uses the canonical reason phrase, never server bytes.
158                drop(resp);
159                let code = format!("http_{}", status.as_u16());
160                if !retryable || *retries_used >= max_retries {
161                    trace_attempt.finish_failed(&code, Some(status.as_u16()), trace_rid);
162                    return Err(format!("{label} request failed: {status}").into());
163                }
164                *retries_used += 1;
165                let delay = retry_delay(*retries_used);
166                trace_attempt.attempt_failed(
167                    tr::retry_class_for_status(status.as_u16()),
168                    delay,
169                    Some(status.as_u16()),
170                    trace_rid,
171                    &code,
172                );
173                tracing::warn!(
174                    "{label} API retry {retries_used}/{max_retries} after {delay:?}: {status}"
175                );
176                tokio::select! {
177                    _ = tokio::time::sleep(delay) => {}
178                    _ = cancel.cancelled() => {
179                        trace_attempt.finish_canceled(None, None);
180                        return Err("request canceled".into());
181                    }
182                }
183                // Retry clocks reset per attempt, right before the re-send.
184                trace_attempt.restart_clock();
185            }
186            Err(e) => {
187                let localhost_refusal = e.is_connect() && url.contains("localhost");
188                let transient = e.is_timeout() || e.is_connect() || e.is_request();
189                if localhost_refusal || !transient || *retries_used >= max_retries {
190                    tracing::warn!(
191                        "{label} request failed (no retry): {}",
192                        crate::core::error::error_chain_string(&e)
193                    );
194                    trace_attempt.finish_failed("transport_error", None, None);
195                    return Err(e.into());
196                }
197                *retries_used += 1;
198                let delay = retry_delay(*retries_used);
199                trace_attempt.attempt_failed(
200                    if e.is_timeout() {
201                        crate::runtime::trace::RetryClass::Timeout
202                    } else {
203                        crate::runtime::trace::RetryClass::Other
204                    },
205                    delay,
206                    None,
207                    None,
208                    "transport_error",
209                );
210                tracing::warn!(
211                    "{label} transport retry {retries_used}/{max_retries} after {delay:?}: {}",
212                    crate::core::error::error_chain_string(&e)
213                );
214                tokio::select! {
215                    _ = tokio::time::sleep(delay) => {}
216                    _ = cancel.cancelled() => {
217                        trace_attempt.finish_canceled(None, None);
218                        return Err("request canceled".into());
219                    }
220                }
221                // Retry clocks reset per attempt, right before the re-send.
222                trace_attempt.restart_clock();
223            }
224        }
225    }
226}
227
228/// Run a single streaming request against an OpenAI-compatible endpoint.
229///
230/// Returns the final assistant response as an Anthropic-shaped content Value
231/// (`{"content": [..text.., ..tool_use..]}`) so the outer agent loop can keep
232/// using the same handling as the native Anthropic path.
233#[allow(clippy::too_many_arguments)]
234pub(crate) async fn call_oai_stream_inner(
235    cfg: &ProviderConfig,
236    broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
237    tools_schema: &[Value],
238    system_prompt: &Option<String>,
239    messages: &[crate::SharedMessage],
240    tx: &mpsc::UnboundedSender<StreamEvent>,
241    temperature: Option<f32>,
242    max_tokens: Option<u32>,
243    thinking_budget: u32,
244    reasoning_level: agent_core::reasoning::ReasoningLevel,
245    cancel: &tokio_util::sync::CancellationToken,
246    trace: &crate::runtime::trace::TraceContext,
247    exact_wire_bytes: bool,
248    suppress_stream_deltas: bool,
249    max_retries: u32,
250) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
251    let (oai_tools, name_map) = translate::tools_to_oai(tools_schema);
252    let oai_messages = translate::messages_to_oai(messages, system_prompt, &name_map);
253    let tools_opt = if oai_tools.is_empty() {
254        None
255    } else {
256        Some(oai_tools)
257    };
258
259    // Google's OpenAI-compat endpoint rejects stream_options
260    let stream_options = if cfg.base_url.contains("googleapis.com") {
261        None
262    } else {
263        Some(StreamOptions {
264            include_usage: true,
265        })
266    };
267
268    let mut body = serde_json::Map::new();
269    body.insert("model".to_string(), json!(cfg.model.clone()));
270    body.insert("messages".to_string(), serde_json::to_value(oai_messages)?);
271    body.insert("stream".to_string(), json!(true));
272    // ZDR: OpenAI's Zero Data Retention requires `store:false` on the wire.
273    // Chat Completions already defaults to false, but we assert it explicitly so the
274    // guarantee is provable in request logs (parity with the Codex Responses path,
275    // build_codex_body). Gated to api.openai.com (covers eu.api.openai.com) so we
276    // never send an unknown field to OpenAI-compatible providers that strict-validate
277    // the body (Groq/xAI/Google), mirroring the googleapis stream_options carve-out.
278    if cfg.base_url.contains("api.openai.com") {
279        body.insert("store".to_string(), json!(false));
280    }
281    if let Some(stream_options) = stream_options {
282        body.insert(
283            "stream_options".to_string(),
284            serde_json::to_value(stream_options)?,
285        );
286    }
287    if let Some(max_tokens) = max_tokens {
288        body.insert("max_tokens".to_string(), json!(max_tokens));
289    }
290    if let Some(temperature) = temperature {
291        body.insert("temperature".to_string(), json!(temperature));
292    }
293    if let Some(tools) = tools_opt {
294        body.insert("tools".to_string(), serde_json::to_value(tools)?);
295    }
296    super::reasoning::apply_openai_reasoning_params(
297        &mut body,
298        super::reasoning::provider_for_key(&cfg.provider),
299        &cfg.model,
300        thinking_budget,
301        reasoning_level,
302    );
303    let body = Value::Object(body);
304
305    tracing::debug!(provider=%cfg.provider, model=%cfg.model, "openai stream request via broker proxy");
306
307    // Serialize ONCE via the sanctioned constructor: the returned bytes are
308    // both digested for the trace and the very buffer stored on the request
309    // (`body_bytes`), which `LocalBroker` sends verbatim — the digest is
310    // never computed over a re-serialization, and `body`/`body_bytes`
311    // cannot diverge.
312    let (proxy_request, body_bytes) = crate::auth::ProxyRequest::post_json_exact(
313        cfg.provider.clone(),
314        "/chat/completions",
315        body,
316        true,
317    )?;
318    // ═══ TRACE (Task 10A): one record per actual attempt ═══════════════════
319    // A remote broker re-serializes the JSON out of process: honest kind
320    // `CloudProxy`, no wire-byte claim. Rule documented in `trace::emit`.
321    let tracer = tr::begin_openai_tracer(
322        trace,
323        &cfg.provider,
324        &cfg.model,
325        if exact_wire_bytes {
326            crate::runtime::trace::TransportKind::OpenAiChatCompletions
327        } else {
328            crate::runtime::trace::TransportKind::CloudProxy
329        },
330        &format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')),
331        exact_wire_bytes.then_some(body_bytes.as_ref()),
332        messages,
333        system_prompt.as_deref(),
334        tools_schema,
335        tr::renamed_tool_losses(&name_map),
336    )
337    .await;
338    // One-shot explicit content capture (`/trace next content`): a no-op in
339    // every context without the arm. `body_bytes` is the serialized request
340    // body this process built pre-send (exact wire bytes on the local
341    // broker; the same body a remote broker re-serializes) — body only,
342    // headers and credentials structurally never reach this seam.
343    if let Some(tracer) = &tracer {
344        tr::capture_request_content(trace, tracer.request_id(), body_bytes.as_ref(), messages);
345    }
346    let mut attempt = tr::StreamAttempt::new(tracer);
347
348    let mut stream_retry = 0u32;
349    loop {
350        if cancel.is_cancelled() {
351            return Err("request canceled".into());
352        }
353        // The broker owns the API key and executes/signs the request; this path
354        // never resolves or attaches a credential.
355        let stream = crate::runtime::api::await_or_cancel(
356            cancel,
357            broker.proxy_stream(proxy_request.clone()),
358        )
359        .await
360        .map_err(|_| "request canceled")?
361        // Privacy (spec §5.1): a broker proxy error may carry an upstream
362        // response-body snippet; redact it to status-only before surfacing.
363        .map_err(|e| {
364            format!(
365                "openai request failed: {}",
366                super::net::redact_provider_proxy_error(&e.to_string())
367            )
368        });
369        let mut stream = match stream {
370            Ok(stream) => {
371                attempt.mark_headers();
372                stream
373            }
374            Err(msg) => {
375                // Status parsed from the redacted static-prefix message only;
376                // codes are `http_<status>` or the static `broker_error`.
377                let status = tr::broker_error_status(&msg);
378                let code =
379                    status.map_or_else(|| "broker_error".to_string(), |s| format!("http_{s}"));
380                attempt.finish_failed(&code, status, None);
381                return Err(msg.into());
382            }
383        };
384
385        if !suppress_stream_deltas {
386            let _ = tx.send(StreamEvent::Llm(crate::LlmEvent::ResponseStart));
387        }
388        let mut interrupted = false;
389        let mut saw_done = false;
390        let mut decoder = StreamDecoder::new();
391        let mut accumulated_text = String::new();
392        let mut tool_use_blocks: Vec<Value> = Vec::new();
393        let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
394        let mut sink: Vec<OaiEvent> = Vec::with_capacity(4);
395        let mut trace_usage: Option<crate::runtime::trace::UsageMeta> = None;
396
397        while let Some(chunk) = tokio::select! {
398            biased;
399            _ = cancel.cancelled() => {
400                attempt.finish_canceled(None, trace_usage);
401                return Err("request canceled".into());
402            }
403            chunk = stream.next() => chunk,
404        } {
405            let chunk = match chunk {
406                Ok(chunk) => {
407                    attempt.mark_first_byte();
408                    chunk
409                }
410                Err(e) => {
411                    if !matches!(e, crate::auth::BrokerError::Transport(_)) {
412                        attempt.finish_failed("broker_error", None, None);
413                        return Err(e.into());
414                    }
415                    interrupted = !saw_done && decoder.finish_reason.is_none();
416                    break;
417                }
418            };
419            buf.extend_from_slice(&chunk);
420
421            // Scan for newline-delimited SSE lines (SIMD-accelerated via memchr)
422            while let Some(nl) = memchr::memchr(b'\n', &buf) {
423                let line_bytes = buf.split_to(nl + 1); // O(1) — ref-counted split
424                let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
425
426                sink.clear();
427                decoder.push_line(line, &mut sink);
428                saw_done |= sink.iter().any(|e| matches!(e, OaiEvent::Done));
429                capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
430                handle_events(
431                    &sink,
432                    tx,
433                    &mut accumulated_text,
434                    &mut tool_use_blocks,
435                    &name_map,
436                    suppress_stream_deltas,
437                );
438            }
439        }
440
441        // Flush any remaining buffered line + final Done
442        if !interrupted && !buf.is_empty() {
443            let line = std::str::from_utf8(&buf).unwrap_or("");
444            sink.clear();
445            decoder.push_line(line, &mut sink);
446            saw_done |= sink.iter().any(|e| matches!(e, OaiEvent::Done));
447            capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
448            handle_events(
449                &sink,
450                tx,
451                &mut accumulated_text,
452                &mut tool_use_blocks,
453                &name_map,
454                suppress_stream_deltas,
455            );
456        }
457        if interrupted || (!saw_done && decoder.finish_reason.is_none()) {
458            if stream_retry >= max_retries {
459                attempt.finish_failed("stream_error", None, None);
460                return Err(STREAM_INTERRUPTED.into());
461            }
462            stream_retry += 1;
463            attempt.attempt_failed(
464                crate::runtime::trace::RetryClass::Network,
465                retry_delay(stream_retry),
466                None,
467                None,
468                "stream_error",
469            );
470            wait_stream_retry(
471                tx,
472                cancel,
473                stream_retry,
474                max_retries,
475                suppress_stream_deltas,
476            )
477            .await
478            .inspect_err(|_| {
479                attempt.finish_canceled(None, None);
480            })?;
481            attempt.restart_clock();
482            continue;
483        }
484        sink.clear();
485        decoder.finish(&mut sink);
486        capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
487        handle_events(
488            &sink,
489            tx,
490            &mut accumulated_text,
491            &mut tool_use_blocks,
492            &name_map,
493            suppress_stream_deltas,
494        );
495
496        // Normalized stop reason: only when a finish_reason was actually
497        // observed on the wire — never inferred.
498        let stop_reason = decoder
499            .finish_reason
500            .as_deref()
501            .map(tr::stop_reason_from_finish_reason);
502        // Broker paths never observe the upstream HTTP status: honest `None`.
503        attempt.finish_success(None, None, stop_reason, trace_usage);
504
505        // Build Anthropic-shaped final response
506        let mut content: Vec<Value> = Vec::new();
507        if !accumulated_text.is_empty() {
508            content.push(json!({"type": "text", "text": accumulated_text}));
509        }
510        content.extend(tool_use_blocks);
511
512        return Ok(json!({
513            "role": "assistant",
514            "content": content,
515        }));
516    }
517}
518
519#[allow(clippy::too_many_arguments)]
520pub(crate) async fn call_codex_stream_inner(
521    cfg: &ProviderConfig,
522    client: &reqwest::Client,
523    broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
524    tools_schema: &[Value],
525    system_prompt: &Option<String>,
526    messages: &[crate::SharedMessage],
527    tx: &mpsc::UnboundedSender<StreamEvent>,
528    temperature: Option<f32>,
529    max_tokens: Option<u32>,
530    reasoning_level: agent_core::reasoning::ReasoningLevel,
531    codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
532    cancel: &tokio_util::sync::CancellationToken,
533    max_retries: u32,
534    trace: &crate::runtime::trace::TraceContext,
535) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
536    // Build the exact provider-qualified plan before any credential or network
537    // access. Logical Ultra is lowered here, never in the generic level enum.
538    use crate::runtime::openai::catalog::plan_codex_execution;
539    let qualified_model = format!("{}/{}", cfg.provider, cfg.model);
540    let plan =
541        match plan_codex_execution(&qualified_model, reasoning_level, codex_request_role, None) {
542            Ok(plan) => plan,
543            Err(error) => {
544                tracing::debug!(
545                    event = "codex_mode_plan",
546                    qualified_model = %qualified_model,
547                    requested_level = %reasoning_level,
548                    runtime_role = codex_request_role.as_str(),
549                    decision = "deny",
550                    deny_code = error.code().as_str(),
551                    network_attempted = false,
552                    "Codex execution plan denied"
553                );
554                return Err(Box::new(error));
555            }
556        };
557    if plan.automatic_delegation() && !codex_has_required_delegation_tools(tools_schema) {
558        tracing::debug!(
559            event = "codex_mode_plan",
560            qualified_model = %qualified_model,
561            requested_level = %reasoning_level,
562            runtime_role = codex_request_role.as_str(),
563            decision = "deny",
564            deny_code = "ultra_requires_subagent_tools",
565            network_attempted = false,
566            "Codex execution plan denied"
567        );
568        return Err(
569            "Ultra requires subagent_start, subagent_status, and subagent_collect tools".into(),
570        );
571    }
572    tracing::debug!(
573        event = "codex_mode_plan",
574        qualified_model = %plan.qualified_model,
575        requested_level = %plan.selected_level,
576        execution_mode = plan.mode.as_str(),
577        wire_effort = plan.wire_effort_label(),
578        capability_source = plan.capability_source.map_or("none", |source| source.as_str()),
579        multi_agent_version = plan.multi_agent_version_label(),
580        runtime_role = plan.request_role.as_str(),
581        multi_agent_mode = plan.multi_agent_mode_label(),
582        automatic_delegation = plan.automatic_delegation(),
583        decision = "allow",
584        network_attempted = false,
585        "Codex execution plan allowed"
586    );
587    // Every Codex credential, local or remote, crosses the broker boundary:
588    // the broker vends an access token + expiry only (refresh tokens are
589    // broker-owned), and this path never opens auth.json.
590    let access = crate::runtime::api::await_or_cancel(
591        cancel,
592        broker.access_token(crate::auth::OAuthProviderId::OpenAiCodex),
593    )
594    .await
595    .map_err(|_| "request canceled")?
596    .map_err(|e| e.to_string())?
597    .token;
598    // Account id is provider-owned metadata carried inside the Codex JWT.
599    let account_id = crate::auth::extract_codex_account_id(&access)
600        .ok_or("Failed to extract ChatGPT account id from Codex token — run `synaps login --provider openai-codex`")?;
601
602    let (tools, name_map) = translate::tools_to_responses(tools_schema);
603    // Codex already receives the effective system prompt in `instructions`.
604    // Do not also prepend it to `input`: that doubles prompt tokens and repeats
605    // context/checkpoint guidance. Pass history to the translator unchanged;
606    // only the synthetic prepend is suppressed. Other providers keep their placement.
607    let oai_messages = translate::messages_to_oai(messages, &None, &name_map);
608
609    // Use the shared pure helper so production and unit tests exercise identical
610    // body construction — no duplication between call_codex_stream_inner and tests.
611    let instructions = codex_instructions(system_prompt);
612    let mut input_items = codex_input_messages(oai_messages);
613    // Key on the pre-insertion head: the mode item varies with the execution
614    // plan (level/role switches) and must not churn the routing key.
615    let prompt_cache_key = codex_prompt_cache_key(&instructions, input_items.first());
616    insert_codex_multi_agent_mode(&mut input_items, &plan);
617    let input = serde_json::Value::Array(input_items);
618    let body = build_codex_body(
619        &cfg.model,
620        &plan,
621        input,
622        instructions,
623        tools,
624        temperature,
625        max_tokens,
626        &prompt_cache_key,
627    );
628
629    let url = format!(
630        "{}/codex/responses",
631        cfg.base_url
632            .trim_end_matches('/')
633            .trim_end_matches("/codex")
634    );
635    tracing::debug!(url=%url, model=%cfg.model, "codex stream request");
636
637    // Serialize ONCE. These exact bytes are digested for the trace AND are
638    // the request body of every attempt — retries resend the identical
639    // buffer, so one wire digest is honest for all attempt records.
640    let body_bytes = bytes::Bytes::from(serde_json::to_vec(&body)?);
641    // ═══ TRACE (Task 10A): one record per actual attempt ═══════════════════
642    let tracer = tr::begin_openai_tracer(
643        trace,
644        &cfg.provider,
645        &cfg.model,
646        crate::runtime::trace::TransportKind::OpenAiResponses,
647        &url,
648        Some(body_bytes.as_ref()),
649        messages,
650        system_prompt.as_deref(),
651        tools_schema,
652        tr::renamed_tool_losses(&name_map),
653    )
654    .await;
655    // One-shot explicit content capture (`/trace next content`): a no-op in
656    // every context without the arm. `body_bytes` is the serialized request
657    // body this process built pre-send (exact wire bytes on the local
658    // broker; the same body a remote broker re-serializes) — body only,
659    // headers and credentials structurally never reach this seam.
660    if let Some(tracer) = &tracer {
661        tr::capture_request_content(trace, tracer.request_id(), body_bytes.as_ref(), messages);
662    }
663    let mut attempt = tr::StreamAttempt::new(tracer);
664    let mut stream_retry = 0u32;
665
666    loop {
667        if cancel.is_cancelled() {
668            return Err("request canceled".into());
669        }
670        let resp = send_with_retries(
671            "codex",
672            &url,
673            || {
674                client
675                    .post(&url)
676                    .bearer_auth(&access)
677                    .header("chatgpt-account-id", account_id.as_str())
678                    .header("originator", "synaps")
679                    .header("OpenAI-Beta", "responses=experimental")
680                    // Cache affinity: the ChatGPT backend routes prompt-cache
681                    // lookups by the `session-id` header (Codex CLI sends its
682                    // conversation id as both headers and as
683                    // `prompt_cache_key`). Without it, every request reported
684                    // cached_tokens = 0, even with a byte-identical prefix.
685                    .header("session-id", prompt_cache_key.as_str())
686                    .header("thread-id", prompt_cache_key.as_str())
687                    .header("content-type", "application/json")
688                    .header("accept", "text/event-stream")
689                    .body(body_bytes.clone())
690            },
691            cancel,
692            max_retries,
693            &mut stream_retry,
694            &mut attempt,
695        )
696        .await?;
697        // Direct HTTP: upstream status and provider request id are observed.
698        let http_status = Some(resp.status().as_u16());
699        let trace_rid = tr::provider_request_id_from_headers(resp.headers());
700
701        let _ = tx.send(StreamEvent::Llm(crate::LlmEvent::ResponseStart));
702        let mut interrupted = false;
703        let mut accumulated_text = String::new();
704        let mut parser = CodexSseDecoder::default();
705        let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
706        let mut stream = resp.bytes_stream();
707
708        while let Some(chunk) = tokio::select! {
709            biased;
710            _ = cancel.cancelled() => {
711                attempt.finish_canceled(http_status, parser.trace_usage());
712                return Err("request canceled".into());
713            }
714            chunk = stream.next() => chunk,
715        } {
716            let chunk = match chunk {
717                Ok(chunk) => {
718                    attempt.mark_first_byte();
719                    chunk
720                }
721                Err(_) => {
722                    interrupted = !parser.terminal_success && parser.terminal_failure.is_none();
723                    break;
724                }
725            };
726            buf.extend_from_slice(&chunk);
727            while let Some(nl) = memchr::memchr(b'\n', &buf) {
728                let line_bytes = buf.split_to(nl + 1);
729                let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
730                parser.push_line(line, tx, &mut accumulated_text);
731            }
732            if parser.saw_model_event {
733                attempt.mark_first_model_event();
734            }
735        }
736        if !interrupted && !buf.is_empty() {
737            let line = std::str::from_utf8(&buf).unwrap_or("");
738            parser.push_line(line, tx, &mut accumulated_text);
739        }
740        if interrupted || (!parser.terminal_success && parser.terminal_failure.is_none()) {
741            if stream_retry >= max_retries {
742                attempt.finish_failed("stream_error", http_status, trace_rid);
743                return Err(format!("Codex{RESPONSES_MISSING_TERMINAL_SUFFIX}").into());
744            }
745            stream_retry += 1;
746            attempt.attempt_failed(
747                crate::runtime::trace::RetryClass::Network,
748                retry_delay(stream_retry),
749                http_status,
750                trace_rid,
751                "stream_error",
752            );
753            wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
754                .await
755                .inspect_err(|_| {
756                    attempt.finish_canceled(None, None);
757                })?;
758            attempt.restart_clock();
759            continue;
760        }
761        parser.finish();
762        // Trailing-buffer flush and finish() can surface the first (or only)
763        // model event — e.g. a payload dispatched by the tail line. Mark it so
764        // first_model_event_ms is honest even for tail-only streams.
765        if parser.saw_model_event {
766            attempt.mark_first_model_event();
767        }
768        if let Err(failure) = parser.terminal_result() {
769            if failure.code == "responses_empty" && stream_retry < max_retries {
770                stream_retry += 1;
771                let delay = retry_delay(stream_retry);
772                attempt.attempt_failed(
773                    crate::runtime::trace::RetryClass::Other,
774                    delay,
775                    http_status,
776                    trace_rid,
777                    failure.code,
778                );
779                tracing::warn!(
780                    "codex stream retry {stream_retry}/{max_retries} after {delay:?}: completed without usable output"
781                );
782                wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
783                    .await
784                    .inspect_err(|_| {
785                        attempt.finish_canceled(None, None);
786                    })?;
787                attempt.restart_clock();
788                continue;
789            }
790            attempt.finish_failed(failure.code, http_status, trace_rid);
791            return Err(failure.message.into());
792        }
793        attempt.finish_success(http_status, trace_rid, None, parser.trace_usage());
794
795        let mut content: Vec<Value> = Vec::new();
796        if !accumulated_text.is_empty() {
797            content.push(json!({"type": "text", "text": accumulated_text}));
798        }
799        content.extend(translate::tool_calls_to_content_blocks(
800            &parser.completed_tools,
801            &name_map,
802        ));
803
804        return Ok(json!({
805            "role": "assistant",
806            "content": content,
807        }));
808    }
809}
810
811/// Stable prompt-cache routing key for this conversation.
812///
813/// Sent as `prompt_cache_key` in the body AND as the `session-id` /
814/// `thread-id` headers: the ChatGPT backend derives cache affinity from the
815/// `session-id` header (upstream codex-rs sends its conversation UUID in all
816/// three places). The body key alone yielded zero cache hits. Synaps
817/// doesn't thread a session id down to the transport, so derive a
818/// deterministic key from the stable head of the prompt: the instructions
819/// plus the first input item. Identical heads hash to the same key — which
820/// is exactly right, since those requests share the very prefix the cache
821/// stores — and a /compact rewrite changes the key together with the prefix
822/// it invalidates. Without this key, cache lookups are at the mercy of
823/// load-balancer routing and long conversations re-pay their full prefix.
824pub(crate) fn codex_prompt_cache_key(instructions: &str, first_item: Option<&Value>) -> String {
825    use sha2::{Digest, Sha256};
826    let mut hasher = Sha256::new();
827    hasher.update(instructions.as_bytes());
828    if let Some(item) = first_item {
829        hasher.update(item.to_string().as_bytes());
830    }
831    format!("synaps-{:x}", hasher.finalize())
832}
833
834/// Pure body construction for Codex Responses-API requests.
835///
836/// Separated from the async function so it can be unit-tested without
837/// any credential access or network I/O.
838///
839/// # Arguments
840/// - `model`: the Codex model id (already validated by the caller)
841/// - `plan`: reasoning plan (must already be validated against model capability)
842/// - `input`: pre-built input items (from `codex_input_messages`)
843/// - `instructions`: pre-built instructions string (from `codex_instructions`)
844/// - `tools`: pre-built tool array
845/// - `temperature`: optional temperature override
846/// - `max_tokens`: optional max_output_tokens override
847/// - `prompt_cache_key`: pre-computed routing key; computed on the
848///   pre-insertion input head by the caller, so it cannot be derived here
849#[allow(clippy::too_many_arguments)] // pure builder mirroring the wire body; grouping would obscure the 1:1 field mapping
850pub(crate) fn build_codex_body(
851    model: &str,
852    plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
853    input: serde_json::Value,
854    instructions: String,
855    tools: Vec<serde_json::Value>,
856    temperature: Option<f32>,
857    max_tokens: Option<u32>,
858    prompt_cache_key: &str,
859) -> serde_json::Value {
860    let mut body = json!({
861        "model": model,
862        "store": false,
863        "stream": true,
864        "instructions": instructions,
865        "input": input,
866        "tool_choice": "auto",
867        "parallel_tool_calls": true,
868        "include": ["reasoning.encrypted_content"],
869        "text": { "verbosity": "medium" },
870        "prompt_cache_key": prompt_cache_key,
871    });
872    if let Some(effort) = plan.wire_effort {
873        body["reasoning"] = json!({ "effort": effort.as_str() });
874    }
875    if !tools.is_empty() {
876        body["tools"] = serde_json::Value::Array(tools);
877    }
878    if let Some(temp) = temperature {
879        body["temperature"] = json!(temp);
880    }
881    if let Some(max) = max_tokens {
882        body["max_output_tokens"] = json!(max);
883    }
884    body
885}
886
887const CODEX_MULTI_AGENT_MODE_OPEN: &str = "<multi_agent_mode>";
888const CODEX_MULTI_AGENT_MODE_CLOSE: &str = "</multi_agent_mode>";
889const 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.";
890const 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.";
891
892fn codex_multi_agent_mode_item(
893    plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
894) -> Option<Value> {
895    use crate::runtime::openai::catalog::CodexMultiAgentMode;
896    let body = match plan.multi_agent_mode? {
897        CodexMultiAgentMode::ExplicitRequestOnly => CODEX_EXPLICIT_REQUEST_ONLY_TEXT,
898        CodexMultiAgentMode::Proactive => CODEX_PROACTIVE_MULTI_AGENT_TEXT,
899    };
900    Some(json!({
901        "type": "message",
902        "role": "developer",
903        "content": [{
904            "type": "input_text",
905            "text": format!("{CODEX_MULTI_AGENT_MODE_OPEN}{body}{CODEX_MULTI_AGENT_MODE_CLOSE}"),
906        }],
907    }))
908}
909
910fn is_codex_multi_agent_mode_item(item: &Value) -> bool {
911    item.pointer("/content/0/text")
912        .and_then(Value::as_str)
913        .is_some_and(|text| {
914            text.trim_start().starts_with(CODEX_MULTI_AGENT_MODE_OPEN)
915                && text.trim_end().ends_with(CODEX_MULTI_AGENT_MODE_CLOSE)
916        })
917}
918
919fn insert_codex_multi_agent_mode(
920    input: &mut Vec<Value>,
921    plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
922) {
923    input.retain(|item| !is_codex_multi_agent_mode_item(item));
924    let Some(item) = codex_multi_agent_mode_item(plan) else {
925        return;
926    };
927    // Prefix-cache stability: the mode item goes at a FIXED position (head of
928    // input), never relative to the latest user message. The previous
929    // placement (before the last user item) moved every user turn, so the
930    // prompt prefix diverged at the prior turn's user message and the entire
931    // preceding agentic turn — often the bulk of the context — was re-billed
932    // uncached (incident: 2026-07-16 codex cache investigation). At index 0
933    // the item is byte-stable across turns; it only changes when the
934    // execution plan's mode changes, which legitimately invalidates the
935    // prefix once.
936    input.insert(0, item);
937}
938
939fn codex_has_required_delegation_tools(tools_schema: &[Value]) -> bool {
940    ["subagent_start", "subagent_status", "subagent_collect"]
941        .into_iter()
942        .all(|required| {
943            tools_schema.iter().any(|tool| {
944                tool.get("name").and_then(Value::as_str) == Some(required)
945                    || tool.pointer("/function/name").and_then(Value::as_str) == Some(required)
946            })
947        })
948}
949
950const 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]";
951
952fn codex_instructions(system_prompt: &Option<String>) -> String {
953    let mut instructions = system_prompt.clone().unwrap_or_default();
954    if instructions.contains("[Synaps autonomous harness policy]") {
955        return instructions;
956    }
957    instructions.push_str(CODEX_AUTONOMOUS_LOOP_POLICY);
958    instructions
959}
960
961fn codex_input_messages(messages: Vec<ChatMessage>) -> Vec<Value> {
962    let mut out = Vec::new();
963    for msg in messages {
964        if msg.role == "tool" {
965            // Skip tool results with no call_id — sending an empty call_id
966            // to the Codex API would cause a 400 with a confusing error.
967            if let Some(call_id) = msg.tool_call_id {
968                out.push(json!({
969                    "type": "function_call_output",
970                    "call_id": call_id,
971                    "output": msg.content.unwrap_or_default(),
972                }));
973            }
974            continue;
975        }
976        // A mixed assistant message carries BOTH text and calls. Emit its
977        // text first rather than continuing after the calls and losing it.
978        // Text-only messages retain the existing string wire representation.
979        if msg.content_parts.is_some() || msg.content.is_some() || msg.tool_calls.is_none() {
980            let content = if let Some(parts) = msg.content_parts {
981                Value::Array(parts.into_iter().map(|part| match part {
982                    ChatContentPart::Text { text } => json!({
983                        "type": if msg.role == "assistant" { "output_text" } else { "input_text" },
984                        "text": text,
985                    }),
986                    ChatContentPart::ImageUrl { image_url } => {
987                        let mut part = json!({"type": "input_image", "image_url": image_url.url});
988                        if let Some(detail) = image_url.detail {
989                            part["detail"] = json!(detail);
990                        }
991                        part
992                    }
993                    ChatContentPart::File { file } => json!({
994                        "type": "input_file",
995                        "filename": file.filename,
996                        "file_data": file.file_data,
997                    }),
998                }).collect())
999            } else {
1000                Value::String(msg.content.unwrap_or_default())
1001            };
1002            out.push(json!({"role": msg.role, "content": content}));
1003        }
1004        if let Some(tool_calls) = msg.tool_calls {
1005            for call in tool_calls {
1006                // The Responses API rejects `id` values that are not the
1007                // original `fc_…` output-item id. We only carry the
1008                // `call_…` correlation id today (see types::ToolCall),
1009                // so emit `id` *only* when the value actually starts
1010                // with `fc`. `call_id` is sufficient on its own to
1011                // correlate the eventual `function_call_output`.
1012                let mut item = json!({
1013                    "type": "function_call",
1014                    "call_id": call.id,
1015                    "name": call.function.name,
1016                    "arguments": call.function.arguments,
1017                });
1018                if call.id.starts_with("fc") {
1019                    item["id"] = Value::from(call.id.clone());
1020                }
1021                out.push(item);
1022            }
1023        }
1024    }
1025    out
1026}
1027
1028#[derive(Clone, Debug, Eq, PartialEq)]
1029struct ResponsesStreamFailure {
1030    code: &'static str,
1031    /// User-facing text built from OUR static templates plus the provider
1032    /// display label — never provider free-text.
1033    message: String,
1034}
1035
1036impl ResponsesStreamFailure {
1037    /// Terminal failures that are transient by nature and safe to re-send
1038    /// with identical request bytes: provider capacity exhaustion, an empty
1039    /// completion, or a stream that ended without any terminal event.
1040    fn is_retryable(&self) -> bool {
1041        matches!(
1042            self.code,
1043            "responses_capacity" | "responses_empty" | "responses_missing_terminal"
1044        )
1045    }
1046}
1047
1048/// Sanitized provider error codes that mean "try again later" on the
1049/// Responses wire. Classification-only: the code is matched against this
1050/// fixed set, never echoed beyond the local sanitized log line.
1051const CAPACITY_ERROR_CODES: &[&str] = &[
1052    "rate_limit_exceeded",
1053    "server_overloaded",
1054    "overloaded",
1055    "capacity",
1056    "server_error",
1057];
1058
1059/// Fixed substrings that mark a provider free-text error message as a
1060/// transient capacity condition (xAI: "The model is currently at capacity
1061/// due to high demand. Please try again in a few minutes…"). The message is
1062/// consulted ONLY to produce a boolean — it is never logged, stored, or
1063/// surfaced (privacy invariant: provider bodies can echo request content).
1064const CAPACITY_MESSAGE_PATTERNS: &[&str] = &[
1065    "at capacity",
1066    "high demand",
1067    "try again in a few minutes",
1068    "overloaded",
1069];
1070
1071/// Classification-only capacity check. `error_code` is already sanitized;
1072/// `message` is raw provider text used solely as a boolean signal.
1073fn is_capacity_failure(error_code: &str, message: Option<&str>) -> bool {
1074    if CAPACITY_ERROR_CODES.contains(&error_code) {
1075        return true;
1076    }
1077    message.is_some_and(|m| {
1078        let lower = m.to_ascii_lowercase();
1079        CAPACITY_MESSAGE_PATTERNS.iter().any(|p| lower.contains(p))
1080    })
1081}
1082
1083struct CodexSseDecoder {
1084    /// Provider display label used in user-facing terminal messages
1085    /// ("Codex", "xAI"). Static, ours — never provider-supplied.
1086    provider_label: &'static str,
1087    buffer: String,
1088    active_tools: Vec<CodexToolAccumulator>,
1089    completed_tools: Vec<ToolCall>,
1090    /// Trace (Task 10A): set once the decoder parses any model event —
1091    /// feeds the first-model-event timing bucket.
1092    saw_model_event: bool,
1093    /// A terminal Responses event was observed. Failure/incomplete events
1094    /// take precedence over completion and are surfaced after the stream is
1095    /// drained; provider-controlled text is never retained.
1096    terminal_success: bool,
1097    terminal_failure: Option<ResponsesStreamFailure>,
1098    emitted_output: bool,
1099    /// Trace (Task 10A): provider-reported usage from `response.completed`
1100    /// (input including cached, output, cached slice). `None` until observed.
1101    observed_usage: Option<(u64, u64, u64)>,
1102}
1103
1104impl Default for CodexSseDecoder {
1105    fn default() -> Self {
1106        Self::for_provider("Codex")
1107    }
1108}
1109
1110#[derive(Default)]
1111struct CodexToolAccumulator {
1112    id: String,
1113    name: String,
1114    arguments: String,
1115    started: bool,
1116}
1117
1118/// Parse a function-call arguments string into a JSON `Value`, mirroring
1119/// `runtime::api::parse_tool_input` so the chat UI's `LlmEvent::ToolUse`
1120/// handling sees the same shape regardless of provider.
1121///
1122/// Empty / whitespace input becomes `{}`. Invalid JSON becomes
1123/// `{"__parse_error": "..."}` — the agent loop already understands that
1124/// shape and converts it into an `is_error: true` tool_result.
1125fn parse_tool_arguments(raw: &str) -> Value {
1126    if raw.trim().is_empty() {
1127        return json!({});
1128    }
1129    match serde_json::from_str(raw) {
1130        Ok(v) => v,
1131        Err(e) => json!({ "__parse_error": format!("invalid tool input JSON: {}", e) }),
1132    }
1133}
1134
1135/// Reduce a provider-supplied error identifier (`error.type`, `error.code`,
1136/// `incomplete_details.reason`) to a token safe for local logs. Lowercase
1137/// snake-case identifiers (`server_error`, `rate_limit_exceeded`,
1138/// `context_length_exceeded`, `max_output_tokens`) pass through verbatim —
1139/// that shape cannot carry prose or credential material. Anything with
1140/// uppercase, whitespace, or symbols outside the identifier charset — or
1141/// longer than 64 bytes — is replaced so free-text (which can echo request
1142/// content) and secret-shaped tokens can never leak into the log stream.
1143fn sanitize_error_identifier(raw: Option<&str>) -> &str {
1144    match raw {
1145        None => "absent",
1146        Some(s) => {
1147            let identifier_shaped = !s.is_empty()
1148                && s.len() <= 64
1149                && s.bytes()
1150                    .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.');
1151            if identifier_shaped
1152                && s.bytes()
1153                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
1154            {
1155                // Lowercase snake_case enum value: safe to record verbatim.
1156                s
1157            } else if identifier_shaped {
1158                // Mixed-case / dashed identifiers can be secret-shaped
1159                // (API keys, tokens): report shape only.
1160                "unlisted_identifier"
1161            } else {
1162                "unsafe_or_freeform"
1163            }
1164        }
1165    }
1166}
1167
1168/// Static message suffixes for Responses terminal failures. The provider
1169/// display label is prefixed at construction; `net.rs` classifies on these
1170/// label-independent suffixes so every label maps to `ApiStatus`.
1171pub(crate) const RESPONSES_AUTH_SUFFIX: &str =
1172    " authentication rejected in stream. Sign in again or switch to an available account.";
1173pub(crate) const RESPONSES_QUOTA_SUFFIX: &str =
1174    " usage quota exhausted in stream. Add credit, wait for reset, or switch models.";
1175pub(crate) const RESPONSES_FAILED_SUFFIX: &str =
1176    " response failed in stream. Provider error details withheld because they can echo request content.";
1177pub(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.";
1178pub(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.";
1179pub(crate) const RESPONSES_INCOMPLETE_SUFFIX: &str =
1180    " response was incomplete. Retry the request or reduce the requested output/context size.";
1181pub(crate) const RESPONSES_EMPTY_SUFFIX: &str =
1182    " completed without text or tool output. Retry the request.";
1183pub(crate) const RESPONSES_MISSING_TERMINAL_SUFFIX: &str =
1184    " response stream ended without a terminal event. Retry the request.";
1185
1186/// Map a Responses terminal failure to a user-facing message. Only reacts to
1187/// the already-sanitized enum identifiers (`error.type` / `error.code`) and
1188/// the boolean capacity classification — never to provider free-text — so no
1189/// request content can leak. Known, user-actionable conditions get a specific
1190/// remedy; everything else keeps the neutral, details-withheld default.
1191fn user_message_for_terminal_failure(
1192    label: &str,
1193    error_kind: &str,
1194    error_code: &str,
1195    capacity: bool,
1196) -> String {
1197    // Context-window overflow is deterministic and user-fixable: the turn
1198    // cannot succeed until the conversation is smaller.
1199    if error_code == "context_length_exceeded" || error_kind == "context_length_exceeded" {
1200        return format!("{label}{RESPONSES_CONTEXT_SUFFIX}");
1201    }
1202    // Both explicit identifiers must agree on a retry class. Unknown or policy
1203    // classes (even with a capacity-looking message) are never retry signals.
1204    let classify = |value: &str| match value {
1205        "absent" | "error" => None,
1206        "invalid_api_key" | "authentication_error" | "invalid_authentication_error" => Some("auth"),
1207        "insufficient_quota" | "quota_exceeded" | "billing_error" => Some("quota"),
1208        "rate_limit_exceeded"
1209        | "rate_limit_error"
1210        | "server_overloaded"
1211        | "overloaded"
1212        | "capacity"
1213        | "server_error"
1214        | "api_error"
1215        | "overloaded_error" => Some("capacity"),
1216        _ => Some("blocked"),
1217    };
1218    let (kind, code) = (classify(error_kind), classify(error_code));
1219    if kind == Some("blocked")
1220        || code == Some("blocked")
1221        || (kind.is_some() && code.is_some() && kind != code)
1222    {
1223        return format!("{label}{RESPONSES_FAILED_SUFFIX}");
1224    }
1225    match code.or(kind) {
1226        Some("auth") => return format!("{label}{RESPONSES_AUTH_SUFFIX}"),
1227        Some("quota") => return format!("{label}{RESPONSES_QUOTA_SUFFIX}"),
1228        Some("capacity") => return format!("{label}{RESPONSES_CAPACITY_SUFFIX}"),
1229        // Preserve legacy flat xAI capacity handling only with no explicit
1230        // failure identifier to contradict it. No new free-text classifiers.
1231        None if capacity => return format!("{label}{RESPONSES_CAPACITY_SUFFIX}"),
1232        _ => {}
1233    }
1234    format!("{label}{RESPONSES_FAILED_SUFFIX}")
1235}
1236
1237impl CodexSseDecoder {
1238    /// Decoder whose user-facing messages name `label` as the provider.
1239    fn for_provider(label: &'static str) -> Self {
1240        Self {
1241            provider_label: label,
1242            buffer: String::new(),
1243            active_tools: Vec::new(),
1244            completed_tools: Vec::new(),
1245            saw_model_event: false,
1246            terminal_success: false,
1247            terminal_failure: None,
1248            emitted_output: false,
1249            observed_usage: None,
1250        }
1251    }
1252
1253    /// True once anything was streamed to the UI for this attempt (text
1254    /// deltas, tool starts, or completed tools). A retry after this point
1255    /// would duplicate output, so callers must treat the failure as terminal.
1256    fn emitted_any_output(&self) -> bool {
1257        self.emitted_output
1258            || !self.completed_tools.is_empty()
1259            || self.active_tools.iter().any(|tool| tool.started)
1260    }
1261
1262    fn push_line(
1263        &mut self,
1264        line: &str,
1265        tx: &mpsc::UnboundedSender<StreamEvent>,
1266        text_acc: &mut String,
1267    ) {
1268        let line = line.trim_end_matches('\r');
1269        if line.is_empty() {
1270            if !self.buffer.is_empty() {
1271                let payload = std::mem::take(&mut self.buffer);
1272                self.push_payload(&payload, tx, text_acc);
1273            }
1274            return;
1275        }
1276        let Some(data) = line.strip_prefix("data:").map(str::trim_start) else {
1277            return;
1278        };
1279        if data == "[DONE]" {
1280            self.terminal_success = true;
1281            self.finish();
1282            return;
1283        }
1284        self.buffer.push_str(data);
1285    }
1286
1287    fn push_payload(
1288        &mut self,
1289        payload: &str,
1290        tx: &mpsc::UnboundedSender<StreamEvent>,
1291        text_acc: &mut String,
1292    ) {
1293        let Ok(event) = serde_json::from_str::<Value>(payload) else {
1294            tracing::debug!(
1295                payload_bytes = payload.len(),
1296                "discarding malformed Responses SSE payload"
1297            );
1298            return;
1299        };
1300        let event_type = event
1301            .get("type")
1302            .and_then(Value::as_str)
1303            .unwrap_or_default();
1304        if matches!(
1305            event_type,
1306            "response.output_text.delta"
1307                | "response.output_item.added"
1308                | "response.function_call_arguments.delta"
1309                | "response.output_item.done"
1310                | "response.completed"
1311                | "response.done"
1312                | "response.failed"
1313                | "response.incomplete"
1314                | "error"
1315        ) {
1316            self.saw_model_event = true;
1317        }
1318        match event_type {
1319            "response.output_text.delta" => {
1320                if let Some(delta) = event.get("delta").and_then(Value::as_str) {
1321                    if !delta.is_empty() {
1322                        self.emitted_output = true;
1323                    }
1324                    text_acc.push_str(delta);
1325                    let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(
1326                        delta.to_string(),
1327                    )));
1328                }
1329            }
1330            "response.output_item.added" => {
1331                if let Some(item) = event.get("item") {
1332                    let idx = event
1333                        .get("output_index")
1334                        .and_then(Value::as_u64)
1335                        .unwrap_or(0) as usize;
1336                    self.add_tool_from_item(idx, item, tx);
1337                }
1338            }
1339            "response.function_call_arguments.delta" => {
1340                let idx = event
1341                    .get("output_index")
1342                    .and_then(Value::as_u64)
1343                    .unwrap_or(0) as usize;
1344                let delta = event
1345                    .get("delta")
1346                    .and_then(Value::as_str)
1347                    .unwrap_or_default();
1348                if !delta.is_empty() {
1349                    let tool = self.ensure_tool(idx);
1350                    tool.arguments.push_str(delta);
1351                    let tool_id = tool.id.clone();
1352                    let _ = tx.send(StreamEvent::Llm(
1353                        crate::runtime::types::LlmEvent::ToolUseDelta {
1354                            tool_id,
1355                            delta: delta.to_string(),
1356                        },
1357                    ));
1358                }
1359            }
1360            "response.output_item.done" => {
1361                if let Some(item) = event.get("item") {
1362                    let idx = event
1363                        .get("output_index")
1364                        .and_then(Value::as_u64)
1365                        .unwrap_or(0) as usize;
1366                    self.complete_tool_from_item(idx, item, tx);
1367                }
1368            }
1369            "response.completed" | "response.done" => {
1370                self.push_usage(&event, tx);
1371                self.terminal_success = true;
1372                self.finish();
1373            }
1374            "response.failed" | "error" => {
1375                self.push_usage(&event, tx);
1376                // Local-only diagnostics: log the provider's enum-like error
1377                // identifiers (`type` / `code`) after sanitizing them down to a
1378                // short identifier charset. The free-text `message` field is
1379                // never logged or surfaced — it can echo request content.
1380                //
1381                // Two envelope shapes are accepted: the nested Responses form
1382                // (`response.error.{type,code,message}` / `error.{…}`) and
1383                // xAI's flat form (`{"type":"error","code":…,"message":…}`)
1384                // where the event's own `type` is just "error", so only the
1385                // top-level `code` is a meaningful identifier there.
1386                let error_obj = event
1387                    .get("response")
1388                    .and_then(|r| r.get("error"))
1389                    .or_else(|| event.get("error"))
1390                    .filter(|e| e.is_object());
1391                let error_kind = sanitize_error_identifier(
1392                    error_obj
1393                        .and_then(|e| e.get("type"))
1394                        .and_then(Value::as_str),
1395                );
1396                let error_code = sanitize_error_identifier(
1397                    error_obj
1398                        .and_then(|e| e.get("code"))
1399                        .or_else(|| event.get("code"))
1400                        .and_then(Value::as_str),
1401                );
1402                // Provider free-text: classification-only boolean, never
1403                // retained past this expression.
1404                let capacity = is_capacity_failure(
1405                    error_code,
1406                    error_obj
1407                        .and_then(|e| e.get("message"))
1408                        .or_else(|| event.get("message"))
1409                        .and_then(Value::as_str),
1410                );
1411                tracing::warn!(
1412                    provider = self.provider_label,
1413                    event_type,
1414                    error_kind,
1415                    error_code,
1416                    capacity,
1417                    "responses stream terminal failure (provider message withheld)"
1418                );
1419                let message = user_message_for_terminal_failure(
1420                    self.provider_label,
1421                    error_kind,
1422                    error_code,
1423                    capacity,
1424                );
1425                self.terminal_failure = Some(ResponsesStreamFailure {
1426                    // Exact account/context failures outrank a free-text capacity
1427                    // hint: do not spend local retries before allowing failover.
1428                    code: if message
1429                        == format!("{}{RESPONSES_CAPACITY_SUFFIX}", self.provider_label)
1430                    {
1431                        "responses_capacity"
1432                    } else {
1433                        "responses_failed"
1434                    },
1435                    message,
1436                });
1437                self.finish();
1438            }
1439            "response.incomplete" => {
1440                self.push_usage(&event, tx);
1441                let reason = sanitize_error_identifier(
1442                    event
1443                        .get("response")
1444                        .and_then(|r| r.get("incomplete_details"))
1445                        .and_then(|d| d.get("reason"))
1446                        .and_then(Value::as_str),
1447                );
1448                tracing::warn!(
1449                    provider = self.provider_label,
1450                    reason,
1451                    "responses stream terminal incomplete"
1452                );
1453                self.terminal_failure = Some(ResponsesStreamFailure {
1454                    code: "responses_incomplete",
1455                    message: format!("{}{RESPONSES_INCOMPLETE_SUFFIX}", self.provider_label),
1456                });
1457                self.finish();
1458            }
1459            _ => {
1460                if !event_type.is_empty() {
1461                    tracing::debug!(event_type, "ignoring unknown Responses SSE event");
1462                }
1463            }
1464        }
1465    }
1466
1467    fn terminal_result(&self) -> Result<(), ResponsesStreamFailure> {
1468        if let Some(failure) = &self.terminal_failure {
1469            return Err(failure.clone());
1470        }
1471        if self.terminal_success {
1472            if self.emitted_output || !self.completed_tools.is_empty() {
1473                Ok(())
1474            } else {
1475                Err(ResponsesStreamFailure {
1476                    code: "responses_empty",
1477                    message: format!("{}{RESPONSES_EMPTY_SUFFIX}", self.provider_label),
1478                })
1479            }
1480        } else {
1481            Err(ResponsesStreamFailure {
1482                code: "responses_missing_terminal",
1483                message: format!("{}{RESPONSES_MISSING_TERMINAL_SUFFIX}", self.provider_label),
1484            })
1485        }
1486    }
1487
1488    fn ensure_tool(&mut self, idx: usize) -> &mut CodexToolAccumulator {
1489        // `idx` is the provider-supplied `output_index`; a hostile or buggy
1490        // provider sending a huge value would otherwise grow this Vec unboundedly
1491        // (OOM/DoS). No legitimate response has thousands of concurrent tool
1492        // calls, so clamp absurd indices to the last slot instead of allocating.
1493        const MAX_TOOLS: usize = 4096;
1494        let idx = idx.min(MAX_TOOLS - 1);
1495        while self.active_tools.len() <= idx {
1496            self.active_tools.push(CodexToolAccumulator::default());
1497        }
1498        &mut self.active_tools[idx]
1499    }
1500
1501    fn add_tool_from_item(
1502        &mut self,
1503        idx: usize,
1504        item: &Value,
1505        tx: &mpsc::UnboundedSender<StreamEvent>,
1506    ) {
1507        if item.get("type").and_then(Value::as_str) != Some("function_call") {
1508            return;
1509        }
1510        let tool = self.ensure_tool(idx);
1511        if let Some(id) = item
1512            .get("call_id")
1513            .or_else(|| item.get("id"))
1514            .and_then(Value::as_str)
1515        {
1516            tool.id = id.to_string();
1517        }
1518        if let Some(name) = item.get("name").and_then(Value::as_str) {
1519            tool.name = name.to_string();
1520        }
1521        if !tool.started && !tool.name.is_empty() {
1522            tool.started = true;
1523            let _ = tx.send(StreamEvent::Llm(
1524                crate::runtime::types::LlmEvent::ToolUseStart {
1525                    tool_name: tool.name.clone(),
1526                    tool_id: tool.id.clone(),
1527                },
1528            ));
1529        }
1530    }
1531
1532    fn complete_tool_from_item(
1533        &mut self,
1534        idx: usize,
1535        item: &Value,
1536        tx: &mpsc::UnboundedSender<StreamEvent>,
1537    ) {
1538        if item.get("type").and_then(Value::as_str) != Some("function_call") {
1539            return;
1540        }
1541        let tool = self.ensure_tool(idx);
1542        if let Some(id) = item
1543            .get("call_id")
1544            .or_else(|| item.get("id"))
1545            .and_then(Value::as_str)
1546        {
1547            tool.id = id.to_string();
1548        }
1549        if let Some(name) = item.get("name").and_then(Value::as_str) {
1550            tool.name = name.to_string();
1551        }
1552        if let Some(arguments) = item.get("arguments").and_then(Value::as_str) {
1553            tool.arguments = arguments.to_string();
1554        }
1555        if !tool.started && !tool.name.is_empty() {
1556            tool.started = true;
1557            let _ = tx.send(StreamEvent::Llm(
1558                crate::runtime::types::LlmEvent::ToolUseStart {
1559                    tool_name: tool.name.clone(),
1560                    tool_id: tool.id.clone(),
1561                },
1562            ));
1563        }
1564        let completed = if !tool.id.is_empty() && !tool.name.is_empty() {
1565            Some(ToolCall {
1566                id: tool.id.clone(),
1567                kind: "function".to_string(),
1568                function: super::types::FunctionCall {
1569                    name: tool.name.clone(),
1570                    arguments: tool.arguments.clone(),
1571                },
1572            })
1573        } else {
1574            None
1575        };
1576        if let Some(call) = completed {
1577            if self.completed_tools.iter().any(|done| done.id == call.id) {
1578                return;
1579            }
1580            self.emitted_output = true;
1581            // Emit the finalized `ToolUse` event so the chat UI can collapse
1582            // the streaming `ToolUseStart` (animated) into a stable
1583            // `ToolUse` block. Without this the bash-trace animation
1584            // persists forever and parallel tool blocks render as "still
1585            // running" even after they've completed. Mirrors the
1586            // Anthropic path in `runtime/api.rs` which emits the same
1587            // event on tool-use content_block_stop.
1588            let input = parse_tool_arguments(&call.function.arguments);
1589            let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::ToolUse {
1590                tool_name: call.function.name.clone(),
1591                tool_id: call.id.clone(),
1592                input,
1593            }));
1594            self.completed_tools.push(ToolCall {
1595                id: call.id,
1596                kind: call.kind,
1597                function: call.function,
1598            });
1599        }
1600    }
1601
1602    fn push_usage(&mut self, event: &Value, tx: &mpsc::UnboundedSender<StreamEvent>) {
1603        let usage = event
1604            .get("response")
1605            .and_then(|r| r.get("usage"))
1606            .or_else(|| event.get("usage"));
1607        let input = usage
1608            .and_then(|u| u.get("input_tokens"))
1609            .and_then(Value::as_u64)
1610            .unwrap_or(0);
1611        let output = usage
1612            .and_then(|u| u.get("output_tokens"))
1613            .and_then(Value::as_u64)
1614            .unwrap_or(0);
1615        // Responses-API usage nests cache hits under input_tokens_details;
1616        // input_tokens INCLUDES them. Downstream accounting uses Anthropic
1617        // semantics (context = input + cache_read + cache_creation, hit% =
1618        // cache_read/total), so report the cached slice separately and
1619        // subtract it from input — otherwise cache hits are invisible
1620        // (reported 0) and the context total double-counts.
1621        let cached = usage
1622            .and_then(|u| u.pointer("/input_tokens_details/cached_tokens"))
1623            .and_then(Value::as_u64)
1624            .unwrap_or(0)
1625            .min(input);
1626        if input > 0 || output > 0 {
1627            // Trace capture (metadata only): the provider-reported totals as
1628            // sent — input including the cached slice.
1629            if self.observed_usage.is_none() {
1630                self.observed_usage = Some((input, output, cached));
1631            }
1632            let _ = tx.send(StreamEvent::Session(
1633                crate::runtime::types::SessionEvent::Usage {
1634                    input_tokens: input - cached,
1635                    output_tokens: output,
1636                    cache_read_input_tokens: cached,
1637                    cache_creation_input_tokens: 0,
1638                    cache_creation_5m: None,
1639                    cache_creation_1h: None,
1640                    model: None,
1641                },
1642            ));
1643        }
1644    }
1645
1646    /// Provider-reported usage for the trace record; `None` until observed.
1647    fn trace_usage(&self) -> Option<crate::runtime::trace::UsageMeta> {
1648        self.observed_usage
1649            .map(|(input, output, cached)| tr::provider_usage(input, output, cached))
1650    }
1651
1652    fn finish(&mut self) {
1653        for tool in self.active_tools.drain(..) {
1654            if !tool.id.is_empty()
1655                && !tool.name.is_empty()
1656                && !self.completed_tools.iter().any(|done| done.id == tool.id)
1657            {
1658                self.emitted_output = true;
1659                self.completed_tools.push(ToolCall {
1660                    id: tool.id,
1661                    kind: "function".to_string(),
1662                    function: super::types::FunctionCall {
1663                        name: tool.name,
1664                        arguments: tool.arguments,
1665                    },
1666                });
1667            }
1668        }
1669    }
1670}
1671
1672fn handle_events(
1673    events: &[OaiEvent],
1674    tx: &mpsc::UnboundedSender<StreamEvent>,
1675    text_acc: &mut String,
1676    tool_blocks: &mut Vec<Value>,
1677    name_map: &translate::ToolNameMap,
1678    suppress_stream_deltas: bool,
1679) {
1680    for ev in events {
1681        if let OaiEvent::TextDelta(t) = ev {
1682            text_acc.push_str(t);
1683        }
1684        if let OaiEvent::ToolCallsComplete { calls, .. } = ev {
1685            tool_blocks.extend(translate::tool_calls_to_content_blocks(calls, name_map));
1686        }
1687        if !suppress_stream_deltas {
1688            if let Some(se) = translate::oai_event_to_llm(ev) {
1689                let _ = tx.send(se);
1690            }
1691        }
1692    }
1693}
1694
1695#[cfg(test)]
1696mod codex_input_messages_tests {
1697    //! Regression tests for the Codex Responses-API `input` shape.
1698    //!
1699    //! Background: the Responses API distinguishes two ids per tool
1700    //! invocation — `id` (the *output item id*, prefix `fc_…`) and
1701    //! `call_id` (the *function call id*, prefix `call_…`). When echoing
1702    //! a previous `function_call` back as an input item, supplying an
1703    //! `id` whose value is *not* a `fc_…` triggers
1704    //!
1705    //!   400 Bad Request: Invalid 'input[N].id': 'call_…'.
1706    //!   Expected an ID that begins with 'fc'.
1707    //!
1708    //! `id` is *optional* on input items — only `call_id` is required to
1709    //! correlate the eventual `function_call_output`. We elect not to
1710    //! emit `id` unless we actually have a real `fc_…` value to send.
1711
1712    use super::super::types::{ChatMessage, FunctionCall, ToolCall};
1713    use super::*;
1714
1715    fn sample_tool_call() -> ToolCall {
1716        ToolCall {
1717            id: "call_nZYquCuGUh8Qs9H51dwHMDgs".to_string(),
1718            kind: "function".to_string(),
1719            function: FunctionCall {
1720                name: "bash".to_string(),
1721                arguments: r#"{"command":"ls"}"#.to_string(),
1722            },
1723        }
1724    }
1725
1726    fn translated_input(messages: Vec<Value>) -> Vec<Value> {
1727        let messages: Vec<crate::SharedMessage> =
1728            messages.into_iter().map(std::sync::Arc::new).collect();
1729        codex_input_messages(translate::messages_to_oai(
1730            &messages,
1731            &None,
1732            &translate::ToolNameMap::default(),
1733        ))
1734    }
1735
1736    #[test]
1737    fn image_text_document_and_pdf_exact_responses_wire() {
1738        let out = translated_input(vec![json!({"role":"user","content":[
1739            {"type":"text","text":"Compare these."},
1740            {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aW1hZ2U="}},
1741            {"type":"document","title":"notes.txt","source":{"type":"text","media_type":"text/plain","data":"Text document\ncontents"}},
1742            {"type":"document","title":"report.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}},
1743            {"type":"text","text":"Then summarize."}
1744        ]})]);
1745        assert_eq!(
1746            out,
1747            vec![json!({"role":"user","content":[
1748                {"type":"input_text","text":"Compare these."},
1749                {"type":"input_text","text":"[attachment origin=user type=image media_type=\"image/png\"; lower-authority data]\n"},
1750                {"type":"input_image","image_url":"data:image/png;base64,aW1hZ2U="},
1751                {"type":"input_text","text":"[/attachment]\n"},
1752                {"type":"input_text","text":"[attachment origin=user type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data]\n"},
1753                {"type":"input_text","text":"Text document\ncontents"},
1754                {"type":"input_text","text":"[/attachment]\n"},
1755                {"type":"input_text","text":"[attachment origin=user type=document media_type=\"application/pdf\" filename=\"report.pdf\"; lower-authority data]\n"},
1756                {"type":"input_file","filename":"report.pdf","file_data":"data:application/pdf;base64,JVBERi0="},
1757                {"type":"input_text","text":"[/attachment]\n"},
1758                {"type":"input_text","text":"Then summarize."}
1759            ]})]
1760        );
1761    }
1762
1763    #[test]
1764    fn mixed_assistant_text_and_all_tool_outputs_precede_lifted_responses_media() {
1765        let out = translated_input(vec![
1766            json!({"role":"assistant","content":[
1767                {"type":"text","text":"Reading both."},
1768                {"type":"tool_use","id":"t1","name":"read","input":{}},
1769                {"type":"tool_use","id":"t2","name":"read","input":{}}
1770            ]}),
1771            json!({"role":"user","content":[
1772                {"type":"tool_result","tool_use_id":"t1","content":[
1773                    {"type":"text","text":"First result"},
1774                    {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aW1hZ2U="}},
1775                    {"type":"document","title":"notes.txt","source":{"type":"text","media_type":"text/plain","data":"DOCUMENT_BODY_SENTINEL"}}
1776                ]},
1777                {"type":"tool_result","tool_use_id":"t2","content":[
1778                    {"type":"text","text":"Second result"},
1779                    {"type":"document","title":"report.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}}
1780                ]}
1781            ]}),
1782        ]);
1783        assert_eq!(
1784            out,
1785            vec![
1786                json!({"role":"assistant","content":"Reading both."}),
1787                json!({"type":"function_call","call_id":"t1","name":"read","arguments":"{}"}),
1788                json!({"type":"function_call","call_id":"t2","name":"read","arguments":"{}"}),
1789                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"}),
1790                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"}),
1791                json!({"role":"user","content":[
1792                    {"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"},
1793                    {"type":"input_image","image_url":"data:image/png;base64,aW1hZ2U="},
1794                    {"type":"input_text","text":"[/attachment]\n"},
1795                    {"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"},
1796                    {"type":"input_text","text":"DOCUMENT_BODY_SENTINEL"},
1797                    {"type":"input_text","text":"[/attachment]\n"},
1798                    {"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"},
1799                    {"type":"input_file","filename":"report.pdf","file_data":"data:application/pdf;base64,JVBERi0="},
1800                    {"type":"input_text","text":"[/attachment]\n"}
1801                ]})
1802            ]
1803        );
1804        for output in out
1805            .iter()
1806            .filter(|item| item["type"] == "function_call_output")
1807        {
1808            let text = output["output"].as_str().unwrap();
1809            for sentinel in [
1810                "aW1hZ2U=",
1811                "JVBERi0=",
1812                "DOCUMENT_BODY_SENTINEL",
1813                "\"source\"",
1814            ] {
1815                assert!(
1816                    !text.contains(sentinel),
1817                    "tool output must contain only text and metadata"
1818                );
1819            }
1820        }
1821    }
1822
1823    #[test]
1824    fn text_only_responses_input_keeps_strings_and_mixed_assistant_text() {
1825        let mut assistant = ChatMessage::assistant_tool_calls(vec![sample_tool_call()]);
1826        assistant.content = Some("Checking.".into());
1827        let out = codex_input_messages(vec![
1828            ChatMessage::system("rules"),
1829            ChatMessage::user("question"),
1830            assistant,
1831            ChatMessage::tool_result("call_nZYquCuGUh8Qs9H51dwHMDgs", "bash", "done"),
1832            ChatMessage::assistant("answer"),
1833        ]);
1834        assert_eq!(
1835            out,
1836            vec![
1837                json!({"role":"system","content":"rules"}),
1838                json!({"role":"user","content":"question"}),
1839                json!({"role":"assistant","content":"Checking."}),
1840                json!({"type":"function_call","call_id":"call_nZYquCuGUh8Qs9H51dwHMDgs","name":"bash","arguments":"{\"command\":\"ls\"}"}),
1841                json!({"type":"function_call_output","call_id":"call_nZYquCuGUh8Qs9H51dwHMDgs","output":"done"}),
1842                json!({"role":"assistant","content":"answer"}),
1843            ]
1844        );
1845    }
1846
1847    #[test]
1848    fn codex_instructions_appends_autonomous_loop_policy() {
1849        let instructions = codex_instructions(&Some("Project-specific rules.".to_string()));
1850        assert!(instructions.contains("Project-specific rules."));
1851        assert!(instructions.contains("Do not stop at phase boundaries"));
1852        assert!(instructions.contains("Do not ask the user whether to continue"));
1853        assert!(
1854            instructions.contains("continue autonomously until the full requested job is complete")
1855        );
1856    }
1857
1858    #[test]
1859    fn function_call_input_omits_non_fc_id() {
1860        let messages = vec![ChatMessage::assistant_tool_calls(vec![sample_tool_call()])];
1861        let out = codex_input_messages(messages);
1862        assert_eq!(out.len(), 1, "one tool_call → one input item");
1863        let item = &out[0];
1864        assert_eq!(
1865            item.get("type").and_then(Value::as_str),
1866            Some("function_call")
1867        );
1868        assert!(
1869            item.get("id").is_none(),
1870            "must not echo a non-`fc_` id back; got {:?}",
1871            item.get("id"),
1872        );
1873        assert_eq!(
1874            item.get("call_id").and_then(Value::as_str),
1875            Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
1876        );
1877        assert_eq!(item.get("name").and_then(Value::as_str), Some("bash"));
1878    }
1879
1880    #[test]
1881    fn function_call_input_keeps_real_fc_id() {
1882        // If we ever do have a genuine `fc_…` id (round-tripped from the
1883        // Responses API), we *should* echo it.
1884        let mut call = sample_tool_call();
1885        call.id = "fc_abc123".to_string();
1886        let messages = vec![ChatMessage::assistant_tool_calls(vec![call])];
1887        let out = codex_input_messages(messages);
1888        let item = &out[0];
1889        assert_eq!(item.get("id").and_then(Value::as_str), Some("fc_abc123"));
1890        assert_eq!(
1891            item.get("call_id").and_then(Value::as_str),
1892            Some("fc_abc123")
1893        );
1894    }
1895
1896    #[test]
1897    fn function_call_output_round_trips_call_id() {
1898        // The follow-up tool message must reference the original call_id.
1899        let messages = vec![ChatMessage::tool_result(
1900            "call_nZYquCuGUh8Qs9H51dwHMDgs",
1901            "bash",
1902            "total 0",
1903        )];
1904        let out = codex_input_messages(messages);
1905        let item = &out[0];
1906        assert_eq!(
1907            item.get("type").and_then(Value::as_str),
1908            Some("function_call_output"),
1909        );
1910        assert_eq!(
1911            item.get("call_id").and_then(Value::as_str),
1912            Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
1913        );
1914        assert_eq!(item.get("output").and_then(Value::as_str), Some("total 0"));
1915    }
1916}
1917
1918#[cfg(test)]
1919mod codex_decoder_tests {
1920    //! Regression tests for `CodexSseDecoder`.
1921    //!
1922    //! The decoder is sync — we drive it via `push_line` and capture
1923    //! emitted `StreamEvent`s from an `unbounded_channel` using
1924    //! `try_recv`, no async runtime needed.
1925
1926    use super::*;
1927    use crate::runtime::types::{LlmEvent, SessionEvent, StreamEvent};
1928
1929    fn collect_events(rx: &mut mpsc::UnboundedReceiver<StreamEvent>) -> Vec<StreamEvent> {
1930        let mut out = Vec::new();
1931        while let Ok(ev) = rx.try_recv() {
1932            out.push(ev);
1933        }
1934        out
1935    }
1936
1937    fn drive(lines: &[&str]) -> (CodexSseDecoder, String, Vec<StreamEvent>) {
1938        let (tx, mut rx) = mpsc::unbounded_channel();
1939        let mut decoder = CodexSseDecoder::default();
1940        let mut text_acc = String::new();
1941        for line in lines {
1942            decoder.push_line(line, &tx, &mut text_acc);
1943        }
1944        let events = collect_events(&mut rx);
1945        (decoder, text_acc, events)
1946    }
1947
1948    #[test]
1949    fn hostile_output_index_does_not_grow_active_tools_unbounded() {
1950        // A provider (or wire corruption) sending an absurd `output_index` must
1951        // not drive an unbounded `active_tools` grow (OOM/DoS). ensure_tool clamps
1952        // to MAX_TOOLS. Without the clamp this test would attempt to allocate ~1e9
1953        // accumulators and hang/OOM.
1954        let lines = [
1955            r#"data: {"type":"response.function_call_arguments.delta","output_index":1000000000,"delta":"x"}"#,
1956            "",
1957        ];
1958        let (decoder, _text, _events) = drive(&lines);
1959        assert!(
1960            decoder.active_tools.len() <= 4096,
1961            "active_tools grew to {} — output_index not bounded",
1962            decoder.active_tools.len()
1963        );
1964    }
1965
1966    #[test]
1967    fn text_delta_aggregates_into_text_acc_and_emits_text_events() {
1968        let lines = [
1969            r#"data: {"type":"response.output_text.delta","delta":"Hello, "}"#,
1970            "",
1971            r#"data: {"type":"response.output_text.delta","delta":"world!"}"#,
1972            "",
1973        ];
1974        let (_decoder, text_acc, events) = drive(&lines);
1975        assert_eq!(text_acc, "Hello, world!");
1976        let texts: Vec<_> = events
1977            .iter()
1978            .filter_map(|e| match e {
1979                StreamEvent::Llm(LlmEvent::Text(t)) => Some(t.as_str()),
1980                _ => None,
1981            })
1982            .collect();
1983        assert_eq!(texts, vec!["Hello, ", "world!"]);
1984    }
1985
1986    #[test]
1987    fn single_function_call_completes_via_output_item_done() {
1988        let lines = [
1989            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
1990            "",
1991            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\""}"#,
1992            "",
1993            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":":\"ls\"}"}"#,
1994            "",
1995            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
1996            "",
1997        ];
1998        let (decoder, _text, events) = drive(&lines);
1999
2000        assert_eq!(decoder.completed_tools.len(), 1);
2001        let tool = &decoder.completed_tools[0];
2002        assert_eq!(tool.id, "call_abc");
2003        assert_eq!(tool.function.name, "bash");
2004        assert_eq!(tool.function.arguments, r#"{"cmd":"ls"}"#);
2005
2006        // Exactly one ToolUseStart for the tool.
2007        let starts: Vec<_> = events
2008            .iter()
2009            .filter_map(|e| match e {
2010                StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => {
2011                    Some((tool_name.as_str(), tool_id.as_str()))
2012                }
2013                _ => None,
2014            })
2015            .collect();
2016        assert_eq!(
2017            starts,
2018            vec![("bash", "call_abc")],
2019            "exactly one ToolUseStart with correct tool_id"
2020        );
2021
2022        // Two argument deltas streamed (each carrying the tool_id so
2023        // parallel calls can be routed correctly by the chat UI).
2024        let deltas: Vec<_> = events
2025            .iter()
2026            .filter_map(|e| match e {
2027                StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id, delta }) => {
2028                    Some((tool_id.as_str(), delta.as_str()))
2029                }
2030                _ => None,
2031            })
2032            .collect();
2033        assert_eq!(
2034            deltas,
2035            vec![("call_abc", r#"{"cmd""#), ("call_abc", r#":"ls"}"#)]
2036        );
2037    }
2038
2039    #[test]
2040    fn parallel_tool_calls_indexed_separately() {
2041        let lines = [
2042            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
2043            "",
2044            r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
2045            "",
2046            r#"data: {"type":"response.function_call_arguments.delta","output_index":1,"delta":"{\"path\":\"a\"}"}"#,
2047            "",
2048            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\":\"ls\"}"}"#,
2049            "",
2050            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
2051            "",
2052            r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
2053            "",
2054        ];
2055        let (decoder, _text, _events) = drive(&lines);
2056
2057        assert_eq!(decoder.completed_tools.len(), 2);
2058        let mut by_id: std::collections::BTreeMap<&str, &ToolCall> =
2059            std::collections::BTreeMap::new();
2060        for tool in &decoder.completed_tools {
2061            by_id.insert(tool.id.as_str(), tool);
2062        }
2063        assert_eq!(by_id["call_1"].function.name, "bash");
2064        assert_eq!(by_id["call_1"].function.arguments, r#"{"cmd":"ls"}"#);
2065        assert_eq!(by_id["call_2"].function.name, "read");
2066        assert_eq!(by_id["call_2"].function.arguments, r#"{"path":"a"}"#);
2067    }
2068
2069    #[test]
2070    fn output_item_done_emits_tool_use_event() {
2071        // Regression: the codex decoder must emit `LlmEvent::ToolUse` once a
2072        // function_call's `output_item.done` arrives so the chat UI can
2073        // collapse `ChatMessage::ToolUseStart` (animated) into the finalized
2074        // `ChatMessage::ToolUse`. Without this the bash-trace animation
2075        // persists forever and parallel tool blocks render as "still
2076        // running" even after they've completed.
2077        let lines = [
2078            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
2079            "",
2080            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"command\":\"ls\"}"}"#,
2081            "",
2082            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
2083            "",
2084        ];
2085        let (_decoder, _text, events) = drive(&lines);
2086
2087        let tool_uses: Vec<_> = events
2088            .iter()
2089            .filter_map(|e| match e {
2090                StreamEvent::Llm(LlmEvent::ToolUse {
2091                    tool_name,
2092                    tool_id,
2093                    input,
2094                }) => Some((tool_name.as_str(), tool_id.as_str(), input.clone())),
2095                _ => None,
2096            })
2097            .collect();
2098        assert_eq!(
2099            tool_uses.len(),
2100            1,
2101            "expected exactly one ToolUse finalize event"
2102        );
2103        assert_eq!(tool_uses[0].0, "bash");
2104        assert_eq!(tool_uses[0].1, "call_abc");
2105        assert_eq!(
2106            tool_uses[0].2,
2107            serde_json::json!({"command": "ls"}),
2108            "input must be parsed as a JSON Value, not a string"
2109        );
2110    }
2111
2112    #[test]
2113    fn parallel_tool_calls_emit_tool_use_per_index() {
2114        // Regression: parallel tool calls must each get their own ToolUse
2115        // finalize event with the correct tool_id, so the chat UI can route
2116        // their results back to the right block by id.
2117        let lines = [
2118            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
2119            "",
2120            r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
2121            "",
2122            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
2123            "",
2124            r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
2125            "",
2126        ];
2127        let (_decoder, _text, events) = drive(&lines);
2128
2129        let tool_uses: Vec<_> = events
2130            .iter()
2131            .filter_map(|e| match e {
2132                StreamEvent::Llm(LlmEvent::ToolUse {
2133                    tool_name,
2134                    tool_id,
2135                    input,
2136                }) => Some((tool_name.clone(), tool_id.clone(), input.clone())),
2137                _ => None,
2138            })
2139            .collect();
2140
2141        assert_eq!(tool_uses.len(), 2, "one ToolUse finalize per parallel call");
2142        let by_id: std::collections::BTreeMap<&str, &(String, String, serde_json::Value)> =
2143            tool_uses.iter().map(|t| (t.1.as_str(), t)).collect();
2144        assert_eq!(by_id["call_1"].0, "bash");
2145        assert_eq!(by_id["call_1"].2, serde_json::json!({"command": "ls"}));
2146        assert_eq!(by_id["call_2"].0, "read");
2147        assert_eq!(by_id["call_2"].2, serde_json::json!({"path": "a"}));
2148    }
2149
2150    #[test]
2151    fn malformed_arguments_emit_tool_use_with_parse_error() {
2152        // If the model produces invalid JSON arguments, surface a structured
2153        // parse error in the `input` (matching how the Anthropic path
2154        // handles it via parse_tool_input) so the agent loop can return an
2155        // error tool_result instead of silently dropping the tool.
2156        let lines = [
2157            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash"}}"#,
2158            "",
2159            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash","arguments":"{not json"}}"#,
2160            "",
2161        ];
2162        let (_decoder, _text, events) = drive(&lines);
2163
2164        let tool_use = events.iter().find_map(|e| match e {
2165            StreamEvent::Llm(LlmEvent::ToolUse { input, .. }) => Some(input.clone()),
2166            _ => None,
2167        });
2168        let input = tool_use.expect("ToolUse event missing");
2169        assert!(
2170            input.get("__parse_error").and_then(Value::as_str).is_some(),
2171            "malformed arguments must surface __parse_error, got {input}"
2172        );
2173    }
2174
2175    #[test]
2176    fn response_completed_emits_usage_event() {
2177        let lines = [
2178            r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":42,"output_tokens":17}}}"#,
2179            "",
2180        ];
2181        let (_decoder, _text, events) = drive(&lines);
2182        let usage = events.iter().find_map(|e| match e {
2183            StreamEvent::Session(SessionEvent::Usage {
2184                input_tokens,
2185                output_tokens,
2186                ..
2187            }) => Some((*input_tokens, *output_tokens)),
2188            _ => None,
2189        });
2190        assert_eq!(usage, Some((42, 17)));
2191    }
2192
2193    /// Responses-API usage nests cache hits under input_tokens_details, and
2194    /// input_tokens INCLUDES them. The decoder must surface the cached slice
2195    /// as cache_read (Anthropic semantics) and subtract it from input —
2196    /// dropping it (the old hardcoded 0) made every codex turn look 100%
2197    /// uncached (incident: 2026-07-16 codex cache investigation).
2198    #[test]
2199    fn response_completed_surfaces_cached_tokens_without_double_count() {
2200        let lines = [
2201            r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":1000,"input_tokens_details":{"cached_tokens":900},"output_tokens":17}}}"#,
2202            "",
2203        ];
2204        let (_decoder, _text, events) = drive(&lines);
2205        let usage = events.iter().find_map(|e| match e {
2206            StreamEvent::Session(SessionEvent::Usage {
2207                input_tokens,
2208                output_tokens,
2209                cache_read_input_tokens,
2210                ..
2211            }) => Some((*input_tokens, *output_tokens, *cache_read_input_tokens)),
2212            _ => None,
2213        });
2214        assert_eq!(
2215            usage,
2216            Some((100, 17, 900)),
2217            "input must exclude the cached slice; cache_read must carry it"
2218        );
2219    }
2220
2221    #[test]
2222    fn response_completed_with_zero_usage_emits_nothing() {
2223        let lines = [
2224            r#"data: {"type":"response.output_text.delta","delta":"ok"}"#,
2225            "",
2226            r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":0,"output_tokens":0}}}"#,
2227            "",
2228        ];
2229        let (decoder, _text, events) = drive(&lines);
2230        let any_usage = events
2231            .iter()
2232            .any(|e| matches!(e, StreamEvent::Session(SessionEvent::Usage { .. })));
2233        assert!(!any_usage, "zero-token usage should be suppressed");
2234        assert_eq!(decoder.terminal_result(), Ok(()));
2235    }
2236
2237    #[test]
2238    fn response_failed_is_a_terminal_failure_without_retaining_provider_text() {
2239        let lines = [
2240            r#"data: {"type":"response.failed","response":{"error":{"type":"server_error","message":"ECHOED:secret prompt"}}}"#,
2241            "",
2242        ];
2243        let (decoder, text, events) = drive(&lines);
2244        let failure = decoder.terminal_result().expect_err("must fail");
2245        // Exact server_error is now preserved as a transient capacity class.
2246        assert_eq!(failure.code, "responses_capacity");
2247        assert!(failure.is_retryable());
2248        assert_eq!(failure.message, format!("Codex{RESPONSES_CAPACITY_SUFFIX}"));
2249        assert!(!failure.message.contains("ECHOED"));
2250        assert!(text.is_empty());
2251        assert!(events.is_empty());
2252    }
2253
2254    #[test]
2255    fn top_level_error_event_is_a_terminal_failure() {
2256        let lines = [
2257            r#"data: {"type":"error","error":{"type":"server_error","message":"private"}}"#,
2258            "",
2259        ];
2260        let (decoder, _text, _events) = drive(&lines);
2261        assert_eq!(
2262            decoder.terminal_result().expect_err("must fail").code,
2263            "responses_capacity"
2264        );
2265    }
2266
2267    #[test]
2268    fn response_incomplete_is_not_misreported_as_empty_success() {
2269        let lines = [
2270            r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
2271            "",
2272        ];
2273        let (decoder, _text, _events) = drive(&lines);
2274        let failure = decoder.terminal_result().expect_err("must fail");
2275        assert_eq!(failure.code, "responses_incomplete");
2276        assert!(failure.message.contains("incomplete"));
2277        assert!(!failure.message.contains("max_output_tokens"));
2278    }
2279
2280    #[test]
2281    fn completed_without_text_or_tools_fails_at_transport_boundary() {
2282        let lines = [
2283            r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":7,"output_tokens":0}}}"#,
2284            "",
2285        ];
2286        let (decoder, _text, _events) = drive(&lines);
2287        assert_eq!(
2288            decoder.terminal_result().expect_err("must fail").code,
2289            "responses_empty"
2290        );
2291    }
2292
2293    #[test]
2294    fn eof_without_terminal_event_fails_closed() {
2295        let lines = [
2296            r#"data: {"type":"response.created","response":{"id":"resp_123"}}"#,
2297            "",
2298        ];
2299        let (decoder, _text, _events) = drive(&lines);
2300        assert_eq!(
2301            decoder.terminal_result().expect_err("must fail").code,
2302            "responses_missing_terminal"
2303        );
2304    }
2305
2306    /// The local-log sanitizer must pass lowercase snake-case identifiers
2307    /// verbatim, collapse secret-shaped identifiers to a shape marker, and
2308    /// never emit free-form provider text.
2309    #[test]
2310    fn error_identifier_sanitizer_never_passes_freeform_text() {
2311        assert_eq!(sanitize_error_identifier(None), "absent");
2312        assert_eq!(
2313            sanitize_error_identifier(Some("rate_limit_exceeded")),
2314            "rate_limit_exceeded"
2315        );
2316        assert_eq!(
2317            sanitize_error_identifier(Some("context_length_exceeded")),
2318            "context_length_exceeded"
2319        );
2320        assert_eq!(
2321            sanitize_error_identifier(Some("some_new_code2")),
2322            "some_new_code2"
2323        );
2324        // Mixed-case / dashed tokens can be secret-shaped (API keys).
2325        assert_eq!(
2326            sanitize_error_identifier(Some("sk-Abc123XYZ")),
2327            "unlisted_identifier"
2328        );
2329        assert_eq!(
2330            sanitize_error_identifier(Some("Server.Error")),
2331            "unlisted_identifier"
2332        );
2333        assert_eq!(
2334            sanitize_error_identifier(Some("ECHOED: user secret prompt")),
2335            "unsafe_or_freeform"
2336        );
2337        assert_eq!(sanitize_error_identifier(Some("")), "unsafe_or_freeform");
2338        let long = "a".repeat(65);
2339        assert_eq!(sanitize_error_identifier(Some(&long)), "unsafe_or_freeform");
2340    }
2341
2342    /// Context-window overflow maps to a specific, actionable message; every
2343    /// other terminal failure keeps the neutral details-withheld default. The
2344    /// mapping only ever reads sanitized enum identifiers, never free-text.
2345    #[test]
2346    fn terminal_failure_user_message_is_specific_only_for_known_codes() {
2347        assert!(user_message_for_terminal_failure(
2348            "Codex",
2349            "invalid_request_error",
2350            "context_length_exceeded",
2351            false
2352        )
2353        .contains("/compact"));
2354        // Code carried on `type` rather than `code` still maps.
2355        assert!(user_message_for_terminal_failure(
2356            "Codex",
2357            "context_length_exceeded",
2358            "absent",
2359            false
2360        )
2361        .contains("context window"));
2362        // Unknown/other codes keep the neutral default (no remedy claim).
2363        let default = user_message_for_terminal_failure(
2364            "Codex",
2365            "server_error",
2366            "unlisted_identifier",
2367            false,
2368        );
2369        assert!(default.starts_with("Codex response failed in stream."));
2370        assert!(!default.contains("/compact"));
2371        // Capacity gets the specific remedy, labelled for the provider.
2372        let capacity = user_message_for_terminal_failure("xAI", "absent", "absent", true);
2373        assert_eq!(
2374            capacity,
2375            "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
2376        );
2377    }
2378
2379    /// The exact flat envelope xAI emits (HTTP 200, one SSE event, close):
2380    /// no nested `error` object, `code` null, free-text `message`. It must
2381    /// classify as retryable capacity — labelled for the provider, and the
2382    /// provider prose must never reach the surfaced message.
2383    #[test]
2384    fn xai_flat_capacity_error_is_retryable_capacity_failure() {
2385        let lines = [
2386            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}"#,
2387            "",
2388        ];
2389        let (tx, mut rx) = mpsc::unbounded_channel();
2390        let mut decoder = CodexSseDecoder::for_provider("xAI");
2391        let mut text = String::new();
2392        for line in lines {
2393            decoder.push_line(line, &tx, &mut text);
2394        }
2395        decoder.finish();
2396        let failure = decoder.terminal_result().expect_err("must fail");
2397        assert_eq!(failure.code, "responses_capacity");
2398        assert!(failure.is_retryable());
2399        assert_eq!(
2400            failure.message,
2401            "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
2402        );
2403        for banned in ["high demand", "docs.x.ai", "service tier", "Codex"] {
2404            assert!(
2405                !failure.message.contains(banned),
2406                "provider text `{banned}` leaked: {}",
2407                failure.message
2408            );
2409        }
2410        assert!(text.is_empty());
2411        assert!(!decoder.emitted_any_output());
2412        assert!(rx.try_recv().is_err(), "no events for an error-only stream");
2413    }
2414
2415    /// Flat `code` identifiers are honoured when no nested error object is
2416    /// present; only vetted codes classify as capacity.
2417    #[test]
2418    fn flat_code_classifies_capacity_only_for_vetted_codes() {
2419        let capacity = [
2420            r#"data: {"type":"error","code":"rate_limit_exceeded","message":"private"}"#,
2421            "",
2422        ];
2423        let (decoder, _, _) = drive(&capacity);
2424        let failure = decoder.terminal_result().expect_err("must fail");
2425        assert_eq!(failure.code, "responses_capacity");
2426        assert!(failure.is_retryable());
2427
2428        let other = [
2429            r#"data: {"type":"error","code":"invalid_api_key","message":"private"}"#,
2430            "",
2431        ];
2432        let (decoder, _, _) = drive(&other);
2433        let failure = decoder.terminal_result().expect_err("must fail");
2434        assert_eq!(failure.code, "responses_failed");
2435        assert!(!failure.is_retryable());
2436        assert!(!failure.message.contains("private"));
2437    }
2438
2439    #[test]
2440    fn provider_label_is_carried_into_every_terminal_message() {
2441        let (tx, _rx) = mpsc::unbounded_channel();
2442        let mut text = String::new();
2443        let mut decoder = CodexSseDecoder::for_provider("xAI");
2444        decoder.push_line(
2445            r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":0}}}"#,
2446            &tx,
2447            &mut text,
2448        );
2449        decoder.push_line("", &tx, &mut text);
2450        let failure = decoder.terminal_result().expect_err("empty");
2451        assert_eq!(failure.code, "responses_empty");
2452        assert!(failure.is_retryable());
2453        assert_eq!(
2454            failure.message,
2455            "xAI completed without text or tool output. Retry the request."
2456        );
2457
2458        let decoder = CodexSseDecoder::for_provider("xAI");
2459        let failure = decoder.terminal_result().expect_err("missing terminal");
2460        assert_eq!(failure.code, "responses_missing_terminal");
2461        assert!(failure.is_retryable());
2462        assert!(failure.message.starts_with("xAI response stream ended"));
2463
2464        let mut decoder = CodexSseDecoder::for_provider("xAI");
2465        decoder.push_line(
2466            r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
2467            &tx,
2468            &mut text,
2469        );
2470        decoder.push_line("", &tx, &mut text);
2471        let failure = decoder.terminal_result().expect_err("incomplete");
2472        assert_eq!(failure.code, "responses_incomplete");
2473        assert!(!failure.is_retryable());
2474        assert!(failure.message.starts_with("xAI response was incomplete."));
2475    }
2476
2477    #[test]
2478    fn done_marker_finishes_decoder() {
2479        let lines = [
2480            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_x","name":"bash"}}"#,
2481            "",
2482            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{}"}"#,
2483            "",
2484            "data: [DONE]",
2485            "",
2486        ];
2487        let (decoder, _text, _events) = drive(&lines);
2488        // active_tools promoted to completed_tools by finish().
2489        assert_eq!(decoder.completed_tools.len(), 1);
2490        assert_eq!(decoder.completed_tools[0].id, "call_x");
2491        assert_eq!(decoder.completed_tools[0].function.arguments, "{}");
2492    }
2493
2494    #[test]
2495    fn finish_is_idempotent_no_double_emit() {
2496        let lines = [
2497            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_y","name":"bash","arguments":"{}"}}"#,
2498            "",
2499        ];
2500        let (mut decoder, _text, _events) = drive(&lines);
2501        assert_eq!(decoder.completed_tools.len(), 1);
2502
2503        // Calling finish() again must not duplicate the tool.
2504        decoder.finish();
2505        assert_eq!(
2506            decoder.completed_tools.len(),
2507            1,
2508            "finish() called twice must not double-emit"
2509        );
2510    }
2511
2512    #[test]
2513    fn finish_drains_active_tools_for_state_hygiene() {
2514        // After [DONE], any leftover active tool entries should have been
2515        // promoted *and* drained from `active_tools`. This guards against
2516        // future code paths that re-call finish() (or new event types
2517        // that would otherwise re-iterate the old buffer).
2518        let lines = [
2519            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_z","name":"bash"}}"#,
2520            "",
2521            "data: [DONE]",
2522            "",
2523        ];
2524        let (decoder, _text, _events) = drive(&lines);
2525        assert_eq!(decoder.completed_tools.len(), 1);
2526        assert!(
2527            decoder.active_tools.is_empty(),
2528            "active_tools must be drained after finish()"
2529        );
2530    }
2531
2532    #[test]
2533    fn unknown_event_types_are_ignored() {
2534        let lines = [
2535            r#"data: {"type":"response.future_unknown_event","payload":{"x":1}}"#,
2536            "",
2537            r#"data: {"type":"response.output_text.delta","delta":"hi"}"#,
2538            "",
2539        ];
2540        let (_decoder, text_acc, _events) = drive(&lines);
2541        assert_eq!(text_acc, "hi");
2542    }
2543}
2544
2545#[cfg(test)]
2546mod broker_stream_tests {
2547    //! Broker-boundary streaming tests: the OpenAI-compatible stream path is
2548    //! driven end-to-end through an in-process `LocalBroker` against a fake
2549    //! upstream. The runtime side supplies NO credential — the broker applies
2550    //! it — and SSE deltas are forwarded to the event channel in real time.
2551
2552    use super::*;
2553    use crate::auth::{CredentialBroker, LocalBroker};
2554    use std::sync::Arc;
2555
2556    async fn spawn_fake_openai_sse() -> (String, Arc<std::sync::Mutex<String>>) {
2557        use axum::routing::post;
2558        let seen_auth = Arc::new(std::sync::Mutex::new(String::new()));
2559        let seen = seen_auth.clone();
2560        let app = axum::Router::new().route(
2561            "/chat/completions",
2562            post(move |headers: axum::http::HeaderMap| {
2563                let seen = seen.clone();
2564                async move {
2565                    *seen.lock().unwrap() = headers
2566                        .get("authorization")
2567                        .and_then(|v| v.to_str().ok())
2568                        .unwrap_or("")
2569                        .to_string();
2570                    let body = concat!(
2571                        "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n",
2572                        "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n",
2573                        "data: [DONE]\n\n",
2574                    );
2575                    ([("content-type", "text/event-stream")], body)
2576                }
2577            }),
2578        );
2579        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2580        let addr = listener.local_addr().unwrap();
2581        tokio::spawn(async move {
2582            axum::serve(listener, app).await.unwrap();
2583        });
2584        (format!("http://{addr}"), seen_auth)
2585    }
2586
2587    #[tokio::test]
2588    async fn oai_stream_flows_through_broker_and_forwards_deltas() {
2589        let (upstream, seen_auth) = spawn_fake_openai_sse().await;
2590        let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2591            reqwest::Client::new(),
2592            upstream,
2593        ));
2594        let cfg = ProviderConfig {
2595            base_url: "unused-broker-derives-the-url".to_string(),
2596            model: "test-model".to_string(),
2597            provider: "local".to_string(),
2598        };
2599        let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
2600        let cancel = tokio_util::sync::CancellationToken::new();
2601
2602        let result = call_oai_stream_inner(
2603            &cfg,
2604            &broker,
2605            &[],
2606            &None,
2607            &[],
2608            &tx,
2609            None,
2610            None,
2611            0,
2612            agent_core::reasoning::ReasoningLevel::Medium,
2613            &cancel,
2614            &crate::runtime::trace::TraceContext::disabled(),
2615            true,
2616            false,
2617            0,
2618        )
2619        .await
2620        .expect("stream must complete");
2621
2622        // Final Anthropic-shaped value carries the accumulated text.
2623        assert_eq!(result["content"][0]["text"], "Hello");
2624
2625        // Deltas were forwarded in real time on the event channel.
2626        drop(tx);
2627        let mut streamed = String::new();
2628        while let Ok(ev) = rx.try_recv() {
2629            if let StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(t)) = ev {
2630                streamed.push_str(&t);
2631            }
2632        }
2633        assert_eq!(streamed, "Hello");
2634
2635        // The credential was applied by the broker, not by this call site.
2636        assert_eq!(&*seen_auth.lock().unwrap(), "Bearer local");
2637    }
2638
2639    /// Broker errors surface as typed failures without opening a stream and
2640    /// without any credential material in the message.
2641    #[tokio::test]
2642    async fn suppressed_sync_route_does_not_enqueue_display_deltas() {
2643        let (upstream, _seen_auth) = spawn_fake_openai_sse().await;
2644        let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2645            reqwest::Client::new(),
2646            upstream,
2647        ));
2648        let cfg = ProviderConfig {
2649            base_url: "unused-broker-derives-the-url".to_string(),
2650            model: "test-model".to_string(),
2651            provider: "local".to_string(),
2652        };
2653        let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
2654        let result = call_oai_stream_inner(
2655            &cfg,
2656            &broker,
2657            &[],
2658            &None,
2659            &[],
2660            &tx,
2661            None,
2662            None,
2663            0,
2664            agent_core::reasoning::ReasoningLevel::Medium,
2665            &tokio_util::sync::CancellationToken::new(),
2666            &crate::runtime::trace::TraceContext::disabled(),
2667            true,
2668            true,
2669            0,
2670        )
2671        .await
2672        .expect("stream must complete");
2673        assert_eq!(result["content"][0]["text"], "Hello");
2674        assert!(
2675            rx.try_recv().is_err(),
2676            "sync route must suppress display deltas at production"
2677        );
2678    }
2679
2680    #[tokio::test]
2681    async fn oai_stream_broker_error_fails_closed() {
2682        // Unconfigured static provider → NotConfigured, no upstream contact.
2683        let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::new(reqwest::Client::new()));
2684        let cfg = ProviderConfig {
2685            base_url: String::new(),
2686            model: "m".to_string(),
2687            provider: "definitely-not-a-provider".to_string(),
2688        };
2689        let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
2690        let cancel = tokio_util::sync::CancellationToken::new();
2691        let err = call_oai_stream_inner(
2692            &cfg,
2693            &broker,
2694            &[],
2695            &None,
2696            &[],
2697            &tx,
2698            None,
2699            None,
2700            0,
2701            agent_core::reasoning::ReasoningLevel::Medium,
2702            &cancel,
2703            &crate::runtime::trace::TraceContext::disabled(),
2704            true,
2705            false,
2706            0,
2707        )
2708        .await
2709        .unwrap_err()
2710        .to_string();
2711        assert!(err.contains("unknown provider"), "got: {err}");
2712        assert!(!err.to_lowercase().contains("bearer"));
2713    }
2714
2715    /// Phase 1 privacy (spec §5.1): a hostile upstream answers the streaming
2716    /// open with HTTP 500 whose body echoes the full request. The broker's
2717    /// transport error carries a body snippet; the runtime must not surface
2718    /// or log it — status + provider label only.
2719    #[tokio::test]
2720    async fn oai_stream_upstream_error_body_never_surfaces() {
2721        use axum::response::IntoResponse;
2722        const SENTINEL: &str = "PH1-OAI-BROKER-SENTINEL-91d2-RAW";
2723        let app = axum::Router::new().route(
2724            "/chat/completions",
2725            axum::routing::post(move |body: String| async move {
2726                (
2727                    axum::http::StatusCode::INTERNAL_SERVER_ERROR,
2728                    [("content-type", "application/json")],
2729                    format!("{{\"error\":{{\"message\":\"ECHOED:{body}\"}}}}"),
2730                )
2731                    .into_response()
2732            }),
2733        );
2734        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2735        let addr = listener.local_addr().unwrap();
2736        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2737
2738        let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2739            reqwest::Client::new(),
2740            format!("http://{addr}"),
2741        ));
2742        let cfg = ProviderConfig {
2743            base_url: "unused-broker-derives-the-url".to_string(),
2744            model: "test-model".to_string(),
2745            provider: "local".to_string(),
2746        };
2747        let tools = vec![serde_json::json!({
2748            "name": "ph1_secret_tool_zz",
2749            "description": "internal tool schema",
2750            "input_schema": {"type": "object", "properties": {}}
2751        })];
2752        let msgs: Vec<crate::SharedMessage> = vec![Arc::new(
2753            serde_json::json!({"role": "user", "content": SENTINEL}),
2754        )];
2755        let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
2756        let cancel = tokio_util::sync::CancellationToken::new();
2757
2758        let err = call_oai_stream_inner(
2759            &cfg,
2760            &broker,
2761            &tools,
2762            &Some(format!("system secret {SENTINEL}")),
2763            &msgs,
2764            &tx,
2765            None,
2766            None,
2767            0,
2768            agent_core::reasoning::ReasoningLevel::Medium,
2769            &cancel,
2770            &crate::runtime::trace::TraceContext::disabled(),
2771            true,
2772            false,
2773            0,
2774        )
2775        .await
2776        .expect_err("500 must fail")
2777        .to_string();
2778
2779        assert!(err.contains("500"), "status must survive: {err}");
2780        for banned in ["ECHOED", SENTINEL, "ph1_secret_tool_zz", "system secret"] {
2781            assert!(
2782                !err.contains(banned),
2783                "provider body content `{banned}` leaked into the surfaced error: {err}"
2784            );
2785        }
2786    }
2787}
2788
2789/// Provider display label for the xai-auth Responses route.
2790const XAI_PROVIDER_LABEL: &str = "xAI";
2791
2792#[allow(clippy::too_many_arguments)]
2793pub(crate) async fn call_xai_responses_stream_inner(
2794    cfg: &ProviderConfig,
2795    broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
2796    tools_schema: &[Value],
2797    system_prompt: &Option<String>,
2798    messages: &[crate::SharedMessage],
2799    tx: &mpsc::UnboundedSender<StreamEvent>,
2800    max_tokens: Option<u32>,
2801    reasoning_level: agent_core::reasoning::ReasoningLevel,
2802    cancel: &tokio_util::sync::CancellationToken,
2803    max_retries: u32,
2804    trace: &crate::runtime::trace::TraceContext,
2805    exact_wire_bytes: bool,
2806) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
2807    let (tools, names) = translate::tools_to_responses(tools_schema);
2808    let input = codex_input_messages(translate::messages_to_oai(messages, system_prompt, &names));
2809    // Pure, validated body construction — rejects unsupported reasoning
2810    // combinations BEFORE any broker credential access or network I/O.
2811    let body = build_xai_body(
2812        &cfg.model,
2813        reasoning_level,
2814        serde_json::to_value(input)?,
2815        tools,
2816        max_tokens,
2817    )
2818    .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
2819    // Serialize ONCE via the sanctioned constructor: the digested bytes are
2820    // the very bytes the broker sends upstream (`body_bytes` handoff,
2821    // LocalBroker verbatim) — `body`/`body_bytes` cannot diverge. On a
2822    // remote broker the daemon re-serializes out of process → honest
2823    // `CloudProxy` kind and no wire-byte claim. Every retry clones this
2824    // same request (identical `body` and `body_bytes`), so one wire digest
2825    // is honest for all attempt records.
2826    let (proxy_request, body_bytes) =
2827        crate::auth::ProxyRequest::post_json_exact("xai-auth", "/responses", body, true)?;
2828    let tracer = tr::begin_openai_tracer(
2829        trace,
2830        &cfg.provider,
2831        &cfg.model,
2832        if exact_wire_bytes {
2833            crate::runtime::trace::TransportKind::OpenAiResponses
2834        } else {
2835            crate::runtime::trace::TransportKind::CloudProxy
2836        },
2837        &format!("{}/responses", cfg.base_url.trim_end_matches('/')),
2838        exact_wire_bytes.then_some(body_bytes.as_ref()),
2839        messages,
2840        system_prompt.as_deref(),
2841        tools_schema,
2842        tr::renamed_tool_losses(&names),
2843    )
2844    .await;
2845    // One-shot explicit content capture (`/trace next content`): a no-op in
2846    // every context without the arm. `body_bytes` is the serialized request
2847    // body this process built pre-send (exact wire bytes on the local
2848    // broker; the same body a remote broker re-serializes) — body only,
2849    // headers and credentials structurally never reach this seam.
2850    if let Some(tracer) = &tracer {
2851        tr::capture_request_content(trace, tracer.request_id(), body_bytes.as_ref(), messages);
2852    }
2853    let mut attempt = tr::StreamAttempt::new(tracer);
2854    let mut stream_retry = 0u32;
2855
2856    loop {
2857        if cancel.is_cancelled() {
2858            return Err("request canceled".into());
2859        }
2860        let stream = crate::runtime::api::await_or_cancel(
2861            cancel,
2862            broker.proxy_stream(proxy_request.clone()),
2863        )
2864        .await
2865        .map_err(|_| "request canceled")?
2866        // Privacy (spec §5.1): a broker proxy error may carry an upstream
2867        // response-body snippet; redact it to status-only before surfacing.
2868        .map_err(|e| super::net::redact_provider_proxy_error(&e.to_string()));
2869        let mut stream = match stream {
2870            Ok(stream) => {
2871                attempt.mark_headers();
2872                stream
2873            }
2874            Err(msg) => {
2875                let status = tr::broker_error_status(&msg);
2876                let code =
2877                    status.map_or_else(|| "broker_error".to_string(), |s| format!("http_{s}"));
2878                attempt.finish_failed(&code, status, None);
2879                return Err(super::net::normalize_broker_http_error(msg).into());
2880            }
2881        };
2882        let _ = tx.send(StreamEvent::Llm(crate::LlmEvent::ResponseStart));
2883        let mut interrupted = false;
2884        let mut text = String::new();
2885        let mut parser = CodexSseDecoder::for_provider(XAI_PROVIDER_LABEL);
2886        let mut buf = bytes::BytesMut::new();
2887        while let Some(chunk) = tokio::select! {
2888            biased;
2889            _ = cancel.cancelled() => {
2890                attempt.finish_canceled(None, parser.trace_usage());
2891                return Err("request canceled".into());
2892            }
2893            c = stream.next() => c,
2894        } {
2895            let chunk = match chunk {
2896                Ok(chunk) => {
2897                    attempt.mark_first_byte();
2898                    chunk
2899                }
2900                Err(e) => {
2901                    if !matches!(e, crate::auth::BrokerError::Transport(_)) {
2902                        attempt.finish_failed("broker_error", None, None);
2903                        return Err(e.into());
2904                    }
2905                    interrupted = !parser.terminal_success && parser.terminal_failure.is_none();
2906                    break;
2907                }
2908            };
2909            buf.extend_from_slice(&chunk);
2910            while let Some(n) = memchr::memchr(b'\n', &buf) {
2911                let line = buf.split_to(n + 1);
2912                parser.push_line(std::str::from_utf8(&line[..n]).unwrap_or(""), tx, &mut text);
2913            }
2914            if parser.saw_model_event {
2915                attempt.mark_first_model_event();
2916            }
2917        }
2918        if !interrupted && !buf.is_empty() {
2919            parser.push_line(std::str::from_utf8(&buf).unwrap_or(""), tx, &mut text);
2920        }
2921        if interrupted || (!parser.terminal_success && parser.terminal_failure.is_none()) {
2922            if stream_retry >= max_retries {
2923                attempt.finish_failed("stream_error", None, None);
2924                return Err(STREAM_INTERRUPTED.into());
2925            }
2926            stream_retry += 1;
2927            attempt.attempt_failed(
2928                crate::runtime::trace::RetryClass::Network,
2929                retry_delay(stream_retry),
2930                None,
2931                None,
2932                "stream_error",
2933            );
2934            wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
2935                .await
2936                .inspect_err(|_| {
2937                    attempt.finish_canceled(None, None);
2938                })?;
2939            attempt.restart_clock();
2940            continue;
2941        }
2942        parser.finish();
2943        // Trailing-buffer flush and finish() can surface the first (or only)
2944        // model event; mark it so first_model_event_ms is honest for tail-only
2945        // streams (mirrors the Codex direct path above).
2946        if parser.saw_model_event {
2947            attempt.mark_first_model_event();
2948        }
2949        // Broker paths never observe the upstream HTTP status; the Responses
2950        // stream does not yet expose a normalized stop reason — both stay
2951        // honest `None` rather than guessed values.
2952        if let Err(failure) = parser.terminal_result() {
2953            // xAI answers HTTP 200 + a lone `{"type":"error",…}` capacity
2954            // event while the model is saturated; a re-send seconds later
2955            // usually succeeds. Retry only when NOTHING reached the UI on
2956            // this attempt — text or tool events already streamed would be
2957            // emitted twice on a re-send, so those failures stay terminal.
2958            let output_already_streamed = !text.is_empty() || parser.emitted_any_output();
2959            if failure.is_retryable() && !output_already_streamed && stream_retry < max_retries {
2960                stream_retry += 1;
2961                let delay = retry_delay(stream_retry);
2962                attempt.attempt_failed(
2963                    crate::runtime::trace::RetryClass::Other,
2964                    delay,
2965                    None,
2966                    None,
2967                    failure.code,
2968                );
2969                tracing::warn!(
2970                    code = failure.code,
2971                    "xai stream retry {stream_retry}/{max_retries} after {delay:?}"
2972                );
2973                tokio::select! {
2974                    _ = tokio::time::sleep(delay) => {}
2975                    _ = cancel.cancelled() => {
2976                        attempt.finish_canceled(None, None);
2977                        return Err("request canceled".into());
2978                    }
2979                }
2980                attempt.restart_clock();
2981                continue;
2982            }
2983            attempt.finish_failed(failure.code, None, None);
2984            return Err(failure.message.into());
2985        }
2986        attempt.finish_success(None, None, None, parser.trace_usage());
2987        let mut content = Vec::new();
2988        if !text.is_empty() {
2989            content.push(json!({"type":"text","text":text}));
2990        }
2991        content.extend(translate::tool_calls_to_content_blocks(
2992            &parser.completed_tools,
2993            &names,
2994        ));
2995        return Ok(json!({"role":"assistant","content":content}));
2996    }
2997}
2998
2999/// Pure, validated body construction for the xAI Responses API.
3000///
3001/// Enforces the exact-model reasoning matrix from
3002/// `docs/specs/anthropic-xai-reasoning-modes-spec.md` BEFORE any credential or
3003/// network access, via the shared per-provider validator:
3004/// - `Off` is rejected (never silently omitted) on models whose reasoning
3005///   cannot be disabled; on the non-reasoning model it is trivially omission.
3006/// - `Adaptive` omits the `reasoning` field → documented provider default.
3007/// - Named levels are emitted as exact `reasoning:{effort:"..."}` only when
3008///   the exact model id documents them; otherwise `Err` (fail closed).
3009pub(crate) fn build_xai_body(
3010    model: &str,
3011    level: agent_core::reasoning::ReasoningLevel,
3012    input: Value,
3013    tools: Vec<Value>,
3014    max_tokens: Option<u32>,
3015) -> Result<Value, String> {
3016    use agent_core::reasoning::ReasoningLevel;
3017    crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
3018        &format!("xai-auth/{model}"),
3019        level,
3020    )?;
3021    let mut body = serde_json::Map::new();
3022    body.insert("model".into(), json!(model));
3023    body.insert("input".into(), input);
3024    body.insert("stream".into(), json!(true));
3025    if !tools.is_empty() {
3026        body.insert("tools".into(), Value::Array(tools));
3027    }
3028    if let Some(max) = max_tokens {
3029        body.insert("max_output_tokens".into(), json!(max));
3030    }
3031    match level {
3032        // Off (validated: only reachable where reasoning is absent/disableable)
3033        // and Adaptive: omit `reasoning` — provider default applies.
3034        ReasoningLevel::Off | ReasoningLevel::Adaptive => {}
3035        l => {
3036            body.insert("reasoning".into(), json!({ "effort": l.as_str() }));
3037        }
3038    }
3039    Ok(Value::Object(body))
3040}
3041
3042#[cfg(test)]
3043mod xai_tests {
3044    use super::*;
3045    use agent_core::reasoning::ReasoningLevel;
3046
3047    #[test]
3048    fn xai_fixture_is_public_responses_shape() {
3049        let fixture = serde_json::json!({"type":"response.output_text.delta","delta":"hello"});
3050        assert_eq!(fixture["type"], "response.output_text.delta");
3051        assert_eq!(fixture["delta"], "hello");
3052    }
3053
3054    fn body_for(model: &str, level: ReasoningLevel) -> Result<Value, String> {
3055        build_xai_body(model, level, json!([]), Vec::new(), Some(1024))
3056    }
3057
3058    // ── Exact Responses wire (spec: anthropic-xai-reasoning-modes) ───────────
3059
3060    #[test]
3061    fn grok45_emits_exact_documented_efforts() {
3062        for (level, effort) in [
3063            (ReasoningLevel::Low, "low"),
3064            (ReasoningLevel::Medium, "medium"),
3065            (ReasoningLevel::High, "high"),
3066        ] {
3067            let body = body_for("grok-4.5", level).expect("supported effort");
3068            assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
3069            assert_eq!(body["model"], "grok-4.5");
3070            assert_eq!(body["stream"], json!(true));
3071            assert_eq!(body["max_output_tokens"], json!(1024));
3072        }
3073    }
3074
3075    #[test]
3076    fn grok47_responses_wire_uses_exact_efforts_and_preserves_tool_shape() {
3077        for (level, effort) in [
3078            (ReasoningLevel::Low, "low"),
3079            (ReasoningLevel::Medium, "medium"),
3080            (ReasoningLevel::High, "high"),
3081            (ReasoningLevel::XHigh, "xhigh"),
3082        ] {
3083            let input = json!([{"role": "user", "content": "hello"}]);
3084            let tool = json!({"type": "function", "name": "inspect", "parameters": {"type": "object"}});
3085            let body = build_xai_body("grok-4.7", level, input.clone(), vec![tool.clone()], Some(1024)).unwrap();
3086            assert_eq!(body["model"], "grok-4.7");
3087            assert_eq!(body["reasoning"], json!({"effort": effort}));
3088            assert_eq!(body["input"], input);
3089            assert_eq!(body["tools"], json!([tool]));
3090            assert_eq!(body["stream"], true);
3091            assert_eq!(body["max_output_tokens"], 1024);
3092            assert!(body.get("messages").is_none());
3093            assert!(body.get("reasoning_effort").is_none());
3094            // Default ciphertext does not require opting into include or
3095            // changing storage policy (official docs say ignoring it works).
3096            assert!(body.get("include").is_none());
3097            assert!(body.get("store").is_none());
3098        }
3099        assert!(body_for("grok-4.7", ReasoningLevel::Adaptive).unwrap().get("reasoning").is_none());
3100        for level in [ReasoningLevel::Off, ReasoningLevel::Max, ReasoningLevel::Ultra, ReasoningLevel::UltraCode] {
3101            assert!(body_for("grok-4.7", level).is_err(), "{level}");
3102        }
3103    }
3104
3105    #[test]
3106    fn grok46_emits_exact_documented_efforts() {
3107        for (level, effort) in [
3108            (ReasoningLevel::Low, "low"),
3109            (ReasoningLevel::Medium, "medium"),
3110            (ReasoningLevel::High, "high"),
3111        ] {
3112            let body = body_for("grok-4.6", level).expect("supported effort");
3113            assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
3114            assert_eq!(body["model"], "grok-4.6");
3115            assert_eq!(body["stream"], json!(true));
3116            assert_eq!(body["max_output_tokens"], json!(1024));
3117        }
3118    }
3119
3120    #[test]
3121    fn adaptive_omits_reasoning_field_provider_default() {
3122        for model in [
3123            "grok-4.5",
3124            "grok-4.5-latest",
3125            "grok-4.6",
3126            "grok-4.3",
3127            "grok-4.20-0309-non-reasoning",
3128        ] {
3129            let body = body_for(model, ReasoningLevel::Adaptive).expect("adaptive is omission");
3130            assert!(body.get("reasoning").is_none(), "{model}");
3131        }
3132    }
3133
3134    #[test]
3135    fn off_is_rejected_pre_network_when_reasoning_cannot_be_disabled() {
3136        for model in [
3137            "grok-4.5",
3138            "grok-4.5-latest",
3139            "grok-4.6",
3140            "grok-4.20-multi-agent-0309",
3141            "grok-4.3",
3142        ] {
3143            let err = body_for(model, ReasoningLevel::Off).unwrap_err();
3144            assert!(err.contains(model), "{model}: {err}");
3145        }
3146        // Non-reasoning model: Off is trivially satisfied by omission.
3147        let body = body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Off).unwrap();
3148        assert!(body.get("reasoning").is_none());
3149    }
3150
3151    #[test]
3152    fn unsupported_named_efforts_are_rejected_pre_network() {
3153        // 4.5/4.6 have no documented xhigh; max/ultra never exist on xAI.
3154        for level in [
3155            ReasoningLevel::XHigh,
3156            ReasoningLevel::Max,
3157            ReasoningLevel::Ultra,
3158        ] {
3159            assert!(body_for("grok-4.5", level).is_err(), "{level}");
3160            assert!(body_for("grok-4.6", level).is_err(), "{level}");
3161        }
3162        // Intrinsic-reasoning models have no documented effort control.
3163        assert!(body_for("grok-4.3", ReasoningLevel::Medium).is_err());
3164        // Non-reasoning models reject named reasoning outright.
3165        assert!(body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Medium).is_err());
3166        // Unknown exact ids fail closed.
3167        assert!(body_for("grok-9000", ReasoningLevel::Medium).is_err());
3168    }
3169
3170    #[test]
3171    fn multi_agent_xhigh_is_exact_agent_count_control() {
3172        let body = body_for("grok-4.20-multi-agent-0309", ReasoningLevel::XHigh).unwrap();
3173        assert_eq!(body["reasoning"], json!({"effort": "xhigh"}));
3174    }
3175
3176    #[test]
3177    fn responses_wire_shape_tools_flat_and_no_chat_completions_fields() {
3178        let tools = vec![json!({
3179            "type": "function",
3180            "name": "get_weather",
3181            "description": "d",
3182            "parameters": {"type": "object"}
3183        })];
3184        let body =
3185            build_xai_body("grok-4.5", ReasoningLevel::High, json!([]), tools, None).unwrap();
3186        // Responses API: flat tool objects + `input`, never chat-completions
3187        // `messages`/nested function wrappers.
3188        assert_eq!(body["tools"][0]["name"], "get_weather");
3189        assert!(body["tools"][0].get("function").is_none());
3190        assert!(body.get("input").is_some());
3191        assert!(body.get("messages").is_none());
3192        assert!(body.get("max_output_tokens").is_none());
3193    }
3194}
3195
3196#[cfg(test)]
3197mod xai_capacity_retry_tests {
3198    //! Regression tests for the xai-auth Responses route: xAI answers HTTP
3199    //! 200 + a lone flat `{"type":"error",…}` capacity event while the model
3200    //! is saturated (reproduced live: grok-4.6 failed 3 of 4 turns, a re-send
3201    //! seconds later succeeded). The route must retry with identical request
3202    //! bytes, label its messages "xAI" (not "Codex"), and never surface the
3203    //! provider prose. A scripted fake broker serves the SSE bodies directly
3204    //! through `proxy_stream` — no HTTP server involved.
3205
3206    use super::*;
3207    use agent_core::auth::{
3208        AccessToken, BrokerError, ProxyByteStream, ProxyRequest, ProxyResponse,
3209    };
3210    use async_trait::async_trait;
3211    use std::sync::{
3212        atomic::{AtomicUsize, Ordering},
3213        Arc,
3214    };
3215
3216    /// The exact envelope observed on the wire (flat: `type` is the event
3217    /// type, `code` null, `message` top-level, no nested `error` object).
3218    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";
3219
3220    const XAI_SUCCESS_SSE: &str = concat!(
3221        "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
3222        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"GROK_OK\"}\n\n",
3223        "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":2}}}\n\n",
3224    );
3225
3226    /// Serves `bodies[n]` for the n-th `proxy_stream` call (the last body
3227    /// repeats once exhausted) and records every request body it saw.
3228    struct ScriptedXaiBroker {
3229        bodies: Vec<&'static str>,
3230        calls: Arc<AtomicUsize>,
3231        seen: Arc<std::sync::Mutex<Vec<ProxyRequest>>>,
3232    }
3233
3234    #[async_trait]
3235    impl crate::auth::CredentialBroker for ScriptedXaiBroker {
3236        async fn access_token(
3237            &self,
3238            _p: agent_core::auth::OAuthProviderId,
3239        ) -> Result<AccessToken, BrokerError> {
3240            Err(BrokerError::Denied(
3241                "xai route must use proxy_stream".into(),
3242            ))
3243        }
3244        async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
3245            Err(BrokerError::Denied("not implemented in stub".into()))
3246        }
3247        async fn proxy_stream(
3248            &self,
3249            request: ProxyRequest,
3250        ) -> Result<ProxyByteStream, BrokerError> {
3251            request.validate()?;
3252            let n = self.calls.fetch_add(1, Ordering::SeqCst);
3253            self.seen.lock().unwrap().push(request);
3254            let body = self.bodies[n.min(self.bodies.len() - 1)];
3255            Ok(Box::pin(futures::stream::iter(vec![Ok(
3256                bytes::Bytes::from(body),
3257            )])))
3258        }
3259        async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
3260            Err(BrokerError::Denied("not implemented in stub".into()))
3261        }
3262        async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
3263            Ok(vec![])
3264        }
3265    }
3266
3267    struct Run {
3268        result: Result<Value, Box<dyn std::error::Error + Send + Sync>>,
3269        calls: usize,
3270        seen: Vec<ProxyRequest>,
3271        events: Vec<StreamEvent>,
3272    }
3273
3274    async fn run_xai(bodies: Vec<&'static str>, max_retries: u32) -> Run {
3275        let calls = Arc::new(AtomicUsize::new(0));
3276        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
3277        let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(ScriptedXaiBroker {
3278            bodies,
3279            calls: Arc::clone(&calls),
3280            seen: Arc::clone(&seen),
3281        });
3282        let cfg = ProviderConfig {
3283            base_url: "https://api.x.ai/v1".to_string(),
3284            model: "grok-4.5".to_string(),
3285            provider: "xai-auth".to_string(),
3286        };
3287        let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
3288        let result = call_xai_responses_stream_inner(
3289            &cfg,
3290            &broker,
3291            &[],
3292            &Some("system".to_string()),
3293            &[],
3294            &tx,
3295            None,
3296            agent_core::reasoning::ReasoningLevel::Adaptive,
3297            &tokio_util::sync::CancellationToken::new(),
3298            max_retries,
3299            &crate::runtime::trace::TraceContext::disabled(),
3300            true,
3301        )
3302        .await;
3303        drop(tx);
3304        let mut events = Vec::new();
3305        while let Ok(event) = rx.try_recv() {
3306            events.push(event);
3307        }
3308        let seen = std::mem::take(&mut *seen.lock().unwrap());
3309        Run {
3310            result,
3311            calls: calls.load(Ordering::SeqCst),
3312            seen,
3313            events,
3314        }
3315    }
3316
3317    fn text_events(events: &[StreamEvent]) -> Vec<String> {
3318        events
3319            .iter()
3320            .filter_map(|e| match e {
3321                StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(t)) => Some(t.clone()),
3322                _ => None,
3323            })
3324            .collect()
3325    }
3326
3327    #[tokio::test(start_paused = true)]
3328    async fn xai_capacity_error_is_retried_with_identical_bytes_then_succeeds() {
3329        let run = run_xai(vec![XAI_CAPACITY_SSE, XAI_SUCCESS_SSE], 3).await;
3330        let value = run.result.expect("second attempt must succeed");
3331        assert_eq!(value["content"][0]["text"], "GROK_OK");
3332        assert_eq!(run.calls, 2, "exactly one retry");
3333        assert_eq!(run.seen.len(), 2);
3334        assert_eq!(
3335            run.seen[0].body_bytes, run.seen[1].body_bytes,
3336            "retry must re-send the identical serialized body"
3337        );
3338        assert_eq!(run.seen[0].body, run.seen[1].body);
3339        assert_eq!(run.seen[0].provider, "xai-auth");
3340        assert_eq!(run.seen[0].path, "/responses");
3341        // No output was streamed twice: the failed attempt emitted nothing.
3342        assert_eq!(text_events(&run.events), vec!["GROK_OK".to_string()]);
3343    }
3344
3345    #[tokio::test(start_paused = true)]
3346    async fn xai_capacity_error_with_zero_retries_surfaces_xai_capacity_message() {
3347        let run = run_xai(vec![XAI_CAPACITY_SSE], 0).await;
3348        let err = run.result.expect_err("no retry budget → terminal");
3349        let msg = err.to_string();
3350        assert_eq!(
3351            msg,
3352            "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
3353        );
3354        assert_eq!(run.calls, 1);
3355        for banned in ["Codex", "high demand", "docs.x.ai", "service tier"] {
3356            assert!(!msg.contains(banned), "`{banned}` leaked: {msg}");
3357        }
3358        // And the runtime classifies it as an API failure, not a config one.
3359        assert!(matches!(
3360            super::super::net::provider_error_to_runtime(err),
3361            crate::RuntimeError::ApiStatus(_)
3362        ));
3363    }
3364
3365    #[tokio::test(start_paused = true)]
3366    async fn xai_capacity_error_exhausting_budget_counts_every_attempt() {
3367        let run = run_xai(vec![XAI_CAPACITY_SSE], 2).await;
3368        let err = run.result.expect_err("persistent capacity → terminal");
3369        assert!(err
3370            .to_string()
3371            .starts_with("xAI reports the model is at capacity"));
3372        assert_eq!(run.calls, 3, "initial attempt + 2 retries");
3373        assert!(text_events(&run.events).is_empty());
3374    }
3375
3376    /// If the failed attempt already streamed text, a re-send would duplicate
3377    /// it in the UI — the failure must stay terminal even with budget left.
3378    #[tokio::test(start_paused = true)]
3379    async fn xai_does_not_retry_after_partial_text_was_streamed() {
3380        const PARTIAL_THEN_ERROR: &str = concat!(
3381            "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
3382            "data: {\"type\":\"error\",\"code\":null,\"message\":\"at capacity\"}\n\n",
3383        );
3384        let run = run_xai(vec![PARTIAL_THEN_ERROR, XAI_SUCCESS_SSE], 3).await;
3385        let err = run.result.expect_err("must not retry after partial output");
3386        assert!(err
3387            .to_string()
3388            .starts_with("xAI reports the model is at capacity"));
3389        assert_eq!(run.calls, 1, "no retry once output reached the UI");
3390        assert_eq!(text_events(&run.events), vec!["partial".to_string()]);
3391    }
3392
3393    /// An unfinished tool preview is not execution; reset it before retrying.
3394    #[tokio::test(start_paused = true)]
3395    async fn xai_retries_eof_after_tool_preview_with_reset() {
3396        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";
3397        let run = run_xai(vec![TOOL_THEN_EOF, XAI_SUCCESS_SSE], 3).await;
3398        assert_eq!(run.result.unwrap()["content"][0]["text"], "GROK_OK");
3399        assert_eq!(run.calls, 2);
3400        assert_eq!(run.seen[0].body_bytes, run.seen[1].body_bytes);
3401        assert!(run
3402            .events
3403            .iter()
3404            .any(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset))));
3405    }
3406
3407    #[test]
3408    fn responses_exact_account_errors_survive_decoder_net_and_driver() {
3409        for (code, suffix, expected) in [
3410            ("invalid_api_key", RESPONSES_AUTH_SUFFIX, "auth"),
3411            ("authentication_error", RESPONSES_AUTH_SUFFIX, "auth"),
3412            (
3413                "invalid_authentication_error",
3414                RESPONSES_AUTH_SUFFIX,
3415                "auth",
3416            ),
3417            ("insufficient_quota", RESPONSES_QUOTA_SUFFIX, "quota"),
3418            ("quota_exceeded", RESPONSES_QUOTA_SUFFIX, "quota"),
3419            ("billing_error", RESPONSES_QUOTA_SUFFIX, "quota"),
3420        ] {
3421            for label in ["Codex", "OpenAI", "xAI"] {
3422                let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3423                let mut decoder = CodexSseDecoder::for_provider(label);
3424                let frame = format!(
3425                    "data: {}\n\n",
3426                    json!({"type":"response.failed", "response":{
3427                        "error":{"code":code,"message":"SECRET at capacity"}
3428                    }})
3429                );
3430                let mut text = String::new();
3431                for line in frame.lines() {
3432                    decoder.push_line(line, &tx, &mut text);
3433                }
3434                let failure = decoder.terminal_result().unwrap_err();
3435                assert_eq!(failure.message, format!("{label}{suffix}"));
3436                assert!(!failure.is_retryable());
3437                let runtime = super::super::net::provider_error_to_runtime(failure.message.into());
3438                assert!(matches!(runtime, crate::RuntimeError::ApiStatus(_)));
3439                let typed = crate::runtime::helpers::turn_error_for(&runtime, "test");
3440                assert_eq!(
3441                    crate::extensions::session_driver::classify_turn_error(&typed),
3442                    (
3443                        crate::extensions::session_driver::Outcome::ProviderError,
3444                        expected.into()
3445                    )
3446                );
3447                assert!(!typed.message.contains("SECRET"));
3448            }
3449        }
3450        for code in [
3451            "permission_denied",
3452            "context_length_exceeded",
3453            "invalid_request_error",
3454            "unknown",
3455        ] {
3456            let message = user_message_for_terminal_failure("Codex", "absent", code, false);
3457            assert_eq!(
3458                crate::extensions::session_driver::classify_error(&message).0,
3459                crate::extensions::session_driver::Outcome::Blocked
3460            );
3461        }
3462    }
3463
3464    #[test]
3465    fn explicit_policy_and_conflicting_error_classes_outrank_capacity_prose() {
3466        for (kind, code) in [
3467            ("absent", "permission_denied"),
3468            ("permission_error", "rate_limit_exceeded"),
3469            ("invalid_request_error", "insufficient_quota"),
3470            ("authentication_error", "quota_exceeded"),
3471            ("server_error", "invalid_api_key"),
3472            ("unknown", "absent"),
3473        ] {
3474            let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3475            let mut decoder = CodexSseDecoder::for_provider("Codex");
3476            let frame = format!(
3477                "data: {}",
3478                json!({"type":"response.failed", "response":{
3479                    "error":{"type":kind, "code":code, "message":"private policy at capacity"}
3480                }})
3481            );
3482            let mut text = String::new();
3483            decoder.push_line(&frame, &tx, &mut text);
3484            decoder.push_line("", &tx, &mut text);
3485            let failure = decoder.terminal_result().unwrap_err();
3486            assert!(!failure.is_retryable());
3487            assert_eq!(
3488                crate::extensions::session_driver::classify_error(&failure.message).0,
3489                crate::extensions::session_driver::Outcome::Blocked
3490            );
3491            assert!(!failure.message.contains("private"));
3492        }
3493    }
3494
3495    /// Deterministic (non-capacity) failures are terminal on the first try
3496    /// and keep the neutral, xAI-labelled, details-withheld message.
3497    #[tokio::test(start_paused = true)]
3498    async fn xai_non_capacity_error_is_terminal_and_labelled_xai() {
3499        const OTHER_ERROR: &str = "data: {\"type\":\"error\",\"code\":\"invalid_api_key\",\"message\":\"ECHOED:secret prompt\"}\n\n";
3500        let run = run_xai(vec![OTHER_ERROR, XAI_SUCCESS_SSE], 3).await;
3501        let err = run.result.expect_err("must fail");
3502        let msg = err.to_string();
3503        assert_eq!(msg, format!("xAI{RESPONSES_AUTH_SUFFIX}"));
3504        assert!(!msg.contains("ECHOED"));
3505        assert_eq!(run.calls, 1);
3506    }
3507}
3508
3509// ─── Codex wire body tests ────────────────────────────────────────────────────
3510
3511#[cfg(test)]
3512mod codex_wire_tests {
3513    use crate::runtime::openai::catalog::{
3514        plan_codex_execution, CodexMultiAgentMode, CodexRequestRole,
3515    };
3516    use agent_core::reasoning::ReasoningLevel;
3517
3518    fn plan(
3519        level: ReasoningLevel,
3520        role: CodexRequestRole,
3521    ) -> crate::runtime::openai::catalog::CodexExecutionPlan {
3522        plan_codex_execution("openai-codex/gpt-5.6-sol", level, role, None)
3523            .expect("Sol request plan")
3524    }
3525
3526    #[allow(clippy::too_many_arguments)]
3527    fn body_for_level(
3528        model: &str,
3529        level: ReasoningLevel,
3530        input: serde_json::Value,
3531        instructions: String,
3532        tools: Vec<serde_json::Value>,
3533        temperature: Option<f32>,
3534        max_tokens: Option<u32>,
3535    ) -> serde_json::Value {
3536        let qualified = format!("openai-codex/{model}");
3537        let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
3538            .expect("request plan");
3539        super::build_codex_body(
3540            model,
3541            &plan,
3542            input,
3543            instructions,
3544            tools,
3545            temperature,
3546            max_tokens,
3547            "synaps-test-cache-key",
3548        )
3549    }
3550
3551    /// Validate → emit exact reasoning.effort shape for known levels.
3552    fn codex_effort_for(model_id: &str, level: ReasoningLevel) -> Result<String, String> {
3553        let qualified = format!("openai-codex/{model_id}");
3554        let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
3555            .map_err(|error| error.to_string())?;
3556        plan.wire_effort
3557            .map(|effort| effort.as_str().to_string())
3558            .ok_or_else(|| "reasoning effort omitted".to_string())
3559    }
3560
3561    #[test]
3562    fn sol_lowers_ultra_to_max_for_requests() {
3563        let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Ultra).unwrap();
3564        assert_eq!(effort, "max");
3565    }
3566
3567    #[test]
3568    fn sol_emits_max_exact() {
3569        let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Max).unwrap();
3570        assert_eq!(effort, "max");
3571    }
3572
3573    #[test]
3574    fn sol_emits_xhigh_exact() {
3575        let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::XHigh).unwrap();
3576        assert_eq!(effort, "xhigh");
3577    }
3578
3579    #[test]
3580    fn luna_emits_max_exact() {
3581        let effort = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Max).unwrap();
3582        assert_eq!(effort, "max");
3583    }
3584
3585    #[test]
3586    fn luna_rejects_ultra_before_network() {
3587        // Validation must fail before any credential/network access.
3588        let err = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Ultra).unwrap_err();
3589        assert!(err.contains("ultra"), "{err}");
3590        assert!(err.contains("gpt-5.6-luna"), "{err}");
3591    }
3592
3593    #[test]
3594    fn gpt55_emits_xhigh_exact() {
3595        let effort = codex_effort_for("gpt-5.5", ReasoningLevel::XHigh).unwrap();
3596        assert_eq!(effort, "xhigh");
3597    }
3598
3599    #[test]
3600    fn gpt55_rejects_max_before_network() {
3601        assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Max).is_err());
3602        assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Ultra).is_err());
3603    }
3604
3605    #[test]
3606    fn off_and_adaptive_omit_reasoning_field() {
3607        // Off/Adaptive: build_codex_body must NOT emit a "reasoning" key.
3608        for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
3609            let body = body_for_level(
3610                "gpt-5.6-sol",
3611                level,
3612                serde_json::json!([]),
3613                "sys".to_string(),
3614                vec![],
3615                None,
3616                None,
3617            );
3618            assert!(
3619                body.get("reasoning").is_none(),
3620                "{level:?} must not emit reasoning field, body={body}"
3621            );
3622        }
3623    }
3624
3625    #[test]
3626    fn max_as_str_is_not_xhigh() {
3627        assert_ne!(ReasoningLevel::Max.as_str(), ReasoningLevel::XHigh.as_str());
3628        assert_eq!(ReasoningLevel::Max.as_str(), "max");
3629    }
3630
3631    #[test]
3632    fn ultra_mode_context_is_exact_proactive_developer_item() {
3633        let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
3634        assert_eq!(plan.multi_agent_mode, Some(CodexMultiAgentMode::Proactive));
3635        let item = super::codex_multi_agent_mode_item(&plan).expect("Ultra context item");
3636        assert_eq!(
3637            item.get("type").and_then(serde_json::Value::as_str),
3638            Some("message")
3639        );
3640        assert_eq!(
3641            item.get("role").and_then(serde_json::Value::as_str),
3642            Some("developer")
3643        );
3644        assert_eq!(
3645            item.pointer("/content/0/text")
3646                .and_then(serde_json::Value::as_str),
3647            Some(concat!(
3648                "<multi_agent_mode>",
3649                "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.",
3650                "</multi_agent_mode>"
3651            ))
3652        );
3653    }
3654
3655    #[test]
3656    fn max_and_xhigh_mode_context_is_exact_explicit_only_item() {
3657        for level in [ReasoningLevel::Max, ReasoningLevel::XHigh] {
3658            let plan = plan(level, CodexRequestRole::Foreground);
3659            assert_eq!(
3660                plan.multi_agent_mode,
3661                Some(CodexMultiAgentMode::ExplicitRequestOnly)
3662            );
3663            let item = super::codex_multi_agent_mode_item(&plan).expect("v2 context item");
3664            assert_eq!(
3665                item.pointer("/content/0/text")
3666                    .and_then(serde_json::Value::as_str),
3667                Some(concat!(
3668                    "<multi_agent_mode>",
3669                    "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.",
3670                    "</multi_agent_mode>"
3671                )),
3672                "{level:?}"
3673            );
3674        }
3675    }
3676
3677    #[test]
3678    fn mode_context_is_inserted_once_at_stable_head_position() {
3679        let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
3680        let mut input = vec![
3681            serde_json::json!({"role":"user","content":"first"}),
3682            serde_json::json!({"role":"assistant","content":"answer"}),
3683            serde_json::json!({"role":"user","content":"current"}),
3684        ];
3685        super::insert_codex_multi_agent_mode(&mut input, &plan);
3686        let mode_positions = input
3687            .iter()
3688            .enumerate()
3689            .filter(|(_, item)| {
3690                item.pointer("/content/0/text")
3691                    .and_then(serde_json::Value::as_str)
3692                    .is_some_and(|text| text.starts_with("<multi_agent_mode>"))
3693            })
3694            .map(|(index, _)| index)
3695            .collect::<Vec<_>>();
3696        assert_eq!(mode_positions, vec![0]);
3697        assert_eq!(
3698            input[3].get("content").and_then(serde_json::Value::as_str),
3699            Some("current")
3700        );
3701    }
3702
3703    /// Prefix-cache stability: as the conversation grows across user turns,
3704    /// the already-sent items must remain a byte-identical prefix of the next
3705    /// request's input. The old before-last-user placement moved the mode
3706    /// item every turn, invalidating the cached prefix at the prior user
3707    /// message and re-billing the entire previous agentic turn uncached.
3708    #[test]
3709    fn mode_context_placement_preserves_prompt_prefix_across_turns() {
3710        let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
3711        let turn1 = vec![
3712            serde_json::json!({"role":"user","content":"first"}),
3713            serde_json::json!({"role":"assistant","content":"answer"}),
3714        ];
3715        let mut turn2 = turn1.clone();
3716        turn2.push(serde_json::json!({"role":"user","content":"follow-up"}));
3717        let mut input1 = turn1;
3718        let mut input2 = turn2;
3719        super::insert_codex_multi_agent_mode(&mut input1, &plan);
3720        super::insert_codex_multi_agent_mode(&mut input2, &plan);
3721        assert_eq!(
3722            &input2[..input1.len()],
3723            &input1[..],
3724            "turn N's input must be a strict prefix of turn N+1's input"
3725        );
3726    }
3727
3728    #[test]
3729    fn prompt_cache_key_is_stable_for_the_same_conversation_head() {
3730        let first = serde_json::json!({"role":"user","content":"hello"});
3731        let a = super::codex_prompt_cache_key("sys", Some(&first));
3732        let b = super::codex_prompt_cache_key("sys", Some(&first));
3733        assert_eq!(a, b);
3734        assert!(a.starts_with("synaps-"), "{a}");
3735    }
3736
3737    #[test]
3738    fn prompt_cache_key_differs_across_conversations() {
3739        let first = serde_json::json!({"role":"user","content":"hello"});
3740        let other = serde_json::json!({"role":"user","content":"different task"});
3741        assert_ne!(
3742            super::codex_prompt_cache_key("sys", Some(&first)),
3743            super::codex_prompt_cache_key("sys", Some(&other)),
3744        );
3745        assert_ne!(
3746            super::codex_prompt_cache_key("sys", Some(&first)),
3747            super::codex_prompt_cache_key("other-sys", Some(&first)),
3748        );
3749    }
3750
3751    #[test]
3752    fn body_carries_prompt_cache_key() {
3753        let body = body_for_level(
3754            "gpt-5.6-sol",
3755            ReasoningLevel::Medium,
3756            serde_json::json!([]),
3757            "sys".to_string(),
3758            vec![],
3759            None,
3760            None,
3761        );
3762        assert_eq!(
3763            body.get("prompt_cache_key")
3764                .and_then(serde_json::Value::as_str),
3765            Some("synaps-test-cache-key")
3766        );
3767    }
3768
3769    #[test]
3770    fn worker_ultra_has_no_mode_context_item() {
3771        let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Worker);
3772        assert!(super::codex_multi_agent_mode_item(&plan).is_none());
3773    }
3774
3775    // ── Pure body construction tests (zero network) ──────────────────────────
3776
3777    #[test]
3778    fn body_lowers_ultra_reasoning_effort_to_max() {
3779        let body = body_for_level(
3780            "gpt-5.6-sol",
3781            ReasoningLevel::Ultra,
3782            serde_json::json!([]),
3783            "sys".to_string(),
3784            vec![],
3785            None,
3786            None,
3787        );
3788        let effort = body
3789            .get("reasoning")
3790            .and_then(|r| r.get("effort"))
3791            .and_then(serde_json::Value::as_str)
3792            .expect("reasoning.effort must be present for Ultra");
3793        assert_eq!(effort, "max");
3794    }
3795
3796    #[test]
3797    fn body_contains_reasoning_effort_for_max() {
3798        let body = body_for_level(
3799            "gpt-5.6-sol",
3800            ReasoningLevel::Max,
3801            serde_json::json!([]),
3802            "sys".to_string(),
3803            vec![],
3804            None,
3805            None,
3806        );
3807        let effort = body
3808            .get("reasoning")
3809            .and_then(|r| r.get("effort"))
3810            .and_then(serde_json::Value::as_str)
3811            .expect("reasoning.effort must be present for Max");
3812        assert_eq!(effort, "max");
3813        // max must NOT be xhigh (critical invariant)
3814        assert_ne!(effort, "xhigh");
3815    }
3816
3817    #[test]
3818    fn body_contains_reasoning_effort_for_xhigh() {
3819        let body = body_for_level(
3820            "gpt-5.6-sol",
3821            ReasoningLevel::XHigh,
3822            serde_json::json!([]),
3823            "sys".to_string(),
3824            vec![],
3825            None,
3826            None,
3827        );
3828        let effort = body
3829            .get("reasoning")
3830            .and_then(|r| r.get("effort"))
3831            .and_then(serde_json::Value::as_str)
3832            .expect("reasoning.effort must be present for XHigh");
3833        assert_eq!(effort, "xhigh");
3834    }
3835
3836    #[test]
3837    fn body_contains_reasoning_effort_for_low_medium_high() {
3838        for (level, expected) in [
3839            (ReasoningLevel::Low, "low"),
3840            (ReasoningLevel::Medium, "medium"),
3841            (ReasoningLevel::High, "high"),
3842        ] {
3843            let body = body_for_level(
3844                "gpt-5.6-sol",
3845                level,
3846                serde_json::json!([]),
3847                "sys".to_string(),
3848                vec![],
3849                None,
3850                None,
3851            );
3852            let effort = body
3853                .get("reasoning")
3854                .and_then(|r| r.get("effort"))
3855                .and_then(serde_json::Value::as_str)
3856                .unwrap_or_else(|| panic!("{level:?} must emit reasoning.effort"));
3857            assert_eq!(effort, expected, "{level:?}");
3858        }
3859    }
3860
3861    #[test]
3862    fn body_always_sets_model_and_stream_and_include() {
3863        let body = body_for_level(
3864            "gpt-5.5",
3865            ReasoningLevel::XHigh,
3866            serde_json::json!([]),
3867            "instructions".to_string(),
3868            vec![],
3869            None,
3870            None,
3871        );
3872        assert_eq!(
3873            body.get("model").and_then(serde_json::Value::as_str),
3874            Some("gpt-5.5")
3875        );
3876        assert_eq!(
3877            body.get("stream").and_then(serde_json::Value::as_bool),
3878            Some(true)
3879        );
3880        let includes = body.get("include").expect("include must be present");
3881        assert!(
3882            includes.as_array().is_some_and(|a| {
3883                a.iter()
3884                    .any(|v| v.as_str() == Some("reasoning.encrypted_content"))
3885            }),
3886            "include must contain reasoning.encrypted_content"
3887        );
3888        assert_eq!(
3889            body.get("store").and_then(serde_json::Value::as_bool),
3890            Some(false)
3891        );
3892    }
3893
3894    #[test]
3895    fn body_omits_temperature_and_max_tokens_when_none() {
3896        let body = body_for_level(
3897            "gpt-5.6-sol",
3898            ReasoningLevel::Medium,
3899            serde_json::json!([]),
3900            "sys".to_string(),
3901            vec![],
3902            None,
3903            None,
3904        );
3905        assert!(body.get("temperature").is_none());
3906        assert!(body.get("max_output_tokens").is_none());
3907    }
3908
3909    #[test]
3910    fn body_includes_temperature_and_max_tokens_when_some() {
3911        let body = body_for_level(
3912            "gpt-5.6-sol",
3913            ReasoningLevel::High,
3914            serde_json::json!([]),
3915            "sys".to_string(),
3916            vec![],
3917            Some(0.7),
3918            Some(2048),
3919        );
3920        let temp = body
3921            .get("temperature")
3922            .and_then(serde_json::Value::as_f64)
3923            .unwrap();
3924        assert!((temp - 0.7f64).abs() < 0.01, "temperature={temp}");
3925        assert_eq!(
3926            body.get("max_output_tokens")
3927                .and_then(serde_json::Value::as_u64),
3928            Some(2048)
3929        );
3930    }
3931}
3932
3933#[cfg(test)]
3934mod send_retry_tests {
3935    //! Regression tests for transient-failure retry on the codex send path
3936    //! (incident: session 20260714-025948-3dab — a single transport failure
3937    //! against chatgpt.com aborted a whole autonomous turn because the codex
3938    //! route was single-shot while the Anthropic route retried).
3939    //!
3940    //! Mirrors the `on401_tests` mock-server pattern in `runtime/api.rs`.
3941    //! Credentials cross the broker boundary: a stub broker vends a
3942    //! JWT-shaped token carrying the ChatGPT account-id claim.
3943
3944    use super::*;
3945    use agent_core::auth::{
3946        AccessToken, BrokerError, ProxyByteStream, ProxyRequest, ProxyResponse,
3947    };
3948    use agent_core::reasoning::ReasoningLevel;
3949    use async_trait::async_trait;
3950    use axum::{http::StatusCode, response::IntoResponse, routing::post as axum_post, Router};
3951    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
3952    use std::sync::{
3953        atomic::{AtomicUsize, Ordering},
3954        Arc,
3955    };
3956    use tokio::sync::mpsc;
3957
3958    /// Minimal Responses-API SSE success body: one text delta + completed.
3959    const CODEX_SSE_SUCCESS: &str = concat!(
3960        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n",
3961        "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n",
3962        "data: [DONE]\n\n",
3963    );
3964
3965    /// JWT-shaped token whose payload carries the ChatGPT account-id claim
3966    /// `extract_codex_account_id` looks for. Signature is irrelevant — the
3967    /// mock server never validates it.
3968    fn fake_codex_token() -> String {
3969        let payload = serde_json::json!({
3970            "https://api.openai.com/auth": { "chatgpt_account_id": "acct_test" }
3971        });
3972        format!("h.{}.s", URL_SAFE_NO_PAD.encode(payload.to_string()))
3973    }
3974
3975    /// Stub broker that vends the fake codex token. Everything else is
3976    /// unreachable in these tests and fails closed.
3977    struct TokenOnlyBroker;
3978
3979    #[async_trait]
3980    impl crate::auth::CredentialBroker for TokenOnlyBroker {
3981        async fn access_token(
3982            &self,
3983            _p: agent_core::auth::OAuthProviderId,
3984        ) -> Result<AccessToken, BrokerError> {
3985            Ok(AccessToken {
3986                token: fake_codex_token(),
3987                expires: u64::MAX,
3988            })
3989        }
3990        async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
3991            Err(BrokerError::Denied("not implemented in stub".into()))
3992        }
3993        async fn proxy_stream(
3994            &self,
3995            _request: ProxyRequest,
3996        ) -> Result<ProxyByteStream, BrokerError> {
3997            Err(BrokerError::Denied("not implemented in stub".into()))
3998        }
3999        async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
4000            Err(BrokerError::Denied("not implemented in stub".into()))
4001        }
4002        async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
4003            Ok(vec![])
4004        }
4005    }
4006
4007    struct DenyingCountingBroker {
4008        access_calls: Arc<AtomicUsize>,
4009    }
4010
4011    #[async_trait]
4012    impl crate::auth::CredentialBroker for DenyingCountingBroker {
4013        async fn access_token(
4014            &self,
4015            _provider: agent_core::auth::OAuthProviderId,
4016        ) -> Result<AccessToken, BrokerError> {
4017            self.access_calls.fetch_add(1, Ordering::SeqCst);
4018            Err(BrokerError::Denied(
4019                "credential access must not occur".into(),
4020            ))
4021        }
4022
4023        async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
4024            Err(BrokerError::Denied("not implemented in stub".into()))
4025        }
4026
4027        async fn proxy_stream(
4028            &self,
4029            _request: ProxyRequest,
4030        ) -> Result<ProxyByteStream, BrokerError> {
4031            Err(BrokerError::Denied("not implemented in stub".into()))
4032        }
4033
4034        async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
4035            Err(BrokerError::Denied("not implemented in stub".into()))
4036        }
4037
4038        async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
4039            Ok(vec![])
4040        }
4041    }
4042
4043    /// Spawn a mock Codex endpoint. First `fail_count` POSTs → `fail_status`;
4044    /// subsequent → SSE success. Returns (base_url, call_counter).
4045    async fn spawn_mock_codex(
4046        fail_count: usize,
4047        fail_status: StatusCode,
4048    ) -> (String, Arc<AtomicUsize>) {
4049        let counter = Arc::new(AtomicUsize::new(0));
4050        let counter_clone = Arc::clone(&counter);
4051        let app = Router::new().route(
4052            "/codex/responses",
4053            axum_post(move || {
4054                let counter = Arc::clone(&counter_clone);
4055                async move {
4056                    let n = counter.fetch_add(1, Ordering::SeqCst);
4057                    if n < fail_count {
4058                        (
4059                            fail_status,
4060                            [("content-type", "application/json")],
4061                            "{\"error\":{\"message\":\"transient upstream sadness\"}}".to_string(),
4062                        )
4063                            .into_response()
4064                    } else {
4065                        (
4066                            StatusCode::OK,
4067                            [("content-type", "text/event-stream")],
4068                            CODEX_SSE_SUCCESS.to_string(),
4069                        )
4070                            .into_response()
4071                    }
4072                }
4073            }),
4074        );
4075        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4076        let addr = listener.local_addr().unwrap();
4077        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4078        (format!("http://{addr}"), counter)
4079    }
4080
4081    async fn spawn_codex_sse(body: &'static str) -> String {
4082        let app = Router::new().route(
4083            "/codex/responses",
4084            axum_post(move || async move {
4085                (
4086                    StatusCode::OK,
4087                    [("content-type", "text/event-stream")],
4088                    body,
4089                )
4090            }),
4091        );
4092        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4093        let addr = listener.local_addr().unwrap();
4094        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4095        format!("http://{addr}")
4096    }
4097
4098    async fn spawn_empty_then_success_codex() -> (
4099        String,
4100        Arc<AtomicUsize>,
4101        Arc<std::sync::Mutex<Vec<bytes::Bytes>>>,
4102    ) {
4103        let counter = Arc::new(AtomicUsize::new(0));
4104        let bodies = Arc::new(std::sync::Mutex::new(Vec::new()));
4105        let app = Router::new().route(
4106            "/codex/responses",
4107            axum_post({
4108                let counter = Arc::clone(&counter);
4109                let bodies = Arc::clone(&bodies);
4110                move |body: bytes::Bytes| {
4111                    let counter = Arc::clone(&counter);
4112                    let bodies = Arc::clone(&bodies);
4113                    async move {
4114                        bodies.lock().unwrap().push(body);
4115                        let n = counter.fetch_add(1, Ordering::SeqCst);
4116                        let response = if n == 0 {
4117                            concat!(
4118                                "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":0}}}\n\n",
4119                                "data: [DONE]\n\n",
4120                            )
4121                        } else {
4122                            CODEX_SSE_SUCCESS
4123                        };
4124                        (
4125                            StatusCode::OK,
4126                            [("content-type", "text/event-stream")],
4127                            response,
4128                        )
4129                    }
4130                }
4131            }),
4132        );
4133        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4134        let addr = listener.local_addr().unwrap();
4135        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4136        (format!("http://{addr}"), counter, bodies)
4137    }
4138
4139    async fn run_codex(
4140        base_url: &str,
4141        max_retries: u32,
4142    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
4143        let cfg = ProviderConfig {
4144            base_url: base_url.to_string(),
4145            // The incident model — validated against the static catalog ladder.
4146            model: "gpt-5.6-sol".to_string(),
4147            provider: "openai-codex".to_string(),
4148        };
4149        let client = reqwest::Client::new();
4150        let broker: std::sync::Arc<dyn crate::auth::CredentialBroker> =
4151            std::sync::Arc::new(TokenOnlyBroker);
4152        let (tx, _rx) = mpsc::unbounded_channel();
4153        call_codex_stream_inner(
4154            &cfg,
4155            &client,
4156            &broker,
4157            &[],
4158            &Some("test".to_string()),
4159            &[],
4160            &tx,
4161            None,
4162            None,
4163            agent_core::reasoning::ReasoningLevel::Medium,
4164            crate::runtime::openai::catalog::CodexRequestRole::Foreground,
4165            &tokio_util::sync::CancellationToken::new(),
4166            max_retries,
4167            &crate::runtime::trace::TraceContext::disabled(),
4168        )
4169        .await
4170    }
4171
4172    // Raw HTTP is intentional: axum's complete bodies cannot reproduce the
4173    // reported EOF in an HTTP/1.1 chunk-size line.
4174    async fn broken_chunk_server(
4175        scripts: Vec<(&'static str, bool)>,
4176    ) -> (
4177        String,
4178        Arc<std::sync::Mutex<Vec<Vec<u8>>>>,
4179        tokio::task::JoinHandle<()>,
4180    ) {
4181        use tokio::io::{AsyncReadExt, AsyncWriteExt};
4182        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4183        let address = listener.local_addr().unwrap();
4184        let requests = Arc::new(std::sync::Mutex::new(Vec::new()));
4185        let seen = requests.clone();
4186        let task = tokio::spawn(async move {
4187            let mut attempt = 0;
4188            loop {
4189                let (mut socket, _) = listener.accept().await.unwrap();
4190                let mut request = Vec::new();
4191                let end = loop {
4192                    let mut chunk = [0; 8192];
4193                    let n = socket.read(&mut chunk).await.unwrap();
4194                    if n == 0 {
4195                        return;
4196                    }
4197                    request.extend_from_slice(&chunk[..n]);
4198                    if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") {
4199                        break end + 4;
4200                    }
4201                };
4202                let length: usize = String::from_utf8_lossy(&request[..end])
4203                    .lines()
4204                    .find_map(|line| {
4205                        line.to_ascii_lowercase()
4206                            .strip_prefix("content-length: ")
4207                            .map(str::to_owned)
4208                    })
4209                    .unwrap()
4210                    .parse()
4211                    .unwrap();
4212                while request.len() < end + length {
4213                    let mut chunk = [0; 8192];
4214                    let n = socket.read(&mut chunk).await.unwrap();
4215                    if n == 0 {
4216                        return;
4217                    }
4218                    request.extend_from_slice(&chunk[..n]);
4219                }
4220                seen.lock()
4221                    .unwrap()
4222                    .push(request[end..end + length].to_vec());
4223                let (body, broken) = scripts[attempt.min(scripts.len() - 1)];
4224                attempt += 1;
4225                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();
4226                socket
4227                    .write_all(format!("{:x}\r\n{}\r\n", body.len(), body).as_bytes())
4228                    .await
4229                    .unwrap();
4230                // Omitting the 0 chunk produces precisely the user's failure.
4231                if !broken {
4232                    socket.write_all(b"0\r\n\r\n").await.unwrap();
4233                }
4234                socket.shutdown().await.unwrap();
4235            }
4236        });
4237        (format!("http://{address}"), requests, task)
4238    }
4239
4240    async fn codex_with_events(
4241        base: &str,
4242        retries: u32,
4243        cancel: &tokio_util::sync::CancellationToken,
4244        tx: &mpsc::UnboundedSender<StreamEvent>,
4245    ) -> Result<Value, super::super::net::BoxedProviderError> {
4246        let cfg = ProviderConfig {
4247            base_url: base.into(),
4248            model: "gpt-5.6-sol".into(),
4249            provider: "openai-codex".into(),
4250        };
4251        let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(TokenOnlyBroker);
4252        call_codex_stream_inner(
4253            &cfg,
4254            &reqwest::Client::new(),
4255            &broker,
4256            &[],
4257            &Some("test".into()),
4258            &[],
4259            tx,
4260            None,
4261            None,
4262            agent_core::reasoning::ReasoningLevel::Medium,
4263            crate::runtime::openai::catalog::CodexRequestRole::Foreground,
4264            cancel,
4265            retries,
4266            &crate::runtime::trace::TraceContext::disabled(),
4267        )
4268        .await
4269    }
4270
4271    const PARTIAL_TOOL: &str = concat!(
4272        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"discard me\"}\n\n",
4273        "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"old_call\",\"name\":\"write\",\"arguments\":\"\"}}\n\n",
4274        "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"{\\\"path\\\":\"}\n\n",
4275    );
4276    const COMPLETE_TOOL: &str = concat!(
4277        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"accepted\"}\n\n",
4278        "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"new_call\",\"name\":\"write\",\"arguments\":\"{\\\"path\\\":\\\"ok\\\"}\"}}\n\n",
4279        "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}}\n\n",
4280    );
4281
4282    #[tokio::test]
4283    async fn interrupted_partial_tool_retries_identical_bytes_and_commits_only_success() {
4284        use crate::LlmEvent;
4285        let (url, requests, server) =
4286            broken_chunk_server(vec![(PARTIAL_TOOL, true), (COMPLETE_TOOL, false)]).await;
4287        let (tx, mut rx) = mpsc::unbounded_channel();
4288        let result = codex_with_events(&url, 1, &tokio_util::sync::CancellationToken::new(), &tx)
4289            .await
4290            .unwrap();
4291        server.abort();
4292        let requests = requests.lock().unwrap();
4293        assert_eq!(requests.len(), 2);
4294        assert_eq!(requests[0], requests[1]);
4295        let tools: Vec<_> = result["content"]
4296            .as_array()
4297            .unwrap()
4298            .iter()
4299            .filter(|v| v["type"] == "tool_use")
4300            .collect();
4301        assert_eq!(tools.len(), 1);
4302        assert_eq!(tools[0]["id"], "new_call");
4303        assert_eq!(result["content"][0]["text"], "accepted");
4304        let mut preview = String::from("prior completed round");
4305        let mut baseline = 0;
4306        let mut resets = 0;
4307        let mut input_tokens = 0;
4308        while let Ok(event) = rx.try_recv() {
4309            match event {
4310                StreamEvent::Llm(LlmEvent::ResponseStart) => baseline = preview.len(),
4311                StreamEvent::Llm(LlmEvent::ResponseReset) => {
4312                    preview.truncate(baseline);
4313                    resets += 1;
4314                }
4315                StreamEvent::Llm(LlmEvent::Text(t)) => preview.push_str(&t),
4316                StreamEvent::Session(crate::SessionEvent::Usage {
4317                    input_tokens: n, ..
4318                }) => input_tokens += n,
4319                _ => {}
4320            }
4321        }
4322        assert_eq!(resets, 1);
4323        assert_eq!(preview, "prior completed roundaccepted");
4324        assert_eq!(input_tokens, 3);
4325    }
4326
4327    #[tokio::test]
4328    async fn completed_response_followed_by_chunk_error_is_not_replayed() {
4329        let (url, requests, server) = broken_chunk_server(vec![(CODEX_SSE_SUCCESS, true)]).await;
4330        assert!(run_codex(&url, 2).await.is_ok());
4331        server.abort();
4332        assert_eq!(requests.lock().unwrap().len(), 1);
4333    }
4334
4335    #[tokio::test]
4336    async fn clean_truncated_eof_retries_but_never_accepts_partial_tools() {
4337        let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, false)]).await;
4338        assert!(run_codex(&url, 1).await.is_err());
4339        server.abort();
4340        assert_eq!(requests.lock().unwrap().len(), 2);
4341    }
4342
4343    #[tokio::test]
4344    async fn broken_chunk_retry_budget_is_finite() {
4345        let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, true)]).await;
4346        assert!(run_codex(&url, 1).await.is_err());
4347        server.abort();
4348        assert_eq!(requests.lock().unwrap().len(), 2);
4349    }
4350
4351    #[tokio::test]
4352    async fn cancellation_during_stream_retry_prevents_second_dispatch() {
4353        let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, true)]).await;
4354        let (tx, mut rx) = mpsc::unbounded_channel();
4355        let cancel = tokio_util::sync::CancellationToken::new();
4356        let waiter = cancel.clone();
4357        let task = tokio::spawn(async move {
4358            while let Some(event) = rx.recv().await {
4359                if matches!(event, StreamEvent::Session(crate::SessionEvent::Notice(_))) {
4360                    waiter.cancel();
4361                    break;
4362                }
4363            }
4364        });
4365        let error = codex_with_events(&url, 10, &cancel, &tx).await.unwrap_err();
4366        task.await.unwrap();
4367        server.abort();
4368        assert_eq!(error.to_string(), "request canceled");
4369        assert_eq!(requests.lock().unwrap().len(), 1);
4370    }
4371
4372    #[tokio::test]
4373    async fn direct_ultra_tool_guard_precedes_broker_access() {
4374        let access_calls = Arc::new(AtomicUsize::new(0));
4375        let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
4376            access_calls: Arc::clone(&access_calls),
4377        });
4378        let cfg = ProviderConfig {
4379            base_url: "http://127.0.0.1:1".to_string(),
4380            model: "gpt-5.6-sol".to_string(),
4381            provider: "openai-codex".to_string(),
4382        };
4383        let (tx, _rx) = mpsc::unbounded_channel();
4384
4385        let error = call_codex_stream_inner(
4386            &cfg,
4387            &reqwest::Client::new(),
4388            &broker,
4389            &[],
4390            &Some("test".to_string()),
4391            &[],
4392            &tx,
4393            None,
4394            None,
4395            ReasoningLevel::Ultra,
4396            crate::runtime::openai::catalog::CodexRequestRole::Foreground,
4397            &tokio_util::sync::CancellationToken::new(),
4398            0,
4399            &crate::runtime::trace::TraceContext::disabled(),
4400        )
4401        .await
4402        .expect_err("foreground Ultra without delegation tools must fail closed");
4403
4404        assert!(error.to_string().contains("subagent_start"), "{error}");
4405        assert_eq!(
4406            access_calls.load(Ordering::SeqCst),
4407            0,
4408            "preflight denial must happen before broker credential access"
4409        );
4410    }
4411
4412    #[tokio::test]
4413    async fn direct_codex_path_rejects_provider_identity_before_broker_access() {
4414        let access_calls = Arc::new(AtomicUsize::new(0));
4415        let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
4416            access_calls: Arc::clone(&access_calls),
4417        });
4418        let cfg = ProviderConfig {
4419            base_url: "http://127.0.0.1:1".to_string(),
4420            model: "gpt-5.6-sol".to_string(),
4421            provider: "openrouter".to_string(),
4422        };
4423        let (tx, _rx) = mpsc::unbounded_channel();
4424
4425        let error = call_codex_stream_inner(
4426            &cfg,
4427            &reqwest::Client::new(),
4428            &broker,
4429            &[],
4430            &Some("test".to_string()),
4431            &[],
4432            &tx,
4433            None,
4434            None,
4435            ReasoningLevel::Medium,
4436            crate::runtime::openai::catalog::CodexRequestRole::Foreground,
4437            &tokio_util::sync::CancellationToken::new(),
4438            0,
4439            &crate::runtime::trace::TraceContext::disabled(),
4440        )
4441        .await
4442        .expect_err("the Codex seam must validate provider-qualified identity");
4443
4444        assert!(error.to_string().contains("openrouter"), "{error}");
4445        assert_eq!(
4446            access_calls.load(Ordering::SeqCst),
4447            0,
4448            "provider identity denial must precede broker credential access"
4449        );
4450    }
4451
4452    /// A stream whose terminal event is `response.failed` must surface a
4453    /// typed API failure at the transport boundary — never `Ok` with empty
4454    /// content (the "model returned an empty response" misdiagnosis,
4455    /// incident: sessions 20260720-022603-6c2a / turn-2629074-0). The
4456    /// provider-controlled error body must not leak into the message.
4457    #[tokio::test]
4458    async fn codex_response_failed_event_is_a_terminal_error_not_empty_success() {
4459        let base_url = spawn_codex_sse(concat!(
4460            "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:secret prompt\"}}}\n\n",
4461            "data: [DONE]\n\n",
4462        ))
4463        .await;
4464        let err = run_codex(&base_url, 0)
4465            .await
4466            .expect_err("response.failed must fail the request");
4467        let msg = err.to_string();
4468        assert!(
4469            msg == format!("Codex{RESPONSES_CAPACITY_SUFFIX}"),
4470            "static transient failure message expected: {msg}"
4471        );
4472        assert!(
4473            !msg.contains("ECHOED") && !msg.contains("secret prompt"),
4474            "provider error body must never surface: {msg}"
4475        );
4476    }
4477
4478    /// `response.incomplete` is a terminal non-success: surfacing it as an
4479    /// empty `Ok` poisoned the turn with the generic empty-response error.
4480    #[tokio::test]
4481    async fn codex_response_incomplete_event_is_a_terminal_error() {
4482        let base_url = spawn_codex_sse(concat!(
4483            "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
4484            "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
4485            "data: [DONE]\n\n",
4486        ))
4487        .await;
4488        let err = run_codex(&base_url, 0)
4489            .await
4490            .expect_err("response.incomplete must fail the request");
4491        assert!(
4492            err.to_string()
4493                .starts_with("Codex response was incomplete."),
4494            "got: {err}"
4495        );
4496    }
4497
4498    /// EOF without any terminal Responses event (mid-stream connection drop
4499    /// after HTTP 200) must fail closed instead of returning empty content.
4500    #[tokio::test]
4501    async fn codex_eof_without_terminal_event_fails_closed() {
4502        let base_url = spawn_codex_sse(
4503            "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
4504        )
4505        .await;
4506        let err = run_codex(&base_url, 0)
4507            .await
4508            .expect_err("EOF without terminal event must fail");
4509        assert!(
4510            err.to_string()
4511                .starts_with("Codex response stream ended without a terminal event."),
4512            "got: {err}"
4513        );
4514    }
4515
4516    #[tokio::test]
4517    async fn codex_completed_without_output_retries_identical_bytes_then_succeeds() {
4518        let (base_url, counter, bodies) = spawn_empty_then_success_codex().await;
4519        let result = run_codex(&base_url, 1)
4520            .await
4521            .expect("one empty-success stream with retries available must not abort the turn");
4522
4523        assert_eq!(counter.load(Ordering::SeqCst), 2, "expected one retry");
4524        let received = bodies.lock().unwrap();
4525        assert_eq!(received.len(), 2, "both attempts must reach the endpoint");
4526        assert_eq!(
4527            received[0], received[1],
4528            "the logical request must resend byte-identical content"
4529        );
4530        assert_eq!(result["content"][0]["text"], "hello");
4531    }
4532
4533    /// Mock Codex endpoint that records every request's (session-id,
4534    /// thread-id, prompt_cache_key). The first `fail_count` POSTs fail 500.
4535    async fn spawn_header_recording_codex(
4536        fail_count: usize,
4537    ) -> (String, Arc<std::sync::Mutex<Vec<(String, String, String)>>>) {
4538        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
4539        let counter = Arc::new(AtomicUsize::new(0));
4540        let app = Router::new().route(
4541            "/codex/responses",
4542            axum_post({
4543                let seen = Arc::clone(&seen);
4544                move |headers: axum::http::HeaderMap, body: bytes::Bytes| {
4545                    let seen = Arc::clone(&seen);
4546                    let counter = Arc::clone(&counter);
4547                    async move {
4548                        let get = |name: &str| {
4549                            headers
4550                                .get(name)
4551                                .and_then(|v| v.to_str().ok())
4552                                .unwrap_or("")
4553                                .to_string()
4554                        };
4555                        let key = serde_json::from_slice::<Value>(&body)
4556                            .ok()
4557                            .and_then(|b| b["prompt_cache_key"].as_str().map(str::to_string))
4558                            .unwrap_or_default();
4559                        seen.lock()
4560                            .unwrap()
4561                            .push((get("session-id"), get("thread-id"), key));
4562                        if counter.fetch_add(1, Ordering::SeqCst) < fail_count {
4563                            (StatusCode::INTERNAL_SERVER_ERROR, "{}".to_string()).into_response()
4564                        } else {
4565                            (
4566                                StatusCode::OK,
4567                                [("content-type", "text/event-stream")],
4568                                CODEX_SSE_SUCCESS.to_string(),
4569                            )
4570                                .into_response()
4571                        }
4572                    }
4573                }
4574            }),
4575        );
4576        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4577        let addr = listener.local_addr().unwrap();
4578        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4579        (format!("http://{addr}"), seen)
4580    }
4581
4582    /// The ChatGPT backend derives prompt-cache affinity from the `session-id`
4583    /// header (Codex CLI `responses_session_id`); without it every request
4584    /// reported `cached_tokens: 0`. Measured A/B: no header 0/0/0 cached;
4585    /// with `session-id`/`thread-id` = prompt_cache_key, 0 then 95%, 95%.
4586    #[tokio::test]
4587    async fn codex_requests_carry_session_and_thread_id_equal_to_the_cache_key() {
4588        let (base_url, seen) = spawn_header_recording_codex(1).await;
4589        run_codex(&base_url, 2)
4590            .await
4591            .expect("one transient 500 then success");
4592        let seen = seen.lock().unwrap();
4593        assert_eq!(seen.len(), 2, "first attempt + one retry");
4594        for (session_id, thread_id, key) in seen.iter() {
4595            assert!(
4596                key.starts_with("synaps-"),
4597                "prompt_cache_key in body: {key:?}"
4598            );
4599            assert_eq!(session_id, key, "session-id header = prompt_cache_key");
4600            assert_eq!(thread_id, key, "thread-id header = prompt_cache_key");
4601        }
4602        assert_eq!(seen[0], seen[1], "a retry keeps the same cache routing");
4603    }
4604
4605    #[tokio::test]
4606    async fn codex_retries_transient_500_then_succeeds() {
4607        let (base_url, counter) = spawn_mock_codex(1, StatusCode::INTERNAL_SERVER_ERROR).await;
4608        let result = run_codex(&base_url, 2)
4609            .await
4610            .expect("one transient 500 with retries available must not abort the turn");
4611        assert_eq!(
4612            counter.load(Ordering::SeqCst),
4613            2,
4614            "expected exactly one retry"
4615        );
4616        let text = result["content"][0]["text"].as_str().unwrap_or_default();
4617        assert_eq!(text, "hello");
4618    }
4619
4620    #[tokio::test]
4621    async fn codex_response_failed_surfaces_typed_provider_error_not_empty_response() {
4622        const BODY: &str = concat!(
4623            "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:private prompt\"}}}\n\n",
4624            "data: [DONE]\n\n",
4625        );
4626        let base_url = spawn_codex_sse(BODY).await;
4627        let err = run_codex(&base_url, 0).await.expect_err("must fail");
4628        let msg = err.to_string();
4629        assert_eq!(msg, format!("Codex{RESPONSES_CAPACITY_SUFFIX}"));
4630        assert!(!msg.contains("empty response"), "{msg}");
4631        assert!(!msg.contains("ECHOED"), "provider text leaked: {msg}");
4632        assert!(
4633            !msg.contains("private prompt"),
4634            "provider text leaked: {msg}"
4635        );
4636    }
4637
4638    #[tokio::test]
4639    async fn codex_response_incomplete_surfaces_typed_provider_error_not_empty_response() {
4640        const BODY: &str = concat!(
4641            "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
4642            "data: [DONE]\n\n",
4643        );
4644        let base_url = spawn_codex_sse(BODY).await;
4645        let err = run_codex(&base_url, 0).await.expect_err("must fail");
4646        let msg = err.to_string();
4647        assert!(msg.starts_with("Codex response was incomplete."), "{msg}");
4648        assert!(!msg.contains("empty response"), "{msg}");
4649        assert!(
4650            !msg.contains("max_output_tokens"),
4651            "provider text leaked: {msg}"
4652        );
4653    }
4654
4655    #[tokio::test]
4656    async fn codex_clean_eof_without_terminal_fails_closed() {
4657        let base_url = spawn_codex_sse(
4658            "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\"}}\n\n",
4659        )
4660        .await;
4661        let err = run_codex(&base_url, 0).await.expect_err("must fail");
4662        assert!(
4663            err.to_string()
4664                .starts_with("Codex response stream ended without a terminal event."),
4665            "{err}"
4666        );
4667    }
4668
4669    #[tokio::test]
4670    async fn codex_retries_429_then_succeeds() {
4671        let (base_url, counter) = spawn_mock_codex(1, StatusCode::TOO_MANY_REQUESTS).await;
4672        run_codex(&base_url, 2)
4673            .await
4674            .expect("429 is transient — must retry");
4675        assert_eq!(counter.load(Ordering::SeqCst), 2);
4676    }
4677
4678    #[tokio::test]
4679    async fn codex_zero_retries_fails_fast_with_status_error() {
4680        let (base_url, counter) =
4681            spawn_mock_codex(usize::MAX, StatusCode::INTERNAL_SERVER_ERROR).await;
4682        let err = run_codex(&base_url, 0).await.expect_err("must fail");
4683        assert_eq!(counter.load(Ordering::SeqCst), 1, "no retries budgeted");
4684        let msg = err.to_string();
4685        assert!(
4686            msg.starts_with("codex request failed: 500"),
4687            "status must survive for classification: {msg}"
4688        );
4689    }
4690
4691    #[tokio::test]
4692    async fn codex_does_not_retry_client_errors() {
4693        let (base_url, counter) = spawn_mock_codex(usize::MAX, StatusCode::BAD_REQUEST).await;
4694        let err = run_codex(&base_url, 3).await.expect_err("must fail");
4695        assert_eq!(
4696            counter.load(Ordering::SeqCst),
4697            1,
4698            "400 is deterministic — retrying it never helps"
4699        );
4700        assert!(err.to_string().starts_with("codex request failed: 400"));
4701    }
4702
4703    #[tokio::test]
4704    async fn codex_exhausted_retries_reports_last_status() {
4705        let (base_url, counter) =
4706            spawn_mock_codex(usize::MAX, StatusCode::SERVICE_UNAVAILABLE).await;
4707        let err = run_codex(&base_url, 1).await.expect_err("must fail");
4708        assert_eq!(
4709            counter.load(Ordering::SeqCst),
4710            2,
4711            "initial attempt + 1 retry"
4712        );
4713        assert!(err.to_string().starts_with("codex request failed: 503"));
4714    }
4715
4716    /// The dispatch seam lifts the generic three-attempt budget to the
4717    /// persistent posture adopted for Anthropic OAuth overloads (10 retries)
4718    /// — incident: 2026-07-16 chatgpt.com 503/520/timeout bursts.
4719    #[test]
4720    fn codex_retry_budget_lifts_configured_default_to_persistent_floor() {
4721        assert_eq!(codex_retry_budget(3), CODEX_PERSISTENT_RETRIES);
4722        assert_eq!(codex_retry_budget(0), CODEX_PERSISTENT_RETRIES);
4723        assert_eq!(codex_retry_budget(10), 10);
4724    }
4725
4726    /// A user who explicitly configured an even larger budget keeps it —
4727    /// the floor only ever raises, never lowers.
4728    #[test]
4729    fn codex_retry_budget_honors_larger_user_configuration() {
4730        assert_eq!(codex_retry_budget(15), 15);
4731    }
4732
4733    /// Backoff doubles per attempt but caps at 2^6 — a 10-deep budget must
4734    /// never sleep 512s on its final attempt.
4735    #[test]
4736    fn retry_delay_is_exponential_with_a_capped_tail() {
4737        assert_eq!(retry_delay(1).as_secs(), 1);
4738        assert_eq!(retry_delay(2).as_secs(), 2);
4739        assert_eq!(retry_delay(4).as_secs(), 8);
4740        assert_eq!(retry_delay(7).as_secs(), 64);
4741        assert_eq!(retry_delay(10).as_secs(), 64);
4742    }
4743
4744    // ─── Phase 1 privacy: hostile provider echoes the request ───────────────
4745    // spec §5.1: provider error bodies are untrusted and may echo the full
4746    // request (prompts, system text, tool schemas, credentials). Neither the
4747    // surfaced error nor ANY log line may contain response-body content —
4748    // status + provider label only.
4749
4750    /// Unique raw-content sentinel placed in the outgoing request body.
4751    const ECHO_SENTINEL: &str = "PH1-OAI-SENTINEL-2b8c4d-RAW-CONTENT";
4752
4753    /// `std::io::Write` sink capturing formatted tracing output in-process.
4754    struct CaptureWriter(Arc<std::sync::Mutex<Vec<u8>>>);
4755
4756    impl std::io::Write for CaptureWriter {
4757        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
4758            self.0.lock().unwrap().extend_from_slice(buf);
4759            Ok(buf.len())
4760        }
4761        fn flush(&mut self) -> std::io::Result<()> {
4762            Ok(())
4763        }
4764    }
4765
4766    fn capture_subscriber(
4767        buf: &Arc<std::sync::Mutex<Vec<u8>>>,
4768    ) -> impl tracing::Subscriber + Send + Sync {
4769        let sink = buf.clone();
4770        tracing_subscriber::fmt()
4771            .with_max_level(tracing::Level::TRACE)
4772            .with_writer(move || CaptureWriter(sink.clone()))
4773            .with_ansi(false)
4774            .finish()
4775    }
4776
4777    /// Hostile loopback provider: every request is answered with `status` and
4778    /// a JSON error envelope whose `message` is `"ECHOED:" + the full request
4779    /// body` — the preserved holdout probe shape, aimed at the OpenAI routes.
4780    async fn spawn_hostile_echo_provider(status: StatusCode) -> String {
4781        let app = Router::new().fallback(move |body: String| async move {
4782            let envelope = serde_json::json!({
4783                "error": { "message": format!("ECHOED:{body}") }
4784            });
4785            (
4786                status,
4787                [("content-type", "application/json")],
4788                envelope.to_string(),
4789            )
4790                .into_response()
4791        });
4792        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4793        let addr = listener.local_addr().unwrap();
4794        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4795        format!("http://{addr}")
4796    }
4797
4798    /// Request body shaped like a real provider request: prompt sentinel plus
4799    /// a distinctive tool schema. If the provider echo survives anywhere,
4800    /// these markers betray it.
4801    fn sentinel_request_body() -> Value {
4802        json!({
4803            "model": "gpt-test",
4804            "messages": [
4805                {"role": "system", "content": format!("system secret {ECHO_SENTINEL}")},
4806                {"role": "user", "content": ECHO_SENTINEL},
4807            ],
4808            "tools": [{
4809                "type": "function",
4810                "function": {
4811                    "name": "ph1_secret_tool_zz",
4812                    "description": "internal tool schema",
4813                    "parameters": {"type": "object", "properties": {}}
4814                }
4815            }],
4816        })
4817    }
4818
4819    /// Markers that must never surface in errors or logs.
4820    const BANNED_MARKERS: &[&str] = &[
4821        "ECHOED",
4822        ECHO_SENTINEL,
4823        "ph1_secret_tool_zz",
4824        "\"messages\"",
4825        "system secret",
4826    ];
4827
4828    fn assert_no_banned(haystack: &str, context: &str) {
4829        for banned in BANNED_MARKERS {
4830            assert!(
4831                !haystack.contains(banned),
4832                "provider body content `{banned}` leaked into {context}: {haystack}"
4833            );
4834        }
4835    }
4836
4837    #[tokio::test]
4838    async fn send_with_retries_non_retryable_error_never_surfaces_provider_body() {
4839        let url = spawn_hostile_echo_provider(StatusCode::BAD_REQUEST).await;
4840        let client = reqwest::Client::new();
4841        let body = sentinel_request_body();
4842
4843        let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
4844        let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
4845
4846        let err = send_with_retries(
4847            "codex",
4848            &url,
4849            || client.post(&url).json(&body),
4850            &tokio_util::sync::CancellationToken::new(),
4851            3,
4852            &mut 0,
4853            &mut tr::StreamAttempt::new(None),
4854        )
4855        .await
4856        .expect_err("400 must fail fast");
4857
4858        let msg = err.to_string();
4859        assert!(
4860            msg.starts_with("codex request failed: 400"),
4861            "status + label must survive for classification/usability: {msg}"
4862        );
4863        assert_no_banned(&msg, "the surfaced error");
4864        let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
4865        assert_no_banned(&logs, "tracing output");
4866    }
4867
4868    #[tokio::test(start_paused = true)]
4869    async fn send_with_retries_retry_logs_never_contain_provider_body() {
4870        let url = spawn_hostile_echo_provider(StatusCode::SERVICE_UNAVAILABLE).await;
4871        let client = reqwest::Client::new();
4872        let body = sentinel_request_body();
4873
4874        let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
4875        let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
4876
4877        let err = send_with_retries(
4878            "codex",
4879            &url,
4880            || client.post(&url).json(&body),
4881            &tokio_util::sync::CancellationToken::new(),
4882            1,
4883            &mut 0,
4884            &mut tr::StreamAttempt::new(None),
4885        )
4886        .await
4887        .expect_err("persistent 503 must exhaust the budget");
4888
4889        let msg = err.to_string();
4890        assert!(
4891            msg.starts_with("codex request failed: 503"),
4892            "status + label must survive after exhaustion: {msg}"
4893        );
4894        assert_no_banned(&msg, "the surfaced error");
4895
4896        let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
4897        assert!(
4898            logs.contains("retry 1/1"),
4899            "retry accounting must stay observable: {logs}"
4900        );
4901        assert!(
4902            logs.contains("503"),
4903            "retry log must keep the status for diagnosis: {logs}"
4904        );
4905        assert_no_banned(&logs, "retry tracing output");
4906    }
4907}
4908
4909#[cfg(test)]
4910mod interrupted_broker_tests {
4911    use super::*;
4912    use crate::auth::{
4913        AccessToken, BrokerError, CredentialBroker, ProxyByteStream, ProxyRequest, ProxyResponse,
4914    };
4915    use std::sync::{Arc, Mutex};
4916    use tokio_util::sync::CancellationToken;
4917
4918    struct Broker {
4919        bodies: Vec<(&'static str, Option<BrokerError>)>,
4920        seen: Mutex<Vec<ProxyRequest>>,
4921    }
4922    #[async_trait::async_trait]
4923    impl CredentialBroker for Broker {
4924        async fn access_token(
4925            &self,
4926            _: crate::auth::OAuthProviderId,
4927        ) -> Result<AccessToken, BrokerError> {
4928            unreachable!()
4929        }
4930        async fn proxy(&self, _: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
4931            unreachable!()
4932        }
4933        async fn proxy_stream(
4934            &self,
4935            request: ProxyRequest,
4936        ) -> Result<ProxyByteStream, BrokerError> {
4937            let mut seen = self.seen.lock().unwrap();
4938            let (body, error) = &self.bodies[seen.len().min(self.bodies.len() - 1)];
4939            seen.push(request);
4940            let mut chunks = vec![Ok(bytes::Bytes::from_static(body.as_bytes()))];
4941            if let Some(error) = error {
4942                chunks.push(Err(error.clone()));
4943            }
4944            Ok(Box::pin(futures::stream::iter(chunks)))
4945        }
4946        async fn anthropic_usage(&self) -> Result<Value, BrokerError> {
4947            unreachable!()
4948        }
4949        async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
4950            Ok(vec![])
4951        }
4952    }
4953    const CHAT_PARTIAL: &str = concat!(
4954        "data: {\"choices\":[{\"delta\":{\"content\":\"discard\"}}]}\n\n",
4955        "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":1}}\n\n"
4956    );
4957    const CHAT_OK: &str =
4958        "data: {\"choices\":[{\"delta\":{\"content\":\"accepted\"}}]}\n\ndata: [DONE]\n\n";
4959    const XAI_PARTIAL: &str =
4960        "data: {\"type\":\"response.output_text.delta\",\"delta\":\"discard\"}\n\n";
4961    const XAI_OK: &str = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"accepted\"}\n\ndata: {\"type\":\"response.completed\"}\n\n";
4962
4963    async fn drive(
4964        broker: Arc<Broker>,
4965        xai: bool,
4966        retries: u32,
4967        suppressed: bool,
4968        cancel: &CancellationToken,
4969    ) -> (
4970        Result<Value, super::super::net::BoxedProviderError>,
4971        Vec<StreamEvent>,
4972    ) {
4973        let cfg = ProviderConfig {
4974            base_url: "http://unused.invalid".into(),
4975            model: if xai { "grok-4.5" } else { "test" }.into(),
4976            provider: if xai { "xai-auth" } else { "local" }.into(),
4977        };
4978        let broker: Arc<dyn CredentialBroker> = broker;
4979        let (tx, mut rx) = mpsc::unbounded_channel();
4980        let trace = crate::runtime::trace::TraceContext::disabled();
4981        let result = if xai {
4982            call_xai_responses_stream_inner(
4983                &cfg,
4984                &broker,
4985                &[],
4986                &None,
4987                &[],
4988                &tx,
4989                None,
4990                agent_core::reasoning::ReasoningLevel::Adaptive,
4991                cancel,
4992                retries,
4993                &trace,
4994                true,
4995            )
4996            .await
4997        } else {
4998            call_oai_stream_inner(
4999                &cfg,
5000                &broker,
5001                &[],
5002                &None,
5003                &[],
5004                &tx,
5005                None,
5006                None,
5007                0,
5008                agent_core::reasoning::ReasoningLevel::Medium,
5009                cancel,
5010                &trace,
5011                true,
5012                suppressed,
5013                retries,
5014            )
5015            .await
5016        };
5017        let mut events = vec![];
5018        while let Ok(event) = rx.try_recv() {
5019            events.push(event);
5020        }
5021        (result, events)
5022    }
5023
5024    #[tokio::test(start_paused = true)]
5025    async fn broker_transport_recovery_preserves_usage_and_identical_bytes() {
5026        for xai in [false, true] {
5027            let broker = Arc::new(Broker {
5028                bodies: vec![
5029                    (
5030                        if xai { XAI_PARTIAL } else { CHAT_PARTIAL },
5031                        Some(BrokerError::Transport(
5032                            "unexpected EOF during chunk size line".into(),
5033                        )),
5034                    ),
5035                    (if xai { XAI_OK } else { CHAT_OK }, None),
5036                ],
5037                seen: Mutex::new(vec![]),
5038            });
5039            let (result, events) =
5040                drive(broker.clone(), xai, 1, false, &CancellationToken::new()).await;
5041            assert_eq!(result.unwrap()["content"][0]["text"], "accepted");
5042            let seen = broker.seen.lock().unwrap();
5043            assert_eq!(seen.len(), 2);
5044            assert_eq!(seen[0].body_bytes, seen[1].body_bytes);
5045            assert_eq!(
5046                events
5047                    .iter()
5048                    .filter(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset)))
5049                    .count(),
5050                1
5051            );
5052            if !xai {
5053                assert_eq!(
5054                    events
5055                        .iter()
5056                        .filter_map(|e| match e {
5057                            StreamEvent::Session(crate::SessionEvent::Usage {
5058                                input_tokens,
5059                                ..
5060                            }) => Some(*input_tokens),
5061                            _ => None,
5062                        })
5063                        .sum::<u64>(),
5064                    7
5065                );
5066            }
5067        }
5068    }
5069    #[tokio::test(start_paused = true)]
5070    async fn broker_nontransport_denial_never_retries_and_precancel_never_dispatches() {
5071        for xai in [false, true] {
5072            let broker = Arc::new(Broker {
5073                bodies: vec![(
5074                    if xai { XAI_PARTIAL } else { CHAT_PARTIAL },
5075                    Some(BrokerError::Denied("blocked".into())),
5076                )],
5077                seen: Mutex::new(vec![]),
5078            });
5079            let (result, events) =
5080                drive(broker.clone(), xai, 10, false, &CancellationToken::new()).await;
5081            assert!(result.is_err());
5082            assert_eq!(broker.seen.lock().unwrap().len(), 1);
5083            assert!(!events
5084                .iter()
5085                .any(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset))));
5086            let canceled = CancellationToken::new();
5087            canceled.cancel();
5088            assert!(drive(broker.clone(), xai, 10, false, &canceled)
5089                .await
5090                .0
5091                .is_err());
5092            assert_eq!(broker.seen.lock().unwrap().len(), 1);
5093        }
5094    }
5095    #[tokio::test(start_paused = true)]
5096    async fn suppressed_retry_emits_no_llm_previews() {
5097        let broker = Arc::new(Broker {
5098            bodies: vec![
5099                (CHAT_PARTIAL, Some(BrokerError::Transport("eof".into()))),
5100                (CHAT_OK, None),
5101            ],
5102            seen: Mutex::new(vec![]),
5103        });
5104        let (result, events) = drive(broker, false, 1, true, &CancellationToken::new()).await;
5105        assert!(result.is_ok());
5106        assert!(!events.iter().any(|e| matches!(e, StreamEvent::Llm(_))));
5107    }
5108    #[test]
5109    fn exhaustion_is_transient_for_auto_driver_but_not_arbitrary_error_prose() {
5110        use crate::extensions::session_driver::{classify_error, Outcome};
5111        assert_eq!(
5112            classify_error(STREAM_INTERRUPTED),
5113            (Outcome::ProviderError, "transient".into())
5114        );
5115        assert_eq!(
5116            classify_error(&format!("API error: {STREAM_INTERRUPTED}")),
5117            (Outcome::ProviderError, "transient".into())
5118        );
5119        assert_eq!(
5120            classify_error(&format!("denied: {STREAM_INTERRUPTED}")).0,
5121            Outcome::Blocked
5122        );
5123    }
5124}