1use 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
37const 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63pub(crate) struct EntrySize {
64 pub label: String,
65 pub source: &'static str,
67 pub size_bytes: usize,
68 pub tokens: usize,
69 pub kind: &'static str,
71 pub name: String,
72 #[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 #[serde(skip_serializing_if = "Option::is_none")]
79 pub path: Option<String>,
80 pub delivered: &'static str,
82 pub source_size_bytes: usize,
85 pub source_tokens: usize,
86 #[serde(flatten, skip_serializing_if = "Option::is_none")]
88 pub startup: Option<StartupDetail>,
89 #[serde(skip_serializing_if = "Option::is_none")]
93 pub via: Option<&'static str>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
99pub(crate) struct StartupDetail {
100 pub role: &'static str,
103 pub owner: &'static str,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub count: Option<usize>,
108 #[serde(skip_serializing_if = "Vec::is_empty")]
110 pub contains: Vec<&'static str>,
111}
112
113const CLAIM_WINDOW_MS: i64 = 60_000;
119
120const EVENT_SLACK_MS: i64 = 10_000;
125
126const PENDING_WAIT: std::time::Duration = std::time::Duration::from_secs(4);
130const PENDING_POLL: std::time::Duration = std::time::Duration::from_millis(200);
131
132const BOUNDARY_ORIGINS_MAX: usize = 256;
135
136#[derive(Default)]
139struct DeliveryState {
140 deliveries: HashMap<DeliveryKey, Delivery>,
141 fallback_claims: HashMap<(String, Reason), i64>,
144 fallbacks: HashMap<String, FallbackDelivery>,
147 boundary_origins: VecDeque<(String, String, i64)>,
150 completed: HashMap<(String, Reason), i64>,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158enum FallbackClaim {
159 Deliver,
162 Skip,
164 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 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 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 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 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 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 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 source: String,
285 part: usize,
287 #[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 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
313pub(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
320pub(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 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 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
377fn 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
405pub(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
436fn 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
447pub(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
485pub(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
530const FALLBACK_TTL_MS: i64 = 10 * 60_000;
533
534#[derive(Debug, Clone)]
536struct FallbackDelivery {
537 delivery_id: String,
538 block_id: String,
539 text: String,
540 frame: serde_json::Value,
542 created_ms: i64,
543}
544
545fn 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 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
589fn 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
615pub(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
621pub(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
637fn 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#[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
675fn 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
681fn 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#[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
724fn 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 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 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 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
783fn global_entries(state: &AppState, block_id: &str) -> Vec<Entry> {
786 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
806fn 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
839fn 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
897fn 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
910fn 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 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 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 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 #[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 #[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 #[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 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 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}