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::types::StreamEvent;
10use futures::StreamExt;
11use serde_json::{json, Value};
12use tokio::sync::mpsc;
13
14/// Run a single streaming request against an OpenAI-compatible endpoint.
15///
16/// Returns the final assistant response as an Anthropic-shaped content Value
17/// (`{"content": [..text.., ..tool_use..]}`) so the outer agent loop can keep
18/// using the same handling as the native Anthropic path.
19#[allow(clippy::too_many_arguments)]
20pub(crate) async fn call_oai_stream_inner(
21    cfg: &ProviderConfig,
22    client: &reqwest::Client,
23    tools_schema: &[Value],
24    system_prompt: &Option<String>,
25    messages: &[Value],
26    tx: &mpsc::UnboundedSender<StreamEvent>,
27    temperature: Option<f32>,
28    max_tokens: Option<u32>,
29    thinking_budget: u32,
30    cancel: &tokio_util::sync::CancellationToken,
31) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
32    let (oai_tools, name_map) = translate::tools_to_oai(tools_schema);
33    let oai_messages = translate::messages_to_oai(messages, system_prompt, &name_map);
34    let tools_opt = if oai_tools.is_empty() { None } else { Some(oai_tools) };
35
36    // Google's OpenAI-compat endpoint rejects stream_options
37    let stream_options = if cfg.base_url.contains("googleapis.com") {
38        None
39    } else {
40        Some(StreamOptions { include_usage: true })
41    };
42
43    let mut body = serde_json::Map::new();
44    body.insert("model".to_string(), json!(cfg.model.clone()));
45    body.insert("messages".to_string(), serde_json::to_value(oai_messages)?);
46    body.insert("stream".to_string(), json!(true));
47    if let Some(stream_options) = stream_options {
48        body.insert("stream_options".to_string(), serde_json::to_value(stream_options)?);
49    }
50    if let Some(max_tokens) = max_tokens {
51        body.insert("max_tokens".to_string(), json!(max_tokens));
52    }
53    if let Some(temperature) = temperature {
54        body.insert("temperature".to_string(), json!(temperature));
55    }
56    if let Some(tools) = tools_opt {
57        body.insert("tools".to_string(), serde_json::to_value(tools)?);
58    }
59    super::reasoning::apply_openai_reasoning_params(
60        &mut body,
61        super::reasoning::provider_for_key(&cfg.provider),
62        &cfg.model,
63        thinking_budget,
64    );
65    let body = Value::Object(body);
66
67    let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/'));
68
69    tracing::debug!(url=%url, model=%cfg.model, "openai stream request");
70
71    let resp = match client
72        .post(&url)
73        .bearer_auth(&cfg.api_key)
74        .header("content-type", "application/json")
75        .header("accept", "text/event-stream")
76        .json(&body)
77        .send()
78        .await
79    {
80        Ok(r) => r,
81        Err(e) => {
82            if e.is_connect() && url.contains("localhost") {
83                return Err(format!(
84                    "Can't reach local endpoint at {} — is Ollama/LM Studio running?",
85                    url
86                ).into());
87            }
88            return Err(e.into());
89        }
90    };
91
92    if !resp.status().is_success() {
93        let status = resp.status();
94        let text = resp.text().await.unwrap_or_default();
95        return Err(format!("openai request failed: {status}: {text}").into());
96    }
97
98    let mut decoder = StreamDecoder::new();
99    let mut accumulated_text = String::new();
100    let mut tool_use_blocks: Vec<Value> = Vec::new();
101    let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
102    let mut sink: Vec<OaiEvent> = Vec::with_capacity(4);
103    let mut stream = resp.bytes_stream();
104
105    while let Some(chunk) = tokio::select! {
106        chunk = stream.next() => chunk,
107        _ = cancel.cancelled() => {
108            return Err("request canceled".into());
109        }
110    } {
111        let chunk = chunk?;
112        buf.extend_from_slice(&chunk);
113
114        // Scan for newline-delimited SSE lines (SIMD-accelerated via memchr)
115        while let Some(nl) = memchr::memchr(b'\n', &buf) {
116            let line_bytes = buf.split_to(nl + 1); // O(1) — ref-counted split
117            let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
118
119            sink.clear();
120            decoder.push_line(line, &mut sink);
121            handle_events(&sink, tx, &mut accumulated_text, &mut tool_use_blocks, &name_map);
122        }
123    }
124
125    // Flush any remaining buffered line + final Done
126    if !buf.is_empty() {
127        let line = std::str::from_utf8(&buf).unwrap_or("");
128        sink.clear();
129        decoder.push_line(line, &mut sink);
130        handle_events(&sink, tx, &mut accumulated_text, &mut tool_use_blocks, &name_map);
131    }
132    sink.clear();
133    decoder.finish(&mut sink);
134    handle_events(&sink, tx, &mut accumulated_text, &mut tool_use_blocks, &name_map);
135
136    // Build Anthropic-shaped final response
137    let mut content: Vec<Value> = Vec::new();
138    if !accumulated_text.is_empty() {
139        content.push(json!({"type": "text", "text": accumulated_text}));
140    }
141    content.extend(tool_use_blocks);
142
143    Ok(json!({
144        "role": "assistant",
145        "content": content,
146    }))
147}
148
149#[allow(clippy::too_many_arguments)]
150pub(crate) async fn call_codex_stream_inner(
151    cfg: &ProviderConfig,
152    client: &reqwest::Client,
153    tools_schema: &[Value],
154    system_prompt: &Option<String>,
155    messages: &[Value],
156    tx: &mpsc::UnboundedSender<StreamEvent>,
157    temperature: Option<f32>,
158    max_tokens: Option<u32>,
159    cancel: &tokio_util::sync::CancellationToken,
160    source: &crate::auth::CredentialSource,
161    cache: &crate::auth::TokenCache,
162) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
163    // Resolve the codex access token honoring the credential source: Remote
164    // fetches from the broker, Local refreshes the openai-codex auth.json. (#158 C4)
165    let (access, account_id) = if !cfg.api_key.is_empty() {
166        (cfg.api_key.clone(), crate::auth::extract_codex_account_id(&cfg.api_key))
167    } else {
168        match source {
169            crate::auth::CredentialSource::Remote { .. } => {
170                let access = crate::auth::resolve_access_token("openai-codex", source, cache, client).await?;
171                let acct = crate::auth::extract_codex_account_id(&access);
172                (access, acct)
173            }
174            crate::auth::CredentialSource::Local => {
175                let creds = crate::auth::ensure_fresh_provider_token(client, "openai-codex").await?;
176                let acct = creds
177                    .account_id
178                    .clone()
179                    .or_else(|| crate::auth::extract_codex_account_id(&creds.access));
180                (creds.access, acct)
181            }
182        }
183    };
184    let account_id = account_id.ok_or("Failed to extract ChatGPT account id from Codex token")?;
185
186    let (oai_tools, name_map) = translate::tools_to_oai(tools_schema);
187    let oai_messages = translate::messages_to_oai(messages, system_prompt, &name_map);
188    let tools: Vec<Value> = oai_tools
189        .into_iter()
190        .map(|tool| {
191            json!({
192                "type": "function",
193                "name": tool.function.name,
194                "description": tool.function.description.unwrap_or_default(),
195                "parameters": tool.function.parameters,
196            })
197        })
198        .collect();
199
200    let mut body = json!({
201        "model": cfg.model,
202        "store": false,
203        "stream": true,
204        "instructions": codex_instructions(system_prompt),
205        "input": codex_input_messages(oai_messages),
206        "tool_choice": "auto",
207        "parallel_tool_calls": true,
208        "include": ["reasoning.encrypted_content"],
209        "text": { "verbosity": "medium" },
210    });
211    if !tools.is_empty() {
212        body["tools"] = Value::Array(tools);
213    }
214    if let Some(temp) = temperature {
215        body["temperature"] = json!(temp);
216    }
217    if let Some(max) = max_tokens {
218        body["max_output_tokens"] = json!(max);
219    }
220
221    let url = format!(
222        "{}/codex/responses",
223        cfg.base_url.trim_end_matches('/').trim_end_matches("/codex")
224    );
225    tracing::debug!(url=%url, model=%cfg.model, "codex stream request");
226
227    let resp = client
228        .post(&url)
229        .bearer_auth(&access)
230        .header("chatgpt-account-id", account_id)
231        .header("originator", "synaps")
232        .header("OpenAI-Beta", "responses=experimental")
233        .header("content-type", "application/json")
234        .header("accept", "text/event-stream")
235        .json(&body)
236        .send()
237        .await?;
238
239    if !resp.status().is_success() {
240        let status = resp.status();
241        let text = resp.text().await.unwrap_or_default();
242        return Err(format!("codex request failed: {status}: {text}").into());
243    }
244
245    let mut accumulated_text = String::new();
246    let mut parser = CodexSseDecoder::default();
247    let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
248    let mut stream = resp.bytes_stream();
249
250    while let Some(chunk) = tokio::select! {
251        chunk = stream.next() => chunk,
252        _ = cancel.cancelled() => {
253            return Err("request canceled".into());
254        }
255    } {
256        let chunk = chunk?;
257        buf.extend_from_slice(&chunk);
258        while let Some(nl) = memchr::memchr(b'\n', &buf) {
259            let line_bytes = buf.split_to(nl + 1);
260            let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
261            parser.push_line(line, tx, &mut accumulated_text);
262        }
263    }
264    if !buf.is_empty() {
265        let line = std::str::from_utf8(&buf).unwrap_or("");
266        parser.push_line(line, tx, &mut accumulated_text);
267    }
268    parser.finish();
269
270    let mut content: Vec<Value> = Vec::new();
271    if !accumulated_text.is_empty() {
272        content.push(json!({"type": "text", "text": accumulated_text}));
273    }
274    content.extend(translate::tool_calls_to_content_blocks(&parser.completed_tools, &name_map));
275
276    Ok(json!({
277        "role": "assistant",
278        "content": content,
279    }))
280}
281
282const 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]";
283
284fn codex_instructions(system_prompt: &Option<String>) -> String {
285    let mut instructions = system_prompt.clone().unwrap_or_default();
286    if instructions.contains("[Synaps autonomous harness policy]") {
287        return instructions;
288    }
289    instructions.push_str(CODEX_AUTONOMOUS_LOOP_POLICY);
290    instructions
291}
292
293fn codex_input_messages(messages: Vec<ChatMessage>) -> Vec<Value> {
294    let mut out = Vec::new();
295    for msg in messages {
296        if let Some(tool_calls) = msg.tool_calls {
297            for call in tool_calls {
298                // The Responses API rejects `id` values that are not the
299                // original `fc_…` output-item id. We only carry the
300                // `call_…` correlation id today (see types::ToolCall),
301                // so emit `id` *only* when the value actually starts
302                // with `fc`. `call_id` is sufficient on its own to
303                // correlate the eventual `function_call_output`.
304                let mut item = json!({
305                    "type": "function_call",
306                    "call_id": call.id,
307                    "name": call.function.name,
308                    "arguments": call.function.arguments,
309                });
310                if call.id.starts_with("fc") {
311                    item["id"] = Value::from(call.id.clone());
312                }
313                out.push(item);
314            }
315            continue;
316        }
317        if msg.role == "tool" {
318            // Skip tool results with no call_id — sending an empty call_id
319            // to the Codex API would cause a 400 with a confusing error.
320            if let Some(call_id) = msg.tool_call_id {
321                out.push(json!({
322                    "type": "function_call_output",
323                    "call_id": call_id,
324                    "output": msg.content.unwrap_or_default(),
325                }));
326            }
327            continue;
328        }
329        out.push(json!({
330            "role": msg.role,
331            "content": msg.content.unwrap_or_default(),
332        }));
333    }
334    out
335}
336
337#[derive(Default)]
338struct CodexSseDecoder {
339    buffer: String,
340    active_tools: Vec<CodexToolAccumulator>,
341    completed_tools: Vec<ToolCall>,
342}
343
344#[derive(Default)]
345struct CodexToolAccumulator {
346    id: String,
347    name: String,
348    arguments: String,
349    started: bool,
350}
351
352/// Parse a function-call arguments string into a JSON `Value`, mirroring
353/// `runtime::api::parse_tool_input` so the chat UI's `LlmEvent::ToolUse`
354/// handling sees the same shape regardless of provider.
355///
356/// Empty / whitespace input becomes `{}`. Invalid JSON becomes
357/// `{"__parse_error": "..."}` — the agent loop already understands that
358/// shape and converts it into an `is_error: true` tool_result.
359fn parse_tool_arguments(raw: &str) -> Value {
360    if raw.trim().is_empty() {
361        return json!({});
362    }
363    match serde_json::from_str(raw) {
364        Ok(v) => v,
365        Err(e) => json!({ "__parse_error": format!("invalid tool input JSON: {}", e) }),
366    }
367}
368
369impl CodexSseDecoder {
370    fn push_line(
371        &mut self,
372        line: &str,
373        tx: &mpsc::UnboundedSender<StreamEvent>,
374        text_acc: &mut String,
375    ) {
376        let line = line.trim_end_matches('\r');
377        if line.is_empty() {
378            if !self.buffer.is_empty() {
379                let payload = std::mem::take(&mut self.buffer);
380                self.push_payload(&payload, tx, text_acc);
381            }
382            return;
383        }
384        let Some(data) = line.strip_prefix("data:").map(str::trim_start) else {
385            return;
386        };
387        if data == "[DONE]" {
388            self.finish();
389            return;
390        }
391        self.buffer.push_str(data);
392    }
393
394    fn push_payload(
395        &mut self,
396        payload: &str,
397        tx: &mpsc::UnboundedSender<StreamEvent>,
398        text_acc: &mut String,
399    ) {
400        let Ok(event) = serde_json::from_str::<Value>(payload) else {
401            return;
402        };
403        let event_type = event.get("type").and_then(Value::as_str).unwrap_or_default();
404        match event_type {
405            "response.output_text.delta" => {
406                if let Some(delta) = event.get("delta").and_then(Value::as_str) {
407                    text_acc.push_str(delta);
408                    let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(
409                        delta.to_string(),
410                    )));
411                }
412            }
413            "response.output_item.added" => {
414                if let Some(item) = event.get("item") {
415                    let idx = event.get("output_index").and_then(Value::as_u64).unwrap_or(0) as usize;
416                    self.add_tool_from_item(idx, item, tx);
417                }
418            }
419            "response.function_call_arguments.delta" => {
420                let idx = event.get("output_index").and_then(Value::as_u64).unwrap_or(0) as usize;
421                let delta = event.get("delta").and_then(Value::as_str).unwrap_or_default();
422                if !delta.is_empty() {
423                    let tool = self.ensure_tool(idx);
424                    tool.arguments.push_str(delta);
425                    let tool_id = tool.id.clone();
426                    let _ = tx.send(StreamEvent::Llm(
427                        crate::runtime::types::LlmEvent::ToolUseDelta {
428                            tool_id,
429                            delta: delta.to_string(),
430                        },
431                    ));
432                }
433            }
434            "response.output_item.done" => {
435                if let Some(item) = event.get("item") {
436                    let idx = event.get("output_index").and_then(Value::as_u64).unwrap_or(0) as usize;
437                    self.complete_tool_from_item(idx, item, tx);
438                }
439            }
440            "response.completed" | "response.done" => {
441                self.push_usage(&event, tx);
442                self.finish();
443            }
444            _ => {}
445        }
446    }
447
448    fn ensure_tool(&mut self, idx: usize) -> &mut CodexToolAccumulator {
449        while self.active_tools.len() <= idx {
450            self.active_tools.push(CodexToolAccumulator::default());
451        }
452        &mut self.active_tools[idx]
453    }
454
455    fn add_tool_from_item(
456        &mut self,
457        idx: usize,
458        item: &Value,
459        tx: &mpsc::UnboundedSender<StreamEvent>,
460    ) {
461        if item.get("type").and_then(Value::as_str) != Some("function_call") {
462            return;
463        }
464        let tool = self.ensure_tool(idx);
465        if let Some(id) = item
466            .get("call_id")
467            .or_else(|| item.get("id"))
468            .and_then(Value::as_str)
469        {
470            tool.id = id.to_string();
471        }
472        if let Some(name) = item.get("name").and_then(Value::as_str) {
473            tool.name = name.to_string();
474        }
475        if !tool.started && !tool.name.is_empty() {
476            tool.started = true;
477            let _ = tx.send(StreamEvent::Llm(
478                crate::runtime::types::LlmEvent::ToolUseStart {
479                    tool_name: tool.name.clone(),
480                    tool_id: tool.id.clone(),
481                },
482            ));
483        }
484    }
485
486    fn complete_tool_from_item(
487        &mut self,
488        idx: usize,
489        item: &Value,
490        tx: &mpsc::UnboundedSender<StreamEvent>,
491    ) {
492        if item.get("type").and_then(Value::as_str) != Some("function_call") {
493            return;
494        }
495        let tool = self.ensure_tool(idx);
496        if let Some(id) = item
497            .get("call_id")
498            .or_else(|| item.get("id"))
499            .and_then(Value::as_str)
500        {
501            tool.id = id.to_string();
502        }
503        if let Some(name) = item.get("name").and_then(Value::as_str) {
504            tool.name = name.to_string();
505        }
506        if let Some(arguments) = item.get("arguments").and_then(Value::as_str) {
507            tool.arguments = arguments.to_string();
508        }
509        if !tool.started && !tool.name.is_empty() {
510            tool.started = true;
511            let _ = tx.send(StreamEvent::Llm(
512                crate::runtime::types::LlmEvent::ToolUseStart {
513                    tool_name: tool.name.clone(),
514                    tool_id: tool.id.clone(),
515                },
516            ));
517        }
518        let completed = if !tool.id.is_empty() && !tool.name.is_empty() {
519            Some(ToolCall {
520                id: tool.id.clone(),
521                kind: "function".to_string(),
522                function: super::types::FunctionCall {
523                    name: tool.name.clone(),
524                    arguments: tool.arguments.clone(),
525                },
526            })
527        } else {
528            None
529        };
530        if let Some(call) = completed {
531            if self.completed_tools.iter().any(|done| done.id == call.id) {
532                return;
533            }
534            // Emit the finalized `ToolUse` event so the chat UI can collapse
535            // the streaming `ToolUseStart` (animated) into a stable
536            // `ToolUse` block. Without this the bash-trace animation
537            // persists forever and parallel tool blocks render as "still
538            // running" even after they've completed. Mirrors the
539            // Anthropic path in `runtime/api.rs` which emits the same
540            // event on tool-use content_block_stop.
541            let input = parse_tool_arguments(&call.function.arguments);
542            let _ = tx.send(StreamEvent::Llm(
543                crate::runtime::types::LlmEvent::ToolUse {
544                    tool_name: call.function.name.clone(),
545                    tool_id: call.id.clone(),
546                    input,
547                },
548            ));
549            self.completed_tools.push(ToolCall {
550                id: call.id,
551                kind: call.kind,
552                function: call.function,
553            });
554        }
555    }
556
557    fn push_usage(&self, event: &Value, tx: &mpsc::UnboundedSender<StreamEvent>) {
558        let usage = event
559            .get("response")
560            .and_then(|r| r.get("usage"))
561            .or_else(|| event.get("usage"));
562        let input = usage
563            .and_then(|u| u.get("input_tokens"))
564            .and_then(Value::as_u64)
565            .unwrap_or(0);
566        let output = usage
567            .and_then(|u| u.get("output_tokens"))
568            .and_then(Value::as_u64)
569            .unwrap_or(0);
570        if input > 0 || output > 0 {
571            let _ = tx.send(StreamEvent::Session(crate::runtime::types::SessionEvent::Usage {
572                input_tokens: input,
573                output_tokens: output,
574                cache_read_input_tokens: 0,
575                cache_creation_input_tokens: 0,
576                cache_creation_5m: None,
577                cache_creation_1h: None,
578                model: None,
579            }));
580        }
581    }
582
583    fn finish(&mut self) {
584        for tool in self.active_tools.drain(..) {
585            if !tool.id.is_empty()
586                && !tool.name.is_empty()
587                && !self.completed_tools.iter().any(|done| done.id == tool.id)
588            {
589                self.completed_tools.push(ToolCall {
590                    id: tool.id,
591                    kind: "function".to_string(),
592                    function: super::types::FunctionCall {
593                        name: tool.name,
594                        arguments: tool.arguments,
595                    },
596                });
597            }
598        }
599    }
600}
601
602fn handle_events(
603    events: &[OaiEvent],
604    tx: &mpsc::UnboundedSender<StreamEvent>,
605    text_acc: &mut String,
606    tool_blocks: &mut Vec<Value>,
607    name_map: &translate::ToolNameMap,
608) {
609    for ev in events {
610        if let OaiEvent::TextDelta(t) = ev {
611            text_acc.push_str(t);
612        }
613        if let OaiEvent::ToolCallsComplete { calls, .. } = ev {
614            tool_blocks.extend(translate::tool_calls_to_content_blocks(calls, name_map));
615        }
616        if let Some(se) = translate::oai_event_to_llm(ev) {
617            let _ = tx.send(se);
618        }
619    }
620}
621
622#[cfg(test)]
623mod codex_input_messages_tests {
624    //! Regression tests for the Codex Responses-API `input` shape.
625    //!
626    //! Background: the Responses API distinguishes two ids per tool
627    //! invocation — `id` (the *output item id*, prefix `fc_…`) and
628    //! `call_id` (the *function call id*, prefix `call_…`). When echoing
629    //! a previous `function_call` back as an input item, supplying an
630    //! `id` whose value is *not* a `fc_…` triggers
631    //!
632    //!   400 Bad Request: Invalid 'input[N].id': 'call_…'.
633    //!   Expected an ID that begins with 'fc'.
634    //!
635    //! `id` is *optional* on input items — only `call_id` is required to
636    //! correlate the eventual `function_call_output`. We elect not to
637    //! emit `id` unless we actually have a real `fc_…` value to send.
638
639    use super::*;
640    use super::super::types::{ChatMessage, FunctionCall, ToolCall};
641
642    fn sample_tool_call() -> ToolCall {
643        ToolCall {
644            id: "call_nZYquCuGUh8Qs9H51dwHMDgs".to_string(),
645            kind: "function".to_string(),
646            function: FunctionCall {
647                name: "bash".to_string(),
648                arguments: r#"{"command":"ls"}"#.to_string(),
649            },
650        }
651    }
652
653    #[test]
654    fn codex_instructions_appends_autonomous_loop_policy() {
655        let instructions = codex_instructions(&Some("Project-specific rules.".to_string()));
656        assert!(instructions.contains("Project-specific rules."));
657        assert!(instructions.contains("Do not stop at phase boundaries"));
658        assert!(instructions.contains("Do not ask the user whether to continue"));
659        assert!(instructions.contains("continue autonomously until the full requested job is complete"));
660    }
661
662    #[test]
663    fn function_call_input_omits_non_fc_id() {
664        let messages = vec![ChatMessage::assistant_tool_calls(vec![sample_tool_call()])];
665        let out = codex_input_messages(messages);
666        assert_eq!(out.len(), 1, "one tool_call → one input item");
667        let item = &out[0];
668        assert_eq!(item.get("type").and_then(Value::as_str), Some("function_call"));
669        assert!(
670            item.get("id").is_none(),
671            "must not echo a non-`fc_` id back; got {:?}",
672            item.get("id"),
673        );
674        assert_eq!(
675            item.get("call_id").and_then(Value::as_str),
676            Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
677        );
678        assert_eq!(item.get("name").and_then(Value::as_str), Some("bash"));
679    }
680
681    #[test]
682    fn function_call_input_keeps_real_fc_id() {
683        // If we ever do have a genuine `fc_…` id (round-tripped from the
684        // Responses API), we *should* echo it.
685        let mut call = sample_tool_call();
686        call.id = "fc_abc123".to_string();
687        let messages = vec![ChatMessage::assistant_tool_calls(vec![call])];
688        let out = codex_input_messages(messages);
689        let item = &out[0];
690        assert_eq!(item.get("id").and_then(Value::as_str), Some("fc_abc123"));
691        assert_eq!(item.get("call_id").and_then(Value::as_str), Some("fc_abc123"));
692    }
693
694    #[test]
695    fn function_call_output_round_trips_call_id() {
696        // The follow-up tool message must reference the original call_id.
697        let messages = vec![ChatMessage::tool_result(
698            "call_nZYquCuGUh8Qs9H51dwHMDgs",
699            "bash",
700            "total 0",
701        )];
702        let out = codex_input_messages(messages);
703        let item = &out[0];
704        assert_eq!(
705            item.get("type").and_then(Value::as_str),
706            Some("function_call_output"),
707        );
708        assert_eq!(
709            item.get("call_id").and_then(Value::as_str),
710            Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
711        );
712        assert_eq!(item.get("output").and_then(Value::as_str), Some("total 0"));
713    }
714}
715
716#[cfg(test)]
717mod codex_decoder_tests {
718    //! Regression tests for `CodexSseDecoder`.
719    //!
720    //! The decoder is sync — we drive it via `push_line` and capture
721    //! emitted `StreamEvent`s from an `unbounded_channel` using
722    //! `try_recv`, no async runtime needed.
723
724    use super::*;
725    use crate::runtime::types::{LlmEvent, SessionEvent, StreamEvent};
726
727    fn collect_events(rx: &mut mpsc::UnboundedReceiver<StreamEvent>) -> Vec<StreamEvent> {
728        let mut out = Vec::new();
729        while let Ok(ev) = rx.try_recv() {
730            out.push(ev);
731        }
732        out
733    }
734
735    fn drive(lines: &[&str]) -> (CodexSseDecoder, String, Vec<StreamEvent>) {
736        let (tx, mut rx) = mpsc::unbounded_channel();
737        let mut decoder = CodexSseDecoder::default();
738        let mut text_acc = String::new();
739        for line in lines {
740            decoder.push_line(line, &tx, &mut text_acc);
741        }
742        let events = collect_events(&mut rx);
743        (decoder, text_acc, events)
744    }
745
746    #[test]
747    fn text_delta_aggregates_into_text_acc_and_emits_text_events() {
748        let lines = [
749            r#"data: {"type":"response.output_text.delta","delta":"Hello, "}"#,
750            "",
751            r#"data: {"type":"response.output_text.delta","delta":"world!"}"#,
752            "",
753        ];
754        let (_decoder, text_acc, events) = drive(&lines);
755        assert_eq!(text_acc, "Hello, world!");
756        let texts: Vec<_> = events
757            .iter()
758            .filter_map(|e| match e {
759                StreamEvent::Llm(LlmEvent::Text(t)) => Some(t.as_str()),
760                _ => None,
761            })
762            .collect();
763        assert_eq!(texts, vec!["Hello, ", "world!"]);
764    }
765
766    #[test]
767    fn single_function_call_completes_via_output_item_done() {
768        let lines = [
769            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
770            "",
771            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\""}"#,
772            "",
773            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":":\"ls\"}"}"#,
774            "",
775            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
776            "",
777        ];
778        let (decoder, _text, events) = drive(&lines);
779
780        assert_eq!(decoder.completed_tools.len(), 1);
781        let tool = &decoder.completed_tools[0];
782        assert_eq!(tool.id, "call_abc");
783        assert_eq!(tool.function.name, "bash");
784        assert_eq!(tool.function.arguments, r#"{"cmd":"ls"}"#);
785
786        // Exactly one ToolUseStart for the tool.
787        let starts: Vec<_> = events
788            .iter()
789            .filter_map(|e| match e {
790                StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => {
791                    Some((tool_name.as_str(), tool_id.as_str()))
792                }
793                _ => None,
794            })
795            .collect();
796        assert_eq!(
797            starts,
798            vec![("bash", "call_abc")],
799            "exactly one ToolUseStart with correct tool_id"
800        );
801
802        // Two argument deltas streamed (each carrying the tool_id so
803        // parallel calls can be routed correctly by the chat UI).
804        let deltas: Vec<_> = events
805            .iter()
806            .filter_map(|e| match e {
807                StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id, delta }) => {
808                    Some((tool_id.as_str(), delta.as_str()))
809                }
810                _ => None,
811            })
812            .collect();
813        assert_eq!(
814            deltas,
815            vec![("call_abc", r#"{"cmd""#), ("call_abc", r#":"ls"}"#)]
816        );
817    }
818
819    #[test]
820    fn parallel_tool_calls_indexed_separately() {
821        let lines = [
822            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
823            "",
824            r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
825            "",
826            r#"data: {"type":"response.function_call_arguments.delta","output_index":1,"delta":"{\"path\":\"a\"}"}"#,
827            "",
828            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\":\"ls\"}"}"#,
829            "",
830            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
831            "",
832            r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
833            "",
834        ];
835        let (decoder, _text, _events) = drive(&lines);
836
837        assert_eq!(decoder.completed_tools.len(), 2);
838        let mut by_id: std::collections::BTreeMap<&str, &ToolCall> = std::collections::BTreeMap::new();
839        for tool in &decoder.completed_tools {
840            by_id.insert(tool.id.as_str(), tool);
841        }
842        assert_eq!(by_id["call_1"].function.name, "bash");
843        assert_eq!(by_id["call_1"].function.arguments, r#"{"cmd":"ls"}"#);
844        assert_eq!(by_id["call_2"].function.name, "read");
845        assert_eq!(by_id["call_2"].function.arguments, r#"{"path":"a"}"#);
846    }
847
848    #[test]
849    fn output_item_done_emits_tool_use_event() {
850        // Regression: the codex decoder must emit `LlmEvent::ToolUse` once a
851        // function_call's `output_item.done` arrives so the chat UI can
852        // collapse `ChatMessage::ToolUseStart` (animated) into the finalized
853        // `ChatMessage::ToolUse`. Without this the bash-trace animation
854        // persists forever and parallel tool blocks render as "still
855        // running" even after they've completed.
856        let lines = [
857            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
858            "",
859            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"command\":\"ls\"}"}"#,
860            "",
861            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
862            "",
863        ];
864        let (_decoder, _text, events) = drive(&lines);
865
866        let tool_uses: Vec<_> = events
867            .iter()
868            .filter_map(|e| match e {
869                StreamEvent::Llm(LlmEvent::ToolUse { tool_name, tool_id, input }) => {
870                    Some((tool_name.as_str(), tool_id.as_str(), input.clone()))
871                }
872                _ => None,
873            })
874            .collect();
875        assert_eq!(tool_uses.len(), 1, "expected exactly one ToolUse finalize event");
876        assert_eq!(tool_uses[0].0, "bash");
877        assert_eq!(tool_uses[0].1, "call_abc");
878        assert_eq!(
879            tool_uses[0].2,
880            serde_json::json!({"command": "ls"}),
881            "input must be parsed as a JSON Value, not a string"
882        );
883    }
884
885    #[test]
886    fn parallel_tool_calls_emit_tool_use_per_index() {
887        // Regression: parallel tool calls must each get their own ToolUse
888        // finalize event with the correct tool_id, so the chat UI can route
889        // their results back to the right block by id.
890        let lines = [
891            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
892            "",
893            r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
894            "",
895            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
896            "",
897            r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
898            "",
899        ];
900        let (_decoder, _text, events) = drive(&lines);
901
902        let tool_uses: Vec<_> = events
903            .iter()
904            .filter_map(|e| match e {
905                StreamEvent::Llm(LlmEvent::ToolUse { tool_name, tool_id, input }) => {
906                    Some((tool_name.clone(), tool_id.clone(), input.clone()))
907                }
908                _ => None,
909            })
910            .collect();
911
912        assert_eq!(tool_uses.len(), 2, "one ToolUse finalize per parallel call");
913        let by_id: std::collections::BTreeMap<&str, &(String, String, serde_json::Value)> =
914            tool_uses.iter().map(|t| (t.1.as_str(), t)).collect();
915        assert_eq!(by_id["call_1"].0, "bash");
916        assert_eq!(by_id["call_1"].2, serde_json::json!({"command": "ls"}));
917        assert_eq!(by_id["call_2"].0, "read");
918        assert_eq!(by_id["call_2"].2, serde_json::json!({"path": "a"}));
919    }
920
921    #[test]
922    fn malformed_arguments_emit_tool_use_with_parse_error() {
923        // If the model produces invalid JSON arguments, surface a structured
924        // parse error in the `input` (matching how the Anthropic path
925        // handles it via parse_tool_input) so the agent loop can return an
926        // error tool_result instead of silently dropping the tool.
927        let lines = [
928            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash"}}"#,
929            "",
930            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash","arguments":"{not json"}}"#,
931            "",
932        ];
933        let (_decoder, _text, events) = drive(&lines);
934
935        let tool_use = events.iter().find_map(|e| match e {
936            StreamEvent::Llm(LlmEvent::ToolUse { input, .. }) => Some(input.clone()),
937            _ => None,
938        });
939        let input = tool_use.expect("ToolUse event missing");
940        assert!(
941            input.get("__parse_error").and_then(Value::as_str).is_some(),
942            "malformed arguments must surface __parse_error, got {input}"
943        );
944    }
945
946    #[test]
947    fn response_completed_emits_usage_event() {
948        let lines = [
949            r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":42,"output_tokens":17}}}"#,
950            "",
951        ];
952        let (_decoder, _text, events) = drive(&lines);
953        let usage = events.iter().find_map(|e| match e {
954            StreamEvent::Session(SessionEvent::Usage {
955                input_tokens,
956                output_tokens,
957                ..
958            }) => Some((*input_tokens, *output_tokens)),
959            _ => None,
960        });
961        assert_eq!(usage, Some((42, 17)));
962    }
963
964    #[test]
965    fn response_completed_with_zero_usage_emits_nothing() {
966        let lines = [
967            r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":0,"output_tokens":0}}}"#,
968            "",
969        ];
970        let (_decoder, _text, events) = drive(&lines);
971        let any_usage = events.iter().any(|e| matches!(e, StreamEvent::Session(SessionEvent::Usage { .. })));
972        assert!(!any_usage, "zero-token usage should be suppressed");
973    }
974
975    #[test]
976    fn done_marker_finishes_decoder() {
977        let lines = [
978            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_x","name":"bash"}}"#,
979            "",
980            r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{}"}"#,
981            "",
982            "data: [DONE]",
983            "",
984        ];
985        let (decoder, _text, _events) = drive(&lines);
986        // active_tools promoted to completed_tools by finish().
987        assert_eq!(decoder.completed_tools.len(), 1);
988        assert_eq!(decoder.completed_tools[0].id, "call_x");
989        assert_eq!(decoder.completed_tools[0].function.arguments, "{}");
990    }
991
992    #[test]
993    fn finish_is_idempotent_no_double_emit() {
994        let lines = [
995            r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_y","name":"bash","arguments":"{}"}}"#,
996            "",
997        ];
998        let (mut decoder, _text, _events) = drive(&lines);
999        assert_eq!(decoder.completed_tools.len(), 1);
1000
1001        // Calling finish() again must not duplicate the tool.
1002        decoder.finish();
1003        assert_eq!(
1004            decoder.completed_tools.len(),
1005            1,
1006            "finish() called twice must not double-emit"
1007        );
1008    }
1009
1010    #[test]
1011    fn finish_drains_active_tools_for_state_hygiene() {
1012        // After [DONE], any leftover active tool entries should have been
1013        // promoted *and* drained from `active_tools`. This guards against
1014        // future code paths that re-call finish() (or new event types
1015        // that would otherwise re-iterate the old buffer).
1016        let lines = [
1017            r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_z","name":"bash"}}"#,
1018            "",
1019            "data: [DONE]",
1020            "",
1021        ];
1022        let (decoder, _text, _events) = drive(&lines);
1023        assert_eq!(decoder.completed_tools.len(), 1);
1024        assert!(
1025            decoder.active_tools.is_empty(),
1026            "active_tools must be drained after finish()"
1027        );
1028    }
1029
1030    #[test]
1031    fn unknown_event_types_are_ignored() {
1032        let lines = [
1033            r#"data: {"type":"response.future_unknown_event","payload":{"x":1}}"#,
1034            "",
1035            r#"data: {"type":"response.output_text.delta","delta":"hi"}"#,
1036            "",
1037        ];
1038        let (_decoder, text_acc, _events) = drive(&lines);
1039        assert_eq!(text_acc, "hi");
1040    }
1041}