Skip to main content

vtcode_core/core/agent/runtime/
mod.rs

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/// Control signal returned when polling the steering channel between turns or tool calls.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum RuntimeControl {
47    /// No steering message pending; execution may proceed normally.
48    Continue,
49    /// Execution was paused and has now been resumed.
50    Resumed,
51    /// A stop request was received; the current turn should be cancelled.
52    StopRequested,
53}
54
55/// Progress event emitted by the model adapter during a streaming LLM response.
56#[doc(hidden)]
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum RuntimeModelProgress {
59    /// A chunk of assistant output text.
60    OutputDelta(String),
61    /// A chunk of reasoning/thinking text.
62    ReasoningDelta(String),
63    /// The active reasoning stage label changed.
64    ReasoningStage(String),
65    /// The provider produced progress that is intentionally not user-visible.
66    ProviderActivity,
67    /// A new tool call began streaming.
68    ToolCallStarted {
69        /// Identifier of the tool call.
70        call_id: String,
71        /// Optional name of the tool being invoked.
72        name: Option<String>,
73    },
74    /// A chunk of arguments for an in-progress tool call.
75    ToolCallDelta {
76        /// Identifier of the tool call receiving the delta.
77        call_id: String,
78        /// Serialized argument fragment.
79        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
213/// Manages steering messages (stop, pause, resume, follow-up inputs) for a running agent turn.
214pub struct RuntimeSteering {
215    steering_receiver: Option<UnboundedReceiver<SteeringMessage>>,
216    queued_follow_up_inputs: VecDeque<QueuedFollowUpIntent>,
217    /// Overflow park for follow-ups that arrive while the primary FIFO is
218    /// full. Drained FIFO into the primary queue as space frees so a rapid
219    /// influx is never silently dropped.
220    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    /// Replace the steering message receiver channel.
243    pub fn set_receiver(&mut self, receiver: Option<UnboundedReceiver<SteeringMessage>>) {
244        self.steering_receiver = receiver;
245    }
246
247    /// Take ownership of the steering receiver, leaving `None` in its place.
248    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    /// Dequeue the next follow-up user input, if any are pending.
258    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    /// Dequeue the next identified follow-up intent.
263    ///
264    /// Refills the primary FIFO from overflow first so an empty primary with
265    /// parked overflow still yields (`has_pending_follow_up_inputs` staying
266    /// true must not race a `None` pop).
267    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    /// Move overflow-parked intents into free primary-FIFO slots.
273    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    /// Queue a follow-up user input. The boolean is `false` when the FIFO is
282    /// full; callers that need the error detail should use the `try_` variant.
283    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    /// Queue a follow-up user input and return an explicit overflow error.
294    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    /// Queue a recovered intent without changing its stable identity.
299    ///
300    /// Primary FIFO first; when it is full the intent parks in a bounded
301    /// overflow buffer so rapid influx is preserved rather than dropped.
302    /// Only when both buffers are full does the call fail.
303    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    /// Mark an intent as applied after its tagged user message is stored.
318    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    /// Primary-FIFO view of pending follow-ups (excludes overflow park and
331    /// in-flight). Prefer [`Self::pending_follow_up_intents_snapshot`] when
332    /// the complete unwritten set matters.
333    #[must_use]
334    pub fn pending_follow_up_intents(&self) -> &VecDeque<QueuedFollowUpIntent> {
335        &self.queued_follow_up_inputs
336    }
337
338    /// Return all accepted intents that are not yet represented by durable
339    /// session history, including the intent currently being processed and
340    /// any parked in the overflow buffer.
341    #[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    /// Pop every queued follow-up intent and move it to the in-flight queue,
352    /// returning the intents for immediate application to live history
353    /// (mid-turn steering). Until the post-turn history checkpoint succeeds,
354    /// the moved intents stay in the pending snapshot exactly like
355    /// turn-boundary delivery.
356    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    /// Mark intents whose tagged user messages have been durably checkpointed
364    /// as applied. Until this is called, the intents remain in the pending
365    /// snapshot so a crash cannot lose them between history and envelope IO.
366    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    /// Release intents after their tagged messages have been applied when no
373    /// history archive exists. This frees FIFO capacity for the in-process
374    /// session without adding applied IDs that could falsely imply crash
375    /// durability.
376    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    /// Poll the steering channel for control signals during the current turn.
387    pub async fn poll_turn_control(&mut self) -> RuntimeControl {
388        self.poll_control().await
389    }
390
391    /// Poll the steering channel for control signals during tool execution.
392    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
456/// Result of executing a single LLM turn, containing the full response and extracted fields.
457pub struct TurnExecution {
458    /// The complete LLM response including usage and finish reason.
459    pub response: LLMResponse,
460    /// The accumulated assistant output text.
461    pub content: String,
462    /// The accumulated reasoning/thinking text, if present.
463    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/// Bounded update throttle shared by the streaming lifecycle paths.
472///
473/// Limits how many intermediate snapshot events are emitted for a single
474/// streaming text/reasoning buffer within one turn:
475///
476/// - emission stops once `max_events` snapshots have been produced;
477/// - an update is emitted eagerly when the caller's `eager` signal is true
478///   (e.g. the first update, or a reasoning stage change);
479/// - otherwise an update requires at least `min_bytes` of new content since
480///   the last emitted snapshot.
481///
482/// The throttle owns only the accounting; the authoritative accumulated text
483/// lives on the lifecycle emitter, which always emits a final lossless
484/// snapshot on completion. This keeps live and replay streams consistent.
485#[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    /// Whether an intermediate snapshot should be emitted now.
497    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    /// Record that a snapshot was emitted at `current_len`.
505    fn record(&mut self, current_len: usize) {
506        self.events += 1;
507        self.last_emit_len = current_len;
508    }
509
510    /// Advance the last-emitted length marker without counting an event.
511    /// Used when a snapshot is emitted outside the throttle's accounting
512    /// (e.g. the first reasoning snapshot before reasoning has "started").
513    fn advance_to(&mut self, current_len: usize) {
514        self.last_emit_len = current_len;
515    }
516
517    /// Whether no snapshot has been emitted yet — the first-update eager signal.
518    fn is_first(&self) -> bool {
519        self.events == 0
520    }
521
522    /// Reset the throttle for a new turn.
523    fn reset(&mut self) {
524        self.events = 0;
525        self.last_emit_len = 0;
526    }
527}
528
529/// Bridges streaming model progress events to the lifecycle event sink for real-time UI updates.
530#[doc(hidden)]
531pub struct StreamingLifecycleBridge {
532    event_sink: Option<EventSink>,
533    assistant_item_id: String,
534    reasoning_item_id: String,
535    lifecycle: SharedLifecycleEmitter,
536    /// Streamed tool calls keyed by provider call id, valued as
537    /// `(harness item id, tool name)` so turn teardown can close items whose
538    /// dispatch never happened with their real tool label.
539    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    /// Create a new bridge that emits lifecycle events to the provided sink.
547    #[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    /// Forward a streaming progress event to the lifecycle emitter and sink.
562    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    /// Abort the streaming turn, marking all open items as failed and flushing events.
578    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    /// Complete all open text items gracefully and flush pending events.
585    pub fn complete_open_items(&mut self) {
586        // Flush the authoritative accumulated tool-call arguments before
587        // closing text items so the UI sees the full streamed arguments even
588        // when intermediate deltas were throttled (small tool calls).
589        self.lifecycle.flush_open_tool_call_arguments();
590        self.lifecycle.complete_open_text_items();
591        self.emit_pending_events();
592    }
593
594    /// Take the mapping of tool call IDs to their `(lifecycle item id, tool
595    /// name)`, leaving the map empty.
596    #[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
697/// Orchestrates a single agent turn: manages steering, lifecycle events, and the LLM call.
698pub struct AgentRuntime {
699    /// Mutable session state including conversation history and statistics.
700    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    /// Create a new runtime with the given session state, optional event sink, and steering receiver.
711    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    /// Replace the structured event sink used for lifecycle event emission.
728    pub fn set_event_handler(&mut self, sink: Option<EventSink>) {
729        self.event_sink = sink;
730    }
731
732    /// Replace the steering message receiver channel for this runtime.
733    pub fn set_steering_receiver(&mut self, receiver: Option<UnboundedReceiver<SteeringMessage>>) {
734        self.steering.set_receiver(receiver);
735    }
736
737    /// Take ownership of the steering receiver, leaving `None` in its place.
738    pub fn take_steering_receiver(&mut self) -> Option<UnboundedReceiver<SteeringMessage>> {
739        self.steering.take_receiver()
740    }
741
742    /// Borrow the session state and steering controller mutably at the same time.
743    pub fn split_mut(&mut self) -> (&mut AgentSessionState, &mut RuntimeSteering) {
744        (&mut self.state, &mut self.steering)
745    }
746
747    /// Returns `true` if there are queued follow-up user inputs waiting to be consumed.
748    #[must_use]
749    pub fn has_pending_follow_up_inputs(&self) -> bool {
750        self.steering.has_pending_follow_up_inputs()
751    }
752
753    /// Pop the next queued follow-up input, add a tagged user message, and
754    /// retain the intent until the enclosing history checkpoint succeeds.
755    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    /// Queue a follow-up input to be consumed by `run_until_idle` on the next
766    /// iteration, causing the session loop to start a new turn with this input
767    /// without waiting for the user to type anything.
768    pub fn queue_follow_up_input(&mut self, input: String) {
769        let _ = self.steering.queue_follow_up_input(input);
770    }
771
772    /// Queue a follow-up input while exposing FIFO overflow to the caller.
773    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    /// Restore the durable steering snapshot after loading session history.
778    /// Pending intents already represented by a tagged user message, or
779    /// already in the applied-ID window, are skipped; identical text with a
780    /// different ID is intentionally retained.
781    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    /// Access the intent IDs acknowledged by this runtime for checkpointing.
810    #[must_use]
811    pub fn applied_follow_up_intent_ids(&self) -> &VecDeque<String> {
812        self.steering.applied_follow_up_intent_ids()
813    }
814
815    /// Drop follow-up inputs belonging to the discarded execution context.
816    pub fn clear_pending_follow_up_inputs(&mut self) {
817        self.steering.clear_pending_follow_up_inputs();
818    }
819
820    /// Poll the steering channel for control signals during the current turn.
821    pub async fn poll_turn_control(&mut self) -> RuntimeControl {
822        self.steering.poll_turn_control().await
823    }
824
825    /// Poll the steering channel for control signals during tool execution.
826    pub async fn poll_tool_control(&mut self) -> RuntimeControl {
827        self.steering.poll_tool_control().await
828    }
829
830    /// Take all emitted lifecycle events, leaving the internal buffer empty.
831    pub fn take_emitted_events(&mut self) -> Vec<ThreadEvent> {
832        std::mem::take(&mut self.emitted_events)
833    }
834
835    /// Look up the lifecycle item ID for a given tool call identifier.
836    #[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    /// Mark a specific tool call as completed with the given status and emit lifecycle events.
842    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    /// Mark all still-open tool calls as completed with the given status and emit lifecycle events.
848    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            // `merge_usage` also feeds the session prompt-cache health
1011            // monitor; a sustained hit-rate collapse warns once per session
1012            // (SEV-style) instead of silently re-paying full input cost.
1013            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    /// Execute a single LLM turn: stream the response, update session state, and return the result.
1094    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        // Primary FIFO drains first in order; overflow parks then refill.
1270        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        // Fill the primary FIFO.
1281        for index in 0..MAX_QUEUED_FOLLOW_UP_INTENTS {
1282            assert!(steering.try_queue_follow_up_input(format!("primary-{index}")).is_ok());
1283        }
1284        // Burst beyond primary parks in overflow rather than dropping.
1285        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        // Drain the primary; overflow items must arrive afterwards in order.
1291        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        // In-flight occupancy can park new intents in overflow while the
1305        // primary FIFO is empty (admission counts in-flight). `has_pending`
1306        // staying true must never race a `None` pop.
1307        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        // In-flight holds every accepted intent, so the primary FIFO is full
1383        // and a new intent parks in overflow instead of failing.
1384        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        // No new bytes, but eager (first update) should still emit.
1590        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        // 500 bytes is below the 1024 threshold and not eager -> no emit.
1600        assert!(!throttle.should_emit(500, false, 1024, 64));
1601        // 1024 bytes meets the threshold.
1602        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        // Cap reached; even an eager signal cannot emit.
1613        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        // last_emit_len is now 100; 100 new bytes is below 256 threshold.
1622        assert!(!throttle.should_emit(200, false, 256, 2));
1623        // 256 new bytes past 100 meets the threshold.
1624        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}