Skip to main content

agent_engine/runtime/openai/
stream.rs

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