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