Skip to main content

agentic_core/executor/
accumulator.rs

1//! Response accumulation and parsing utilities.
2//!
3//! Handles both streaming (SSE) and non-streaming JSON response formats,
4//! accumulating chunks into a unified `ResponsePayload` structure.
5//!
6//! Streaming path uses a channel + `spawn_blocking` so that SSE JSON parsing
7//! runs on a blocking thread while the async task continues reading from the
8//! network — keeping the tokio executor thread free between chunk arrivals.
9
10use 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
31/// Tracks a single output item currently being streamed, together with its
32/// accumulated text/arguments buffer.
33enum 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/// Accumulates LLM response chunks from streaming or non-streaming sources.
121#[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 output items keyed by `item_id`, in insertion order.
131    in_flight: IndexMap<String, InFlightEntry>,
132    /// Completed streaming items waiting to be emitted in `output_index` order.
133    completed: Vec<(u32, OutputItem)>,
134}
135
136impl ResponseAccumulator {
137    /// Creates a new response accumulator.
138    #[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    /// Parses a non-streaming JSON response body.
154    ///
155    /// # Errors
156    /// Returns `ExecutorError::ParseError` if JSON parsing fails or required fields are missing.
157    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    /// Accumulates an async stream of raw SSE lines with parallel processing.
195    ///
196    /// The async task feeds raw SSE lines through a channel while a `spawn_blocking`
197    /// worker handles JSON parsing on a blocking thread — keeping the tokio executor
198    /// free between chunk arrivals.
199    ///
200    /// # Errors
201    /// Returns `ExecutorError::ParseError` if chunk parsing fails, or
202    /// `ExecutorError::StreamError` if the stream or worker encounters an error.
203    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        // Convert to owned here — spawn_blocking closure must be 'static.
209        let conv_id_owned = conversation_id.map(str::to_string);
210
211        // Spawn blocking task: JSON parsing is CPU-bound, runs off the async executor.
212        let worker_handle = tokio::task::spawn_blocking(move || Self::process_stream_chunks(rx, conv_id_owned));
213
214        // Feed raw SSE lines from the async stream to the blocking worker.
215        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        // Signal EOF to worker.
227        drop(tx);
228
229        // Properly async join — does not block the tokio executor thread.
230        worker_handle
231            .await
232            .map_err(|_| ExecutorError::StreamError("Worker thread panicked".into()))
233    }
234
235    /// Worker function that processes SSE lines from the channel (runs on blocking thread).
236    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    /// Processes pre-collected raw SSE lines synchronously.
246    ///
247    /// Useful when lines have already been buffered (e.g. replaying a recorded stream).
248    /// Prefer [`from_stream`](Self::from_stream) for live async streams.
249    /// Line parse errors are silently skipped — this function is infallible.
250    #[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    /// Finalizes all streaming items in upstream `output_index` order.
261    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    /// Processes a typed [`EventFrame`], updating accumulator state.
355    ///
356    /// This is the core state machine — callers that already have a normalized
357    /// frame (e.g. [`StreamTee`](future)) can call this directly without
358    /// re-parsing from a raw line.
359    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    /// Marks the response as incomplete due to an error or interruption.
590    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    /// Finalizes the accumulator into a `ResponsePayload`.
598    ///
599    /// The caller supplies fields that come from the original request, not from
600    /// the LLM response stream.
601    #[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    /// Orphaned delta (no active function call for this `item_id`) is silently dropped.
1712    #[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}