1use 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 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 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 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 } 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 let mut tool_call_ids: HashMap<usize, String> = HashMap::new();
135
136 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 #[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 #[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 #[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 #[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 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 #[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 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 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;
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 #[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 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 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 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 #[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 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 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 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 #[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
685fn estimate_tokens_from_messages(messages: &[Message]) -> usize {
700 let json = serde_json::to_string(messages).unwrap_or_default();
701 json.len() / 4
702}
703
704fn 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
724fn detect_harmony_leak(text: &str) -> bool {
732 use std::sync::LazyLock;
733
734 static MARKER_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
736 #[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 static BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
748 #[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 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 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 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 #[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 #[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}