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