1use futures::StreamExt;
12use oxi_ai::{
13 ContentBlock, Context, Message, ProviderEvent, StopReason, StreamOptions, Tool as OxTool,
14};
15use std::collections::{HashMap, HashSet};
16
17use super::helpers::sanitize_orphaned_tool_results;
18use super::stream_outcome::StreamOutcome;
19use super::ttsr::{MatchSource, TtsrEngine, TtsrMatchContext};
20
21pub(crate) async fn stream_assistant_response(
22 loop_ref: &super::AgentLoop,
23 messages: &mut Vec<Message>,
24 emit: &super::EmitFn,
25 ttsr: Option<&TtsrEngine>,
26) -> StreamOutcome {
27 let model = match loop_ref.resolve_model() {
28 Ok(m) => m,
29 Err(_) => {
30 return StreamOutcome::Error {
31 message: oxi_ai::AssistantMessage::new(
32 oxi_ai::Api::OpenAiCompletions,
33 "agent",
34 &loop_ref.config.model_id,
35 ),
36 detail: "Failed to resolve model".to_string(),
37 };
38 }
39 };
40
41 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 oxi_tools: Vec<OxTool> = Vec::with_capacity(tool_defs.len());
59 for def in &tool_defs {
60 let schema = serde_json::to_value(&def.input_schema)
61 .unwrap_or_else(|_| serde_json::json!({"type": "object", "properties": {}}));
62 oxi_tools.push(OxTool::new(&def.name, &def.description, schema));
63 }
64
65 if let Some(dialect) = loop_ref.config.dialect {
66 let base_prompt = loop_ref.config.system_prompt.clone().unwrap_or_default();
71 let catalog = oxi_ai::dialect::render_inband_tool_prompt(&oxi_tools, dialect);
72 let full_prompt = if base_prompt.trim().is_empty() {
73 catalog
74 } else {
75 format!("{base_prompt}\n\n{catalog}")
76 };
77 context.set_system_prompt(full_prompt);
78
79 for msg in oxi_ai::dialect::encode_inband_tool_history(messages, dialect, &oxi_tools) {
80 context.add_message(msg);
81 }
82 } else {
85 if let Some(ref system_prompt) = loop_ref.config.system_prompt {
86 context.set_system_prompt(system_prompt.clone());
87 }
88 for msg in messages.iter() {
89 context.add_message(msg.clone());
90 }
91 if !oxi_tools.is_empty() {
92 context.set_tools(oxi_tools);
93 }
94 }
95
96 let stream_options = StreamOptions {
97 temperature: Some(loop_ref.config.temperature as f64),
98 max_tokens: Some(loop_ref.config.max_tokens as usize),
99 provider_options: loop_ref.config.provider_options.clone(),
100 ..Default::default()
101 };
102
103 let stream = match super::retry::stream_with_retry(
104 loop_ref,
105 &model,
106 &context,
107 Some(stream_options),
108 emit,
109 )
110 .await
111 {
112 Ok(s) => s,
113 Err(e) => {
114 return StreamOutcome::Error {
115 message: oxi_ai::AssistantMessage::new(
116 oxi_ai::Api::OpenAiCompletions,
117 "agent",
118 &loop_ref.config.model_id,
119 ),
120 detail: e.to_string(),
121 };
122 }
123 };
124
125 let mut added_partial = false;
126 let mut event_count = 0u32;
127 let mut tool_call_ids: HashMap<usize, String> = HashMap::new();
133
134 if let Some(detector) = loop_ref.thinking_loop_detector.lock().as_mut() {
140 detector.reset();
141 }
142 let mut rx = stream;
143 let stream_idle_timeout = std::time::Duration::from_secs(30);
144 let cancel_check_interval = std::time::Duration::from_millis(500);
145 let mut last_event_at = std::time::Instant::now();
146
147 loop {
148 let next_event = tokio::select! {
149 event = rx.next() => event,
150 _ = tokio::time::sleep(cancel_check_interval) => {
151 if loop_ref.is_cancelled() {
152 tracing::info!(
153 "Stream cancelled (detected in periodic check)"
154 );
155 if added_partial {
156 let last_idx = messages.len() - 1;
157 if let Message::Assistant(ref mut m) = messages[last_idx] {
158 m.stop_reason = StopReason::Aborted;
159 }
160 let last_msg = messages.last().expect("non-empty").clone();
161 emit(super::AgentEvent::MessageEnd {
162 message: last_msg.clone(),
163 });
164 if let Message::Assistant(m) = &last_msg {
165 return StreamOutcome::Cancelled(m.clone());
166 }
167 }
168 return StreamOutcome::Cancelled(oxi_ai::AssistantMessage::new(
169 oxi_ai::Api::OpenAiCompletions,
170 "agent",
171 &loop_ref.config.model_id,
172 ));
173 }
174
175 if last_event_at.elapsed() >= stream_idle_timeout {
176 tracing::warn!(
177 "Stream idle timeout ({:?}) reached after {} events",
178 stream_idle_timeout, event_count
179 );
180 let mut err_asst = oxi_ai::AssistantMessage::new(
181 oxi_ai::Api::OpenAiCompletions,
182 "agent",
183 &loop_ref.config.model_id,
184 );
185 err_asst.stop_reason = StopReason::Error;
186 err_asst.error_message = Some(format!(
187 "Stream timed out after {:?} of inactivity",
188 stream_idle_timeout
189 ));
190 if added_partial {
191 let last_idx = messages.len() - 1;
192 if let Message::Assistant(ref mut m) = messages[last_idx] {
193 m.stop_reason = StopReason::Error;
194 }
195 }
196 emit(super::AgentEvent::MessageEnd {
197 message: Message::Assistant(err_asst.clone()),
198 });
199 emit(super::AgentEvent::Error {
200 message: format!(
201 "Stream timed out after {:?} of inactivity",
202 stream_idle_timeout
203 ),
204 session_id: loop_ref.session_id.clone(),
205 });
206 return StreamOutcome::Error { message: err_asst, detail: format!("Stream timed out after {:?} of inactivity", stream_idle_timeout) };
207 }
208
209 continue;
210 }
211 };
212
213 let event = match next_event {
214 Some(e) => e,
215 None => break,
216 };
217
218 last_event_at = std::time::Instant::now();
219
220 if loop_ref.is_cancelled() {
221 tracing::info!("Stream cancelled after {} events", event_count);
222 if added_partial {
223 let last_idx = messages.len() - 1;
224 if let Message::Assistant(ref mut m) = messages[last_idx] {
225 m.stop_reason = StopReason::Aborted;
226 }
227 let last_msg = messages.last().expect("non-empty").clone();
228 emit(super::AgentEvent::MessageEnd {
229 message: last_msg.clone(),
230 });
231 if let Message::Assistant(m) = &last_msg {
232 return StreamOutcome::Cancelled(m.clone());
233 }
234 }
235 return StreamOutcome::Cancelled(oxi_ai::AssistantMessage::new(
236 oxi_ai::Api::OpenAiCompletions,
237 "agent",
238 &loop_ref.config.model_id,
239 ));
240 }
241
242 event_count += 1;
243 match event {
244 ProviderEvent::Start { partial } => {
245 tracing::info!("Stream event #{}: Start", event_count);
246 messages.push(Message::Assistant((*partial).clone()));
247 added_partial = true;
248 emit(super::AgentEvent::MessageStart {
249 message: messages.last().expect("non-empty after push").clone(),
250 });
251 }
252
253 ProviderEvent::TextDelta { delta, partial, .. } => {
254 if added_partial {
255 let last_idx = messages.len() - 1;
256 if let Message::Assistant(ref mut m) = messages[last_idx] {
257 *m = (*partial).clone();
258 }
259 }
260 let last_msg = messages.last().expect("non-empty").clone();
261 let delta_clone = delta.clone();
262 emit(super::AgentEvent::MessageUpdate {
263 message: last_msg,
264 delta: Some(delta),
265 });
266
267 if let Some(engine) = ttsr {
269 let ctx = TtsrMatchContext {
270 source: MatchSource::Text,
271 file_paths: vec![],
272 tool_name: None,
273 };
274 let violations = engine.check_delta(&delta_clone, &ctx);
275 if !violations.is_empty() {
276 let mut partial_msg = messages
277 .last()
278 .and_then(|m| match m {
279 Message::Assistant(a) => Some(a.clone()),
280 _ => None,
281 })
282 .unwrap_or_else(|| {
283 oxi_ai::AssistantMessage::new(
284 oxi_ai::Api::OpenAiCompletions,
285 "agent",
286 &loop_ref.config.model_id,
287 )
288 });
289 partial_msg.stop_reason = StopReason::Aborted;
290 return StreamOutcome::RuleInterrupt {
291 partial: partial_msg,
292 rule: violations.into_iter().next().expect("non-empty"),
293 };
294 }
295 }
296
297 if loop_ref.config.harmony_leak_detection && detect_harmony_leak(&delta_clone) {
299 let preview = if delta_clone.len() > 80 {
300 format!("{}...", &delta_clone[..80])
301 } else {
302 delta_clone.clone()
303 };
304 tracing::warn!(
305 session_id = ?loop_ref.session_id,
306 preview = %preview,
307 "Harmony leak detected, aborting stream"
308 );
309 emit(super::AgentEvent::HarmonyLeakDetected {
310 preview: preview.clone(),
311 session_id: loop_ref.session_id.clone(),
312 });
313 let mut partial_msg = messages
314 .last()
315 .and_then(|m| match m {
316 Message::Assistant(a) => Some(a.clone()),
317 _ => None,
318 })
319 .unwrap_or_else(|| {
320 oxi_ai::AssistantMessage::new(
321 oxi_ai::Api::OpenAiCompletions,
322 "agent",
323 &loop_ref.config.model_id,
324 )
325 });
326 partial_msg.stop_reason = StopReason::Aborted;
327 return StreamOutcome::Error {
328 message: partial_msg,
329 detail: format!("Harmony leak detected: {}", preview),
330 };
331 }
332 }
333
334 ProviderEvent::ThinkingStart { partial, .. } if added_partial => {
335 let last_idx = messages.len() - 1;
336 if let Message::Assistant(ref mut m) = messages[last_idx] {
337 *m = (*partial).clone();
338 }
339 emit(super::AgentEvent::Thinking);
340 }
341 ProviderEvent::ThinkingDelta { delta, partial, .. } => {
342 if let Some(detector) = loop_ref.thinking_loop_detector.lock().as_mut()
346 && let Some(reason) = detector.push(&delta)
347 {
348 tracing::warn!(
349 session_id = ?loop_ref.session_id,
350 reason = %reason,
351 "thinking-loop detected; aborting stream"
352 );
353 emit(super::AgentEvent::Error {
354 message: reason,
355 session_id: loop_ref.session_id.clone(),
356 });
357 break;
361 }
362 if added_partial {
363 let last_idx = messages.len() - 1;
364 if let Message::Assistant(ref mut m) = messages[last_idx] {
365 *m = (*partial).clone();
366 }
367 }
368 let last_msg = messages.last().expect("non-empty").clone();
369 emit(super::AgentEvent::ThinkingDelta {
370 text: delta.clone(),
371 });
372 emit(super::AgentEvent::MessageUpdate {
373 message: last_msg,
374 delta: Some(delta),
375 });
376 }
377 ProviderEvent::ThinkingEnd { partial, .. } if added_partial => {
378 let last_idx = messages.len() - 1;
379 if let Message::Assistant(ref mut m) = messages[last_idx] {
380 *m = (*partial).clone();
381 }
382 emit(super::AgentEvent::ThinkingEnd);
383 }
384
385 ProviderEvent::ToolCallStart {
386 content_index,
387 tool_call_id,
388 partial,
389 ..
390 } if added_partial => {
391 let last_idx = messages.len() - 1;
392 if let Message::Assistant(ref mut m) = messages[last_idx] {
393 *m = (*partial).clone();
394 }
395 if let Some(id) = tool_call_id
400 && !id.is_empty()
401 {
402 tool_call_ids.insert(content_index, id);
403 }
404 }
405
406 ProviderEvent::ToolCallDelta {
407 content_index,
408 delta,
409 partial,
410 ..
411 } if added_partial => {
412 let last_idx = messages.len() - 1;
413 if let Message::Assistant(ref mut m) = messages[last_idx] {
414 *m = (*partial).clone();
415 }
416 let resolved_id = tool_call_ids
423 .get(&content_index)
424 .cloned()
425 .or_else(|| extract_tool_call_id(messages, content_index));
426 if let Some(id) = resolved_id {
427 emit(super::AgentEvent::ToolCallDelta {
428 tool_call_id: id,
429 args_delta: delta,
430 });
431 }
432 }
433
434 ProviderEvent::ToolCallEnd {
435 content_index,
436 tool_call,
437 ..
438 } if added_partial => {
439 tool_call_ids.insert(content_index, tool_call.id.clone());
442 let last_idx = messages.len() - 1;
443 if let Message::Assistant(ref mut m) = messages[last_idx] {
444 m.content.push(ContentBlock::ToolCall(tool_call));
445 }
446 let last_msg = messages.last().expect("non-empty").clone();
447 emit(super::AgentEvent::MessageUpdate {
448 message: last_msg,
449 delta: None,
450 });
451 }
452
453 ProviderEvent::Done { message, .. } => {
454 let (input, output) = (message.usage.input, message.usage.output);
455 if input > 0 || output > 0 {
456 let prompt_len = messages.len().saturating_sub(1);
481 let estimate_at_report = estimate_tokens_from_messages(&messages[..prompt_len]);
482 loop_ref.state.update(|s| {
483 s.record_usage(input, output);
484 s.record_provider_turn(input, estimate_at_report);
485 });
486 emit(super::AgentEvent::Usage {
487 input_tokens: input,
488 output_tokens: output,
489 });
490 }
491
492 tracing::info!(
493 "Stream event #{}: Done (stop_reason={:?})",
494 event_count,
495 message.stop_reason
496 );
497
498 if added_partial {
499 let last_idx = messages.len() - 1;
500 if let Message::Assistant(ref mut m) = messages[last_idx] {
501 let mut seen_ids: HashSet<String> = message
502 .content
503 .iter()
504 .filter_map(|b| match b {
505 ContentBlock::ToolCall(tc) => Some(tc.id.clone()),
506 _ => None,
507 })
508 .collect();
509
510 let extra_tool_calls: Vec<ContentBlock> = m
511 .content
512 .iter()
513 .filter(|b| match b {
514 ContentBlock::ToolCall(tc) => seen_ids.insert(tc.id.clone()),
515 _ => false,
516 })
517 .cloned()
518 .collect();
519
520 let tc_count = extra_tool_calls.len();
521 *m = message.clone();
522 m.content.extend(extra_tool_calls);
523
524 tracing::info!(
525 "Done: merged {} extra tool_calls, final has {} content blocks, stop_reason={:?}",
526 tc_count,
527 m.content.len(),
528 m.stop_reason
529 );
530 }
531 } else {
532 messages.push(Message::Assistant(message.clone()));
533 }
534 if let Some(dialect) = loop_ref.config.dialect {
539 let last_idx = messages.len() - 1;
540 if let Message::Assistant(ref mut m) = messages[last_idx] {
541 let dialect_tools: Vec<OxTool> = tool_defs
542 .iter()
543 .map(|def| {
544 let schema = serde_json::to_value(&def.input_schema)
545 .unwrap_or_else(
546 |_| serde_json::json!({"type": "object", "properties": {}}),
547 );
548 OxTool::new(&def.name, &def.description, schema)
549 })
550 .collect();
551 let parsed = dialect.parse_assistant_message(m, &dialect_tools);
552 let found = parsed
553 .content
554 .iter()
555 .filter(|b| b.as_tool_call().is_some())
556 .count();
557 if found > 0 {
558 *m = parsed;
559 if m.stop_reason == StopReason::Stop {
563 m.stop_reason = StopReason::ToolUse;
564 }
565 tracing::info!(
566 "Owned dialect: re-materialized {} in-band tool call(s)",
567 found
568 );
569 }
570 }
571 }
572
573 let last_msg = messages.last().expect("non-empty").clone();
574 emit(super::AgentEvent::MessageEnd {
575 message: last_msg.clone(),
576 });
577 if let Message::Assistant(m) = &last_msg {
578 return StreamOutcome::Complete(m.clone());
579 } else {
580 return StreamOutcome::Complete(message);
581 }
582 }
583
584 ProviderEvent::Error { mut error, .. } => {
585 tracing::info!("Stream event #{}: Error", event_count);
586 let raw_msg = error.text_content();
587 let friendly = if raw_msg.is_empty() {
588 "Unknown provider error".to_string()
589 } else {
590 raw_msg
591 };
592 tracing::error!(
593 session_id = ?loop_ref.session_id,
594 "Provider stream error: {}", friendly
595 );
596
597 error.stop_reason = StopReason::Error;
598
599 if added_partial {
600 let last_idx = messages.len() - 1;
601 if let Message::Assistant(ref mut m) = messages[last_idx] {
602 *m = error.clone();
603 }
604 } else {
605 messages.push(Message::Assistant(error.clone()));
606 }
607
608 emit(super::AgentEvent::MessageEnd {
609 message: Message::Assistant(error.clone()),
610 });
611 emit(super::AgentEvent::Error {
612 message: format!("⚠ {}", friendly),
613 session_id: loop_ref.session_id.clone(),
614 });
615
616 return StreamOutcome::Error {
617 message: error,
618 detail: format!("⚠ {}", friendly),
619 };
620 }
621
622 _ => {}
623 }
624 }
625
626 tracing::info!("Stream ended after {} events", event_count);
627
628 let final_message = match messages.last().and_then(|m| match m {
629 Message::Assistant(a) => Some(a.clone()),
630 _ => None,
631 }) {
632 Some(m) => m,
633 None => {
634 return StreamOutcome::Error {
635 message: oxi_ai::AssistantMessage::new(
636 oxi_ai::Api::OpenAiCompletions,
637 "agent",
638 &loop_ref.config.model_id,
639 ),
640 detail: "No final assistant message in stream".to_string(),
641 };
642 }
643 };
644
645 if !added_partial {
646 tracing::warn!("Stream ended without Start event, emitting synthetic MessageStart");
647 emit(super::AgentEvent::MessageStart {
648 message: Message::Assistant(final_message.clone()),
649 });
650 }
651
652 emit(super::AgentEvent::MessageEnd {
653 message: Message::Assistant(final_message.clone()),
654 });
655 StreamOutcome::Complete(final_message)
656}
657
658fn estimate_tokens_from_messages(messages: &[Message]) -> usize {
673 let json = serde_json::to_string(messages).unwrap_or_default();
674 json.len() / 4
675}
676
677fn extract_tool_call_id(messages: &[Message], content_index: usize) -> Option<String> {
687 let last = messages.last()?;
688 let Message::Assistant(m) = last else {
689 return None;
690 };
691 m.content.get(content_index).and_then(|b| match b {
692 ContentBlock::ToolCall(tc) => Some(tc.id.clone()),
693 _ => None,
694 })
695}
696
697fn detect_harmony_leak(text: &str) -> bool {
705 use std::sync::LazyLock;
706
707 static MARKER_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
709 regex::Regex::new(r"\bto=functions\.[A-Za-z_]\w*\b").expect("valid harmony marker regex")
710 });
711 if MARKER_RE.is_match(text) {
712 return true;
713 }
714
715 static BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
717 regex::Regex::new(r"<\|\s*(?:start|end|channel|message|call|return)\s*\|>")
718 .expect("valid harmony block regex")
719 });
720 if BLOCK_RE.is_match(text) {
721 return true;
722 }
723
724 false
725}
726
727#[cfg(test)]
728mod streaming_lifecycle_tests {
729 use super::stream_assistant_response;
739 use crate::ProviderResolver;
740 use crate::config::ToolExecutionMode;
741 use crate::events::AgentEvent;
742 use crate::state::SharedState;
743 use crate::tools::ToolRegistry;
744 use crate::{AgentLoop, AgentLoopConfig};
745 use futures::Stream;
746 use oxi_ai::{
747 Api, AssistantMessage, CompactionStrategy, ContentBlock, Context, Message, Model, Provider,
748 ProviderEvent, StopReason, StreamOptions, StreamResult, ToolCall, UserMessage,
749 };
750 use std::collections::VecDeque;
751 use std::future::Future;
752 use std::pin::Pin;
753 use std::sync::{Arc, Mutex};
754 use std::task::{Context as TaskContext, Poll};
755
756 struct ScriptedProvider {
758 events: Arc<Vec<ProviderEvent>>,
759 }
760
761 impl ScriptedProvider {
762 fn new(events: Vec<ProviderEvent>) -> Self {
763 Self {
764 events: Arc::new(events),
765 }
766 }
767 }
768
769 impl Provider for ScriptedProvider {
770 fn stream<'a>(
771 &'a self,
772 _model: &'a Model,
773 _context: &'a Context,
774 _options: Option<StreamOptions>,
775 ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
776 let events = Arc::clone(&self.events);
777 Box::pin(async move {
778 Ok(Box::pin(ScriptedStream {
779 events: VecDeque::from((*events).clone()),
780 })
781 as Pin<Box<dyn Stream<Item = ProviderEvent> + Send>>)
782 })
783 }
784 }
785
786 struct ScriptedStream {
787 events: VecDeque<ProviderEvent>,
788 }
789
790 impl Stream for ScriptedStream {
791 type Item = ProviderEvent;
792 fn poll_next(
793 mut self: Pin<&mut Self>,
794 _cx: &mut TaskContext<'_>,
795 ) -> Poll<Option<Self::Item>> {
796 Poll::Ready(self.events.pop_front())
797 }
798 }
799
800 struct DummyResolver;
801 impl ProviderResolver for DummyResolver {
802 fn resolve_provider(&self, _name: &str) -> Option<Arc<dyn Provider>> {
803 None
804 }
805 fn resolve_model(&self, _model_id: &str) -> Option<Model> {
806 Some(Model::new(
807 "test/model",
808 "Test",
809 Api::AnthropicMessages,
810 "mock",
811 "https://mock.test",
812 ))
813 }
814 }
815
816 fn empty_partial() -> Arc<AssistantMessage> {
817 Arc::new(AssistantMessage::new(
818 Api::AnthropicMessages,
819 "mock",
820 "test/model",
821 ))
822 }
823
824 fn make_loop(provider: Arc<dyn Provider>) -> AgentLoop {
825 let config = AgentLoopConfig {
826 model_id: "test/model".to_string(),
827 system_prompt: None,
828 temperature: 1.0,
829 max_tokens: 4096,
830 tool_execution: ToolExecutionMode::Sequential,
831 compaction_strategy: CompactionStrategy::Disabled,
832 context_window: 128_000,
833 compact_on_start: false,
834 auto_retry_enabled: false,
835 auto_retry_max_attempts: 1,
836 thinking_loop_detection: false,
837 ..Default::default()
838 };
839 AgentLoop::new_with_resolver(
840 provider,
841 config,
842 Arc::new(ToolRegistry::new()),
843 SharedState::new(),
844 Arc::new(DummyResolver),
845 )
846 }
847
848 async fn run_script(events: Vec<ProviderEvent>) -> Vec<AgentEvent> {
851 let provider: Arc<dyn Provider> = Arc::new(ScriptedProvider::new(events));
852 let agent_loop = make_loop(provider);
853 let collected: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
854 let sink = Arc::clone(&collected);
855 let emit: Arc<dyn Fn(AgentEvent) + Send + Sync> =
856 Arc::new(move |e| sink.lock().unwrap().push(e));
857 let mut messages: Vec<Message> = vec![Message::User(UserMessage::new("hi".to_string()))];
858 let _ = stream_assistant_response(&agent_loop, &mut messages, &emit, None).await;
859 collected.lock().unwrap().clone()
860 }
861
862 #[tokio::test]
864 async fn thinking_end_and_tool_call_delta_forwarded() {
865 let finalized = ToolCall::new("tc_abc", "bash", serde_json::json!({"command":"ls"}));
866 let mut done_msg = AssistantMessage::new(Api::AnthropicMessages, "mock", "test/model");
867 done_msg
868 .content
869 .push(ContentBlock::ToolCall(finalized.clone()));
870 let events = vec![
871 ProviderEvent::Start {
872 partial: empty_partial(),
873 },
874 ProviderEvent::ThinkingStart {
875 content_index: 0,
876 partial: empty_partial(),
877 },
878 ProviderEvent::ThinkingDelta {
879 content_index: 0,
880 delta: "reasoning...".to_string(),
881 partial: empty_partial(),
882 },
883 ProviderEvent::ThinkingEnd {
884 content_index: 0,
885 content: "reasoning...".to_string(),
886 partial: empty_partial(),
887 },
888 ProviderEvent::ToolCallStart {
889 content_index: 1,
890 tool_call_id: Some("tc_abc".to_string()),
891 tool_name: Some("bash".to_string()),
892 partial: empty_partial(),
893 },
894 ProviderEvent::ToolCallDelta {
895 content_index: 1,
896 delta: "{\"command\":".to_string(),
897 partial: empty_partial(),
898 },
899 ProviderEvent::ToolCallDelta {
900 content_index: 1,
901 delta: "\"ls\"}".to_string(),
902 partial: empty_partial(),
903 },
904 ProviderEvent::ToolCallEnd {
905 content_index: 1,
906 tool_call: finalized,
907 partial: empty_partial(),
908 },
909 ProviderEvent::Done {
910 reason: StopReason::Stop,
911 message: done_msg,
912 },
913 ];
914
915 let emitted = run_script(events).await;
916
917 let thinking_end_at = emitted
918 .iter()
919 .position(|e| matches!(e, AgentEvent::ThinkingEnd));
920 assert!(
921 thinking_end_at.is_some(),
922 "AgentEvent::ThinkingEnd must be emitted"
923 );
924
925 let deltas: Vec<(&str, &str)> = emitted
926 .iter()
927 .filter_map(|e| match e {
928 AgentEvent::ToolCallDelta {
929 tool_call_id,
930 args_delta,
931 } => Some((tool_call_id.as_str(), args_delta.as_str())),
932 _ => None,
933 })
934 .collect();
935 assert_eq!(deltas.len(), 2, "expected exactly two ToolCallDelta events");
936 assert_eq!(deltas[0], ("tc_abc", "{\"command\":"));
937 assert_eq!(deltas[1], ("tc_abc", "\"ls\"}"));
938
939 let first_delta_at = emitted
940 .iter()
941 .position(|e| matches!(e, AgentEvent::ToolCallDelta { .. }))
942 .expect("at least one ToolCallDelta");
943 assert!(
944 thinking_end_at.unwrap() < first_delta_at,
945 "ThinkingEnd must precede ToolCallDelta"
946 );
947 }
948
949 #[tokio::test]
953 async fn tool_call_delta_resolves_late_id() {
954 let finalized = ToolCall::new("tc_late", "grep", serde_json::json!({"pattern":"foo"}));
955 let mut done_msg = AssistantMessage::new(Api::AnthropicMessages, "mock", "test/model");
956 done_msg
957 .content
958 .push(ContentBlock::ToolCall(finalized.clone()));
959 let events = vec![
960 ProviderEvent::Start {
961 partial: empty_partial(),
962 },
963 ProviderEvent::ToolCallStart {
964 content_index: 0,
965 tool_call_id: None,
966 tool_name: Some("grep".to_string()),
967 partial: empty_partial(),
968 },
969 ProviderEvent::ToolCallStart {
970 content_index: 0,
971 tool_call_id: Some("tc_late".to_string()),
972 tool_name: Some("grep".to_string()),
973 partial: empty_partial(),
974 },
975 ProviderEvent::ToolCallDelta {
976 content_index: 0,
977 delta: "{\"pattern\":".to_string(),
978 partial: empty_partial(),
979 },
980 ProviderEvent::ToolCallEnd {
981 content_index: 0,
982 tool_call: finalized,
983 partial: empty_partial(),
984 },
985 ProviderEvent::Done {
986 reason: StopReason::Stop,
987 message: done_msg,
988 },
989 ];
990
991 let emitted = run_script(events).await;
992
993 let ids: Vec<String> = emitted
994 .iter()
995 .filter_map(|e| match e {
996 AgentEvent::ToolCallDelta { tool_call_id, .. } => Some(tool_call_id.clone()),
997 _ => None,
998 })
999 .collect();
1000 assert_eq!(
1001 ids,
1002 vec!["tc_late".to_string()],
1003 "ToolCallDelta must resolve the id from the second ToolCallStart"
1004 );
1005 }
1006}