1use std::collections::{BTreeSet, HashMap};
37
38use serde::{Deserialize, Serialize};
39
40use rig_core::completion::FinishReason;
41use rig_core::message::{
42 AssistantContent, Reasoning, ToolCall, ToolFunction, ToolResult, non_empty,
43};
44
45use crate::{
46 agent::prompt_request::{TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER, tool_result_message},
47 completion::{CompletionError, Message, Usage},
48 json_utils,
49 streaming::{StreamedAssistantContent, ToolCallDeltaContent},
50};
51
52pub(crate) fn ordered_assistant_content(
56 reasoning_items: impl IntoIterator<Item = Reasoning>,
57 text_items: impl IntoIterator<Item = AssistantContent>,
58 trailing_items: impl IntoIterator<Item = AssistantContent>,
59) -> Vec<AssistantContent> {
60 let mut content_items = reasoning_items
61 .into_iter()
62 .map(AssistantContent::Reasoning)
63 .collect::<Vec<_>>();
64 content_items.extend(text_items);
65 content_items.extend(trailing_items);
66 content_items
67}
68
69pub(crate) fn ordered_streaming_assistant_content(
72 reasoning_items: impl IntoIterator<Item = Reasoning>,
73 text_items: impl IntoIterator<Item = AssistantContent>,
74 trailing_items: impl IntoIterator<Item = AssistantContent>,
75) -> Option<Vec<AssistantContent>> {
76 non_empty(ordered_assistant_content(
77 reasoning_items,
78 text_items,
79 trailing_items,
80 ))
81}
82
83fn unknown_payload_loses_assistant_content(payload: &serde_json::Value) -> bool {
107 if AssistantContent::deserialize(payload).is_ok() {
111 return true;
112 }
113 payload
117 .get("text")
118 .is_some_and(serde_json::Value::is_string)
119 && payload.get("additional_params").is_some()
120}
121
122pub(crate) fn assistant_text_items_from_choice(
123 choice: &[AssistantContent],
124) -> Vec<AssistantContent> {
125 choice
126 .iter()
127 .filter_map(|content| match content {
128 AssistantContent::Text(text) => (!text.text.is_empty()
129 || text.additional_params.is_some())
130 .then(|| AssistantContent::Text(text.clone())),
131 _ => None,
132 })
133 .collect()
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct StreamedInvalidToolCall {
140 pub tool_call: ToolCall,
143 pub internal_call_id: String,
145 pub args: Option<String>,
147 pub executable_tool_names: BTreeSet<String>,
149 pub allowed_tool_names: BTreeSet<String>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct PartialStreamedTurn {
158 pub message_id: Option<String>,
160 pub text: Option<String>,
162 pub reasoning: Vec<Reasoning>,
165 pub pending_tool_calls: Vec<ToolCall>,
167}
168
169impl PartialStreamedTurn {
170 pub(crate) fn assistant_message(&self, current_tool_call: Option<ToolCall>) -> Option<Message> {
174 let text_items = match &self.text {
175 Some(text) if !text.is_empty() => vec![AssistantContent::text(text.clone())],
176 _ => Vec::new(),
177 };
178 let mut tool_items = self
179 .pending_tool_calls
180 .iter()
181 .cloned()
182 .map(AssistantContent::ToolCall)
183 .collect::<Vec<_>>();
184 if let Some(tool_call) = current_tool_call {
185 tool_items.push(AssistantContent::ToolCall(tool_call));
186 }
187
188 let content = ordered_streaming_assistant_content(
189 self.reasoning.iter().cloned(),
190 text_items,
191 tool_items,
192 )?;
193 Some(Message::Assistant {
194 id: self.message_id.clone(),
195 content,
196 })
197 }
198
199 pub(crate) fn rollback_messages(
203 &self,
204 invalid_tool_call: ToolCall,
205 feedback: String,
206 ) -> Option<(Message, Message)> {
207 let assistant_message = self.assistant_message(Some(invalid_tool_call.clone()))?;
213
214 let mut retry_results = self
215 .pending_tool_calls
216 .iter()
217 .map(|tool_call| {
218 tool_result_message(
219 tool_call.id.clone(),
220 tool_call.provider.clone(),
221 tool_call.function.name.clone(),
222 TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER.to_string(),
223 )
224 })
225 .collect::<Vec<_>>();
226 retry_results.push(tool_result_message(
227 invalid_tool_call.id,
228 invalid_tool_call.provider,
229 invalid_tool_call.function.name,
230 feedback,
231 ));
232
233 let user_message = Message::User {
236 content: retry_results,
237 };
238
239 Some((assistant_message, user_message))
240 }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct StreamedTurn {
247 pub message_id: Option<String>,
249 pub choice: Vec<AssistantContent>,
253 pub executable_tool_names: BTreeSet<String>,
255 pub allowed_tool_names: BTreeSet<String>,
257 #[serde(default)]
261 pub internal_call_ids: Vec<(String, String)>,
262 #[serde(default)]
269 pub finish_reason: Option<FinishReason>,
270}
271
272#[derive(Debug)]
277pub enum StreamedResolution {
278 Repaired {
282 tool_name: String,
284 },
285 TurnAbandoned {
290 skipped_tool_result: Option<Box<ToolResult>>,
293 },
294}
295
296#[derive(Debug, Clone)]
301pub enum StreamedTurnEvent {
302 EmitIngested,
305 EmitToolCallDelta {
308 internal_call_id: String,
310 content: ToolCallDeltaContent,
312 },
313 InvalidToolCall(Box<StreamedInvalidToolCall>),
318 Completed {
324 usage: Usage,
327 emit_final: bool,
330 finish_reason: Option<FinishReason>,
337 },
338}
339
340#[derive(Default)]
341struct ToolCallDeltaState {
342 name_validated: bool,
343 buffered_arguments: Vec<String>,
344}
345
346struct ReasoningPart {
351 correlator: Option<String>,
352 provider_id: Option<String>,
353 state: ReasoningPartState,
354}
355
356#[derive(Clone)]
357enum ReasoningPartState {
358 Pending(String),
360 Completed(Reasoning),
363}
364
365fn reasoning_from_part(
368 state: ReasoningPartState,
369 provider_id: Option<String>,
370) -> Option<Reasoning> {
371 match state {
372 ReasoningPartState::Completed(reasoning) => Some(reasoning),
373 ReasoningPartState::Pending(text) if !text.is_empty() => {
374 let mut assembled = Reasoning::new(&text);
375 if let Some(id) = provider_id {
376 assembled = assembled.with_id(id);
377 }
378 Some(assembled)
379 }
380 ReasoningPartState::Pending(_) => None,
381 }
382}
383
384enum PendingInvalid {
385 FullCall {
387 tool_call: Box<ToolCall>,
388 internal_call_id: String,
389 },
390 NameDelta { internal_call_id: String },
392}
393
394pub struct StreamedTurnAssembler {
397 executable_tool_names: BTreeSet<String>,
398 allowed_tool_names: BTreeSet<String>,
399 text: String,
400 saw_text: bool,
401 reasoning_parts: Vec<ReasoningPart>,
402 pending_tool_calls: Vec<(ToolCall, String)>,
403 delta_states: HashMap<String, ToolCallDeltaState>,
404 pending_invalid: Option<PendingInvalid>,
405 finish_reason: Option<FinishReason>,
408 excluded_assistant_content: ExclusionCount,
412}
413
414#[derive(Default)]
422struct ExclusionCount(usize);
423
424impl Drop for ExclusionCount {
425 fn drop(&mut self) {
426 if self.0 > 0 {
427 tracing::warn!(
428 excluded = self.0,
429 "stream items matching rig's tagged assistant-content \
430 serialization were excluded from the assembled assistant \
431 message — replayed assistant blocks are not stream-item \
432 shapes, and their content is lost from assembled history"
433 );
434 }
435 }
436}
437
438impl StreamedTurnAssembler {
439 pub fn new(
442 executable_tool_names: BTreeSet<String>,
443 allowed_tool_names: BTreeSet<String>,
444 ) -> Self {
445 Self {
446 executable_tool_names,
447 allowed_tool_names,
448 text: String::new(),
449 saw_text: false,
450 reasoning_parts: Vec::new(),
451 pending_tool_calls: Vec::new(),
452 delta_states: HashMap::new(),
453 pending_invalid: None,
454 finish_reason: None,
455 excluded_assistant_content: ExclusionCount::default(),
456 }
457 }
458
459 pub fn excluded_assistant_content(&self) -> usize {
464 self.excluded_assistant_content.0
465 }
466
467 pub fn aggregated_text(&self) -> &str {
470 &self.text
471 }
472
473 pub fn aggregated_reasoning(&self, correlator: &str) -> Option<&str> {
480 self.reasoning_parts.iter().find_map(|part| {
481 match (&part.state, part.correlator.as_deref()) {
482 (ReasoningPartState::Pending(text), Some(id)) if id == correlator => {
483 Some(text.as_str())
484 }
485 _ => None,
486 }
487 })
488 }
489
490 fn canonical_choice_with(
497 &self,
498 reasoning: Vec<Reasoning>,
499 provider_choice: &[AssistantContent],
500 ) -> Vec<AssistantContent> {
501 if !self.pending_tool_calls.is_empty() || !reasoning.is_empty() {
502 let text_items = assistant_text_items_from_choice(provider_choice);
503 let tool_items = self
504 .pending_tool_calls
505 .iter()
506 .map(|(tool_call, _)| AssistantContent::ToolCall(tool_call.clone()))
507 .collect::<Vec<_>>();
508 ordered_assistant_content(reasoning, text_items, tool_items)
512 } else {
513 provider_choice.to_vec()
514 }
515 }
516
517 fn ingest_completed_reasoning(&mut self, reasoning: &Reasoning, correlator: &str) {
530 let replace_at = self
538 .reasoning_parts
539 .iter()
540 .position(|part| part.correlator.as_deref() == Some(correlator))
541 .or_else(|| {
542 self.reasoning_parts.iter().position(|part| {
543 matches!(part.state, ReasoningPartState::Pending(_))
544 && matches!(
545 (&part.provider_id, &reasoning.id),
546 (Some(pending_id), Some(incoming_id)) if pending_id == incoming_id
547 )
548 })
549 });
550 if let Some(part) = replace_at.and_then(|index| self.reasoning_parts.get_mut(index)) {
551 if reasoning.id.is_some() {
552 part.provider_id = reasoning.id.clone();
553 }
554 part.state = ReasoningPartState::Completed(reasoning.clone());
555 return;
556 }
557
558 let extends = self.reasoning_parts.iter_mut().rev().find(|part| {
561 matches!(part.state, ReasoningPartState::Completed(_))
562 && matches!(
563 (&part.provider_id, &reasoning.id),
564 (Some(existing_id), Some(incoming_id)) if existing_id == incoming_id
565 )
566 });
567 if let Some(part) = extends {
568 if let ReasoningPartState::Completed(existing) = &mut part.state {
569 existing.content.extend(reasoning.content.clone());
570 }
571 return;
572 }
573
574 self.reasoning_parts.push(ReasoningPart {
575 correlator: Some(correlator.to_owned()),
576 provider_id: reasoning.id.clone(),
577 state: ReasoningPartState::Completed(reasoning.clone()),
578 });
579 }
580
581 fn assembled_reasoning(&self) -> Vec<Reasoning> {
585 self.reasoning_parts
586 .iter()
587 .filter_map(|part| reasoning_from_part(part.state.clone(), part.provider_id.clone()))
588 .collect()
589 }
590
591 fn drain_reasoning(&mut self) -> Vec<Reasoning> {
595 std::mem::take(&mut self.reasoning_parts)
596 .into_iter()
597 .filter_map(|part| reasoning_from_part(part.state, part.provider_id))
598 .collect()
599 }
600
601 pub fn ingest(
608 &mut self,
609 item: &StreamedAssistantContent,
610 ) -> Result<Vec<StreamedTurnEvent>, CompletionError> {
611 if self.pending_invalid.is_some() {
612 return Err(CompletionError::ResponseError(
613 "streamed turn ingested while an invalid tool call awaits resolution".to_string(),
614 ));
615 }
616
617 match item {
618 StreamedAssistantContent::Text(text) => {
619 if !self.saw_text {
620 self.text.clear();
621 self.saw_text = true;
622 }
623 self.text.push_str(&text.text);
624 Ok(vec![StreamedTurnEvent::EmitIngested])
625 }
626 StreamedAssistantContent::Reasoning { reasoning, id } => {
627 self.ingest_completed_reasoning(reasoning, id);
628 Ok(vec![StreamedTurnEvent::EmitIngested])
629 }
630 StreamedAssistantContent::ReasoningDelta {
631 id,
632 reasoning,
633 provider_id,
634 } => {
635 let index = self
644 .reasoning_parts
645 .iter()
646 .position(|part| {
647 part.correlator.as_deref() == Some(id.as_str())
648 && matches!(part.state, ReasoningPartState::Pending(_))
649 })
650 .unwrap_or_else(|| {
651 self.reasoning_parts.push(ReasoningPart {
652 correlator: Some(id.clone()),
653 provider_id: None,
654 state: ReasoningPartState::Pending(String::new()),
655 });
656 self.reasoning_parts.len() - 1
657 });
658 if let Some(part) = self.reasoning_parts.get_mut(index) {
659 if let ReasoningPartState::Pending(text) = &mut part.state {
660 text.push_str(reasoning);
661 }
662 if part.provider_id.is_none() {
663 part.provider_id = provider_id.clone();
664 }
665 }
666 Ok(vec![StreamedTurnEvent::EmitIngested])
667 }
668 StreamedAssistantContent::ToolCall {
669 tool_call,
670 internal_call_id,
671 } => {
672 if !self.allowed_tool_names.contains(&tool_call.function.name) {
673 return Ok(self.surface_invalid_call(
674 tool_call.clone(),
675 internal_call_id.clone(),
676 Some(json_utils::serialize_json_value(
677 &tool_call.function.arguments,
678 )),
679 PendingInvalid::FullCall {
680 tool_call: Box::new(tool_call.clone()),
681 internal_call_id: internal_call_id.clone(),
682 },
683 ));
684 }
685
686 self.pending_tool_calls
687 .push((tool_call.clone(), internal_call_id.clone()));
688 Ok(Vec::new())
689 }
690 StreamedAssistantContent::ToolCallDelta {
691 internal_call_id,
692 content,
693 } => {
694 let key = internal_call_id.clone();
695 match content {
696 ToolCallDeltaContent::Name(name) => {
697 if !self.allowed_tool_names.contains(name) {
698 let buffered_args = self
699 .delta_states
700 .get(&key)
701 .map(|state| state.buffered_arguments.join(""))
702 .unwrap_or_default();
703 let tool_call =
704 self.name_delta_diagnostic_tool_call(name, &buffered_args);
705 return Ok(self.surface_invalid_call(
706 tool_call,
707 internal_call_id.clone(),
708 Some(buffered_args),
709 PendingInvalid::NameDelta {
710 internal_call_id: internal_call_id.clone(),
711 },
712 ));
713 }
714
715 Ok(self.validate_delta_name(&key, name.clone()))
716 }
717 ToolCallDeltaContent::Delta(arguments) => {
718 let state = self.delta_states.entry(key.clone()).or_default();
719 if state.name_validated {
720 Ok(vec![StreamedTurnEvent::EmitToolCallDelta {
721 internal_call_id: internal_call_id.clone(),
722 content: ToolCallDeltaContent::Delta(arguments.clone()),
723 }])
724 } else {
725 state.buffered_arguments.push(arguments.clone());
726 Ok(Vec::new())
727 }
728 }
729 }
730 }
731 StreamedAssistantContent::Final(final_response) => {
732 if let Some(err) = self.pending_delta_error() {
733 return Err(err);
734 }
735
736 let usage = final_response.usage;
737 let emit_final = self.saw_text;
738 self.saw_text = false;
739 let finish_reason = final_response.finish_reason.clone();
743 self.finish_reason = finish_reason.clone();
744 Ok(vec![StreamedTurnEvent::Completed {
745 usage,
746 emit_final,
747 finish_reason,
748 }])
749 }
750 StreamedAssistantContent::Unknown(payload) => {
751 if unknown_payload_loses_assistant_content(payload.value()) {
763 self.excluded_assistant_content.0 += 1;
764 tracing::debug!(
765 excluded = self.excluded_assistant_content.0,
766 "stream item is a replayed assistant block, not a \
767 stream-item shape; excluded from assembly"
768 );
769 }
770 Ok(vec![StreamedTurnEvent::EmitIngested])
771 }
772 }
773 }
774
775 pub fn resolve_pending_invalid(
780 &mut self,
781 resolution: &StreamedResolution,
782 ) -> Vec<StreamedTurnEvent> {
783 let Some(pending) = self.pending_invalid.take() else {
784 return Vec::new();
785 };
786
787 match (resolution, pending) {
788 (
789 StreamedResolution::Repaired { tool_name },
790 PendingInvalid::FullCall {
791 mut tool_call,
792 internal_call_id,
793 },
794 ) => {
795 tool_call.function.name = tool_name.clone();
796 self.pending_tool_calls.push((*tool_call, internal_call_id));
797 Vec::new()
798 }
799 (
800 StreamedResolution::Repaired { tool_name },
801 PendingInvalid::NameDelta { internal_call_id },
802 ) => self.validate_delta_name(&internal_call_id, tool_name.clone()),
803 (
804 StreamedResolution::TurnAbandoned { .. },
805 PendingInvalid::NameDelta { internal_call_id },
806 ) => {
807 self.delta_states.remove(&internal_call_id);
810 Vec::new()
811 }
812 (StreamedResolution::TurnAbandoned { .. }, PendingInvalid::FullCall { .. }) => {
813 Vec::new()
814 }
815 }
816 }
817
818 pub fn pending_delta_error(&self) -> Option<CompletionError> {
821 self.delta_states
822 .iter()
823 .find(|(_, state)| !state.name_validated && !state.buffered_arguments.is_empty())
824 .map(|(internal_call_id, state)| {
825 CompletionError::ResponseError(format!(
826 "streamed tool call arguments received before a validated tool name for internal_call_id `{internal_call_id}` ({} buffered argument delta(s))",
827 state.buffered_arguments.len()
828 ))
829 })
830 }
831
832 pub fn partial_turn(&self, message_id: Option<String>) -> PartialStreamedTurn {
834 let reasoning = self.assembled_reasoning();
835
836 PartialStreamedTurn {
837 message_id,
838 text: self.saw_text.then(|| self.text.clone()),
839 reasoning,
840 pending_tool_calls: self
841 .pending_tool_calls
842 .iter()
843 .map(|(tool_call, _)| tool_call.clone())
844 .collect(),
845 }
846 }
847
848 pub fn finish(
852 mut self,
853 message_id: Option<String>,
854 final_choice: &[AssistantContent],
855 ) -> StreamedTurn {
856 let reasoning = self.drain_reasoning();
857 let choice = self.canonical_choice_with(reasoning, final_choice);
858 let internal_call_ids: Vec<(String, String)> = self
859 .pending_tool_calls
860 .iter()
861 .map(|(tool_call, internal_call_id)| {
862 (tool_call.id.as_str().to_owned(), internal_call_id.clone())
863 })
864 .collect();
865
866 StreamedTurn {
867 message_id,
868 choice,
869 executable_tool_names: self.executable_tool_names,
870 allowed_tool_names: self.allowed_tool_names,
871 internal_call_ids,
872 finish_reason: self.finish_reason.take(),
873 }
874 }
875
876 fn surface_invalid_call(
879 &mut self,
880 tool_call: ToolCall,
881 internal_call_id: String,
882 args: Option<String>,
883 pending: PendingInvalid,
884 ) -> Vec<StreamedTurnEvent> {
885 let invalid = StreamedInvalidToolCall {
886 tool_call,
887 internal_call_id,
888 args,
889 executable_tool_names: self.executable_tool_names.clone(),
890 allowed_tool_names: self.allowed_tool_names.clone(),
891 };
892 self.pending_invalid = Some(pending);
893 vec![StreamedTurnEvent::InvalidToolCall(Box::new(invalid))]
894 }
895
896 fn name_delta_diagnostic_tool_call(&self, name: &str, buffered_args: &str) -> ToolCall {
897 let diagnostic_args = if buffered_args.trim().is_empty() {
898 serde_json::Value::Null
899 } else {
900 serde_json::from_str(buffered_args).unwrap_or(serde_json::Value::Null)
901 };
902 ToolCall::new(
908 rig_core::message::ToolCallId::mint(),
909 ToolFunction::new(name.to_string(), diagnostic_args),
910 )
911 }
912
913 fn validate_delta_name(&mut self, key: &str, name: String) -> Vec<StreamedTurnEvent> {
914 let state = self.delta_states.entry(key.to_owned()).or_default();
915 state.name_validated = true;
916 let buffered_arguments = std::mem::take(&mut state.buffered_arguments);
917
918 let mut events = vec![StreamedTurnEvent::EmitToolCallDelta {
919 internal_call_id: key.to_owned(),
920 content: ToolCallDeltaContent::Name(name),
921 }];
922 events.extend(buffered_arguments.into_iter().map(|arguments| {
923 StreamedTurnEvent::EmitToolCallDelta {
924 internal_call_id: key.to_owned(),
925 content: ToolCallDeltaContent::Delta(arguments),
926 }
927 }));
928 events
929 }
930}
931
932#[cfg(test)]
933mod tests {
934 use super::*;
935 use crate::agent::hook::InvalidToolCallAction;
936 use crate::agent::run::{AgentRun, AgentRunStep};
937 use crate::completion::PromptError;
938 use crate::test_utils::mock_final;
939 use rig_core::message::{Text, ToolResultContent, UserContent};
940 use serde_json::json;
941
942 fn tool_names(names: &[&str]) -> BTreeSet<String> {
943 names.iter().map(|name| (*name).to_string()).collect()
944 }
945
946 fn assembler() -> StreamedTurnAssembler {
947 StreamedTurnAssembler::new(tool_names(&["add"]), tool_names(&["add"]))
948 }
949
950 fn text_item(text: &str) -> StreamedAssistantContent {
951 StreamedAssistantContent::Text(Text::new(text.to_string()))
952 }
953
954 fn tool_call(id: &str, name: &str) -> ToolCall {
955 ToolCall::from_wire(id, ToolFunction::new(name.to_string(), json!({"x": 1})))
958 }
959
960 fn tool_call_item(id: &str, name: &str) -> StreamedAssistantContent {
961 StreamedAssistantContent::ToolCall {
962 tool_call: tool_call(id, name),
963 internal_call_id: format!("internal_{id}"),
964 }
965 }
966
967 fn final_item() -> StreamedAssistantContent {
968 StreamedAssistantContent::Final(mock_final(Usage::new()))
969 }
970
971 fn name_delta(id: &str, name: &str) -> StreamedAssistantContent {
972 StreamedAssistantContent::ToolCallDelta {
973 internal_call_id: format!("internal_{id}"),
974 content: ToolCallDeltaContent::Name(name.to_string()),
975 }
976 }
977
978 fn args_delta(id: &str, arguments: &str) -> StreamedAssistantContent {
979 StreamedAssistantContent::ToolCallDelta {
980 internal_call_id: format!("internal_{id}"),
981 content: ToolCallDeltaContent::Delta(arguments.to_string()),
982 }
983 }
984
985 fn expect_invalid(events: Vec<StreamedTurnEvent>) -> StreamedInvalidToolCall {
986 match events.into_iter().next() {
987 Some(StreamedTurnEvent::InvalidToolCall(invalid)) => *invalid,
988 other => panic!("expected InvalidToolCall, got {other:?}"),
989 }
990 }
991
992 #[test]
993 fn text_accumulates_and_emits() {
994 let mut asm = assembler();
995 let events = asm
996 .ingest(&text_item("hel"))
997 .expect("ingest should succeed");
998 assert!(matches!(
999 events.as_slice(),
1000 [StreamedTurnEvent::EmitIngested]
1001 ));
1002 asm.ingest(&text_item("lo")).expect("ingest should succeed");
1003 assert_eq!(asm.aggregated_text(), "hello");
1004 }
1005
1006 #[test]
1007 fn unknown_item_emits_to_consumer_without_touching_accumulation() {
1008 let mut asm = assembler();
1009 asm.ingest(&text_item("answer"))
1010 .expect("ingest text should succeed");
1011
1012 let events = asm
1013 .ingest(&StreamedAssistantContent::Unknown(
1014 json!({ "type": "web_search_call", "id": "ws_1" }).into(),
1015 ))
1016 .expect("ingest unknown should succeed");
1017
1018 assert!(matches!(
1020 events.as_slice(),
1021 [StreamedTurnEvent::EmitIngested]
1022 ));
1023 assert_eq!(asm.aggregated_text(), "answer");
1025 }
1026
1027 #[derive(Debug, Clone, Copy, PartialEq)]
1035 enum ShapeClass {
1036 WellFormedText,
1037 UnknownKeyedText,
1038 TaggedText,
1039 TaggedRigBlock,
1040 MalformedParamsText,
1041 ProviderNativeTextCarrying,
1046 ProviderNativeUnmodeled,
1047 }
1048
1049 #[derive(Debug, PartialEq)]
1050 enum ExpectedOutcome {
1051 Assembled { text: &'static str },
1052 ExcludedAndCounted,
1053 ExcludedQuiet,
1054 }
1055
1056 fn expected(shape: ShapeClass) -> ExpectedOutcome {
1059 match shape {
1060 ShapeClass::WellFormedText
1061 | ShapeClass::UnknownKeyedText
1062 | ShapeClass::TaggedText
1063 | ShapeClass::ProviderNativeTextCarrying => ExpectedOutcome::Assembled { text: "hi" },
1064 ShapeClass::TaggedRigBlock | ShapeClass::MalformedParamsText => {
1065 ExpectedOutcome::ExcludedAndCounted
1066 }
1067 ShapeClass::ProviderNativeUnmodeled => ExpectedOutcome::ExcludedQuiet,
1068 }
1069 }
1070
1071 fn decode_matrix_cases() -> Vec<(ShapeClass, serde_json::Value)> {
1075 vec![
1076 (ShapeClass::WellFormedText, json!({"text": "hi"})),
1077 (
1078 ShapeClass::UnknownKeyedText,
1079 json!({"text": "hi", "citations": ["stray"], "future": 1}),
1080 ),
1081 (
1082 ShapeClass::TaggedText,
1083 json!({"type": "text", "text": "hi"}),
1084 ),
1085 (
1086 ShapeClass::TaggedRigBlock,
1087 json!({"type": "toolcall", "id": "call_1",
1088 "function": {"name": "add", "arguments": {}}}),
1089 ),
1090 (
1091 ShapeClass::TaggedRigBlock,
1092 json!({"type": "reasoning", "id": null, "content": []}),
1093 ),
1094 (
1095 ShapeClass::TaggedRigBlock,
1096 json!({"type": "image", "data": {"type": "base64", "value": "aGk="}}),
1097 ),
1098 (
1099 ShapeClass::MalformedParamsText,
1100 json!({"text": "hi", "additional_params": []}),
1101 ),
1102 (
1103 ShapeClass::MalformedParamsText,
1104 json!({"type": "text", "text": "hi", "additional_params": []}),
1105 ),
1106 (
1107 ShapeClass::ProviderNativeUnmodeled,
1108 json!({"type": "web_search_call", "id": "ws_1"}),
1109 ),
1110 (
1111 ShapeClass::ProviderNativeTextCarrying,
1112 json!({"type": "output_text.annotation", "text": "hi"}),
1113 ),
1114 (ShapeClass::ProviderNativeUnmodeled, json!({"text": 42})),
1115 ]
1116 }
1117
1118 #[test]
1119 fn decode_outcome_matrix_is_total_and_no_shape_is_silent() {
1120 let cases = decode_matrix_cases();
1121 assert!(!cases.is_empty(), "decode_matrix_cases returned no rows");
1124 let witnesses = [
1128 ShapeClass::WellFormedText,
1129 ShapeClass::UnknownKeyedText,
1130 ShapeClass::TaggedText,
1131 ShapeClass::TaggedRigBlock,
1132 ShapeClass::MalformedParamsText,
1133 ShapeClass::ProviderNativeTextCarrying,
1134 ShapeClass::ProviderNativeUnmodeled,
1135 ];
1136 for shape in witnesses {
1137 assert!(
1138 cases.iter().any(|(case_shape, _)| *case_shape == shape),
1139 "no fixture for {shape:?} — add a row to decode_matrix_cases"
1140 );
1141 }
1142
1143 for (shape, payload) in cases {
1144 let item = serde_json::from_value::<StreamedAssistantContent>(payload.clone())
1145 .expect("stream-item decode is tolerant and must not fail");
1146 let mut asm = assembler();
1147 match expected(shape) {
1148 ExpectedOutcome::Assembled { text } => {
1149 assert!(
1150 matches!(&item, StreamedAssistantContent::Text(t) if t.text == text),
1151 "{shape:?} must decode as stream text: {payload}"
1152 );
1153 asm.ingest(&item).expect("ingest");
1154 assert_eq!(asm.aggregated_text(), text, "{shape:?}: {payload}");
1155 assert_eq!(
1156 asm.excluded_assistant_content(),
1157 0,
1158 "{shape:?} must not count as excluded: {payload}"
1159 );
1160 }
1161 ExpectedOutcome::ExcludedAndCounted => {
1162 assert!(
1163 matches!(&item, StreamedAssistantContent::Unknown(_)),
1164 "{shape:?} must decode Unknown: {payload}"
1165 );
1166 asm.ingest(&item).expect("ingest");
1167 assert_eq!(asm.aggregated_text(), "", "{shape:?}: {payload}");
1168 assert_eq!(
1169 asm.excluded_assistant_content(),
1170 1,
1171 "{shape:?} loses assistant content and must be counted: {payload}"
1172 );
1173 }
1174 ExpectedOutcome::ExcludedQuiet => {
1175 assert!(
1176 matches!(&item, StreamedAssistantContent::Unknown(_)),
1177 "{shape:?} must decode Unknown: {payload}"
1178 );
1179 asm.ingest(&item).expect("ingest");
1180 assert_eq!(asm.aggregated_text(), "", "{shape:?}: {payload}");
1181 assert_eq!(
1182 asm.excluded_assistant_content(),
1183 0,
1184 "{shape:?} is provider-native and must stay quiet: {payload}"
1185 );
1186 }
1187 }
1188 }
1189 }
1190 #[test]
1191 fn choice_text_items_judge_annotation_by_presence() {
1192 let unannotated = AssistantContent::Text(Text {
1197 text: String::new(),
1198 additional_params: rig_core::message::AdditionalParams::try_from_value(json!({}))
1199 .expect("object params"),
1200 });
1201 assert!(assistant_text_items_from_choice(&[unannotated]).is_empty());
1202
1203 let annotated = AssistantContent::Text(Text {
1205 text: String::new(),
1206 additional_params: rig_core::message::AdditionalParams::try_from_value(
1207 json!({"citations": [1]}),
1208 )
1209 .expect("object params"),
1210 });
1211 assert_eq!(assistant_text_items_from_choice(&[annotated]).len(), 1);
1212 }
1213
1214 #[test]
1215 fn argument_deltas_buffer_until_name_validates() {
1216 let mut asm = assembler();
1217
1218 let events = asm
1219 .ingest(&args_delta("tc_1", "{\"x\""))
1220 .expect("ingest should succeed");
1221 assert!(events.is_empty(), "arguments must buffer before the name");
1222
1223 let events = asm
1224 .ingest(&name_delta("tc_1", "add"))
1225 .expect("ingest should succeed");
1226 let contents: Vec<_> = events
1227 .iter()
1228 .map(|event| match event {
1229 StreamedTurnEvent::EmitToolCallDelta { content, .. } => content.clone(),
1230 other => panic!("expected EmitToolCallDelta, got {other:?}"),
1231 })
1232 .collect();
1233 assert_eq!(
1234 contents,
1235 vec![
1236 ToolCallDeltaContent::Name("add".to_string()),
1237 ToolCallDeltaContent::Delta("{\"x\"".to_string()),
1238 ]
1239 );
1240
1241 let events = asm
1243 .ingest(&args_delta("tc_1", ":1}"))
1244 .expect("ingest should succeed");
1245 assert_eq!(events.len(), 1);
1246 }
1247
1248 #[test]
1249 fn buffered_arguments_without_validated_name_error_at_final() {
1250 let mut asm = assembler();
1251 asm.ingest(&args_delta("tc_1", "{\"x\":1}"))
1252 .expect("ingest should succeed");
1253
1254 assert!(asm.pending_delta_error().is_some());
1255 assert!(asm.ingest(&final_item()).is_err());
1256 }
1257
1258 #[test]
1259 fn finish_orders_reasoning_text_then_tool_calls() {
1260 let mut asm = assembler();
1261 asm.ingest(&StreamedAssistantContent::ReasoningDelta {
1262 id: "corr_1".to_string(),
1263 provider_id: Some("rs_1".to_string()),
1264 reasoning: "think".to_string(),
1265 })
1266 .expect("ingest should succeed");
1267 asm.ingest(&tool_call_item("tc_1", "add"))
1268 .expect("ingest should succeed");
1269
1270 let final_choice = vec![
1272 AssistantContent::text("answer"),
1273 AssistantContent::ToolCall(tool_call("tc_1", "add")),
1274 ];
1275
1276 let turn = asm.finish(Some("msg_1".to_string()), &final_choice);
1277 let kinds: Vec<&'static str> = turn
1278 .choice
1279 .iter()
1280 .map(|item| match item {
1281 AssistantContent::Reasoning(_) => "reasoning",
1282 AssistantContent::Text(_) => "text",
1283 AssistantContent::ToolCall(_) => "tool_call",
1284 _ => "other",
1285 })
1286 .collect();
1287 assert_eq!(kinds, vec!["reasoning", "text", "tool_call"]);
1288 }
1289
1290 fn reasoning_delta(
1291 correlator: &str,
1292 provider_id: Option<&str>,
1293 text: &str,
1294 ) -> StreamedAssistantContent {
1295 StreamedAssistantContent::ReasoningDelta {
1296 id: correlator.to_string(),
1297 provider_id: provider_id.map(str::to_string),
1298 reasoning: text.to_string(),
1299 }
1300 }
1301
1302 fn completed_reasoning(
1303 correlator: &str,
1304 provider_id: Option<&str>,
1305 text: &str,
1306 signature: Option<&str>,
1307 ) -> StreamedAssistantContent {
1308 let mut reasoning = Reasoning::new_with_signature(text, signature.map(str::to_string));
1309 if let Some(provider_id) = provider_id {
1310 reasoning = reasoning.with_id(provider_id.to_string());
1311 }
1312 StreamedAssistantContent::Reasoning {
1313 reasoning,
1314 id: correlator.to_string(),
1315 }
1316 }
1317
1318 fn assembled_reasoning_of(asm: &StreamedTurnAssembler) -> Vec<Reasoning> {
1319 asm.partial_turn(None).reasoning
1320 }
1321
1322 #[test]
1323 fn aggregated_reasoning_delta_is_scoped_to_each_interleaved_part() {
1324 let mut asm = assembler();
1325 asm.ingest(&reasoning_delta("corr_a", None, "first "))
1326 .expect("ingest");
1327 assert_eq!(asm.aggregated_reasoning("corr_a"), Some("first "));
1328
1329 asm.ingest(&reasoning_delta("corr_b", Some("rs_b"), "second"))
1330 .expect("ingest");
1331 assert_eq!(asm.aggregated_reasoning("corr_b"), Some("second"));
1332
1333 asm.ingest(&reasoning_delta("corr_a", Some("rs_a"), "part"))
1334 .expect("ingest");
1335 assert_eq!(asm.aggregated_reasoning("corr_a"), Some("first part"));
1336 assert_eq!(asm.aggregated_reasoning("corr_b"), Some("second"));
1337 assert_eq!(asm.aggregated_reasoning("missing"), None);
1338
1339 let reasoning = assembled_reasoning_of(&asm);
1340 assert_eq!(reasoning[0].id.as_deref(), Some("rs_a"));
1341 assert_eq!(reasoning[1].id.as_deref(), Some("rs_b"));
1342 }
1343
1344 #[test]
1345 fn aggregated_reasoning_delta_uses_a_new_pending_part_after_completion() {
1346 let mut asm = assembler();
1347 asm.ingest(&reasoning_delta("corr_a", Some("rs_a"), "old"))
1348 .expect("ingest");
1349 asm.ingest(&completed_reasoning(
1350 "corr_a",
1351 Some("rs_a"),
1352 "old",
1353 Some("sig"),
1354 ))
1355 .expect("ingest");
1356 assert_eq!(asm.aggregated_reasoning("corr_a"), None);
1357
1358 asm.ingest(&reasoning_delta("corr_a", Some("rs_new"), "new"))
1359 .expect("ingest");
1360 assert_eq!(asm.aggregated_reasoning("corr_a"), Some("new"));
1361 }
1362
1363 #[test]
1364 fn interleaved_delta_parts_stay_distinct_in_arrival_order() {
1365 let mut asm = assembler();
1366 asm.ingest(&reasoning_delta("corr_a", None, "first "))
1367 .expect("ingest");
1368 asm.ingest(&reasoning_delta("corr_a", None, "part"))
1369 .expect("ingest");
1370 asm.ingest(&tool_call_item("tc_1", "add")).expect("ingest");
1371 asm.ingest(&reasoning_delta("corr_b", None, "second part"))
1372 .expect("ingest");
1373
1374 let reasoning = assembled_reasoning_of(&asm);
1375 assert_eq!(
1376 reasoning.len(),
1377 2,
1378 "two parts must not merge: {reasoning:?}"
1379 );
1380 assert!(matches!(
1381 reasoning[0].content.first(),
1382 Some(rig_core::message::ReasoningContent::Text { text, .. }) if text == "first part"
1383 ));
1384 assert!(matches!(
1385 reasoning[1].content.first(),
1386 Some(rig_core::message::ReasoningContent::Text { text, .. }) if text == "second part"
1387 ));
1388 }
1389
1390 #[test]
1391 fn delta_only_part_survives_alongside_a_completed_block() {
1392 let mut asm = assembler();
1396 asm.ingest(&reasoning_delta("corr_cot", None, "visible thoughts"))
1397 .expect("ingest");
1398 asm.ingest(&completed_reasoning(
1399 "corr_enc",
1400 Some("rd_1"),
1401 "encrypted payload",
1402 Some("sig"),
1403 ))
1404 .expect("ingest");
1405
1406 let reasoning = assembled_reasoning_of(&asm);
1407 assert_eq!(
1408 reasoning.len(),
1409 2,
1410 "the visible chain of thought must not be dropped: {reasoning:?}"
1411 );
1412 assert!(matches!(
1413 reasoning[0].content.first(),
1414 Some(rig_core::message::ReasoningContent::Text { text, .. })
1415 if text == "visible thoughts"
1416 ));
1417 assert_eq!(reasoning[0].id, None);
1418 assert_eq!(reasoning[1].id.as_deref(), Some("rd_1"));
1419 }
1420
1421 #[test]
1425 fn a_same_correlator_completion_replaces_the_completed_part() {
1426 let mut asm = assembler();
1427 asm.ingest(&reasoning_delta("corr_a", None, "think"))
1428 .expect("ingest");
1429 asm.ingest(&completed_reasoning("corr_a", None, "think", None))
1430 .expect("ingest");
1431 asm.ingest(&completed_reasoning("corr_a", None, "think", Some("sig")))
1432 .expect("ingest");
1433
1434 let reasoning = assembled_reasoning_of(&asm);
1435 assert_eq!(
1436 reasoning.len(),
1437 1,
1438 "one part per correlator, signed restatement replaces: {reasoning:?}"
1439 );
1440 assert!(matches!(
1441 reasoning[0].content.first(),
1442 Some(rig_core::message::ReasoningContent::Text { text, signature: Some(sig) })
1443 if text == "think" && sig == "sig"
1444 ));
1445 }
1446
1447 #[test]
1451 fn a_same_correlator_completion_with_a_provider_id_does_not_double_extend() {
1452 let mut asm = assembler();
1453 asm.ingest(&reasoning_delta("corr_a", Some("rs_1"), "think"))
1454 .expect("ingest");
1455 asm.ingest(&completed_reasoning("corr_a", Some("rs_1"), "think", None))
1456 .expect("ingest");
1457 asm.ingest(&completed_reasoning(
1458 "corr_a",
1459 Some("rs_1"),
1460 "think",
1461 Some("sig"),
1462 ))
1463 .expect("ingest");
1464
1465 let reasoning = assembled_reasoning_of(&asm);
1466 assert_eq!(reasoning.len(), 1, "{reasoning:?}");
1467 assert_eq!(
1468 reasoning[0].content.len(),
1469 1,
1470 "the restatement must replace, not extend: {reasoning:?}"
1471 );
1472 }
1473
1474 #[test]
1475 fn completed_block_supersedes_its_deltas_by_correlator() {
1476 let mut asm = assembler();
1477 asm.ingest(&reasoning_delta("corr_a", None, "streamed text"))
1478 .expect("ingest");
1479 asm.ingest(&completed_reasoning(
1480 "corr_a",
1481 None,
1482 "streamed text",
1483 Some("sig_1"),
1484 ))
1485 .expect("ingest");
1486
1487 let reasoning = assembled_reasoning_of(&asm);
1488 assert_eq!(
1489 reasoning.len(),
1490 1,
1491 "the completed block replaces its own deltas: {reasoning:?}"
1492 );
1493 assert!(matches!(
1494 reasoning[0].content.first(),
1495 Some(rig_core::message::ReasoningContent::Text { text, signature: Some(sig) })
1496 if text == "streamed text" && sig == "sig_1"
1497 ));
1498 }
1499
1500 #[test]
1501 fn completed_block_supersedes_its_deltas_by_provider_id() {
1502 let mut asm = assembler();
1503 asm.ingest(&reasoning_delta("corr_a", Some("rs_1"), "streamed text"))
1504 .expect("ingest");
1505 asm.ingest(&completed_reasoning(
1509 "corr_other",
1510 Some("rs_1"),
1511 "restated text",
1512 None,
1513 ))
1514 .expect("ingest");
1515
1516 let reasoning = assembled_reasoning_of(&asm);
1517 assert_eq!(reasoning.len(), 1, "{reasoning:?}");
1518 assert!(matches!(
1519 reasoning[0].content.first(),
1520 Some(rig_core::message::ReasoningContent::Text { text, .. }) if text == "restated text"
1521 ));
1522 }
1523
1524 #[test]
1525 fn completed_blocks_sharing_a_provider_id_extend_one_part() {
1526 let mut asm = assembler();
1527 asm.ingest(&completed_reasoning(
1528 "corr_1",
1529 Some("rs_1"),
1530 "step-1",
1531 Some("sig-1"),
1532 ))
1533 .expect("ingest");
1534 asm.ingest(&completed_reasoning(
1535 "corr_2",
1536 Some("rs_1"),
1537 "step-2",
1538 Some("sig-2"),
1539 ))
1540 .expect("ingest");
1541 asm.ingest(&completed_reasoning("corr_3", Some("rs_2"), "other", None))
1542 .expect("ingest");
1543
1544 let reasoning = assembled_reasoning_of(&asm);
1545 assert_eq!(reasoning.len(), 2, "{reasoning:?}");
1546 assert_eq!(reasoning[0].id.as_deref(), Some("rs_1"));
1547 assert_eq!(reasoning[0].content.len(), 2);
1548 assert_eq!(reasoning[1].id.as_deref(), Some("rs_2"));
1549 }
1550
1551 #[test]
1552 fn completed_blocks_without_ids_stay_separate_parts() {
1553 let mut asm = assembler();
1554 asm.ingest(&completed_reasoning("corr_1", None, "first", None))
1555 .expect("ingest");
1556 asm.ingest(&completed_reasoning("corr_2", None, "second", None))
1557 .expect("ingest");
1558
1559 let reasoning = assembled_reasoning_of(&asm);
1560 assert_eq!(
1561 reasoning.len(),
1562 2,
1563 "id-less blocks never merge: {reasoning:?}"
1564 );
1565 }
1566
1567 #[test]
1568 fn each_delta_part_keeps_its_own_provider_id() {
1569 let mut asm = assembler();
1570 asm.ingest(&reasoning_delta("corr_a", Some("rs_a"), "alpha"))
1571 .expect("ingest");
1572 asm.ingest(&reasoning_delta("corr_b", Some("rs_b"), "beta"))
1573 .expect("ingest");
1574
1575 let reasoning = assembled_reasoning_of(&asm);
1576 assert_eq!(reasoning.len(), 2, "{reasoning:?}");
1577 assert_eq!(reasoning[0].id.as_deref(), Some("rs_a"));
1578 assert_eq!(reasoning[1].id.as_deref(), Some("rs_b"));
1579 }
1580
1581 #[test]
1582 fn canonical_choice_and_partial_turn_agree_on_multi_part_reasoning() {
1583 let mut asm = assembler();
1584 asm.ingest(&reasoning_delta("corr_a", None, "visible"))
1585 .expect("ingest");
1586 asm.ingest(&completed_reasoning(
1587 "corr_b",
1588 Some("rd_1"),
1589 "enc",
1590 Some("sig"),
1591 ))
1592 .expect("ingest");
1593
1594 let partial = asm.partial_turn(None).reasoning;
1595 let final_choice = vec![AssistantContent::text("")];
1596 let turn = asm.finish(None, &final_choice);
1597 let finished: Vec<Reasoning> = turn
1598 .choice
1599 .iter()
1600 .filter_map(|content| match content {
1601 AssistantContent::Reasoning(reasoning) => Some(reasoning.clone()),
1602 _ => None,
1603 })
1604 .collect();
1605 assert_eq!(partial, finished, "partial and finished assembly agree");
1606 assert_eq!(finished.len(), 2);
1607 }
1608
1609 #[test]
1610 fn finish_passes_raw_choice_through_for_plain_text_turns() {
1611 let mut asm = assembler();
1612 asm.ingest(&text_item("hi")).expect("ingest should succeed");
1613
1614 let final_choice = vec![AssistantContent::text("hi")];
1615 let turn = asm.finish(None, &final_choice);
1616 assert_eq!(
1617 serde_json::to_value(&turn.choice).expect("serialize"),
1618 serde_json::to_value(&final_choice).expect("serialize"),
1619 );
1620 }
1621
1622 #[test]
1623 fn streamed_run_completes_a_tool_roundtrip() {
1624 let mut run = AgentRun::new("add things").max_turns(2);
1625
1626 let AgentRunStep::CallModel { .. } = run.next_step().expect("next_step") else {
1628 panic!("expected CallModel");
1629 };
1630 let mut asm = assembler();
1631 assert!(
1632 asm.ingest(&tool_call_item("tc_1", "add"))
1633 .expect("ingest should succeed")
1634 .is_empty()
1635 );
1636 let usage = Usage {
1637 input_tokens: 5,
1638 output_tokens: 7,
1639 total_tokens: 12,
1640 ..Usage::new()
1641 };
1642 run.record_streamed_completion_call(
1643 usage,
1644 rig_core::completion::ResponseIdentity::default(),
1645 None,
1646 serde_json::Value::Null,
1647 )
1648 .expect("record should succeed");
1649 let final_choice = vec![AssistantContent::ToolCall(tool_call("tc_1", "add"))];
1650 run.streamed_turn(asm.finish(Some("msg_1".to_string()), &final_choice))
1651 .expect("streamed_turn should succeed");
1652
1653 let AgentRunStep::CallTools { calls } = run.next_step().expect("next_step") else {
1654 panic!("expected CallTools");
1655 };
1656 assert_eq!(calls.len(), 1);
1657 assert_eq!(calls[0].internal_call_id.as_deref(), Some("internal_tc_1"));
1658 run.tool_results(vec![UserContent::tool_result(
1659 "tc_1",
1660 "add",
1661 vec![ToolResultContent::text("2")],
1662 )])
1663 .expect("tool_results should succeed");
1664
1665 let AgentRunStep::CallModel { .. } = run.next_step().expect("next_step") else {
1667 panic!("expected CallModel");
1668 };
1669 let asm = assembler();
1670 run.record_streamed_completion_call(
1671 Usage::new(),
1672 rig_core::completion::ResponseIdentity::default(),
1673 None,
1674 serde_json::Value::Null,
1675 )
1676 .expect("record should succeed");
1677 let final_choice = vec![AssistantContent::text("done")];
1678 run.streamed_turn(asm.finish(None, &final_choice))
1679 .expect("streamed_turn should succeed");
1680
1681 let AgentRunStep::Done(response) = run.next_step().expect("next_step") else {
1682 panic!("expected Done");
1683 };
1684 assert_eq!(response.output, "done");
1685 assert_eq!(response.usage, usage);
1686 assert_eq!(response.completion_calls.len(), 2);
1687 assert_eq!(response.completion_calls[0].usage, usage);
1688 assert_eq!(response.completion_calls[1].usage, Usage::new());
1689 assert_eq!(
1691 response
1692 .messages
1693 .expect("messages should be recorded")
1694 .len(),
1695 4
1696 );
1697 }
1698
1699 #[test]
1700 fn streamed_invalid_tool_call_retry_rolls_back_with_partial_turn() {
1701 let mut run = AgentRun::new("use the tool")
1702 .max_turns(2)
1703 .max_invalid_tool_call_retries(1);
1704 run.next_step().expect("next_step");
1705
1706 let mut asm = assembler();
1707 asm.ingest(&text_item("thinking ")).expect("ingest");
1708 let invalid = expect_invalid(
1709 asm.ingest(&tool_call_item("tc_1", "default_api"))
1710 .expect("ingest should succeed"),
1711 );
1712 let partial = asm.partial_turn(Some("msg_1".to_string()));
1713 assert_eq!(partial.text.as_deref(), Some("thinking "));
1714
1715 let context = run.streamed_invalid_tool_call_context(&partial, &invalid);
1716 assert!(context.is_streaming);
1717 assert_eq!(context.tool_name, "default_api");
1718 assert_eq!(context.internal_call_id.as_deref(), Some("internal_tc_1"));
1719
1720 let resolution = run
1721 .resolve_streamed_invalid_tool_call(
1722 &partial,
1723 &invalid,
1724 InvalidToolCallAction::retry("use add instead"),
1725 )
1726 .expect("retry should be accepted");
1727 assert!(matches!(
1728 resolution,
1729 StreamedResolution::TurnAbandoned {
1730 skipped_tool_result: None
1731 }
1732 ));
1733 asm.resolve_pending_invalid(&resolution);
1734
1735 run.record_streamed_completion_call(
1737 Usage::new(),
1738 rig_core::completion::ResponseIdentity::default(),
1739 None,
1740 serde_json::Value::Null,
1741 )
1742 .expect("record after rollback should succeed");
1743
1744 assert_eq!(run.messages().len(), 3);
1746 let AgentRunStep::CallModel { turn, .. } = run.next_step().expect("next_step") else {
1747 panic!("expected CallModel retry");
1748 };
1749 assert_eq!(turn, 2);
1750 }
1751
1752 #[test]
1753 fn streamed_invalid_tool_call_stop_leaves_run_terminal() {
1754 let mut run = AgentRun::new("use the tool");
1755 run.next_step().expect("next_step");
1756
1757 let mut asm = assembler();
1758 let invalid = expect_invalid(
1759 asm.ingest(&tool_call_item("tc_1", "default_api"))
1760 .expect("ingest should succeed"),
1761 );
1762 let partial = asm.partial_turn(Some("msg_1".to_string()));
1763
1764 let err = run
1765 .resolve_streamed_invalid_tool_call(
1766 &partial,
1767 &invalid,
1768 InvalidToolCallAction::stop("operator stop"),
1769 )
1770 .expect_err("stop should cancel the run");
1771 assert!(matches!(
1772 err,
1773 PromptError::PromptCancelled { reason, .. } if reason == "operator stop"
1774 ));
1775
1776 let err = run
1777 .next_step()
1778 .expect_err("a stopped streamed run must remain terminal");
1779 assert!(matches!(
1780 err,
1781 PromptError::PromptCancelled { reason, .. }
1782 if reason.contains("next_step called after the run already failed")
1783 ));
1784 }
1785
1786 #[test]
1787 fn streamed_invalid_tool_call_retry_cannot_emit_call_past_total_budget() {
1788 let mut run = AgentRun::new("use the tool")
1789 .max_turns(1)
1790 .max_invalid_tool_call_retries(1);
1791 run.next_step().expect("initial model call");
1792
1793 let mut asm = assembler();
1794 let invalid = expect_invalid(
1795 asm.ingest(&tool_call_item("tc_1", "default_api"))
1796 .expect("ingest should succeed"),
1797 );
1798 let partial = asm.partial_turn(Some("msg_1".to_string()));
1799 let resolution = run
1800 .resolve_streamed_invalid_tool_call(
1801 &partial,
1802 &invalid,
1803 InvalidToolCallAction::retry("use add instead"),
1804 )
1805 .expect("retry resolution should be accepted");
1806 assert!(matches!(
1807 resolution,
1808 StreamedResolution::TurnAbandoned {
1809 skipped_tool_result: None
1810 }
1811 ));
1812 run.record_streamed_completion_call(
1813 Usage::new(),
1814 rig_core::completion::ResponseIdentity::default(),
1815 None,
1816 serde_json::Value::Null,
1817 )
1818 .expect("completion call should be recorded");
1819 assert_eq!(run.completion_calls().len(), 1);
1820
1821 let err = run
1822 .next_step()
1823 .expect_err("retry must not emit a second model call");
1824 assert!(matches!(
1825 err,
1826 PromptError::MaxTurnsError { max_turns: 1, .. }
1827 ));
1828 assert_eq!(run.turn(), 1);
1829 }
1830
1831 #[test]
1832 fn streamed_invalid_tool_call_skip_returns_synthetic_result() {
1833 let mut run = AgentRun::new("use the tool").max_turns(2);
1834 run.next_step().expect("next_step");
1835
1836 let mut asm = assembler();
1837 let invalid = expect_invalid(
1838 asm.ingest(&tool_call_item("tc_1", "default_api"))
1839 .expect("ingest should succeed"),
1840 );
1841 let partial = asm.partial_turn(None);
1842
1843 let resolution = run
1844 .resolve_streamed_invalid_tool_call(
1845 &partial,
1846 &invalid,
1847 InvalidToolCallAction::skip("not available"),
1848 )
1849 .expect("skip should be accepted");
1850 let StreamedResolution::TurnAbandoned {
1851 skipped_tool_result: Some(tool_result),
1852 } = &resolution
1853 else {
1854 panic!("expected skipped tool result");
1855 };
1856 assert_eq!(tool_result.call, "tc_1");
1857 }
1858
1859 #[test]
1860 fn streamed_invalid_name_delta_repair_replays_buffered_arguments() {
1861 let mut run = AgentRun::new("use the tool").max_turns(2);
1862 run.next_step().expect("next_step");
1863
1864 let mut asm = assembler();
1865 asm.ingest(&args_delta("tc_1", "{\"x\":1}"))
1866 .expect("ingest should succeed");
1867 let invalid = expect_invalid(
1868 asm.ingest(&name_delta("tc_1", "default_api"))
1869 .expect("ingest should succeed"),
1870 );
1871 assert_eq!(invalid.args.as_deref(), Some("{\"x\":1}"));
1872
1873 let partial = asm.partial_turn(None);
1874 let resolution = run
1875 .resolve_streamed_invalid_tool_call(
1876 &partial,
1877 &invalid,
1878 InvalidToolCallAction::repair("add"),
1879 )
1880 .expect("repair should be accepted");
1881 assert!(matches!(
1882 resolution,
1883 StreamedResolution::Repaired { ref tool_name } if tool_name == "add"
1884 ));
1885
1886 let events = asm.resolve_pending_invalid(&resolution);
1887 let contents: Vec<_> = events
1888 .iter()
1889 .map(|event| match event {
1890 StreamedTurnEvent::EmitToolCallDelta { content, .. } => content.clone(),
1891 other => panic!("expected EmitToolCallDelta, got {other:?}"),
1892 })
1893 .collect();
1894 assert_eq!(
1895 contents,
1896 vec![
1897 ToolCallDeltaContent::Name("add".to_string()),
1898 ToolCallDeltaContent::Delta("{\"x\":1}".to_string()),
1899 ]
1900 );
1901 }
1902
1903 #[test]
1904 fn streamed_turn_rejects_unknown_tool_calls_fail_fast() {
1905 let mut run = AgentRun::new("use the tool");
1906 run.next_step().expect("next_step");
1907
1908 let turn = StreamedTurn {
1909 message_id: None,
1910 choice: vec![AssistantContent::ToolCall(tool_call("tc_1", "unknown"))],
1911 executable_tool_names: tool_names(&["add"]),
1912 allowed_tool_names: tool_names(&["add"]),
1913 internal_call_ids: Vec::new(),
1914 finish_reason: None,
1915 };
1916 let err = run
1917 .streamed_turn(turn)
1918 .expect_err("unknown tool should fail fast");
1919 assert!(matches!(
1920 err,
1921 PromptError::UnknownToolCall { tool_name, .. } if tool_name == "unknown"
1922 ));
1923 }
1924
1925 #[test]
1926 fn streamed_completion_call_record_requires_a_model_call() {
1927 let mut run = AgentRun::new("hello");
1930 let err = run
1931 .record_streamed_completion_call(
1932 Usage::new(),
1933 rig_core::completion::ResponseIdentity::default(),
1934 None,
1935 serde_json::Value::Null,
1936 )
1937 .expect_err("recording before any model call must be rejected");
1938 assert!(matches!(err, PromptError::PromptCancelled { .. }));
1939
1940 run.next_step().expect("next_step should still succeed");
1942 run.record_streamed_completion_call(
1943 Usage::new(),
1944 rig_core::completion::ResponseIdentity::default(),
1945 None,
1946 serde_json::Value::Null,
1947 )
1948 .expect("recording during a pending model call succeeds");
1949 }
1950
1951 #[test]
1952 fn duplicate_tool_call_ids_keep_distinct_internal_ids_through_the_run() {
1953 let mut run = AgentRun::new("do both").max_turns(2);
1954 run.next_step().expect("next_step");
1955
1956 let mut asm = assembler();
1957 asm.ingest(&StreamedAssistantContent::ToolCall {
1958 tool_call: tool_call("tc_1", "add"),
1959 internal_call_id: "internal_a".to_string(),
1960 })
1961 .expect("ingest should succeed");
1962 asm.ingest(&StreamedAssistantContent::ToolCall {
1963 tool_call: tool_call("tc_1", "add"),
1964 internal_call_id: "internal_b".to_string(),
1965 })
1966 .expect("ingest should succeed");
1967 run.record_streamed_completion_call(
1968 Usage::new(),
1969 rig_core::completion::ResponseIdentity::default(),
1970 None,
1971 serde_json::Value::Null,
1972 )
1973 .expect("record should succeed");
1974
1975 let final_choice = vec![
1976 AssistantContent::ToolCall(tool_call("tc_1", "add")),
1977 AssistantContent::ToolCall(tool_call("tc_1", "add")),
1978 ];
1979 run.streamed_turn(asm.finish(None, &final_choice))
1980 .expect("streamed_turn should succeed");
1981
1982 let serialized = serde_json::to_string(&run).expect("serialize");
1985 let mut restored: AgentRun = serde_json::from_str(&serialized).expect("deserialize");
1986 let AgentRunStep::CallTools { calls } = restored.next_step().expect("next_step") else {
1987 panic!("expected CallTools");
1988 };
1989 assert_eq!(calls.len(), 2);
1990 assert_eq!(calls[0].internal_call_id.as_deref(), Some("internal_a"));
1991 assert_eq!(calls[1].internal_call_id.as_deref(), Some("internal_b"));
1992 }
1993
1994 #[test]
1995 fn streamed_turn_records_the_completion_call_when_the_driver_did_not() {
1996 let mut run = AgentRun::new("hello");
1997 run.next_step().expect("next_step");
1998
1999 let asm = assembler();
2000 let final_choice = vec![AssistantContent::text("done")];
2001 run.streamed_turn(asm.finish(None, &final_choice))
2002 .expect("streamed_turn should succeed");
2003
2004 assert_eq!(run.completion_calls().len(), 1);
2007 assert_eq!(run.completion_calls()[0].usage, Usage::new());
2008 }
2009
2010 #[test]
2011 fn streamed_completion_call_is_recorded_once_per_turn() {
2012 let mut run = AgentRun::new("hello");
2013 run.next_step().expect("next_step");
2014
2015 run.record_streamed_completion_call(
2016 Usage::new(),
2017 rig_core::completion::ResponseIdentity::default(),
2018 None,
2019 serde_json::Value::Null,
2020 )
2021 .expect("first record succeeds");
2022 let err = run
2023 .record_streamed_completion_call(
2024 Usage::new(),
2025 rig_core::completion::ResponseIdentity::default(),
2026 None,
2027 serde_json::Value::Null,
2028 )
2029 .expect_err("second record for the same turn must be rejected");
2030 assert!(matches!(err, PromptError::PromptCancelled { .. }));
2031 assert_eq!(run.completion_calls().len(), 1);
2032 }
2033
2034 #[test]
2035 fn streamed_run_serde_round_trips_while_tools_pend() {
2036 let mut run = AgentRun::new("add things").max_turns(2);
2037 run.next_step().expect("next_step");
2038
2039 let mut asm = assembler();
2040 asm.ingest(&tool_call_item("tc_1", "add"))
2041 .expect("ingest should succeed");
2042 run.record_streamed_completion_call(
2043 Usage::new(),
2044 rig_core::completion::ResponseIdentity::default(),
2045 None,
2046 serde_json::Value::Null,
2047 )
2048 .expect("record should succeed");
2049 let final_choice = vec![AssistantContent::ToolCall(tool_call("tc_1", "add"))];
2050 run.streamed_turn(asm.finish(None, &final_choice))
2051 .expect("streamed_turn should succeed");
2052 run.next_step().expect("CallTools step");
2053
2054 let serialized = serde_json::to_string(&run).expect("serialize mid-run");
2055 let mut restored: AgentRun =
2056 serde_json::from_str(&serialized).expect("deserialize mid-run");
2057 restored
2058 .tool_results(vec![UserContent::tool_result(
2059 "tc_1",
2060 "add",
2061 vec![ToolResultContent::text("2")],
2062 )])
2063 .expect("tool_results should succeed");
2064 assert!(matches!(
2065 restored.next_step().expect("next turn"),
2066 AgentRunStep::CallModel { turn: 2, .. }
2067 ));
2068 }
2069}