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