Skip to main content

newt_core/agentic/
driver.rs

1//! A **non-blocking turn driver** around [`chat_complete`](super::chat_complete)
2//! (issue #308 — the cowork foundation).
3//!
4//! [`chat_complete`] is `async` and, inside `run_chat`, is pumped by a blocking
5//! `rustyline` REPL (`block_in_place` + `block_on`). A downstream `ratatui`
6//! app cannot block its event loop like that: it must poll input, redraw, and
7//! poll the turn's progress every frame. This module is that seam — a struct an
8//! external `crossterm` event loop drives without ever calling `run_chat`:
9//!
10//! ```text
11//! loop {                                 // the consumer's crossterm loop
12//!     if event::poll(..) { /* read key, maybe driver.submit(line) */ }
13//!     match driver.poll() {              // non-blocking
14//!         TurnStatus::Completed { .. } => { /* redraw transcript */ }
15//!         TurnStatus::Running          => { /* show a spinner */ }
16//!         _ => {}
17//!     }
18//!     // turn driver.transcript() into rows via transcript_lines(.., width)
19//!     terminal.draw(|f| draw(transcript_lines(driver.transcript(), f.area().width as usize)));
20//! }
21//! ```
22//!
23//! ## How it spans the async boundary
24//!
25//! [`ChatCtx`](super::ChatCtx) is borrow-heavy — most of its fields are
26//! references with the caller's lifetime, several are `&mut dyn` session
27//! handles (note sink, recall source, permission gate, …). Those trait objects
28//! aren't `Send`, so `chat_complete`'s future isn't `Send` either and cannot go
29//! through `tokio::spawn`. So the driver owns a [`TurnDriverConfig`] of plain
30//! owned values and, on [`submit`](TurnDriver::submit), launches a **dedicated
31//! OS thread** running its own current-thread tokio runtime. That thread builds
32//! a **headless** `ChatCtx` from a clone of the config plus a snapshot of the
33//! transcript, `block_on`s one turn against [`NoMcp`] (raced against a cancel
34//! signal), and sends the result back over a [`oneshot`] channel. The
35//! session-bound `Option` fields (`note_sink`, `recall_source`, `summarizer`,
36//! `compress_state`, …) are all `None` here: a cowork turn is a clean drive,
37//! exactly like the ACP worker / `newt-eval` headless callers. Compression and
38//! budget logic are untouched — the driver wraps `chat_complete`, it does not
39//! reach inside it.
40//!
41//! Running on its own thread also means the driver does **not** require the
42//! consumer to call from inside a tokio runtime — a plain `crossterm` loop can
43//! own a `TurnDriver` with no async scaffolding of its own.
44//!
45//! ## Minimal by design
46//!
47//! `submit` → `poll` → `cancel`, plus `submit_observation` to fold a
48//! [`ShellObservation`] into the next turn's context. That is the whole
49//! surface; richer wiring (live MCP tools, a session note store) stays with the
50//! downstream consumer, which can build a full `ChatCtx` itself if it needs
51//! one.
52
53use std::thread::JoinHandle;
54
55use tokio::sync::oneshot;
56
57use super::observation::ShellObservation;
58use super::{chat_complete, openai_chat_complete, ChatCtx, NoMcp};
59use crate::{BackendKind, MemMessage, Role, TokenUsage};
60
61/// Owned, `'static` configuration for one [`TurnDriver`] — the cloneable
62/// counterpart of the borrow-heavy [`ChatCtx`]. The driver clones it into each
63/// spawned turn task so nothing borrows across the async boundary.
64///
65/// The fields mirror the inference-relevant subset of [`ChatCtx`]; the
66/// session-bound `&mut` handles (note sink, recall, permission gate, summarizer,
67/// compress state) are intentionally absent — a driven cowork turn is headless.
68#[derive(Debug, Clone)]
69pub struct TurnDriverConfig {
70    /// Inference endpoint base URL.
71    pub url: String,
72    /// Model name.
73    pub model: String,
74    /// Wire protocol of the backend.
75    pub kind: BackendKind,
76    /// Bearer token for authenticated OpenAI-compatible endpoints.
77    pub api_key: Option<String>,
78    /// Absolute workspace path the tool loop runs against.
79    pub workspace: String,
80    /// Permission caveats enforced for this turn's tool calls.
81    pub caveats: crate::caveats::Caveats,
82    /// Maximum tool-call rounds before a forced final completion.
83    pub max_tool_rounds: usize,
84    /// Additional progress-aware rounds after `max_tool_rounds`; `0` makes the
85    /// normal cap hard.
86    pub workflow_grace_rounds: usize,
87    /// Max lines of tool output shown inline.
88    pub tool_output_lines: usize,
89    /// Ollama `options.num_ctx` (ignored on the OpenAI path).
90    pub num_ctx: Option<u32>,
91    /// TCP connect timeout.
92    pub connect_timeout_secs: u64,
93    /// Total inference timeout.
94    pub inference_timeout_secs: u64,
95    /// Message count at which the in-flight conversation is trimmed mid-turn.
96    pub mid_loop_trim_threshold: usize,
97    /// Token threshold that also triggers a mid-loop trim. `None` disables it.
98    pub mid_loop_trim_tokens: Option<usize>,
99    /// Highest input-token count the model has accepted (pre-send budget gate).
100    pub max_ok_input: Option<u32>,
101    /// Shell command run after every successful write for ground-truth feedback.
102    pub build_check_cmd: Option<String>,
103    /// Empirically safe context size used to detect likely overflow.
104    pub safe_context: Option<u32>,
105}
106
107impl TurnDriverConfig {
108    /// Construct a config with sensible cowork defaults; only the endpoint, the
109    /// model, the backend kind, and the workspace are required. Caveats default
110    /// to [`Caveats::top`](crate::caveats::Caveats::top) — the downstream
111    /// consumer is expected to narrow them.
112    pub fn new(
113        url: impl Into<String>,
114        model: impl Into<String>,
115        kind: BackendKind,
116        workspace: impl Into<String>,
117    ) -> Self {
118        Self {
119            url: url.into(),
120            model: model.into(),
121            kind,
122            api_key: None,
123            workspace: workspace.into(),
124            caveats: crate::caveats::Caveats::top(),
125            max_tool_rounds: 40,
126            workflow_grace_rounds: 5,
127            tool_output_lines: 20,
128            num_ctx: None,
129            connect_timeout_secs: 5,
130            inference_timeout_secs: 120,
131            mid_loop_trim_threshold: 40,
132            mid_loop_trim_tokens: None,
133            max_ok_input: None,
134            build_check_cmd: None,
135            safe_context: None,
136        }
137    }
138}
139
140/// The outcome of one completed turn.
141#[derive(Debug, Clone)]
142pub struct TurnOutcome {
143    /// The model's reply text.
144    pub reply: String,
145    /// Whether the reply was streamed token-by-token by the loop (it prints to
146    /// stdout when `color` is on; the driver always runs headless, so this is
147    /// informational).
148    pub was_streamed: bool,
149    /// Token usage for the turn, when the backend reported it.
150    pub usage: Option<TokenUsage>,
151    /// Count of hallucinated (non-existent) tool calls the loop saw.
152    pub hallucinations: u32,
153}
154
155/// Non-blocking snapshot of the driver's state, returned by
156/// [`TurnDriver::poll`].
157#[derive(Debug)]
158pub enum TurnStatus {
159    /// No turn in flight (nothing submitted, or the last result was drained).
160    Idle,
161    /// A turn is running; the result is not ready yet.
162    Running,
163    /// A turn finished successfully. Returned **once**; the reply has already
164    /// been appended to the transcript as an assistant message.
165    Completed(TurnOutcome),
166    /// A turn failed (transport error, dispatch error, …). Returned **once**.
167    Failed(String),
168}
169
170/// Internal in-flight turn handle: the worker thread, the channel it reports
171/// on, and the cancel signal that races its `block_on`.
172struct InFlight {
173    handle: JoinHandle<()>,
174    rx: oneshot::Receiver<Result<TurnOutcome, String>>,
175    cancel_tx: Option<oneshot::Sender<()>>,
176}
177
178/// A pumpable, non-blocking driver for one agentic turn at a time.
179///
180/// Owns the running transcript and at most one in-flight turn. The external
181/// event loop [`submit`](Self::submit)s input (or
182/// [`submit_observation`](Self::submit_observation)s shell activity),
183/// [`poll`](Self::poll)s for completion every frame, and may
184/// [`cancel`](Self::cancel) the in-flight turn.
185pub struct TurnDriver {
186    config: TurnDriverConfig,
187    transcript: Vec<MemMessage>,
188    in_flight: Option<InFlight>,
189}
190
191impl TurnDriver {
192    /// Build a driver with an empty transcript.
193    pub fn new(config: TurnDriverConfig) -> Self {
194        Self {
195            config,
196            transcript: Vec::new(),
197            in_flight: None,
198        }
199    }
200
201    /// Build a driver seeded with an existing transcript (e.g. a system prompt
202    /// plus prior turns the consumer already assembled).
203    pub fn with_transcript(config: TurnDriverConfig, transcript: Vec<MemMessage>) -> Self {
204        Self {
205            config,
206            transcript,
207            in_flight: None,
208        }
209    }
210
211    /// The current transcript — every message the model has seen plus the
212    /// replies it produced. Feed it to
213    /// [`transcript_lines`](super::transcript_lines) for renderer-agnostic,
214    /// width-wrapped rows, or render it however the consumer likes.
215    pub fn transcript(&self) -> &[MemMessage] {
216        &self.transcript
217    }
218
219    /// Whether a turn is currently in flight.
220    pub fn is_running(&self) -> bool {
221        self.in_flight.is_some()
222    }
223
224    /// Append a [`ShellObservation`] to the transcript so it becomes part of the
225    /// **next** turn's context. The observation is redacted and framed by
226    /// [`ShellObservation::into_mem_message`] — credentials in shell output are
227    /// scrubbed before they enter the transcript. This does **not** start a
228    /// turn on its own; a real human message ([`submit`](Self::submit)) drives
229    /// the loop, with the accumulated observations already in context.
230    pub fn submit_observation(&mut self, obs: ShellObservation) {
231        self.transcript.push(obs.into_mem_message());
232    }
233
234    /// Submit a human message and start a turn.
235    ///
236    /// Appends the message as a `User` turn, snapshots the transcript, and
237    /// spawns a task that runs one [`chat_complete`] against the configured
238    /// backend. Returns an error (without starting anything) if a turn is
239    /// already in flight — the driver runs one turn at a time. Poll
240    /// [`poll`](Self::poll) for the result.
241    pub fn submit(&mut self, input: impl Into<String>) -> Result<(), TurnDriverError> {
242        if self.in_flight.is_some() {
243            return Err(TurnDriverError::Busy);
244        }
245        let task = input.into();
246        self.transcript.push(MemMessage::user(task.clone()));
247        self.spawn_turn(task);
248        Ok(())
249    }
250
251    /// Launch the turn on a dedicated thread over a clone of the config + a
252    /// snapshot of the transcript. The `task` string is the current user
253    /// message, threaded into `ChatCtx.task` (the loop's nudges key on it).
254    ///
255    /// `chat_complete`'s future is `!Send` (its `ChatCtx` holds `&mut dyn`
256    /// session handles), so it can't ride `tokio::spawn`. A current-thread
257    /// runtime on its own OS thread keeps the non-`Send` future local; a
258    /// cancel oneshot races the turn so [`cancel`](Self::cancel) returns the
259    /// thread promptly instead of waiting out the inference timeout.
260    fn spawn_turn(&mut self, task: String) {
261        let config = self.config.clone();
262        let messages = self.transcript.clone();
263        let (tx, rx) = oneshot::channel();
264        let (cancel_tx, cancel_rx) = oneshot::channel();
265        let handle = std::thread::spawn(move || {
266            // A current-thread runtime: no work-stealing, nothing to require
267            // `Send`, and self-contained so the consumer needn't be in a
268            // runtime itself.
269            let rt = match tokio::runtime::Builder::new_current_thread()
270                .enable_all()
271                .build()
272            {
273                Ok(rt) => rt,
274                Err(e) => {
275                    let _ = tx.send(Err(format!("failed to start turn runtime: {e}")));
276                    return;
277                }
278            };
279            let result = rt.block_on(async move {
280                tokio::select! {
281                    biased;
282                    // Cancellation wins the race when signalled.
283                    _ = cancel_rx => Err("turn cancelled".to_string()),
284                    out = run_one_turn(&config, &messages, &task) => out,
285                }
286            });
287            // The receiver may have been dropped (driver went away); fine.
288            let _ = tx.send(result);
289        });
290        self.in_flight = Some(InFlight {
291            handle,
292            rx,
293            cancel_tx: Some(cancel_tx),
294        });
295    }
296
297    /// Non-blocking poll for the in-flight turn's status.
298    ///
299    /// - [`TurnStatus::Idle`] — nothing in flight.
300    /// - [`TurnStatus::Running`] — in flight, not done.
301    /// - [`TurnStatus::Completed`] — done; the reply has been appended to the
302    ///   transcript as an assistant message. Returned exactly once.
303    /// - [`TurnStatus::Failed`] — errored; nothing appended. Returned once.
304    pub fn poll(&mut self) -> TurnStatus {
305        let Some(in_flight) = self.in_flight.as_mut() else {
306            return TurnStatus::Idle;
307        };
308        match in_flight.rx.try_recv() {
309            Ok(Ok(outcome)) => {
310                self.transcript
311                    .push(MemMessage::assistant(outcome.reply.clone()));
312                self.in_flight = None;
313                TurnStatus::Completed(outcome)
314            }
315            Ok(Err(err)) => {
316                self.in_flight = None;
317                TurnStatus::Failed(err)
318            }
319            Err(oneshot::error::TryRecvError::Empty) => TurnStatus::Running,
320            Err(oneshot::error::TryRecvError::Closed) => {
321                // The task ended without sending (it was aborted, or panicked).
322                self.in_flight = None;
323                TurnStatus::Failed("turn task ended without a result".to_string())
324            }
325        }
326    }
327
328    /// Cancel the in-flight turn, if any. Signals the worker thread to abandon
329    /// the turn (winning the `select!` race against `chat_complete`) and joins
330    /// it so the runtime tears down before returning. The user message that
331    /// started the turn stays in the transcript — the consumer can pop it for a
332    /// clean retry. No-op when idle.
333    ///
334    /// A turn already blocked deep inside a single HTTP dispatch returns when
335    /// that dispatch resolves (or times out); the cancel signal is honored at
336    /// the next `select!` poll point, which in practice is between dispatches.
337    pub fn cancel(&mut self) {
338        if let Some(mut in_flight) = self.in_flight.take() {
339            if let Some(cancel_tx) = in_flight.cancel_tx.take() {
340                let _ = cancel_tx.send(());
341            }
342            let _ = in_flight.handle.join();
343        }
344    }
345}
346
347/// Run exactly one turn: assemble a headless [`ChatCtx`] from owned config +
348/// messages and dispatch on the right wire protocol. Factored out of the spawn
349/// closure so it is directly unit-testable against a wiremock backend.
350async fn run_one_turn(
351    config: &TurnDriverConfig,
352    messages: &[MemMessage],
353    task: &str,
354) -> Result<TurnOutcome, String> {
355    let ctx = ChatCtx {
356        url: &config.url,
357        model: &config.model,
358        kind: config.kind,
359        api_key: config.api_key.as_deref(),
360        messages,
361        task,
362        workspace: &config.workspace,
363        // The driver is headless: the loop's inline progress prints are
364        // suppressed so nothing fights the consumer's ratatui frame.
365        color: false,
366        // Cowork renders into the consumer's frame, not the scroller — no
367        // markdown ANSI here (it would fight the host UI).
368        markdown: false,
369        // Headless: no tool-offload (26.3) / scratchpad (26.4) — bit-for-bit.
370        tool_offload: false,
371        spill_store: None,
372        compaction_store: None,
373        scratchpad: false,
374        scratchpad_store: None,
375        code_search: None,
376        experience_store: None,
377        step_ledger: None,
378        caveats: &config.caveats,
379        max_tool_rounds: config.max_tool_rounds,
380        workflow_grace_rounds: config.workflow_grace_rounds,
381        tool_output_lines: config.tool_output_lines,
382        debug: false,
383        trace: false,
384        num_ctx: config.num_ctx,
385        connect_timeout_secs: config.connect_timeout_secs,
386        inference_timeout_secs: config.inference_timeout_secs,
387        mid_loop_trim_threshold: config.mid_loop_trim_threshold,
388        mid_loop_trim_tokens: config.mid_loop_trim_tokens,
389        max_ok_input: config.max_ok_input,
390        build_check_cmd: config.build_check_cmd.clone(),
391        safe_context: config.safe_context,
392        // Session-bound seams a driven cowork turn does not carry (headless,
393        // exactly like the ACP worker / newt-eval callers).
394        recover_cw_400: None,
395        note_sink: None,
396        note_nudge: None,
397        recall_source: None,
398        memory_source: None,
399        summarizer: None,
400        compress_state: None,
401        tool_events: None,
402        phantom_reaches: None,
403        permission_gate: None,
404        // Phase 20 (spec §5): headless surfaces neither read nor write the
405        // capability cache — the hook stays absent and no calibration is
406        // applied, preserving today's behavior exactly.
407        on_round_usage: None,
408        estimate_ratio: None,
409        estimation: crate::tokens::TokenEstimation::default(),
410        summary_input_cap_floor_chars: 8_192,
411        // #307: the headless driver carries no preset clamp — exec authority is
412        // whatever `config.caveats` already grants, exactly like the ACP worker
413        // / newt-eval callers. A consumer enforcing a mode clamps `caveats` itself.
414        exec_floor: None,
415        write_ledger: None,
416        // The headless driver has no interactive keyboard to interrupt from;
417        // a consumer that wants cancellation drives its own ChatCtx.
418        cancel: None,
419        git_tool: None,
420        crew_runner: None,
421    };
422    // NoMcp: the cowork driver advertises only the built-in tools. A consumer
423    // that wants live MCP tools assembles its own ChatCtx.
424    let mut mcp = NoMcp;
425    let dispatch = if config.kind == BackendKind::Openai {
426        openai_chat_complete(ctx, &mut mcp).await
427    } else {
428        chat_complete(ctx, &mut mcp).await
429    };
430    match dispatch {
431        Ok((reply, was_streamed, usage, hallucinations)) => Ok(TurnOutcome {
432            reply,
433            was_streamed,
434            usage,
435            hallucinations,
436        }),
437        Err(e) => Err(e.to_string()),
438    }
439}
440
441/// Errors from driving a turn.
442#[derive(Debug, thiserror::Error)]
443pub enum TurnDriverError {
444    /// A turn is already in flight; the driver runs one at a time.
445    #[error("a turn is already in flight — poll() for it or cancel() before submitting another")]
446    Busy,
447}
448
449/// Roles whose messages a transcript renderer typically shows to the human. The
450/// driver exposes it so a consumer's render can filter on the same set the
451/// newt-tui render uses (system + tool turns are usually hidden).
452pub const VISIBLE_TRANSCRIPT_ROLES: [Role; 2] = [Role::User, Role::Assistant];
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::caveats::Caveats;
458    use std::sync::atomic::{AtomicUsize, Ordering};
459    use std::sync::Arc;
460    use std::time::Duration;
461    use wiremock::matchers::{method, path};
462    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
463
464    /// Ollama-shaped responder: a plain text answer (no tools), echoing back a
465    /// fixed reply. Counts how many chat requests it served.
466    struct PlainOllama {
467        served: Arc<AtomicUsize>,
468        reply: String,
469    }
470
471    impl Respond for PlainOllama {
472        fn respond(&self, _req: &Request) -> ResponseTemplate {
473            self.served.fetch_add(1, Ordering::SeqCst);
474            ResponseTemplate::new(200).set_body_json(serde_json::json!({
475                "message": { "content": self.reply }
476            }))
477        }
478    }
479
480    fn cfg(url: &str) -> TurnDriverConfig {
481        let mut c = TurnDriverConfig::new(url, "test-model", BackendKind::Ollama, ".");
482        c.caveats = Caveats::top();
483        c
484    }
485
486    /// Pump the driver to completion the way a crossterm loop would: poll on an
487    /// interval until the turn resolves, never blocking the "frame".
488    async fn pump_to_done(driver: &mut TurnDriver) -> TurnStatus {
489        for _ in 0..600 {
490            match driver.poll() {
491                TurnStatus::Running => tokio::time::sleep(Duration::from_millis(10)).await,
492                other => return other,
493            }
494        }
495        panic!("turn did not complete within the pump budget");
496    }
497
498    /// THE acceptance test: a consumer drives one turn through the driver and
499    /// gets the answer back — no `run_chat`, no blocking REPL, only the
500    /// submit → poll surface.
501    #[tokio::test]
502    async fn driver_pumps_one_turn_and_yields_the_answer() {
503        let server = MockServer::start().await;
504        let served = Arc::new(AtomicUsize::new(0));
505        Mock::given(method("POST"))
506            .and(path("/api/chat"))
507            .respond_with(PlainOllama {
508                served: served.clone(),
509                reply: "the driver answered".into(),
510            })
511            .mount(&server)
512            .await;
513
514        let mut driver = TurnDriver::new(cfg(&server.uri()));
515        // Idle before anything is submitted.
516        assert!(matches!(driver.poll(), TurnStatus::Idle));
517        assert!(!driver.is_running());
518
519        driver
520            .submit("what is 2 + 2?")
521            .expect("submit starts a turn");
522        assert!(driver.is_running());
523
524        let status = pump_to_done(&mut driver).await;
525        match status {
526            TurnStatus::Completed(outcome) => {
527                assert_eq!(outcome.reply, "the driver answered");
528            }
529            other => panic!("expected Completed, got {other:?}"),
530        }
531
532        // The transcript now holds the user turn AND the assistant reply, with
533        // no run_chat involved.
534        let t = driver.transcript();
535        assert_eq!(t.len(), 2);
536        assert_eq!(t[0].role, Role::User);
537        assert_eq!(t[0].content, "what is 2 + 2?");
538        assert_eq!(t[1].role, Role::Assistant);
539        assert_eq!(t[1].content, "the driver answered");
540
541        // The backend served exactly the streaming re-issue + probe (the
542        // tools-present probe then the stream); at least one request landed.
543        assert!(served.load(Ordering::SeqCst) >= 1);
544        // Drained — back to idle.
545        assert!(matches!(driver.poll(), TurnStatus::Idle));
546        assert!(!driver.is_running());
547    }
548
549    /// A shell observation feeds the NEXT turn's context, redacted. We prove the
550    /// observation reaches the backend's request body (so the model sees it) and
551    /// that a secret in it never does.
552    #[tokio::test]
553    async fn observation_is_in_context_for_the_next_turn_and_redacted() {
554        let server = MockServer::start().await;
555        // Capture the request body the model would see.
556        let seen = Arc::new(std::sync::Mutex::new(String::new()));
557        struct Capture {
558            seen: Arc<std::sync::Mutex<String>>,
559        }
560        impl Respond for Capture {
561            fn respond(&self, req: &Request) -> ResponseTemplate {
562                *self.seen.lock().unwrap() = String::from_utf8_lossy(&req.body).into_owned();
563                ResponseTemplate::new(200).set_body_json(serde_json::json!({
564                    "message": { "content": "ack" }
565                }))
566            }
567        }
568        Mock::given(method("POST"))
569            .and(path("/api/chat"))
570            .respond_with(Capture { seen: seen.clone() })
571            .mount(&server)
572            .await;
573
574        let mut driver = TurnDriver::new(cfg(&server.uri()));
575        driver.submit_observation(ShellObservation::new(
576            "zsh",
577            "$ cat creds\nsecret_key=wJalrXUtnFEMIabcdEFGHIJKLMNOPbPxRfiCY",
578        ));
579        // Observation alone does not start a turn.
580        assert!(!driver.is_running());
581        assert_eq!(driver.transcript().len(), 1);
582
583        driver.submit("did you see my shell?").expect("submit");
584        let _ = pump_to_done(&mut driver).await;
585
586        let body = seen.lock().unwrap().clone();
587        // The observation framing reached the model …
588        assert!(
589            body.contains("shell observation"),
590            "observation missing from request body: {body}"
591        );
592        // … but the secret value did not.
593        assert!(
594            !body.contains("wJalrXUtnFEMIabcdEFGHIJKLMNOPbPxRfiCY"),
595            "secret leaked into the request body: {body}"
596        );
597    }
598
599    /// Submitting while a turn is in flight is rejected — one turn at a time.
600    #[tokio::test]
601    async fn second_submit_while_running_is_busy() {
602        let server = MockServer::start().await;
603        Mock::given(method("POST"))
604            .and(path("/api/chat"))
605            // Delay so the first turn is reliably still in flight.
606            .respond_with(
607                ResponseTemplate::new(200)
608                    .set_delay(Duration::from_millis(200))
609                    .set_body_json(serde_json::json!({ "message": { "content": "slow" } })),
610            )
611            .mount(&server)
612            .await;
613
614        let mut driver = TurnDriver::new(cfg(&server.uri()));
615        driver.submit("first").expect("first submit");
616        let err = driver
617            .submit("second")
618            .expect_err("second must be rejected");
619        assert!(matches!(err, TurnDriverError::Busy));
620        // Let it finish so the test doesn't leak the task.
621        let _ = pump_to_done(&mut driver).await;
622    }
623
624    /// Cancel aborts the in-flight turn and returns the driver to idle.
625    #[tokio::test]
626    async fn cancel_aborts_the_in_flight_turn() {
627        let server = MockServer::start().await;
628        Mock::given(method("POST"))
629            .and(path("/api/chat"))
630            .respond_with(
631                ResponseTemplate::new(200)
632                    .set_delay(Duration::from_secs(30))
633                    .set_body_json(serde_json::json!({ "message": { "content": "never" } })),
634            )
635            .mount(&server)
636            .await;
637
638        let mut driver = TurnDriver::new(cfg(&server.uri()));
639        driver.submit("start a slow turn").expect("submit");
640        assert!(driver.is_running());
641        driver.cancel();
642        assert!(!driver.is_running());
643        assert!(matches!(driver.poll(), TurnStatus::Idle));
644        // The user message that started it survives for a clean retry.
645        assert_eq!(driver.transcript().len(), 1);
646        assert_eq!(driver.transcript()[0].content, "start a slow turn");
647    }
648}