1use 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#[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 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 while let Some(nl) = memchr::memchr(b'\n', &buf) {
116 let line_bytes = buf.split_to(nl + 1); 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 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 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 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 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 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
352fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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}