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