1use crate::core::agent::events::{EventSink, SharedLifecycleEmitter};
2use crate::core::agent::session::AgentSessionState;
3use crate::core::agent::steering::{
4 FollowUpQueueFull, MAX_APPLIED_FOLLOW_UP_INTENT_IDS, MAX_OVERFLOW_FOLLOW_UP_INTENTS, MAX_QUEUED_FOLLOW_UP_INTENTS,
5 QueuedFollowUpIntent, SteeringMessage,
6};
7use crate::exec::events::{ThreadEvent, ToolCallStatus, ToolOutcome};
8use crate::llm::provider::{
9 AssistantPhase, FinishReason, LLMProvider, LLMRequest, LLMResponse, NormalizedStreamEvent, ToolCall,
10 Usage as ProviderUsage,
11};
12use crate::llm::providers::gemini::wire::{Content, Part};
13use anyhow::Result;
14use async_trait::async_trait;
15use futures::StreamExt;
16use std::collections::HashSet;
17use std::collections::VecDeque;
18use std::time::Instant;
19use tokio::sync::mpsc::UnboundedReceiver;
20use tokio::sync::mpsc::error::TryRecvError;
21
22fn merge_stream_and_completed_text(accumulated: &mut String, completed: Option<&str>) {
23 let Some(completed_text) = completed else {
24 return;
25 };
26 if completed_text.is_empty() {
27 return;
28 }
29 if accumulated.is_empty() {
30 accumulated.push_str(completed_text);
31 return;
32 }
33 if completed_text == accumulated.as_str() {
34 return;
35 }
36 if let Some(suffix) = completed_text.strip_prefix(accumulated.as_str()) {
37 accumulated.push_str(suffix);
38 return;
39 }
40 accumulated.clear();
41 accumulated.push_str(completed_text);
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum RuntimeControl {
47 Continue,
49 Resumed,
51 StopRequested,
53}
54
55#[doc(hidden)]
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum RuntimeModelProgress {
59 OutputDelta(String),
61 ReasoningDelta(String),
63 ReasoningStage(String),
65 ProviderActivity,
67 ToolCallStarted {
69 call_id: String,
71 name: Option<String>,
73 },
74 ToolCallDelta {
76 call_id: String,
78 delta: String,
80 },
81}
82
83#[derive(Debug, Clone)]
84struct RuntimeModelOutput {
85 response: LLMResponse,
86}
87
88#[async_trait]
89trait RuntimeModelAdapter {
90 async fn execute(
91 &mut self,
92 request: LLMRequest,
93 timeout: Option<std::time::Duration>,
94 on_progress: &mut (dyn FnMut(RuntimeModelProgress) + Send),
95 ) -> Result<RuntimeModelOutput>;
96}
97
98struct ProviderRuntimeModelAdapter<'a> {
99 provider: &'a mut Box<dyn LLMProvider>,
100 steering: &'a mut RuntimeSteering,
101}
102
103impl<'a> ProviderRuntimeModelAdapter<'a> {
104 fn new(provider: &'a mut Box<dyn LLMProvider>, steering: &'a mut RuntimeSteering) -> Self {
105 Self { provider, steering }
106 }
107}
108
109#[async_trait]
110impl RuntimeModelAdapter for ProviderRuntimeModelAdapter<'_> {
111 async fn execute(
112 &mut self,
113 request: LLMRequest,
114 timeout: Option<std::time::Duration>,
115 on_progress: &mut (dyn FnMut(RuntimeModelProgress) + Send),
116 ) -> Result<RuntimeModelOutput> {
117 let started_at = Instant::now();
118 let request_model = request.model.clone();
119 let mut stream = if let Some(duration) = timeout {
120 match tokio::time::timeout(duration, self.provider.stream_normalized(request)).await {
121 Ok(result) => result?,
122 Err(_) => {
123 tracing::warn!(model = %request_model, elapsed_ms = started_at.elapsed().as_millis() as u64, "model stream timed out");
124 return Err(anyhow::anyhow!("Stream request timed out after {duration:?}"));
125 }
126 }
127 } else {
128 self.provider.stream_normalized(request).await?
129 };
130
131 let mut final_usage = ProviderUsage::default();
132 let mut completed_response: Option<LLMResponse> = None;
133 let mut provider_activity_reported = false;
134 while let Some(event_result) = stream.next().await {
135 if matches!(self.steering.poll_turn_control().await, RuntimeControl::StopRequested) {
136 tracing::info!(model = %request_model, elapsed_ms = started_at.elapsed().as_millis() as u64, "model stream cancelled");
137 let mut response = LLMResponse {
138 model: request_model.clone(),
139 finish_reason: FinishReason::Error("Cancelled".to_string()),
140 usage: Some(final_usage.clone()),
141 ..Default::default()
142 };
143 if response.usage.as_ref().is_some_and(|usage| {
144 usage.prompt_tokens == 0 && usage.completion_tokens == 0 && usage.total_tokens == 0
145 }) {
146 response.usage = None;
147 }
148 return Ok(RuntimeModelOutput { response });
149 }
150
151 match event_result? {
152 NormalizedStreamEvent::TextDelta { delta } => {
153 on_progress(RuntimeModelProgress::OutputDelta(delta));
154 }
155 NormalizedStreamEvent::ReasoningDelta { delta, source } if source.is_public_summary() => {
156 on_progress(RuntimeModelProgress::ReasoningDelta(delta));
157 }
158 NormalizedStreamEvent::ReasoningDelta { .. } => {
159 if !provider_activity_reported {
160 provider_activity_reported = true;
161 on_progress(RuntimeModelProgress::ProviderActivity);
162 }
163 }
164 NormalizedStreamEvent::ReasoningStage { stage } => {
165 on_progress(RuntimeModelProgress::ReasoningStage(stage));
166 }
167 NormalizedStreamEvent::ToolCallStart { call_id, name } => {
168 on_progress(RuntimeModelProgress::ToolCallStarted { call_id, name });
169 }
170 NormalizedStreamEvent::ToolCallDelta { call_id, delta } => {
171 on_progress(RuntimeModelProgress::ToolCallDelta { call_id, delta });
172 }
173 NormalizedStreamEvent::Usage { usage } => {
174 final_usage = usage;
175 }
176 NormalizedStreamEvent::Done { response } => {
177 let mut response = *response;
178 if response.usage.is_none()
179 && (final_usage.prompt_tokens > 0
180 || final_usage.completion_tokens > 0
181 || final_usage.total_tokens > 0)
182 {
183 response.usage = Some(final_usage.clone());
184 }
185 completed_response = Some(response);
186 break;
187 }
188 }
189 }
190
191 let mut response = completed_response.unwrap_or_default();
192 if response.model.is_empty() {
193 response.model = request_model;
194 }
195 if response.usage.is_none()
196 && (final_usage.prompt_tokens > 0 || final_usage.completion_tokens > 0 || final_usage.total_tokens > 0)
197 {
198 response.usage = Some(final_usage);
199 }
200
201 tracing::debug!(
202 model = %response.model,
203 elapsed_ms = started_at.elapsed().as_millis() as u64,
204 input_tokens = response.usage.as_ref().map_or(0, |usage| usage.prompt_tokens),
205 output_tokens = response.usage.as_ref().map_or(0, |usage| usage.completion_tokens),
206 finish_reason = ?response.finish_reason,
207 "model stream completed"
208 );
209 Ok(RuntimeModelOutput { response })
210 }
211}
212
213pub struct RuntimeSteering {
215 steering_receiver: Option<UnboundedReceiver<SteeringMessage>>,
216 queued_follow_up_inputs: VecDeque<QueuedFollowUpIntent>,
217 overflow_follow_up_inputs: VecDeque<QueuedFollowUpIntent>,
221 in_flight_follow_up_intents: VecDeque<QueuedFollowUpIntent>,
222 applied_follow_up_intent_ids: VecDeque<String>,
223}
224
225impl Default for RuntimeSteering {
226 fn default() -> Self {
227 Self::new(None)
228 }
229}
230
231impl RuntimeSteering {
232 fn new(steering_receiver: Option<UnboundedReceiver<SteeringMessage>>) -> Self {
233 Self {
234 steering_receiver,
235 queued_follow_up_inputs: VecDeque::new(),
236 overflow_follow_up_inputs: VecDeque::new(),
237 in_flight_follow_up_intents: VecDeque::new(),
238 applied_follow_up_intent_ids: VecDeque::new(),
239 }
240 }
241
242 pub fn set_receiver(&mut self, receiver: Option<UnboundedReceiver<SteeringMessage>>) {
244 self.steering_receiver = receiver;
245 }
246
247 pub fn take_receiver(&mut self) -> Option<UnboundedReceiver<SteeringMessage>> {
249 self.steering_receiver.take()
250 }
251
252 #[must_use]
253 pub fn has_pending_follow_up_inputs(&self) -> bool {
254 !self.queued_follow_up_inputs.is_empty() || !self.overflow_follow_up_inputs.is_empty()
255 }
256
257 pub fn pop_follow_up_input(&mut self) -> Option<String> {
259 self.pop_follow_up_intent().map(|intent| intent.into_parts().1)
260 }
261
262 pub fn pop_follow_up_intent(&mut self) -> Option<QueuedFollowUpIntent> {
268 self.drain_overflow_into_primary();
269 self.queued_follow_up_inputs.pop_front()
270 }
271
272 fn drain_overflow_into_primary(&mut self) {
274 while self.queued_follow_up_inputs.len() < MAX_QUEUED_FOLLOW_UP_INTENTS
275 && let Some(intent) = self.overflow_follow_up_inputs.pop_front()
276 {
277 self.queued_follow_up_inputs.push_back(intent);
278 }
279 }
280
281 pub fn queue_follow_up_input(&mut self, input: String) -> bool {
284 match self.try_queue_follow_up_input(input) {
285 Ok(()) => true,
286 Err(error) => {
287 tracing::warn!(%error, "Rejected follow-up steering intent");
288 false
289 }
290 }
291 }
292
293 pub fn try_queue_follow_up_input(&mut self, input: String) -> Result<(), FollowUpQueueFull> {
295 self.try_queue_follow_up_intent(QueuedFollowUpIntent::new(input))
296 }
297
298 pub fn try_queue_follow_up_intent(&mut self, intent: QueuedFollowUpIntent) -> Result<(), FollowUpQueueFull> {
304 if self.queued_follow_up_inputs.len() + self.in_flight_follow_up_intents.len() < MAX_QUEUED_FOLLOW_UP_INTENTS {
305 self.queued_follow_up_inputs.push_back(intent);
306 return Ok(());
307 }
308 if self.overflow_follow_up_inputs.len() < MAX_OVERFLOW_FOLLOW_UP_INTENTS {
309 self.overflow_follow_up_inputs.push_back(intent);
310 return Ok(());
311 }
312 Err(FollowUpQueueFull {
313 capacity: MAX_QUEUED_FOLLOW_UP_INTENTS + MAX_OVERFLOW_FOLLOW_UP_INTENTS,
314 })
315 }
316
317 pub fn acknowledge_follow_up_intent(&mut self, intent_id: impl Into<String>) {
319 if self.applied_follow_up_intent_ids.len() >= MAX_APPLIED_FOLLOW_UP_INTENT_IDS {
320 self.applied_follow_up_intent_ids.pop_front();
321 }
322 self.applied_follow_up_intent_ids.push_back(intent_id.into());
323 }
324
325 #[must_use]
326 pub fn applied_follow_up_intent_ids(&self) -> &VecDeque<String> {
327 &self.applied_follow_up_intent_ids
328 }
329
330 #[must_use]
334 pub fn pending_follow_up_intents(&self) -> &VecDeque<QueuedFollowUpIntent> {
335 &self.queued_follow_up_inputs
336 }
337
338 #[must_use]
342 pub fn pending_follow_up_intents_snapshot(&self) -> Vec<QueuedFollowUpIntent> {
343 self.queued_follow_up_inputs
344 .iter()
345 .chain(self.overflow_follow_up_inputs.iter())
346 .chain(self.in_flight_follow_up_intents.iter())
347 .cloned()
348 .collect()
349 }
350
351 pub fn drain_follow_up_intents_to_in_flight(&mut self) -> Vec<QueuedFollowUpIntent> {
357 self.drain_overflow_into_primary();
358 let drained: Vec<_> = self.queued_follow_up_inputs.drain(..).collect();
359 self.in_flight_follow_up_intents.extend(drained.iter().cloned());
360 drained
361 }
362
363 pub fn acknowledge_durable_follow_up_intents(&mut self) {
367 while let Some(intent) = self.in_flight_follow_up_intents.pop_front() {
368 self.acknowledge_follow_up_intent(intent.id());
369 }
370 }
371
372 pub fn release_in_flight_follow_up_intents_without_persistence(&mut self) {
377 self.in_flight_follow_up_intents.clear();
378 }
379
380 pub fn clear_pending_follow_up_inputs(&mut self) {
381 self.queued_follow_up_inputs.clear();
382 self.overflow_follow_up_inputs.clear();
383 self.in_flight_follow_up_intents.clear();
384 }
385
386 pub async fn poll_turn_control(&mut self) -> RuntimeControl {
388 self.poll_control().await
389 }
390
391 pub async fn poll_tool_control(&mut self) -> RuntimeControl {
393 self.poll_control().await
394 }
395
396 async fn poll_control(&mut self) -> RuntimeControl {
397 let mut paused = false;
398
399 loop {
400 let Some(receiver) = self.steering_receiver.as_mut() else {
401 return if paused {
402 RuntimeControl::Resumed
403 } else {
404 RuntimeControl::Continue
405 };
406 };
407
408 match receiver.try_recv() {
409 Ok(SteeringMessage::SteerStop) => return RuntimeControl::StopRequested,
410 Ok(SteeringMessage::Pause) => {
411 paused = true;
412 if matches!(self.wait_for_resume().await, RuntimeControl::StopRequested) {
413 return RuntimeControl::StopRequested;
414 }
415 }
416 Ok(SteeringMessage::Resume) => {
417 paused = true;
418 }
419 Ok(SteeringMessage::FollowUpInput(input)) => {
420 if let Err(error) = self.try_queue_follow_up_input(input) {
421 tracing::warn!(%error, "Rejected follow-up steering intent");
422 }
423 }
424 Err(TryRecvError::Empty | TryRecvError::Disconnected) => {
425 return if paused {
426 RuntimeControl::Resumed
427 } else {
428 RuntimeControl::Continue
429 };
430 }
431 }
432 }
433 }
434
435 async fn wait_for_resume(&mut self) -> RuntimeControl {
436 loop {
437 let Some(receiver) = self.steering_receiver.as_mut() else {
438 return RuntimeControl::Continue;
439 };
440
441 match receiver.recv().await {
442 Some(SteeringMessage::Resume) => return RuntimeControl::Continue,
443 Some(SteeringMessage::SteerStop) => return RuntimeControl::StopRequested,
444 Some(SteeringMessage::FollowUpInput(input)) => {
445 if let Err(error) = self.try_queue_follow_up_input(input) {
446 tracing::warn!(%error, "Rejected follow-up steering intent");
447 }
448 }
449 Some(SteeringMessage::Pause) => {}
450 None => return RuntimeControl::Continue,
451 }
452 }
453 }
454}
455
456pub struct TurnExecution {
458 pub response: LLMResponse,
460 pub content: String,
462 pub reasoning: Option<String>,
464}
465
466const MIN_REASONING_UPDATE_BYTES: usize = 256;
467const MAX_REASONING_UPDATE_EVENTS: usize = 2;
468const MIN_OUTPUT_UPDATE_BYTES: usize = 1024;
469const MAX_OUTPUT_UPDATE_EVENTS: usize = 64;
470
471#[derive(Debug, Clone, Default)]
486struct UpdateThrottle {
487 events: usize,
488 last_emit_len: usize,
489}
490
491impl UpdateThrottle {
492 const fn new() -> Self {
493 Self { events: 0, last_emit_len: 0 }
494 }
495
496 fn should_emit(&self, current_len: usize, eager: bool, min_bytes: usize, max_events: usize) -> bool {
498 if self.events >= max_events {
499 return false;
500 }
501 eager || current_len.saturating_sub(self.last_emit_len) >= min_bytes
502 }
503
504 fn record(&mut self, current_len: usize) {
506 self.events += 1;
507 self.last_emit_len = current_len;
508 }
509
510 fn advance_to(&mut self, current_len: usize) {
514 self.last_emit_len = current_len;
515 }
516
517 fn is_first(&self) -> bool {
519 self.events == 0
520 }
521
522 fn reset(&mut self) {
524 self.events = 0;
525 self.last_emit_len = 0;
526 }
527}
528
529#[doc(hidden)]
531pub struct StreamingLifecycleBridge {
532 event_sink: Option<EventSink>,
533 assistant_item_id: String,
534 reasoning_item_id: String,
535 lifecycle: SharedLifecycleEmitter,
536 tool_call_item_ids: hashbrown::HashMap<String, (String, String)>,
540 reasoning_stage: Option<String>,
541 output: UpdateThrottle,
542 reasoning: UpdateThrottle,
543}
544
545impl StreamingLifecycleBridge {
546 #[must_use]
548 pub fn new(event_sink: Option<EventSink>, turn_id: &str, step: usize, attempt: usize) -> Self {
549 Self {
550 event_sink,
551 assistant_item_id: format!("{turn_id}-step-{step}-assistant-stream-{attempt}"),
552 reasoning_item_id: format!("{turn_id}-step-{step}-reasoning-stream-{attempt}"),
553 lifecycle: SharedLifecycleEmitter::default(),
554 tool_call_item_ids: hashbrown::HashMap::new(),
555 reasoning_stage: None,
556 output: UpdateThrottle::new(),
557 reasoning: UpdateThrottle::new(),
558 }
559 }
560
561 pub fn on_progress(&mut self, event: RuntimeModelProgress) {
563 match event {
564 RuntimeModelProgress::OutputDelta(delta) => self.push_assistant_delta(&delta),
565 RuntimeModelProgress::ReasoningDelta(delta) => self.push_reasoning_delta(&delta),
566 RuntimeModelProgress::ReasoningStage(stage) => self.update_reasoning_stage(stage),
567 RuntimeModelProgress::ProviderActivity => {}
568 RuntimeModelProgress::ToolCallStarted { call_id, name } => {
569 self.start_tool_call(call_id, name);
570 }
571 RuntimeModelProgress::ToolCallDelta { call_id, delta } => {
572 self.push_tool_call_delta(call_id, &delta);
573 }
574 }
575 }
576
577 pub fn abort(&mut self) {
579 self.lifecycle.complete_open_text_items();
580 self.lifecycle.complete_open_tool_calls_with_status(ToolCallStatus::Failed);
581 self.emit_pending_events();
582 }
583
584 pub fn complete_open_items(&mut self) {
586 self.lifecycle.flush_open_tool_call_arguments();
590 self.lifecycle.complete_open_text_items();
591 self.emit_pending_events();
592 }
593
594 #[must_use]
597 pub fn take_streamed_tool_call_items(&mut self) -> hashbrown::HashMap<String, (String, String)> {
598 std::mem::take(&mut self.tool_call_item_ids)
599 }
600
601 fn push_assistant_delta(&mut self, delta: &str) {
602 if !self.lifecycle.append_assistant_delta(delta) {
603 return;
604 }
605
606 let len = self.lifecycle.assistant_len();
607 if self
608 .output
609 .should_emit(len, self.output.is_first(), MIN_OUTPUT_UPDATE_BYTES, MAX_OUTPUT_UPDATE_EVENTS)
610 && self.lifecycle.emit_assistant_snapshot(Some(self.assistant_item_id.clone()))
611 {
612 self.output.record(len);
613 self.emit_pending_events();
614 }
615 }
616
617 fn push_reasoning_delta(&mut self, delta: &str) {
618 if !self.lifecycle.append_reasoning_delta(delta) {
619 return;
620 }
621
622 if !self.lifecycle.reasoning_started() {
623 if self.lifecycle.emit_reasoning_snapshot(Some(self.reasoning_item_id.clone())) {
624 self.reasoning.advance_to(self.lifecycle.reasoning_len());
625 self.emit_pending_events();
626 }
627 return;
628 }
629
630 let len = self.lifecycle.reasoning_len();
631 if self
632 .reasoning
633 .should_emit(len, false, MIN_REASONING_UPDATE_BYTES, MAX_REASONING_UPDATE_EVENTS)
634 && self.lifecycle.emit_reasoning_snapshot(Some(self.reasoning_item_id.clone()))
635 {
636 self.reasoning.record(len);
637 self.emit_pending_events();
638 }
639 }
640
641 fn update_reasoning_stage(&mut self, stage: String) {
642 let stage_changed = self.reasoning_stage.as_deref() != Some(stage.as_str());
643 if !stage_changed || !self.lifecycle.set_reasoning_stage(Some(stage.clone())) {
644 self.reasoning_stage = Some(stage);
645 return;
646 }
647 self.reasoning_stage = Some(stage);
648
649 if !self.lifecycle.reasoning_started() {
650 return;
651 }
652
653 let len = self.lifecycle.reasoning_len();
654 if self
655 .reasoning
656 .should_emit(len, true, MIN_REASONING_UPDATE_BYTES, MAX_REASONING_UPDATE_EVENTS)
657 && self.lifecycle.emit_reasoning_stage_update()
658 {
659 self.reasoning.record(len);
660 self.emit_pending_events();
661 }
662 }
663
664 fn start_tool_call(&mut self, call_id: String, name: Option<String>) {
665 let item_id = format!("{}-tool-call-{call_id}", self.assistant_item_id);
666 let tool_name = name.clone().unwrap_or_default();
667 self.tool_call_item_ids.insert(call_id.clone(), (item_id.clone(), tool_name));
668 let _ = self.lifecycle.start_tool_call(&call_id, name, Some(item_id));
669 self.emit_pending_events();
670 }
671
672 fn push_tool_call_delta(&mut self, call_id: String, delta: &str) {
673 if !self.lifecycle.append_tool_call_delta(
674 &call_id,
675 delta,
676 None,
677 Some(format!("{}-tool-call-{call_id}", self.assistant_item_id)),
678 ) {
679 return;
680 }
681 self.emit_pending_events();
682 }
683
684 fn emit_pending_events(&mut self) {
685 let Some(sink) = &self.event_sink else {
686 let _ = self.lifecycle.drain_events();
687 return;
688 };
689
690 for event in self.lifecycle.drain_events() {
691 let mut callback = sink.lock();
692 callback(&event);
693 }
694 }
695}
696
697pub struct AgentRuntime {
699 pub state: AgentSessionState,
701 steering: RuntimeSteering,
702 event_sink: Option<EventSink>,
703 lifecycle: SharedLifecycleEmitter,
704 emitted_events: Vec<ThreadEvent>,
705 output: UpdateThrottle,
706 reasoning: UpdateThrottle,
707}
708
709impl AgentRuntime {
710 pub fn new(
712 state: AgentSessionState,
713 event_sink: Option<EventSink>,
714 steering_receiver: Option<UnboundedReceiver<SteeringMessage>>,
715 ) -> Self {
716 Self {
717 state,
718 steering: RuntimeSteering::new(steering_receiver),
719 event_sink,
720 lifecycle: SharedLifecycleEmitter::default(),
721 emitted_events: Vec::new(),
722 output: UpdateThrottle::new(),
723 reasoning: UpdateThrottle::new(),
724 }
725 }
726
727 pub fn set_event_handler(&mut self, sink: Option<EventSink>) {
729 self.event_sink = sink;
730 }
731
732 pub fn set_steering_receiver(&mut self, receiver: Option<UnboundedReceiver<SteeringMessage>>) {
734 self.steering.set_receiver(receiver);
735 }
736
737 pub fn take_steering_receiver(&mut self) -> Option<UnboundedReceiver<SteeringMessage>> {
739 self.steering.take_receiver()
740 }
741
742 pub fn split_mut(&mut self) -> (&mut AgentSessionState, &mut RuntimeSteering) {
744 (&mut self.state, &mut self.steering)
745 }
746
747 #[must_use]
749 pub fn has_pending_follow_up_inputs(&self) -> bool {
750 self.steering.has_pending_follow_up_inputs()
751 }
752
753 pub fn run_until_idle(&mut self) -> Option<String> {
756 let intent = self.steering.pop_follow_up_intent()?;
757 let (intent_id, input) = intent.into_parts();
758 self.state.add_user_message_with_intent(input.clone(), Some(intent_id.clone()));
759 self.steering
760 .in_flight_follow_up_intents
761 .push_back(QueuedFollowUpIntent::from_parts(intent_id, input.clone()));
762 Some(input)
763 }
764
765 pub fn queue_follow_up_input(&mut self, input: String) {
769 let _ = self.steering.queue_follow_up_input(input);
770 }
771
772 pub fn try_queue_follow_up_input(&mut self, input: String) -> Result<(), FollowUpQueueFull> {
774 self.steering.try_queue_follow_up_input(input)
775 }
776
777 pub fn restore_follow_up_state(
782 &mut self,
783 pending_intents: impl IntoIterator<Item = QueuedFollowUpIntent>,
784 applied_intent_ids: impl IntoIterator<Item = String>,
785 ) -> Result<(), FollowUpQueueFull> {
786 let mut applied = HashSet::new();
787 for intent_id in applied_intent_ids {
788 if applied.insert(intent_id.clone()) {
789 self.steering.acknowledge_follow_up_intent(intent_id);
790 }
791 }
792 let represented: HashSet<String> = self
793 .state
794 .messages
795 .iter()
796 .filter_map(|message| message.metadata.as_ref().and_then(|metadata| metadata.intent_id()))
797 .map(ToOwned::to_owned)
798 .collect();
799
800 for intent in pending_intents {
801 if applied.contains(intent.id()) || represented.contains(intent.id()) {
802 continue;
803 }
804 self.steering.try_queue_follow_up_intent(intent)?;
805 }
806 Ok(())
807 }
808
809 #[must_use]
811 pub fn applied_follow_up_intent_ids(&self) -> &VecDeque<String> {
812 self.steering.applied_follow_up_intent_ids()
813 }
814
815 pub fn clear_pending_follow_up_inputs(&mut self) {
817 self.steering.clear_pending_follow_up_inputs();
818 }
819
820 pub async fn poll_turn_control(&mut self) -> RuntimeControl {
822 self.steering.poll_turn_control().await
823 }
824
825 pub async fn poll_tool_control(&mut self) -> RuntimeControl {
827 self.steering.poll_tool_control().await
828 }
829
830 pub fn take_emitted_events(&mut self) -> Vec<ThreadEvent> {
832 std::mem::take(&mut self.emitted_events)
833 }
834
835 #[must_use]
837 pub fn tool_call_item_id(&self, call_id: &str) -> Option<String> {
838 self.lifecycle.tool_call_item_id(call_id).map(str::to_string)
839 }
840
841 pub fn complete_tool_call(&mut self, call_id: &str, status: ToolCallStatus, outcome: Option<ToolOutcome>) {
843 let _ = self.lifecycle.complete_tool_call(call_id, status, outcome);
844 self.emit_pending_lifecycle_events();
845 }
846
847 pub fn complete_open_tool_calls(&mut self, status: ToolCallStatus) {
849 self.lifecycle.complete_open_tool_calls_with_status(status);
850 self.emit_pending_lifecycle_events();
851 }
852
853 fn emit_event(&mut self, event: ThreadEvent) {
854 self.emitted_events.push(event.clone());
855 if let Some(sink) = &self.event_sink {
856 let mut callback = sink.lock();
857 callback(&event);
858 }
859 }
860
861 fn emit_pending_lifecycle_events(&mut self) {
862 for event in self.lifecycle.drain_events() {
863 self.emit_event(event);
864 }
865 }
866
867 fn finalize_assistant_lifecycle(&mut self, text: &str) {
868 if text.trim().is_empty() {
869 return;
870 }
871
872 let should_emit_snapshot = !self.lifecycle.assistant_started() || self.lifecycle.replace_assistant_text(text);
873 if should_emit_snapshot {
874 let _ = self.lifecycle.emit_assistant_snapshot(None);
875 }
876 let _ = self.lifecycle.complete_assistant_stream();
877 }
878
879 fn finalize_reasoning_lifecycle(&mut self, text: &str) {
880 if text.trim().is_empty() {
881 return;
882 }
883
884 let should_emit_snapshot = !self.lifecycle.reasoning_started() || self.lifecycle.replace_reasoning_text(text);
885 if should_emit_snapshot {
886 let _ = self.lifecycle.emit_reasoning_snapshot(None);
887 }
888 let _ = self.lifecycle.complete_reasoning_stream();
889 }
890
891 fn finalize_tool_call_lifecycle(&mut self, tool_calls: Option<&[ToolCall]>, _finish_reason: &str) {
892 if let Some(tool_calls) = tool_calls {
893 for call in tool_calls {
894 let tool_name = call.function.as_ref().map(|function| function.name.clone());
895 let _ = self.lifecycle.start_tool_call(&call.id, tool_name.clone(), None);
896 if let Some(function) = call.function.as_ref() {
897 let _ = self
898 .lifecycle
899 .sync_tool_call_arguments(&call.id, &function.arguments, tool_name, None);
900 }
901 }
902 return;
903 }
904
905 self.lifecycle.complete_open_tool_calls_with_status(ToolCallStatus::Failed);
906 }
907
908 fn record_model_progress(
909 &mut self,
910 event: RuntimeModelProgress,
911 full_text: &mut String,
912 full_reasoning: &mut String,
913 ) {
914 match event {
915 RuntimeModelProgress::OutputDelta(delta) => {
916 full_text.push_str(&delta);
917 if self.lifecycle.append_assistant_delta(&delta) {
918 let len = self.lifecycle.assistant_len();
919 if self.output.should_emit(
920 len,
921 self.output.is_first(),
922 MIN_OUTPUT_UPDATE_BYTES,
923 MAX_OUTPUT_UPDATE_EVENTS,
924 ) && self.lifecycle.emit_assistant_snapshot(None)
925 {
926 self.output.record(len);
927 self.emit_pending_lifecycle_events();
928 }
929 }
930 }
931 RuntimeModelProgress::ReasoningDelta(delta) => {
932 full_reasoning.push_str(&delta);
933 if self.lifecycle.append_reasoning_delta(&delta) {
934 let len = self.lifecycle.reasoning_len();
935 if self.reasoning.should_emit(
936 len,
937 self.reasoning.is_first(),
938 MIN_REASONING_UPDATE_BYTES,
939 MAX_REASONING_UPDATE_EVENTS,
940 ) && self.lifecycle.emit_reasoning_snapshot(None)
941 {
942 self.reasoning.record(len);
943 self.emit_pending_lifecycle_events();
944 }
945 }
946 }
947 RuntimeModelProgress::ReasoningStage(stage) => {
948 if self.lifecycle.set_reasoning_stage(Some(stage)) {
949 let _ = self.lifecycle.emit_reasoning_stage_update();
950 self.emit_pending_lifecycle_events();
951 }
952 }
953 RuntimeModelProgress::ProviderActivity => {}
954 RuntimeModelProgress::ToolCallStarted { call_id, name } => {
955 let _ = self.lifecycle.start_tool_call(&call_id, name, None);
956 self.emit_pending_lifecycle_events();
957 }
958 RuntimeModelProgress::ToolCallDelta { call_id, delta } => {
959 if self.lifecycle.append_tool_call_delta(&call_id, &delta, None, None) {
960 self.emit_pending_lifecycle_events();
961 }
962 }
963 }
964 }
965
966 async fn run_turn_once_with_adapter<A: RuntimeModelAdapter + ?Sized>(
967 &mut self,
968 adapter: &mut A,
969 request: LLMRequest,
970 timeout: Option<std::time::Duration>,
971 ) -> Result<TurnExecution> {
972 let request_model = request.model.clone();
973 let start_time = Instant::now();
974 self.output.reset();
975 self.reasoning.reset();
976 let mut full_text = String::new();
977 let mut full_reasoning = String::new();
978 let mut on_progress = |event| self.record_model_progress(event, &mut full_text, &mut full_reasoning);
979 let RuntimeModelOutput { mut response } = match adapter.execute(request, timeout, &mut on_progress).await {
980 Ok(output) => output,
981 Err(error) => {
982 self.complete_open_tool_calls(ToolCallStatus::Failed);
983 tracing::warn!(elapsed_ms = start_time.elapsed().as_millis() as u64, error = %error, "agent turn failed during model execution");
984 return Err(error);
985 }
986 };
987
988 merge_stream_and_completed_text(&mut full_text, response.content.as_deref());
989 let provider_reasoning = response.reasoning.clone();
990
991 let finish_reason = match response.finish_reason.clone() {
992 FinishReason::Stop => "stop".to_string(),
993 FinishReason::ToolCalls => "tool_calls".to_string(),
994 FinishReason::Length => "length".to_string(),
995 FinishReason::Error(message) => message,
996 _ => "unknown".to_string(),
997 };
998 let final_usage = response.usage.take().unwrap_or_default();
999 let mut aggregated_tool_calls = response.tool_calls.take();
1000
1001 self.finalize_assistant_lifecycle(&full_text);
1002 self.finalize_reasoning_lifecycle(&full_reasoning);
1003 self.finalize_tool_call_lifecycle(aggregated_tool_calls.as_deref(), &finish_reason);
1004 self.emit_pending_lifecycle_events();
1005
1006 let mut turn_recorded = false;
1007 self.state.record_turn(&start_time, &mut turn_recorded);
1008
1009 if final_usage.prompt_tokens > 0 || final_usage.completion_tokens > 0 {
1010 if let Some(alert) = self.state.stats.merge_usage(&final_usage) {
1014 let message = alert.message();
1015 tracing::warn!("{message}");
1016 self.state.push_warning(message);
1017 }
1018 }
1019
1020 aggregated_tool_calls = aggregated_tool_calls.filter(|calls| !calls.is_empty());
1021
1022 let mut assistant_message = crate::llm::provider::Message::assistant(full_text.clone());
1023 if let Some(reasoning) = provider_reasoning
1024 .clone()
1025 .or_else(|| (!full_reasoning.is_empty()).then(|| full_reasoning.clone()))
1026 {
1027 assistant_message = assistant_message.with_reasoning(Some(reasoning));
1028 }
1029 if let Some(details) = response.reasoning_details.clone() {
1030 let values: Vec<serde_json::Value> = details.into_iter().map(serde_json::Value::String).collect();
1031 assistant_message = assistant_message.with_reasoning_details(Some(values));
1032 }
1033
1034 match aggregated_tool_calls.as_ref() {
1035 Some(calls) => {
1036 assistant_message = assistant_message
1037 .with_tool_calls(calls.clone())
1038 .with_phase(Some(AssistantPhase::Commentary));
1039 }
1040 None => {
1041 assistant_message = assistant_message.with_phase(Some(AssistantPhase::FinalAnswer));
1042 }
1043 }
1044
1045 self.state.adjust_token_count(assistant_message.estimate_tokens() as isize);
1046 self.state.messages_mut().push(assistant_message);
1047
1048 self.state.conversation.push(Content {
1049 role: "model".to_string(),
1050 parts: vec![Part::Text { text: full_text.clone(), thought_signature: None }],
1051 });
1052 self.state.last_processed_message_idx = self.state.conversation.len();
1053
1054 if response.model.is_empty() {
1055 response.model = request_model;
1056 }
1057 response.content = Some(full_text.clone());
1058 if response.reasoning.is_none() && !full_reasoning.is_empty() {
1059 response.reasoning = Some(full_reasoning.clone());
1060 }
1061 response.tool_calls = aggregated_tool_calls;
1062 response.usage = Some(final_usage);
1063 response.finish_reason = if finish_reason == "tool_calls" {
1064 FinishReason::ToolCalls
1065 } else if finish_reason == "Cancelled" || finish_reason == "cancelled" {
1066 FinishReason::Error("Cancelled".to_string())
1067 } else {
1068 response.finish_reason
1069 };
1070
1071 tracing::debug!(
1072 elapsed_ms = start_time.elapsed().as_millis() as u64,
1073 output_bytes = full_text.len(),
1074 reasoning_bytes = full_reasoning.len(),
1075 tool_calls = response.tool_calls.as_ref().map_or(0, Vec::len),
1076 input_tokens = response.usage.as_ref().map_or(0, |usage| usage.prompt_tokens),
1077 output_tokens = response.usage.as_ref().map_or(0, |usage| usage.completion_tokens),
1078 finish_reason = %finish_reason,
1079 "agent turn completed"
1080 );
1081
1082 Ok(TurnExecution {
1083 response,
1084 content: full_text,
1085 reasoning: if full_reasoning.is_empty() {
1086 None
1087 } else {
1088 Some(full_reasoning)
1089 },
1090 })
1091 }
1092
1093 pub async fn run_turn_once(
1095 &mut self,
1096 provider: &mut Box<dyn LLMProvider>,
1097 request: LLMRequest,
1098 timeout: Option<std::time::Duration>,
1099 ) -> Result<TurnExecution> {
1100 let mut steering = std::mem::take(&mut self.steering);
1101 let mut adapter = ProviderRuntimeModelAdapter::new(provider, &mut steering);
1102 let result = self.run_turn_once_with_adapter(&mut adapter, request, timeout).await;
1103 self.steering = steering;
1104 result
1105 }
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110 use super::*;
1111 use async_trait::async_trait;
1112 use futures::stream;
1113
1114 use crate::llm::provider::{
1115 LLMError, LLMNormalizedStream, LLMStream, LLMStreamEvent, NormalizedStreamEvent, ReasoningSource,
1116 };
1117
1118 #[derive(Clone)]
1119 struct CompletedOnlyStreamProvider {
1120 response: LLMResponse,
1121 }
1122
1123 #[derive(Clone)]
1124 struct DeltaStreamProvider {
1125 response: LLMResponse,
1126 text_delta: String,
1127 reasoning_delta: String,
1128 reasoning_source: ReasoningSource,
1129 tool_call_start: bool,
1130 }
1131
1132 #[async_trait]
1133 impl LLMProvider for CompletedOnlyStreamProvider {
1134 fn name(&self) -> &str {
1135 "test-provider"
1136 }
1137
1138 fn supports_streaming(&self) -> bool {
1139 true
1140 }
1141
1142 async fn generate(&self, _request: LLMRequest) -> Result<LLMResponse, LLMError> {
1143 Ok(self.response.clone())
1144 }
1145
1146 async fn stream(&self, _request: LLMRequest) -> Result<LLMStream, LLMError> {
1147 Ok(Box::pin(stream::iter(vec![Ok(LLMStreamEvent::Completed {
1148 response: Box::new(self.response.clone()),
1149 })])))
1150 }
1151
1152 async fn stream_normalized(&self, _request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
1153 Ok(Box::pin(stream::iter(vec![Ok(NormalizedStreamEvent::Done {
1154 response: Box::new(self.response.clone()),
1155 })])))
1156 }
1157
1158 fn supported_models(&self) -> Vec<String> {
1159 vec!["test-model".to_string()]
1160 }
1161
1162 fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
1163 Ok(())
1164 }
1165 }
1166
1167 #[async_trait]
1168 impl LLMProvider for DeltaStreamProvider {
1169 fn name(&self) -> &str {
1170 "delta-provider"
1171 }
1172
1173 fn supports_streaming(&self) -> bool {
1174 true
1175 }
1176
1177 async fn generate(&self, _request: LLMRequest) -> Result<LLMResponse, LLMError> {
1178 Ok(self.response.clone())
1179 }
1180
1181 async fn stream(&self, _request: LLMRequest) -> Result<LLMStream, LLMError> {
1182 Ok(Box::pin(stream::iter(vec![Ok(LLMStreamEvent::Completed {
1183 response: Box::new(self.response.clone()),
1184 })])))
1185 }
1186
1187 async fn stream_normalized(&self, _request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
1188 let mut events = vec![Ok(NormalizedStreamEvent::ReasoningDelta {
1189 delta: self.reasoning_delta.clone(),
1190 source: self.reasoning_source,
1191 })];
1192 if self.tool_call_start {
1193 events.push(Ok(NormalizedStreamEvent::ToolCallStart {
1194 call_id: "call_test".to_string(),
1195 name: Some("read_file".to_string()),
1196 }));
1197 }
1198 events.extend([
1199 Ok(NormalizedStreamEvent::TextDelta { delta: self.text_delta.clone() }),
1200 Ok(NormalizedStreamEvent::Done { response: Box::new(self.response.clone()) }),
1201 ]);
1202 Ok(Box::pin(stream::iter(events)))
1203 }
1204
1205 fn supported_models(&self) -> Vec<String> {
1206 vec!["test-model".to_string()]
1207 }
1208
1209 fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
1210 Ok(())
1211 }
1212 }
1213
1214 #[tokio::test]
1215 async fn queued_follow_up_inputs_are_applied_one_at_a_time() {
1216 let state = AgentSessionState::new("session".to_string(), 16, 4, 128_000);
1217 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
1218 let mut runtime = AgentRuntime::new(state, None, Some(receiver));
1219
1220 sender
1221 .send(SteeringMessage::FollowUpInput("first".to_string()))
1222 .expect("first follow-up should queue");
1223 sender
1224 .send(SteeringMessage::FollowUpInput("second".to_string()))
1225 .expect("second follow-up should queue");
1226
1227 assert_eq!(runtime.poll_turn_control().await, RuntimeControl::Continue);
1228 assert!(runtime.has_pending_follow_up_inputs());
1229 assert!(runtime.state.messages.is_empty());
1230
1231 assert_eq!(runtime.run_until_idle().as_deref(), Some("first"));
1232 assert_eq!(
1233 runtime
1234 .state
1235 .messages
1236 .last()
1237 .map(|message| message.get_text_content().into_owned()),
1238 Some("first".to_string())
1239 );
1240 assert!(runtime.has_pending_follow_up_inputs());
1241
1242 assert_eq!(runtime.run_until_idle().as_deref(), Some("second"));
1243 assert_eq!(
1244 runtime
1245 .state
1246 .messages
1247 .last()
1248 .map(|message| message.get_text_content().into_owned()),
1249 Some("second".to_string())
1250 );
1251 assert!(!runtime.has_pending_follow_up_inputs());
1252 }
1253
1254 #[test]
1255 fn follow_up_queue_has_bounded_identity_preserving_fifo() {
1256 let mut steering = RuntimeSteering::default();
1257 let total_capacity = MAX_QUEUED_FOLLOW_UP_INTENTS + MAX_OVERFLOW_FOLLOW_UP_INTENTS;
1258 for index in 0..total_capacity {
1259 assert!(
1260 steering.try_queue_follow_up_input(format!("input-{index}")).is_ok(),
1261 "input {index} must be accepted within the combined capacity"
1262 );
1263 }
1264 let error = steering
1265 .try_queue_follow_up_input("overflow".to_string())
1266 .expect_err("overflow must be reported once both buffers are full");
1267 assert_eq!(error.capacity, total_capacity);
1268
1269 let first = steering.pop_follow_up_intent().expect("first intent should exist");
1271 let second = steering.pop_follow_up_intent().expect("second intent should exist");
1272 assert_ne!(first.id(), second.id());
1273 assert_eq!(first.text(), "input-0");
1274 assert_eq!(second.text(), "input-1");
1275 }
1276
1277 #[test]
1278 fn follow_up_overflow_parks_instead_of_dropping_bursts() {
1279 let mut steering = RuntimeSteering::default();
1280 for index in 0..MAX_QUEUED_FOLLOW_UP_INTENTS {
1282 assert!(steering.try_queue_follow_up_input(format!("primary-{index}")).is_ok());
1283 }
1284 for index in 0..5 {
1286 assert!(steering.try_queue_follow_up_input(format!("overflow-{index}")).is_ok());
1287 }
1288 assert!(steering.has_pending_follow_up_inputs());
1289
1290 for index in 0..MAX_QUEUED_FOLLOW_UP_INTENTS {
1292 let intent = steering.pop_follow_up_intent().expect("primary intent");
1293 assert_eq!(intent.text(), format!("primary-{index}"));
1294 }
1295 for index in 0..5 {
1296 let intent = steering.pop_follow_up_intent().expect("overflow intent");
1297 assert_eq!(intent.text(), format!("overflow-{index}"));
1298 }
1299 assert!(!steering.has_pending_follow_up_inputs());
1300 }
1301
1302 #[test]
1303 fn pop_yields_overflow_when_primary_is_empty() {
1304 let mut steering = RuntimeSteering::default();
1308 for index in 0..MAX_QUEUED_FOLLOW_UP_INTENTS {
1309 assert!(steering.try_queue_follow_up_input(format!("inflight-{index}")).is_ok());
1310 }
1311 assert_eq!(steering.drain_follow_up_intents_to_in_flight().len(), MAX_QUEUED_FOLLOW_UP_INTENTS);
1312 assert!(steering.queued_follow_up_inputs.is_empty(), "primary must be empty after a full in-flight drain");
1313 assert!(steering.try_queue_follow_up_input("parked".to_string()).is_ok());
1314 assert!(steering.has_pending_follow_up_inputs());
1315 let intent = steering
1316 .pop_follow_up_intent()
1317 .expect("pop must yield overflow when primary is empty");
1318 assert_eq!(intent.text(), "parked");
1319 }
1320
1321 #[test]
1322 fn run_until_idle_tags_and_acknowledges_follow_up_intent() {
1323 let state = AgentSessionState::new("session".to_string(), 16, 4, 128_000);
1324 let mut runtime = AgentRuntime::new(state, None, None);
1325 runtime
1326 .try_queue_follow_up_input("steer me".to_string())
1327 .expect("intent should be accepted");
1328
1329 assert_eq!(runtime.run_until_idle().as_deref(), Some("steer me"));
1330 let message = runtime.state.messages.last().expect("tagged message should exist");
1331 let intent_id = message
1332 .metadata
1333 .as_ref()
1334 .and_then(|metadata| metadata.intent_id())
1335 .expect("follow-up message should carry intent identity")
1336 .to_string();
1337 assert!(runtime.applied_follow_up_intent_ids().is_empty());
1338 assert_eq!(runtime.steering.pending_follow_up_intents_snapshot().len(), 1);
1339 runtime.steering.acknowledge_durable_follow_up_intents();
1340 assert_eq!(runtime.applied_follow_up_intent_ids().back(), Some(&intent_id));
1341 }
1342
1343 #[test]
1344 fn recovery_replays_only_unrepresented_intent_ids() {
1345 let pending = QueuedFollowUpIntent::from_parts("intent-pending", "finish the request");
1346 let same_text_different_id = QueuedFollowUpIntent::from_parts("intent-distinct", pending.text());
1347
1348 let mut interrupted =
1349 AgentRuntime::new(AgentSessionState::new("session".to_string(), 16, 4, 128_000), None, None);
1350 interrupted
1351 .restore_follow_up_state([pending.clone(), same_text_different_id.clone()], Vec::new())
1352 .expect("unwritten intents should be recoverable");
1353 assert_eq!(interrupted.steering.pending_follow_up_intents().len(), 2);
1354 assert_eq!(interrupted.steering.pending_follow_up_intents()[0], pending);
1355 assert_eq!(interrupted.steering.pending_follow_up_intents()[1], same_text_different_id);
1356
1357 let mut restarted_state = AgentSessionState::new("session".to_string(), 16, 4, 128_000);
1358 restarted_state.add_user_message_with_intent(pending.text().to_string(), Some(pending.id().to_string()));
1359 let mut restarted = AgentRuntime::new(restarted_state, None, None);
1360 restarted
1361 .restore_follow_up_state([pending.clone(), same_text_different_id], Vec::new())
1362 .expect("distinct pending intent should fit the recovery queue");
1363
1364 assert_eq!(restarted.steering.pending_follow_up_intents().len(), 1);
1365 assert_eq!(restarted.steering.pending_follow_up_intents()[0].id(), "intent-distinct");
1366 }
1367
1368 #[test]
1369 fn no_archive_release_reuses_follow_up_capacity_without_applied_ids() {
1370 let state = AgentSessionState::new("session".to_string(), 16, 4, 128_000);
1371 let mut runtime = AgentRuntime::new(state, None, None);
1372 let total_capacity = MAX_QUEUED_FOLLOW_UP_INTENTS + MAX_OVERFLOW_FOLLOW_UP_INTENTS;
1373 for index in 0..total_capacity {
1374 runtime
1375 .try_queue_follow_up_input(format!("input-{index}"))
1376 .expect("intent should be accepted");
1377 }
1378
1379 for _ in 0..total_capacity {
1380 runtime.run_until_idle().expect("queued intent should become a user message");
1381 }
1382 assert!(runtime.try_queue_follow_up_input("parked-0".to_string()).is_ok());
1385 for index in 1..MAX_OVERFLOW_FOLLOW_UP_INTENTS {
1386 assert!(runtime.try_queue_follow_up_input(format!("parked-{index}")).is_ok());
1387 }
1388 assert!(runtime.try_queue_follow_up_input("rejected".to_string()).is_err());
1389
1390 runtime.steering.release_in_flight_follow_up_intents_without_persistence();
1391 assert!(runtime.try_queue_follow_up_input("after-release".to_string()).is_ok());
1392 assert!(runtime.applied_follow_up_intent_ids().is_empty());
1393 }
1394
1395 #[tokio::test]
1396 async fn paused_runtime_resumes_and_preserves_follow_up_inputs() {
1397 let state = AgentSessionState::new("session".to_string(), 16, 4, 128_000);
1398 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
1399 let mut runtime = AgentRuntime::new(state, None, Some(receiver));
1400
1401 sender.send(SteeringMessage::Pause).expect("pause should send");
1402 sender
1403 .send(SteeringMessage::FollowUpInput("queued while paused".to_string()))
1404 .expect("follow-up should send");
1405 sender.send(SteeringMessage::Resume).expect("resume should send");
1406
1407 assert_eq!(runtime.poll_turn_control().await, RuntimeControl::Resumed);
1408 assert!(runtime.has_pending_follow_up_inputs());
1409 assert_eq!(runtime.run_until_idle().as_deref(), Some("queued while paused"));
1410 }
1411
1412 #[tokio::test]
1413 async fn paused_runtime_stop_request_wins() {
1414 let state = AgentSessionState::new("session".to_string(), 16, 4, 128_000);
1415 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
1416 let mut runtime = AgentRuntime::new(state, None, Some(receiver));
1417
1418 sender.send(SteeringMessage::Pause).expect("pause should send");
1419 sender.send(SteeringMessage::SteerStop).expect("stop should send");
1420
1421 assert_eq!(runtime.poll_turn_control().await, RuntimeControl::StopRequested);
1422 assert!(!runtime.has_pending_follow_up_inputs());
1423 }
1424
1425 #[tokio::test]
1426 async fn run_turn_once_uses_completed_payload_when_no_deltas_exist() {
1427 let response = LLMResponse {
1428 content: Some("### Header\n- item".to_string()),
1429 model: "test-model".to_string(),
1430 finish_reason: FinishReason::Stop,
1431 reasoning: Some("**why** this works".to_string()),
1432 reasoning_details: Some(vec!["continuation detail".to_string()]),
1433 ..Default::default()
1434 };
1435 let provider = CompletedOnlyStreamProvider { response: response.clone() };
1436 let state = AgentSessionState::new("session".to_string(), 16, 4, 128_000);
1437 let mut runtime = AgentRuntime::new(state, None, None);
1438 let mut provider_box: Box<dyn LLMProvider> = Box::new(provider);
1439 let request = LLMRequest {
1440 model: "test-model".to_string(),
1441 ..Default::default()
1442 };
1443
1444 let turn = runtime
1445 .run_turn_once(&mut provider_box, request, None)
1446 .await
1447 .expect("run_turn_once should succeed");
1448
1449 assert_eq!(turn.content, "### Header\n- item");
1450 assert_eq!(turn.reasoning, None);
1451 assert_eq!(turn.response.content.as_deref(), Some("### Header\n- item"));
1452 assert_eq!(turn.response.reasoning.as_deref(), Some("**why** this works"));
1453 assert_eq!(turn.response.reasoning_details.as_deref(), Some(["continuation detail".to_string()].as_slice()));
1454 }
1455
1456 #[tokio::test]
1457 async fn provider_runtime_model_adapter_emits_delta_progress() {
1458 let response = LLMResponse {
1459 content: Some("hello world".to_string()),
1460 model: "test-model".to_string(),
1461 finish_reason: FinishReason::Stop,
1462 reasoning: Some("trace".to_string()),
1463 ..Default::default()
1464 };
1465 let provider = DeltaStreamProvider {
1466 response,
1467 text_delta: "hello world".to_string(),
1468 reasoning_delta: "trace".to_string(),
1469 reasoning_source: ReasoningSource::ProviderSummary,
1470 tool_call_start: false,
1471 };
1472 let mut steering = RuntimeSteering::default();
1473 let mut provider_box: Box<dyn LLMProvider> = Box::new(provider);
1474 let request = LLMRequest {
1475 model: "test-model".to_string(),
1476 ..Default::default()
1477 };
1478
1479 let mut adapter = ProviderRuntimeModelAdapter::new(&mut provider_box, &mut steering);
1480 let mut seen_progress = Vec::new();
1481 let mut callback = |event| seen_progress.push(event);
1482 let output = adapter
1483 .execute(request, None, &mut callback)
1484 .await
1485 .expect("adapter execution should succeed");
1486
1487 assert_eq!(output.response.content.as_deref(), Some("hello world"));
1488 assert_eq!(output.response.reasoning.as_deref(), Some("trace"));
1489 assert_eq!(
1490 seen_progress,
1491 vec![
1492 RuntimeModelProgress::ReasoningDelta("trace".to_string()),
1493 RuntimeModelProgress::OutputDelta("hello world".to_string()),
1494 ]
1495 );
1496 }
1497
1498 #[tokio::test]
1499 async fn adapter_hides_unclassified_reasoning_but_keeps_text_and_metadata() {
1500 let response = LLMResponse {
1501 content: Some("hello".to_string()),
1502 model: "test-model".to_string(),
1503 finish_reason: FinishReason::Stop,
1504 reasoning: Some("private trace".to_string()),
1505 reasoning_details: Some(vec!["continuation detail".to_string()]),
1506 ..Default::default()
1507 };
1508 let provider = DeltaStreamProvider {
1509 response,
1510 text_delta: "hello".to_string(),
1511 reasoning_delta: "private trace".to_string(),
1512 reasoning_source: ReasoningSource::Unknown,
1513 tool_call_start: false,
1514 };
1515 let mut steering = RuntimeSteering::default();
1516 let mut provider_box: Box<dyn LLMProvider> = Box::new(provider);
1517 let mut seen_progress = Vec::new();
1518 let mut callback = |event| seen_progress.push(event);
1519 let output = ProviderRuntimeModelAdapter::new(&mut provider_box, &mut steering)
1520 .execute(LLMRequest::default(), None, &mut callback)
1521 .await
1522 .expect("adapter execution should succeed");
1523
1524 assert_eq!(output.response.reasoning.as_deref(), Some("private trace"));
1525 assert_eq!(output.response.reasoning_details.as_deref(), Some(["continuation detail".to_string()].as_slice()));
1526 assert_eq!(
1527 seen_progress,
1528 vec![
1529 RuntimeModelProgress::ProviderActivity,
1530 RuntimeModelProgress::OutputDelta("hello".to_string())
1531 ]
1532 );
1533 assert!(
1534 !seen_progress
1535 .iter()
1536 .any(|event| matches!(event, RuntimeModelProgress::ToolCallStarted { .. }))
1537 );
1538 }
1539
1540 #[tokio::test]
1541 async fn adapter_emits_structured_tool_call_status_without_textual_trigger() {
1542 let provider = DeltaStreamProvider {
1543 response: LLMResponse {
1544 model: "test-model".to_string(),
1545 finish_reason: FinishReason::ToolCalls,
1546 ..Default::default()
1547 },
1548 text_delta: String::new(),
1549 reasoning_delta: String::new(),
1550 reasoning_source: ReasoningSource::Unknown,
1551 tool_call_start: true,
1552 };
1553 let mut steering = RuntimeSteering::default();
1554 let mut provider_box: Box<dyn LLMProvider> = Box::new(provider);
1555 let mut seen_progress = Vec::new();
1556 let mut callback = |event| seen_progress.push(event);
1557 ProviderRuntimeModelAdapter::new(&mut provider_box, &mut steering)
1558 .execute(LLMRequest::default(), None, &mut callback)
1559 .await
1560 .expect("adapter execution should succeed");
1561
1562 assert!(seen_progress.iter().any(|event| matches!(
1563 event,
1564 RuntimeModelProgress::ToolCallStarted { call_id, name }
1565 if call_id == "call_test" && name.as_deref() == Some("read_file")
1566 )));
1567 }
1568
1569 #[test]
1570 fn streaming_lifecycle_bridge_tracks_tool_call_item_ids() {
1571 let mut bridge = StreamingLifecycleBridge::new(None, "turn_tool_map", 5, 2);
1572 bridge.on_progress(RuntimeModelProgress::ToolCallStarted {
1573 call_id: "call_42".to_string(),
1574 name: Some("shell".to_string()),
1575 });
1576
1577 let item_ids = bridge.take_streamed_tool_call_items();
1578 assert_eq!(
1579 item_ids.get("call_42").map(|(item_id, _)| item_id.as_str()),
1580 Some("turn_tool_map-step-5-assistant-stream-2-tool-call-call_42")
1581 );
1582 assert_eq!(item_ids.get("call_42").map(|(_, name)| name.as_str()), Some("shell"));
1583 }
1584
1585 #[test]
1586 fn update_throttle_first_update_is_eager() {
1587 let mut throttle = UpdateThrottle::new();
1588 assert!(throttle.is_first());
1589 assert!(throttle.should_emit(0, throttle.is_first(), 1024, 64));
1591 throttle.record(0);
1592 assert!(!throttle.is_first());
1593 }
1594
1595 #[test]
1596 fn update_throttle_requires_min_bytes_after_first() {
1597 let mut throttle = UpdateThrottle::new();
1598 throttle.record(0);
1599 assert!(!throttle.should_emit(500, false, 1024, 64));
1601 assert!(throttle.should_emit(1024, false, 1024, 64));
1603 }
1604
1605 #[test]
1606 fn update_throttle_stops_at_max_events() {
1607 let mut throttle = UpdateThrottle::new();
1608 for _ in 0..2 {
1609 assert!(throttle.should_emit(0, true, 256, 2));
1610 throttle.record(0);
1611 }
1612 assert!(!throttle.should_emit(0, true, 256, 2));
1614 }
1615
1616 #[test]
1617 fn update_throttle_advance_to_does_not_count_event() {
1618 let mut throttle = UpdateThrottle::new();
1619 throttle.advance_to(100);
1620 assert!(throttle.is_first());
1621 assert!(!throttle.should_emit(200, false, 256, 2));
1623 assert!(throttle.should_emit(356, false, 256, 2));
1625 }
1626
1627 #[test]
1628 fn update_throttle_reset_restores_initial_state() {
1629 let mut throttle = UpdateThrottle::new();
1630 throttle.record(500);
1631 assert!(!throttle.is_first());
1632 throttle.reset();
1633 assert!(throttle.is_first());
1634 assert!(throttle.should_emit(0, true, 1024, 64));
1635 }
1636}