Skip to main content

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