Skip to main content

oxi_agent/agent_loop/
streaming.rs

1/// Streaming implementation for agent loop.
2///
3/// pi-mono pattern: the provider accumulates content into a single `output`
4/// message. Each event carries a snapshot (`partial`) of this message.
5/// Done carries the complete accumulated message.
6///
7/// TTSR integration: when a [`TtsrEngine`](super::ttsr::TtsrEngine) is
8/// provided, every [`ProviderEvent::TextDelta`] is checked against
9/// registered rules. A match aborts the stream and returns
10/// [`StreamOutcome::RuleInterrupt`].
11use futures::StreamExt;
12use oxi_ai::{
13    ContentBlock, Context, Message, ProviderEvent, StopReason, StreamOptions, Tool as OxTool,
14};
15use std::collections::{HashMap, HashSet};
16
17use super::helpers::sanitize_orphaned_tool_results;
18use super::stream_outcome::StreamOutcome;
19use super::ttsr::{MatchSource, TtsrEngine, TtsrMatchContext};
20
21pub(crate) async fn stream_assistant_response(
22    loop_ref: &super::AgentLoop,
23    messages: &mut Vec<Message>,
24    emit: &super::EmitFn,
25    ttsr: Option<&TtsrEngine>,
26) -> StreamOutcome {
27    let model = match loop_ref.resolve_model() {
28        Ok(m) => m,
29        Err(_) => {
30            return StreamOutcome::Error {
31                message: oxi_ai::AssistantMessage::new(
32                    oxi_ai::Api::OpenAiCompletions,
33                    "agent",
34                    &loop_ref.config.model_id,
35                ),
36                detail: "Failed to resolve model".to_string(),
37            };
38        }
39    };
40
41    // Proactively sanitize orphaned tool results to prevent provider
42    // errors like "Messages with role 'tool' must be a response to a
43    // preceding message with 'tool_calls'".
44    let removed = sanitize_orphaned_tool_results(messages);
45    if removed > 0 {
46        tracing::warn!(
47            session_id = ?loop_ref.session_id,
48            removed,
49            "Sanitized orphaned tool results before streaming"
50        );
51    }
52
53    let mut context = Context::new();
54
55    // Build the tool definitions once — used both for native tool calling and
56    // for the in-band (owned dialect) prompt catalog.
57    let tool_defs = loop_ref.tools.definitions();
58    let mut oxi_tools: Vec<OxTool> = Vec::with_capacity(tool_defs.len());
59    for def in &tool_defs {
60        let schema = serde_json::to_value(&def.input_schema)
61            .unwrap_or_else(|_| serde_json::json!({"type": "object", "properties": {}}));
62        oxi_tools.push(OxTool::new(&def.name, &def.description, schema));
63    }
64
65    if let Some(dialect) = loop_ref.config.dialect {
66        // Owned (in-band) tool calling: the model has no native tool support, so
67        // the tool catalog rides in the system prompt, prior tool calls/results
68        // are re-encoded as text, and NO native `tools` are sent. The model's
69        // text output is parsed back into tool calls at `Done` (below).
70        let base_prompt = loop_ref.config.system_prompt.clone().unwrap_or_default();
71        let catalog = oxi_ai::dialect::render_inband_tool_prompt(&oxi_tools, dialect);
72        let full_prompt = if base_prompt.trim().is_empty() {
73            catalog
74        } else {
75            format!("{base_prompt}\n\n{catalog}")
76        };
77        context.set_system_prompt(full_prompt);
78
79        for msg in oxi_ai::dialect::encode_inband_tool_history(messages, dialect, &oxi_tools) {
80            context.add_message(msg);
81        }
82        // Deliberately no `context.set_tools(...)` — owned dialects send no
83        // native tools (any `tool_choice` would error on a tools-less request).
84    } else {
85        if let Some(ref system_prompt) = loop_ref.config.system_prompt {
86            context.set_system_prompt(system_prompt.clone());
87        }
88        for msg in messages.iter() {
89            context.add_message(msg.clone());
90        }
91        if !oxi_tools.is_empty() {
92            context.set_tools(oxi_tools);
93        }
94    }
95
96    let stream_options = StreamOptions {
97        temperature: Some(loop_ref.config.temperature as f64),
98        max_tokens: Some(loop_ref.config.max_tokens as usize),
99        provider_options: loop_ref.config.provider_options.clone(),
100        ..Default::default()
101    };
102
103    let stream = match super::retry::stream_with_retry(
104        loop_ref,
105        &model,
106        &context,
107        Some(stream_options),
108        emit,
109    )
110    .await
111    {
112        Ok(s) => s,
113        Err(e) => {
114            return StreamOutcome::Error {
115                message: oxi_ai::AssistantMessage::new(
116                    oxi_ai::Api::OpenAiCompletions,
117                    "agent",
118                    &loop_ref.config.model_id,
119                ),
120                detail: e.to_string(),
121            };
122        }
123    };
124
125    let mut added_partial = false;
126    let mut event_count = 0u32;
127    // content_index → resolved tool-call id, populated from `ToolCallStart`
128    // and reconciled at `ToolCallEnd`. Lets `ToolCallDelta` forward the
129    // correct id even though providers (Anthropic, OpenAI) keep the id in a
130    // private pending map and do not embed a `ContentBlock::ToolCall` in the
131    // streaming partial until the call finalizes.
132    let mut tool_call_ids: HashMap<usize, String> = HashMap::new();
133
134    // Reset the thinking-loop detector so each stream attempt is guarded
135    // independently. Without this, the detector's `fired` flag stays
136    // sticky after the first hit and retries run unguarded (issue
137    // flagged in advisory review). The retry layer may resample several
138    // times; each attempt deserves a fresh detector.
139    if let Some(detector) = loop_ref.thinking_loop_detector.lock().as_mut() {
140        detector.reset();
141    }
142    let mut rx = stream;
143    let stream_idle_timeout = std::time::Duration::from_secs(30);
144    let cancel_check_interval = std::time::Duration::from_millis(500);
145    let mut last_event_at = std::time::Instant::now();
146
147    loop {
148        let next_event = tokio::select! {
149            event = rx.next() => event,
150            _ = tokio::time::sleep(cancel_check_interval) => {
151                if loop_ref.is_cancelled() {
152                    tracing::info!(
153                        "Stream cancelled (detected in periodic check)"
154                    );
155                    if added_partial {
156                        let last_idx = messages.len() - 1;
157                        if let Message::Assistant(ref mut m) = messages[last_idx] {
158                            m.stop_reason = StopReason::Aborted;
159                        }
160                        let last_msg = messages.last().expect("non-empty").clone();
161                        emit(super::AgentEvent::MessageEnd {
162                            message: last_msg.clone(),
163                        });
164                        if let Message::Assistant(m) = &last_msg {
165                            return StreamOutcome::Cancelled(m.clone());
166                        }
167                    }
168                    return StreamOutcome::Cancelled(oxi_ai::AssistantMessage::new(
169                        oxi_ai::Api::OpenAiCompletions,
170                        "agent",
171                        &loop_ref.config.model_id,
172                    ));
173                }
174
175                if last_event_at.elapsed() >= stream_idle_timeout {
176                    tracing::warn!(
177                        "Stream idle timeout ({:?}) reached after {} events",
178                        stream_idle_timeout, event_count
179                    );
180                    let mut err_asst = oxi_ai::AssistantMessage::new(
181                        oxi_ai::Api::OpenAiCompletions,
182                        "agent",
183                        &loop_ref.config.model_id,
184                    );
185                    err_asst.stop_reason = StopReason::Error;
186                    err_asst.error_message = Some(format!(
187                        "Stream timed out after {:?} of inactivity",
188                        stream_idle_timeout
189                    ));
190                    if added_partial {
191                        let last_idx = messages.len() - 1;
192                        if let Message::Assistant(ref mut m) = messages[last_idx] {
193                            m.stop_reason = StopReason::Error;
194                        }
195                    }
196                    emit(super::AgentEvent::MessageEnd {
197                        message: Message::Assistant(err_asst.clone()),
198                    });
199                    emit(super::AgentEvent::Error {
200                        message: format!(
201                            "Stream timed out after {:?} of inactivity",
202                            stream_idle_timeout
203                        ),
204                        session_id: loop_ref.session_id.clone(),
205                    });
206                    return StreamOutcome::Error { message: err_asst, detail: format!("Stream timed out after {:?} of inactivity", stream_idle_timeout) };
207                }
208
209                continue;
210            }
211        };
212
213        let event = match next_event {
214            Some(e) => e,
215            None => break,
216        };
217
218        last_event_at = std::time::Instant::now();
219
220        if loop_ref.is_cancelled() {
221            tracing::info!("Stream cancelled after {} events", event_count);
222            if added_partial {
223                let last_idx = messages.len() - 1;
224                if let Message::Assistant(ref mut m) = messages[last_idx] {
225                    m.stop_reason = StopReason::Aborted;
226                }
227                let last_msg = messages.last().expect("non-empty").clone();
228                emit(super::AgentEvent::MessageEnd {
229                    message: last_msg.clone(),
230                });
231                if let Message::Assistant(m) = &last_msg {
232                    return StreamOutcome::Cancelled(m.clone());
233                }
234            }
235            return StreamOutcome::Cancelled(oxi_ai::AssistantMessage::new(
236                oxi_ai::Api::OpenAiCompletions,
237                "agent",
238                &loop_ref.config.model_id,
239            ));
240        }
241
242        event_count += 1;
243        match event {
244            ProviderEvent::Start { partial } => {
245                tracing::info!("Stream event #{}: Start", event_count);
246                messages.push(Message::Assistant((*partial).clone()));
247                added_partial = true;
248                emit(super::AgentEvent::MessageStart {
249                    message: messages.last().expect("non-empty after push").clone(),
250                });
251            }
252
253            ProviderEvent::TextDelta { delta, partial, .. } => {
254                if added_partial {
255                    let last_idx = messages.len() - 1;
256                    if let Message::Assistant(ref mut m) = messages[last_idx] {
257                        *m = (*partial).clone();
258                    }
259                }
260                let last_msg = messages.last().expect("non-empty").clone();
261                let delta_clone = delta.clone();
262                emit(super::AgentEvent::MessageUpdate {
263                    message: last_msg,
264                    delta: Some(delta),
265                });
266
267                // ── TTSR check ──
268                if let Some(engine) = ttsr {
269                    let ctx = TtsrMatchContext {
270                        source: MatchSource::Text,
271                        file_paths: vec![],
272                        tool_name: None,
273                    };
274                    let violations = engine.check_delta(&delta_clone, &ctx);
275                    if !violations.is_empty() {
276                        let mut partial_msg = messages
277                            .last()
278                            .and_then(|m| match m {
279                                Message::Assistant(a) => Some(a.clone()),
280                                _ => None,
281                            })
282                            .unwrap_or_else(|| {
283                                oxi_ai::AssistantMessage::new(
284                                    oxi_ai::Api::OpenAiCompletions,
285                                    "agent",
286                                    &loop_ref.config.model_id,
287                                )
288                            });
289                        partial_msg.stop_reason = StopReason::Aborted;
290                        return StreamOutcome::RuleInterrupt {
291                            partial: partial_msg,
292                            rule: violations.into_iter().next().expect("non-empty"),
293                        };
294                    }
295                }
296
297                // ── Harmony leak detection ──
298                if loop_ref.config.harmony_leak_detection && detect_harmony_leak(&delta_clone) {
299                    let preview = if delta_clone.len() > 80 {
300                        format!("{}...", &delta_clone[..80])
301                    } else {
302                        delta_clone.clone()
303                    };
304                    tracing::warn!(
305                        session_id = ?loop_ref.session_id,
306                        preview = %preview,
307                        "Harmony leak detected, aborting stream"
308                    );
309                    emit(super::AgentEvent::HarmonyLeakDetected {
310                        preview: preview.clone(),
311                        session_id: loop_ref.session_id.clone(),
312                    });
313                    let mut partial_msg = messages
314                        .last()
315                        .and_then(|m| match m {
316                            Message::Assistant(a) => Some(a.clone()),
317                            _ => None,
318                        })
319                        .unwrap_or_else(|| {
320                            oxi_ai::AssistantMessage::new(
321                                oxi_ai::Api::OpenAiCompletions,
322                                "agent",
323                                &loop_ref.config.model_id,
324                            )
325                        });
326                    partial_msg.stop_reason = StopReason::Aborted;
327                    return StreamOutcome::Error {
328                        message: partial_msg,
329                        detail: format!("Harmony leak detected: {}", preview),
330                    };
331                }
332            }
333
334            ProviderEvent::ThinkingStart { partial, .. } if added_partial => {
335                let last_idx = messages.len() - 1;
336                if let Message::Assistant(ref mut m) = messages[last_idx] {
337                    *m = (*partial).clone();
338                }
339                emit(super::AgentEvent::Thinking);
340            }
341            ProviderEvent::ThinkingDelta { delta, partial, .. } => {
342                // Feed the thinking-loop detector if enabled. On detection
343                // we surface an error event so the retry layer resamples;
344                // matches omp's "transient stream stall" classification.
345                if let Some(detector) = loop_ref.thinking_loop_detector.lock().as_mut()
346                    && let Some(reason) = detector.push(&delta)
347                {
348                    tracing::warn!(
349                        session_id = ?loop_ref.session_id,
350                        reason = %reason,
351                        "thinking-loop detected; aborting stream"
352                    );
353                    emit(super::AgentEvent::Error {
354                        message: reason,
355                        session_id: loop_ref.session_id.clone(),
356                    });
357                    // Break out of the stream loop — the upstream
358                    // retry policy treats transient errors as
359                    // resample candidates.
360                    break;
361                }
362                if added_partial {
363                    let last_idx = messages.len() - 1;
364                    if let Message::Assistant(ref mut m) = messages[last_idx] {
365                        *m = (*partial).clone();
366                    }
367                }
368                let last_msg = messages.last().expect("non-empty").clone();
369                emit(super::AgentEvent::ThinkingDelta {
370                    text: delta.clone(),
371                });
372                emit(super::AgentEvent::MessageUpdate {
373                    message: last_msg,
374                    delta: Some(delta),
375                });
376            }
377            ProviderEvent::ThinkingEnd { partial, .. } if added_partial => {
378                let last_idx = messages.len() - 1;
379                if let Message::Assistant(ref mut m) = messages[last_idx] {
380                    *m = (*partial).clone();
381                }
382                emit(super::AgentEvent::ThinkingEnd);
383            }
384
385            ProviderEvent::ToolCallStart {
386                content_index,
387                tool_call_id,
388                partial,
389                ..
390            } if added_partial => {
391                let last_idx = messages.len() - 1;
392                if let Message::Assistant(ref mut m) = messages[last_idx] {
393                    *m = (*partial).clone();
394                }
395                // Register the provider id so later ToolCallDelta events can
396                // forward it. OpenAI re-emits ToolCallStart on id-bearing
397                // deltas, so this also fills the map when the first start
398                // lacked an id.
399                if let Some(id) = tool_call_id
400                    && !id.is_empty()
401                {
402                    tool_call_ids.insert(content_index, id);
403                }
404            }
405
406            ProviderEvent::ToolCallDelta {
407                content_index,
408                delta,
409                partial,
410                ..
411            } if added_partial => {
412                let last_idx = messages.len() - 1;
413                if let Message::Assistant(ref mut m) = messages[last_idx] {
414                    *m = (*partial).clone();
415                }
416                // Forward the streamed argument fragment to downstream
417                // consumers (live tool-arg construction UIs, Oxios kernel).
418                // Resolve the id from the ToolCallStart registration; fall
419                // back to the finalized block in the accumulated partial for
420                // any provider that embeds it there. If neither resolves we
421                // skip this delta rather than emit an unverified id.
422                let resolved_id = tool_call_ids
423                    .get(&content_index)
424                    .cloned()
425                    .or_else(|| extract_tool_call_id(messages, content_index));
426                if let Some(id) = resolved_id {
427                    emit(super::AgentEvent::ToolCallDelta {
428                        tool_call_id: id,
429                        args_delta: delta,
430                    });
431                }
432            }
433
434            ProviderEvent::ToolCallEnd {
435                content_index,
436                tool_call,
437                ..
438            } if added_partial => {
439                // Reconcile the id map with the finalized call so any
440                // trailing deltas (and the map itself) stay authoritative.
441                tool_call_ids.insert(content_index, tool_call.id.clone());
442                let last_idx = messages.len() - 1;
443                if let Message::Assistant(ref mut m) = messages[last_idx] {
444                    m.content.push(ContentBlock::ToolCall(tool_call));
445                }
446                let last_msg = messages.last().expect("non-empty").clone();
447                emit(super::AgentEvent::MessageUpdate {
448                    message: last_msg,
449                    delta: None,
450                });
451            }
452
453            ProviderEvent::Done { message, .. } => {
454                let (input, output) = (message.usage.input, message.usage.output);
455                if input > 0 || output > 0 {
456                    // Snapshot the heuristic estimate of what was *just
457                    // sent* so we can compare it to the provider's
458                    // reported input_tokens on the same snapshot. This
459                    // is the drift metric referenced by issue #28:
460                    // `bytes/4` can undercount by 3-4× on token-dense
461                    // content (base64, JSON, CJK), and the legacy
462                    // compaction path used that heuristic directly.
463                    //
464                    // The slice we estimate over is the *prompt* the
465                    // provider tokenized, NOT the prompt + the
466                    // assistant turn we just streamed. At
467                    // `ProviderEvent::Done`, `messages` ends with the
468                    // just-completed assistant message (pushed on
469                    // `Start` at the start of the stream, or — in the
470                    // no-partial-Start path — on `Done` after this
471                    // branch; we record before that push). We slice
472                    // off the trailing assistant message so the
473                    // heuristic matches what `usage.input` actually
474                    // covers; otherwise the drift metric would
475                    // *understate* #28's bytes/4 underestimate.
476                    //
477                    // The compaction decision itself is unaffected —
478                    // it reads `Real(last_input_tokens)` =
479                    // `usage.input`, which is correct.
480                    let prompt_len = messages.len().saturating_sub(1);
481                    let estimate_at_report = estimate_tokens_from_messages(&messages[..prompt_len]);
482                    loop_ref.state.update(|s| {
483                        s.record_usage(input, output);
484                        s.record_provider_turn(input, estimate_at_report);
485                    });
486                    emit(super::AgentEvent::Usage {
487                        input_tokens: input,
488                        output_tokens: output,
489                    });
490                }
491
492                tracing::info!(
493                    "Stream event #{}: Done (stop_reason={:?})",
494                    event_count,
495                    message.stop_reason
496                );
497
498                if added_partial {
499                    let last_idx = messages.len() - 1;
500                    if let Message::Assistant(ref mut m) = messages[last_idx] {
501                        let mut seen_ids: HashSet<String> = message
502                            .content
503                            .iter()
504                            .filter_map(|b| match b {
505                                ContentBlock::ToolCall(tc) => Some(tc.id.clone()),
506                                _ => None,
507                            })
508                            .collect();
509
510                        let extra_tool_calls: Vec<ContentBlock> = m
511                            .content
512                            .iter()
513                            .filter(|b| match b {
514                                ContentBlock::ToolCall(tc) => seen_ids.insert(tc.id.clone()),
515                                _ => false,
516                            })
517                            .cloned()
518                            .collect();
519
520                        let tc_count = extra_tool_calls.len();
521                        *m = message.clone();
522                        m.content.extend(extra_tool_calls);
523
524                        tracing::info!(
525                            "Done: merged {} extra tool_calls, final has {} content blocks, stop_reason={:?}",
526                            tc_count,
527                            m.content.len(),
528                            m.stop_reason
529                        );
530                    }
531                } else {
532                    messages.push(Message::Assistant(message.clone()));
533                }
534                // Owned dialect: re-materialize in-band tool calls (emitted as
535                // text) into native `ToolCall` blocks so the rest of the loop
536                // executes them unchanged. Persist into `messages` so the next
537                // turn's history encoding sees canonical tool calls.
538                if let Some(dialect) = loop_ref.config.dialect {
539                    let last_idx = messages.len() - 1;
540                    if let Message::Assistant(ref mut m) = messages[last_idx] {
541                        let dialect_tools: Vec<OxTool> = tool_defs
542                            .iter()
543                            .map(|def| {
544                                let schema = serde_json::to_value(&def.input_schema)
545                                    .unwrap_or_else(
546                                        |_| serde_json::json!({"type": "object", "properties": {}}),
547                                    );
548                                OxTool::new(&def.name, &def.description, schema)
549                            })
550                            .collect();
551                        let parsed = dialect.parse_assistant_message(m, &dialect_tools);
552                        let found = parsed
553                            .content
554                            .iter()
555                            .filter(|b| b.as_tool_call().is_some())
556                            .count();
557                        if found > 0 {
558                            *m = parsed;
559                            // A clean stop with re-materialized calls must
560                            // continue the loop; a length/error stop is left as
561                            // is (the call may be truncated).
562                            if m.stop_reason == StopReason::Stop {
563                                m.stop_reason = StopReason::ToolUse;
564                            }
565                            tracing::info!(
566                                "Owned dialect: re-materialized {} in-band tool call(s)",
567                                found
568                            );
569                        }
570                    }
571                }
572
573                let last_msg = messages.last().expect("non-empty").clone();
574                emit(super::AgentEvent::MessageEnd {
575                    message: last_msg.clone(),
576                });
577                if let Message::Assistant(m) = &last_msg {
578                    return StreamOutcome::Complete(m.clone());
579                } else {
580                    return StreamOutcome::Complete(message);
581                }
582            }
583
584            ProviderEvent::Error { mut error, .. } => {
585                tracing::info!("Stream event #{}: Error", event_count);
586                let raw_msg = error.text_content();
587                let friendly = if raw_msg.is_empty() {
588                    "Unknown provider error".to_string()
589                } else {
590                    raw_msg
591                };
592                tracing::error!(
593                    session_id = ?loop_ref.session_id,
594                    "Provider stream error: {}", friendly
595                );
596
597                error.stop_reason = StopReason::Error;
598
599                if added_partial {
600                    let last_idx = messages.len() - 1;
601                    if let Message::Assistant(ref mut m) = messages[last_idx] {
602                        *m = error.clone();
603                    }
604                } else {
605                    messages.push(Message::Assistant(error.clone()));
606                }
607
608                emit(super::AgentEvent::MessageEnd {
609                    message: Message::Assistant(error.clone()),
610                });
611                emit(super::AgentEvent::Error {
612                    message: format!("⚠ {}", friendly),
613                    session_id: loop_ref.session_id.clone(),
614                });
615
616                return StreamOutcome::Error {
617                    message: error,
618                    detail: format!("⚠ {}", friendly),
619                };
620            }
621
622            _ => {}
623        }
624    }
625
626    tracing::info!("Stream ended after {} events", event_count);
627
628    let final_message = match messages.last().and_then(|m| match m {
629        Message::Assistant(a) => Some(a.clone()),
630        _ => None,
631    }) {
632        Some(m) => m,
633        None => {
634            return StreamOutcome::Error {
635                message: oxi_ai::AssistantMessage::new(
636                    oxi_ai::Api::OpenAiCompletions,
637                    "agent",
638                    &loop_ref.config.model_id,
639                ),
640                detail: "No final assistant message in stream".to_string(),
641            };
642        }
643    };
644
645    if !added_partial {
646        tracing::warn!("Stream ended without Start event, emitting synthetic MessageStart");
647        emit(super::AgentEvent::MessageStart {
648            message: Message::Assistant(final_message.clone()),
649        });
650    }
651
652    emit(super::AgentEvent::MessageEnd {
653        message: Message::Assistant(final_message.clone()),
654    });
655    StreamOutcome::Complete(final_message)
656}
657
658/// Heuristic token estimate for a messages slice, mirroring
659/// `AgentState::estimate_tokens` (serialized JSON length / 4).
660///
661/// Used in [`stream_assistant_response`] at the moment the provider
662/// reports `usage.input_tokens` to record the divergence between
663/// the legacy heuristic and the ground-truth provider count (see
664/// issue #28 gap 2). The result is cached on
665/// `AgentState::last_estimate_at_report` / `last_estimate_divergence`
666/// so the operator can see how badly `bytes/4` is undercounting on
667/// token-dense content.
668///
669/// Kept local (not a method on `AgentState`) so we can call it with
670/// a borrowed slice of the loop's working `messages` buffer without
671/// cloning the whole history.
672fn estimate_tokens_from_messages(messages: &[Message]) -> usize {
673    let json = serde_json::to_string(messages).unwrap_or_default();
674    json.len() / 4
675}
676
677/// Best-effort extraction of a tool-call id from the accumulated assistant
678/// message's content block at `content_index`.
679///
680/// This is a fallback for the `tool_call_ids` map used in
681/// [`stream_assistant_response`]: most providers (Anthropic, OpenAI) surface
682/// the id at `ToolCallStart` and keep it in a private pending map, so the
683/// `ToolCall` block is *not* present in the streaming partial during deltas.
684/// The lookup only resolves for providers that embed the block early, but it
685/// costs nothing and is forward-compatible.
686fn extract_tool_call_id(messages: &[Message], content_index: usize) -> Option<String> {
687    let last = messages.last()?;
688    let Message::Assistant(m) = last else {
689        return None;
690    };
691    m.content.get(content_index).and_then(|b| match b {
692        ContentBlock::ToolCall(tc) => Some(tc.id.clone()),
693        _ => None,
694    })
695}
696
697/// Detect GPT-5 Harmony protocol leakage in model output.
698///
699/// Checks for known Harmony marker patterns:
700/// - `to=functions.<name>` — function routing directive
701/// - `<|start|>`, `<|end|>`, `<|channel|>`, etc. — Harmony block markers
702///
703/// Returns `true` if any marker is found.
704fn detect_harmony_leak(text: &str) -> bool {
705    use std::sync::LazyLock;
706
707    // Harmony function routing marker: `to=functions.xxx`
708    static MARKER_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
709        regex::Regex::new(r"\bto=functions\.[A-Za-z_]\w*\b").expect("valid harmony marker regex")
710    });
711    if MARKER_RE.is_match(text) {
712        return true;
713    }
714
715    // Harmony block markers: `<|start|>`, `<|end|>`, `<|channel|>`, etc.
716    static BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
717        regex::Regex::new(r"<\|\s*(?:start|end|channel|message|call|return)\s*\|>")
718            .expect("valid harmony block regex")
719    });
720    if BLOCK_RE.is_match(text) {
721        return true;
722    }
723
724    false
725}
726
727#[cfg(test)]
728mod streaming_lifecycle_tests {
729    //! Verifies `stream_assistant_response` forwards provider streaming
730    //! lifecycle events as `AgentEvent`s:
731    //! - `ProviderEvent::ThinkingEnd` → `AgentEvent::ThinkingEnd`
732    //! - `ProviderEvent::ToolCallDelta` → `AgentEvent::ToolCallDelta { tool_call_id, args_delta }`
733    //!
734    //! The `tool_call_id` is resolved from a content_index→id map populated
735    //! at `ToolCallStart`/`ToolCallEnd`, because providers keep the id in a
736    //! private pending map and never embed a `ContentBlock::ToolCall` in the
737    //! streaming partial until the call finalizes.
738    use super::stream_assistant_response;
739    use crate::ProviderResolver;
740    use crate::config::ToolExecutionMode;
741    use crate::events::AgentEvent;
742    use crate::state::SharedState;
743    use crate::tools::ToolRegistry;
744    use crate::{AgentLoop, AgentLoopConfig};
745    use futures::Stream;
746    use oxi_ai::{
747        Api, AssistantMessage, CompactionStrategy, ContentBlock, Context, Message, Model, Provider,
748        ProviderEvent, StopReason, StreamOptions, StreamResult, ToolCall, UserMessage,
749    };
750    use std::collections::VecDeque;
751    use std::future::Future;
752    use std::pin::Pin;
753    use std::sync::{Arc, Mutex};
754    use std::task::{Context as TaskContext, Poll};
755
756    /// Provider that replays a fixed script of `ProviderEvent`s.
757    struct ScriptedProvider {
758        events: Arc<Vec<ProviderEvent>>,
759    }
760
761    impl ScriptedProvider {
762        fn new(events: Vec<ProviderEvent>) -> Self {
763            Self {
764                events: Arc::new(events),
765            }
766        }
767    }
768
769    impl Provider for ScriptedProvider {
770        fn stream<'a>(
771            &'a self,
772            _model: &'a Model,
773            _context: &'a Context,
774            _options: Option<StreamOptions>,
775        ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
776            let events = Arc::clone(&self.events);
777            Box::pin(async move {
778                Ok(Box::pin(ScriptedStream {
779                    events: VecDeque::from((*events).clone()),
780                })
781                    as Pin<Box<dyn Stream<Item = ProviderEvent> + Send>>)
782            })
783        }
784    }
785
786    struct ScriptedStream {
787        events: VecDeque<ProviderEvent>,
788    }
789
790    impl Stream for ScriptedStream {
791        type Item = ProviderEvent;
792        fn poll_next(
793            mut self: Pin<&mut Self>,
794            _cx: &mut TaskContext<'_>,
795        ) -> Poll<Option<Self::Item>> {
796            Poll::Ready(self.events.pop_front())
797        }
798    }
799
800    struct DummyResolver;
801    impl ProviderResolver for DummyResolver {
802        fn resolve_provider(&self, _name: &str) -> Option<Arc<dyn Provider>> {
803            None
804        }
805        fn resolve_model(&self, _model_id: &str) -> Option<Model> {
806            Some(Model::new(
807                "test/model",
808                "Test",
809                Api::AnthropicMessages,
810                "mock",
811                "https://mock.test",
812            ))
813        }
814    }
815
816    fn empty_partial() -> Arc<AssistantMessage> {
817        Arc::new(AssistantMessage::new(
818            Api::AnthropicMessages,
819            "mock",
820            "test/model",
821        ))
822    }
823
824    fn make_loop(provider: Arc<dyn Provider>) -> AgentLoop {
825        let config = AgentLoopConfig {
826            model_id: "test/model".to_string(),
827            system_prompt: None,
828            temperature: 1.0,
829            max_tokens: 4096,
830            tool_execution: ToolExecutionMode::Sequential,
831            compaction_strategy: CompactionStrategy::Disabled,
832            context_window: 128_000,
833            compact_on_start: false,
834            auto_retry_enabled: false,
835            auto_retry_max_attempts: 1,
836            thinking_loop_detection: false,
837            ..Default::default()
838        };
839        AgentLoop::new_with_resolver(
840            provider,
841            config,
842            Arc::new(ToolRegistry::new()),
843            SharedState::new(),
844            Arc::new(DummyResolver),
845        )
846    }
847
848    /// Runs a scripted provider through `stream_assistant_response` and
849    /// returns every emitted `AgentEvent` in order.
850    async fn run_script(events: Vec<ProviderEvent>) -> Vec<AgentEvent> {
851        let provider: Arc<dyn Provider> = Arc::new(ScriptedProvider::new(events));
852        let agent_loop = make_loop(provider);
853        let collected: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
854        let sink = Arc::clone(&collected);
855        let emit: Arc<dyn Fn(AgentEvent) + Send + Sync> =
856            Arc::new(move |e| sink.lock().unwrap().push(e));
857        let mut messages: Vec<Message> = vec![Message::User(UserMessage::new("hi".to_string()))];
858        let _ = stream_assistant_response(&agent_loop, &mut messages, &emit, None).await;
859        collected.lock().unwrap().clone()
860    }
861
862    /// Anthropic-style: the tool-call id is known up front at `ToolCallStart`.
863    #[tokio::test]
864    async fn thinking_end_and_tool_call_delta_forwarded() {
865        let finalized = ToolCall::new("tc_abc", "bash", serde_json::json!({"command":"ls"}));
866        let mut done_msg = AssistantMessage::new(Api::AnthropicMessages, "mock", "test/model");
867        done_msg
868            .content
869            .push(ContentBlock::ToolCall(finalized.clone()));
870        let events = vec![
871            ProviderEvent::Start {
872                partial: empty_partial(),
873            },
874            ProviderEvent::ThinkingStart {
875                content_index: 0,
876                partial: empty_partial(),
877            },
878            ProviderEvent::ThinkingDelta {
879                content_index: 0,
880                delta: "reasoning...".to_string(),
881                partial: empty_partial(),
882            },
883            ProviderEvent::ThinkingEnd {
884                content_index: 0,
885                content: "reasoning...".to_string(),
886                partial: empty_partial(),
887            },
888            ProviderEvent::ToolCallStart {
889                content_index: 1,
890                tool_call_id: Some("tc_abc".to_string()),
891                tool_name: Some("bash".to_string()),
892                partial: empty_partial(),
893            },
894            ProviderEvent::ToolCallDelta {
895                content_index: 1,
896                delta: "{\"command\":".to_string(),
897                partial: empty_partial(),
898            },
899            ProviderEvent::ToolCallDelta {
900                content_index: 1,
901                delta: "\"ls\"}".to_string(),
902                partial: empty_partial(),
903            },
904            ProviderEvent::ToolCallEnd {
905                content_index: 1,
906                tool_call: finalized,
907                partial: empty_partial(),
908            },
909            ProviderEvent::Done {
910                reason: StopReason::Stop,
911                message: done_msg,
912            },
913        ];
914
915        let emitted = run_script(events).await;
916
917        let thinking_end_at = emitted
918            .iter()
919            .position(|e| matches!(e, AgentEvent::ThinkingEnd));
920        assert!(
921            thinking_end_at.is_some(),
922            "AgentEvent::ThinkingEnd must be emitted"
923        );
924
925        let deltas: Vec<(&str, &str)> = emitted
926            .iter()
927            .filter_map(|e| match e {
928                AgentEvent::ToolCallDelta {
929                    tool_call_id,
930                    args_delta,
931                } => Some((tool_call_id.as_str(), args_delta.as_str())),
932                _ => None,
933            })
934            .collect();
935        assert_eq!(deltas.len(), 2, "expected exactly two ToolCallDelta events");
936        assert_eq!(deltas[0], ("tc_abc", "{\"command\":"));
937        assert_eq!(deltas[1], ("tc_abc", "\"ls\"}"));
938
939        let first_delta_at = emitted
940            .iter()
941            .position(|e| matches!(e, AgentEvent::ToolCallDelta { .. }))
942            .expect("at least one ToolCallDelta");
943        assert!(
944            thinking_end_at.unwrap() < first_delta_at,
945            "ThinkingEnd must precede ToolCallDelta"
946        );
947    }
948
949    /// OpenAI-style: the first `ToolCallStart` carries no id (name only),
950    /// then a second id-bearing delta re-emits `ToolCallStart`. The map must
951    /// pick up the id so later `ToolCallDelta`s resolve.
952    #[tokio::test]
953    async fn tool_call_delta_resolves_late_id() {
954        let finalized = ToolCall::new("tc_late", "grep", serde_json::json!({"pattern":"foo"}));
955        let mut done_msg = AssistantMessage::new(Api::AnthropicMessages, "mock", "test/model");
956        done_msg
957            .content
958            .push(ContentBlock::ToolCall(finalized.clone()));
959        let events = vec![
960            ProviderEvent::Start {
961                partial: empty_partial(),
962            },
963            ProviderEvent::ToolCallStart {
964                content_index: 0,
965                tool_call_id: None,
966                tool_name: Some("grep".to_string()),
967                partial: empty_partial(),
968            },
969            ProviderEvent::ToolCallStart {
970                content_index: 0,
971                tool_call_id: Some("tc_late".to_string()),
972                tool_name: Some("grep".to_string()),
973                partial: empty_partial(),
974            },
975            ProviderEvent::ToolCallDelta {
976                content_index: 0,
977                delta: "{\"pattern\":".to_string(),
978                partial: empty_partial(),
979            },
980            ProviderEvent::ToolCallEnd {
981                content_index: 0,
982                tool_call: finalized,
983                partial: empty_partial(),
984            },
985            ProviderEvent::Done {
986                reason: StopReason::Stop,
987                message: done_msg,
988            },
989        ];
990
991        let emitted = run_script(events).await;
992
993        let ids: Vec<String> = emitted
994            .iter()
995            .filter_map(|e| match e {
996                AgentEvent::ToolCallDelta { tool_call_id, .. } => Some(tool_call_id.clone()),
997                _ => None,
998            })
999            .collect();
1000        assert_eq!(
1001            ids,
1002            vec!["tc_late".to_string()],
1003            "ToolCallDelta must resolve the id from the second ToolCallStart"
1004        );
1005    }
1006}