Skip to main content

synaps_cli/
watcher_session.rs

1//! Watcher agent (`synaps agent`) session accounting and turn driving.
2//!
3//! The agent's `[limits]` (session tokens, cost, time, tool calls, and the
4//! daily cost cap) are enforced by the ENGINE inside each turn: before every
5//! turn the role budget is narrowed to what the session has left
6//! ([`SessionAllowance::turn_budget`]), so the engine stops the turn at a
7//! round boundary with a valid history and a typed `BudgetExceeded`. Calls
8//! past the tool-call allowance get the engine's synthetic refusal result,
9//! not a cancel; in-budget calls of the same parallel batch still run.
10//!
11//! [`drive_turn`] meters one turn's stream into [`SessionTotals`]: usage is
12//! priced with the runtime's pricing table (cache reads and writes
13//! included), tool calls are counted when they produce a result (a refused
14//! call is not counted), and a clean exit is recorded only when the
15//! `watcher_exit` tool reports that the handoff was saved.
16
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21use futures::{Stream, StreamExt};
22use tokio_util::sync::CancellationToken;
23
24use crate::runtime::budget::{TurnBudget, TOOL_CALL_NOT_EXECUTED};
25use crate::runtime::{CancelCause, TurnCompletion};
26use crate::tools::WATCHER_EXIT_ACK;
27use crate::{
28    LlmEvent, Runtime, SessionEvent, SessionLimits, SharedMessage, StreamEvent, TurnError,
29};
30
31/// What one watcher session has used so far.
32#[derive(Debug, Clone, Copy, Default, PartialEq)]
33pub struct SessionTotals {
34    /// Uncached input + output tokens (what `max_session_tokens` limits).
35    pub tokens: u64,
36    /// Priced spend, cache reads and writes included.
37    pub cost_usd: f64,
38    /// Tool calls that ran (refused calls are not counted).
39    pub tool_calls: u64,
40}
41
42/// A watcher session limit.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum SessionLimit {
45    Tokens,
46    Cost,
47    DailyCost,
48    Time,
49    ToolCalls,
50}
51
52impl SessionLimit {
53    /// The exit reason recorded in the session log and stats.
54    pub fn exit_reason(self) -> &'static str {
55        match self {
56            SessionLimit::Tokens => "token_limit",
57            SessionLimit::Cost => "cost_limit",
58            SessionLimit::DailyCost => "daily_cost_limit",
59            SessionLimit::Time => "time_limit",
60            SessionLimit::ToolCalls => "tool_limit",
61        }
62    }
63}
64
65/// A session's limits plus what was already spent today before it started.
66#[derive(Debug, Clone, Copy)]
67pub struct SessionAllowance<'a> {
68    pub limits: &'a SessionLimits,
69    /// Cost booked to today's stats by earlier sessions.
70    pub daily_spent_before: f64,
71}
72
73impl SessionAllowance<'_> {
74    fn max_duration(&self) -> Duration {
75        Duration::from_secs(self.limits.max_session_duration_mins.saturating_mul(60))
76    }
77
78    /// When a turn still running is cancelled: the session time limit plus
79    /// [`TURN_DEADLINE_GRACE`].
80    pub fn turn_deadline(&self, session_start: Instant) -> Instant {
81        session_start + self.max_duration() + TURN_DEADLINE_GRACE
82    }
83
84    /// The first limit `totals` has reached after `elapsed`, if any.
85    pub fn limit_reached(&self, totals: &SessionTotals, elapsed: Duration) -> Option<SessionLimit> {
86        if totals.tokens >= self.limits.max_session_tokens {
87            Some(SessionLimit::Tokens)
88        } else if totals.cost_usd >= self.limits.max_session_cost_usd {
89            Some(SessionLimit::Cost)
90        } else if self.daily_spent_before + totals.cost_usd >= self.limits.max_daily_cost_usd {
91            Some(SessionLimit::DailyCost)
92        } else if elapsed >= self.max_duration() {
93            Some(SessionLimit::Time)
94        } else if totals.tool_calls >= self.limits.max_tool_calls {
95            Some(SessionLimit::ToolCalls)
96        } else {
97            None
98        }
99    }
100
101    /// The engine budget for the next turn: `role` narrowed to what the
102    /// session has left (tool calls, cost against both the session and the
103    /// daily cap, time, tokens), the stricter limit winning per dimension.
104    pub fn turn_budget(
105        &self,
106        role: &TurnBudget,
107        totals: &SessionTotals,
108        elapsed: Duration,
109    ) -> TurnBudget {
110        let limits = self.limits;
111        let session_cost_left = limits.max_session_cost_usd - totals.cost_usd;
112        let daily_cost_left =
113            limits.max_daily_cost_usd - (self.daily_spent_before + totals.cost_usd);
114        let remaining = TurnBudget {
115            max_provider_rounds: None,
116            max_tool_calls: Some(
117                u32::try_from(limits.max_tool_calls.saturating_sub(totals.tool_calls))
118                    .unwrap_or(u32::MAX),
119            ),
120            max_elapsed: Some(self.max_duration().saturating_sub(elapsed)),
121            max_accumulated_tool_result_bytes: None,
122            max_context_tokens: None,
123            max_cost_usd: Some(session_cost_left.min(daily_cost_left).max(0.0)),
124            max_total_tokens: Some(limits.max_session_tokens.saturating_sub(totals.tokens)),
125            max_round_renewals: u32::MAX,
126        };
127        role.tighter(&remaining)
128    }
129}
130
131/// Price one request's usage with the runtime's pricing table (the same one
132/// the engine's turn cost budget uses), cache reads and writes included.
133pub fn usage_cost(
134    model: &str,
135    input: u64,
136    output: u64,
137    cache_read: u64,
138    cache_write: u64,
139    cache_write_5m: Option<u64>,
140    cache_write_1h: Option<u64>,
141) -> f64 {
142    crate::pricing::calculate_cost_optional_split(
143        model,
144        input,
145        output,
146        cache_read,
147        cache_write,
148        cache_write_5m,
149        cache_write_1h,
150    )
151}
152
153/// How one watcher turn ended.
154#[derive(Debug, Default)]
155pub struct TurnReport {
156    /// The turn's final history (valid: every tool_use has a result).
157    pub history: Option<Vec<SharedMessage>>,
158    /// `watcher_exit` ran and reported the handoff saved.
159    pub handoff_saved: bool,
160    /// The turn's terminal error (budget stop, provider failure, ...).
161    pub error: Option<TurnError>,
162}
163
164/// Drain one turn's stream, metering it into `totals`. `model` prices usage
165/// events that do not name their model. `on_event` sees every event after
166/// it was metered (for logging).
167pub async fn drive_turn<S>(
168    mut stream: S,
169    model: &str,
170    totals: &mut SessionTotals,
171    mut on_event: impl FnMut(&StreamEvent, &SessionTotals),
172) -> TurnReport
173where
174    S: Stream<Item = StreamEvent> + Unpin,
175{
176    let mut report = TurnReport::default();
177    let mut exit_call: Option<String> = None;
178    while let Some(event) = stream.next().await {
179        match &event {
180            StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id })
181                if tool_name == "watcher_exit" =>
182            {
183                exit_call = Some(tool_id.clone());
184            }
185            StreamEvent::Llm(LlmEvent::ToolResult { tool_id, result }) => {
186                // Calls a budget refused, and calls a cancel cut short, did
187                // not run to completion and are not counted.
188                if !result.starts_with(TOOL_CALL_NOT_EXECUTED) && !result.starts_with("Canceled") {
189                    totals.tool_calls = totals.tool_calls.saturating_add(1);
190                }
191                if exit_call.as_deref() == Some(tool_id.as_str())
192                    && result.starts_with(WATCHER_EXIT_ACK)
193                {
194                    report.handoff_saved = true;
195                }
196            }
197            StreamEvent::Session(SessionEvent::Usage {
198                input_tokens,
199                output_tokens,
200                cache_read_input_tokens,
201                cache_creation_input_tokens,
202                cache_creation_5m,
203                cache_creation_1h,
204                model: event_model,
205            }) => {
206                totals.tokens = totals
207                    .tokens
208                    .saturating_add(input_tokens.saturating_add(*output_tokens));
209                totals.cost_usd += usage_cost(
210                    event_model.as_deref().unwrap_or(model),
211                    *input_tokens,
212                    *output_tokens,
213                    *cache_read_input_tokens,
214                    *cache_creation_input_tokens,
215                    *cache_creation_5m,
216                    *cache_creation_1h,
217                );
218            }
219            StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
220                report.history = Some(history.clone());
221            }
222            StreamEvent::Session(SessionEvent::Error(error)) => {
223                report.error = Some(error.clone());
224            }
225            _ => {}
226        }
227        on_event(&event, totals);
228    }
229    report
230}
231
232/// Append a user prompt to `messages` without creating two consecutive user
233/// messages: a turn that ended on its tool results (a budget stop) already
234/// ends with a user message, which then gets the prompt as a text block.
235pub fn push_user_prompt(messages: &mut Vec<SharedMessage>, prompt: &str) {
236    if let Some(last) = messages.last() {
237        if last["role"] == "user" {
238            let mut merged = (**last).clone();
239            let text = serde_json::json!({"type": "text", "text": prompt});
240            match merged["content"].take() {
241                serde_json::Value::Array(mut blocks) => {
242                    blocks.push(text);
243                    merged["content"] = serde_json::Value::Array(blocks);
244                }
245                serde_json::Value::String(s) => {
246                    merged["content"] = serde_json::json!([{"type": "text", "text": s}, text]);
247                }
248                _ => merged["content"] = serde_json::json!([text]),
249            }
250            *messages.last_mut().expect("non-empty") = std::sync::Arc::new(merged);
251            return;
252        }
253    }
254    messages.push(std::sync::Arc::new(
255        serde_json::json!({"role": "user", "content": prompt}),
256    ));
257}
258
259/// The prompt for the turn that asks for a handoff after a session limit.
260pub const HANDOFF_PROMPT: &str = "You're being shut down (resource limit reached). Call watcher_exit NOW with your handoff state — summarize what you did, what's pending, and any context your next session needs.";
261
262/// How long the post-limit handoff turn may run.
263pub const HANDOFF_TIMEOUT: Duration = Duration::from_secs(60);
264
265/// Watches one streaming turn: cancels it on SIGINT/SIGTERM, or once
266/// `deadline` passes, as a host stop (canceled tool results say so, not
267/// "by user"). Exits when the turn ends. Session limits are otherwise
268/// enforced by the engine inside the turn.
269pub async fn watch_turn(
270    cancel: CancellationToken,
271    completion: TurnCompletion,
272    interrupted: Arc<AtomicBool>,
273    turn_over: Arc<AtomicBool>,
274    deadline: Option<Instant>,
275    poll: Duration,
276) {
277    while !turn_over.load(Ordering::Acquire) && !cancel.is_cancelled() {
278        if interrupted.load(Ordering::Acquire) || deadline.is_some_and(|d| Instant::now() >= d) {
279            completion.note_cancel_cause(CancelCause::Host);
280            cancel.cancel();
281            break;
282        }
283        tokio::time::sleep(poll).await;
284    }
285}
286
287/// Run one turn of `runtime` over `messages` under `budget`, metered into
288/// `totals`; `messages` becomes the turn's final history. A signal cancels
289/// the turn; so does `deadline` (the handoff turn's backstop).
290pub async fn run_turn(
291    runtime: &mut Runtime,
292    messages: &mut Vec<SharedMessage>,
293    budget: TurnBudget,
294    totals: &mut SessionTotals,
295    interrupted: &Arc<AtomicBool>,
296    deadline: Option<Instant>,
297    on_event: impl FnMut(&StreamEvent, &SessionTotals),
298) -> TurnReport {
299    runtime.set_turn_budget(budget);
300    let cancel = CancellationToken::new();
301    let completion = TurnCompletion::new();
302    let turn_over = Arc::new(AtomicBool::new(false));
303    tokio::spawn(watch_turn(
304        cancel.clone(),
305        completion.clone(),
306        Arc::clone(interrupted),
307        Arc::clone(&turn_over),
308        deadline,
309        Duration::from_millis(250),
310    ));
311    let model = runtime.model().to_string();
312    let stream = runtime
313        .run_stream_tracked(messages.clone(), cancel, None, None, false, completion)
314        .await;
315    let report = drive_turn(stream, &model, totals, on_event).await;
316    turn_over.store(true, Ordering::Release);
317    if let Some(history) = &report.history {
318        *messages = history.clone();
319    }
320    report
321}
322
323/// Extra time past the session time limit before a turn still running is
324/// cancelled. The engine stops a turn at the next provider call once the
325/// session time is used up; this deadline only catches a turn stuck in a
326/// long tool round (a slow command, a `timeout: 0` subagent).
327pub const TURN_DEADLINE_GRACE: Duration = Duration::from_secs(5);
328
329/// One turn of a watcher session, as `synaps agent` runs it: the role
330/// budget narrowed to what the session has left.
331#[allow(clippy::too_many_arguments)]
332pub async fn run_session_turn(
333    runtime: &mut Runtime,
334    messages: &mut Vec<SharedMessage>,
335    allowance: &SessionAllowance<'_>,
336    role: &TurnBudget,
337    totals: &mut SessionTotals,
338    session_start: Instant,
339    interrupted: &Arc<AtomicBool>,
340    on_event: impl FnMut(&StreamEvent, &SessionTotals),
341) -> TurnReport {
342    let budget = allowance.turn_budget(role, totals, session_start.elapsed());
343    let deadline = allowance.turn_deadline(session_start);
344    run_turn(
345        runtime,
346        messages,
347        budget,
348        totals,
349        interrupted,
350        Some(deadline),
351        on_event,
352    )
353    .await
354}
355
356/// After a session limit: ask for the handoff in one more turn, bounded by
357/// [`HANDOFF_TIMEOUT`] and the role's per-turn limits, and metered into
358/// `totals` like every other turn (it is real spend).
359pub async fn run_handoff_turn(
360    runtime: &mut Runtime,
361    messages: &mut Vec<SharedMessage>,
362    role: &TurnBudget,
363    totals: &mut SessionTotals,
364    interrupted: &Arc<AtomicBool>,
365    on_event: impl FnMut(&StreamEvent, &SessionTotals),
366) -> TurnReport {
367    push_user_prompt(messages, HANDOFF_PROMPT);
368    let budget = role.tighter(&TurnBudget {
369        max_elapsed: Some(HANDOFF_TIMEOUT),
370        max_round_renewals: u32::MAX,
371        ..TurnBudget::for_role(crate::runtime::budget::TurnRole::Foreground)
372    });
373    run_turn(
374        runtime,
375        messages,
376        budget,
377        totals,
378        interrupted,
379        Some(Instant::now() + HANDOFF_TIMEOUT),
380        on_event,
381    )
382    .await
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::runtime::budget::{TurnRole, TOOL_CALL_BUDGET_REFUSAL};
389
390    fn limits() -> SessionLimits {
391        SessionLimits {
392            max_session_tokens: 1_000,
393            max_session_duration_mins: 60,
394            max_session_cost_usd: 0.50,
395            max_daily_cost_usd: 10.0,
396            max_tool_calls: 10,
397            ..SessionLimits::default()
398        }
399    }
400
401    #[test]
402    fn limits_are_reached_at_their_configured_values() {
403        let limits = limits();
404        let allowance = SessionAllowance {
405            limits: &limits,
406            daily_spent_before: 0.0,
407        };
408        let none = SessionTotals::default();
409        assert_eq!(allowance.limit_reached(&none, Duration::ZERO), None);
410        for (totals, elapsed, expected) in [
411            (
412                SessionTotals {
413                    tokens: 1_000,
414                    ..none
415                },
416                Duration::ZERO,
417                SessionLimit::Tokens,
418            ),
419            (
420                SessionTotals {
421                    cost_usd: 0.50,
422                    ..none
423                },
424                Duration::ZERO,
425                SessionLimit::Cost,
426            ),
427            (
428                SessionTotals {
429                    tool_calls: 10,
430                    ..none
431                },
432                Duration::ZERO,
433                SessionLimit::ToolCalls,
434            ),
435            (none, Duration::from_secs(3600), SessionLimit::Time),
436        ] {
437            assert_eq!(allowance.limit_reached(&totals, elapsed), Some(expected));
438        }
439        let late_in_the_day = SessionAllowance {
440            limits: &limits,
441            daily_spent_before: 9.80,
442        };
443        let spent = SessionTotals {
444            cost_usd: 0.20,
445            ..none
446        };
447        assert_eq!(
448            late_in_the_day.limit_reached(&spent, Duration::ZERO),
449            Some(SessionLimit::DailyCost)
450        );
451    }
452
453    #[test]
454    fn the_turn_budget_is_the_remaining_allowance_capped_by_the_role() {
455        let limits = limits();
456        let allowance = SessionAllowance {
457            limits: &limits,
458            daily_spent_before: 9.90,
459        };
460        let role = TurnBudget::for_role(TurnRole::Autonomous);
461        let totals = SessionTotals {
462            tokens: 400,
463            cost_usd: 0.05,
464            tool_calls: 8,
465        };
466        let budget = allowance.turn_budget(&role, &totals, Duration::from_secs(50 * 60));
467        assert_eq!(budget.max_tool_calls, Some(2));
468        assert_eq!(budget.max_total_tokens, Some(600));
469        assert_eq!(budget.max_elapsed, Some(Duration::from_secs(10 * 60)));
470        // The daily cap (0.05 left) is tighter than the session's 0.45.
471        assert!((budget.max_cost_usd.unwrap() - 0.05).abs() < 1e-9);
472        // Role limits still apply where they are tighter.
473        assert_eq!(budget.max_provider_rounds, Some(24));
474        assert_eq!(budget.max_round_renewals, 0);
475
476        let fresh = allowance.turn_budget(&role, &SessionTotals::default(), Duration::ZERO);
477        assert_eq!(fresh.max_tool_calls, Some(10));
478        let roomy = SessionLimits {
479            max_tool_calls: 500,
480            ..limits
481        };
482        let roomy = SessionAllowance {
483            limits: &roomy,
484            daily_spent_before: 0.0,
485        };
486        assert_eq!(
487            roomy
488                .turn_budget(&role, &SessionTotals::default(), Duration::ZERO)
489                .max_tool_calls,
490            Some(96),
491            "the role's per-turn cap wins when the session has more left"
492        );
493    }
494
495    #[test]
496    fn cache_heavy_usage_is_priced_with_the_runtime_table() {
497        let model = "claude-opus-4-1";
498        let cost = usage_cost(model, 2_000, 1_000, 400_000, 100_000, None, None);
499        let uncached_only = usage_cost(model, 2_000, 1_000, 0, 0, None, None);
500        assert_eq!(
501            cost,
502            crate::pricing::calculate_cost_optional_split(
503                model, 2_000, 1_000, 400_000, 100_000, None, None
504            )
505        );
506        assert!(
507            cost > uncached_only * 5.0,
508            "cache reads/writes dominate a long watcher context: {cost} vs {uncached_only}"
509        );
510    }
511
512    #[tokio::test]
513    async fn drive_turn_meters_cache_tokens_and_counts_only_executed_calls() {
514        let events = vec![
515            StreamEvent::Llm(LlmEvent::ToolUseStart {
516                tool_name: "ls".into(),
517                tool_id: "t1".into(),
518            }),
519            StreamEvent::Llm(LlmEvent::ToolUseStart {
520                tool_name: "ls".into(),
521                tool_id: "t2".into(),
522            }),
523            StreamEvent::Session(SessionEvent::Usage {
524                input_tokens: 100,
525                output_tokens: 50,
526                cache_read_input_tokens: 200_000,
527                cache_creation_input_tokens: 20_000,
528                cache_creation_5m: None,
529                cache_creation_1h: None,
530                model: None,
531            }),
532            StreamEvent::Llm(LlmEvent::ToolResult {
533                tool_id: "t1".into(),
534                result: "ok".into(),
535            }),
536            StreamEvent::Llm(LlmEvent::ToolResult {
537                tool_id: "t2".into(),
538                result: TOOL_CALL_BUDGET_REFUSAL.into(),
539            }),
540        ];
541        let mut totals = SessionTotals::default();
542        let report = drive_turn(
543            futures::stream::iter(events),
544            "claude-sonnet-4-5",
545            &mut totals,
546            |_, _| {},
547        )
548        .await;
549        assert_eq!(totals.tokens, 150);
550        assert_eq!(totals.tool_calls, 1, "the refused call is not counted");
551        let expected = usage_cost("claude-sonnet-4-5", 100, 50, 200_000, 20_000, None, None);
552        assert!((totals.cost_usd - expected).abs() < 1e-12);
553        assert!(!report.handoff_saved);
554    }
555
556    #[tokio::test]
557    async fn only_a_successful_watcher_exit_result_counts_as_a_saved_handoff() {
558        let start = StreamEvent::Llm(LlmEvent::ToolUseStart {
559            tool_name: "watcher_exit".into(),
560            tool_id: "x1".into(),
561        });
562        for (result, saved) in [
563            (format!("{WATCHER_EXIT_ACK} Reason: done"), true),
564            (TOOL_CALL_BUDGET_REFUSAL.to_string(), false),
565            ("Canceled by user".to_string(), false),
566            ("Error: Failed to rename handoff file".to_string(), false),
567        ] {
568            let mut totals = SessionTotals::default();
569            let report = drive_turn(
570                futures::stream::iter(vec![
571                    start.clone(),
572                    StreamEvent::Llm(LlmEvent::ToolResult {
573                        tool_id: "x1".into(),
574                        result,
575                    }),
576                ]),
577                "claude-sonnet-4-5",
578                &mut totals,
579                |_, _| {},
580            )
581            .await;
582            assert_eq!(report.handoff_saved, saved);
583        }
584        // The call starting is not enough.
585        let mut totals = SessionTotals::default();
586        let report = drive_turn(
587            futures::stream::iter(vec![start]),
588            "claude-sonnet-4-5",
589            &mut totals,
590            |_, _| {},
591        )
592        .await;
593        assert!(!report.handoff_saved);
594    }
595
596    #[tokio::test]
597    async fn turn_watcher_cancels_on_signal_and_at_the_deadline_only() {
598        let poll = Duration::from_millis(5);
599        // Signal.
600        let cancel = CancellationToken::new();
601        let watcher = tokio::spawn(watch_turn(
602            cancel.clone(),
603            TurnCompletion::new(),
604            Arc::new(AtomicBool::new(true)),
605            Arc::new(AtomicBool::new(false)),
606            None,
607            poll,
608        ));
609        tokio::time::timeout(Duration::from_secs(5), cancel.cancelled())
610            .await
611            .expect("signal cancels the turn");
612        watcher.await.unwrap();
613        // Deadline.
614        let cancel = CancellationToken::new();
615        let watcher = tokio::spawn(watch_turn(
616            cancel.clone(),
617            TurnCompletion::new(),
618            Arc::new(AtomicBool::new(false)),
619            Arc::new(AtomicBool::new(false)),
620            Some(Instant::now()),
621            poll,
622        ));
623        tokio::time::timeout(Duration::from_secs(5), cancel.cancelled())
624            .await
625            .expect("deadline cancels the turn");
626        watcher.await.unwrap();
627        // Turn over: exits without cancelling.
628        let cancel = CancellationToken::new();
629        let turn_over = Arc::new(AtomicBool::new(false));
630        let watcher = tokio::spawn(watch_turn(
631            cancel.clone(),
632            TurnCompletion::new(),
633            Arc::new(AtomicBool::new(false)),
634            Arc::clone(&turn_over),
635            None,
636            poll,
637        ));
638        turn_over.store(true, Ordering::Release);
639        tokio::time::timeout(Duration::from_secs(5), watcher)
640            .await
641            .expect("watcher exits when the turn ends")
642            .unwrap();
643        assert!(!cancel.is_cancelled());
644    }
645
646    #[test]
647    fn a_prompt_after_tool_results_joins_the_last_user_message() {
648        let mut messages: Vec<SharedMessage> = vec![
649            std::sync::Arc::new(serde_json::json!({"role": "user", "content": "boot"})),
650            std::sync::Arc::new(serde_json::json!({"role": "assistant", "content": [
651                {"type": "tool_use", "id": "t1", "name": "ls", "input": {}}
652            ]})),
653            std::sync::Arc::new(serde_json::json!({"role": "user", "content": [
654                {"type": "tool_result", "tool_use_id": "t1", "content": "x"}
655            ]})),
656        ];
657        push_user_prompt(&mut messages, "write your handoff");
658        assert_eq!(messages.len(), 3);
659        let blocks = messages[2]["content"].as_array().unwrap();
660        assert_eq!(blocks[0]["type"], "tool_result");
661        assert_eq!(blocks[1]["text"], "write your handoff");
662
663        messages.push(std::sync::Arc::new(
664            serde_json::json!({"role": "assistant", "content": "ok"}),
665        ));
666        push_user_prompt(&mut messages, "next");
667        assert_eq!(messages.len(), 5);
668        assert_eq!(messages[4]["content"], "next");
669    }
670}