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