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