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: Some(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 };
289 let violations = engine.check_delta(&delta_clone, &ctx);
290 if !violations.is_empty() {
291 let mut partial_msg = messages
292 .last()
293 .and_then(|m| match m {
294 Message::Assistant(a) => Some(a.clone()),
295 _ => None,
296 })
297 .unwrap_or_else(|| {
298 oxicode_ai::AssistantMessage::new(
299 oxicode_ai::Api::OpenAiCompletions,
300 "agent",
301 &loop_ref.config.model_id,
302 )
303 });
304 partial_msg.stop_reason = StopReason::Aborted;
305 #[allow(clippy::expect_used)]
307 return StreamOutcome::RuleInterrupt {
308 partial: partial_msg,
309 rule: violations.into_iter().next().expect("non-empty"),
310 };
311 }
312 }
313
314 if loop_ref.config.harmony_leak_detection && detect_harmony_leak(&delta_clone) {
316 let preview = if delta_clone.len() > 80 {
317 format!("{}...", &delta_clone[..80])
318 } else {
319 delta_clone.clone()
320 };
321 tracing::warn!(
322 session_id = ?loop_ref.session_id,
323 preview = %preview,
324 "Harmony leak detected, aborting stream"
325 );
326 emit(super::AgentEvent::HarmonyLeakDetected {
327 preview: preview.clone(),
328 session_id: loop_ref.session_id.clone(),
329 });
330 let mut partial_msg = messages
331 .last()
332 .and_then(|m| match m {
333 Message::Assistant(a) => Some(a.clone()),
334 _ => None,
335 })
336 .unwrap_or_else(|| {
337 oxicode_ai::AssistantMessage::new(
338 oxicode_ai::Api::OpenAiCompletions,
339 "agent",
340 &loop_ref.config.model_id,
341 )
342 });
343 partial_msg.stop_reason = StopReason::Aborted;
344 return StreamOutcome::Error {
345 message: partial_msg,
346 detail: format!("Harmony leak detected: {}", preview),
347 };
348 }
349 }
350
351 ProviderEvent::ThinkingStart { partial, .. } if added_partial => {
352 let last_idx = messages.len() - 1;
353 if let Message::Assistant(ref mut m) = messages[last_idx] {
354 *m = (*partial).clone();
355 }
356 emit(super::AgentEvent::Thinking);
357 }
358 ProviderEvent::ThinkingDelta { delta, partial, .. } => {
359 if let Some(detector) = loop_ref.thinking_loop_detector.lock().as_mut()
363 && let Some(reason) = detector.push(&delta)
364 {
365 tracing::warn!(
366 session_id = ?loop_ref.session_id,
367 reason = %reason,
368 "thinking-loop detected; aborting stream"
369 );
370 emit(super::AgentEvent::Error {
371 message: reason,
372 session_id: loop_ref.session_id.clone(),
373 });
374 break;
378 }
379 if added_partial {
380 let last_idx = messages.len() - 1;
381 if let Message::Assistant(ref mut m) = messages[last_idx] {
382 *m = (*partial).clone();
383 }
384 }
385 #[allow(clippy::expect_used)]
388 let last_msg = messages.last().expect("non-empty").clone();
389 emit(super::AgentEvent::ThinkingDelta {
390 text: delta.clone(),
391 });
392 emit(super::AgentEvent::MessageUpdate {
393 message: last_msg,
394 delta: Some(delta),
395 });
396 }
397 ProviderEvent::ThinkingEnd { partial, .. } if added_partial => {
398 let last_idx = messages.len() - 1;
399 if let Message::Assistant(ref mut m) = messages[last_idx] {
400 *m = (*partial).clone();
401 }
402 emit(super::AgentEvent::ThinkingEnd);
403 }
404
405 ProviderEvent::ToolCallStart {
406 content_index,
407 tool_call_id,
408 partial,
409 ..
410 } if added_partial => {
411 let last_idx = messages.len() - 1;
412 if let Message::Assistant(ref mut m) = messages[last_idx] {
413 *m = (*partial).clone();
414 }
415 if let Some(id) = tool_call_id
420 && !id.is_empty()
421 {
422 tool_call_ids.insert(content_index, id);
423 }
424 }
425
426 ProviderEvent::ToolCallDelta {
427 content_index,
428 delta,
429 partial,
430 ..
431 } 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 let resolved_id = tool_call_ids
443 .get(&content_index)
444 .cloned()
445 .or_else(|| extract_tool_call_id(messages, content_index));
446 if let Some(id) = resolved_id {
447 emit(super::AgentEvent::ToolCallDelta {
448 tool_call_id: id,
449 args_delta: delta,
450 });
451 }
452 }
453
454 ProviderEvent::ToolCallEnd {
455 content_index,
456 tool_call,
457 ..
458 } if added_partial => {
459 tool_call_ids.insert(content_index, tool_call.id.clone());
462 let last_idx = messages.len() - 1;
463 if let Message::Assistant(ref mut m) = messages[last_idx] {
464 m.content.push(ContentBlock::ToolCall(tool_call));
465 }
466 #[allow(clippy::expect_used)]
469 let last_msg = messages.last().expect("non-empty").clone();
470 emit(super::AgentEvent::MessageUpdate {
471 message: last_msg,
472 delta: None,
473 });
474 }
475
476 ProviderEvent::Done { message, .. } => {
477 let (input, output) = (message.usage.input, message.usage.output);
478 if input > 0 || output > 0 {
479 let prompt_len = messages.len().saturating_sub(1);
504 let estimate_at_report = estimate_tokens_from_messages(&messages[..prompt_len]);
505 loop_ref.state.update(|s| {
506 s.record_usage(input, output);
507 s.record_provider_turn(input, estimate_at_report);
508 });
509 emit(super::AgentEvent::Usage {
510 input_tokens: input,
511 output_tokens: output,
512 });
513 }
514
515 tracing::info!(
516 "Stream event #{}: Done (stop_reason={:?})",
517 event_count,
518 message.stop_reason
519 );
520
521 if added_partial {
522 let last_idx = messages.len() - 1;
523 if let Message::Assistant(ref mut m) = messages[last_idx] {
524 let mut seen_ids: HashSet<String> = message
525 .content
526 .iter()
527 .filter_map(|b| match b {
528 ContentBlock::ToolCall(tc) => Some(tc.id.clone()),
529 _ => None,
530 })
531 .collect();
532
533 let extra_tool_calls: Vec<ContentBlock> = m
534 .content
535 .iter()
536 .filter(|b| match b {
537 ContentBlock::ToolCall(tc) => seen_ids.insert(tc.id.clone()),
538 _ => false,
539 })
540 .cloned()
541 .collect();
542
543 let tc_count = extra_tool_calls.len();
544 *m = message.clone();
545 m.content.extend(extra_tool_calls);
546
547 tracing::info!(
548 "Done: merged {} extra tool_calls, final has {} content blocks, stop_reason={:?}",
549 tc_count,
550 m.content.len(),
551 m.stop_reason
552 );
553 }
554 } else {
555 messages.push(Message::Assistant(message.clone()));
556 }
557 if let Some(dialect) = loop_ref.config.dialect {
562 let last_idx = messages.len() - 1;
563 if let Message::Assistant(ref mut m) = messages[last_idx] {
564 let dialect_tools: Vec<OxTool> = tool_defs
565 .iter()
566 .map(|def| {
567 let schema = serde_json::to_value(&def.input_schema)
568 .unwrap_or_else(
569 |_| serde_json::json!({"type": "object", "properties": {}}),
570 );
571 OxTool::new(&def.name, &def.description, schema)
572 })
573 .collect();
574 let parsed = dialect.parse_assistant_message(m, &dialect_tools);
575 let found = parsed
576 .content
577 .iter()
578 .filter(|b| b.as_tool_call().is_some())
579 .count();
580 if found > 0 {
581 *m = parsed;
582 if m.stop_reason == StopReason::Stop {
586 m.stop_reason = StopReason::ToolUse;
587 }
588 tracing::info!(
589 "Owned dialect: re-materialized {} in-band tool call(s)",
590 found
591 );
592 }
593 }
594 }
595
596 #[allow(clippy::expect_used)]
599 let last_msg = messages.last().expect("non-empty").clone();
600 emit(super::AgentEvent::MessageEnd {
601 message: last_msg.clone(),
602 });
603 if let Message::Assistant(m) = &last_msg {
604 return StreamOutcome::Complete(m.clone());
605 } else {
606 return StreamOutcome::Complete(message);
607 }
608 }
609
610 ProviderEvent::Error { mut error, .. } => {
611 tracing::info!("Stream event #{}: Error", event_count);
612 let raw_msg = error.text_content();
613 let friendly = if raw_msg.is_empty() {
614 "Unknown provider error".to_string()
615 } else {
616 raw_msg
617 };
618 tracing::error!(
619 session_id = ?loop_ref.session_id,
620 "Provider stream error: {}", friendly
621 );
622
623 error.stop_reason = StopReason::Error;
624
625 if added_partial {
626 let last_idx = messages.len() - 1;
627 if let Message::Assistant(ref mut m) = messages[last_idx] {
628 *m = error.clone();
629 }
630 } else {
631 messages.push(Message::Assistant(error.clone()));
632 }
633
634 emit(super::AgentEvent::MessageEnd {
635 message: Message::Assistant(error.clone()),
636 });
637 emit(super::AgentEvent::Error {
638 message: format!("⚠ {}", friendly),
639 session_id: loop_ref.session_id.clone(),
640 });
641
642 return StreamOutcome::Error {
643 message: error,
644 detail: format!("⚠ {}", friendly),
645 };
646 }
647
648 _ => {}
649 }
650 }
651
652 tracing::info!("Stream ended after {} events", event_count);
653
654 let final_message = match messages.last().and_then(|m| match m {
655 Message::Assistant(a) => Some(a.clone()),
656 _ => None,
657 }) {
658 Some(m) => m,
659 None => {
660 return StreamOutcome::Error {
661 message: oxicode_ai::AssistantMessage::new(
662 oxicode_ai::Api::OpenAiCompletions,
663 "agent",
664 &loop_ref.config.model_id,
665 ),
666 detail: "No final assistant message in stream".to_string(),
667 };
668 }
669 };
670
671 if !added_partial {
672 tracing::warn!("Stream ended without Start event, emitting synthetic MessageStart");
673 emit(super::AgentEvent::MessageStart {
674 message: Message::Assistant(final_message.clone()),
675 });
676 }
677
678 emit(super::AgentEvent::MessageEnd {
679 message: Message::Assistant(final_message.clone()),
680 });
681 StreamOutcome::Complete(final_message)
682}
683
684fn estimate_tokens_from_messages(messages: &[Message]) -> usize {
699 let json = serde_json::to_string(messages).unwrap_or_default();
700 json.len() / 4
701}
702
703fn extract_tool_call_id(messages: &[Message], content_index: usize) -> Option<String> {
713 let last = messages.last()?;
714 let Message::Assistant(m) = last else {
715 return None;
716 };
717 m.content.get(content_index).and_then(|b| match b {
718 ContentBlock::ToolCall(tc) => Some(tc.id.clone()),
719 _ => None,
720 })
721}
722
723fn detect_harmony_leak(text: &str) -> bool {
731 use std::sync::LazyLock;
732
733 static MARKER_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
735 #[allow(clippy::expect_used)]
739 regex::Regex::new(r"\bto=functions\.[A-Za-z_]\w*\b").expect("valid harmony marker regex")
740 });
741 if MARKER_RE.is_match(text) {
742 return true;
743 }
744
745 static BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
747 #[allow(clippy::expect_used)]
751 regex::Regex::new(r"<\|\s*(?:start|end|channel|message|call|return)\s*\|>")
752 .expect("valid harmony block regex")
753 });
754 if BLOCK_RE.is_match(text) {
755 return true;
756 }
757
758 false
759}
760
761#[cfg(test)]
762mod streaming_lifecycle_tests {
763 use super::stream_assistant_response;
773 use crate::ProviderResolver;
774 use crate::config::ToolExecutionMode;
775 use crate::events::AgentEvent;
776 use crate::state::SharedState;
777 use crate::tools::ToolRegistry;
778 use crate::{AgentLoop, AgentLoopConfig};
779 use futures::Stream;
780 use oxicode_ai::{
781 Api, AssistantMessage, CompactionStrategy, ContentBlock, Context, Message, Model, Provider,
782 ProviderEvent, StopReason, StreamOptions, StreamResult, ToolCall, UserMessage,
783 };
784 use std::collections::VecDeque;
785 use std::future::Future;
786 use std::pin::Pin;
787 use std::sync::{Arc, Mutex};
788 use std::task::{Context as TaskContext, Poll};
789
790 struct ScriptedProvider {
792 events: Arc<Vec<ProviderEvent>>,
793 }
794
795 impl ScriptedProvider {
796 fn new(events: Vec<ProviderEvent>) -> Self {
797 Self {
798 events: Arc::new(events),
799 }
800 }
801 }
802
803 impl Provider for ScriptedProvider {
804 fn stream<'a>(
805 &'a self,
806 _model: &'a Model,
807 _context: &'a Context,
808 _options: Option<StreamOptions>,
809 ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
810 let events = Arc::clone(&self.events);
811 Box::pin(async move {
812 Ok(Box::pin(ScriptedStream {
813 events: VecDeque::from((*events).clone()),
814 })
815 as Pin<Box<dyn Stream<Item = ProviderEvent> + Send>>)
816 })
817 }
818 }
819
820 struct ScriptedStream {
821 events: VecDeque<ProviderEvent>,
822 }
823
824 impl Stream for ScriptedStream {
825 type Item = ProviderEvent;
826 fn poll_next(
827 mut self: Pin<&mut Self>,
828 _cx: &mut TaskContext<'_>,
829 ) -> Poll<Option<Self::Item>> {
830 Poll::Ready(self.events.pop_front())
831 }
832 }
833
834 struct DummyResolver;
835 impl ProviderResolver for DummyResolver {
836 fn resolve_provider(&self, _name: &str) -> Option<Arc<dyn Provider>> {
837 None
838 }
839 fn resolve_model(&self, _model_id: &str) -> Option<Model> {
840 Some(Model::new(
841 "test/model",
842 "Test",
843 Api::AnthropicMessages,
844 "mock",
845 "https://mock.test",
846 ))
847 }
848 }
849
850 fn empty_partial() -> Arc<AssistantMessage> {
851 Arc::new(AssistantMessage::new(
852 Api::AnthropicMessages,
853 "mock",
854 "test/model",
855 ))
856 }
857
858 fn make_loop(provider: Arc<dyn Provider>) -> AgentLoop {
859 let config = AgentLoopConfig {
860 model_id: "test/model".to_string(),
861 system_prompt: None,
862 temperature: 1.0,
863 max_tokens: 4096,
864 tool_execution: ToolExecutionMode::Sequential,
865 compaction_strategy: CompactionStrategy::Disabled,
866 context_window: 128_000,
867 compact_on_start: false,
868 auto_retry_enabled: false,
869 auto_retry_max_attempts: 1,
870 thinking_loop_detection: false,
871 ..Default::default()
872 };
873 AgentLoop::new_with_resolver(
874 provider,
875 config,
876 Arc::new(ToolRegistry::new()),
877 SharedState::new(),
878 Arc::new(DummyResolver),
879 )
880 }
881
882 async fn run_script(events: Vec<ProviderEvent>) -> Vec<AgentEvent> {
885 let provider: Arc<dyn Provider> = Arc::new(ScriptedProvider::new(events));
886 let agent_loop = make_loop(provider);
887 let collected: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
888 let sink = Arc::clone(&collected);
889 let emit: Arc<dyn Fn(AgentEvent) + Send + Sync> =
890 Arc::new(move |e| sink.lock().unwrap().push(e));
891 let mut messages: Vec<Message> = vec![Message::User(UserMessage::new("hi".to_string()))];
892 let _ = stream_assistant_response(&agent_loop, &mut messages, &emit, None).await;
893 collected.lock().unwrap().clone()
894 }
895
896 #[tokio::test]
898 async fn thinking_end_and_tool_call_delta_forwarded() {
899 let finalized = ToolCall::new("tc_abc", "bash", serde_json::json!({"command":"ls"}));
900 let mut done_msg = AssistantMessage::new(Api::AnthropicMessages, "mock", "test/model");
901 done_msg
902 .content
903 .push(ContentBlock::ToolCall(finalized.clone()));
904 let events = vec![
905 ProviderEvent::Start {
906 partial: empty_partial(),
907 },
908 ProviderEvent::ThinkingStart {
909 content_index: 0,
910 partial: empty_partial(),
911 },
912 ProviderEvent::ThinkingDelta {
913 content_index: 0,
914 delta: "reasoning...".to_string(),
915 partial: empty_partial(),
916 },
917 ProviderEvent::ThinkingEnd {
918 content_index: 0,
919 content: "reasoning...".to_string(),
920 partial: empty_partial(),
921 },
922 ProviderEvent::ToolCallStart {
923 content_index: 1,
924 tool_call_id: Some("tc_abc".to_string()),
925 tool_name: Some("bash".to_string()),
926 partial: empty_partial(),
927 },
928 ProviderEvent::ToolCallDelta {
929 content_index: 1,
930 delta: "{\"command\":".to_string(),
931 partial: empty_partial(),
932 },
933 ProviderEvent::ToolCallDelta {
934 content_index: 1,
935 delta: "\"ls\"}".to_string(),
936 partial: empty_partial(),
937 },
938 ProviderEvent::ToolCallEnd {
939 content_index: 1,
940 tool_call: finalized,
941 partial: empty_partial(),
942 },
943 ProviderEvent::Done {
944 reason: StopReason::Stop,
945 message: done_msg,
946 },
947 ];
948
949 let emitted = run_script(events).await;
950
951 let thinking_end_at = emitted
952 .iter()
953 .position(|e| matches!(e, AgentEvent::ThinkingEnd));
954 assert!(
955 thinking_end_at.is_some(),
956 "AgentEvent::ThinkingEnd must be emitted"
957 );
958
959 let deltas: Vec<(&str, &str)> = emitted
960 .iter()
961 .filter_map(|e| match e {
962 AgentEvent::ToolCallDelta {
963 tool_call_id,
964 args_delta,
965 } => Some((tool_call_id.as_str(), args_delta.as_str())),
966 _ => None,
967 })
968 .collect();
969 assert_eq!(deltas.len(), 2, "expected exactly two ToolCallDelta events");
970 assert_eq!(deltas[0], ("tc_abc", "{\"command\":"));
971 assert_eq!(deltas[1], ("tc_abc", "\"ls\"}"));
972
973 let first_delta_at = emitted
974 .iter()
975 .position(|e| matches!(e, AgentEvent::ToolCallDelta { .. }))
976 .expect("at least one ToolCallDelta");
977 assert!(
978 thinking_end_at.unwrap() < first_delta_at,
979 "ThinkingEnd must precede ToolCallDelta"
980 );
981 }
982
983 #[tokio::test]
987 async fn tool_call_delta_resolves_late_id() {
988 let finalized = ToolCall::new("tc_late", "grep", serde_json::json!({"pattern":"foo"}));
989 let mut done_msg = AssistantMessage::new(Api::AnthropicMessages, "mock", "test/model");
990 done_msg
991 .content
992 .push(ContentBlock::ToolCall(finalized.clone()));
993 let events = vec![
994 ProviderEvent::Start {
995 partial: empty_partial(),
996 },
997 ProviderEvent::ToolCallStart {
998 content_index: 0,
999 tool_call_id: None,
1000 tool_name: Some("grep".to_string()),
1001 partial: empty_partial(),
1002 },
1003 ProviderEvent::ToolCallStart {
1004 content_index: 0,
1005 tool_call_id: Some("tc_late".to_string()),
1006 tool_name: Some("grep".to_string()),
1007 partial: empty_partial(),
1008 },
1009 ProviderEvent::ToolCallDelta {
1010 content_index: 0,
1011 delta: "{\"pattern\":".to_string(),
1012 partial: empty_partial(),
1013 },
1014 ProviderEvent::ToolCallEnd {
1015 content_index: 0,
1016 tool_call: finalized,
1017 partial: empty_partial(),
1018 },
1019 ProviderEvent::Done {
1020 reason: StopReason::Stop,
1021 message: done_msg,
1022 },
1023 ];
1024
1025 let emitted = run_script(events).await;
1026
1027 let ids: Vec<String> = emitted
1028 .iter()
1029 .filter_map(|e| match e {
1030 AgentEvent::ToolCallDelta { tool_call_id, .. } => Some(tool_call_id.clone()),
1031 _ => None,
1032 })
1033 .collect();
1034 assert_eq!(
1035 ids,
1036 vec!["tc_late".to_string()],
1037 "ToolCallDelta must resolve the id from the second ToolCallStart"
1038 );
1039 }
1040}