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