Skip to main content

agentmux_srv/server/
memory_delivery_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The endpoints Claude Code's `SessionStart` hook calls to deliver an agent's
5//! memory (docs/specs/SPEC_GLOBAL_MEMORY_DELIVERY_2026_09_27.md §7 P2). The
6//! composition and the part rules are `backend::memory_delivery`.
7//!
8//! - `POST /api/v1/agent/memory/session-start/part` — one part. Each of the
9//!   [`HOOK_PARTS`](memory_delivery::HOOK_PARTS) hook commands asks for its
10//!   own part number. They run in parallel, so the first request for a
11//!   (block, session, reason) composes the delivery and caches it, and every
12//!   other part comes from that same composition.
13//! - `POST /api/v1/agent/memory/session-start/ack` — a hook wrote its part to
14//!   Claude. Once every part is acknowledged the delivery is complete: the pane
15//!   gets one persisted `agentmux_memory_injected` frame naming each entry and
16//!   its size (owner decision D11). A part fetched but never acknowledged (the
17//!   hook crashed or timed out) leaves the delivery incomplete, and no notice
18//!   claims otherwise.
19//!
20//! The agent comes from the request's credential (`X-Agent-Token` →
21//! [`Caller::Agent`](super::caller::Caller)): its Personal Memory is that
22//! agent's. Without a token only Global Memory is delivered.
23
24use std::collections::{HashMap, VecDeque};
25use std::sync::{LazyLock, Mutex};
26
27use axum::extract::State;
28use axum::response::IntoResponse;
29use axum::Json;
30use serde::{Deserialize, Serialize};
31
32use crate::backend::memory_delivery::{self, Entry, Reason, Tier};
33
34use super::caller::Caller;
35use super::AppState;
36
37/// How long a composed delivery stays cached for its remaining parts and
38/// acknowledgements. The hooks of one session start run within seconds.
39const DELIVERY_TTL_MS: i64 = 5 * 60_000;
40
41#[derive(Debug, Clone, PartialEq, Eq, Hash)]
42struct DeliveryKey {
43    block_id: String,
44    session_id: String,
45    reason: Reason,
46}
47
48#[derive(Debug, Clone)]
49struct Delivery {
50    parts: Vec<String>,
51    entries: Vec<EntrySize>,
52    summary_bytes: usize,
53    acked: Vec<bool>,
54    created_ms: i64,
55    notice_sent: bool,
56}
57
58/// One item of a delivery as the notice shows it: one card row
59/// (SPEC_CONTEXT_DELIVERY_2026_09_30 §3.1, §3.4). `label` and `source` are
60/// what older builds read; the rest is per-item detail. Sizes are those of
61/// what was delivered, so an item cut by the part cap reports its slice.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63pub(crate) struct EntrySize {
64    pub label: String,
65    /// `global`, `personal`, or `summary` for the running summary.
66    pub source: &'static str,
67    pub size_bytes: usize,
68    pub tokens: usize,
69    /// `global_memory`, `personal_memory` or `running_summary`.
70    pub kind: &'static str,
71    pub name: String,
72    /// Global Memory only: `system` (AgentMux's own) or `workspace`.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub tier: Option<&'static str>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub bundle_id: Option<String>,
77    /// Personal Memory only: the file.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub path: Option<String>,
80    /// `full`, `partial` or `omitted` (§3.4 step 3).
81    pub delivered: &'static str,
82    /// The whole entry's size, whatever was delivered: what the Personal
83    /// Memory size band measures, so a cut delivery still warns.
84    pub source_size_bytes: usize,
85    pub source_tokens: usize,
86    /// A file the CLI read by itself at startup (`kind: "startup_file"`).
87    #[serde(flatten, skip_serializing_if = "Option::is_none")]
88    pub startup: Option<StartupDetail>,
89    /// `startup_file`: a Global Memory entry the agent got through its
90    /// startup file rather than the hook (LC3), so it carries no size of its
91    /// own (the file's row counts it).
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub via: Option<&'static str>,
94}
95
96/// What a `startup_file` item adds (SPEC_LAUNCH_CONTEXT_WORKSPACE_RULE_AND_
97/// STARTUP_FILES_2026_09_30.md §4.1, `backend::startup_files`).
98#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
99pub(crate) struct StartupDetail {
100    /// `user_instructions`, `instructions`, `instructions_import`, `skills`
101    /// or `mcp_servers`.
102    pub role: &'static str,
103    /// `agentmux`, `user` or `external`.
104    pub owner: &'static str,
105    /// Skills and MCP servers: how many are listed.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub count: Option<usize>,
108    /// AgentMux sections the file carries: `global_memory`, `skills_index`.
109    #[serde(skip_serializing_if = "Vec::is_empty")]
110    pub contains: Vec<&'static str>,
111}
112
113/// How long a claim on one event (a session start, a compaction) holds: the
114/// hook and the frontend's fallback for the same event arrive within
115/// seconds of each other, and one block has no two such events that close.
116/// Not for a fallback that waited behind a turn: a dated claim
117/// ([`DeliveryState::claim_event`]) covers that.
118const CLAIM_WINDOW_MS: i64 = 60_000;
119
120/// A fresh session's `agentmux_session_outcome` frame can be written just
121/// after the respawn it reports (`retry_after_resume_failure`), so a hook
122/// delivery composed up to this long before the frame is still that
123/// session's.
124const EVENT_SLACK_MS: i64 = 10_000;
125
126/// How long the fallback waits for a hook delivery still in flight, and how
127/// often it looks. A hook that never finishes must not leave the agent with
128/// no memory, so after this the fallback delivers instead.
129const PENDING_WAIT: std::time::Duration = std::time::Duration::from_secs(4);
130const PENDING_POLL: std::time::Duration = std::time::Duration::from_millis(200);
131
132/// How many compaction boundaries' origins are remembered. Boundary uuids are
133/// unique across blocks.
134const BOUNDARY_ORIGINS_MAX: usize = 256;
135
136/// Deliveries and fallback claims, behind one lock so "who claimed this event
137/// first" is decided atomically.
138#[derive(Default)]
139struct DeliveryState {
140    deliveries: HashMap<DeliveryKey, Delivery>,
141    /// Events the frontend's hidden-reinjection fallback claimed:
142    /// (block, reason) → when.
143    fallback_claims: HashMap<(String, Reason), i64>,
144    /// Re-deliveries srv composed for the frontend's fallback, by
145    /// `delivery_id`, until the hidden message that sends one arrives.
146    fallbacks: HashMap<String, FallbackDelivery>,
147    /// Which block's CLI wrote each compaction boundary, and when: (`uuid`,
148    /// block, ms), oldest first.
149    boundary_origins: VecDeque<(String, String, i64)>,
150    /// The newest hook delivery each (block, reason) finished: when it was
151    /// composed. Unlike `deliveries` it isn't pruned, so a fallback deferred
152    /// behind a long turn still finds it. One entry per block and reason.
153    completed: HashMap<(String, Reason), i64>,
154}
155
156/// What the fallback's claim found.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158enum FallbackClaim {
159    /// Nothing delivered this event yet, or the fallback claimed it already:
160    /// the fallback delivers.
161    Deliver,
162    /// The hook delivered this event completely: the fallback stands down.
163    Skip,
164    /// The hook is delivering this event right now.
165    Pending,
166}
167
168impl DeliveryState {
169    fn prune(&mut self, now: i64) {
170        self.deliveries.retain(|_, d| now - d.created_ms < DELIVERY_TTL_MS);
171        self.fallback_claims.retain(|_, at| now - *at < CLAIM_WINDOW_MS);
172        self.fallbacks.retain(|_, f| now - f.created_ms < FALLBACK_TTL_MS);
173    }
174
175    fn fallback_claimed(&self, block_id: &str, reason: Reason) -> bool {
176        self.fallback_claims.contains_key(&(block_id.to_string(), reason))
177    }
178
179    /// The hook's deliveries of this event, newest first: inside the claim
180    /// window and created after `since`.
181    fn hook_deliveries_mut(&mut self, block_id: &str, reason: Reason, now: i64, since: i64) -> Vec<&mut Delivery> {
182        let mut found: Vec<&mut Delivery> = self
183            .deliveries
184            .iter_mut()
185            .filter(|(k, d)| {
186                k.block_id == block_id && k.reason == reason && now - d.created_ms < CLAIM_WINDOW_MS && d.created_ms > since
187            })
188            .map(|(_, d)| d)
189            .collect();
190        found.sort_by_key(|d| std::cmp::Reverse(d.created_ms));
191        found
192    }
193
194    /// The fallback's atomic claim on an event. A `Deliver` for an unclaimed
195    /// event records the claim, so the hook's parts for it come back empty.
196    /// Hook deliveries created at or before `since` belong to an earlier event.
197    fn claim_fallback(&mut self, block_id: &str, reason: Reason, now: i64, since: i64) -> FallbackClaim {
198        if self.fallback_claimed(block_id, reason) {
199            return FallbackClaim::Deliver;
200        }
201        let hook = self.hook_deliveries_mut(block_id, reason, now, since).into_iter().next().map(|d| d.notice_sent);
202        match hook {
203            Some(true) => FallbackClaim::Skip,
204            Some(false) => FallbackClaim::Pending,
205            None => {
206                self.fallback_claims.insert((block_id.to_string(), reason), now);
207                FallbackClaim::Deliver
208            }
209        }
210    }
211
212    fn record_boundary(&mut self, uuid: &str, block_id: &str, now: i64) {
213        if self.boundary_origins.iter().any(|(u, _, _)| u == uuid) {
214            return;
215        }
216        if self.boundary_origins.len() >= BOUNDARY_ORIGINS_MAX {
217            self.boundary_origins.pop_front();
218        }
219        self.boundary_origins.push_back((uuid.to_string(), block_id.to_string(), now));
220    }
221
222    /// A claim naming its compaction boundary. Only the block whose own CLI
223    /// wrote the boundary may deliver: another block of the agent reads it from
224    /// the shared transcript zone, and its CLI did not compact. A boundary no
225    /// stdout here wrote (another srv instance's) is not this block's either.
226    /// A hook delivery from before the block's previous boundary was that
227    /// compaction's, not this one's.
228    fn claim_boundary(&mut self, block_id: &str, reason: Reason, boundary_uuid: Option<&str>, now: i64) -> FallbackClaim {
229        let mut since = i64::MIN;
230        if let Some(uuid) = boundary_uuid {
231            let Some(idx) = self.boundary_origins.iter().position(|(u, b, _)| u == uuid && b == block_id) else {
232                return FallbackClaim::Skip;
233            };
234            if let Some((_, _, at)) = self.boundary_origins.range(..idx).rev().find(|(_, b, _)| b == block_id) {
235                since = *at;
236            }
237        }
238        self.claim_fallback(block_id, reason, now, since)
239    }
240
241    /// A claim dated by its event: a fresh session, at its
242    /// `agentmux_session_outcome` frame's `timestamp` (srv's own clock). A
243    /// hook delivery composed after the event and finished is that event's
244    /// however long ago, so a fallback deferred behind the session's first
245    /// turn stands down even past [`CLAIM_WINDOW_MS`]. One from before it
246    /// was an earlier session's and doesn't count.
247    fn claim_event(&mut self, block_id: &str, reason: Reason, event_at: i64, now: i64) -> FallbackClaim {
248        let since = event_at.saturating_sub(EVENT_SLACK_MS);
249        let hook_finished = self.completed.get(&(block_id.to_string(), reason)).is_some_and(|at| *at > since);
250        if hook_finished && !self.fallback_claimed(block_id, reason) {
251            return FallbackClaim::Skip;
252        }
253        self.claim_fallback(block_id, reason, now, since)
254    }
255
256    /// The hook finished a delivery: its notice went out.
257    fn note_completed(&mut self, key: &DeliveryKey, created_ms: i64) {
258        let at = self.completed.entry((key.block_id.clone(), key.reason)).or_insert(created_ms);
259        *at = (*at).max(created_ms);
260    }
261
262    /// The fallback gave up waiting for the hook: it delivers, and the hook's
263    /// unfinished delivery is closed so a late acknowledgement adds no second
264    /// notice.
265    fn take_over_from_hook(&mut self, block_id: &str, reason: Reason, now: i64) {
266        for d in self.hook_deliveries_mut(block_id, reason, now, i64::MIN) {
267            d.notice_sent = true;
268        }
269        self.fallback_claims.insert((block_id.to_string(), reason), now);
270    }
271}
272
273static STATE: LazyLock<Mutex<DeliveryState>> = LazyLock::new(|| Mutex::new(DeliveryState::default()));
274
275fn state_lock() -> std::sync::MutexGuard<'static, DeliveryState> {
276    STATE.lock().unwrap_or_else(|e| e.into_inner())
277}
278
279#[derive(Debug, Deserialize)]
280pub(crate) struct PartRequest {
281    block_id: String,
282    session_id: String,
283    /// The hook's `source` (`startup`, `resume`, `clear`, `compact`, `fork`).
284    source: String,
285    /// 1-based.
286    part: usize,
287    /// The session's working directory and Claude's config dir, from the
288    /// hook (its stdin `cwd`, its env `CLAUDE_CONFIG_DIR`): where the CLI
289    /// looked for its startup files. Empty from an older hook binary.
290    #[serde(default)]
291    cwd: String,
292    #[serde(default)]
293    config_dir: String,
294}
295
296#[derive(Debug, Serialize, PartialEq, Eq)]
297pub(crate) struct PartResponse {
298    /// This part's text, or `None` when there is none to deliver (no memory,
299    /// a source that gets no delivery, or a part number past the last).
300    text: Option<String>,
301    part: usize,
302    of: usize,
303}
304
305#[derive(Debug, Deserialize)]
306pub(crate) struct AckRequest {
307    block_id: String,
308    session_id: String,
309    source: String,
310    part: usize,
311}
312
313/// Whether `block_id`'s new-session card went out for a delivery composed
314/// after `since_ms`: what the continued-session note waits for
315/// (`backend::continued_note`).
316pub(crate) fn startup_card_written_since(block_id: &str, since_ms: i64) -> bool {
317    state_lock().completed.get(&(block_id.to_string(), Reason::Startup)).is_some_and(|at| *at > since_ms)
318}
319
320/// `POST /api/v1/agent/memory/session-start/part`
321pub(crate) async fn handle_session_start_part(
322    State(state): State<AppState>,
323    caller: Option<axum::Extension<Caller>>,
324    Json(req): Json<PartRequest>,
325) -> impl IntoResponse {
326    let Some(reason) = Reason::from_hook_source(&req.source) else {
327        return Json(PartResponse { text: None, part: req.part, of: 0 });
328    };
329    let key = DeliveryKey { block_id: req.block_id.clone(), session_id: req.session_id.clone(), reason };
330    let now = agentmux_common::time::now_ms();
331
332    let cached = {
333        let mut st = state_lock();
334        st.prune(now);
335        // The frontend's fallback already delivered this event: stand down.
336        if st.fallback_claimed(&req.block_id, reason) {
337            return Json(PartResponse { text: None, part: req.part, of: 0 });
338        }
339        st.deliveries.get(&key).cloned()
340    };
341    let delivery = match cached {
342        Some(d) => d,
343        None => {
344            let uid = caller.as_ref().and_then(|c| c.uid()).map(str::to_string);
345            let launch = LaunchDirs::from_request(&req);
346            let composed = {
347                let state = state.clone();
348                let block_id = req.block_id.clone();
349                tokio::task::spawn_blocking(move || {
350                    compose_delivery(&state, &block_id, uid.as_deref(), reason, launch.as_ref(), now)
351                })
352                    .await
353                    .unwrap_or_else(|e| {
354                        tracing::warn!(error = %e, "memory delivery: composing failed");
355                        None
356                    })
357            };
358            let Some(composed) = composed else {
359                return Json(PartResponse { text: None, part: req.part, of: 0 });
360            };
361            // A concurrent part may have composed first: keep that one, so
362            // every part of this session start is from the same composition.
363            // And the fallback may have claimed the event meanwhile.
364            let mut st = state_lock();
365            if st.fallback_claimed(&req.block_id, reason) {
366                return Json(PartResponse { text: None, part: req.part, of: 0 });
367            }
368            st.deliveries.entry(key.clone()).or_insert(composed).clone()
369        }
370    };
371    if delivery.parts.is_empty() {
372        send_partless_notice(&state, &key);
373    }
374    Json(part_response(&delivery, req.part))
375}
376
377/// A delivery with nothing for the hook to carry (its Global Memory is in the
378/// startup file, and there's no Personal Memory) still has a card: written on
379/// the first part request, exactly once, since no part will be acknowledged.
380fn send_partless_notice(state: &AppState, key: &DeliveryKey) {
381    let frame = {
382        let mut st = state_lock();
383        match st.deliveries.get_mut(key) {
384            Some(d) if d.parts.is_empty() && !d.notice_sent && !d.entries.is_empty() => {
385                d.notice_sent = true;
386                let (frame, created_ms) = (notice_frame(key, d), d.created_ms);
387                st.note_completed(key, created_ms);
388                frame
389            }
390            _ => return,
391        }
392    };
393    let line = format!("{frame}\n");
394    let zone = crate::backend::blockcontroller::shell::resolve_global_output_zone(&Some(state.mstore.clone()), &key.block_id);
395    crate::backend::blockcontroller::shell::handle_append_block_file(
396        &state.broker,
397        &key.block_id,
398        crate::backend::blockcontroller::persistent::PERSISTENT_OUTPUT_SUBJECT,
399        line.as_bytes(),
400        Some(&state.filestore),
401        zone.as_deref(),
402    );
403}
404
405/// `POST /api/v1/agent/memory/session-start/ack`
406pub(crate) async fn handle_session_start_ack(State(state): State<AppState>, Json(req): Json<AckRequest>) -> impl IntoResponse {
407    let Some(reason) = Reason::from_hook_source(&req.source) else {
408        return Json(serde_json::json!({ "complete": false }));
409    };
410    let key = DeliveryKey { block_id: req.block_id.clone(), session_id: req.session_id.clone(), reason };
411    let notice = {
412        let mut st = state_lock();
413        let done = st.deliveries.get_mut(&key).and_then(|d| acknowledge(d, req.part).then(|| (notice_frame(&key, d), d.created_ms)));
414        done.map(|(frame, created_ms)| {
415            st.note_completed(&key, created_ms);
416            frame
417        })
418    };
419    let complete = notice.is_some();
420    if let Some(frame) = notice {
421        tracing::info!(block_id = %req.block_id, reason = reason.as_str(), "memory delivery: complete, notice sent");
422        let line = format!("{frame}\n");
423        let zone = crate::backend::blockcontroller::shell::resolve_global_output_zone(&Some(state.mstore.clone()), &req.block_id);
424        crate::backend::blockcontroller::shell::handle_append_block_file(
425            &state.broker,
426            &req.block_id,
427            crate::backend::blockcontroller::persistent::PERSISTENT_OUTPUT_SUBJECT,
428            line.as_bytes(),
429            Some(&state.filestore),
430            zone.as_deref(),
431        );
432    }
433    Json(serde_json::json!({ "complete": complete }))
434}
435
436/// The event a frontend reinjection reason stands for: a compaction, or a
437/// fresh session (a spawn without `--resume`, whose hook `source` is
438/// `startup`). `None` for anything else.
439fn reason_for_fallback(reason: &str) -> Option<Reason> {
440    match reason {
441        "compaction" => Some(Reason::Compact),
442        "fresh_session" => Some(Reason::Startup),
443        _ => None,
444    }
445}
446
447/// Whether the frontend's hidden-reinjection fallback should deliver this
448/// event (`true`) or stand down because the `SessionStart` hook delivered it
449/// (`false`). Waits out a hook delivery still in flight for up to
450/// [`PENDING_WAIT`], then lets the fallback deliver, so a hook that died
451/// part-way never leaves the agent without its memory. With a
452/// `boundary_uuid`, only the block whose CLI compacted can deliver. With
453/// `event_at_ms` (a fresh session's time), the hook's delivery for that
454/// session counts however late the claim comes.
455pub(crate) async fn claim_fallback(block_id: &str, reason: &str, boundary_uuid: Option<&str>, event_at_ms: Option<i64>) -> bool {
456    let Some(reason) = reason_for_fallback(reason) else { return true };
457    let deadline = tokio::time::Instant::now() + PENDING_WAIT;
458    let boundary_uuid = boundary_uuid.filter(|u| !u.is_empty());
459    loop {
460        let now = agentmux_common::time::now_ms();
461        let claim = {
462            let mut st = state_lock();
463            st.prune(now);
464            match (boundary_uuid, event_at_ms) {
465                (None, Some(event_at)) => st.claim_event(block_id, reason, event_at, now),
466                _ => st.claim_boundary(block_id, reason, boundary_uuid, now),
467            }
468        };
469        match claim {
470            FallbackClaim::Deliver => return true,
471            FallbackClaim::Skip => {
472                tracing::info!(block_id, reason = reason.as_str(), "memory delivery: the hook delivered, or not this block's compaction; fallback stands down");
473                return false;
474            }
475            FallbackClaim::Pending if tokio::time::Instant::now() >= deadline => {
476                tracing::warn!(block_id, reason = reason.as_str(), "memory delivery: hook still unfinished; fallback delivers instead");
477                state_lock().take_over_from_hook(block_id, reason, agentmux_common::time::now_ms());
478                return true;
479            }
480            FallbackClaim::Pending => tokio::time::sleep(PENDING_POLL).await,
481        }
482    }
483}
484
485/// Registers `memorydelivery:claim_fallback` — the frontend's hidden memory
486/// reinjection asks it right before it would fire — and
487/// `memorydelivery:compose`, which composes what it sends.
488pub(crate) fn register_memory_delivery_handlers(
489    engine: &std::sync::Arc<crate::backend::rpc::engine::WshRpcEngine>,
490    state: &AppState,
491) {
492    use crate::backend::rpc_types::{
493        CommandMemoryDeliveryClaimFallbackData, CommandMemoryDeliveryComposeData, MemoryDeliveryClaimFallbackResult,
494        MemoryDeliveryComposeResult, COMMAND_MEMORY_DELIVERY_CLAIM_FALLBACK, COMMAND_MEMORY_DELIVERY_COMPOSE,
495    };
496    engine.register_typed(
497        COMMAND_MEMORY_DELIVERY_CLAIM_FALLBACK,
498        |req: CommandMemoryDeliveryClaimFallbackData, _ctx| async move {
499            Ok::<_, String>(MemoryDeliveryClaimFallbackResult { deliver: claim_fallback(&req.block_id, &req.reason, req.boundary_uuid.as_deref(), req.event_at_ms).await })
500        },
501    );
502    let state = state.clone();
503    engine.register_typed(COMMAND_MEMORY_DELIVERY_COMPOSE, move |req: CommandMemoryDeliveryComposeData, _ctx| {
504        let state = state.clone();
505        async move {
506            let reason = memory_delivery::FallbackReason::from_wire(&req.reason)
507                .ok_or_else(|| format!("memorydelivery:compose: unknown reason {:?}", req.reason))?;
508            let now = agentmux_common::time::now_ms();
509            let composed = tokio::task::spawn_blocking(move || compose_fallback_delivery(&state, &req.block_id, reason, now))
510                .await
511                .map_err(|e| format!("memorydelivery:compose: {e}"))?;
512            Ok::<_, String>(match composed {
513                Some(f) => {
514                    let reply = MemoryDeliveryComposeResult {
515                        delivery_id: Some(f.delivery_id.clone()),
516                        text: Some(f.text.clone()),
517                        frame: Some(f.frame.clone()),
518                    };
519                    let mut st = state_lock();
520                    st.prune(now);
521                    st.fallbacks.insert(f.delivery_id.clone(), f);
522                    reply
523                }
524                None => MemoryDeliveryComposeResult::default(),
525            })
526        }
527    });
528}
529
530/// How long a composed fallback delivery waits for its hidden message. The
531/// frontend sends it right after composing, unless the pane turned busy.
532const FALLBACK_TTL_MS: i64 = 10 * 60_000;
533
534/// A re-delivery srv composed for the frontend's fallback (CD2b).
535#[derive(Debug, Clone)]
536struct FallbackDelivery {
537    delivery_id: String,
538    block_id: String,
539    text: String,
540    /// The card, written to the pane when the hidden message is accepted.
541    frame: serde_json::Value,
542    created_ms: i64,
543}
544
545/// Composes the fallback's hidden message from the same memory the hook
546/// delivers (`global_entries`, `personal_entries`), and the card that will
547/// stand for it. `None` when there is no memory.
548fn compose_fallback_delivery(
549    state: &AppState,
550    block_id: &str,
551    reason: memory_delivery::FallbackReason,
552    now: i64,
553) -> Option<FallbackDelivery> {
554    let mut entries = global_entries(state, block_id);
555    if let Some(uid) = crate::server::agent_handlers::input::persisted_agent_identity(&state.mstore, block_id).map(|i| i.uid) {
556        entries.extend(personal_entries(&uid));
557    }
558    let composed = memory_delivery::compose_fallback(&entries, reason)?;
559    let mut items = delivery_items(&entries, &composed, composed.text.chars().count());
560    // A compaction's running summary rides along: srv's input handler appends
561    // it to this same message (`continuity_state::with_state_after_compaction`).
562    let summary = (reason == memory_delivery::FallbackReason::Compaction)
563        .then(|| crate::backend::continuity_state::running_summary_section(&state.mstore, block_id))
564        .flatten();
565    if let Some(summary) = &summary {
566        let (size, tokens) = (summary.len(), summary.chars().count().div_ceil(4));
567        items.push(EntrySize {
568            label: "Running summary".into(),
569            source: "summary",
570            size_bytes: size,
571            tokens,
572            kind: "running_summary",
573            name: "Running summary (AgentMux)".into(),
574            tier: None,
575            bundle_id: None,
576            path: None,
577            delivered: "full",
578            source_size_bytes: size,
579            source_tokens: tokens,
580            startup: None,
581            via: None,
582        });
583    }
584    let delivery_id = format!("fb-{}", uuid::Uuid::new_v4());
585    let frame = fallback_frame(&delivery_id, reason, &items, summary.as_ref().map_or(0, |s| s.len()), now);
586    Some(FallbackDelivery { delivery_id, block_id: block_id.to_string(), text: composed.text, frame, created_ms: now })
587}
588
589/// The card for a fallback re-delivery: the hook notice's shape, under the
590/// delivery's own id. `fallback: true` tells replay that the hidden message
591/// right after it is this delivery's echo, not a second one.
592fn fallback_frame(
593    delivery_id: &str,
594    reason: memory_delivery::FallbackReason,
595    entries: &[EntrySize],
596    summary_bytes: usize,
597    now: i64,
598) -> serde_json::Value {
599    serde_json::json!({
600        "type": "system",
601        "subtype": "agentmux_memory_injected",
602        "id": format!("memory-injected-{delivery_id}"),
603        "reason": match reason {
604            memory_delivery::FallbackReason::Compaction => "compact",
605            memory_delivery::FallbackReason::FreshSession => "resume_fresh",
606        },
607        "fallback": true,
608        "delivery_id": delivery_id,
609        "entries": entries,
610        "summary_bytes": summary_bytes,
611        "timestamp": chrono::DateTime::from_timestamp_millis(now).unwrap_or_default().to_rfc3339(),
612    })
613}
614
615/// Records that `block_id`'s CLI wrote the compaction boundary `uuid`. Called
616/// from its stdout reader, before the line reaches any pane.
617pub(crate) fn record_compaction_boundary(block_id: &str, uuid: &str) {
618    state_lock().record_boundary(uuid, block_id, agentmux_common::time::now_ms());
619}
620
621/// The card of the fallback delivery `delivery_id`, taken once, when its
622/// hidden message reaches srv. `None` for an unknown or expired id, or one
623/// composed for another block.
624pub(crate) fn take_fallback_frame(block_id: &str, delivery_id: &str) -> Option<serde_json::Value> {
625    let mut st = state_lock();
626    st.prune(agentmux_common::time::now_ms());
627    match st.fallbacks.remove(delivery_id) {
628        Some(f) if f.block_id == block_id => Some(f.frame),
629        Some(f) => {
630            tracing::warn!(block_id, delivery_block = %f.block_id, "memory delivery: a fallback delivery id from another block");
631            None
632        }
633        None => None,
634    }
635}
636
637/// A Global Memory entry that reached the agent through its startup file:
638/// listed in the card, sized 0 (the file's own row counts it).
639fn in_startup_file_item(e: &Entry) -> EntrySize {
640    EntrySize {
641        label: e.label.clone(),
642        source: "global",
643        size_bytes: 0,
644        tokens: 0,
645        kind: "global_memory",
646        name: e.name.clone(),
647        tier: Some(if e.system { "system" } else { "workspace" }),
648        bundle_id: e.bundle_id.clone(),
649        path: None,
650        delivered: "full",
651        source_size_bytes: e.text.len(),
652        source_tokens: e.text.chars().count().div_ceil(4),
653        startup: None,
654        via: Some("startup_file"),
655    }
656}
657
658/// Where a session's CLI looked for its startup files.
659#[derive(Debug, Clone)]
660struct LaunchDirs {
661    cwd: std::path::PathBuf,
662    config_dir: Option<std::path::PathBuf>,
663}
664
665impl LaunchDirs {
666    fn from_request(req: &PartRequest) -> Option<Self> {
667        let cwd = req.cwd.trim();
668        (!cwd.is_empty()).then(|| Self {
669            cwd: cwd.into(),
670            config_dir: Some(req.config_dir.trim()).filter(|c| !c.is_empty()).map(Into::into),
671        })
672    }
673}
674
675/// The files the CLI read by itself at session start.
676fn startup_files(launch: &LaunchDirs) -> Vec<crate::backend::startup_files::StartupFile> {
677    let home = dirs::home_dir();
678    crate::backend::startup_files::claude_startup_files(&launch.cwd, launch.config_dir.as_deref(), home.as_deref())
679}
680
681/// Whether the CLI already got the current Global Memory from a startup
682/// file: one AgentMux wrote that holds every current section, byte for byte.
683/// A foreign or stale file with some old section doesn't count, or the hook
684/// would drop current entries the agent never saw (Codex on #4131).
685fn global_memory_in_startup_files(files: &[crate::backend::startup_files::StartupFile], global: &[Entry]) -> bool {
686    !global.is_empty()
687        && files.iter().any(|f| {
688            f.owner == crate::backend::startup_files::Owner::Agentmux && global.iter().all(|e| f.text.contains(&e.text))
689        })
690}
691
692/// The files the CLI read by itself at session start, as card items. Only
693/// for a new session: on `/clear` and compaction they are still in context.
694#[cfg(test)]
695fn startup_items(launch: &LaunchDirs) -> Vec<EntrySize> {
696    startup_items_of(startup_files(launch))
697}
698
699fn startup_items_of(files: Vec<crate::backend::startup_files::StartupFile>) -> Vec<EntrySize> {
700    files
701        .into_iter()
702        .map(|f| {
703            let (size_bytes, tokens) = (f.text.len(), f.text.chars().count().div_ceil(4));
704            EntrySize {
705                label: f.name.clone(),
706                source: "startup",
707                size_bytes,
708                tokens,
709                kind: "startup_file",
710                name: f.name,
711                tier: None,
712                bundle_id: None,
713                path: Some(f.path.to_string_lossy().into_owned()),
714                delivered: "full",
715                source_size_bytes: size_bytes,
716                source_tokens: tokens,
717                startup: Some(StartupDetail { role: f.role, owner: f.owner.as_str(), count: f.count, contains: f.contains }),
718                via: None,
719            }
720        })
721        .collect()
722}
723
724/// Reads the memory and composes the delivery. `None` when there is nothing
725/// to deliver. A new session's delivery also lists the startup files the CLI
726/// loaded from `launch` (`startup_items`), ahead of the memory items.
727fn compose_delivery(
728    state: &AppState,
729    block_id: &str,
730    agent_uid: Option<&str>,
731    reason: Reason,
732    launch: Option<&LaunchDirs>,
733    now: i64,
734) -> Option<Delivery> {
735    // What the CLI loaded itself comes first, then what the hook carried.
736    let files = match (reason, launch) {
737        (Reason::Startup, Some(launch)) => startup_files(launch),
738        _ => Vec::new(),
739    };
740    let global = global_entries(state, block_id);
741    // A startup file the CLI loaded already carries the current Global
742    // Memory: the hook doesn't send it a second time (LC3, §4.4). The card
743    // still lists each entry, marked as coming through that file.
744    let global_in_file = global_memory_in_startup_files(&files, &global);
745    let mut items = startup_items_of(files);
746    let mut entries = if global_in_file { Vec::new() } else { global.clone() };
747    if let Some(uid) = agent_uid {
748        entries.extend(personal_entries(uid));
749    }
750    if global_in_file {
751        items.extend(global.iter().map(in_startup_file_item));
752    }
753    let summary = (reason == Reason::Compact)
754        .then(|| crate::backend::continuity_state::running_summary_section(&state.mstore, block_id))
755        .flatten();
756    // Nothing left for the hook to carry is still a delivery when there are
757    // startup items to show: it has no parts, and its card goes out at once.
758    let (parts, delivered) = match memory_delivery::compose_items_with(&entries, reason, summary.as_deref(), global_in_file) {
759        Some(composed) => {
760            let (parts, delivered_chars) = memory_delivery::split_into_parts_counted(
761                &composed.text,
762                memory_delivery::MAX_PART_CHARS,
763                memory_delivery::HOOK_PARTS,
764            );
765            let delivered = delivery_items(&entries, &composed, delivered_chars);
766            (parts, delivered)
767        }
768        None if !items.is_empty() => (Vec::new(), Vec::new()),
769        None => return None,
770    };
771    let acked = vec![false; parts.len()];
772    items.extend(delivered);
773    Some(Delivery {
774        entries: items,
775        summary_bytes: summary.map_or(0, |s| s.len()),
776        parts,
777        acked,
778        created_ms: now,
779        notice_sent: false,
780    })
781}
782
783/// Global Memory as the startup file carries it — its sections, Operator
784/// Config first (`globalmemory:sections`, P1).
785fn global_entries(state: &AppState, block_id: &str) -> Vec<Entry> {
786    // The same entries the pane's startup file got: Operator Config targeted
787    // at another agent kind is left out.
788    let bundles = crate::backend::operator_config_seed::global_bundles_for_agent(
789        state.id_store.bundle_list_global().unwrap_or_default(),
790        &crate::backend::operator_config_seed::agent_mode_of_block(&state.mstore, block_id),
791    );
792    crate::backend::storage::global_bundle_sections(&bundles)
793        .into_iter()
794        .map(|s| Entry {
795            label: format!("{} {}", if s.is_system { "[AgentMux System]" } else { "[Workspace]" }, s.name),
796            tier: Tier::Global,
797            text: s.text,
798            name: s.name,
799            system: s.is_system,
800            bundle_id: Some(s.id),
801            path: None,
802        })
803        .collect()
804}
805
806/// The agent's Personal Memory files from its latest spawn's memory folder,
807/// by name, without the `MEMORY.md` index (the CLI's own table of contents).
808fn personal_entries(agent_uid: &str) -> Vec<Entry> {
809    let Some(dir) = crate::server::native_memory_handlers::memory_dir_from_spawn(agent_uid) else { return Vec::new() };
810    personal_entries_in(&dir)
811}
812
813fn personal_entries_in(dir: &std::path::Path) -> Vec<Entry> {
814    let Ok(read) = std::fs::read_dir(dir) else { return Vec::new() };
815    let mut files: Vec<(String, std::path::PathBuf)> = read
816        .flatten()
817        .filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
818        .filter_map(|e| Some((e.file_name().into_string().ok()?, e.path())))
819        .filter(|(name, _)| name.ends_with(".md") && name != "MEMORY.md")
820        .collect();
821    files.sort();
822    files
823        .into_iter()
824        .filter_map(|(name, path)| {
825            let text = std::fs::read_to_string(&path).ok()?;
826            (!text.trim().is_empty()).then_some(Entry {
827                label: name.clone(),
828                tier: Tier::Personal,
829                text,
830                name,
831                system: false,
832                bundle_id: None,
833                path: Some(path.to_string_lossy().into_owned()),
834            })
835        })
836        .collect()
837}
838
839/// The notice's items, in delivery order, sized by what the parts carried.
840fn delivery_items(entries: &[Entry], composed: &memory_delivery::Composed, delivered_chars: usize) -> Vec<EntrySize> {
841    let chars: Vec<char> = composed.text.chars().collect();
842    composed
843        .spans
844        .iter()
845        .map(|span| {
846            let delivered = memory_delivery::Delivered::of(span, delivered_chars);
847            let slice: String = chars[span.start..span.end.min(delivered_chars).max(span.start)].iter().collect();
848            let (size_bytes, tokens) = (slice.len(), slice.chars().count().div_ceil(4));
849            let whole: String = chars[span.start..span.end].iter().collect();
850            let (source_size_bytes, source_tokens) = (whole.len(), (span.end - span.start).div_ceil(4));
851            match span.entry.map(|i| &entries[i]) {
852                Some(e) => EntrySize {
853                    label: e.label.clone(),
854                    source: e.tier.as_str(),
855                    size_bytes,
856                    tokens,
857                    kind: match e.tier {
858                        Tier::Global => "global_memory",
859                        Tier::Personal => "personal_memory",
860                    },
861                    name: e.name.clone(),
862                    tier: (e.tier == Tier::Global).then_some(if e.system { "system" } else { "workspace" }),
863                    bundle_id: e.bundle_id.clone(),
864                    path: e.path.clone(),
865                    delivered: delivered.as_str(),
866                    source_size_bytes,
867                    source_tokens,
868                    startup: None,
869                    via: None,
870                },
871                None => EntrySize {
872                    label: "Running summary".into(),
873                    source: "summary",
874                    size_bytes,
875                    tokens,
876                    kind: "running_summary",
877                    name: "Running summary (AgentMux)".into(),
878                    tier: None,
879                    bundle_id: None,
880                    path: None,
881                    delivered: delivered.as_str(),
882                    source_size_bytes,
883                    source_tokens,
884                    startup: None,
885                    via: None,
886                },
887            }
888        })
889        .collect()
890}
891
892fn part_response(d: &Delivery, part: usize) -> PartResponse {
893    let text = part.checked_sub(1).and_then(|i| d.parts.get(i)).cloned();
894    PartResponse { text, part, of: d.parts.len() }
895}
896
897/// Marks `part` written. True exactly once: when this makes every part
898/// written and no notice has gone out yet.
899fn acknowledge(d: &mut Delivery, part: usize) -> bool {
900    if let Some(slot) = part.checked_sub(1).and_then(|i| d.acked.get_mut(i)) {
901        *slot = true;
902    }
903    if d.notice_sent || !d.acked.iter().all(|a| *a) {
904        return false;
905    }
906    d.notice_sent = true;
907    true
908}
909
910/// The persisted notice (D11): which memory was delivered, and each entry's
911/// size. `id` is stable for the delivery, so a live frame and a replay of the
912/// same line render once.
913fn notice_frame(key: &DeliveryKey, d: &Delivery) -> serde_json::Value {
914    let timestamp = chrono::DateTime::from_timestamp_millis(d.created_ms).map(|t| t.to_rfc3339()).unwrap_or_default();
915    serde_json::json!({
916        "type": "system",
917        "subtype": "agentmux_memory_injected",
918        "id": format!("memory-injected-{}-{}-{}", key.session_id, key.reason.as_str(), d.created_ms),
919        "reason": key.reason.as_str(),
920        "session_id": key.session_id,
921        "parts": d.parts.len(),
922        "entries": d.entries,
923        "summary_bytes": d.summary_bytes,
924        "timestamp": timestamp,
925    })
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931
932    fn delivery(parts: usize) -> Delivery {
933        Delivery {
934            parts: (1..=parts).map(|i| format!("part {i}")).collect(),
935            entries: vec![EntrySize {
936                label: "notes.md".into(),
937                source: "personal",
938                size_bytes: 5,
939                tokens: 2,
940                kind: "personal_memory",
941                name: "notes.md".into(),
942                tier: None,
943                bundle_id: None,
944                path: None,
945                delivered: "full",
946                source_size_bytes: 5,
947                source_tokens: 2,
948                startup: None,
949                via: None,
950            }],
951            summary_bytes: 0,
952            acked: vec![false; parts],
953            created_ms: 1_790_000_000_000,
954            notice_sent: false,
955        }
956    }
957
958    fn state_with_hook_delivery(block: &str, reason: Reason, notice_sent: bool) -> DeliveryState {
959        let mut st = DeliveryState::default();
960        let mut d = delivery(1);
961        d.notice_sent = notice_sent;
962        st.deliveries.insert(DeliveryKey { block_id: block.into(), session_id: "s".into(), reason }, d);
963        st
964    }
965    const NOW: i64 = 1_790_000_000_500;
966
967    #[test]
968    fn an_unclaimed_event_goes_to_the_fallback_and_the_hook_then_stands_down() {
969        let mut st = DeliveryState::default();
970        assert_eq!(st.claim_fallback("b", Reason::Compact, NOW, i64::MIN), FallbackClaim::Deliver);
971        assert!(st.fallback_claimed("b", Reason::Compact), "the hook's parts now come back empty");
972        assert_eq!(st.claim_fallback("b", Reason::Compact, NOW, i64::MIN), FallbackClaim::Deliver, "a repeated claim is still the fallback's");
973        assert!(!st.fallback_claimed("b", Reason::Startup), "another event is untouched");
974        assert!(!st.fallback_claimed("other", Reason::Compact), "another block is untouched");
975    }
976
977    #[test]
978    fn a_complete_hook_delivery_makes_the_fallback_stand_down() {
979        let mut st = state_with_hook_delivery("b", Reason::Compact, true);
980        assert_eq!(st.claim_fallback("b", Reason::Compact, NOW, i64::MIN), FallbackClaim::Skip);
981        assert!(!st.fallback_claimed("b", Reason::Compact));
982    }
983
984    #[test]
985    fn a_hook_delivery_in_flight_is_pending_until_the_fallback_takes_over() {
986        let mut st = state_with_hook_delivery("b", Reason::Startup, false);
987        assert_eq!(st.claim_fallback("b", Reason::Startup, NOW, i64::MIN), FallbackClaim::Pending);
988        st.take_over_from_hook("b", Reason::Startup, NOW);
989        assert!(st.fallback_claimed("b", Reason::Startup));
990        let d = st.deliveries.values_mut().next().unwrap();
991        assert!(!acknowledge(d, 1), "a late ack after the takeover adds no second notice");
992    }
993
994    fn startup_key(block: &str) -> DeliveryKey {
995        DeliveryKey { block_id: block.into(), session_id: "s".into(), reason: Reason::Startup }
996    }
997
998    #[test]
999    fn a_fresh_session_claimed_after_a_long_first_turn_still_finds_its_hook_delivery() {
1000        // The hook delivered a second after the fresh-session frame; the
1001        // fallback waited out a 95 s first turn, past the claim window, and the
1002        // delivery itself has been pruned.
1003        let event_at = NOW;
1004        let mut st = DeliveryState::default();
1005        st.note_completed(&startup_key("b"), event_at + 1_000);
1006        let later = event_at + 95_000;
1007        st.prune(later);
1008        assert_eq!(st.claim_event("b", Reason::Startup, event_at, later), FallbackClaim::Skip);
1009        assert!(!st.fallback_claimed("b", Reason::Startup));
1010        // Undated, the same claim delivers a duplicate, as it used to.
1011        assert_eq!(st.claim_fallback("b", Reason::Startup, later, i64::MIN), FallbackClaim::Deliver);
1012    }
1013
1014    #[test]
1015    fn an_earlier_sessions_hook_delivery_does_not_cover_a_new_fresh_session() {
1016        let mut st = DeliveryState::default();
1017        st.note_completed(&startup_key("b"), NOW);
1018        let event_at = NOW + EVENT_SLACK_MS + 1;
1019        assert_eq!(st.claim_event("b", Reason::Startup, event_at, event_at + 1_000), FallbackClaim::Deliver);
1020        assert!(st.fallback_claimed("b", Reason::Startup), "the new session's hook now stands down");
1021    }
1022
1023    #[test]
1024    fn a_hook_delivery_just_before_its_sessions_frame_still_counts() {
1025        let mut st = DeliveryState::default();
1026        st.note_completed(&startup_key("b"), NOW);
1027        assert_eq!(st.claim_event("b", Reason::Startup, NOW + EVENT_SLACK_MS - 1, NOW + 120_000), FallbackClaim::Skip);
1028    }
1029
1030    #[test]
1031    fn a_dated_claim_waits_for_a_hook_delivery_in_flight() {
1032        let mut st = state_with_hook_delivery("b", Reason::Startup, false);
1033        let created = st.deliveries.values().next().unwrap().created_ms;
1034        assert_eq!(st.claim_event("b", Reason::Startup, created - 1_000, NOW), FallbackClaim::Pending);
1035    }
1036
1037    #[test]
1038    fn a_dated_claim_is_per_block_and_reason() {
1039        let mut st = DeliveryState::default();
1040        st.note_completed(&startup_key("b"), NOW);
1041        assert_eq!(st.claim_event("other", Reason::Startup, NOW, NOW), FallbackClaim::Deliver);
1042        assert_eq!(st.claim_event("b", Reason::Compact, NOW, NOW), FallbackClaim::Deliver);
1043    }
1044
1045    #[test]
1046    fn completion_keeps_the_newest_delivery() {
1047        let mut st = DeliveryState::default();
1048        st.note_completed(&startup_key("b"), NOW);
1049        st.note_completed(&startup_key("b"), NOW - 5);
1050        assert_eq!(st.completed.get(&("b".to_string(), Reason::Startup)), Some(&NOW));
1051    }
1052
1053    #[test]
1054    fn stale_claims_and_deliveries_expire() {
1055        let mut st = state_with_hook_delivery("b", Reason::Compact, true);
1056        st.fallback_claims.insert(("b".into(), Reason::Startup), NOW);
1057        st.prune(NOW + CLAIM_WINDOW_MS + 1);
1058        assert!(!st.fallback_claimed("b", Reason::Startup));
1059        assert_eq!(
1060            st.claim_fallback("b", Reason::Compact, NOW + CLAIM_WINDOW_MS + 1, i64::MIN),
1061            FallbackClaim::Deliver,
1062            "a delivery older than the window is another event"
1063        );
1064    }
1065
1066    #[test]
1067    fn only_the_block_that_compacted_claims_its_boundary() {
1068        let mut st = DeliveryState::default();
1069        st.record_boundary("u1", "b", NOW);
1070        assert_eq!(
1071            st.claim_boundary("other", Reason::Compact, Some("u1"), NOW),
1072            FallbackClaim::Skip,
1073            "another block of the agent, reading the shared zone"
1074        );
1075        assert_eq!(
1076            st.claim_boundary("b", Reason::Compact, Some("u2"), NOW),
1077            FallbackClaim::Skip,
1078            "a boundary no stdout here wrote"
1079        );
1080        assert_eq!(st.claim_boundary("b", Reason::Compact, Some("u1"), NOW), FallbackClaim::Deliver);
1081        assert_eq!(
1082            st.claim_boundary("b", Reason::Compact, Some("u1"), NOW),
1083            FallbackClaim::Deliver,
1084            "a repeat by the same block delivers again: a duplicate over missing memory"
1085        );
1086    }
1087
1088    #[test]
1089    fn a_boundary_claim_still_stands_down_for_a_hook_delivery_in_the_window() {
1090        let mut st = state_with_hook_delivery("b", Reason::Compact, true);
1091        st.record_boundary("u1", "b", NOW);
1092        assert_eq!(st.claim_boundary("b", Reason::Compact, Some("u1"), NOW), FallbackClaim::Skip);
1093        let mut st = state_with_hook_delivery("b", Reason::Compact, true);
1094        st.record_boundary("u1", "b", NOW);
1095        assert_eq!(
1096            st.claim_boundary("b", Reason::Compact, Some("u1"), NOW + CLAIM_WINDOW_MS + 1),
1097            FallbackClaim::Deliver,
1098            "a hook delivery older than the window is another event"
1099        );
1100    }
1101
1102    #[test]
1103    fn an_earlier_compactions_hook_delivery_does_not_cover_the_next_boundary() {
1104        // The hook delivered compaction 1 (created NOW - 500) just before its
1105        // boundary; compaction 2 follows inside the claim window.
1106        let mut st = state_with_hook_delivery("b", Reason::Compact, true);
1107        st.record_boundary("u1", "b", NOW - 400);
1108        st.record_boundary("u2", "b", NOW - 100);
1109        assert_eq!(st.claim_boundary("b", Reason::Compact, Some("u1"), NOW), FallbackClaim::Skip);
1110        assert_eq!(st.claim_boundary("b", Reason::Compact, Some("u2"), NOW), FallbackClaim::Deliver);
1111    }
1112
1113    #[test]
1114    fn a_claim_without_a_boundary_keeps_the_old_behaviour() {
1115        let mut st = DeliveryState::default();
1116        assert_eq!(st.claim_boundary("b", Reason::Compact, None, NOW), FallbackClaim::Deliver);
1117    }
1118
1119    #[test]
1120    fn boundary_origins_are_bounded() {
1121        let mut st = DeliveryState::default();
1122        for i in 0..=BOUNDARY_ORIGINS_MAX {
1123            st.record_boundary(&format!("u{i}"), "b", NOW);
1124        }
1125        st.record_boundary("u5", "b", NOW);
1126        assert_eq!(st.boundary_origins.len(), BOUNDARY_ORIGINS_MAX);
1127        assert_eq!(st.boundary_origins.front().map(|(u, _, _)| u.as_str()), Some("u1"));
1128    }
1129
1130    #[test]
1131    fn the_fallback_reasons_map_to_hook_events() {
1132        assert_eq!(reason_for_fallback("compaction"), Some(Reason::Compact));
1133        assert_eq!(reason_for_fallback("fresh_session"), Some(Reason::Startup));
1134        assert_eq!(reason_for_fallback("other"), None);
1135    }
1136
1137    #[test]
1138    fn parts_are_one_based_and_past_the_last_is_empty() {
1139        let d = delivery(2);
1140        assert_eq!(part_response(&d, 1), PartResponse { text: Some("part 1".into()), part: 1, of: 2 });
1141        assert_eq!(part_response(&d, 2).text.as_deref(), Some("part 2"));
1142        assert_eq!(part_response(&d, 3), PartResponse { text: None, part: 3, of: 2 });
1143        assert_eq!(part_response(&d, 0).text, None);
1144    }
1145
1146    #[test]
1147    fn the_notice_goes_out_once_every_part_is_written_and_only_once() {
1148        let mut d = delivery(3);
1149        assert!(!acknowledge(&mut d, 1));
1150        assert!(!acknowledge(&mut d, 3));
1151        assert!(!acknowledge(&mut d, 3), "a repeated ack is not the missing part");
1152        assert!(acknowledge(&mut d, 2), "the last missing part completes it");
1153        assert!(!acknowledge(&mut d, 2), "never twice");
1154        assert!(!acknowledge(&mut d, 9), "an out-of-range part changes nothing");
1155    }
1156
1157    #[test]
1158    fn an_unacknowledged_part_leaves_no_notice() {
1159        let mut d = delivery(2);
1160        assert!(!acknowledge(&mut d, 1));
1161        assert!(!d.notice_sent);
1162    }
1163
1164    #[test]
1165    fn the_notice_names_each_entry_and_its_size() {
1166        let key = DeliveryKey { block_id: "b".into(), session_id: "s1".into(), reason: Reason::Startup };
1167        let f = notice_frame(&key, &delivery(1));
1168        assert_eq!(f["type"], "system");
1169        assert_eq!(f["subtype"], "agentmux_memory_injected");
1170        assert_eq!(f["reason"], "startup");
1171        assert_eq!(f["entries"][0]["label"], "notes.md");
1172        assert_eq!(f["entries"][0]["size_bytes"], 5);
1173        assert_eq!(f["entries"][0]["tokens"], 2);
1174        assert_eq!(f["id"], "memory-injected-s1-startup-1790000000000");
1175    }
1176
1177    /// A new session's card lists the files the CLI loaded by itself, as
1178    /// `startup_file` items with their role and owner flattened onto the item
1179    /// (SPEC_LAUNCH_CONTEXT_WORKSPACE_RULE_AND_STARTUP_FILES_2026_09_30 §4).
1180    #[test]
1181    fn startup_items_carry_role_owner_and_what_the_file_contains() {
1182        let dir = tempfile::tempdir().unwrap();
1183        let cwd = dir.path().join("ws");
1184        std::fs::create_dir_all(cwd.join(".claude")).unwrap();
1185        std::fs::write(cwd.join("CLAUDE.md"), "@.claude/AGENTMUX_MEMORY.md\n").unwrap();
1186        std::fs::write(cwd.join(".claude/AGENTMUX_MEMORY.md"), "# [AgentMux System] App API\n").unwrap();
1187        std::fs::write(cwd.join(".mcp.json"), r#"{"mcpServers":{"agentmux":{}}}"#).unwrap();
1188        let launch = LaunchDirs { cwd: cwd.clone(), config_dir: None };
1189
1190        let items: Vec<serde_json::Value> =
1191            startup_items(&launch).iter().map(|i| serde_json::to_value(i).unwrap()).collect();
1192        let memory = items.iter().find(|i| i["role"] == "instructions_import").expect("the import");
1193        assert_eq!(memory["kind"], "startup_file");
1194        assert_eq!(memory["owner"], "agentmux");
1195        assert_eq!(memory["contains"], serde_json::json!(["global_memory"]));
1196        assert_eq!(memory["delivered"], "full");
1197        assert!(memory.get("startup").is_none(), "flattened, not nested");
1198        let own = items.iter().find(|i| i["role"] == "instructions").unwrap();
1199        assert_eq!(own["owner"], "user");
1200        assert!(own.get("contains").is_none(), "empty lists are left out");
1201        let mcp = items.iter().find(|i| i["role"] == "mcp_servers").unwrap();
1202        assert_eq!((mcp["count"].as_u64(), mcp["size_bytes"].as_u64()), (Some(1), Some(0)));
1203    }
1204
1205    /// Only an AgentMux-written startup file holding every current section
1206    /// counts as delivering the Global Memory (Codex on #4131).
1207    #[test]
1208    fn global_memory_counts_as_in_the_file_only_when_every_current_section_is() {
1209        use crate::backend::startup_files::{Owner, StartupFile};
1210        let entry = |name: &str, text: &str| Entry {
1211            label: name.into(),
1212            tier: Tier::Global,
1213            text: text.into(),
1214            name: name.into(),
1215            system: true,
1216            bundle_id: None,
1217            path: None,
1218        };
1219        let file = |owner: Owner, text: &str| StartupFile {
1220            role: "instructions_import",
1221            name: "AGENTMUX_MEMORY.md".into(),
1222            path: "/ws/.claude/AGENTMUX_MEMORY.md".into(),
1223            owner,
1224            text: text.into(),
1225            count: None,
1226            contains: vec!["global_memory"],
1227        };
1228        let global = [entry("A", "# [AgentMux System] A\n\nnew a"), entry("B", "# [Workspace] B\n\nb")];
1229        let both = "# Memory\n# [AgentMux System] A\n\nnew a\n\n---\n\n# [Workspace] B\n\nb\n";
1230        assert!(global_memory_in_startup_files(&[file(Owner::Agentmux, both)], &global));
1231        assert!(!global_memory_in_startup_files(&[file(Owner::User, both)], &global), "not AgentMux's");
1232        let stale = "# Memory\n# [AgentMux System] A\n\nold a\n\n---\n\n# [Workspace] B\n\nb\n";
1233        assert!(!global_memory_in_startup_files(&[file(Owner::Agentmux, stale)], &global), "an old section");
1234        assert!(!global_memory_in_startup_files(&[file(Owner::Agentmux, "# [Workspace] B\n\nb")], &global), "one missing");
1235        assert!(!global_memory_in_startup_files(&[file(Owner::Agentmux, both)], &[]), "nothing to deliver");
1236    }
1237
1238    #[test]
1239    fn launch_dirs_need_a_cwd_and_ignore_an_empty_config_dir() {
1240        let req = |cwd: &str, config_dir: &str| PartRequest {
1241            block_id: "b".into(),
1242            session_id: "s".into(),
1243            source: "startup".into(),
1244            part: 1,
1245            cwd: cwd.into(),
1246            config_dir: config_dir.into(),
1247        };
1248        assert!(LaunchDirs::from_request(&req("", "/cfg")).is_none(), "an older hook sends no cwd");
1249        let l = LaunchDirs::from_request(&req("/ws", " ")).unwrap();
1250        assert_eq!((l.cwd, l.config_dir), ("/ws".into(), None));
1251    }
1252
1253    /// The fallback's card is the hook notice's shape under the delivery's
1254    /// own id, flagged so replay knows the hidden echo after it (CD2b).
1255    #[test]
1256    fn a_fallback_card_names_its_delivery_and_reason() {
1257        let items = delivery(1).entries;
1258        let f = fallback_frame("fb-1", memory_delivery::FallbackReason::FreshSession, &items, 0, 1_790_000_000_000);
1259        assert_eq!(f["subtype"], "agentmux_memory_injected");
1260        assert_eq!(f["id"], "memory-injected-fb-1");
1261        assert_eq!(f["reason"], "resume_fresh");
1262        assert_eq!(f["fallback"], true);
1263        assert_eq!(f["delivery_id"], "fb-1");
1264        assert_eq!(f["entries"][0]["label"], "notes.md");
1265        let c = fallback_frame("fb-2", memory_delivery::FallbackReason::Compaction, &items, 0, 0);
1266        assert_eq!(c["reason"], "compact");
1267    }
1268
1269    #[test]
1270    fn a_fallback_card_is_taken_once_and_only_by_its_own_block() {
1271        let now = agentmux_common::time::now_ms();
1272        let put = |id: &str, block: &str| {
1273            state_lock().fallbacks.insert(
1274                id.to_string(),
1275                FallbackDelivery {
1276                    delivery_id: id.to_string(),
1277                    block_id: block.to_string(),
1278                    text: String::new(),
1279                    frame: serde_json::json!({"id": id}),
1280                    created_ms: now,
1281                },
1282            );
1283        };
1284        put("fb-take-1", "blk-a");
1285        assert_eq!(take_fallback_frame("blk-a", "fb-take-1"), Some(serde_json::json!({"id": "fb-take-1"})));
1286        assert_eq!(take_fallback_frame("blk-a", "fb-take-1"), None, "once");
1287        put("fb-take-2", "blk-a");
1288        assert_eq!(take_fallback_frame("blk-b", "fb-take-2"), None, "another block's id");
1289        assert_eq!(take_fallback_frame("blk-a", "fb-unknown"), None);
1290    }
1291
1292    #[test]
1293    fn personal_entries_skip_the_index_empty_files_and_non_markdown() {
1294        let dir = tempfile::tempdir().unwrap();
1295        std::fs::write(dir.path().join("MEMORY.md"), "- [a](a.md)").unwrap();
1296        std::fs::write(dir.path().join("b.md"), "bee").unwrap();
1297        std::fs::write(dir.path().join("a.md"), "ay").unwrap();
1298        std::fs::write(dir.path().join("empty.md"), "  \n").unwrap();
1299        std::fs::write(dir.path().join("notes.txt"), "not memory").unwrap();
1300        let entries = personal_entries_in(dir.path());
1301        assert_eq!(
1302            entries.iter().map(|e| (e.label.as_str(), e.text.as_str())).collect::<Vec<_>>(),
1303            vec![("a.md", "ay"), ("b.md", "bee")]
1304        );
1305        assert!(entries.iter().all(|e| e.tier == Tier::Personal));
1306        assert!(personal_entries_in(&dir.path().join("missing")).is_empty());
1307    }
1308
1309    fn item(label: &str, tier: Tier, text: &str) -> Entry {
1310        Entry {
1311            label: label.into(),
1312            tier,
1313            text: text.into(),
1314            name: label.into(),
1315            system: label.starts_with("sys"),
1316            bundle_id: (tier == Tier::Global).then(|| format!("id-{label}")),
1317            path: (tier == Tier::Personal).then(|| format!("/mem/{label}")),
1318        }
1319    }
1320
1321    #[test]
1322    fn items_carry_their_detail_and_follow_the_cut() {
1323        let entries = [
1324            item("sys-api", Tier::Global, &"a".repeat(100)),
1325            item("rules", Tier::Global, &"b".repeat(100)),
1326            item("notes.md", Tier::Personal, &"c".repeat(100)),
1327        ];
1328        let composed = memory_delivery::compose_items(&entries, Reason::Compact, Some(&"d".repeat(50))).unwrap();
1329        // Cut halfway through the second global entry.
1330        let cut = composed.spans[1].start + 40;
1331        let items = delivery_items(&entries, &composed, cut);
1332
1333        assert_eq!(items.len(), 4);
1334        assert_eq!((items[0].kind, items[0].tier, items[0].delivered, items[0].size_bytes), ("global_memory", Some("system"), "full", 100));
1335        assert_eq!(items[0].bundle_id.as_deref(), Some("id-sys-api"));
1336        assert_eq!((items[1].tier, items[1].delivered, items[1].size_bytes), (Some("workspace"), "partial", 40));
1337        assert_eq!((items[2].kind, items[2].delivered, items[2].size_bytes), ("personal_memory", "omitted", 0));
1338        // The whole entry's size survives the cut, for the size band.
1339        assert_eq!((items[2].source_size_bytes, items[2].source_tokens), (100, 25));
1340        assert_eq!((items[1].source_size_bytes, items[1].size_bytes), (100, 40));
1341        assert_eq!(items[2].path.as_deref(), Some("/mem/notes.md"));
1342        assert_eq!((items[3].kind, items[3].source, items[3].delivered), ("running_summary", "summary", "omitted"));
1343    }
1344
1345    #[test]
1346    fn an_uncut_delivery_reports_every_item_whole() {
1347        let entries = [item("rules", Tier::Global, "rules text"), item("n.md", Tier::Personal, "notes")];
1348        let composed = memory_delivery::compose_items(&entries, Reason::Startup, None).unwrap();
1349        let items = delivery_items(&entries, &composed, composed.text.chars().count());
1350        assert!(items.iter().all(|i| i.delivered == "full"));
1351        assert_eq!(items.iter().map(|i| i.size_bytes).collect::<Vec<_>>(), vec![10, 5]);
1352        let json = serde_json::to_value(&items[1]).unwrap();
1353        assert!(json.get("tier").is_none() && json.get("bundle_id").is_none(), "{json}");
1354        assert_eq!(json["path"], "/mem/n.md");
1355    }
1356}