1use std::pin::Pin;
11use std::sync::mpsc;
12
13use indexmap::IndexMap;
14
15use futures::{Stream, StreamExt};
16
17use crate::events::{EventFrame, EventPayload, SSEEventType, SSEItemType, normalize_sse_line};
18use crate::executor::error::{ExecutorError, ExecutorResult};
19use crate::executor::function_sse::{FunctionSseTranslation, FunctionSseTranslator};
20use crate::types::event::{MessageStatus, ResponseStatus};
21use crate::types::io::output::McpListTools;
22use crate::types::io::{
23 ApplyDone, CompactionItem, CustomToolCall, FunctionToolCall, OutputItem, OutputMessage, OutputTextContent,
24 ReasoningOutput, ReasoningTextContent, ResponseUsage,
25};
26use crate::types::io::{McpCall, WebSearchCall};
27use crate::types::request_response::{IncompleteDetails, ResponsePayload};
28use crate::utils::common::{deserialize_from_str, deserialize_from_value_opt};
29use crate::utils::uuid7_str;
30
31enum InFlight {
34 Message { item: OutputMessage, text: String },
35 Reasoning { item: ReasoningOutput, text: String },
36 FunctionCall { item: FunctionToolCall, arguments: String },
37 CustomToolCall { item: CustomToolCall, input: String },
38 WebSearchCall { item: Option<WebSearchCall> },
39 McpCall { item: McpCall },
40 McpListTools { item: McpListTools },
41 Compaction { item: CompactionItem },
42}
43
44impl std::fmt::Debug for InFlight {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 match self {
47 Self::Message { .. } => write!(f, "InFlight::Message {{ .. }}"),
48 Self::Reasoning { .. } => write!(f, "InFlight::Reasoning {{ .. }}"),
49 Self::FunctionCall { .. } => write!(f, "InFlight::FunctionCall {{ .. }}"),
50 Self::CustomToolCall { .. } => write!(f, "InFlight::CustomToolCall {{ .. }}"),
51 Self::WebSearchCall { .. } => write!(f, "InFlight::WebSearchCall {{ .. }}"),
52 Self::McpCall { .. } => write!(f, "InFlight::McpCall {{ .. }}"),
53 Self::McpListTools { .. } => write!(f, "InFlight::McpListTools {{ .. }}"),
54 Self::Compaction { .. } => write!(f, "InFlight::Compaction {{ .. }}"),
55 }
56 }
57}
58
59impl InFlight {
60 fn finalize(self) -> Option<OutputItem> {
61 match self {
62 Self::Reasoning { mut item, text } => {
63 if !text.is_empty() {
64 item.content.push(ReasoningTextContent::new(text));
65 }
66 Some(OutputItem::Reasoning(item))
67 }
68 Self::FunctionCall { mut item, arguments } => {
69 if !arguments.is_empty() && item.arguments.is_empty() {
70 item.arguments = arguments;
71 }
72 item.status = MessageStatus::Completed;
73 Some(OutputItem::FunctionCall(item))
74 }
75 Self::Message { mut item, text } => {
76 if !text.is_empty() {
77 item.content.push(OutputTextContent::new(text));
78 }
79 item.status = MessageStatus::Completed;
80 Some(OutputItem::Message(item))
81 }
82 Self::CustomToolCall { mut item, input } => {
83 if item.input.is_empty() {
84 item.input = input;
85 }
86 item.status = Some(MessageStatus::Completed);
87 Some(OutputItem::CustomToolCall(item))
88 }
89 Self::WebSearchCall { item } => item.map(OutputItem::WebSearchCall),
90 Self::McpCall { item } => Some(OutputItem::McpCall(item)),
91 Self::McpListTools { item } => Some(OutputItem::McpListTools(item)),
92 Self::Compaction { item } => Some(OutputItem::Compaction(item)),
93 }
94 }
95}
96
97#[derive(Debug)]
98struct InFlightEntry {
99 output_index: u32,
100 item: InFlight,
101}
102
103#[derive(Clone, Copy)]
104pub(super) struct AccumulatedFunctionCall<'a> {
105 pub(super) item: &'a FunctionToolCall,
106 pub(super) output_index: u32,
107 arguments: &'a str,
108}
109
110impl AccumulatedFunctionCall<'_> {
111 pub(super) fn arguments(&self) -> &str {
112 if self.item.arguments.is_empty() {
113 self.arguments
114 } else {
115 &self.item.arguments
116 }
117 }
118}
119
120#[derive(Debug)]
122pub struct ResponseAccumulator {
123 response_id: String,
124 conversation_id: Option<String>,
125 output: Vec<OutputItem>,
126 usage: Option<ResponseUsage>,
127 status: ResponseStatus,
128 incomplete_details: Option<IncompleteDetails>,
129 error: Option<serde_json::Value>,
130 in_flight: IndexMap<String, InFlightEntry>,
132 completed: Vec<(u32, OutputItem)>,
134}
135
136impl ResponseAccumulator {
137 #[must_use]
139 pub fn new(response_id: String, conversation_id: Option<String>) -> Self {
140 Self {
141 response_id,
142 conversation_id,
143 output: Vec::new(),
144 usage: None,
145 status: ResponseStatus::InProgress,
146 incomplete_details: None,
147 error: None,
148 in_flight: IndexMap::new(),
149 completed: Vec::new(),
150 }
151 }
152
153 pub fn from_json(body: &str, conversation_id: Option<&str>) -> ExecutorResult<Self> {
158 let mut json: serde_json::Value = deserialize_from_str(body).map_err(ExecutorError::JsonError)?;
159
160 let response_id = json["id"]
161 .as_str()
162 .ok_or_else(|| ExecutorError::ParseError("missing 'id' field in response".into()))?
163 .to_string();
164
165 let output = deserialize_from_value_opt::<Vec<serde_json::Value>>(json["output"].take())
166 .map(|items| {
167 let mut out = Vec::with_capacity(items.len());
168 out.extend(items.into_iter().filter_map(deserialize_from_value_opt::<OutputItem>));
169 out
170 })
171 .unwrap_or_default();
172
173 let status = json["status"]
174 .as_str()
175 .map_or(ResponseStatus::Completed, |s| s.parse().unwrap_or_default());
176
177 let usage = deserialize_from_value_opt::<ResponseUsage>(json["usage"].take());
178 let incomplete_details = deserialize_from_value_opt::<IncompleteDetails>(json["incomplete_details"].take());
179 let error = (!json["error"].is_null()).then(|| json["error"].take());
180
181 Ok(Self {
182 response_id,
183 conversation_id: conversation_id.map(str::to_string),
184 output,
185 usage,
186 status,
187 incomplete_details,
188 error,
189 in_flight: IndexMap::new(),
190 completed: Vec::new(),
191 })
192 }
193
194 pub async fn from_stream(
204 mut stream: Pin<Box<dyn Stream<Item = Result<String, ExecutorError>> + Send>>,
205 conversation_id: Option<&str>,
206 ) -> ExecutorResult<Self> {
207 let (tx, rx) = mpsc::channel::<String>();
208 let conv_id_owned = conversation_id.map(str::to_string);
210
211 let worker_handle = tokio::task::spawn_blocking(move || Self::process_stream_chunks(rx, conv_id_owned));
213
214 while let Some(chunk_result) = stream.next().await {
216 match chunk_result {
217 Ok(chunk) => {
218 if tx.send(chunk).is_err() {
219 break;
220 }
221 }
222 Err(e) => return Err(e),
223 }
224 }
225
226 drop(tx);
228
229 worker_handle
231 .await
232 .map_err(|_| ExecutorError::StreamError("Worker thread panicked".into()))
233 }
234
235 fn process_stream_chunks(rx: mpsc::Receiver<String>, conversation_id: Option<String>) -> Self {
237 let mut acc = Self::new(uuid7_str("resp_"), conversation_id);
238 for line in rx {
239 let _ = acc.process_sse_line(&line);
240 }
241 acc.finish_stream();
242 acc
243 }
244
245 #[must_use]
251 pub fn from_sse_lines(lines: impl IntoIterator<Item = String>, conversation_id: Option<&str>) -> Self {
252 let mut acc = Self::new(uuid7_str("resp_"), conversation_id.map(str::to_string));
253 for line in lines {
254 let _ = acc.process_sse_line(&line);
255 }
256 acc.finalize_all();
257 acc
258 }
259
260 pub(crate) fn finalize_all(&mut self) {
262 self.completed.extend(
263 self.in_flight
264 .drain(..)
265 .filter_map(|(_, entry)| entry.item.finalize().map(|item| (entry.output_index, item))),
266 );
267 self.completed.sort_by_key(|(output_index, _)| *output_index);
268 self.output
269 .extend(self.completed.drain(..).map(|(_, output_item)| output_item));
270 }
271
272 pub(crate) fn process_sse_line(&mut self, line: &str) -> Option<EventFrame> {
273 let frame = normalize_sse_line(line)?;
274 self.capture_terminal_details_if_needed(&frame);
275 self.process_event(&frame);
276 Some(frame)
277 }
278
279 pub(super) fn process_sse_line_with_translator(
280 &mut self,
281 line: &str,
282 translator: &mut FunctionSseTranslator,
283 ) -> ExecutorResult<Option<FunctionSseTranslation>> {
284 let Some(frame) = self.process_sse_line(line) else {
285 return Ok(None);
286 };
287 let call_key = function_event_key(&frame.payload);
288 let call = call_key.and_then(|(item_id, output_index)| self.accumulated_function_call(item_id, output_index));
289 translator.translate(frame, call).map(Some)
290 }
291
292 fn accumulated_function_call(&self, item_id: &str, output_index: u32) -> Option<AccumulatedFunctionCall<'_>> {
293 let entry = self
294 .in_flight
295 .get(item_id)
296 .filter(|entry| entry.output_index == output_index && matches!(entry.item, InFlight::FunctionCall { .. }))
297 .or_else(|| {
298 self.in_flight.values().find(|entry| {
299 entry.output_index == output_index && matches!(entry.item, InFlight::FunctionCall { .. })
300 })
301 });
302 if let Some(InFlightEntry {
303 output_index,
304 item: InFlight::FunctionCall { item, arguments },
305 }) = entry
306 {
307 return Some(AccumulatedFunctionCall {
308 item,
309 output_index: *output_index,
310 arguments,
311 });
312 }
313
314 self.completed.iter().rev().find_map(|(completed_index, item)| {
315 let OutputItem::FunctionCall(item) = item else {
316 return None;
317 };
318 (*completed_index == output_index).then_some(AccumulatedFunctionCall {
319 item,
320 output_index: *completed_index,
321 arguments: &item.arguments,
322 })
323 })
324 }
325
326 fn capture_terminal_details(&mut self, frame: &EventFrame) {
327 let Some(response) = frame.wire.rest.get("response") else {
328 return;
329 };
330
331 self.incomplete_details = response
332 .get("incomplete_details")
333 .cloned()
334 .and_then(deserialize_from_value_opt::<IncompleteDetails>);
335 self.error = response.get("error").filter(|error| !error.is_null()).cloned();
336 }
337
338 fn capture_terminal_details_if_needed(&mut self, frame: &EventFrame) {
339 if matches!(
340 frame.event_type,
341 SSEEventType::ResponseFailed | SSEEventType::ResponseIncomplete
342 ) {
343 self.capture_terminal_details(frame);
344 }
345 }
346
347 pub(crate) fn finish_stream(&mut self) {
348 self.finalize_all();
349 if self.status == ResponseStatus::InProgress {
350 self.status = ResponseStatus::Completed;
351 }
352 }
353
354 pub(crate) fn process_event(&mut self, frame: &EventFrame) {
360 match (&frame.event_type, &frame.payload) {
361 (SSEEventType::ResponseCreated, EventPayload::Response { id, .. }) if !id.is_empty() => {
362 self.response_id.clone_from(id);
363 }
364 (SSEEventType::OutputItemAdded, payload @ EventPayload::OutputItemAdded { .. }) => {
365 self.start_output_item(payload);
366 }
367 (SSEEventType::OutputItemDone, payload @ EventPayload::OutputItemDone { .. }) => {
368 self.complete_call_item(payload);
369 }
370 (SSEEventType::ReasoningTextDelta, EventPayload::ReasoningDelta { delta, item_id }) => {
371 if let Some(InFlight::Reasoning { text, .. }) =
372 self.in_flight.get_mut(item_id).map(|entry| &mut entry.item)
373 {
374 text.push_str(delta);
375 }
376 }
377 (SSEEventType::ReasoningTextDone, EventPayload::ReasoningDone { item_id, .. }) => {
378 if let Some(InFlight::Reasoning { item, text }) =
379 self.in_flight.get_mut(item_id).map(|entry| &mut entry.item)
380 {
381 item.apply_done(&frame.payload, text);
382 }
383 }
384 (
385 SSEEventType::FunctionCallArgumentsDelta,
386 EventPayload::FunctionCallArgsDelta {
387 delta,
388 item_id,
389 output_index,
390 ..
391 },
392 ) => {
393 let key = self.in_flight_call_key(item_id, SSEItemType::FunctionCall, *output_index);
394 if let Some(InFlight::FunctionCall { arguments, .. }) = key
395 .as_deref()
396 .and_then(|key| self.in_flight.get_mut(key))
397 .map(|entry| &mut entry.item)
398 {
399 arguments.push_str(delta);
400 }
401 }
402 (
403 SSEEventType::FunctionCallArgumentsDone,
404 EventPayload::FunctionCallArgsDone {
405 item_id, output_index, ..
406 },
407 ) => {
408 let key = self.in_flight_call_key(item_id, SSEItemType::FunctionCall, *output_index);
409 if let Some(InFlight::FunctionCall { item, arguments }) = key
410 .as_deref()
411 .and_then(|key| self.in_flight.get_mut(key))
412 .map(|entry| &mut entry.item)
413 {
414 item.apply_done(&frame.payload, arguments);
415 }
416 }
417 (SSEEventType::CustomToolCallInputDelta, EventPayload::CustomToolCallInputDelta { delta, item_id, .. }) => {
418 if let Some(InFlight::CustomToolCall { input, .. }) =
419 self.in_flight.get_mut(item_id).map(|entry| &mut entry.item)
420 {
421 input.push_str(delta);
422 }
423 }
424 (SSEEventType::CustomToolCallInputDone, EventPayload::CustomToolCallInputDone { item_id, .. }) => {
425 if let Some(InFlight::CustomToolCall { item, input }) =
426 self.in_flight.get_mut(item_id).map(|entry| &mut entry.item)
427 {
428 item.apply_done(&frame.payload, input);
429 }
430 }
431 (SSEEventType::OutputTextDelta, EventPayload::TextDelta { delta, item_id, .. }) => {
432 if let Some(InFlight::Message { text, .. }) =
433 self.in_flight.get_mut(item_id).map(|entry| &mut entry.item)
434 {
435 text.push_str(delta);
436 }
437 }
438 (SSEEventType::ResponseCompleted, EventPayload::Response { usage, .. }) => {
439 self.finish_response(ResponseStatus::Completed, *usage);
440 }
441 (SSEEventType::ResponseFailed, EventPayload::Response { usage, .. }) => {
442 self.finish_response(ResponseStatus::Error, *usage);
443 }
444 (SSEEventType::ResponseIncomplete, EventPayload::Response { usage, .. }) => {
445 self.finish_response(ResponseStatus::Incomplete, *usage);
446 }
447 _ => {}
448 }
449 }
450
451 fn start_output_item(&mut self, payload: &EventPayload) {
452 let EventPayload::OutputItemAdded {
453 item_id,
454 item_type,
455 output_index,
456 ..
457 } = payload
458 else {
459 return;
460 };
461 let item = match item_type {
462 SSEItemType::Reasoning => ReasoningOutput::try_from(payload).ok().map(|item| InFlight::Reasoning {
463 item,
464 text: String::with_capacity(256),
465 }),
466 SSEItemType::FunctionCall => FunctionToolCall::try_from(payload)
467 .ok()
468 .map(|item| InFlight::FunctionCall {
469 item,
470 arguments: String::with_capacity(128),
471 }),
472 SSEItemType::CustomToolCall => {
473 CustomToolCall::try_from(payload)
474 .ok()
475 .map(|item| InFlight::CustomToolCall {
476 item,
477 input: String::with_capacity(256),
478 })
479 }
480 SSEItemType::Message => OutputMessage::try_from(payload).ok().map(|item| InFlight::Message {
481 item,
482 text: String::with_capacity(256),
483 }),
484 SSEItemType::WebSearchCall if !item_id.is_empty() => Some(InFlight::WebSearchCall { item: None }),
485 SSEItemType::Compaction => CompactionItem::try_from(payload)
486 .ok()
487 .map(|item| InFlight::Compaction { item }),
488 SSEItemType::WebSearchCall => None,
489 SSEItemType::McpCall => McpCall::try_from(payload).ok().map(|item| InFlight::McpCall { item }),
490 SSEItemType::McpListTools => McpListTools::try_from(payload)
491 .ok()
492 .map(|item| InFlight::McpListTools { item }),
493 };
494 if let Some(item) = item {
495 let needs_internal_key = matches!(&item, InFlight::FunctionCall { .. })
496 && (item_id.is_empty() || self.in_flight.contains_key(item_id));
497 let key = if needs_internal_key {
498 let mut key = format!("__output_index_{output_index}");
499 while self.in_flight.contains_key(&key) {
500 key.push('_');
501 }
502 key
503 } else {
504 item_id.clone()
505 };
506 self.in_flight.insert(
507 key,
508 InFlightEntry {
509 output_index: *output_index,
510 item,
511 },
512 );
513 }
514 }
515
516 fn finish_response(&mut self, status: ResponseStatus, usage: Option<ResponseUsage>) {
517 self.finalize_all();
518 self.status = status;
519 self.usage = usage;
520 }
521
522 fn complete_call_item(&mut self, payload: &EventPayload) {
523 let EventPayload::OutputItemDone {
524 item_id,
525 item_type,
526 output_index,
527 item: raw_item,
528 ..
529 } = payload
530 else {
531 return;
532 };
533 let in_flight_key = self.in_flight_call_key(item_id, *item_type, *output_index);
534 let done_item = deserialize_from_value_opt::<OutputItem>(raw_item.clone());
535 if let Some(entry) = in_flight_key.as_deref().and_then(|key| self.in_flight.get_mut(key)) {
536 match (&mut entry.item, done_item) {
537 (InFlight::FunctionCall { item, arguments }, _) => item.apply_done(payload, arguments),
538 (InFlight::CustomToolCall { item, input }, _) => item.apply_done(payload, input),
539 (InFlight::McpCall { item }, _) => item.apply_done(payload, &mut String::new()),
540 (InFlight::McpListTools { item }, _) => item.apply_done(payload, &mut String::new()),
541 (InFlight::Compaction { item }, _) => item.apply_done(payload, &mut String::new()),
542 (InFlight::WebSearchCall { item }, Some(OutputItem::WebSearchCall(mut call))) => {
543 if call.id.is_empty() {
544 call.id = in_flight_key
545 .as_deref()
546 .filter(|id| !id.is_empty())
547 .map_or_else(|| uuid7_str("ws_"), str::to_owned);
548 }
549 *item = Some(call);
550 }
551 _ => {}
552 }
553 return;
554 }
555
556 if let Some(
557 mut output_item @ (OutputItem::FunctionCall(_)
558 | OutputItem::CustomToolCall(_)
559 | OutputItem::WebSearchCall(_)
560 | OutputItem::McpCall(_)
561 | OutputItem::McpListTools(_)
562 | OutputItem::Compaction(_)),
563 ) = done_item
564 {
565 let OutputItem::WebSearchCall(call) = &mut output_item else {
566 self.completed.push((*output_index, output_item));
567 return;
568 };
569 if call.id.is_empty() {
570 call.id = uuid7_str("ws_");
571 }
572 self.completed.push((*output_index, output_item));
573 }
574 }
575
576 fn in_flight_call_key(&self, item_id: &str, item_type: SSEItemType, output_index: u32) -> Option<String> {
577 self.in_flight
578 .get(item_id)
579 .filter(|entry| entry.output_index == output_index && in_flight_matches_call_type(&entry.item, item_type))
580 .map(|_| item_id.to_owned())
581 .or_else(|| {
582 self.in_flight.iter().find_map(|(key, entry)| {
583 (entry.output_index == output_index && in_flight_matches_call_type(&entry.item, item_type))
584 .then(|| key.clone())
585 })
586 })
587 }
588
589 pub fn mark_incomplete(&mut self, reason: impl Into<String>) {
591 self.status = ResponseStatus::Incomplete;
592 self.incomplete_details = Some(IncompleteDetails {
593 reason: Some(reason.into()),
594 });
595 }
596
597 #[must_use]
602 pub fn finalize(
603 self,
604 model: &str,
605 previous_response_id: Option<&str>,
606 instructions: Option<&str>,
607 ) -> ResponsePayload {
608 ResponsePayload {
609 id: self.response_id,
610 object: "response".to_string(),
611 created_at: chrono::Utc::now().timestamp(),
612 model: model.to_string(),
613 status: self.status.as_str().to_string(),
614 output: self.output,
615 usage: self.usage,
616 incomplete_details: self.incomplete_details,
617 error: self.error,
618 previous_response_id: previous_response_id.map(str::to_string),
619 conversation_id: self.conversation_id,
620 instructions: instructions.map(str::to_string),
621 }
622 }
623}
624
625fn in_flight_matches_call_type(item: &InFlight, item_type: SSEItemType) -> bool {
626 matches!(
627 (item, item_type),
628 (InFlight::FunctionCall { .. }, SSEItemType::FunctionCall)
629 | (InFlight::CustomToolCall { .. }, SSEItemType::CustomToolCall)
630 | (InFlight::WebSearchCall { .. }, SSEItemType::WebSearchCall)
631 | (InFlight::McpCall { .. }, SSEItemType::McpCall)
632 | (InFlight::McpListTools { .. }, SSEItemType::McpListTools)
633 | (InFlight::Compaction { .. }, SSEItemType::Compaction)
634 )
635}
636
637fn function_event_key(payload: &EventPayload) -> Option<(&str, u32)> {
638 match payload {
639 EventPayload::OutputItemAdded {
640 item_id,
641 item_type: SSEItemType::FunctionCall,
642 output_index,
643 ..
644 }
645 | EventPayload::OutputItemDone {
646 item_id,
647 item_type: SSEItemType::FunctionCall,
648 output_index,
649 ..
650 }
651 | EventPayload::FunctionCallArgsDelta {
652 item_id, output_index, ..
653 }
654 | EventPayload::FunctionCallArgsDone {
655 item_id, output_index, ..
656 } => Some((item_id, *output_index)),
657 _ => None,
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664 use crate::events::WireEvent;
665 use crate::types::io::{McpCallError, McpCallStatus, WebSearchCallStatus};
666
667 #[test]
668 fn test_accumulator_new() {
669 let acc = ResponseAccumulator::new("resp_123".into(), Some("conv_456".into()));
670 assert_eq!(acc.response_id, "resp_123");
671 assert_eq!(acc.conversation_id, Some("conv_456".into()));
672 assert_eq!(acc.status, ResponseStatus::InProgress);
673 }
674
675 #[test]
676 fn test_accumulator_mark_incomplete() {
677 let mut acc = ResponseAccumulator::new("resp_123".into(), None);
678 acc.mark_incomplete("Stream interrupted");
679 assert_eq!(acc.status, ResponseStatus::Incomplete);
680 assert!(acc.incomplete_details.is_some());
681 }
682
683 #[test]
684 fn test_accumulator_preserves_streamed_failure_details() {
685 let acc = ResponseAccumulator::from_sse_lines(
686 [r#"data: {"type":"response.failed","response":{"id":"resp_failed","status":"failed","error":{"code":"tool_catalog_too_large","message":"Too many tools"},"incomplete_details":{"reason":"upstream_error"}}}"#.to_owned()],
687 None,
688 );
689 let payload = acc.finalize("test-model", None, None);
690
691 assert_eq!(payload.status, "error");
692 assert_eq!(payload.error.as_ref().unwrap()["code"], "tool_catalog_too_large");
693 assert_eq!(
694 payload.incomplete_details.unwrap().reason.as_deref(),
695 Some("upstream_error")
696 );
697 }
698
699 #[test]
700 fn test_accumulator_finalize() {
701 let acc = ResponseAccumulator::new("resp_123".into(), Some("conv_456".into()));
702 let payload = acc.finalize("gpt-4o", Some("resp_prev"), Some("be helpful"));
703 assert_eq!(payload.id, "resp_123");
704 assert_eq!(payload.model, "gpt-4o");
705 assert_eq!(payload.conversation_id, Some("conv_456".into()));
706 assert_eq!(payload.previous_response_id, Some("resp_prev".into()));
707 assert_eq!(payload.instructions, Some("be helpful".into()));
708 assert_eq!(payload.status, ResponseStatus::InProgress.as_str());
709 }
710
711 #[test]
712 fn test_accumulator_from_sse_lines_empty() {
713 let acc = ResponseAccumulator::from_sse_lines(vec![], None);
714 assert_eq!(acc.status, ResponseStatus::InProgress);
715 assert!(acc.output.is_empty());
716 }
717
718 #[test]
719 fn test_accumulator_text_delta_assigned_to_message() {
720 let lines = vec![
721 r#"data: {"type":"response.created","response":{"id":"resp_abc"}}"#.to_string(),
722 r#"data: {"type":"response.output_item.added","item":{"id":"msg_1"}}"#.to_string(),
723 r#"data: {"type":"response.output_text.delta","delta":"Hello","item_id":"msg_1"}"#.to_string(),
724 r#"data: {"type":"response.output_text.delta","delta":" world","item_id":"msg_1"}"#.to_string(),
725 r#"data: {"type":"response.done","response":{"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}"#.to_string(),
726 ];
727
728 let acc = ResponseAccumulator::from_sse_lines(lines, None);
729 assert_eq!(acc.status, ResponseStatus::Completed);
730 assert_eq!(acc.output.len(), 1);
731
732 if let OutputItem::Message(msg) = &acc.output[0] {
733 assert_eq!(msg.content.len(), 1);
734 assert_eq!(msg.content[0].text, "Hello world");
735 } else {
736 panic!("expected OutputItem::Message");
737 }
738
739 assert!(acc.usage.is_some());
740 let usage = acc.usage.unwrap();
741 assert_eq!(usage.total_tokens, 7);
742 }
743
744 #[test]
745 fn test_message_status_enum() {
746 assert_eq!(MessageStatus::Completed.as_str(), "completed");
747 assert_eq!(MessageStatus::InProgress.as_str(), "in_progress");
748 }
749
750 #[test]
751 fn test_process_event_response_created_sets_id() {
752 let mut acc = ResponseAccumulator::new("resp_old".into(), None);
753 let frame = EventFrame {
754 event_type: SSEEventType::ResponseCreated,
755 payload: EventPayload::Response {
756 id: "resp_new".into(),
757 status: "in_progress".into(),
758 usage: None,
759 },
760 wire: WireEvent::new("test"),
761 };
762 acc.process_event(&frame);
763 assert_eq!(acc.response_id, "resp_new");
764 }
765
766 #[test]
767 fn test_process_event_response_created_empty_id_no_overwrite() {
768 let mut acc = ResponseAccumulator::new("resp_keep".into(), None);
769 let frame = EventFrame {
770 event_type: SSEEventType::ResponseCreated,
771 payload: EventPayload::Response {
772 id: String::new(),
773 status: "in_progress".into(),
774 usage: None,
775 },
776 wire: WireEvent::new("test"),
777 };
778 acc.process_event(&frame);
779 assert_eq!(acc.response_id, "resp_keep");
780 }
781
782 #[test]
783 fn test_process_event_text_delta_accumulates() {
784 let mut acc = ResponseAccumulator::new("resp_1".into(), None);
785
786 acc.process_event(&EventFrame {
787 event_type: SSEEventType::OutputItemAdded,
788 payload: EventPayload::OutputItemAdded {
789 item_id: "msg_1".into(),
790 item_type: "message".into(),
791 output_index: 0,
792 name: None,
793 namespace: None,
794 call_id: None,
795 },
796 wire: WireEvent::new("test"),
797 });
798
799 acc.process_event(&EventFrame {
800 event_type: SSEEventType::OutputTextDelta,
801 payload: EventPayload::TextDelta {
802 delta: "Hello".into(),
803 item_id: "msg_1".into(),
804 output_index: 0,
805 content_index: 0,
806 },
807 wire: WireEvent::new("test"),
808 });
809 acc.process_event(&EventFrame {
810 event_type: SSEEventType::OutputTextDelta,
811 payload: EventPayload::TextDelta {
812 delta: " world".into(),
813 item_id: "msg_1".into(),
814 output_index: 0,
815 content_index: 0,
816 },
817 wire: WireEvent::new("test"),
818 });
819
820 acc.process_event(&EventFrame {
821 event_type: SSEEventType::ResponseCompleted,
822 payload: EventPayload::Response {
823 id: "resp_1".into(),
824 status: "completed".into(),
825 usage: None,
826 },
827 wire: WireEvent::new("test"),
828 });
829
830 assert_eq!(acc.status, ResponseStatus::Completed);
831 assert_eq!(acc.output.len(), 1);
832 if let OutputItem::Message(msg) = &acc.output[0] {
833 assert_eq!(msg.content[0].text, "Hello world");
834 } else {
835 panic!("expected Message");
836 }
837 }
838
839 #[test]
840 fn test_process_event_mcp_call_done_accumulates_output() {
841 let lines = vec![
842 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"mcp_call","id":"mcp_1","server_label":"counter","name":"increment","arguments":"","status":"in_progress","approval_request_id":null,"output":null,"error":null}}"#.to_string(),
843 r#"data: {"type":"response.mcp_call.in_progress","item_id":"mcp_1","output_index":0}"#.to_string(),
844 r#"data: {"type":"response.mcp_call_arguments.delta","delta":"{}","item_id":"mcp_1","output_index":0}"#.to_string(),
845 r#"data: {"type":"response.mcp_call_arguments.done","arguments":"{}","item_id":"mcp_1","output_index":0}"#.to_string(),
846 r#"data: {"type":"response.mcp_call.completed","item_id":"mcp_1","output_index":0}"#.to_string(),
847 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"mcp_call","id":"mcp_1","server_label":"counter","name":"increment","arguments":"{}","status":"completed","approval_request_id":null,"output":"1","error":null}}"#.to_string(),
848 r#"data: {"type":"response.done","response":{"id":"resp_1","status":"completed","usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}"#.to_string(),
849 ];
850
851 let acc = ResponseAccumulator::from_sse_lines(lines, None);
852 assert_eq!(acc.status, ResponseStatus::Completed);
853 assert_eq!(acc.output.len(), 1);
854 assert!(matches!(acc.output[0], OutputItem::McpCall(_)));
855 }
856
857 #[test]
858 fn test_process_event_mcp_list_tools_done_accumulates_output() {
859 let added = r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"mcp_list_tools","id":"mcpl_1","server_label":"counter","tools":[]}}"#;
860 let remaining = [
861 r#"data: {"type":"response.mcp_list_tools.in_progress","item_id":"mcpl_1","output_index":0}"#.to_string(),
862 r#"data: {"type":"response.mcp_list_tools.completed","item_id":"mcpl_1","output_index":0}"#.to_string(),
863 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"mcp_list_tools","id":"mcpl_1","server_label":"counter","tools":[{"name":"increment","description":"Increment the counter","input_schema":{"type":"object","properties":{}},"annotations":{"read_only":false}}]}}"#.to_string(),
864 r#"data: {"type":"response.done","response":{"id":"resp_1","status":"completed"}}"#.to_string(),
865 ];
866
867 let mut acc = ResponseAccumulator::new("resp_1".to_owned(), None);
868 acc.process_sse_line(added);
869 let Some(InFlightEntry {
870 output_index: 0,
871 item: InFlight::McpListTools { item },
872 }) = acc.in_flight.get("mcpl_1")
873 else {
874 panic!("expected in-flight mcp_list_tools");
875 };
876 assert!(item.server_label.is_empty());
877 assert!(item.tools.is_empty());
878
879 for line in remaining {
880 acc.process_sse_line(&line);
881 }
882 acc.finalize_all();
883
884 assert_eq!(acc.status, ResponseStatus::Completed);
885 assert_eq!(acc.output.len(), 1);
886 let OutputItem::McpListTools(item) = &acc.output[0] else {
887 panic!("expected mcp_list_tools");
888 };
889 assert_eq!(item.id, "mcpl_1");
890 assert_eq!(item.server_label, "counter");
891 assert_eq!(item.tools.len(), 1);
892 assert_eq!(item.tools[0].name, "increment");
893 assert_eq!(item.tools[0].annotations, Some(serde_json::json!({"read_only": false})));
894 }
895
896 #[test]
897 fn compaction_added_and_done_accumulate_typed_output() {
898 let done = r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"compaction","id":"cmp_1","encrypted_content":"durable summary"}}"#;
899 let lines = [
900 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"compaction","id":"cmp_1","encrypted_content":"durable summary"}}"#.to_owned(),
901 done.to_owned(),
902 r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}"#.to_owned(),
903 ];
904
905 let acc = ResponseAccumulator::from_sse_lines(lines, None);
906 assert_compaction_output(&acc.output);
907
908 let done_only = ResponseAccumulator::from_sse_lines([done.to_owned()], None);
909 assert_compaction_output(&done_only.output);
910 }
911
912 fn assert_compaction_output(output: &[OutputItem]) {
913 assert_eq!(output.len(), 1);
914 let OutputItem::Compaction(item) = &output[0] else {
915 panic!("expected compaction output");
916 };
917 assert_eq!(item.id.as_deref(), Some("cmp_1"));
918 assert_eq!(item.encrypted_content, "durable summary");
919 }
920
921 #[test]
922 fn test_accumulator_reasoning_before_mcp_call_preserves_order() {
923 let lines = vec![
924 r#"data: {"type":"response.created","response":{"id":"resp_abc"}}"#.to_string(),
925 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[]}}"#.to_string(),
926 r#"data: {"type":"response.reasoning_text.done","text":"thinking...","item_id":"rs_1"}"#.to_string(),
927 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"mcp_call","id":"mcp_1","server_label":"counter","name":"increment","arguments":"","status":"in_progress","approval_request_id":null,"output":null,"error":null}}"#.to_string(),
928 r#"data: {"type":"response.mcp_call.completed","item_id":"mcp_1","output_index":1}"#.to_string(),
929 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"mcp_call","id":"mcp_1","server_label":"counter","name":"increment","arguments":"{}","status":"completed","approval_request_id":null,"output":"1","error":null}}"#.to_string(),
930 r#"data: {"type":"response.done","response":{"id":"resp_abc","status":"completed","usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}"#.to_string(),
931 ];
932
933 let acc = ResponseAccumulator::from_sse_lines(lines, None);
934 assert_eq!(acc.output.len(), 2);
935 assert!(matches!(acc.output[0], OutputItem::Reasoning(_)));
936 assert!(matches!(acc.output[1], OutputItem::McpCall(_)));
937 }
938
939 #[test]
940 fn test_accumulator_reasoning_before_done_only_mcp_call_preserves_order() {
941 let lines = vec![
942 r#"data: {"type":"response.created","response":{"id":"resp_abc"}}"#.to_string(),
943 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[]}}"#.to_string(),
944 r#"data: {"type":"response.reasoning_text.done","text":"thinking...","item_id":"rs_1"}"#.to_string(),
945 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"mcp_call","id":"mcp_1","server_label":"counter","name":"increment","arguments":"{}","status":"completed","approval_request_id":null,"output":"1","error":null}}"#.to_string(),
946 r#"data: {"type":"response.done","response":{"id":"resp_abc","status":"completed","usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}"#.to_string(),
947 ];
948
949 let acc = ResponseAccumulator::from_sse_lines(lines, None);
950 assert_eq!(acc.output.len(), 2);
951 assert!(matches!(acc.output[0], OutputItem::Reasoning(_)));
952 assert!(matches!(acc.output[1], OutputItem::McpCall(_)));
953 }
954
955 #[test]
956 fn test_accumulator_reasoning_before_web_search_call_preserves_order() {
957 let lines = vec![
958 r#"data: {"type":"response.created","response":{"id":"resp_abc"}}"#.to_string(),
959 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[]}}"#.to_string(),
960 r#"data: {"type":"response.reasoning_text.done","text":"thinking...","item_id":"rs_1"}"#.to_string(),
961 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","id":"ws_1","status":"in_progress","action":{"type":"search","query":"","sources":[]}}}"#.to_string(),
962 r#"data: {"type":"response.web_search_call.in_progress","item_id":"ws_1","output_index":1}"#.to_string(),
963 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"rust","sources":[]}}}"#.to_string(),
964 r#"data: {"type":"response.done","response":{"id":"resp_abc","status":"completed","usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}"#.to_string(),
965 ];
966
967 let acc = ResponseAccumulator::from_sse_lines(lines, None);
968 assert_eq!(acc.output.len(), 2);
969 assert!(matches!(acc.output[0], OutputItem::Reasoning(_)));
970 let OutputItem::WebSearchCall(call) = &acc.output[1] else {
971 panic!("expected web_search_call");
972 };
973 assert_eq!(call.status, WebSearchCallStatus::Completed);
974 assert_eq!(call.action.as_search().unwrap().query, "rust");
975 }
976
977 #[test]
978 fn test_accumulator_preserves_open_page_web_search_action() {
979 let lines = vec![
980 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"web_search_call","id":"ws_1","status":"in_progress"}}"#.to_string(),
981 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"open_page","url":"https://example.com"}}}"#.to_string(),
982 r#"data: {"type":"response.done","response":{"id":"resp_1","status":"completed"}}"#.to_string(),
983 ];
984
985 let acc = ResponseAccumulator::from_sse_lines(lines, None);
986 assert_eq!(acc.output.len(), 1);
987 let action = match &acc.output[0] {
988 OutputItem::WebSearchCall(call) => serde_json::to_value(&call.action).unwrap(),
989 _ => panic!("expected web_search_call"),
990 };
991 assert_eq!(action["type"], "open_page");
992 assert_eq!(action["url"], "https://example.com");
993 }
994
995 #[test]
996 fn test_accumulator_preserves_find_in_page_web_search_action() {
997 let lines = vec![
998 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"web_search_call","id":"ws_1","status":"in_progress"}}"#.to_string(),
999 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"find_in_page","url":"https://example.com","pattern":"needle"}}}"#.to_string(),
1000 r#"data: {"type":"response.done","response":{"id":"resp_1","status":"completed"}}"#.to_string(),
1001 ];
1002
1003 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1004 assert_eq!(acc.output.len(), 1);
1005 let action = match &acc.output[0] {
1006 OutputItem::WebSearchCall(call) => serde_json::to_value(&call.action).unwrap(),
1007 _ => panic!("expected web_search_call"),
1008 };
1009 assert_eq!(action["type"], "find_in_page");
1010 assert_eq!(action["url"], "https://example.com");
1011 assert_eq!(action["pattern"], "needle");
1012 }
1013
1014 #[test]
1015 fn test_accumulator_drops_unfinished_web_search_placeholder() {
1016 let lines = vec![
1017 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"web_search_call","id":"ws_1","status":"in_progress"}}"#.to_string(),
1018 r#"data: {"type":"response.done","response":{"id":"resp_1","status":"completed"}}"#.to_string(),
1019 ];
1020
1021 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1022 assert!(acc.output.is_empty());
1023 }
1024
1025 #[test]
1026 fn test_accumulator_empty_added_id_then_stable_done_does_not_duplicate() {
1027 let lines = vec![
1028 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"web_search_call","id":"","status":"in_progress"}}"#.to_string(),
1029 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"rust","sources":[]}}}"#.to_string(),
1030 r#"data: {"type":"response.done","response":{"id":"resp_1","status":"completed"}}"#.to_string(),
1031 ];
1032
1033 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1034 assert_eq!(acc.output.len(), 1);
1035 let OutputItem::WebSearchCall(call) = &acc.output[0] else {
1036 panic!("expected web_search_call");
1037 };
1038 assert_eq!(call.id, "ws_1");
1039 }
1040
1041 #[test]
1042 fn test_accumulator_stable_added_id_survives_empty_done_id() {
1043 let lines = vec![
1044 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"web_search_call","id":"ws_added","status":"in_progress"}}"#.to_string(),
1045 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"web_search_call","id":"","status":"completed","action":{"type":"search","query":"rust","sources":[]}}}"#.to_string(),
1046 r#"data: {"type":"response.done","response":{"id":"resp_1","status":"completed"}}"#.to_string(),
1047 ];
1048
1049 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1050 assert_eq!(acc.output.len(), 1);
1051 let OutputItem::WebSearchCall(call) = &acc.output[0] else {
1052 panic!("expected web_search_call");
1053 };
1054 assert_eq!(call.id, "ws_added");
1055 }
1056
1057 #[test]
1058 fn test_unknown_mcp_call_error_shape_is_not_dropped() {
1059 let lines = vec![
1060 r#"data: {"type":"response.created","response":{"id":"resp_abc"}}"#.to_string(),
1061 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"mcp_call","id":"mcp_1","server_label":"counter","name":"increment","arguments":"","status":"in_progress","approval_request_id":null,"output":null,"error":null}}"#.to_string(),
1062 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"mcp_call","id":"mcp_1","server_label":"counter","name":"increment","arguments":"{}","status":"failed","approval_request_id":null,"output":null,"error":{"type":"mcp_protocol_error","code":-32000,"message":"boom"}}}"#.to_string(),
1063 r#"data: {"type":"response.done","response":{"id":"resp_abc","status":"completed","usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}"#.to_string(),
1064 ];
1065
1066 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1067 assert_eq!(acc.output.len(), 1);
1068 let OutputItem::McpCall(call) = &acc.output[0] else {
1069 panic!("expected mcp_call");
1070 };
1071 let Some(McpCallError::Unknown(error)) = &call.error else {
1072 panic!("expected unknown MCP error payload");
1073 };
1074 assert_eq!(error["type"], "mcp_protocol_error");
1075 assert_eq!(error["code"], -32000);
1076 assert_eq!(error["message"], "boom");
1077 }
1078
1079 #[test]
1080 fn test_streaming_preserves_all_documented_mcp_call_statuses() {
1081 let lines = vec![
1082 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"mcp_call","id":"mcp_calling","server_label":"counter","name":"increment","arguments":"{}","status":"calling","approval_request_id":null,"output":null,"error":null}}"#.to_string(),
1083 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"mcp_call","id":"mcp_incomplete","server_label":"counter","name":"increment","arguments":"{}","status":"incomplete","approval_request_id":null,"output":null,"error":null}}"#.to_string(),
1084 r#"data: {"type":"response.output_item.done","output_index":2,"item":{"type":"mcp_call","id":"mcp_omitted","server_label":"counter","name":"increment","arguments":"{}","approval_request_id":null,"output":"1","error":null}}"#.to_string(),
1085 r#"data: {"type":"response.done","response":{"id":"resp_1","status":"completed","usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}"#.to_string(),
1086 ];
1087
1088 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1089 let statuses = acc
1090 .output
1091 .iter()
1092 .map(|item| match item {
1093 OutputItem::McpCall(call) => call.status,
1094 _ => panic!("expected mcp_call"),
1095 })
1096 .collect::<Vec<_>>();
1097
1098 assert_eq!(
1099 statuses,
1100 vec![Some(McpCallStatus::Calling), Some(McpCallStatus::Incomplete), None]
1101 );
1102 }
1103
1104 #[test]
1105 fn test_process_event_web_search_done_accumulates_output() {
1106 let lines = vec![
1107 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"web_search_call","id":"ws_1","status":"in_progress","action":{"type":"search","query":"rust","sources":[]}}}"#.to_string(),
1108 r#"data: {"type":"response.web_search_call.in_progress","item_id":"ws_1","output_index":0}"#.to_string(),
1109 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"rust","sources":[]}}}"#.to_string(),
1110 r#"data: {"type":"response.done","response":{"id":"resp_1","status":"completed","usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}"#.to_string(),
1111 ];
1112
1113 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1114 assert_eq!(acc.status, ResponseStatus::Completed);
1115 assert_eq!(acc.output.len(), 1);
1116 assert!(matches!(acc.output[0], OutputItem::WebSearchCall(_)));
1117 }
1118
1119 #[test]
1120 fn test_process_event_completed_with_usage() {
1121 let mut acc = ResponseAccumulator::new("resp_1".into(), None);
1122 let frame = EventFrame {
1123 event_type: SSEEventType::ResponseCompleted,
1124 payload: EventPayload::Response {
1125 id: "resp_1".into(),
1126 status: "completed".into(),
1127 usage: Some(ResponseUsage {
1128 input_tokens: 10,
1129 output_tokens: 5,
1130 total_tokens: 15,
1131 ..Default::default()
1132 }),
1133 },
1134 wire: WireEvent::new("test"),
1135 };
1136 acc.process_event(&frame);
1137 assert_eq!(acc.status, ResponseStatus::Completed);
1138 assert!(acc.usage.is_some());
1139 assert_eq!(acc.usage.unwrap().total_tokens, 15);
1140 }
1141
1142 #[test]
1143 fn test_process_event_failed_sets_error_status() {
1144 let mut acc = ResponseAccumulator::new("resp_1".into(), None);
1145 acc.process_event(&EventFrame {
1146 event_type: SSEEventType::ResponseFailed,
1147 payload: EventPayload::Response {
1148 id: "resp_1".into(),
1149 status: "failed".into(),
1150 usage: None,
1151 },
1152 wire: WireEvent::new("response.failed"),
1153 });
1154 assert_eq!(acc.status, ResponseStatus::Error);
1155 }
1156
1157 #[test]
1158 fn test_process_event_incomplete_sets_incomplete_status() {
1159 let mut acc = ResponseAccumulator::new("resp_1".into(), None);
1160 acc.process_event(&EventFrame {
1161 event_type: SSEEventType::ResponseIncomplete,
1162 payload: EventPayload::Response {
1163 id: "resp_1".into(),
1164 status: "incomplete".into(),
1165 usage: None,
1166 },
1167 wire: WireEvent::new("test"),
1168 });
1169 assert_eq!(acc.status, ResponseStatus::Incomplete);
1170 }
1171
1172 #[test]
1173 fn test_process_event_unknown_payload_ignored() {
1174 let mut acc = ResponseAccumulator::new("resp_1".into(), None);
1175 let frame = EventFrame {
1176 event_type: SSEEventType::ContentPartAdded,
1177 payload: EventPayload::Raw(serde_json::json!({"type": "response.content_part.added"})),
1178 wire: WireEvent::new("test"),
1179 };
1180 acc.process_event(&frame);
1181 assert_eq!(acc.response_id, "resp_1");
1182 assert_eq!(acc.status, ResponseStatus::InProgress);
1183 assert!(acc.output.is_empty());
1184 }
1185
1186 #[test]
1187 fn test_accumulator_reasoning_and_message_from_sse() {
1188 let lines = vec![
1189 r#"data: {"type":"response.created","response":{"id":"resp_abc"}}"#.to_string(),
1190 r#"data: {"type":"response.output_item.added","item":{"id":"rs_1","type":"reasoning","summary":[]}}"#.to_string(),
1191 r#"data: {"type":"response.reasoning_text.delta","delta":"Let me ","item_id":"rs_1"}"#.to_string(),
1192 r#"data: {"type":"response.reasoning_text.delta","delta":"think.","item_id":"rs_1"}"#.to_string(),
1193 r#"data: {"type":"response.reasoning_text.done","text":"Let me think.","item_id":"rs_1"}"#.to_string(),
1194 r#"data: {"type":"response.output_item.added","item":{"id":"msg_1","type":"message"}}"#.to_string(),
1195 r#"data: {"type":"response.output_text.delta","delta":"Hello","item_id":"msg_1"}"#.to_string(),
1196 r#"data: {"type":"response.done","response":{"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}"#.to_string(),
1197 ];
1198
1199 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1200 assert_eq!(acc.status, ResponseStatus::Completed);
1201 assert_eq!(acc.output.len(), 2);
1202
1203 if let OutputItem::Reasoning(r) = &acc.output[0] {
1204 assert_eq!(r.id, "rs_1");
1205 assert_eq!(r.content.len(), 1);
1206 assert_eq!(r.content[0].text, "Let me think.");
1207 } else {
1208 panic!("expected OutputItem::Reasoning, got {:?}", acc.output[0]);
1209 }
1210
1211 if let OutputItem::Message(msg) = &acc.output[1] {
1212 assert_eq!(msg.id, "msg_1");
1213 assert_eq!(msg.content[0].text, "Hello");
1214 } else {
1215 panic!("expected OutputItem::Message");
1216 }
1217 }
1218
1219 #[test]
1220 fn test_accumulator_message_then_reasoning_preserves_order() {
1221 let lines = vec![
1222 r#"data: {"type":"response.created","response":{"id":"resp_abc"}}"#.to_string(),
1223 r#"data: {"type":"response.output_item.added","item":{"id":"msg_1","type":"message"}}"#.to_string(),
1224 r#"data: {"type":"response.output_text.delta","delta":"Hello","item_id":"msg_1"}"#.to_string(),
1225 r#"data: {"type":"response.output_item.added","item":{"id":"rs_1","type":"reasoning","summary":[]}}"#.to_string(),
1226 r#"data: {"type":"response.reasoning_text.done","text":"thinking...","item_id":"rs_1"}"#.to_string(),
1227 r#"data: {"type":"response.done","response":{"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}"#.to_string(),
1228 ];
1229
1230 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1231 assert_eq!(acc.output.len(), 2);
1232 assert!(matches!(acc.output[0], OutputItem::Message(_)));
1233 assert!(matches!(acc.output[1], OutputItem::Reasoning(_)));
1234 }
1235
1236 #[test]
1237 fn test_accumulator_reasoning_done_without_delta_uses_text() {
1238 let lines = vec![
1239 r#"data: {"type":"response.output_item.added","item":{"id":"rs_1","type":"reasoning","summary":[]}}"#.to_string(),
1240 r#"data: {"type":"response.reasoning_text.done","text":"done only","item_id":"rs_1"}"#.to_string(),
1241 r#"data: {"type":"response.done","response":{"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}"#.to_string(),
1242 ];
1243
1244 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1245 if let OutputItem::Reasoning(reasoning) = &acc.output[0] {
1246 assert_eq!(reasoning.content.len(), 1);
1247 assert_eq!(reasoning.content[0].text, "done only");
1248 } else {
1249 panic!("expected reasoning output");
1250 }
1251 }
1252
1253 #[test]
1254 fn test_accumulator_reasoning_from_json() {
1255 let body = serde_json::json!({
1256 "id": "resp_xyz",
1257 "status": "completed",
1258 "output": [
1259 {
1260 "id": "rs_1",
1261 "type": "reasoning",
1262 "summary": [],
1263 "content": [{"text": "thinking...", "type": "reasoning_text"}],
1264 "encrypted_content": null,
1265 "status": null
1266 },
1267 {
1268 "id": "msg_1",
1269 "type": "message",
1270 "role": "assistant",
1271 "status": "completed",
1272 "content": [{"type": "output_text", "text": "answer", "annotations": []}]
1273 }
1274 ],
1275 "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
1276 });
1277
1278 let acc = ResponseAccumulator::from_json(&body.to_string(), None).unwrap();
1279 assert_eq!(acc.output.len(), 2);
1280 assert!(matches!(acc.output[0], OutputItem::Reasoning(_)));
1281 assert!(matches!(acc.output[1], OutputItem::Message(_)));
1282 }
1283
1284 #[test]
1285 fn test_blocking_preserves_all_documented_mcp_call_statuses() {
1286 let cases: [(Option<&str>, Option<McpCallStatus>); 3] = [
1287 (Some("calling"), Some(McpCallStatus::Calling)),
1288 (Some("incomplete"), Some(McpCallStatus::Incomplete)),
1289 (None, None),
1290 ];
1291
1292 for (status, expected) in cases {
1293 let mut item = serde_json::json!({
1294 "type": "mcp_call",
1295 "id": "mcp_1",
1296 "server_label": "counter",
1297 "name": "increment",
1298 "arguments": "{}",
1299 "approval_request_id": null,
1300 "output": null,
1301 "error": null
1302 });
1303 if let Some(status) = status {
1304 item["status"] = serde_json::json!(status);
1305 }
1306 let body = serde_json::json!({
1307 "id": "resp_1",
1308 "status": "completed",
1309 "output": [item],
1310 "usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7}
1311 });
1312
1313 let acc = ResponseAccumulator::from_json(&body.to_string(), None).unwrap();
1314 assert_eq!(acc.output.len(), 1);
1315 let OutputItem::McpCall(call) = &acc.output[0] else {
1316 panic!("expected mcp_call");
1317 };
1318 assert_eq!(call.status, expected);
1319 }
1320 }
1321
1322 #[test]
1323 fn test_function_call_accumulation_basic() {
1324 let mut acc = ResponseAccumulator::new("resp_1".into(), None);
1325
1326 acc.process_event(&EventFrame {
1327 event_type: SSEEventType::OutputItemAdded,
1328 payload: EventPayload::OutputItemAdded {
1329 item_id: "fc_1".into(),
1330 item_type: "function_call".into(),
1331 output_index: 0,
1332 name: Some("get_weather".into()),
1333 namespace: Some("mcp__weather".into()),
1334 call_id: Some("call_abc".into()),
1335 },
1336 wire: WireEvent::new("test"),
1337 });
1338
1339 acc.process_event(&EventFrame {
1340 event_type: SSEEventType::FunctionCallArgumentsDelta,
1341 payload: EventPayload::FunctionCallArgsDelta {
1342 delta: r#"{"location""#.into(),
1343 call_id: Some("call_abc".into()),
1344 item_id: "fc_1".into(),
1345 output_index: 0,
1346 },
1347 wire: WireEvent::new("test"),
1348 });
1349
1350 acc.process_event(&EventFrame {
1351 event_type: SSEEventType::FunctionCallArgumentsDelta,
1352 payload: EventPayload::FunctionCallArgsDelta {
1353 delta: r#":"Paris"}"#.into(),
1354 call_id: Some("call_abc".into()),
1355 item_id: "fc_1".into(),
1356 output_index: 0,
1357 },
1358 wire: WireEvent::new("test"),
1359 });
1360
1361 acc.process_event(&EventFrame {
1362 event_type: SSEEventType::FunctionCallArgumentsDone,
1363 payload: EventPayload::FunctionCallArgsDone {
1364 arguments: r#"{"location":"Paris"}"#.into(),
1365 call_id: Some("call_abc".into()),
1366 item_id: "fc_1".into(),
1367 name: "get_weather".into(),
1368 output_index: 0,
1369 },
1370 wire: WireEvent::new("test"),
1371 });
1372
1373 acc.process_event(&EventFrame {
1374 event_type: SSEEventType::ResponseCompleted,
1375 payload: EventPayload::Response {
1376 id: "resp_1".into(),
1377 status: "completed".into(),
1378 usage: None,
1379 },
1380 wire: WireEvent::new("test"),
1381 });
1382
1383 assert_eq!(acc.status, ResponseStatus::Completed);
1384 assert_eq!(acc.output.len(), 1);
1385 if let OutputItem::FunctionCall(fc) = &acc.output[0] {
1386 assert_eq!(fc.id, "fc_1");
1387 assert_eq!(fc.call_id, "call_abc");
1388 assert_eq!(fc.name, "get_weather");
1389 assert_eq!(fc.namespace.as_deref(), Some("mcp__weather"));
1390 assert_eq!(fc.arguments, r#"{"location":"Paris"}"#);
1391 assert_eq!(fc.status, MessageStatus::Completed);
1392 } else {
1393 panic!("expected FunctionCall");
1394 }
1395 }
1396
1397 #[test]
1398 fn test_function_call_done_uses_deltas_when_arguments_empty() {
1399 let mut acc = ResponseAccumulator::new("resp_1".into(), None);
1400
1401 acc.process_event(&EventFrame {
1402 event_type: SSEEventType::OutputItemAdded,
1403 payload: EventPayload::OutputItemAdded {
1404 item_id: "fc_1".into(),
1405 item_type: "function_call".into(),
1406 output_index: 0,
1407 name: Some("search".into()),
1408 namespace: None,
1409 call_id: Some("call_1".into()),
1410 },
1411 wire: WireEvent::new("test"),
1412 });
1413
1414 acc.process_event(&EventFrame {
1415 event_type: SSEEventType::FunctionCallArgumentsDelta,
1416 payload: EventPayload::FunctionCallArgsDelta {
1417 delta: r#"{"q":"rust"}"#.into(),
1418 call_id: Some("call_1".into()),
1419 item_id: "fc_1".into(),
1420 output_index: 0,
1421 },
1422 wire: WireEvent::new("test"),
1423 });
1424
1425 acc.process_event(&EventFrame {
1426 event_type: SSEEventType::FunctionCallArgumentsDone,
1427 payload: EventPayload::FunctionCallArgsDone {
1428 arguments: String::new(),
1429 call_id: Some("call_1".into()),
1430 item_id: "fc_1".into(),
1431 name: "search".into(),
1432 output_index: 0,
1433 },
1434 wire: WireEvent::new("test"),
1435 });
1436
1437 acc.finalize_all();
1438 assert_eq!(acc.output.len(), 1);
1439 if let OutputItem::FunctionCall(fc) = &acc.output[0] {
1440 assert_eq!(fc.arguments, r#"{"q":"rust"}"#);
1441 } else {
1442 panic!("expected FunctionCall");
1443 }
1444 }
1445
1446 #[test]
1447 fn test_function_call_multiple_parallel() {
1448 let mut acc = ResponseAccumulator::new("resp_1".into(), None);
1449
1450 acc.process_event(&EventFrame {
1451 event_type: SSEEventType::OutputItemAdded,
1452 payload: EventPayload::OutputItemAdded {
1453 item_id: "fc_1".into(),
1454 item_type: "function_call".into(),
1455 output_index: 0,
1456 name: Some("get_weather".into()),
1457 namespace: None,
1458 call_id: Some("call_1".into()),
1459 },
1460 wire: WireEvent::new("test"),
1461 });
1462 acc.process_event(&EventFrame {
1463 event_type: SSEEventType::FunctionCallArgumentsDone,
1464 payload: EventPayload::FunctionCallArgsDone {
1465 arguments: r#"{"city":"NYC"}"#.into(),
1466 call_id: Some("call_1".into()),
1467 item_id: "fc_1".into(),
1468 name: "get_weather".into(),
1469 output_index: 0,
1470 },
1471 wire: WireEvent::new("test"),
1472 });
1473
1474 acc.process_event(&EventFrame {
1475 event_type: SSEEventType::OutputItemAdded,
1476 payload: EventPayload::OutputItemAdded {
1477 item_id: "fc_2".into(),
1478 item_type: "function_call".into(),
1479 output_index: 1,
1480 name: Some("get_time".into()),
1481 namespace: None,
1482 call_id: Some("call_2".into()),
1483 },
1484 wire: WireEvent::new("test"),
1485 });
1486 acc.process_event(&EventFrame {
1487 event_type: SSEEventType::FunctionCallArgumentsDone,
1488 payload: EventPayload::FunctionCallArgsDone {
1489 arguments: r#"{"tz":"EST"}"#.into(),
1490 call_id: Some("call_2".into()),
1491 item_id: "fc_2".into(),
1492 name: "get_time".into(),
1493 output_index: 1,
1494 },
1495 wire: WireEvent::new("test"),
1496 });
1497
1498 acc.process_event(&EventFrame {
1499 event_type: SSEEventType::ResponseCompleted,
1500 payload: EventPayload::Response {
1501 id: "resp_1".into(),
1502 status: "completed".into(),
1503 usage: None,
1504 },
1505 wire: WireEvent::new("test"),
1506 });
1507
1508 assert_eq!(acc.output.len(), 2);
1509 assert!(matches!(&acc.output[0], OutputItem::FunctionCall(fc) if fc.name == "get_weather"));
1510 assert!(matches!(&acc.output[1], OutputItem::FunctionCall(fc) if fc.name == "get_time"));
1511 }
1512
1513 #[test]
1514 fn test_function_call_interleaved_with_message() {
1515 let mut acc = ResponseAccumulator::new("resp_1".into(), None);
1516
1517 acc.process_event(&EventFrame {
1518 event_type: SSEEventType::OutputItemAdded,
1519 payload: EventPayload::OutputItemAdded {
1520 item_id: "msg_1".into(),
1521 item_type: "message".into(),
1522 output_index: 0,
1523 name: None,
1524 namespace: None,
1525 call_id: None,
1526 },
1527 wire: WireEvent::new("test"),
1528 });
1529 acc.process_event(&EventFrame {
1530 event_type: SSEEventType::OutputTextDelta,
1531 payload: EventPayload::TextDelta {
1532 delta: "Let me check".into(),
1533 item_id: "msg_1".into(),
1534 output_index: 0,
1535 content_index: 0,
1536 },
1537 wire: WireEvent::new("test"),
1538 });
1539
1540 acc.process_event(&EventFrame {
1541 event_type: SSEEventType::OutputItemAdded,
1542 payload: EventPayload::OutputItemAdded {
1543 item_id: "fc_1".into(),
1544 item_type: "function_call".into(),
1545 output_index: 1,
1546 name: Some("lookup".into()),
1547 namespace: None,
1548 call_id: Some("call_x".into()),
1549 },
1550 wire: WireEvent::new("test"),
1551 });
1552 acc.process_event(&EventFrame {
1553 event_type: SSEEventType::FunctionCallArgumentsDone,
1554 payload: EventPayload::FunctionCallArgsDone {
1555 arguments: "{}".into(),
1556 call_id: Some("call_x".into()),
1557 item_id: "fc_1".into(),
1558 name: "lookup".into(),
1559 output_index: 1,
1560 },
1561 wire: WireEvent::new("test"),
1562 });
1563
1564 acc.process_event(&EventFrame {
1565 event_type: SSEEventType::ResponseCompleted,
1566 payload: EventPayload::Response {
1567 id: "resp_1".into(),
1568 status: "completed".into(),
1569 usage: None,
1570 },
1571 wire: WireEvent::new("test"),
1572 });
1573
1574 assert_eq!(acc.output.len(), 2);
1575 assert!(matches!(&acc.output[0], OutputItem::Message(m) if m.content[0].text == "Let me check"));
1576 assert!(matches!(&acc.output[1], OutputItem::FunctionCall(fc) if fc.name == "lookup"));
1577 }
1578
1579 #[test]
1580 fn test_function_call_done_updates_metadata() {
1581 let mut acc = ResponseAccumulator::new("resp_1".into(), None);
1582
1583 acc.process_event(&EventFrame {
1584 event_type: SSEEventType::OutputItemAdded,
1585 payload: EventPayload::OutputItemAdded {
1586 item_id: "fc_1".into(),
1587 item_type: "function_call".into(),
1588 output_index: 0,
1589 name: Some("old_name".into()),
1590 namespace: None,
1591 call_id: Some("old_call".into()),
1592 },
1593 wire: WireEvent::new("test"),
1594 });
1595
1596 acc.process_event(&EventFrame {
1597 event_type: SSEEventType::FunctionCallArgumentsDone,
1598 payload: EventPayload::FunctionCallArgsDone {
1599 arguments: "{}".into(),
1600 call_id: Some("new_call".into()),
1601 item_id: "fc_1".into(),
1602 name: "new_name".into(),
1603 output_index: 0,
1604 },
1605 wire: WireEvent::new("test"),
1606 });
1607
1608 acc.finalize_all();
1609 if let OutputItem::FunctionCall(fc) = &acc.output[0] {
1610 assert_eq!(fc.call_id, "new_call");
1611 assert_eq!(fc.name, "new_name");
1612 } else {
1613 panic!("expected FunctionCall");
1614 }
1615 }
1616
1617 #[test]
1618 fn test_output_item_done_restores_initially_unnamed_function_call() {
1619 let lines = vec![
1620 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"","name":"","arguments":"","status":"in_progress"}}"#.to_string(),
1621 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"item_id":"fc_1","delta":"{\"input\":\"hello\"}"}"#.to_string(),
1622 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"raw_echo","arguments":"","status":"completed"}}"#.to_string(),
1623 r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":null}}"#.to_string(),
1624 ];
1625
1626 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1627 assert_eq!(acc.output.len(), 1);
1628 let OutputItem::FunctionCall(call) = &acc.output[0] else {
1629 panic!("expected function_call");
1630 };
1631 assert_eq!(call.id, "fc_1");
1632 assert_eq!(call.call_id, "call_1");
1633 assert_eq!(call.name, "raw_echo");
1634 assert_eq!(call.arguments, r#"{"input":"hello"}"#);
1635 assert_eq!(call.status, MessageStatus::Completed);
1636 }
1637
1638 #[test]
1639 fn test_function_call_done_matches_empty_added_id_by_output_index() {
1640 let lines = vec![
1641 r#"data: {"type":"response.output_item.added","output_index":3,"item":{"type":"function_call","id":"","call_id":"","name":"","arguments":"","status":"in_progress"}}"#.to_string(),
1642 r#"data: {"type":"response.output_item.done","output_index":3,"item":{"type":"function_call","id":"fc_done","call_id":"call_done","name":"raw_echo","arguments":"{}","status":"completed"}}"#.to_string(),
1643 r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":null}}"#.to_string(),
1644 ];
1645
1646 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1647 assert_eq!(acc.output.len(), 1);
1648 let OutputItem::FunctionCall(call) = &acc.output[0] else {
1649 panic!("expected function_call");
1650 };
1651 assert_eq!(call.id, "fc_done");
1652 assert_eq!(call.call_id, "call_done");
1653 assert_eq!(call.name, "raw_echo");
1654 }
1655
1656 #[test]
1657 fn test_done_only_function_call_is_completed() {
1658 let lines = vec![
1659 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"Paris\"}","status":"completed"}}"#.to_string(),
1660 r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":null}}"#.to_string(),
1661 ];
1662
1663 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1664 assert_eq!(acc.output.len(), 1);
1665 let OutputItem::FunctionCall(call) = &acc.output[0] else {
1666 panic!("expected function_call");
1667 };
1668 assert_eq!(call.id, "fc_1");
1669 assert_eq!(call.call_id, "call_1");
1670 assert_eq!(call.name, "get_weather");
1671 assert_eq!(call.arguments, r#"{"city":"Paris"}"#);
1672 }
1673
1674 #[test]
1675 fn test_function_call_empty_item_id_generates_uuid() {
1676 let mut acc = ResponseAccumulator::new("resp_1".into(), None);
1677
1678 acc.process_event(&EventFrame {
1679 event_type: SSEEventType::OutputItemAdded,
1680 payload: EventPayload::OutputItemAdded {
1681 item_id: String::new(),
1682 item_type: "function_call".into(),
1683 output_index: 0,
1684 name: Some("tool".into()),
1685 namespace: None,
1686 call_id: Some("c1".into()),
1687 },
1688 wire: WireEvent::new("test"),
1689 });
1690
1691 acc.process_event(&EventFrame {
1692 event_type: SSEEventType::FunctionCallArgumentsDone,
1693 payload: EventPayload::FunctionCallArgsDone {
1694 arguments: "{}".into(),
1695 call_id: Some("c1".into()),
1696 item_id: String::new(),
1697 name: "tool".into(),
1698 output_index: 0,
1699 },
1700 wire: WireEvent::new("test"),
1701 });
1702
1703 acc.finalize_all();
1704 if let OutputItem::FunctionCall(fc) = &acc.output[0] {
1705 assert!(fc.id.starts_with("fc_"), "expected fc_ prefix, got: {}", fc.id);
1706 } else {
1707 panic!("expected FunctionCall");
1708 }
1709 }
1710
1711 #[test]
1713 fn test_function_call_orphaned_delta_safe() {
1714 let mut acc = ResponseAccumulator::new("resp_1".into(), None);
1715
1716 acc.process_event(&EventFrame {
1717 event_type: SSEEventType::FunctionCallArgumentsDelta,
1718 payload: EventPayload::FunctionCallArgsDelta {
1719 delta: "orphan".into(),
1720 call_id: None,
1721 item_id: String::new(),
1722 output_index: 0,
1723 },
1724 wire: WireEvent::new("test"),
1725 });
1726
1727 assert!(acc.output.is_empty());
1728 assert!(acc.in_flight.is_empty());
1729 }
1730
1731 #[test]
1732 fn test_function_call_finalized_on_response_completed() {
1733 let mut acc = ResponseAccumulator::new("resp_1".into(), None);
1734
1735 acc.process_event(&EventFrame {
1736 event_type: SSEEventType::OutputItemAdded,
1737 payload: EventPayload::OutputItemAdded {
1738 item_id: "fc_1".into(),
1739 item_type: "function_call".into(),
1740 output_index: 0,
1741 name: Some("partial".into()),
1742 namespace: None,
1743 call_id: Some("c1".into()),
1744 },
1745 wire: WireEvent::new("test"),
1746 });
1747 acc.process_event(&EventFrame {
1748 event_type: SSEEventType::FunctionCallArgumentsDelta,
1749 payload: EventPayload::FunctionCallArgsDelta {
1750 delta: r#"{"x":1}"#.into(),
1751 call_id: Some("c1".into()),
1752 item_id: "fc_1".into(),
1753 output_index: 0,
1754 },
1755 wire: WireEvent::new("test"),
1756 });
1757
1758 acc.process_event(&EventFrame {
1759 event_type: SSEEventType::ResponseCompleted,
1760 payload: EventPayload::Response {
1761 id: "resp_1".into(),
1762 status: "completed".into(),
1763 usage: None,
1764 },
1765 wire: WireEvent::new("test"),
1766 });
1767
1768 assert_eq!(acc.output.len(), 1);
1769 if let OutputItem::FunctionCall(fc) = &acc.output[0] {
1770 assert_eq!(fc.arguments, r#"{"x":1}"#);
1771 assert_eq!(fc.status, MessageStatus::Completed);
1772 } else {
1773 panic!("expected FunctionCall");
1774 }
1775 }
1776
1777 #[test]
1778 fn test_function_call_from_sse_lines() {
1779 let lines = vec![
1780 r#"data: {"type":"response.created","response":{"id":"resp_fc"}}"#.to_string(),
1781 r#"data: {"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","name":"get_weather","call_id":"call_abc"}}"#.to_string(),
1782 r#"data: {"type":"response.function_call_arguments.delta","delta":"{\"city\":","item_id":"fc_1"}"#.to_string(),
1783 r#"data: {"type":"response.function_call_arguments.delta","delta":"\"SF\"}}","item_id":"fc_1"}"#.to_string(),
1784 r#"data: {"type":"response.function_call_arguments.done","arguments":"{\"city\":\"SF\"}","call_id":"call_abc","name":"get_weather","item_id":"fc_1"}"#.to_string(),
1785 r#"data: {"type":"response.done","response":{"id":"resp_fc","usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}"#.to_string(),
1786 ];
1787
1788 let acc = ResponseAccumulator::from_sse_lines(lines, Some("conv_1"));
1789 assert_eq!(acc.status, ResponseStatus::Completed);
1790 assert_eq!(acc.output.len(), 1);
1791
1792 if let OutputItem::FunctionCall(fc) = &acc.output[0] {
1793 assert_eq!(fc.name, "get_weather");
1794 assert_eq!(fc.arguments, r#"{"city":"SF"}"#);
1795 assert_eq!(fc.call_id, "call_abc");
1796 } else {
1797 panic!("expected FunctionCall");
1798 }
1799
1800 assert_eq!(acc.usage.unwrap().total_tokens, 15);
1801 }
1802
1803 #[test]
1804 fn test_custom_tool_call_accumulates_freeform_input() {
1805 let lines = vec![
1806 r#"data: {"type":"response.created","response":{"id":"resp_custom"}}"#.to_string(),
1807 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"ctc_1","type":"custom_tool_call","call_id":"","name":"","input":"","status":"in_progress"}}"#.to_string(),
1808 r#"data: {"type":"response.custom_tool_call_input.delta","item_id":"ctc_1","output_index":0,"delta":"*** Begin"}"#.to_string(),
1809 r#"data: {"type":"response.custom_tool_call_input.delta","item_id":"ctc_1","output_index":0,"delta":" Patch"}"#.to_string(),
1810 r#"data: {"type":"response.custom_tool_call_input.done","item_id":"ctc_1","output_index":0,"input":""}"#.to_string(),
1811 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"id":"ctc_1","type":"custom_tool_call","call_id":"call_1","name":"apply_patch","input":"","status":"completed"}}"#.to_string(),
1812 r#"data: {"type":"response.completed","response":{"id":"resp_custom","status":"completed","usage":null}}"#.to_string(),
1813 ];
1814
1815 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1816 assert_eq!(acc.output.len(), 1);
1817 let OutputItem::CustomToolCall(call) = &acc.output[0] else {
1818 panic!("expected CustomToolCall");
1819 };
1820 assert_eq!(call.call_id, "call_1");
1821 assert_eq!(call.name, "apply_patch");
1822 assert_eq!(call.input, "*** Begin Patch");
1823 assert_eq!(call.status, Some(MessageStatus::Completed));
1824 }
1825
1826 #[test]
1827 fn test_reasoning_before_done_only_custom_tool_call_preserves_order() {
1828 let lines = vec![
1829 r#"data: {"type":"response.created","response":{"id":"resp_custom"}}"#.to_string(),
1830 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[]}}"#.to_string(),
1831 r#"data: {"type":"response.reasoning_text.done","text":"thinking...","item_id":"rs_1"}"#.to_string(),
1832 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"id":"ctc_1","type":"custom_tool_call","call_id":"call_1","name":"raw_echo","input":"hello","status":"completed"}}"#.to_string(),
1833 r#"data: {"type":"response.completed","response":{"id":"resp_custom","status":"completed","usage":null}}"#.to_string(),
1834 ];
1835
1836 let acc = ResponseAccumulator::from_sse_lines(lines, None);
1837 assert_eq!(acc.output.len(), 2);
1838 assert!(matches!(acc.output[0], OutputItem::Reasoning(_)));
1839 let OutputItem::CustomToolCall(call) = &acc.output[1] else {
1840 panic!("expected CustomToolCall");
1841 };
1842 assert_eq!(call.call_id, "call_1");
1843 assert_eq!(call.name, "raw_echo");
1844 assert_eq!(call.input, "hello");
1845 }
1846}