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 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 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 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 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 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 } 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 let mut tool_call_ids: HashMap<usize, String> = HashMap::new();
168
169 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 #[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 #[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 #[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 #[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 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 #[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 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 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;
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 #[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 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 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 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 #[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 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 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 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 #[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
718fn estimate_tokens_from_messages(messages: &[Message]) -> usize {
733 let json = serde_json::to_string(messages).unwrap_or_default();
734 json.len() / 4
735}
736
737fn 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
757fn detect_harmony_leak(text: &str) -> bool {
765 use std::sync::LazyLock;
766
767 static MARKER_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
769 #[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 static BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
781 #[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 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 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 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 #[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 #[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}