Skip to main content

mempal_runtime/cowork/
inbox.rs

1//! Bidirectional cowork inbox for P8 cowork-push protocol.
2//!
3//! File-based ephemeral message queue between Claude Code and Codex
4//! agents working in the same project (git root). Push appends a jsonl
5//! entry; drain atomically renames + reads + deletes the file.
6//!
7//! Design: docs/specs/2026-04-14-p8-cowork-inbox-push.md
8//! Spec:   specs/p8-cowork-inbox-push.spec.md
9
10use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12
13use super::peek::Tool;
14
15pub const MAX_MESSAGE_SIZE: usize = 8 * 1024;
16pub const MAX_PENDING_MESSAGES: usize = 16;
17pub const MAX_TOTAL_INBOX_BYTES: u64 = 32 * 1024;
18
19static UNLOCKED_MESSAGE_ID_COUNTER: std::sync::atomic::AtomicU64 =
20    std::sync::atomic::AtomicU64::new(0);
21
22#[derive(Debug, thiserror::Error)]
23pub enum InboxError {
24    #[error("message content exceeds {MAX_MESSAGE_SIZE} bytes: got {0} bytes")]
25    MessageTooLarge(usize),
26    #[error("invalid cwd path (contains `..` or is not absolute): {0}")]
27    InvalidCwd(String),
28    #[error("cannot push to self (both caller and target resolve to {0:?})")]
29    SelfPush(Tool),
30    #[error(
31        "inbox full: {current_count} messages / {current_bytes} bytes pending \
32         (limits: {MAX_PENDING_MESSAGES} messages, {MAX_TOTAL_INBOX_BYTES} bytes) — \
33         partner must drain first"
34    )]
35    InboxFull {
36        current_count: usize,
37        current_bytes: u64,
38    },
39    #[error("io error: {0}")]
40    Io(#[from] std::io::Error),
41    #[error("json error: {0}")]
42    Json(#[from] serde_json::Error),
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct InboxMessage {
47    pub pushed_at: String,
48    pub from: String,
49    pub content: String,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub thread_id: Option<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub channel: Option<String>,
54    /// P116 delivery receipt handle. Absent on pre-P116 inbox lines.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub message_id: Option<String>,
57}
58
59/// Result of a receipt-tracked push (P116).
60#[derive(Debug, Clone)]
61pub struct PushOutcome {
62    pub inbox_path: PathBuf,
63    pub inbox_size_after: u64,
64    pub message_id: String,
65}
66
67/// Deterministic receipt handle: `msg_` + first 12 hex of SHA-256 over
68/// `pushed_at`, `from`, and `content` (null-byte separated).
69#[must_use]
70pub fn build_message_id(pushed_at: &str, from: &str, content: &str) -> String {
71    use sha2::{Digest, Sha256};
72
73    let mut hasher = Sha256::new();
74    hasher.update(pushed_at.as_bytes());
75    hasher.update([0]);
76    hasher.update(from.as_bytes());
77    hasher.update([0]);
78    hasher.update(content.as_bytes());
79    let digest = format!("{:x}", hasher.finalize());
80    format!("msg_{}", &digest[..12])
81}
82
83/// Push like [`push`], then best-effort append a `queued` receipt event
84/// (P116). Receipt IO failures never fail the push.
85///
86/// # Errors
87///
88/// Returns [`InboxError`] when the push violates inbox validation or the
89/// inbox message itself cannot be serialized or written.
90pub fn push_with_receipt(
91    mempal_home: &Path,
92    caller: Tool,
93    target: Tool,
94    cwd: &Path,
95    content: String,
96    pushed_at: String,
97) -> Result<PushOutcome, InboxError> {
98    // Hold the per-project receipts lock across id selection, the inbox
99    // write, AND the queued receipt append: without it two concurrent
100    // identical pushes can both read the same used-id set and pick the
101    // same handle. Lock failure switches id selection to a collision-
102    // resistant fallback and leaves receipt IO best-effort.
103    let guard = super::receipts::acquire_receipts_lock(mempal_home, cwd);
104    // P9's Windows implementation deliberately returns a no-op guard. It
105    // keeps the same API shape but does not serialize id selection.
106    let id_selection_is_serialized = guard.is_some() && cfg!(unix);
107
108    let base_id = build_message_id(&pushed_at, caller.dir_name(), &content);
109    // `pushed_at` is second-precision, so same-second identical pushes
110    // would collide. Uniquify against handles already visible in the
111    // receipts log and the live inboxes (best-effort: an unreadable
112    // receipts log must not block delivery).
113    let (used_ids, used_id_snapshot_is_complete) = collect_used_message_ids(mempal_home, cwd);
114    let message_id = if id_selection_is_serialized && used_id_snapshot_is_complete {
115        next_serialized_message_id(&base_id, &used_ids)
116    } else {
117        next_unserialized_message_id(&base_id, &used_ids)
118    };
119    let (inbox_path, inbox_size_after) = push_message(
120        mempal_home,
121        caller,
122        target,
123        cwd,
124        content,
125        pushed_at.clone(),
126        Some(message_id.clone()),
127    )?;
128
129    let event = super::receipts::ReceiptEvent {
130        event: super::receipts::EVENT_QUEUED.to_string(),
131        message_id: Some(message_id.clone()),
132        from: caller.dir_name().to_string(),
133        to: target.dir_name().to_string(),
134        at: pushed_at,
135        injected_as: None,
136        hook_runtime: None,
137    };
138    // Receipts are observability; never fail the push over them.
139    let _ = super::receipts::append_event_assuming_locked(mempal_home, cwd, &event);
140
141    Ok(PushOutcome {
142        inbox_path,
143        inbox_size_after,
144        message_id,
145    })
146}
147
148fn next_serialized_message_id(
149    base_id: &str,
150    used_ids: &std::collections::HashSet<String>,
151) -> String {
152    let mut message_id = base_id.to_string();
153    let mut suffix = 2;
154    while used_ids.contains(&message_id) {
155        message_id = format!("{base_id}-{suffix}");
156        suffix += 1;
157    }
158    message_id
159}
160
161/// Select a collision-resistant handle when the receipts lock is missing or
162/// is the documented Windows no-op. Concurrent local processes have distinct
163/// process ids, while the atomic counter separates threads in one process;
164/// wall-clock nanos also defend against process-id reuse across launches.
165fn next_unserialized_message_id(
166    base_id: &str,
167    used_ids: &std::collections::HashSet<String>,
168) -> String {
169    use std::sync::atomic::Ordering;
170    use std::time::{SystemTime, UNIX_EPOCH};
171
172    loop {
173        let nanos = SystemTime::now()
174            .duration_since(UNIX_EPOCH)
175            .map(|duration| duration.as_nanos())
176            .unwrap_or_default();
177        let sequence = UNLOCKED_MESSAGE_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
178        let candidate = format!("{base_id}-u{:x}-{nanos:x}-{sequence:x}", std::process::id());
179        if !used_ids.contains(&candidate) {
180            return candidate;
181        }
182    }
183}
184
185/// Every `message_id` currently visible in the receipts log or a live inbox,
186/// plus whether every source was read successfully. Callers must not trust a
187/// deterministic candidate when the snapshot is incomplete.
188fn collect_used_message_ids(
189    mempal_home: &Path,
190    cwd: &Path,
191) -> (std::collections::HashSet<String>, bool) {
192    let mut used = std::collections::HashSet::new();
193    let mut is_complete = true;
194    match super::receipts::load_events_with_completeness(mempal_home, cwd) {
195        Ok((events, receipts_are_complete)) => {
196            used.extend(events.into_iter().filter_map(|event| event.message_id));
197            is_complete &= receipts_are_complete;
198        }
199        Err(_) => is_complete = false,
200    }
201    for target in [Tool::Claude, Tool::Codex] {
202        let Ok(path) = inbox_path(mempal_home, target, cwd) else {
203            is_complete = false;
204            continue;
205        };
206        let content = match std::fs::read_to_string(&path) {
207            Ok(content) => content,
208            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
209            Err(_) => {
210                is_complete = false;
211                continue;
212            }
213        };
214        for line in content.lines() {
215            match serde_json::from_str::<InboxMessage>(line.trim()) {
216                Ok(message) => {
217                    if let Some(id) = message.message_id {
218                        used.insert(id);
219                    }
220                }
221                Err(_) => is_complete = false,
222            }
223        }
224    }
225    (used, is_complete)
226}
227
228/// Resolve ~/.mempal using the HOME env var. Matches the existing
229/// `expand_home` pattern at src/main.rs:949-957. Used by both the CLI
230/// subcommands (cowork-drain / cowork-status / cowork-install-hooks)
231/// and the MCP server handler (mempal_cowork_push).
232///
233/// No `dirs` crate dependency — P8 explicitly promises zero new runtime deps.
234pub fn mempal_home() -> PathBuf {
235    match std::env::var_os("HOME") {
236        Some(home) => PathBuf::from(home).join(".mempal"),
237        None => PathBuf::from(".mempal"),
238    }
239}
240
241/// Resolve the given cwd to a canonical "project identity" path. Walks the
242/// directory tree looking for a `.git` entry (git repo root); falls back to
243/// the raw cwd if no `.git` ancestor is found.
244///
245/// This normalizes the "Claude in repo root, Codex in src/cowork" scenario —
246/// both resolve to the same project identity, so push and drain see the same
247/// inbox file.
248pub fn project_identity(cwd: &Path) -> PathBuf {
249    let mut current = cwd.to_path_buf();
250    loop {
251        if current.join(".git").exists() {
252            return current;
253        }
254        match current.parent() {
255            Some(parent) => current = parent.to_path_buf(),
256            None => return cwd.to_path_buf(),
257        }
258    }
259}
260
261/// Encode an already-normalized project identity path into the dashed
262/// filename format. Input should be the OUTPUT of `project_identity`, not
263/// a raw cwd. Rejects non-absolute paths and paths containing `..`.
264pub fn encode_project_identity(identity: &Path) -> Result<String, InboxError> {
265    let s = identity.to_string_lossy();
266    if !identity.is_absolute() || s.contains("..") {
267        return Err(InboxError::InvalidCwd(s.to_string()));
268    }
269    Ok(s.replace('/', "-"))
270}
271
272/// Return `<mempal_home>/cowork-inbox/<target>/<encoded_project_identity>.jsonl`.
273pub fn inbox_path(mempal_home: &Path, target: Tool, cwd: &Path) -> Result<PathBuf, InboxError> {
274    let identity = project_identity(cwd);
275    let encoded = encode_project_identity(&identity)?;
276    Ok(mempal_home
277        .join("cowork-inbox")
278        .join(target.dir_name())
279        .join(format!("{encoded}.jsonl")))
280}
281
282/// Append a message to the target agent's inbox. Enforces self-push rejection,
283/// size cap, and PROSPECTIVE backpressure (checks post-append state, not
284/// pre-append state — ensures MAX_TOTAL_INBOX_BYTES is a real upper bound).
285///
286/// Returns `(inbox_path, total_bytes_after_append)`.
287pub fn push(
288    mempal_home: &Path,
289    caller: Tool,
290    target: Tool,
291    cwd: &Path,
292    content: String,
293    pushed_at: String,
294) -> Result<(PathBuf, u64), InboxError> {
295    push_message(mempal_home, caller, target, cwd, content, pushed_at, None)
296}
297
298#[allow(clippy::too_many_arguments)]
299fn push_message(
300    mempal_home: &Path,
301    caller: Tool,
302    target: Tool,
303    cwd: &Path,
304    content: String,
305    pushed_at: String,
306    message_id: Option<String>,
307) -> Result<(PathBuf, u64), InboxError> {
308    use std::fs;
309    use std::io::Write;
310
311    if caller == target {
312        return Err(InboxError::SelfPush(caller));
313    }
314    if content.len() > MAX_MESSAGE_SIZE {
315        return Err(InboxError::MessageTooLarge(content.len()));
316    }
317
318    let path = inbox_path(mempal_home, target, cwd)?;
319    if let Some(parent) = path.parent() {
320        fs::create_dir_all(parent)?;
321    }
322
323    let (existing_count, existing_bytes) = if path.exists() {
324        let content_bytes = fs::read_to_string(&path).unwrap_or_default();
325        let line_count = content_bytes
326            .lines()
327            .filter(|l| !l.trim().is_empty())
328            .count();
329        (line_count, content_bytes.len() as u64)
330    } else {
331        (0, 0)
332    };
333
334    let msg = InboxMessage {
335        pushed_at,
336        from: caller.dir_name().to_string(),
337        content,
338        thread_id: None,
339        channel: None,
340        message_id,
341    };
342    let line = serde_json::to_string(&msg)?;
343    // writeln! appends exactly 1 byte for `\n`
344    let new_line_bytes = (line.len() as u64) + 1;
345    let prospective_count = existing_count + 1;
346    let prospective_bytes = existing_bytes.saturating_add(new_line_bytes);
347    if prospective_count > MAX_PENDING_MESSAGES || prospective_bytes > MAX_TOTAL_INBOX_BYTES {
348        return Err(InboxError::InboxFull {
349            current_count: existing_count,
350            current_bytes: existing_bytes,
351        });
352    }
353
354    let mut file = fs::OpenOptions::new()
355        .create(true)
356        .append(true)
357        .open(&path)?;
358    writeln!(file, "{line}")?;
359    file.flush()?;
360
361    let size = fs::metadata(&path)?.len();
362    Ok((path, size))
363}
364
365/// Drain all messages from this (target, project_identity) inbox.
366///
367/// **At-most-once, winner-takes-all.** Two concurrent drain calls race on
368/// `fs::rename(path → path.draining)`. POSIX guarantees this rename is atomic:
369/// exactly one caller wins and proceeds to read+delete; the loser sees
370/// `ErrorKind::NotFound` and returns an empty Vec. **Crash window**: a winner
371/// crashing after rename but before delete leaves an orphaned `.draining`
372/// file whose content is lost. This is an accepted tradeoff; P8 does not
373/// implement crash recovery.
374pub fn drain(
375    mempal_home: &Path,
376    target: Tool,
377    cwd: &Path,
378) -> Result<Vec<InboxMessage>, InboxError> {
379    use std::fs;
380
381    let path = inbox_path(mempal_home, target, cwd)?;
382    let draining = path.with_extension("draining");
383
384    match fs::rename(&path, &draining) {
385        Ok(_) => {}
386        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
387            return Ok(Vec::new());
388        }
389        Err(e) => return Err(e.into()),
390    }
391
392    let content = fs::read_to_string(&draining)?;
393    let mut messages = Vec::new();
394    for line in content.lines() {
395        let trimmed = line.trim();
396        if trimmed.is_empty() {
397            continue;
398        }
399        // Skip malformed lines rather than failing the whole drain.
400        if let Ok(msg) = serde_json::from_str::<InboxMessage>(trimmed) {
401            messages.push(msg);
402        }
403    }
404
405    // Best-effort cleanup; content is already in `messages`.
406    let _ = fs::remove_file(&draining);
407    Ok(messages)
408}
409
410/// Format drained messages as plain text for prepend-to-prompt hooks.
411pub fn format_plain(from: Tool, messages: &[InboxMessage]) -> String {
412    if messages.is_empty() {
413        return String::new();
414    }
415    let mut out = format!(
416        "[Partner inbox from {} ({} message{} since last check):]\n",
417        from.dir_name(),
418        messages.len(),
419        if messages.len() == 1 { "" } else { "s" }
420    );
421    for msg in messages {
422        out.push_str(&format!("- {}: {}\n", msg.pushed_at, msg.content));
423    }
424    out.push_str("[End partner inbox]\n");
425    out
426}
427
428/// Format drained messages as Codex native hook JSON envelope.
429/// Returns empty string when no messages.
430pub fn format_codex_hook_json(from: Tool, messages: &[InboxMessage]) -> Result<String, InboxError> {
431    if messages.is_empty() {
432        return Ok(String::new());
433    }
434    let plain = format_plain(from, messages);
435    let envelope = serde_json::json!({
436        "hookSpecificOutput": {
437            "hookEventName": "UserPromptSubmit",
438            "additionalContext": plain
439        }
440    });
441    Ok(envelope.to_string())
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use std::fs;
448    use tempfile::TempDir;
449
450    #[test]
451    fn project_identity_walks_to_git_root_from_subdir() {
452        let tmp = TempDir::new().unwrap();
453        let repo_root = tmp.path().join("project-gamma");
454        let subdir = repo_root.join("src").join("cowork");
455        fs::create_dir_all(&subdir).unwrap();
456        fs::create_dir_all(repo_root.join(".git")).unwrap();
457
458        assert_eq!(project_identity(&subdir), repo_root);
459        assert_eq!(project_identity(&repo_root), repo_root);
460    }
461
462    #[test]
463    fn project_identity_falls_back_to_raw_cwd_without_git() {
464        let tmp = TempDir::new().unwrap();
465        let plain = tmp.path().join("no-git-dir");
466        fs::create_dir_all(&plain).unwrap();
467
468        assert_eq!(project_identity(&plain), plain);
469    }
470
471    #[test]
472    fn encode_project_identity_rejects_relative_path() {
473        let result = encode_project_identity(Path::new("relative/path"));
474        assert!(matches!(result, Err(InboxError::InvalidCwd(_))));
475    }
476
477    #[test]
478    fn encode_project_identity_rejects_parent_traversal() {
479        let result = encode_project_identity(Path::new("/tmp/../etc"));
480        assert!(matches!(result, Err(InboxError::InvalidCwd(_))));
481    }
482
483    #[test]
484    fn encode_project_identity_replaces_slashes_with_dashes() {
485        let encoded =
486            encode_project_identity(Path::new("/Users/zhangalex/Work/Projects/AI/mempal")).unwrap();
487        assert_eq!(encoded, "-Users-zhangalex-Work-Projects-AI-mempal");
488    }
489
490    #[test]
491    fn mempal_home_resolves_from_home_env_var() {
492        // `mempal_home()` reads `$HOME` at call time. This test verifies the
493        // shape — `$HOME/.mempal` — without mutating the process env.
494        let home = std::env::var("HOME").unwrap_or_default();
495        if home.is_empty() {
496            return;
497        }
498        let resolved = mempal_home();
499        assert_eq!(resolved, PathBuf::from(home).join(".mempal"));
500    }
501
502    #[test]
503    fn inbox_path_composes_home_target_and_encoded_identity() {
504        let tmp = TempDir::new().unwrap();
505        let repo = tmp.path().join("proj");
506        fs::create_dir_all(repo.join(".git")).unwrap();
507
508        let path = inbox_path(tmp.path(), Tool::Codex, &repo).unwrap();
509        assert!(path.starts_with(tmp.path().join("cowork-inbox").join("codex")));
510        assert!(path.to_string_lossy().ends_with(".jsonl"));
511        let encoded_name = path.file_name().unwrap().to_string_lossy().into_owned();
512        assert!(encoded_name.contains("proj"));
513    }
514
515    fn rfc3339() -> String {
516        "2026-04-15T00:00:00Z".to_string()
517    }
518
519    fn tmpdir_with_git() -> (TempDir, PathBuf) {
520        let tmp = TempDir::new().unwrap();
521        let repo = tmp.path().join("proj");
522        fs::create_dir_all(repo.join(".git")).unwrap();
523        (tmp, repo)
524    }
525
526    #[test]
527    fn push_rejects_content_over_max_size() {
528        let tmp_home = TempDir::new().unwrap();
529        let (_tmp_repo, repo) = tmpdir_with_git();
530        let oversize = "x".repeat(MAX_MESSAGE_SIZE + 1);
531        let err = push(
532            tmp_home.path(),
533            Tool::Claude,
534            Tool::Codex,
535            &repo,
536            oversize,
537            rfc3339(),
538        )
539        .unwrap_err();
540        assert!(matches!(err, InboxError::MessageTooLarge(n) if n == MAX_MESSAGE_SIZE + 1));
541    }
542
543    #[test]
544    fn push_rejects_cwd_with_parent_traversal() {
545        let tmp = TempDir::new().unwrap();
546        let weird = Path::new("/tmp/../etc");
547        let err = push(
548            tmp.path(),
549            Tool::Claude,
550            Tool::Codex,
551            weird,
552            "x".into(),
553            rfc3339(),
554        )
555        .unwrap_err();
556        assert!(matches!(err, InboxError::InvalidCwd(_)));
557    }
558
559    #[test]
560    fn push_rejects_self_push() {
561        let tmp_home = TempDir::new().unwrap();
562        let (_t, repo) = tmpdir_with_git();
563        let err = push(
564            tmp_home.path(),
565            Tool::Codex,
566            Tool::Codex,
567            &repo,
568            "x".into(),
569            rfc3339(),
570        )
571        .unwrap_err();
572        assert!(matches!(err, InboxError::SelfPush(Tool::Codex)));
573    }
574
575    #[test]
576    fn push_rejects_when_prospective_count_would_exceed_limit() {
577        let tmp_home = TempDir::new().unwrap();
578        let (_t, repo) = tmpdir_with_git();
579        for _ in 0..MAX_PENDING_MESSAGES {
580            push(
581                tmp_home.path(),
582                Tool::Claude,
583                Tool::Codex,
584                &repo,
585                "a".into(),
586                rfc3339(),
587            )
588            .unwrap();
589        }
590        let err = push(
591            tmp_home.path(),
592            Tool::Claude,
593            Tool::Codex,
594            &repo,
595            "a".into(),
596            rfc3339(),
597        )
598        .unwrap_err();
599        assert!(matches!(
600            err,
601            InboxError::InboxFull {
602                current_count: 16,
603                ..
604            }
605        ));
606    }
607
608    #[test]
609    fn push_rejects_when_prospective_bytes_would_cross_limit() {
610        // Spec S16' requires precise precondition: existing_bytes == 32700,
611        // existing_count == 10. Land there using serde probe technique.
612        let tmp_home = TempDir::new().unwrap();
613        let (_t, repo) = tmpdir_with_git();
614
615        const TARGET_BYTES: u64 = 32_700;
616        const TARGET_COUNT: usize = 10;
617        let bytes_per_push = (TARGET_BYTES / TARGET_COUNT as u64) as usize;
618
619        let probe = InboxMessage {
620            pushed_at: rfc3339(),
621            from: Tool::Claude.dir_name().to_string(),
622            content: String::new(),
623            thread_id: None,
624            channel: None,
625            message_id: None,
626        };
627        let empty_line_bytes = serde_json::to_string(&probe).unwrap().len() + 1;
628        assert!(
629            bytes_per_push > empty_line_bytes,
630            "bytes_per_push ({bytes_per_push}) must exceed empty_line_bytes ({empty_line_bytes})"
631        );
632        let content_per_push = "a".repeat(bytes_per_push - empty_line_bytes);
633
634        for _ in 0..TARGET_COUNT {
635            push(
636                tmp_home.path(),
637                Tool::Claude,
638                Tool::Codex,
639                &repo,
640                content_per_push.clone(),
641                rfc3339(),
642            )
643            .unwrap();
644        }
645
646        let inbox = inbox_path(tmp_home.path(), Tool::Codex, &repo).unwrap();
647        let current_bytes = fs::metadata(&inbox).unwrap().len();
648        let current_count = fs::read_to_string(&inbox)
649            .unwrap()
650            .lines()
651            .filter(|l| !l.trim().is_empty())
652            .count();
653        assert_eq!(
654            current_bytes, TARGET_BYTES,
655            "precondition: current_bytes == 32700"
656        );
657        assert_eq!(
658            current_count, TARGET_COUNT,
659            "precondition: current_count == 10"
660        );
661
662        let would_cross = "y".repeat(200);
663        let err = push(
664            tmp_home.path(),
665            Tool::Claude,
666            Tool::Codex,
667            &repo,
668            would_cross,
669            rfc3339(),
670        )
671        .unwrap_err();
672        assert!(
673            matches!(
674                err,
675                InboxError::InboxFull {
676                    current_count: 10,
677                    current_bytes: 32_700,
678                }
679            ),
680            "expected InboxFull with 10/32700 preconditions, got: {err:?}"
681        );
682
683        let after = fs::metadata(&inbox).unwrap().len();
684        assert_eq!(after, TARGET_BYTES);
685    }
686
687    #[test]
688    fn push_accepts_when_prospective_bytes_exactly_at_limit_and_rejects_one_more() {
689        // Bracket the boundary: exact hit accepts, one byte over rejects.
690        // This pair is what actually proves `>` vs `>=`.
691        //
692        // A single push is capped at MAX_MESSAGE_SIZE = 8 KB, so we cannot
693        // fill a 32 KB inbox in one go. Seed with big pushes first to get
694        // the inbox within one message slot of the limit, then compute the
695        // exact content length for the final push.
696        let tmp_home = TempDir::new().unwrap();
697        let (_t, repo) = tmpdir_with_git();
698
699        let probe = InboxMessage {
700            pushed_at: rfc3339(),
701            from: Tool::Claude.dir_name().to_string(),
702            content: String::new(),
703            thread_id: None,
704            channel: None,
705            message_id: None,
706        };
707        let probe_empty_line_bytes = serde_json::to_string(&probe).unwrap().len() as u64 + 1;
708
709        // Seed until the remaining budget is within one MAX_MESSAGE_SIZE slot.
710        // Each seed push adds `probe_empty_line_bytes + seed_content.len()`
711        // bytes. Use seeds of (MAX_MESSAGE_SIZE - 100) so the size check
712        // never fails, and loop until we're close to the limit.
713        let seed_content_len = MAX_MESSAGE_SIZE - 100;
714        let seed_content = "s".repeat(seed_content_len);
715        let inbox_preview = inbox_path(tmp_home.path(), Tool::Codex, &repo).unwrap();
716        loop {
717            let current = if inbox_preview.exists() {
718                fs::metadata(&inbox_preview).unwrap().len()
719            } else {
720                0
721            };
722            let remaining_after_maybe_seed = MAX_TOTAL_INBOX_BYTES
723                - current
724                - probe_empty_line_bytes
725                - (seed_content_len as u64);
726            // If pushing another seed would leave remaining <= MAX_MESSAGE_SIZE
727            // + overhead, we're ready for the final exact push. Stop seeding.
728            if remaining_after_maybe_seed < (MAX_MESSAGE_SIZE as u64) {
729                break;
730            }
731            push(
732                tmp_home.path(),
733                Tool::Claude,
734                Tool::Codex,
735                &repo,
736                seed_content.clone(),
737                rfc3339(),
738            )
739            .unwrap();
740        }
741        // Do one more seed push to actually land within one slot of the limit.
742        push(
743            tmp_home.path(),
744            Tool::Claude,
745            Tool::Codex,
746            &repo,
747            seed_content.clone(),
748            rfc3339(),
749        )
750        .unwrap();
751
752        let inbox = inbox_path(tmp_home.path(), Tool::Codex, &repo).unwrap();
753        let current_bytes = fs::metadata(&inbox).unwrap().len();
754        let remaining = MAX_TOTAL_INBOX_BYTES - current_bytes;
755        // remaining should be > probe_empty_line_bytes (room for at least one more line)
756        // AND content portion should fit in MAX_MESSAGE_SIZE.
757        let exact_content_len = (remaining - probe_empty_line_bytes) as usize;
758        assert!(
759            exact_content_len <= MAX_MESSAGE_SIZE,
760            "seed math is wrong: exact_content_len {exact_content_len} > MAX_MESSAGE_SIZE {MAX_MESSAGE_SIZE}"
761        );
762        let exact_content = "a".repeat(exact_content_len);
763
764        push(
765            tmp_home.path(),
766            Tool::Claude,
767            Tool::Codex,
768            &repo,
769            exact_content,
770            rfc3339(),
771        )
772        .unwrap();
773
774        let final_bytes = fs::metadata(&inbox).unwrap().len();
775        assert_eq!(
776            final_bytes, MAX_TOTAL_INBOX_BYTES,
777            "inbox MUST land exactly on the 32 KB boundary"
778        );
779
780        // One byte over → reject. This is the `>` vs `>=` discriminator.
781        let err = push(
782            tmp_home.path(),
783            Tool::Claude,
784            Tool::Codex,
785            &repo,
786            "x".into(),
787            rfc3339(),
788        )
789        .unwrap_err();
790        assert!(
791            matches!(err, InboxError::InboxFull { .. }),
792            "one byte over the limit MUST be rejected, got: {err:?}"
793        );
794
795        let after_rejected = fs::metadata(&inbox).unwrap().len();
796        assert_eq!(after_rejected, MAX_TOTAL_INBOX_BYTES);
797    }
798
799    #[test]
800    fn drain_round_trip_preserves_content_bytes() {
801        let tmp_home = TempDir::new().unwrap();
802        let (_t, repo) = tmpdir_with_git();
803        let content = "hello from claude, P8 test #1".to_string();
804        push(
805            tmp_home.path(),
806            Tool::Claude,
807            Tool::Codex,
808            &repo,
809            content.clone(),
810            rfc3339(),
811        )
812        .unwrap();
813
814        let messages = drain(tmp_home.path(), Tool::Codex, &repo).unwrap();
815        assert_eq!(messages.len(), 1);
816        assert_eq!(messages[0].content, content);
817        assert_eq!(messages[0].from, "claude");
818    }
819
820    #[test]
821    fn drain_preserves_unicode_bytes_round_trip() {
822        let tmp_home = TempDir::new().unwrap();
823        let (_t, repo) = tmpdir_with_git();
824        let content = "决策:采用 Arc<Mutex<>> 🔒 because 'shared ownership' 需要".to_string();
825        push(
826            tmp_home.path(),
827            Tool::Claude,
828            Tool::Codex,
829            &repo,
830            content.clone(),
831            rfc3339(),
832        )
833        .unwrap();
834
835        let messages = drain(tmp_home.path(), Tool::Codex, &repo).unwrap();
836        assert_eq!(messages.len(), 1);
837        assert_eq!(messages[0].content, content);
838    }
839
840    #[test]
841    fn drain_empty_inbox_returns_empty_vec() {
842        let tmp_home = TempDir::new().unwrap();
843        let (_t, repo) = tmpdir_with_git();
844        let messages = drain(tmp_home.path(), Tool::Claude, &repo).unwrap();
845        assert!(messages.is_empty());
846    }
847
848    #[test]
849    fn drain_nonexistent_inbox_dir_returns_empty_vec() {
850        let tmp_home = TempDir::new().unwrap();
851        let (_t, repo) = tmpdir_with_git();
852        let messages = drain(tmp_home.path(), Tool::Codex, &repo).unwrap();
853        assert!(messages.is_empty());
854    }
855
856    #[test]
857    fn drain_preserves_fifo_order() {
858        let tmp_home = TempDir::new().unwrap();
859        let (_t, repo) = tmpdir_with_git();
860        for i in 0..3 {
861            push(
862                tmp_home.path(),
863                Tool::Claude,
864                Tool::Codex,
865                &repo,
866                format!("message-{i}"),
867                rfc3339(),
868            )
869            .unwrap();
870        }
871
872        let messages = drain(tmp_home.path(), Tool::Codex, &repo).unwrap();
873        assert_eq!(messages.len(), 3);
874        assert_eq!(messages[0].content, "message-0");
875        assert_eq!(messages[1].content, "message-1");
876        assert_eq!(messages[2].content, "message-2");
877    }
878
879    #[test]
880    fn drain_is_one_shot_file_disappears() {
881        let tmp_home = TempDir::new().unwrap();
882        let (_t, repo) = tmpdir_with_git();
883        push(
884            tmp_home.path(),
885            Tool::Claude,
886            Tool::Codex,
887            &repo,
888            "one".into(),
889            rfc3339(),
890        )
891        .unwrap();
892
893        let first = drain(tmp_home.path(), Tool::Codex, &repo).unwrap();
894        assert_eq!(first.len(), 1);
895
896        let second = drain(tmp_home.path(), Tool::Codex, &repo).unwrap();
897        assert!(second.is_empty());
898
899        let path = inbox_path(tmp_home.path(), Tool::Codex, &repo).unwrap();
900        assert!(!path.exists());
901    }
902
903    #[test]
904    fn drain_is_isolated_per_distinct_project() {
905        let tmp_home = TempDir::new().unwrap();
906        let proj_a = tmp_home.path().join("alpha");
907        let proj_b = tmp_home.path().join("beta");
908        fs::create_dir_all(proj_a.join(".git")).unwrap();
909        fs::create_dir_all(proj_b.join(".git")).unwrap();
910
911        push(
912            tmp_home.path(),
913            Tool::Claude,
914            Tool::Codex,
915            &proj_a,
916            "for alpha".into(),
917            rfc3339(),
918        )
919        .unwrap();
920
921        let drained = drain(tmp_home.path(), Tool::Codex, &proj_b).unwrap();
922        assert!(
923            drained.is_empty(),
924            "proj-b drain must not see proj-a messages"
925        );
926
927        let path_a = inbox_path(tmp_home.path(), Tool::Codex, &proj_a).unwrap();
928        assert!(path_a.exists(), "proj-a inbox still present");
929    }
930
931    #[test]
932    fn format_plain_empty_messages_returns_empty_string() {
933        let out = format_plain(Tool::Codex, &[]);
934        assert!(out.is_empty());
935    }
936
937    #[test]
938    fn format_plain_includes_count_and_message_lines() {
939        let msgs = vec![
940            InboxMessage {
941                pushed_at: "2026-04-15T01:00:00Z".into(),
942                from: "codex".into(),
943                content: "first".into(),
944                thread_id: None,
945                channel: None,
946                message_id: None,
947            },
948            InboxMessage {
949                pushed_at: "2026-04-15T01:01:00Z".into(),
950                from: "codex".into(),
951                content: "second".into(),
952                thread_id: None,
953                channel: None,
954                message_id: None,
955            },
956        ];
957        let out = format_plain(Tool::Codex, &msgs);
958        assert!(out.contains("Partner inbox from codex"));
959        assert!(out.contains("2 messages"));
960        assert!(out.contains("first"));
961        assert!(out.contains("second"));
962        assert!(out.contains("[End partner inbox]"));
963    }
964
965    #[test]
966    fn format_codex_hook_json_wraps_plain_in_correct_envelope() {
967        let msgs = vec![InboxMessage {
968            pushed_at: "2026-04-15T01:00:00Z".into(),
969            from: "claude".into(),
970            content: "test\nwith\nnewlines and \"quotes\"".into(),
971            thread_id: None,
972            channel: None,
973            message_id: None,
974        }];
975        let out = format_codex_hook_json(Tool::Claude, &msgs).unwrap();
976
977        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
978        assert_eq!(
979            parsed["hookSpecificOutput"]["hookEventName"],
980            "UserPromptSubmit"
981        );
982
983        let ac = parsed["hookSpecificOutput"]["additionalContext"]
984            .as_str()
985            .unwrap();
986        let expected_plain = format_plain(Tool::Claude, &msgs);
987        assert_eq!(ac, expected_plain);
988
989        assert!(ac.contains("test\nwith\nnewlines"));
990        assert!(ac.contains("\"quotes\""));
991    }
992
993    #[test]
994    fn format_codex_hook_json_empty_returns_empty_string() {
995        let out = format_codex_hook_json(Tool::Claude, &[]).unwrap();
996        assert!(out.is_empty());
997    }
998}