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}