Skip to main content

agentmux_srv/backend/
memory_delivery.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! An agent's memory, delivered through Claude Code's `SessionStart` hook
5//! (docs/specs/SPEC_GLOBAL_MEMORY_DELIVERY_2026_09_27.md §7 P2).
6//!
7//! The hook's `additionalContext` reaches the model without showing as a
8//! conversation turn — but, measured against the CLI AgentMux bundles
9//! (2.1.280), only up to about 10,000 characters **per hook command**: past
10//! that the CLI swaps it for a saved file plus a 2 KB preview, which the model
11//! can't reliably read. Several hook commands' contexts are all delivered,
12//! each under its own cap, in the order the hooks *finish* (they run in
13//! parallel). So the memory is split into [`MAX_PART_CHARS`] parts, each
14//! labelled "part N of M", and [`HOOK_PARTS`] hook commands each ask for one.
15//!
16//! This module is pure: composition, splitting and the source rules. The
17//! per-session cache, the acknowledgements and the notice live in the server
18//! handlers.
19
20/// The most characters one part carries. The measured per-hook cap is about
21/// 10,000; the margin covers the part label and any counting difference.
22pub const MAX_PART_CHARS: usize = 9_000;
23
24/// How many hook commands are installed, so the most a delivery can carry is
25/// `HOOK_PARTS * MAX_PART_CHARS` characters. A memory larger than that is cut
26/// at the end, and the last part says so.
27pub const HOOK_PARTS: usize = 8;
28
29/// Why memory is being delivered — the `SessionStart` hook's `source`, for the
30/// sources that get a delivery.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum Reason {
33    /// A new session (`startup`).
34    Startup,
35    /// `/clear` started a new, empty conversation.
36    Clear,
37    /// The conversation was just compacted.
38    Compact,
39}
40
41impl Reason {
42    /// The reason for a hook `source`, or `None` when that source gets no
43    /// delivery: `resume` and `fork` continue a conversation that is intact,
44    /// and the startup file still carries the memory (owner decision D10).
45    pub fn from_hook_source(source: &str) -> Option<Reason> {
46        match source {
47            "startup" => Some(Reason::Startup),
48            "clear" => Some(Reason::Clear),
49            "compact" => Some(Reason::Compact),
50            _ => None,
51        }
52    }
53
54    pub fn as_str(self) -> &'static str {
55        match self {
56            Reason::Startup => "startup",
57            Reason::Clear => "clear",
58            Reason::Compact => "compact",
59        }
60    }
61
62    fn clause(self) -> &'static str {
63        match self {
64            Reason::Startup => "This is the start of a new session.",
65            Reason::Clear => "The conversation was just cleared.",
66            Reason::Compact => "Your recent conversation was just compacted into a summary.",
67        }
68    }
69}
70
71/// Whether an entry is Global Memory (Operator Config included) or the agent's
72/// own Personal Memory.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Tier {
75    Global,
76    Personal,
77}
78
79impl Tier {
80    pub fn as_str(self) -> &'static str {
81        match self {
82            Tier::Global => "global",
83            Tier::Personal => "personal",
84        }
85    }
86}
87
88/// One memory entry: a Global Memory section as the startup file carries it,
89/// or one Personal Memory file.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Entry {
92    /// What the notice calls it (`[AgentMux System] MuxAPI`, `notes.md`, …).
93    pub label: String,
94    pub tier: Tier,
95    /// The full text sent to the model.
96    pub text: String,
97    /// The entry's own name: the Global Memory entry's name, or the file name.
98    pub name: String,
99    /// Global Memory only: AgentMux's own system tier, not the workspace's.
100    pub system: bool,
101    /// Global Memory only: the entry's id.
102    pub bundle_id: Option<String>,
103    /// Personal Memory only: the file it was read from.
104    pub path: Option<String>,
105}
106
107/// The text delivered to the model: the Global Memory block (its sections
108/// joined exactly as the startup file joins them), the Personal Memory files,
109/// and — after a compaction — AgentMux's running summary, which the provider's
110/// own compaction summary can't drop.
111///
112/// `None` when there is nothing to deliver.
113#[cfg(test)]
114pub fn compose(entries: &[Entry], reason: Reason, running_summary: Option<&str>) -> Option<String> {
115    compose_items(entries, reason, running_summary).map(|c| c.text)
116}
117
118/// Where one item sits in a composed delivery, in characters
119/// (SPEC_CONTEXT_DELIVERY_2026_09_30 §3.4 step 3).
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct ItemSpan {
122    /// The item's index in `entries`, or `None` for the running summary.
123    pub entry: Option<usize>,
124    pub start: usize,
125    pub end: usize,
126}
127
128/// A composed delivery and where each item sits in it.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Composed {
131    pub text: String,
132    /// In delivery order: Global Memory, Personal Memory, then the summary.
133    pub spans: Vec<ItemSpan>,
134}
135
136/// A string being built, with its length in characters kept alongside.
137struct Tracked {
138    out: String,
139    chars: usize,
140}
141
142impl Tracked {
143    /// Appends `s` and returns the character range it now occupies.
144    fn push(&mut self, s: &str) -> (usize, usize) {
145        let start = self.chars;
146        self.out.push_str(s);
147        self.chars += s.chars().count();
148        (start, self.chars)
149    }
150}
151
152/// [`compose`], also recording where each item landed so the notice can say
153/// which items a cut delivery carried whole, in part, or not at all.
154#[cfg(test)]
155pub fn compose_items(entries: &[Entry], reason: Reason, running_summary: Option<&str>) -> Option<Composed> {
156    compose_items_with(entries, reason, running_summary, false)
157}
158
159/// [`compose_items`], saying so when the Global Memory was left out because
160/// the agent's startup file already carries it (a new Claude session that
161/// loaded `AGENTMUX_MEMORY.md` or an AgentMux-owned `CLAUDE.md`;
162/// SPEC_LAUNCH_CONTEXT_WORKSPACE_RULE_AND_STARTUP_FILES_2026_09_30.md §4.4).
163pub fn compose_items_with(
164    entries: &[Entry],
165    reason: Reason,
166    running_summary: Option<&str>,
167    global_in_startup_file: bool,
168) -> Option<Composed> {
169    let global: Vec<usize> = (0..entries.len()).filter(|&i| entries[i].tier == Tier::Global).collect();
170    let personal: Vec<usize> = (0..entries.len()).filter(|&i| entries[i].tier == Tier::Personal).collect();
171    let summary = running_summary.filter(|s| !s.trim().is_empty() && reason == Reason::Compact);
172    if global.is_empty() && personal.is_empty() && summary.is_none() {
173        return None;
174    }
175
176    let mut b = Tracked { out: String::new(), chars: 0 };
177    if global_in_startup_file {
178        b.push(&format!(
179            "AgentMux memory for this agent. {} Your Global Memory is already in your startup \
180             instructions, so it isn't repeated here. Read all of this now — it is your complete \
181             Personal Memory, not just the index.\n\n",
182            reason.clause()
183        ));
184    } else {
185        b.push(&format!(
186            "AgentMux memory for this agent. {} Read all of it now — it is your complete Global \
187             Memory and Personal Memory, not just the index.\n\n",
188            reason.clause()
189        ));
190    }
191    let mut spans = Vec::new();
192    let wrote_section = push_sections(&mut b, entries, &global, &personal, &mut spans);
193    if let Some(summary) = summary {
194        if wrote_section {
195            b.push("\n");
196        }
197        let (start, end) = b.push(summary);
198        spans.push(ItemSpan { entry: None, start, end });
199    }
200    Some(Composed { text: b.out, spans })
201}
202
203/// `# Global Memory (n entries)` and `# Personal Memory (…)`, each item's
204/// text whole, sections separated by a blank line; empty sections left out.
205/// The frontend's `composeReinjectionMessage` writes the same, so a
206/// re-delivery carries the startup block's sections byte for byte. Returns
207/// whether anything was written.
208fn push_sections(b: &mut Tracked, entries: &[Entry], global: &[usize], personal: &[usize], spans: &mut Vec<ItemSpan>) -> bool {
209    let mut wrote_section = false;
210    let sections = [
211        ("Global Memory", global, crate::backend::storage::bundles::GLOBAL_SECTION_SEPARATOR),
212        ("Personal Memory", personal, "\n---\n"),
213    ];
214    for (heading, list, separator) in sections {
215        if list.is_empty() {
216            continue;
217        }
218        if wrote_section {
219            b.push("\n");
220        }
221        let noun = if list.len() == 1 { "entry" } else { "entries" };
222        b.push(&format!("# {heading} ({} {noun})\n", list.len()));
223        for (k, &i) in list.iter().enumerate() {
224            if k > 0 {
225                b.push(separator);
226            }
227            let (start, end) = b.push(&entries[i].text);
228            spans.push(ItemSpan { entry: Some(i), start, end });
229        }
230        b.push("\n");
231        wrote_section = true;
232    }
233    wrote_section
234}
235
236/// The sentence every hidden memory re-delivery opens with, whatever the
237/// reason: what `is_hidden_reinjection_text` (srv) and
238/// `isMemoryReinjectionMessage` (frontend) recognise it by.
239pub const REINJECTION_SIGNATURE: &str = "Your memory was reinjected because your working context was just reset.";
240
241/// Why the frontend's fallback re-delivers memory
242/// (`memory-reinjection.ts`'s `ReinjectionReason`).
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum FallbackReason {
245    /// The conversation was compacted.
246    Compaction,
247    /// AgentMux couldn't resume the prior session, so a fresh one started.
248    FreshSession,
249}
250
251impl FallbackReason {
252    pub fn from_wire(reason: &str) -> Option<Self> {
253        match reason {
254            "compaction" => Some(Self::Compaction),
255            "fresh_session" => Some(Self::FreshSession),
256            _ => None,
257        }
258    }
259
260    /// The second sentence — the frontend's `REASON_CLAUSE`, verbatim.
261    pub fn clause(self) -> &'static str {
262        match self {
263            Self::Compaction => "Your recent conversation was just compacted into a summary.",
264            Self::FreshSession => {
265                "AgentMux could not resume this agent's prior session, so a fresh one was started. Any record \
266                 of the prior conversation AgentMux had came with your first message, in an \
267                 <agentmux-continuation> block."
268            }
269        }
270    }
271}
272
273/// The fallback's hidden message, composed on srv
274/// (SPEC_CONTEXT_DELIVERY_2026_09_30.md §3.4 step 2, CD2b). Its wire format
275/// is the frontend's `composeReinjectionMessage`, unchanged: the
276/// `<system-reminder>` envelope and [`REINJECTION_SIGNATURE`] are how srv's
277/// digest, the continuation packet and replay recognise a hidden turn. No
278/// part cap: the fallback is one message. `None` when there is no memory.
279/// A compaction's running summary is not here: srv's input handler appends
280/// it (`continuity_state::with_state_after_compaction`).
281pub fn compose_fallback(entries: &[Entry], reason: FallbackReason) -> Option<Composed> {
282    let global: Vec<usize> = (0..entries.len()).filter(|&i| entries[i].tier == Tier::Global).collect();
283    let personal: Vec<usize> = (0..entries.len()).filter(|&i| entries[i].tier == Tier::Personal).collect();
284    if global.is_empty() && personal.is_empty() {
285        return None;
286    }
287    let mut b = Tracked { out: String::new(), chars: 0 };
288    b.push(&format!(
289        "<system-reminder>\n{REINJECTION_SIGNATURE} {} Below is your\n\
290         complete Global Memory and Personal Memory content — read all of it now,\n\
291         not just the index.\n\n",
292        reason.clause()
293    ));
294    let mut spans = Vec::new();
295    push_sections(&mut b, entries, &global, &personal, &mut spans);
296    b.push("</system-reminder>\n");
297    Some(Composed { text: b.out, spans })
298}
299
300/// How much of one item a delivery carried.
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum Delivered {
303    Full,
304    /// Cut by the part cap: this many of its characters went out.
305    Partial(usize),
306    Omitted,
307}
308
309impl Delivered {
310    /// Where `span` falls against the first `delivered_chars` characters of
311    /// the composed text, which is all a cut delivery carries.
312    pub fn of(span: &ItemSpan, delivered_chars: usize) -> Delivered {
313        if span.end <= delivered_chars {
314            Delivered::Full
315        } else if span.start >= delivered_chars {
316            Delivered::Omitted
317        } else {
318            Delivered::Partial(delivered_chars - span.start)
319        }
320    }
321
322    pub fn as_str(self) -> &'static str {
323        match self {
324            Delivered::Full => "full",
325            Delivered::Partial(_) => "partial",
326            Delivered::Omitted => "omitted",
327        }
328    }
329}
330
331/// `text` split into parts of at most `max_chars` characters each, label
332/// included, every one saying which part of how many it is (the hooks
333/// deliver in the order they finish, not in order). Cuts at a line end where
334/// one falls in the second half of a part, else mid-line. More than
335/// `max_parts` parts: the text is cut, and the last part ends with a note
336/// giving how much was left out.
337#[cfg(test)]
338pub fn split_into_parts(text: &str, max_chars: usize, max_parts: usize) -> Vec<String> {
339    split_into_parts_counted(text, max_chars, max_parts).0
340}
341
342/// [`split_into_parts`], plus how many of `text`'s characters the parts carry:
343/// all of them unless the text was cut.
344pub fn split_into_parts_counted(text: &str, max_chars: usize, max_parts: usize) -> (Vec<String>, usize) {
345    // The label is at most "[AgentMux memory — part 99 of 99]\n".
346    const LABEL_ROOM: usize = 40;
347    const CUT_NOTE_ROOM: usize = 120;
348    let budget = max_chars.saturating_sub(LABEL_ROOM).max(1);
349
350    let chars: Vec<char> = text.chars().collect();
351    let mut chunks: Vec<String> = Vec::new();
352    let mut at = 0;
353    while at < chars.len() {
354        let end = (at + budget).min(chars.len());
355        let cut = if end == chars.len() {
356            end
357        } else {
358            // The last newline in the second half of this chunk, if any.
359            (at + budget / 2..end).rev().find(|&i| chars[i] == '\n').map_or(end, |i| i + 1)
360        };
361        chunks.push(chars[at..cut].iter().collect());
362        at = cut;
363    }
364
365    let mut delivered = chars.len();
366    if chunks.len() > max_parts {
367        let kept: usize = chunks[..max_parts].iter().map(|c| c.chars().count()).sum();
368        let omitted = chars.len() - kept;
369        chunks.truncate(max_parts);
370        let last = chunks.last_mut().expect("max_parts >= 1");
371        // Make room for the note inside the last part's budget.
372        let keep = last.chars().count().min(budget.saturating_sub(CUT_NOTE_ROOM));
373        let dropped = last.chars().count() - keep;
374        *last = last.chars().take(keep).collect();
375        last.push_str(&format!(
376            "\n\n[AgentMux: memory cut here — {} more characters did not fit in one delivery.]",
377            omitted + dropped
378        ));
379        delivered = kept - dropped;
380    }
381
382    let of = chunks.len();
383    let parts = chunks
384        .into_iter()
385        .enumerate()
386        .map(|(i, c)| format!("[AgentMux memory — part {} of {of}]\n{c}", i + 1))
387        .collect();
388    (parts, delivered)
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    fn entry(label: &str, tier: Tier, text: &str) -> Entry {
396        Entry { label: label.into(), tier, text: text.into(), name: label.into(), system: false, bundle_id: None, path: None }
397    }
398
399    #[test]
400    fn spans_point_at_each_item_in_the_composed_text() {
401        let entries = [
402            entry("A", Tier::Global, "alpha"),
403            entry("n.md", Tier::Personal, "notes"),
404            entry("B", Tier::Global, "beta"),
405        ];
406        let c = compose_items(&entries, Reason::Compact, Some("summary")).unwrap();
407        assert_eq!(c.text, compose(&entries, Reason::Compact, Some("summary")).unwrap(), "same text as compose");
408        let chars: Vec<char> = c.text.chars().collect();
409        let at = |s: &ItemSpan| chars[s.start..s.end].iter().collect::<String>();
410        // Global first (in entry order), then Personal, then the summary.
411        let order: Vec<Option<usize>> = c.spans.iter().map(|s| s.entry).collect();
412        assert_eq!(order, vec![Some(0), Some(2), Some(1), None]);
413        assert_eq!(c.spans.iter().map(at).collect::<Vec<_>>(), vec!["alpha", "beta", "notes", "summary"]);
414    }
415
416    #[test]
417    fn spans_are_counted_in_characters_not_bytes() {
418        let entries = [entry("é", Tier::Global, "ééé"), entry("x", Tier::Personal, "xyz")];
419        let c = compose_items(&entries, Reason::Startup, None).unwrap();
420        let chars: Vec<char> = c.text.chars().collect();
421        assert_eq!(chars[c.spans[1].start..c.spans[1].end].iter().collect::<String>(), "xyz");
422    }
423
424    #[test]
425    fn delivered_status_follows_the_cut() {
426        let span = ItemSpan { entry: Some(0), start: 100, end: 200 };
427        assert_eq!(Delivered::of(&span, 200), Delivered::Full);
428        assert_eq!(Delivered::of(&span, 5_000), Delivered::Full);
429        assert_eq!(Delivered::of(&span, 150), Delivered::Partial(50));
430        assert_eq!(Delivered::of(&span, 100), Delivered::Omitted);
431        assert_eq!(Delivered::of(&span, 10), Delivered::Omitted);
432    }
433
434    #[test]
435    fn counted_split_reports_everything_when_uncut() {
436        let text = "z".repeat(20_000);
437        let (parts, delivered) = split_into_parts_counted(&text, MAX_PART_CHARS, HOOK_PARTS);
438        assert_eq!(delivered, text.chars().count());
439        assert_eq!(parts, split_into_parts(&text, MAX_PART_CHARS, HOOK_PARTS));
440    }
441
442    #[test]
443    fn counted_split_reports_exactly_the_delivered_prefix_when_cut() {
444        let text = (0..200_000).map(|i| char::from(b'a' + (i % 26) as u8)).collect::<String>();
445        let (parts, delivered) = split_into_parts_counted(&text, MAX_PART_CHARS, HOOK_PARTS);
446        assert!(delivered < text.chars().count());
447        let carried: String = parts
448            .iter()
449            .map(|p| p.split_once('\n').unwrap().1.split("\n\n[AgentMux: memory cut here").next().unwrap())
450            .collect();
451        assert_eq!(carried.chars().count(), delivered);
452        assert!(text.starts_with(&carried), "the parts carry a prefix of the text");
453    }
454
455    #[test]
456    fn resume_and_fork_get_no_delivery() {
457        assert_eq!(Reason::from_hook_source("startup"), Some(Reason::Startup));
458        assert_eq!(Reason::from_hook_source("clear"), Some(Reason::Clear));
459        assert_eq!(Reason::from_hook_source("compact"), Some(Reason::Compact));
460        assert_eq!(Reason::from_hook_source("resume"), None, "D10");
461        assert_eq!(Reason::from_hook_source("fork"), None, "a fork continues an intact conversation");
462        assert_eq!(Reason::from_hook_source("anything"), None);
463    }
464
465    #[test]
466    fn composes_global_block_then_personal_then_summary_on_compact() {
467        let entries = [
468            entry("[AgentMux System] App API", Tier::Global, "IMPORTANT: …\n\n# [AgentMux System] App API\n\napi"),
469            entry("[Workspace] Rules", Tier::Global, "# [Workspace] Rules\n\nrules"),
470            entry("notes.md", Tier::Personal, "my notes"),
471        ];
472        let text = compose(&entries, Reason::Compact, Some("# Running summary …\nsummary")).unwrap();
473        let global_block = format!(
474            "IMPORTANT: …\n\n# [AgentMux System] App API\n\napi{}# [Workspace] Rules\n\nrules",
475            crate::backend::storage::bundles::GLOBAL_SECTION_SEPARATOR
476        );
477        assert!(text.contains(&format!("# Global Memory (2 entries)\n{global_block}\n")), "{text}");
478        assert!(text.contains("# Personal Memory (1 entry)\nmy notes\n"));
479        let (g, p, s) = (text.find("# Global").unwrap(), text.find("# Personal").unwrap(), text.find("# Running summary").unwrap());
480        assert!(g < p && p < s);
481        assert!(text.contains("just compacted"));
482    }
483
484    #[test]
485    fn the_summary_rides_only_on_a_compaction() {
486        let entries = [entry("notes.md", Tier::Personal, "n")];
487        let startup = compose(&entries, Reason::Startup, Some("# Running summary …")).unwrap();
488        assert!(!startup.contains("Running summary"));
489        assert!(startup.contains("start of a new session"));
490    }
491
492    #[test]
493    fn nothing_to_deliver_is_none() {
494        assert_eq!(compose(&[], Reason::Startup, None), None);
495        assert_eq!(compose(&[], Reason::Startup, Some("summary")), None, "no summary outside a compaction");
496        assert!(compose(&[], Reason::Compact, Some("summary")).is_some(), "a summary alone is worth delivering");
497    }
498
499    #[test]
500    fn small_text_is_one_labelled_part() {
501        let parts = split_into_parts("hello", MAX_PART_CHARS, HOOK_PARTS);
502        assert_eq!(parts, vec!["[AgentMux memory — part 1 of 1]\nhello".to_string()]);
503    }
504
505    #[test]
506    fn every_part_fits_the_cap_and_the_parts_rebuild_the_text() {
507        let text = (0..3_000).map(|i| format!("line {i} of the memory\n")).collect::<String>();
508        let parts = split_into_parts(&text, MAX_PART_CHARS, HOOK_PARTS);
509        assert!(parts.len() > 1);
510        for (i, p) in parts.iter().enumerate() {
511            assert!(p.chars().count() <= MAX_PART_CHARS, "part {i} is {} chars", p.chars().count());
512            assert!(p.starts_with(&format!("[AgentMux memory — part {} of {}]\n", i + 1, parts.len())));
513        }
514        let rebuilt: String = parts.iter().map(|p| p.split_once('\n').unwrap().1).collect();
515        assert_eq!(rebuilt, text);
516        assert!(parts[..parts.len() - 1].iter().all(|p| p.ends_with('\n')), "cut at line ends");
517    }
518
519    #[test]
520    fn a_long_line_is_cut_mid_line() {
521        let text = "x".repeat(20_000);
522        let parts = split_into_parts(&text, MAX_PART_CHARS, HOOK_PARTS);
523        assert!(parts.iter().all(|p| p.chars().count() <= MAX_PART_CHARS));
524        let rebuilt: String = parts.iter().map(|p| p.split_once('\n').unwrap().1).collect();
525        assert_eq!(rebuilt, text);
526    }
527
528    #[test]
529    fn more_than_the_hooks_can_carry_is_cut_with_a_note() {
530        let text = "y".repeat(MAX_PART_CHARS * (HOOK_PARTS + 2));
531        let parts = split_into_parts(&text, MAX_PART_CHARS, HOOK_PARTS);
532        assert_eq!(parts.len(), HOOK_PARTS);
533        assert!(parts.iter().all(|p| p.chars().count() <= MAX_PART_CHARS));
534        let last = parts.last().unwrap();
535        assert!(last.contains("memory cut here"), "{}", &last[last.len() - 200..]);
536        let kept: usize = parts
537            .iter()
538            .map(|p| p.split_once('\n').unwrap().1.split("\n\n[AgentMux: memory cut here").next().unwrap().len())
539            .sum();
540        let omitted: usize = last.rsplit_once("— ").unwrap().1.split(' ').next().unwrap().parse().unwrap();
541        assert_eq!(kept + omitted, text.len(), "the note's count is exact");
542    }
543
544    #[test]
545    fn multibyte_text_is_counted_in_characters() {
546        let text = "é".repeat(12_000);
547        let parts = split_into_parts(&text, MAX_PART_CHARS, HOOK_PARTS);
548        assert!(parts.iter().all(|p| p.chars().count() <= MAX_PART_CHARS));
549        let rebuilt: String = parts.iter().map(|p| p.split_once('\n').unwrap().1).collect();
550        assert_eq!(rebuilt, text);
551    }
552
553    /// The fallback's wire format is the frontend's `composeReinjectionMessage`
554    /// byte for byte (CD2b). `memory-reinjection.test.ts` pins this same
555    /// literal for the same entries, so the two can't drift.
556    #[test]
557    fn compose_fallback_writes_the_frontends_exact_message() {
558        let entries = vec![
559            Entry {
560                label: "[AgentMux System] App API".into(),
561                tier: Tier::Global,
562                text: "# [AgentMux System] App API\n\nG1".into(),
563                name: "App API".into(),
564                system: true,
565                bundle_id: Some("b1".into()),
566                path: None,
567            },
568            Entry {
569                label: "[Workspace] Rules".into(),
570                tier: Tier::Global,
571                text: "# [Workspace] Rules\n\nG2".into(),
572                name: "Rules".into(),
573                system: false,
574                bundle_id: Some("b2".into()),
575                path: None,
576            },
577            Entry {
578                label: "notes.md".into(),
579                tier: Tier::Personal,
580                text: "P1".into(),
581                name: "notes.md".into(),
582                system: false,
583                bundle_id: None,
584                path: Some("/m/notes.md".into()),
585            },
586        ];
587        let c = compose_fallback(&entries, FallbackReason::Compaction).unwrap();
588        assert_eq!(
589            c.text,
590            "<system-reminder>\n\
591             Your memory was reinjected because your working context was just reset. Your recent conversation was just compacted into a summary. Below is your\n\
592             complete Global Memory and Personal Memory content — read all of it now,\n\
593             not just the index.\n\n\
594             # Global Memory (2 entries)\n\
595             # [AgentMux System] App API\n\nG1\n\n---\n\n# [Workspace] Rules\n\nG2\n\
596             \n\
597             # Personal Memory (1 entry)\n\
598             P1\n\
599             </system-reminder>\n"
600        );
601        assert_eq!(c.spans.len(), 3);
602        assert!(crate::ambient::digest::is_hidden_reinjection_text(&c.text));
603
604        let fresh = compose_fallback(&entries[2..], FallbackReason::FreshSession).unwrap();
605        assert!(fresh.text.contains(
606            "AgentMux could not resume this agent's prior session, so a fresh one was started. Any record of the prior \
607             conversation AgentMux had came with your first message, in an <agentmux-continuation> block. Below is your\n"
608        ));
609        assert!(!fresh.text.contains("# Global Memory"), "an empty section is left out");
610        assert_eq!(compose_fallback(&[], FallbackReason::Compaction), None);
611    }
612
613    #[test]
614    fn fallback_reasons_are_the_frontends() {
615        assert_eq!(FallbackReason::from_wire("compaction"), Some(FallbackReason::Compaction));
616        assert_eq!(FallbackReason::from_wire("fresh_session"), Some(FallbackReason::FreshSession));
617        assert_eq!(FallbackReason::from_wire("startup"), None);
618    }
619
620    #[test]
621    fn a_delivery_without_the_global_memory_says_where_it_is() {
622        let personal = vec![Entry {
623            label: "notes.md".into(),
624            tier: Tier::Personal,
625            text: "P1".into(),
626            name: "notes.md".into(),
627            system: false,
628            bundle_id: None,
629            path: None,
630        }];
631        let c = compose_items_with(&personal, Reason::Startup, None, true).unwrap();
632        assert!(c.text.contains("Your Global Memory is already in your startup instructions"), "{}", c.text);
633        assert!(!c.text.contains("# Global Memory"));
634        assert!(c.text.contains("# Personal Memory (1 entry)\nP1\n"));
635        assert_eq!(compose_items_with(&[], Reason::Startup, None, true), None);
636    }
637}