Skip to main content

myco_agent/
lib.rs

1//! Drive model context through generation and tool calls at replayable boundaries.
2//! Callers supply tool execution, event sinks, context, and persistence callbacks.
3//!
4//! # Embedding
5//!
6//! Construct [`Agent`] with a [`GenerativeModel`], [`ToolExecutor`], and [`EventSink`].
7//! Supply input with [`Agent::append_input`] or install history with
8//! [`Agent::replace_context`], then await [`Agent::run`]. The crate does not read
9//! Myco configuration or create sessions, prompts, hosts, or terminal output.
10//!
11//! # Ownership and persistence
12//!
13//! The executor owns live resources independently of agent history. Calls within
14//! a round run concurrently; results are recorded in call order. Checkpoints
15//! persist pending effects and their settled observations. Pending tool batches
16//! are durable intent, not replayable model context; recover them before reuse.
17//!
18//! Cancel a clone of the run's [`CancelToken`] and await the run future to let
19//! cleanup finish. Aborting the task bypasses that cooperative completion path.
20//! [`EventSink`] provides live observations, not a complete durable event log.
21//!
22//! See the [agent guide](https://tsnl.github.io/myco/developers/agents.html) and
23//! run `cargo run -p myco-agent --example headless` for an offline model/tool round.
24
25use std::sync::Arc;
26use std::sync::atomic::{AtomicBool, Ordering};
27
28use futures::future;
29use myco_model::{
30    self as generative_model, Content, GenerateError, GenerationFailure, GenerativeModel, Message,
31    Recovery, RetryPolicy, TokenUsage, ToolResult, ToolUse, TurnEndReason,
32};
33use uuid::Uuid;
34
35mod generation;
36mod state;
37pub use state::{
38    AgentState, Effect, OperationId, PendingOperation, StateError, recover_checkpoint,
39    validate_checkpoint, validate_context,
40};
41
42pub use tokio_util::sync::CancellationToken as CancelToken;
43pub type Async<T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send>>;
44pub const DEFAULT_MAX_TRUNCATED_RESUMES: u32 = 3;
45
46/// Capabilities available to this execution. Ownership and routing belong to the caller.
47pub trait ToolExecutor: Send + Sync {
48    /// Schemas to pass into the model's configuration. Changing the executor
49    /// does not update schemas on a model that was already constructed.
50    fn tool_specs(&self) -> Vec<generative_model::ToolSpec>;
51    /// Execute a call, validating its input and returning failures as tool results.
52    /// Calls in a round may overlap. Observe cancellation and clean up owned work.
53    /// `background` requests an early result while preserving ongoing work,
54    /// when the tool supports it. It must never be treated as cancellation.
55    fn dispatch(
56        self: Arc<Self>,
57        tool: ToolUse,
58        cancel: CancelToken,
59        background: CancelToken,
60    ) -> Async<ToolResult>;
61}
62
63//
64// Event sink — live observability for agent / tool activity
65//
66
67/// Event attribution for frontends and evaluators. Session and thread are
68/// present when bound; depth lets displays distinguish nested workers.
69#[derive(Debug, Clone)]
70pub struct TraceContext {
71    /// Stable id for this agent instance (root or subagent).
72    pub agent_id: Uuid,
73    /// Nesting depth: root agent is 0; each nested agent is parent depth + 1.
74    pub depth: usize,
75    pub session_id: Option<String>,
76    pub thread_id: Option<String>,
77}
78
79impl Default for TraceContext {
80    fn default() -> Self {
81        Self {
82            agent_id: Uuid::nil(),
83            depth: 0,
84            session_id: None,
85            thread_id: None,
86        }
87    }
88}
89
90impl TraceContext {
91    pub fn root() -> Self {
92        Self {
93            agent_id: Uuid::new_v4(),
94            depth: 0,
95            session_id: None,
96            thread_id: None,
97        }
98    }
99}
100
101/// Live events emitted by the agent runtime.
102///
103/// All ongoing work is attributed via [`TraceContext::agent_id`].
104#[derive(Debug, Clone)]
105pub enum AgentEvent {
106    Failure {
107        failure: GenerationFailure,
108        attempt: u32,
109        max_attempts: u32,
110        retry_in: Option<std::time::Duration>,
111        context: TraceContext,
112    },
113    /// Incremental assistant text (for streaming UX).
114    TextDelta {
115        text: String,
116        context: TraceContext,
117    },
118    /// Incremental thinking *summary* text (streamed for UI; also stored in history).
119    ThinkingDelta {
120        text: String,
121        context: TraceContext,
122    },
123    ToolStarted {
124        call_id: Uuid,
125        tool_use: ToolUse,
126        background: CancelToken,
127        context: TraceContext,
128    },
129    ToolFinished {
130        call_id: Uuid,
131        tool_use: ToolUse,
132        result: ToolResult,
133        context: TraceContext,
134    },
135    TurnFinished {
136        context: TraceContext,
137    },
138}
139
140/// Consumer of [`AgentEvent`]s (CLI, TUI, metrics, …).
141pub trait EventSink: Send + Sync {
142    /// Observe an event synchronously. Avoid blocking the execution task.
143    fn emit(&self, event: AgentEvent);
144}
145
146/// No-op sink for tests and headless runs.
147#[derive(Debug, Default)]
148pub struct NullEventSink;
149
150impl EventSink for NullEventSink {
151    fn emit(&self, _event: AgentEvent) {}
152}
153
154//
155// Agent
156//
157
158/// Persist context and pending intent before effects begin, and completed
159/// observations before the next effect. Failure stops execution. A pending tool
160/// batch is a durable observation, not a valid model input or context fork.
161pub type Checkpoint = Box<dyn Fn(&AgentState) -> Result<(), String> + Send + Sync>;
162
163/// Supply a pending runtime notice before a generation step. The returned text
164/// is appended as internal system content to the latest user input or tool result
165/// and checkpointed before generation; this callback does not reload the system
166/// prompt. Retries reuse the same input. Consume the notice only when the future
167/// completes, since cancellation can drop the future.
168pub type BeforeGenerationNotice =
169    Box<dyn Fn(&TraceContext, &[Message]) -> Async<Option<String>> + Send + Sync>;
170
171/// How long a cancelled tool dispatch may keep running to do its own
172/// cleanup (process-group kill, buffer drain) before the agent abandons it
173/// and records an unknown outcome.
174const CANCEL_TOOL_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
175
176/// The user turn sent to resume a reply that `max_tokens` cut off mid-text.
177///
178/// A plain "Continue" invites the model to acknowledge the instruction or start
179/// the thought over; naming the requirement keeps the seam invisible in the
180/// finished answer.
181pub const CONTINUE_PROMPT: &str = "Continue from exactly where you stopped. Do not repeat anything you have already written, \
182     and do not acknowledge this message.";
183
184/// A run ending at an output cap is distinct from a completed model turn.
185#[derive(Debug, Clone)]
186pub struct RunOutcome {
187    pub answer: Vec<Content>,
188    pub reason: TurnEndReason,
189    pub usage: Option<TokenUsage>,
190}
191
192/// A headless model/tool loop over caller-supplied context and capabilities.
193///
194/// Resource lifetime belongs to [`ToolExecutor`]; persistence belongs to the
195/// caller. The context-window setting is informational and does not enforce a
196/// token limit or perform automatic compaction.
197pub struct Agent {
198    retry_policy: RetryPolicy,
199    model: Arc<dyn GenerativeModel>,
200    tools: Arc<dyn ToolExecutor>,
201    sink: Arc<dyn EventSink>,
202    context: TraceContext,
203    state: AgentState,
204    /// Context window for the active model (tokens).
205    context_window_tokens: u64,
206    checkpoint: Option<Checkpoint>,
207    before_generation_notice: Option<BeforeGenerationNotice>,
208    checkpoint_failed: AtomicBool,
209}
210
211impl Agent {
212    pub fn new(
213        model: Arc<dyn GenerativeModel>,
214        tools: Arc<dyn ToolExecutor>,
215        sink: Arc<dyn EventSink>,
216    ) -> Self {
217        Self::with_context(model, tools, sink, TraceContext::root())
218    }
219
220    pub fn with_context(
221        model: Arc<dyn GenerativeModel>,
222        tools: Arc<dyn ToolExecutor>,
223        sink: Arc<dyn EventSink>,
224        context: TraceContext,
225    ) -> Self {
226        Self {
227            retry_policy: RetryPolicy::default(),
228            model,
229            tools,
230            sink,
231            context,
232            state: AgentState::default(),
233            context_window_tokens: 200_000,
234            checkpoint: None,
235            before_generation_notice: None,
236            checkpoint_failed: AtomicBool::new(false),
237        }
238    }
239
240    /// Replace dispatch capabilities without rebuilding the model's tool catalog.
241    /// Keep both in sync when adding or removing advertised tools.
242    pub fn set_tools(&mut self, tools: Arc<dyn ToolExecutor>) {
243        self.tools = tools;
244    }
245
246    pub fn set_context(&mut self, context: TraceContext) {
247        self.context = context;
248    }
249
250    /// Install the durable effect checkpoint (see [`Checkpoint`]).
251    pub fn set_checkpoint(&mut self, checkpoint: Option<Checkpoint>) {
252        self.checkpoint = checkpoint;
253    }
254
255    pub fn set_before_generation_notice(&mut self, notice: Option<BeforeGenerationNotice>) {
256        self.before_generation_notice = notice;
257    }
258
259    fn emit_checkpoint(&self) -> Result<(), AgentInteractionError> {
260        let result = self
261            .checkpoint
262            .as_ref()
263            .map_or(Ok(()), |checkpoint| checkpoint(&self.state));
264        self.checkpoint_failed
265            .store(result.is_err(), Ordering::Relaxed);
266        result.map_err(AgentInteractionError::Checkpoint)
267    }
268
269    pub fn checkpoint_failed(&self) -> bool {
270        self.checkpoint_failed.load(Ordering::Relaxed)
271    }
272
273    /// Retry persistence without advancing or replaying an effect.
274    pub fn checkpoint(&self) -> Result<(), AgentInteractionError> {
275        self.emit_checkpoint()
276    }
277
278    pub fn history(&self) -> &[Message] {
279        self.state.history()
280    }
281
282    /// Replace model context and its usage estimate without changing live tool state.
283    pub fn replace_context(
284        &mut self,
285        history: Vec<Message>,
286        usage: Option<TokenUsage>,
287    ) -> Result<(), StateError> {
288        self.state.replace_context(history, usage)
289    }
290
291    pub fn state(&self) -> &AgentState {
292        &self.state
293    }
294
295    pub fn set_retry_policy(&mut self, retry_policy: RetryPolicy) {
296        self.retry_policy = retry_policy;
297    }
298
299    /// Swap the generative model (e.g. mid-session `/effort` rebuild). History is kept.
300    pub fn set_model(&mut self, model: Arc<dyn GenerativeModel>) {
301        self.model = model;
302    }
303
304    /// Append input at a well-formed context boundary and checkpoint it.
305    pub fn append_input(&mut self, message: Message) -> Result<(), AgentInteractionError> {
306        self.state.append_input(message)?;
307        self.emit_checkpoint()?;
308        Ok(())
309    }
310
311    pub fn append_system(&mut self, parts: Vec<Content>) -> Result<(), AgentInteractionError> {
312        self.state.append_system(parts)?;
313        self.emit_checkpoint()
314    }
315
316    pub fn truncate_history(
317        &mut self,
318        index: usize,
319    ) -> Result<Vec<Message>, AgentInteractionError> {
320        let dropped = self.state.truncate_history(index)?;
321        self.emit_checkpoint()?;
322        Ok(dropped)
323    }
324
325    /// Set the model context budget available to callers.
326    pub fn set_context_window_tokens(&mut self, tokens: u64) {
327        self.context_window_tokens = tokens.max(1);
328    }
329
330    pub fn context_window_tokens(&self) -> u64 {
331        self.context_window_tokens
332    }
333
334    /// Set how many consecutive `max_tokens` truncations one turn resumes
335    /// through (the active model's `max_truncated_resumes`; `0` never resumes).
336    pub fn set_max_truncated_resumes(&mut self, resumes: u32) {
337        self.state.set_max_truncated_resumes(resumes);
338    }
339
340    /// Last observed prompt/context token usage (from the provider), if any.
341    pub fn last_usage(&self) -> Option<TokenUsage> {
342        self.state.last_usage()
343    }
344
345    pub fn context(&self) -> &TraceContext {
346        &self.context
347    }
348
349    /// Drive the existing model context to completion. The caller supplies input separately.
350    /// The supplied tool executor determines the lifetime of live resources.
351    ///
352    /// Returns answer content from the final generation; intervening responses
353    /// and tool rounds remain in [`Self::history`]. Emits [`AgentEvent::TurnFinished`]
354    /// on success, error, or cooperative cancellation. Checkpoints persist both
355    /// pending effects and the final settled state.
356    pub async fn run(
357        &mut self,
358        cancel: CancelToken,
359    ) -> Result<Vec<Content>, AgentInteractionError> {
360        self.run_with_outcome(cancel)
361            .await
362            .map(|outcome| outcome.answer)
363    }
364
365    /// Drive context and retain the stop reason and measured usage for evaluators.
366    pub async fn run_with_outcome(
367        &mut self,
368        cancel: CancelToken,
369    ) -> Result<RunOutcome, AgentInteractionError> {
370        if let Err(error) = self.start_run() {
371            self.finish_output();
372            return Err(error);
373        }
374        self.continue_run(cancel).await
375    }
376
377    pub fn start_run(&mut self) -> Result<(), AgentInteractionError> {
378        self.state.start()?;
379        Ok(())
380    }
381
382    /// Continue after a checkpoint failure without repeating completed effects.
383    /// A dropped in-flight effect requires explicit `recover_interrupted` first.
384    pub async fn continue_run(
385        &mut self,
386        cancel: CancelToken,
387    ) -> Result<RunOutcome, AgentInteractionError> {
388        loop {
389            if let Some(outcome) = self.step(cancel.clone()).await? {
390                return Ok(outcome);
391            }
392        }
393    }
394
395    pub fn replace_at_boundary(
396        &mut self,
397        history: Vec<Message>,
398        usage: Option<TokenUsage>,
399    ) -> Result<(), StateError> {
400        self.state.replace_at_boundary(history, usage)
401    }
402
403    pub fn recover_interrupted(&mut self) -> Result<(), AgentInteractionError> {
404        self.state.recover_interrupted()?;
405        self.emit_checkpoint()
406    }
407
408    pub fn cancel_at_boundary(&mut self) -> Result<(), AgentInteractionError> {
409        self.state.cancel_at_boundary()?;
410        self.finish_output();
411        self.emit_checkpoint()
412    }
413
414    /// Execute one generation or tool batch and checkpoint its outcome. `None`
415    /// yields to the caller at the next effect, allowing compaction or inspection.
416    pub async fn step(
417        &mut self,
418        cancel: CancelToken,
419    ) -> Result<Option<RunOutcome>, AgentInteractionError> {
420        let result = self.step_effect(cancel).await;
421        let result = match self.emit_checkpoint() {
422            Ok(()) => result,
423            Err(error) => Err(error),
424        };
425        if !matches!(result, Ok(None)) {
426            self.finish_output();
427        }
428        result
429    }
430
431    fn finish_output(&self) {
432        self.sink.emit(AgentEvent::TurnFinished {
433            context: self.context.clone(),
434        });
435    }
436
437    async fn step_effect(
438        &mut self,
439        cancel: CancelToken,
440    ) -> Result<Option<RunOutcome>, AgentInteractionError> {
441        self.emit_checkpoint()?;
442        let effect = self
443            .state
444            .effect()
445            .ok_or(StateError::UnexpectedCompletion)?;
446        let next = match effect {
447            Effect::Generate { operation } => {
448                self.state.begin_effect(operation)?;
449                let output = match async {
450                    self.append_pending_notice(operation, &cancel).await?;
451                    generation::generate(self, cancel).await
452                }
453                .await
454                {
455                    Ok(output) => output,
456                    Err(error) => {
457                        self.state.generation_failed(operation)?;
458                        return Err(error);
459                    }
460                };
461                self.state.generated(operation, output)?
462            }
463            Effect::ExecuteTools { operation, calls } => {
464                self.state.begin_effect(operation)?;
465                let results = future::join_all(
466                    calls
467                        .into_iter()
468                        .map(|call| self.dispatch_tool_use(call, cancel.clone())),
469                )
470                .await;
471                self.state
472                    .tools_completed(operation, results, cancel.is_cancelled())?
473            }
474            terminal => terminal,
475        };
476        match next {
477            Effect::Finished { answer, reason } => Ok(Some(RunOutcome {
478                answer,
479                reason,
480                usage: self.state.run_usage(),
481            })),
482            Effect::Cancelled => Err(AgentInteractionError::Cancelled),
483            _ => Ok(None),
484        }
485    }
486
487    async fn append_pending_notice(
488        &mut self,
489        operation: OperationId,
490        cancel: &CancelToken,
491    ) -> Result<(), AgentInteractionError> {
492        let Some(poll_notice) = &self.before_generation_notice else {
493            return Ok(());
494        };
495        let note = tokio::select! {
496            biased;
497            _ = cancel.cancelled() => return Err(AgentInteractionError::Cancelled),
498            note = poll_notice(&self.context, self.state.history()) => note,
499        };
500        if let Some(text) = note {
501            self.state.append_generation_notice(operation, text)?;
502            self.emit_checkpoint()?;
503        }
504        Ok(())
505    }
506
507    async fn dispatch_tool_use(&self, tool_use: ToolUse, cancel: CancelToken) -> ToolResult {
508        if cancel.is_cancelled() {
509            return ToolResult::err("cancelled before dispatch");
510        }
511        let call_id = Uuid::new_v4();
512        let background = CancelToken::new();
513        self.sink.emit(AgentEvent::ToolStarted {
514            call_id,
515            tool_use: tool_use.clone(),
516            background: background.clone(),
517            context: self.context.clone(),
518        });
519
520        let work = self
521            .tools
522            .clone()
523            .dispatch(tool_use.clone(), cancel.clone(), background);
524
525        // Race cancel vs tool — but on cancel, give the dispatch a short grace
526        // window instead of dropping it immediately. Cancel-aware tools use it
527        // to run their own cleanup and return an honest partial result: bash
528        // kills the exec's whole process group (kill_on_drop alone SIGKILLs
529        // only the leader, orphaning grandchildren), drains its capture tasks,
530        // and a mid-write bash session gets its taken ChildStdin back instead
531        // of having it dropped (which would close the session's stdin for
532        // good). Tools that ignore cancel are abandoned when the grace
533        // expires; for subprocess hosts that only abandons this waiter —
534        // the pipe demuxes by correlation id, so siblings are unaffected.
535        let mut work = std::pin::pin!(work);
536        let result = tokio::select! {
537            biased;
538            _ = cancel.cancelled() => {
539                match tokio::time::timeout(CANCEL_TOOL_GRACE, &mut work).await {
540                    Ok(result) => result.with_status("cancel requested; partial result recorded"),
541                    Err(_) => ToolResult::err("cancel requested; tool did not acknowledge before the deadline; effects are unknown").with_status("cancel requested; effects unknown"),
542                }
543            }
544            result = &mut work => result,
545        };
546        self.sink.emit(AgentEvent::ToolFinished {
547            call_id,
548            tool_use,
549            result: result.clone(),
550            context: self.context.clone(),
551        });
552        result
553    }
554}
555
556#[derive(thiserror::Error, Debug)]
557pub enum AgentInteractionError {
558    #[error("Error during generation: {0}")]
559    GenerateError(#[from] generative_model::GenerateError),
560    /// In-flight turn aborted (e.g. Ctrl-C). History is left well-formed when tools
561    /// had already started (unacknowledged cancellations record unknown effects).
562    #[error("cancelled")]
563    Cancelled,
564    #[error("{0}")]
565    State(StateError),
566    #[error("could not persist agent state; execution stopped: {0}")]
567    Checkpoint(String),
568    #[error("compaction failed: {0}")]
569    Compaction(String),
570}
571
572impl From<StateError> for AgentInteractionError {
573    fn from(error: StateError) -> Self {
574        match error {
575            StateError::InvalidResponse(message) => {
576                Self::GenerateError(GenerateError::MalformedResponseError(message))
577            }
578            error => Self::State(error),
579        }
580    }
581}
582
583impl AgentInteractionError {
584    /// Whether the failed turn can be resubmitted as-is, or the last user
585    /// message has to be rewound out of history first
586    /// by the caller before starting another run.
587    pub fn recovery(&self) -> Recovery {
588        match self {
589            AgentInteractionError::GenerateError(e) => e.recovery(),
590            // History is well-formed after a cancel; the same turn can be re-sent.
591            AgentInteractionError::Cancelled => Recovery::Retry,
592            AgentInteractionError::State(_)
593            | AgentInteractionError::Checkpoint(_)
594            | AgentInteractionError::Compaction(_) => Recovery::Stop,
595        }
596    }
597}
598
599#[cfg(test)]
600mod test_support;
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use crate::test_support::{
606        ScriptedModel, assistant, assistant_tool, result_text, tool_results, user,
607    };
608    use crate::test_support::{TestTools, interact};
609    use futures::stream;
610    use myco_model::{
611        ContentDelta, GenerateError, GenerateOutput, GenerationEvent, MessagePart, ToolSpec,
612        TurnEndReason,
613    };
614    use serde_json::json;
615    use std::sync::Mutex;
616    use std::time::{Duration, Instant};
617
618    #[derive(Default)]
619    struct EventLog(Mutex<Vec<AgentEvent>>);
620
621    #[tokio::test]
622    async fn cancelling_notice_poll_preserves_the_update_for_the_next_run() {
623        let started = CancelToken::new();
624        let release = CancelToken::new();
625        let model = ScriptedModel::new(vec![GenerateOutput {
626            content: vec![],
627            tool_uses: vec![],
628            turn_end_reason: TurnEndReason::EndTurn,
629            usage: None,
630        }]);
631        let mut agent = Agent::new(
632            model.clone(),
633            TestTools::new(vec![]),
634            Arc::new(NullEventSink),
635        );
636        agent.replace_context(vec![user("task")], None).unwrap();
637        agent.set_before_generation_notice(Some(Box::new({
638            let started = started.clone();
639            let release = release.clone();
640            move |_, _| {
641                let started = started.clone();
642                let release = release.clone();
643                Box::pin(async move {
644                    started.cancel();
645                    release.cancelled().await;
646                    Some("updated context".into())
647                })
648            }
649        })));
650        let cancel = CancelToken::new();
651        let (outcome, ()) = tokio::join!(agent.run(cancel.clone()), async {
652            started.cancelled().await;
653            cancel.cancel();
654        });
655        assert!(matches!(outcome, Err(AgentInteractionError::Cancelled)));
656        assert_eq!(model.remaining(), 1);
657        assert_eq!(
658            serde_json::to_value(agent.history()).unwrap(),
659            serde_json::to_value([user("task")]).unwrap()
660        );
661
662        let saved = Arc::new(Mutex::new(Vec::new()));
663        agent.set_checkpoint(Some(Box::new({
664            let saved = saved.clone();
665            move |state| {
666                if state.pending_operation().is_some() {
667                    *saved.lock().unwrap() = state.history().to_vec();
668                }
669                Ok(())
670            }
671        })));
672        release.cancel();
673        agent.run(CancelToken::new()).await.unwrap();
674        assert_eq!(model.remaining(), 0);
675        let snapshot = saved.lock().unwrap();
676        assert!(
677            matches!(snapshot.as_slice(), [Message::UserMessage { content }]
678            if matches!(content.last(), Some(Content::System { kind, text, .. })
679                if kind == "generation_notice" && text == "updated context"))
680        );
681        assert_eq!(agent.history().len(), 2);
682    }
683
684    impl EventSink for EventLog {
685        fn emit(&self, event: AgentEvent) {
686            self.0.lock().unwrap().push(event);
687        }
688    }
689
690    #[tokio::test]
691    async fn each_run_closes_its_event_stream_once_on_success_failure_or_cancel() {
692        let model = ScriptedModel::new(vec![GenerateOutput {
693            content: vec![],
694            tool_uses: vec![],
695            turn_end_reason: TurnEndReason::EndTurn,
696            usage: None,
697        }])
698        .then_fail(GenerateError::ExecutionError("unavailable".into()));
699        let events = Arc::new(EventLog::default());
700        let mut agent = Agent::new(model, TestTools::new(vec![]), events.clone());
701        let cancelled = CancelToken::new();
702        cancelled.cancel();
703        for (cancel, succeeds) in [
704            (CancelToken::new(), true),
705            (CancelToken::new(), false),
706            (cancelled, false),
707        ] {
708            let result = interact(&mut agent, vec![], cancel).await;
709            assert_eq!(result.is_ok(), succeeds);
710            let mut emitted = events.0.lock().unwrap();
711            assert!(matches!(
712                emitted.last(),
713                Some(AgentEvent::TurnFinished { .. })
714            ));
715            assert_eq!(
716                emitted
717                    .iter()
718                    .filter(|event| matches!(event, AgentEvent::TurnFinished { .. }))
719                    .count(),
720                1
721            );
722            emitted.clear();
723        }
724    }
725
726    /// Sleeps, records start/end instants, returns the configured label.
727    struct SlowService {
728        name: String,
729        delay: Duration,
730        starts: Arc<Mutex<Vec<(String, Instant)>>>,
731        ends: Arc<Mutex<Vec<(String, Instant)>>>,
732    }
733
734    impl ToolExecutor for SlowService {
735        fn tool_specs(&self) -> Vec<ToolSpec> {
736            vec![ToolSpec {
737                name: self.name.clone(),
738                description: format!("slow test tool {}", self.name),
739                input_schema: json!({
740                    "type": "object",
741                    "properties": {},
742                    "additionalProperties": false,
743                }),
744            }]
745        }
746
747        fn dispatch(
748            self: Arc<Self>,
749            tool_use: ToolUse,
750            _cancel: CancelToken,
751            _background: CancelToken,
752        ) -> Async<ToolResult> {
753            Box::pin(async move {
754                let started = Instant::now();
755                self.starts
756                    .lock()
757                    .unwrap()
758                    .push((tool_use.name.clone(), started));
759                tokio::time::sleep(self.delay).await;
760                let ended = Instant::now();
761                self.ends
762                    .lock()
763                    .unwrap()
764                    .push((tool_use.name.clone(), ended));
765                ToolResult::text(format!("done:{}", tool_use.name))
766            })
767        }
768    }
769
770    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
771    async fn concurrent_tool_uses_overlap_and_preserve_order() {
772        let starts = Arc::new(Mutex::new(Vec::new()));
773        let ends = Arc::new(Mutex::new(Vec::new()));
774        // Long enough that serial execution is unambiguous even under CI load.
775        let delay = Duration::from_millis(300);
776
777        // Two distinct tool names so the test executor can route both (same service type).
778        let slow_a = Arc::new(SlowService {
779            name: "slow_a".into(),
780            delay,
781            starts: starts.clone(),
782            ends: ends.clone(),
783        });
784        let slow_b = Arc::new(SlowService {
785            name: "slow_b".into(),
786            delay,
787            starts: starts.clone(),
788            ends: ends.clone(),
789        });
790
791        let tools = TestTools::new(vec![
792            slow_a as Arc<dyn ToolExecutor>,
793            slow_b as Arc<dyn ToolExecutor>,
794        ]);
795
796        let model = ScriptedModel::new(vec![
797            GenerateOutput {
798                content: vec![],
799                tool_uses: vec![
800                    ToolUse {
801                        name: "slow_a".into(),
802                        input: json!({}),
803                    },
804                    ToolUse {
805                        name: "slow_b".into(),
806                        input: json!({}),
807                    },
808                ],
809                turn_end_reason: TurnEndReason::ToolUse,
810                usage: None,
811            },
812            GenerateOutput {
813                content: vec![Content::Text {
814                    text: "all done".into(),
815                }],
816                tool_uses: vec![],
817                turn_end_reason: TurnEndReason::EndTurn,
818                usage: None,
819            },
820        ]);
821
822        let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
823        let wall_start = Instant::now();
824        let reply = interact(
825            &mut agent,
826            vec![Content::Text {
827                text: "run both".into(),
828            }],
829            crate::CancelToken::new(),
830        )
831        .await
832        .expect("interact");
833        let wall = wall_start.elapsed();
834
835        // Reply is the final assistant text.
836        assert_eq!(reply.len(), 1);
837        match &reply[0] {
838            Content::Text { text } => assert_eq!(text, "all done"),
839            other => panic!("expected text reply, got {other:?}"),
840        }
841
842        // History: user, assistant(tool_use), tool_results, assistant(end).
843        let history = agent.history();
844        assert_eq!(history.len(), 4);
845        match &history[2] {
846            Message::ToolResults { tool_use_results } => {
847                assert_eq!(tool_use_results.len(), 2);
848                // Order matches the original tool_uses list, not completion order.
849                assert_eq!(result_text(&tool_use_results[0]), "done:slow_a");
850                assert_eq!(result_text(&tool_use_results[1]), "done:slow_b");
851                assert!(!tool_use_results[0].is_error);
852                assert!(!tool_use_results[1].is_error);
853            }
854            other => panic!("expected ToolResults, got {other:?}"),
855        }
856
857        // Both tools started before either finished → concurrent.
858        let starts = starts.lock().unwrap().clone();
859        let ends = ends.lock().unwrap().clone();
860        assert_eq!(starts.len(), 2);
861        assert_eq!(ends.len(), 2);
862        let first_end = ends.iter().map(|(_, t)| *t).min().unwrap();
863        let last_start = starts.iter().map(|(_, t)| *t).max().unwrap();
864        assert!(
865            last_start < first_end,
866            "expected overlapping execution: last_start={last_start:?} first_end={first_end:?} starts={starts:?} ends={ends:?}"
867        );
868
869        // Overlap of starts/ends is the real concurrency signal. Wall clock is
870        // only a coarse guard against fully serial execution; allow large slack
871        // for CI / parallel suite load (scheduler jitter, other tests).
872        assert!(
873            wall < delay * 6 + Duration::from_secs(1),
874            "expected concurrent wall time ~1 delay, got {wall:?} (delay={delay:?})"
875        );
876    }
877
878    /// Tool intent must be durable before dispatch, and its result durable
879    /// before the following generation.
880    #[tokio::test]
881    async fn checkpoints_include_tool_intent_and_completed_observations() {
882        let slow = Arc::new(SlowService {
883            name: "slow_a".into(),
884            delay: Duration::from_millis(1),
885            starts: Arc::new(Mutex::new(Vec::new())),
886            ends: Arc::new(Mutex::new(Vec::new())),
887        });
888        let tools = TestTools::new(vec![slow as Arc<dyn ToolExecutor>]);
889        let model = ScriptedModel::new(vec![
890            GenerateOutput {
891                content: vec![],
892                tool_uses: vec![ToolUse {
893                    name: "slow_a".into(),
894                    input: json!({}),
895                }],
896                turn_end_reason: TurnEndReason::ToolUse,
897                usage: None,
898            },
899            GenerateOutput {
900                content: vec![Content::Text {
901                    text: "done".into(),
902                }],
903                tool_uses: vec![],
904                turn_end_reason: TurnEndReason::EndTurn,
905                usage: None,
906            },
907        ]);
908        let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
909        let snapshots: Arc<Mutex<Vec<AgentState>>> = Arc::new(Mutex::new(Vec::new()));
910        let record = snapshots.clone();
911        agent.set_checkpoint(Some(Box::new(move |state| {
912            validate_checkpoint(state.history(), state.pending_operation()).unwrap();
913            record.lock().unwrap().push(state.clone());
914            Ok(())
915        })));
916
917        interact(
918            &mut agent,
919            vec![Content::Text { text: "run".into() }],
920            crate::CancelToken::new(),
921        )
922        .await
923        .expect("interact");
924
925        let snapshots = snapshots.lock().unwrap();
926        let intent = snapshots
927            .iter()
928            .position(|state| {
929                matches!(
930                    state.pending_operation(),
931                    Some(PendingOperation::Tools { .. })
932                )
933            })
934            .unwrap();
935        let observed = snapshots
936            .iter()
937            .position(|state| matches!(state.history().last(), Some(Message::ToolResults { .. })))
938            .unwrap();
939        assert!(intent < observed);
940        assert!(matches!(
941            snapshots[observed].pending_operation(),
942            Some(PendingOperation::Generation { .. })
943        ));
944        assert_eq!(snapshots.first().unwrap().history().len(), 1);
945        let finished = snapshots.last().unwrap();
946        assert!(finished.pending_operation().is_none());
947        assert_eq!(finished.history().len(), 4);
948    }
949
950    #[tokio::test]
951    async fn failed_intent_checkpoint_prevents_tool_dispatch_and_another_generation() {
952        let starts = Arc::new(Mutex::new(Vec::new()));
953        let tool = Arc::new(SlowService {
954            name: "effect".into(),
955            delay: Duration::ZERO,
956            starts: starts.clone(),
957            ends: Arc::new(Mutex::new(Vec::new())),
958        });
959        let model = ScriptedModel::new(vec![
960            GenerateOutput {
961                content: vec![],
962                tool_uses: vec![ToolUse {
963                    name: "effect".into(),
964                    input: json!({}),
965                }],
966                turn_end_reason: TurnEndReason::ToolUse,
967                usage: None,
968            },
969            GenerateOutput {
970                content: vec![],
971                tool_uses: vec![],
972                turn_end_reason: TurnEndReason::EndTurn,
973                usage: None,
974            },
975        ]);
976        let mut agent = Agent::new(model, TestTools::new(vec![tool]), Arc::new(NullEventSink));
977        agent.set_checkpoint(Some(Box::new(|state| {
978            if matches!(
979                state.pending_operation(),
980                Some(PendingOperation::Tools { .. })
981            ) {
982                Err("disk full".into())
983            } else {
984                Ok(())
985            }
986        })));
987        let error = interact(
988            &mut agent,
989            vec![Content::Text {
990                text: "task".into(),
991            }],
992            CancelToken::new(),
993        )
994        .await
995        .unwrap_err();
996        assert!(matches!(error, AgentInteractionError::Checkpoint(_)));
997        assert_eq!(error.recovery(), Recovery::Stop);
998        assert!(starts.lock().unwrap().is_empty());
999        assert!(matches!(
1000            agent.state().pending_operation(),
1001            Some(PendingOperation::Tools { .. })
1002        ));
1003        assert!(matches!(
1004            agent.run(CancelToken::new()).await,
1005            Err(AgentInteractionError::State(StateError::Busy))
1006        ));
1007        assert!(starts.lock().unwrap().is_empty());
1008        agent.set_checkpoint(None);
1009        agent.continue_run(CancelToken::new()).await.unwrap();
1010        assert_eq!(starts.lock().unwrap().len(), 1);
1011    }
1012
1013    #[tokio::test]
1014    async fn failed_result_checkpoint_retains_observations_and_stops_before_the_next_model_call() {
1015        let starts = Arc::new(Mutex::new(Vec::new()));
1016        let tool = Arc::new(SlowService {
1017            name: "effect".into(),
1018            delay: Duration::ZERO,
1019            starts: starts.clone(),
1020            ends: Arc::new(Mutex::new(Vec::new())),
1021        });
1022        let model = ScriptedModel::new(vec![
1023            GenerateOutput {
1024                content: vec![],
1025                tool_uses: vec![ToolUse {
1026                    name: "effect".into(),
1027                    input: json!({}),
1028                }],
1029                turn_end_reason: TurnEndReason::ToolUse,
1030                usage: None,
1031            },
1032            GenerateOutput {
1033                content: vec![],
1034                tool_uses: vec![],
1035                turn_end_reason: TurnEndReason::EndTurn,
1036                usage: None,
1037            },
1038        ]);
1039        let mut agent = Agent::new(model, TestTools::new(vec![tool]), Arc::new(NullEventSink));
1040        agent.set_checkpoint(Some(Box::new(|state| {
1041            if matches!(state.history().last(), Some(Message::ToolResults { .. })) {
1042                Err("disk full".into())
1043            } else {
1044                Ok(())
1045            }
1046        })));
1047        let error = interact(
1048            &mut agent,
1049            vec![Content::Text {
1050                text: "task".into(),
1051            }],
1052            CancelToken::new(),
1053        )
1054        .await
1055        .unwrap_err();
1056        assert!(matches!(error, AgentInteractionError::Checkpoint(_)));
1057        assert_eq!(starts.lock().unwrap().len(), 1);
1058        assert!(
1059            matches!(agent.history().last(), Some(Message::ToolResults { tool_use_results }) if !tool_use_results[0].is_error)
1060        );
1061        assert!(matches!(
1062            agent.state().pending_operation(),
1063            Some(PendingOperation::Generation { .. })
1064        ));
1065        agent.set_checkpoint(None);
1066        agent.continue_run(CancelToken::new()).await.unwrap();
1067        assert_eq!(starts.lock().unwrap().len(), 1);
1068    }
1069
1070    #[tokio::test]
1071    async fn dropping_a_tool_step_requires_explicit_recovery_and_never_replays_it() {
1072        let starts = Arc::new(Mutex::new(Vec::new()));
1073        let tool = Arc::new(SlowService {
1074            name: "effect".into(),
1075            delay: Duration::from_secs(60),
1076            starts: starts.clone(),
1077            ends: Arc::new(Mutex::new(Vec::new())),
1078        });
1079        let model = ScriptedModel::new(vec![
1080            GenerateOutput {
1081                content: vec![],
1082                tool_uses: vec![ToolUse {
1083                    name: "effect".into(),
1084                    input: json!({}),
1085                }],
1086                turn_end_reason: TurnEndReason::ToolUse,
1087                usage: None,
1088            },
1089            GenerateOutput {
1090                content: vec![],
1091                tool_uses: vec![],
1092                turn_end_reason: TurnEndReason::EndTurn,
1093                usage: None,
1094            },
1095        ]);
1096        let mut agent = Agent::new(model, TestTools::new(vec![tool]), Arc::new(NullEventSink));
1097        agent
1098            .append_input(Message::UserMessage {
1099                content: vec![Content::Text {
1100                    text: "task".into(),
1101                }],
1102            })
1103            .unwrap();
1104        agent.start_run().unwrap();
1105        assert!(agent.step(CancelToken::new()).await.unwrap().is_none());
1106        {
1107            let step = agent.step(CancelToken::new());
1108            futures::pin_mut!(step);
1109            assert!(futures::poll!(step).is_pending());
1110        }
1111        assert_eq!(starts.lock().unwrap().len(), 1);
1112        assert!(matches!(
1113            agent.continue_run(CancelToken::new()).await,
1114            Err(AgentInteractionError::State(StateError::Busy))
1115        ));
1116        agent.recover_interrupted().unwrap();
1117        assert!(agent.history().iter().any(|message| matches!(message, Message::ToolResults { tool_use_results } if tool_use_results[0].is_error)));
1118        agent.run(CancelToken::new()).await.unwrap();
1119        assert_eq!(starts.lock().unwrap().len(), 1);
1120    }
1121
1122    #[tokio::test]
1123    async fn last_usage_sums_output_across_tool_round_trips() {
1124        let tool = Arc::new(SlowService {
1125            name: "fast".into(),
1126            delay: Duration::ZERO,
1127            starts: Arc::new(Mutex::new(Vec::new())),
1128            ends: Arc::new(Mutex::new(Vec::new())),
1129        });
1130        let tools = TestTools::new(vec![tool as Arc<dyn ToolExecutor>]);
1131
1132        let model = ScriptedModel::new(vec![
1133            GenerateOutput {
1134                content: vec![],
1135                tool_uses: vec![ToolUse {
1136                    name: "fast".into(),
1137                    input: json!({}),
1138                }],
1139                turn_end_reason: TurnEndReason::ToolUse,
1140                usage: Some(TokenUsage {
1141                    input_tokens: 1_000,
1142                    output_tokens: 500,
1143                    cached_input_tokens: 800,
1144                }),
1145            },
1146            GenerateOutput {
1147                content: vec![Content::Text {
1148                    text: "done".into(),
1149                }],
1150                tool_uses: vec![],
1151                turn_end_reason: TurnEndReason::EndTurn,
1152                usage: Some(TokenUsage {
1153                    input_tokens: 1_600,
1154                    output_tokens: 20,
1155                    cached_input_tokens: 900,
1156                }),
1157            },
1158        ]);
1159
1160        let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1161        interact(
1162            &mut agent,
1163            vec![Content::Text { text: "go".into() }],
1164            crate::CancelToken::new(),
1165        )
1166        .await
1167        .expect("interact");
1168
1169        // Input side tracks the latest request (the live context); output sums the turn.
1170        let usage = agent.last_usage().expect("usage recorded");
1171        assert_eq!(usage.input_tokens, 1_600);
1172        assert_eq!(usage.cached_input_tokens, 900);
1173        assert_eq!(usage.output_tokens, 520);
1174        assert_eq!(usage.context_tokens(), 1_600);
1175    }
1176
1177    /// Slow generate stream: cancel mid-stream must return Cancelled quickly.
1178    struct SlowStreamModel {
1179        delay: Duration,
1180        chunks: usize,
1181    }
1182
1183    impl GenerativeModel for SlowStreamModel {
1184        fn generate(&self, _input: &[Message]) -> myco_model::AsyncStream<GenerationEvent> {
1185            let delay = self.delay;
1186            let chunks = self.chunks;
1187            // State machine: 0 = MessageStart, 1 = ContentStart, 2..chunks+1 = delayed
1188            // deltas, last = TurnEndReason.
1189            Box::pin(stream::unfold(0usize, move |step| {
1190                let delay = delay;
1191                async move {
1192                    let last = chunks + 2;
1193                    if step > last {
1194                        return None;
1195                    }
1196                    let part = if step == 0 {
1197                        MessagePart::MessageStart
1198                    } else if step == 1 {
1199                        MessagePart::ContentStart(generative_model::ContentStart::Text { index: 0 })
1200                    } else if step <= chunks + 1 {
1201                        tokio::time::sleep(delay).await;
1202                        MessagePart::ContentDelta(ContentDelta::Text {
1203                            index: 0,
1204                            delta: format!("chunk{}", step - 2),
1205                        })
1206                    } else {
1207                        MessagePart::TurnEndReason(TurnEndReason::EndTurn)
1208                    };
1209                    Some((GenerationEvent::Part(part), step + 1))
1210                }
1211            }))
1212        }
1213    }
1214
1215    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1216    async fn cancel_during_generate_returns_cancelled() {
1217        let tools = TestTools::new(vec![]);
1218        let model = Arc::new(SlowStreamModel {
1219            delay: Duration::from_millis(200),
1220            chunks: 20,
1221        });
1222        let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1223        let cancel = crate::CancelToken::new();
1224        let cancel2 = cancel.clone();
1225        tokio::spawn(async move {
1226            tokio::time::sleep(Duration::from_millis(50)).await;
1227            cancel2.cancel();
1228        });
1229
1230        let t0 = Instant::now();
1231        let err = interact(
1232            &mut agent,
1233            vec![Content::Text { text: "go".into() }],
1234            cancel,
1235        )
1236        .await
1237        .expect_err("should cancel");
1238        let elapsed = t0.elapsed();
1239        assert!(
1240            matches!(err, AgentInteractionError::Cancelled),
1241            "got {err:?}"
1242        );
1243        assert!(
1244            // Prompt under light load; allow CI / suite contention headroom.
1245            elapsed < Duration::from_secs(2),
1246            "cancel should be prompt, took {elapsed:?}"
1247        );
1248        // User message kept; no incomplete assistant pushed.
1249        assert_eq!(agent.history().len(), 1);
1250        assert!(matches!(agent.history()[0], Message::UserMessage { .. }));
1251    }
1252
1253    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1254    async fn cancel_during_slow_tool_records_cancelled_result() {
1255        let starts = Arc::new(Mutex::new(Vec::new()));
1256        let ends = Arc::new(Mutex::new(Vec::new()));
1257        let slow = Arc::new(SlowService {
1258            name: "slow_a".into(),
1259            // Long enough that a delayed cancel still hits mid-tool under load.
1260            delay: Duration::from_secs(5),
1261            starts: starts.clone(),
1262            ends: ends.clone(),
1263        });
1264        let tools = TestTools::new(vec![slow as Arc<dyn ToolExecutor>]);
1265        let model = ScriptedModel::new(vec![GenerateOutput {
1266            content: vec![],
1267            tool_uses: vec![ToolUse {
1268                name: "slow_a".into(),
1269                input: json!({}),
1270            }],
1271            turn_end_reason: TurnEndReason::ToolUse,
1272            usage: None,
1273        }]);
1274        // No EndTurn scripted — cancel during tools must stop without another generate.
1275        let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1276        let cancel = crate::CancelToken::new();
1277        let cancel2 = cancel.clone();
1278        let starts_bg = starts.clone();
1279        tokio::spawn(async move {
1280            // Cancel only after the tool has started (not a fixed sleep race).
1281            let deadline = Instant::now() + Duration::from_secs(2);
1282            loop {
1283                if !starts_bg.lock().unwrap().is_empty() {
1284                    break;
1285                }
1286                if Instant::now() > deadline {
1287                    break;
1288                }
1289                tokio::time::sleep(Duration::from_millis(5)).await;
1290            }
1291            cancel2.cancel();
1292        });
1293
1294        let t0 = Instant::now();
1295        let err = interact(
1296            &mut agent,
1297            vec![Content::Text { text: "run".into() }],
1298            cancel,
1299        )
1300        .await
1301        .expect_err("should cancel");
1302        let elapsed = t0.elapsed();
1303        assert!(matches!(err, AgentInteractionError::Cancelled));
1304        // A cancel-ignoring tool is abandoned after CANCEL_TOOL_GRACE — the
1305        // turn must end well before the tool's own 5s delay.
1306        assert!(
1307            elapsed < Duration::from_secs(4),
1308            "should wait only the cancel grace, not the full tool delay, took {elapsed:?}"
1309        );
1310
1311        // user + assistant(tool_use) + tool_results (cancelled)
1312        let history = agent.history();
1313        assert_eq!(history.len(), 3);
1314        match &history[2] {
1315            Message::ToolResults { tool_use_results } => {
1316                assert_eq!(tool_use_results.len(), 1);
1317                assert!(tool_use_results[0].is_error);
1318                let text = result_text(&tool_use_results[0]);
1319                assert!(text.contains("effects are unknown"), "{text}");
1320            }
1321            other => panic!("expected ToolResults, got {other:?}"),
1322        }
1323    }
1324
1325    #[tokio::test]
1326    async fn generate_error_after_tool_results_keeps_well_formed_history() {
1327        let slow = Arc::new(SlowService {
1328            name: "slow_a".into(),
1329            delay: Duration::from_millis(1),
1330            starts: Arc::new(Mutex::new(Vec::new())),
1331            ends: Arc::new(Mutex::new(Vec::new())),
1332        });
1333        let tools = TestTools::new(vec![slow as Arc<dyn ToolExecutor>]);
1334        let model = ScriptedModel::new(vec![GenerateOutput {
1335            content: vec![],
1336            tool_uses: vec![ToolUse {
1337                name: "slow_a".into(),
1338                input: json!({}),
1339            }],
1340            turn_end_reason: TurnEndReason::ToolUse,
1341            usage: None,
1342        }])
1343        .then_fail(GenerateError::ExecutionError(
1344            "provider 500 after tools".into(),
1345        ));
1346        let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1347        let err = interact(
1348            &mut agent,
1349            vec![Content::Text {
1350                text: "run tool then fail".into(),
1351            }],
1352            crate::CancelToken::new(),
1353        )
1354        .await
1355        .expect_err("second generate should fail");
1356        assert!(
1357            matches!(err, AgentInteractionError::GenerateError(_)),
1358            "got {err:?}"
1359        );
1360
1361        // user + assistant(tool_use) + tool_results — no incomplete assistant.
1362        let history = agent.history();
1363        assert_eq!(history.len(), 3, "history={history:?}");
1364        assert!(matches!(history[0], Message::UserMessage { .. }));
1365        match &history[1] {
1366            Message::AssistantMessage {
1367                tool_uses,
1368                turn_end_reason,
1369                ..
1370            } => {
1371                assert_eq!(tool_uses.len(), 1);
1372                assert_eq!(tool_uses[0].name, "slow_a");
1373                assert_eq!(*turn_end_reason, Some(TurnEndReason::ToolUse));
1374            }
1375            other => panic!("expected assistant tool_use, got {other:?}"),
1376        }
1377        match &history[2] {
1378            Message::ToolResults { tool_use_results } => {
1379                assert_eq!(tool_use_results.len(), 1);
1380                assert!(!tool_use_results[0].is_error);
1381            }
1382            other => panic!("expected ToolResults, got {other:?}"),
1383        }
1384    }
1385
1386    #[tokio::test]
1387    async fn generate_error_before_assistant_keeps_only_user() {
1388        let tools = TestTools::new(vec![]);
1389        let model = ScriptedModel::new(vec![]).then_fail(GenerateError::ExecutionError(
1390            "boom on first generate".into(),
1391        ));
1392        let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1393        let err = interact(
1394            &mut agent,
1395            vec![Content::Text { text: "hi".into() }],
1396            crate::CancelToken::new(),
1397        )
1398        .await
1399        .expect_err("generate should fail");
1400        assert!(matches!(err, AgentInteractionError::GenerateError(_)));
1401        assert_eq!(agent.history().len(), 1);
1402        assert!(matches!(agent.history()[0], Message::UserMessage { .. }));
1403    }
1404
1405    /// A tool_use stop with zero streamed tool uses must fail loud, not loop
1406    /// generate forever on unchanged history or push empty ToolResults.
1407    #[tokio::test]
1408    async fn tool_use_stop_with_zero_tool_uses_errors_not_loops() {
1409        let tools = TestTools::new(vec![]);
1410        let model = ScriptedModel::new(vec![GenerateOutput {
1411            content: vec![Content::Text { text: "hmm".into() }],
1412            tool_uses: vec![],
1413            turn_end_reason: TurnEndReason::ToolUse,
1414            usage: None,
1415        }]);
1416        let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1417        let err = interact(
1418            &mut agent,
1419            vec![Content::Text { text: "hi".into() }],
1420            crate::CancelToken::new(),
1421        )
1422        .await
1423        .expect_err("malformed turn should error");
1424        assert!(matches!(err, AgentInteractionError::GenerateError(_)));
1425        // History stays well-formed: user + assistant, no ToolResults message.
1426        assert_eq!(agent.history().len(), 2);
1427        assert!(matches!(
1428            agent.history()[1],
1429            Message::AssistantMessage { .. }
1430        ));
1431    }
1432
1433    /// A truncated call still needs a result before generation can continue,
1434    /// including when the executor rejects it.
1435    #[tokio::test]
1436    async fn max_tokens_mid_tool_call_answers_the_dangling_tool_use_and_resumes() {
1437        let tools = TestTools::new(vec![]);
1438        let model = ScriptedModel::new(vec![
1439            GenerateOutput {
1440                content: vec![Content::Text {
1441                    text: "let me check".into(),
1442                }],
1443                tool_uses: vec![ToolUse {
1444                    name: "unavailable".into(),
1445                    input: serde_json::json!({}),
1446                }],
1447                turn_end_reason: TurnEndReason::MaxTokens,
1448                usage: None,
1449            },
1450            GenerateOutput {
1451                content: vec![Content::Text { text: "ok".into() }],
1452                tool_uses: vec![],
1453                turn_end_reason: TurnEndReason::EndTurn,
1454                usage: None,
1455            },
1456        ]);
1457        let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1458        let reply = interact(
1459            &mut agent,
1460            vec![Content::Text { text: "hi".into() }],
1461            crate::CancelToken::new(),
1462        )
1463        .await
1464        .expect("turn should resume through the truncation, not error");
1465
1466        // The truncation is resumed inside the same turn, so the caller gets the
1467        // continuation rather than the partial answer that preceded the tool call.
1468        assert!(matches!(&reply[0], Content::Text { text } if text == "ok"));
1469
1470        // user + assistant(tool_use) + tool_results + assistant — the tool_use is
1471        // answered, and the resumed generate appended its reply to the same turn.
1472        assert_eq!(agent.history().len(), 4);
1473        match &agent.history()[2] {
1474            Message::ToolResults { tool_use_results } => {
1475                assert_eq!(tool_use_results.len(), 1);
1476                assert!(tool_use_results[0].is_error);
1477                let text = result_text(&tool_use_results[0]);
1478                assert!(text.contains("unknown tool 'unavailable'"), "text={text}");
1479            }
1480            other => panic!("expected ToolResults, got {other:?}"),
1481        }
1482    }
1483
1484    /// A `max_tokens` stop with no tool calls leaves history on the assistant's
1485    /// cut-off message, which providers reject as a prefill. The turn resumes by
1486    /// asking for the rest in a user turn — the one continuation every provider
1487    /// accepts — so a truncated sentence finishes instead of dead-ending.
1488    #[tokio::test]
1489    async fn max_tokens_without_tool_calls_resumes_with_a_continue_turn() {
1490        let tools = TestTools::new(vec![]);
1491        let model = ScriptedModel::new(vec![
1492            GenerateOutput {
1493                content: vec![Content::Text {
1494                    text: "half a sen".into(),
1495                }],
1496                tool_uses: vec![],
1497                turn_end_reason: TurnEndReason::MaxTokens,
1498                usage: None,
1499            },
1500            GenerateOutput {
1501                content: vec![Content::Text {
1502                    text: "tence.".into(),
1503                }],
1504                tool_uses: vec![],
1505                turn_end_reason: TurnEndReason::EndTurn,
1506                usage: None,
1507            },
1508        ]);
1509        let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1510        let reply = interact(
1511            &mut agent,
1512            vec![Content::Text { text: "hi".into() }],
1513            crate::CancelToken::new(),
1514        )
1515        .await
1516        .expect("turn should resume through the truncation");
1517
1518        assert!(matches!(&reply[0], Content::Text { text } if text == "tence."));
1519
1520        // user + assistant(truncated) + user(continue) + assistant(rest).
1521        assert_eq!(agent.history().len(), 4);
1522        match &agent.history()[2] {
1523            Message::UserMessage { content } => match content.as_slice() {
1524                [Content::System { text, kind, .. }] => {
1525                    assert_eq!(text, CONTINUE_PROMPT);
1526                    assert_eq!(kind, "continuation");
1527                }
1528                other => panic!("expected one text block, got {other:?}"),
1529            },
1530            other => panic!("expected the continuation user turn, got {other:?}"),
1531        }
1532    }
1533
1534    /// A model whose output cap is too low truncates every turn. Resuming is
1535    /// capped so an unattended run stops instead of spending the night
1536    /// re-truncating; the turn still ends cleanly rather than erroring.
1537    #[tokio::test]
1538    async fn consecutive_max_tokens_resumes_are_bounded() {
1539        const CAP: u32 = 2;
1540        let tools = TestTools::new(vec![]);
1541        let truncated_with_tool_call = || GenerateOutput {
1542            content: vec![Content::Text {
1543                text: "still going".into(),
1544            }],
1545            tool_uses: vec![ToolUse {
1546                name: "bash".into(),
1547                input: serde_json::json!({}),
1548            }],
1549            turn_end_reason: TurnEndReason::MaxTokens,
1550            usage: None,
1551        };
1552        // One more script than the cap can consume, so the surplus proves the
1553        // loop stopped on the cap rather than on an exhausted script list.
1554        let scripts = (0..CAP + 2).map(|_| truncated_with_tool_call()).collect();
1555        let model = ScriptedModel::new(scripts);
1556        let mut agent = Agent::new(model.clone(), tools, Arc::new(NullEventSink));
1557        agent.set_max_truncated_resumes(CAP);
1558        interact(
1559            &mut agent,
1560            vec![Content::Text { text: "hi".into() }],
1561            crate::CancelToken::new(),
1562        )
1563        .await
1564        .expect("turn should hand back once the cap is hit, not error");
1565
1566        // The initial generate plus CAP resumes.
1567        assert_eq!(model.remaining() as u32, 1);
1568    }
1569
1570    /// `max_truncated_resumes = 0` is the opt-out: the turn hands back the
1571    /// partial answer, exactly as it did before resuming existed.
1572    #[tokio::test]
1573    async fn zero_max_truncated_resumes_hands_back_the_partial_answer() {
1574        let tools = TestTools::new(vec![]);
1575        let model = ScriptedModel::new(vec![
1576            GenerateOutput {
1577                content: vec![Content::Text {
1578                    text: "half a sen".into(),
1579                }],
1580                tool_uses: vec![],
1581                turn_end_reason: TurnEndReason::MaxTokens,
1582                usage: None,
1583            },
1584            GenerateOutput {
1585                content: vec![Content::Text {
1586                    text: "unused".into(),
1587                }],
1588                tool_uses: vec![],
1589                turn_end_reason: TurnEndReason::EndTurn,
1590                usage: None,
1591            },
1592        ]);
1593        let mut agent = Agent::new(model.clone(), tools, Arc::new(NullEventSink));
1594        agent.set_max_truncated_resumes(0);
1595        let reply = interact(
1596            &mut agent,
1597            vec![Content::Text { text: "hi".into() }],
1598            crate::CancelToken::new(),
1599        )
1600        .await
1601        .expect("turn should hand back the partial answer");
1602
1603        assert!(matches!(&reply[0], Content::Text { text } if text == "half a sen"));
1604        // user + assistant only: no continuation turn, no second generate.
1605        assert_eq!(agent.history().len(), 2);
1606        assert_eq!(model.remaining(), 1, "second script must stay unconsumed");
1607    }
1608
1609    /// A turn that ends cleanly with no tool calls gains no ToolResults message.
1610    #[tokio::test]
1611    async fn plain_end_turn_pushes_no_tool_results() {
1612        let tools = TestTools::new(vec![]);
1613        let model = ScriptedModel::new(vec![GenerateOutput {
1614            content: vec![Content::Text { text: "hi".into() }],
1615            tool_uses: vec![],
1616            turn_end_reason: TurnEndReason::EndTurn,
1617            usage: None,
1618        }]);
1619        let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1620        interact(
1621            &mut agent,
1622            vec![Content::Text { text: "hi".into() }],
1623            crate::CancelToken::new(),
1624        )
1625        .await
1626        .expect("turn should succeed");
1627        assert_eq!(agent.history().len(), 2);
1628    }
1629
1630    /// An oversized request is a property of the history, so the top-level
1631    /// error must say the last message has to come out — not "try again".
1632    /// (The rewind contract itself is proven by
1633    /// `rewind_drops_the_whole_turn_and_keeps_earlier_ones`.)
1634    #[tokio::test]
1635    async fn oversized_request_reports_omit_last_message() {
1636        let tools = TestTools::new(vec![]);
1637        let model = ScriptedModel::new(vec![])
1638            .then_fail(GenerateError::RequestTooLargeError("42 MiB".into()));
1639        let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1640        let err = interact(
1641            &mut agent,
1642            vec![Content::Image {
1643                source: "data:image/png;base64,AAAA".into(),
1644            }],
1645            crate::CancelToken::new(),
1646        )
1647        .await
1648        .expect_err("oversized request should fail");
1649
1650        assert_eq!(err.recovery(), Recovery::OmitLastMessage);
1651    }
1652
1653    /// History is well-formed after a cancel, so the same turn can be re-sent.
1654    /// (Generate-error recovery mapping is proven in `generative_model` tests.)
1655    #[test]
1656    fn cancelled_interaction_is_retryable() {
1657        assert_eq!(AgentInteractionError::Cancelled.recovery(), Recovery::Retry);
1658    }
1659
1660    /// Rewinding mid-turn drops the assistant/tool messages that followed the
1661    /// user message too — the remaining prefix must end where the *previous*
1662    /// turn ended, or the next request is malformed.
1663    #[tokio::test]
1664    async fn truncating_context_drops_the_whole_turn_and_keeps_earlier_ones() {
1665        let tools = TestTools::new(vec![]);
1666        let model = ScriptedModel::new(vec![]);
1667        let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1668        agent
1669            .replace_context(
1670                vec![
1671                    user("first"),
1672                    assistant("ok"),
1673                    user("second"),
1674                    assistant_tool(None, "noop", json!({})),
1675                    tool_results(&["done"]),
1676                ],
1677                None,
1678            )
1679            .unwrap();
1680
1681        let dropped = agent.truncate_history(2).unwrap();
1682        assert!(
1683            matches!(&dropped[0], Message::UserMessage { content } if matches!(&content[0], Content::Text { text } if text == "second"))
1684        );
1685
1686        let history = agent.history();
1687        assert_eq!(history.len(), 2);
1688        assert!(matches!(history[0], Message::UserMessage { .. }));
1689        assert!(matches!(history[1], Message::AssistantMessage { .. }));
1690    }
1691
1692    /// Simulate crash after tools: persist history, new agent + model resumes and ends turn.
1693    #[tokio::test]
1694    async fn resume_after_tools_mid_turn_continues_cleanly() {
1695        // The well-formed mid-turn snapshot a checkpoint would have persisted
1696        // before the crash: user + assistant(tool_use) + matching tool_results
1697        // (the shape `generate_error_after_tool_results_keeps_well_formed_history`
1698        // proves the agent leaves behind).
1699        let snapshot = vec![
1700            user("mid turn"),
1701            assistant_tool(None, "slow_a", json!({})),
1702            tool_results(&["done:slow_a"]),
1703        ];
1704
1705        // "Resume": new agent, same well-formed history, model only needs EndTurn.
1706        let tools = TestTools::new(vec![]);
1707        let resume_model = ScriptedModel::new(vec![GenerateOutput {
1708            content: vec![Content::Text {
1709                text: "recovered".into(),
1710            }],
1711            tool_uses: vec![],
1712            turn_end_reason: TurnEndReason::EndTurn,
1713            usage: None,
1714        }]);
1715        let mut resumed = Agent::new(resume_model, tools, Arc::new(NullEventSink));
1716        resumed.replace_context(snapshot, None).unwrap();
1717
1718        // Continue by interacting with a follow-up user message (CLI would re-prompt);
1719        // history already has tool_results so a fresh user turn is the normal path.
1720        // Also verify the restored context is well-formed for provider requests by
1721        // checking the model can complete a new turn on top.
1722        let reply = interact(
1723            &mut resumed,
1724            vec![Content::Text {
1725                text: "continue".into(),
1726            }],
1727            crate::CancelToken::new(),
1728        )
1729        .await
1730        .expect("resume interact");
1731        assert_eq!(reply.len(), 1);
1732        match &reply[0] {
1733            Content::Text { text } => assert_eq!(text, "recovered"),
1734            other => panic!("expected text, got {other:?}"),
1735        }
1736
1737        let history = resumed.history();
1738        // prior 3 + new user + new assistant
1739        assert_eq!(history.len(), 5);
1740        assert!(matches!(history[0], Message::UserMessage { .. }));
1741        assert!(matches!(history[1], Message::AssistantMessage { .. }));
1742        assert!(matches!(history[2], Message::ToolResults { .. }));
1743        assert!(matches!(history[3], Message::UserMessage { .. }));
1744        assert!(matches!(history[4], Message::AssistantMessage { .. }));
1745    }
1746}