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