Skip to main content

basis_tasks/
state.rs

1//! Durable per-agent state: metadata, terminal records, and their bounds.
2//!
3//! Three files carry an agent's task state, each atomic-replace JSON:
4//! `meta.json` (bookkeeping and the recorded spawn request — never
5//! conversation content, which is mentra's store's alone), `inbox.json`
6//! (messages, see [`super::inbox`](mod@super::inbox)), and `terminal.json` — written as the
7//! executor's **last** act, whose existence is the completion signal. An agent
8//! is resumable iff its terminal record does not exist; every crash before
9//! that write resolves toward resumable.
10
11use std::{
12    io,
13    time::{SystemTime, UNIX_EPOCH},
14};
15
16use basis::{Bound, Effort, RunUsage, SystemPrompt};
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use crate::approve::Approve;
22use crate::data_dir::{AgentPaths, write_private_atomic};
23
24pub(crate) const MAX_MESSAGES: usize = 16;
25pub(crate) const MAX_MESSAGE: usize = 256 * 1024;
26/// The largest prompt `spawn` accepts, in bytes. Public so a host that
27/// assembles a prompt from parts of its own can refuse an oversized one as
28/// the caller's mistake, before `spawn` refuses it as a failure.
29pub const MAX_PROMPT: usize = 256 * 1024;
30pub(crate) const MAX_EVENT_BYTES: usize = 32 * 1024;
31pub(crate) const MAX_RESULT_BYTES: usize = 1024 * 1024;
32/// How many tasks one workspace may hold at once. `spawn` refuses past it
33/// (archiving an old agent directory is the way out); the ownership-policy
34/// walks in `policy` and `attach::cancel_tree` are bounded by it too, so
35/// corrupt or cyclic metadata cannot loop forever.
36pub const MAX_TASKS: usize = 1024;
37/// Byte cap on one agent's `events.jsonl`, standing in for the daemon-era
38/// journal cap: on overflow a final notice line is appended and recording
39/// stops — a run is never failed for observability.
40pub(crate) const MAX_EVENTS_BYTES: u64 = 32 * 1024 * 1024;
41
42/// The recorded spawn request, so a later attach can execute what spawn
43/// accepted. Credentials are intentionally absent: the attaching process owns
44/// its own environment, exactly as any other host does.
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
46pub(crate) struct RunOptions {
47    pub provider: Option<String>,
48    pub base_url: Option<String>,
49    pub model: Option<String>,
50    pub no_shell: bool,
51    /// The host's say over the system prompt, held as the type it is:
52    /// `Replace` displaces the workspace's context, `Append` follows it.
53    ///
54    /// Written typed (`{"replace": …}` / `{"append": …}`). The pre-0.6
55    /// record spelled it as two flat strings, and a task directory outlives
56    /// the binary that minted it, so the compat reader below still accepts
57    /// the flat `Replace` half and [`folded`](Self::folded) picks up the
58    /// legacy append field on load.
59    #[serde(
60        default,
61        skip_serializing_if = "Option::is_none",
62        deserialize_with = "system_prompt_compat"
63    )]
64    pub system_prompt: Option<SystemPrompt>,
65    /// The pre-0.6 append half, read-only: folded into
66    /// [`system_prompt`](Self::system_prompt) on load and never written back.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub append_system_prompt: Option<String>,
69    /// Typed, and byte-for-byte the strings the old writer spelled
70    /// (`"low"` … `"max"`), so an old record needs no shim.
71    pub effort: Option<Effort>,
72    /// Typed, same story: `"always"` / `"never"` / `"prompt"` on the wire,
73    /// unchanged — and an invalid mode is now unrepresentable, so corruption
74    /// fails at decode, named, instead of somewhere downstream.
75    pub approve: Approve,
76    pub deadline_ms: Option<u64>,
77    pub tool_budget: Option<usize>,
78    pub token_budget: Option<u64>,
79}
80
81impl RunOptions {
82    /// Folds the pre-0.6 two-string system-prompt spelling into the typed
83    /// field. Called by [`load_meta`], so everything downstream sees one
84    /// spelling however old the record is.
85    fn folded(self) -> Self {
86        let (system_prompt, append) = (
87            self.system_prompt.clone(),
88            self.append_system_prompt.clone(),
89        );
90        Self {
91            system_prompt: system_prompt.or_else(|| append.map(SystemPrompt::Append)),
92            append_system_prompt: None,
93            ..self
94        }
95    }
96}
97
98/// Accepts the typed spelling and the pre-0.6 flat string, which was always
99/// the `Replace` half — clap refused both flags at once before either was
100/// recorded.
101fn system_prompt_compat<'de, D>(deserializer: D) -> Result<Option<SystemPrompt>, D::Error>
102where
103    D: serde::Deserializer<'de>,
104{
105    #[derive(Deserialize)]
106    #[serde(untagged)]
107    enum Compat {
108        Typed(SystemPrompt),
109        Legacy(String),
110    }
111
112    Ok(
113        Option::<Compat>::deserialize(deserializer)?.map(|value| match value {
114            Compat::Typed(system_prompt) => system_prompt,
115            Compat::Legacy(replace) => SystemPrompt::Replace(replace),
116        }),
117    )
118}
119
120/// Accepts what the typed writer spells — the same `deadline` /
121/// `tool_budget` / `token_budget` strings the old one used — and reads
122/// anything else, like the `unknown` the old writer could emit for a bound it
123/// had no name for, as no bound rather than an unloadable record.
124fn bound_compat<'de, D>(deserializer: D) -> Result<Option<Bound>, D::Error>
125where
126    D: serde::Deserializer<'de>,
127{
128    let name = Option::<String>::deserialize(deserializer)?;
129    Ok(name.and_then(|name| Bound::from_type_tag(&name)))
130}
131
132/// A task whose own work has finished, but whose attached children may still
133/// lack terminal records. Recorded in `meta.json` before the settle pass, so a
134/// kill between a child's terminal and the parent's leaves the parent
135/// resumable with the model work already done.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(tag = "state", rename_all = "snake_case")]
138#[non_exhaustive]
139pub enum Terminal {
140    /// The task produced its final answer.
141    Succeeded { result: String },
142    /// The task stopped with a failure message.
143    Failed { error: String },
144    /// The task stopped because cancellation was requested.
145    Cancelled,
146}
147
148/// A terminal payload exactly as written, with the basis-owned fields this
149/// build recognizes alongside it.
150#[derive(Debug, Clone, PartialEq)]
151pub struct TerminalRecord {
152    /// The exact terminal or message-terminal JSON read from disk.
153    pub raw: Value,
154    /// The typed terminal state, or `None` when a newer writer used a state
155    /// this build does not know.
156    pub terminal: Option<Terminal>,
157    /// The bound that stopped the run, when one did.
158    pub stopped_by: Option<Bound>,
159    /// Usage reported by the completed task, when present.
160    pub usage: Option<RunUsage>,
161    /// Whether the result text was truncated before it was recorded.
162    pub result_truncated: bool,
163}
164
165impl TerminalRecord {
166    pub(crate) fn from_raw(raw: Value) -> Self {
167        let terminal = serde_json::from_value(raw.clone()).ok();
168        let stopped_by = raw
169            .get("stopped_by")
170            .and_then(Value::as_str)
171            .and_then(Bound::from_type_tag);
172        let usage = raw
173            .get("usage")
174            .cloned()
175            .and_then(|usage| serde_json::from_value(usage).ok());
176        let result_truncated = raw
177            .get("result_truncated")
178            .and_then(Value::as_bool)
179            .unwrap_or(false);
180        Self {
181            raw,
182            terminal,
183            stopped_by,
184            usage,
185            result_truncated,
186        }
187    }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum MessageState {
193    /// Accepted but not yet presented to the task.
194    Pending,
195    /// Presented to the task in the current turn.
196    InFlight,
197    /// Presented to a completed turn.
198    Delivered,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct MessageReply {
203    /// The reply text.
204    pub result: String,
205    /// Whether the reply text was truncated.
206    #[serde(default, skip_serializing_if = "is_false")]
207    pub result_truncated: bool,
208    /// The bound that stopped the reply's turn, when one did.
209    #[serde(
210        default,
211        skip_serializing_if = "Option::is_none",
212        deserialize_with = "bound_compat"
213    )]
214    pub stopped_by: Option<Bound>,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct MessageRecord {
219    /// The durable message id.
220    pub id: String,
221    /// The message text.
222    pub body: String,
223    /// Whether the bounded public view shortened [`Self::body`].
224    #[serde(default, skip_serializing_if = "is_false")]
225    pub body_truncated: bool,
226    /// Delivery state.
227    pub state: MessageState,
228    /// Milliseconds since the Unix epoch when the message was accepted.
229    pub created_ms: u64,
230    /// The correlated reply, when the task produced one.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub reply: Option<MessageReply>,
233}
234
235/// The exact `basis inbox` payload with its bounded typed messages alongside.
236#[derive(Debug, Clone, PartialEq)]
237pub struct InboxRecord {
238    /// The exact JSON shape printed by `basis inbox --json`.
239    pub raw: Value,
240    /// Bounded message views, oldest first.
241    pub messages: Vec<MessageRecord>,
242}
243
244fn is_zero(value: &u32) -> bool {
245    *value == 0
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249pub(crate) struct TaskMeta {
250    pub id: String,
251    pub parent: Option<String>,
252    pub detached: bool,
253    pub workspace: String,
254    /// The mentra resume key; empty until the first attach prepares the run.
255    pub agent_id: String,
256    /// The conversation this task was told to pick up, when `spawn` was given
257    /// `--continue` or `--session`. The first attach resumes it instead of
258    /// minting one, which is what lets a new handle carry an old dialogue —
259    /// and why continuing a settled task is a new task rather than a message
260    /// to a closed inbox (ADR-0019).
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub continues: Option<String>,
263    /// How many assistant turns the conversation already held when this task
264    /// first attached to it.
265    ///
266    /// Zero for a task that opened its own conversation. Nonzero only for a
267    /// continued one, where the previous answers are on the transcript from
268    /// the very first turn — and where the resume recovery in `attach` would
269    /// otherwise read one of them as this task's own answer and settle
270    /// `succeeded` without ever asking its prompt.
271    #[serde(default)]
272    pub answered_before: usize,
273    pub prompt: String,
274    pub options: RunOptions,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub pending_terminal: Option<Terminal>,
277    #[serde(default, skip_serializing_if = "is_false")]
278    pub result_truncated: bool,
279    #[serde(
280        default,
281        skip_serializing_if = "Option::is_none",
282        deserialize_with = "bound_compat"
283    )]
284    pub stopped_by: Option<Bound>,
285    /// What every turn this task has driven reported spending, summed.
286    ///
287    /// Kept in `meta.json` rather than recomputed from the event journal
288    /// because a task outlives the process that drove it: two attaches, each
289    /// running turns, both add to one tally, and the journal may have been
290    /// capped ([`MAX_EVENTS_BYTES`]) long before anyone asks.
291    #[serde(default)]
292    pub usage: RunUsage,
293    pub deadline_at_ms: Option<u64>,
294    /// The agent-to-agent hop the task's work arrived at — see
295    /// [`RunSpec::with_hop`](crate::RunSpec::with_hop). Absent is 0, which
296    /// is what every task recorded before hops existed was.
297    #[serde(default, skip_serializing_if = "is_zero")]
298    pub hop: u32,
299    pub created_ms: u64,
300    /// When this task last advanced, as its executor records it: the attach
301    /// that opened its conversation, every turn it banked, and the completion
302    /// it settled on. Each write to this record is a step the task took, which
303    /// is what lets one field carry both meanings — a bookkeeping rewrite that
304    /// is *not* a step would have to say so rather than borrow this one.
305    ///
306    /// Read by [`super::tasks`](mod@super::tasks) to answer "the conversation
307    /// I was just having", the question `--continue` asks. Defaulted for that
308    /// reason: a record that omits it must still load, because a task that
309    /// vanished from `basis list` after an upgrade would be a worse answer
310    /// than a task ordered by the wrong clock. The reader falls back to
311    /// `created_ms`.
312    #[serde(default)]
313    pub updated_ms: u64,
314}
315
316impl TaskMeta {
317    pub(crate) fn new(
318        id: String,
319        parent: Option<String>,
320        detached: bool,
321        workspace: String,
322        prompt: String,
323        options: RunOptions,
324        deadline_at_ms: Option<u64>,
325    ) -> Self {
326        let now = now_ms();
327        Self {
328            id,
329            parent,
330            detached,
331            workspace,
332            agent_id: String::new(),
333            continues: None,
334            answered_before: 0,
335            prompt,
336            options,
337            pending_terminal: None,
338            result_truncated: false,
339            stopped_by: None,
340            usage: RunUsage::default(),
341            deadline_at_ms,
342            hop: 0,
343            created_ms: now,
344            updated_ms: now,
345        }
346    }
347
348    pub(crate) fn at_hop(self, hop: u32) -> Self {
349        Self { hop, ..self }
350    }
351
352    /// Records the conversation this task continues, when it continues one.
353    ///
354    /// A field rather than a pre-set `agent_id`, because the two mean
355    /// different things to the executor: `agent_id` says *this task has
356    /// attached before*, and taking a continued conversation for a resumed
357    /// one is how a task settles on someone else's answer.
358    #[must_use]
359    pub(crate) fn continuing(self, agent_id: Option<String>) -> Self {
360        Self {
361            continues: agent_id,
362            ..self
363        }
364    }
365
366    pub(crate) fn deadline_passed(&self) -> bool {
367        self.deadline_at_ms
368            .is_some_and(|deadline| deadline <= now_ms())
369    }
370
371    /// The terminal payload the recorded completion earns: exactly the shape
372    /// the daemon's journal produced, minted once into `terminal.json`.
373    pub(crate) fn terminal_payload(&self) -> Option<Value> {
374        let payload = match self.pending_terminal.as_ref()? {
375            Terminal::Succeeded { result } => {
376                let mut terminal = serde_json::json!({"state": "succeeded", "result": result});
377                if self.result_truncated {
378                    terminal["result_truncated"] = Value::Bool(true);
379                }
380                with_stopped_by(terminal, self.stopped_by)
381            }
382            Terminal::Failed { error } => with_stopped_by(
383                serde_json::json!({"state": "failed", "error": error}),
384                self.stopped_by,
385            ),
386            Terminal::Cancelled => serde_json::json!({"state": "cancelled"}),
387        };
388        Some(with_usage(payload, self.usage))
389    }
390}
391
392fn with_stopped_by(mut payload: Value, stopped_by: Option<Bound>) -> Value {
393    if let Some(stopped_by) = stopped_by {
394        payload["stopped_by"] = serde_json::json!(stopped_by);
395    }
396    payload
397}
398
399/// Adds what the task spent, when it spent anything.
400///
401/// A record that names no usage is a task whose turns reported none — a
402/// cancellation that never reached the model is the ordinary case — and a
403/// `usage` object full of zeros would claim a measurement nobody made. The
404/// same rule the finish line follows (`Event::RunFinished::usage`), so the
405/// stream and the record cannot disagree.
406fn with_usage(mut payload: Value, usage: RunUsage) -> Value {
407    if usage != RunUsage::default() {
408        payload["usage"] = serde_json::json!(usage);
409    }
410    payload
411}
412
413fn is_false(value: &bool) -> bool {
414    !*value
415}
416
417pub(crate) fn load_meta(paths: &AgentPaths) -> Result<TaskMeta, String> {
418    let bytes = std::fs::read(paths.meta())
419        .map_err(|error| format!("read task metadata for {}: {error}", paths.dir().display()))?;
420    let meta: TaskMeta = serde_json::from_slice(&bytes).map_err(|error| {
421        format!(
422            "decode task metadata for {}: {error}",
423            paths.dir().display()
424        )
425    })?;
426
427    // One spelling downstream, however old the record: the pre-0.6 system
428    // prompt strings fold into the typed field on the way in.
429    Ok(TaskMeta {
430        options: meta.options.folded(),
431        ..meta
432    })
433}
434
435pub(crate) fn save_meta(paths: &AgentPaths, meta: &TaskMeta) -> Result<(), String> {
436    let bytes =
437        serde_json::to_vec(meta).map_err(|error| format!("encode task metadata: {error}"))?;
438    write_private_atomic(&paths.meta(), &bytes)
439        .map_err(|error| format!("persist task metadata: {error}"))
440}
441
442/// Reads the terminal record. `None` is the resumable state.
443pub(crate) fn read_terminal(paths: &AgentPaths) -> Result<Option<Value>, String> {
444    match std::fs::read(paths.terminal()) {
445        Ok(bytes) => serde_json::from_slice(&bytes)
446            .map(Some)
447            .map_err(|error| format!("decode terminal record: {error}")),
448        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
449        Err(error) => Err(format!("read terminal record: {error}")),
450    }
451}
452
453pub(crate) fn write_terminal(paths: &AgentPaths, payload: &Value) -> Result<(), String> {
454    let bytes =
455        serde_json::to_vec(payload).map_err(|error| format!("encode terminal record: {error}"))?;
456    write_private_atomic(&paths.terminal(), &bytes)
457        .map_err(|error| format!("persist terminal record: {error}"))
458}
459
460pub(crate) fn cancel_requested(paths: &AgentPaths) -> bool {
461    paths.cancel_marker().exists()
462}
463
464/// Writes the cancel marker. Existence is the signal; the content is
465/// diagnostic only.
466pub(crate) fn request_cancel(paths: &AgentPaths, by: Option<&str>) -> Result<(), String> {
467    let content = serde_json::json!({"requested_ms": now_ms(), "by": by});
468    write_private_atomic(&paths.cancel_marker(), content.to_string().as_bytes())
469        .map_err(|error| format!("record cancel request: {error}"))
470}
471
472pub(crate) fn bounded_text(mut value: String, limit: usize) -> (String, bool) {
473    if value.len() <= limit {
474        return (value, false);
475    }
476    let mut end = limit;
477    while !value.is_char_boundary(end) {
478        end -= 1;
479    }
480    value.truncate(end);
481    (value, true)
482}
483
484/// Milliseconds since the epoch, clamped rather than panicking on a clock
485/// before it. The one clock every durable record here is stamped with —
486/// `created_ms`, `updated_ms`, a cancel marker's `requested_ms`, a message's
487/// `created_ms` — and what a host rendering one of those alongside its own
488/// notion of "now" (`basis list`'s own age column, for one) should read
489/// `now` from, so the two clocks cannot disagree about the epoch.
490pub fn now_ms() -> u64 {
491    SystemTime::now()
492        .duration_since(UNIX_EPOCH)
493        .unwrap_or_default()
494        .as_millis()
495        .min(u128::from(u64::MAX)) as u64
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use crate::data_dir::DataDir;
502
503    fn agent(dir: &tempfile::TempDir) -> AgentPaths {
504        let data = DataDir::from_path(dir.path()).unwrap();
505        let paths = data
506            .agent_dir("0123456789abcdef/0123456789abcdef0123456789abcdef")
507            .unwrap();
508        std::fs::create_dir_all(paths.dir()).unwrap();
509        paths
510    }
511
512    fn meta(paths: &AgentPaths) -> TaskMeta {
513        TaskMeta::new(
514            "0123456789abcdef/0123456789abcdef0123456789abcdef".to_string(),
515            None,
516            true,
517            "/repo".to_string(),
518            "prompt".to_string(),
519            RunOptions::default(),
520            None,
521        )
522        .tap(paths)
523    }
524
525    trait Tap {
526        fn tap(self, paths: &AgentPaths) -> Self;
527    }
528    impl Tap for TaskMeta {
529        fn tap(self, paths: &AgentPaths) -> Self {
530            save_meta(paths, &self).unwrap();
531            self
532        }
533    }
534
535    #[test]
536    fn metadata_round_trips_through_its_file() {
537        let dir = tempfile::tempdir().unwrap();
538        let paths = agent(&dir);
539        let saved = meta(&paths);
540        assert_eq!(load_meta(&paths).unwrap(), saved);
541    }
542
543    #[test]
544    fn terminal_records_are_absent_until_written_then_repeatably_observable() {
545        let dir = tempfile::tempdir().unwrap();
546        let paths = agent(&dir);
547        assert!(read_terminal(&paths).unwrap().is_none());
548
549        let mut record = meta(&paths);
550        record.pending_terminal = Some(Terminal::Succeeded {
551            result: "done".to_string(),
552        });
553        let payload = record.terminal_payload().unwrap();
554        write_terminal(&paths, &payload).unwrap();
555
556        assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
557        assert_eq!(read_terminal(&paths).unwrap().unwrap(), payload);
558        assert_eq!(
559            payload,
560            serde_json::json!({"state": "succeeded", "result": "done"})
561        );
562    }
563
564    #[test]
565    fn terminal_record_keeps_raw_json_beside_the_typed_view() {
566        let raw = serde_json::json!({
567            "state": "succeeded",
568            "result": "done",
569            "result_truncated": true,
570            "stopped_by": "token_budget",
571            "usage": {
572                "input_tokens": 7,
573                "output_tokens": 3,
574                "cache_read_tokens": 0,
575                "cache_creation_tokens": 0
576            },
577            "newer_field": "kept",
578        });
579        let record = TerminalRecord::from_raw(raw.clone());
580
581        assert_eq!(record.raw, raw);
582        assert_eq!(
583            record.terminal,
584            Some(Terminal::Succeeded {
585                result: "done".to_string()
586            })
587        );
588        assert_eq!(record.stopped_by, Some(Bound::TokenBudget));
589        assert_eq!(record.usage.expect("typed usage").input_tokens, 7);
590        assert!(record.result_truncated);
591
592        let unknown = serde_json::json!({"state": "paused_by_newer_basis", "detail": 1});
593        let record = TerminalRecord::from_raw(unknown.clone());
594        assert_eq!(record.raw, unknown);
595        assert_eq!(record.terminal, None);
596    }
597
598    #[test]
599    fn terminal_payloads_carry_truncation_and_bound_metadata() {
600        let dir = tempfile::tempdir().unwrap();
601        let paths = agent(&dir);
602        let mut record = meta(&paths);
603        record.pending_terminal = Some(Terminal::Failed {
604            error: "took too long".to_string(),
605        });
606        record.stopped_by = Some(Bound::Deadline);
607        assert_eq!(
608            record.terminal_payload().unwrap(),
609            serde_json::json!({"state": "failed", "error": "took too long", "stopped_by": "deadline"})
610        );
611
612        record.pending_terminal = Some(Terminal::Cancelled);
613        assert_eq!(
614            record.terminal_payload().unwrap(),
615            serde_json::json!({"state": "cancelled"}),
616            "a cancelled terminal never reports a bound"
617        );
618    }
619
620    /// The terminal record is what `basis wait --json` and `basis list --json`
621    /// read, and it is the only place a settled task's cost survives: the
622    /// event journal can be capped, and the process that spent the tokens is
623    /// gone.
624    #[test]
625    fn a_settled_task_records_what_it_spent_only_when_it_spent_something() {
626        let dir = tempfile::tempdir().unwrap();
627        let paths = agent(&dir);
628        let mut record = meta(&paths);
629        record.pending_terminal = Some(Terminal::Succeeded {
630            result: "done".to_string(),
631        });
632
633        assert_eq!(
634            record.terminal_payload().unwrap(),
635            serde_json::json!({"state": "succeeded", "result": "done"}),
636            "a task whose turns reported nothing claims no measurement"
637        );
638
639        record.usage = RunUsage {
640            input_tokens: 900,
641            output_tokens: 100,
642            cache_read_tokens: 7,
643            cache_creation_tokens: 3,
644            reasoning_tokens: 0,
645            thoughts_tokens: 0,
646            model_responses: 1,
647        };
648        let payload = record.terminal_payload().unwrap();
649        assert_eq!(payload["usage"]["input_tokens"], 900);
650        assert_eq!(payload["usage"]["output_tokens"], 100);
651        assert_eq!(payload["usage"]["cache_read_tokens"], 7);
652        assert_eq!(payload["usage"]["cache_creation_tokens"], 3);
653    }
654
655    #[test]
656    fn cancel_markers_signal_by_existence() {
657        let dir = tempfile::tempdir().unwrap();
658        let paths = agent(&dir);
659        assert!(!cancel_requested(&paths));
660        request_cancel(&paths, Some("caller/id")).unwrap();
661        assert!(cancel_requested(&paths));
662    }
663
664    #[test]
665    fn bounded_text_never_splits_utf8() {
666        let (value, truncated) = bounded_text("a界b".to_string(), 2);
667        assert_eq!(value, "a");
668        assert!(truncated);
669    }
670}