mecha_core/outbox.rs
1//! The outbox: staged outbound actions awaiting the user's review.
2//!
3//! An outbox-routed tool call is never executed by the agent loop — it is
4//! written here as a draft, and nothing leaves the machine until the user
5//! reads exactly what would be sent and releases it (`mecha outbox send`).
6//! That is "draft-only, never send" made structural: the gate lives in core,
7//! so an email or calendar tool — including a third-party MCP server's —
8//! needs no knowledge of it to be covered by it.
9//!
10//! The item keeps the drafted arguments (`args_before`) separate from the
11//! arguments the release will execute (`args`), because the difference is a
12//! measurement: a user edit before sending is a writing correction, and
13//! `mecha reflect` mines `diff(args_before, args)` into the learning store.
14//!
15//! **Staging is sink-agnostic; reviewing is not.** The outbox generalised to a
16//! new kind of outbound action — publishing a bundle to the public surface —
17//! without a line changing here, which was the design goal. Its *review*
18//! affordances did not: `show` printing arguments, `edit` opening them in
19//! `$EDITOR`, and the writing miner reading the diff all assume the staged
20//! thing is a message someone wrote. That is why an item carries an
21//! [`OutboxKind`], set at staging from `[outbox] publish_tools`, and why the
22//! miner keys on it — see [`OutboxKind::Publish`].
23//!
24//! Storage follows the learning store's rules: one pretty-printed JSON file
25//! per item so `$EDITOR` and `git diff` work on it, temp-sibling-and-rename
26//! for every rewrite so a reader never sees a half-written file, and an
27//! advisory flock for writers — taken *before reading the state acted on*,
28//! and never held across an editor invocation. Staging takes no lock at all:
29//! a fresh item is a fresh file with a unique id, and the agent loop must
30//! never block on a human's review session.
31
32use anyhow::{Context, Result};
33use serde::{Deserialize, Serialize};
34use serde_json::Value;
35use std::path::{Path, PathBuf};
36
37use crate::agent::Taint;
38use crate::session::Session;
39
40/// What kind of outbound action a staged item is, which decides how it is
41/// *reviewed* rather than how it is staged.
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum OutboxKind {
45 /// Prose somebody wrote and somebody else will read: an email, a calendar
46 /// invitation, a message. The reviewable object *is* the arguments, and an
47 /// edit before release is a writing correction worth learning from.
48 #[default]
49 Message,
50 /// A publication to the public surface: a rendered bundle, an alias move,
51 /// a request-type push. Three things follow, and each is a bug if undone:
52 ///
53 /// - The reviewable object is the **rendered page**, not the arguments —
54 /// which are a path and a visibility flag.
55 /// - `edit` is refused. Editing the content means editing the source and
56 /// re-rendering, which stages a new item; rewriting the path is not
57 /// editing the draft.
58 /// - The writing miner **excludes it**. Feeding `diff(args_before, args)`
59 /// of a changed directory path to a reflector that mines voice rules
60 /// would carry noise into every future run's cached prefix. Same mistake
61 /// as learning from `"Blocked by a hook:"`, in a new costume.
62 Publish,
63}
64
65impl OutboxKind {
66 pub fn as_str(&self) -> &'static str {
67 match self {
68 OutboxKind::Message => "message",
69 OutboxKind::Publish => "publish",
70 }
71 }
72}
73
74/// One staged outbound action.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct OutboxItem {
77 pub id: String,
78 /// `pending` | `sent` | `rejected`.
79 pub status: String,
80 /// The tool a release will execute, by registry name (`web__fetch`).
81 pub tool: String,
82 /// How this is reviewed. Defaulted rather than required, so items staged
83 /// before the field existed load as the kind they in fact were.
84 #[serde(default)]
85 pub kind: OutboxKind,
86 /// The arguments as the agent drafted them. Never modified — this is the
87 /// baseline the learning capture diffs against.
88 pub args_before: Value,
89 /// The arguments a release will execute. Starts equal to `args_before`;
90 /// `mecha outbox edit` rewrites it.
91 pub args: Value,
92 /// One line for `mecha outbox list`.
93 pub summary: String,
94 /// The session that drafted this, when the front-end knew it.
95 #[serde(default)]
96 pub session_id: Option<String>,
97 /// The path jail the call was drafted under.
98 ///
99 /// A staged call is a *deferred* tool call, and a tool call only means
100 /// anything relative to the workspace it was made in: `bundle` here is a
101 /// directory under the drafting run's jail. Release happens in another
102 /// process, minutes or hours later, from whatever directory the reviewer
103 /// happens to be standing in — so without this the release resolves the
104 /// argument against the wrong root. An absolute path fails loudly; a
105 /// relative one is worse, because a same-named directory beside the
106 /// reviewer would quietly publish the wrong bytes.
107 ///
108 /// Recording it also keeps the release inside the jail the *agent* was
109 /// held to, rather than the reviewer's, which is the stricter of the two
110 /// and the one the interlock reasoned about.
111 ///
112 /// Defaulted, like `kind`: items staged before the field existed load as
113 /// `None` and release exactly as they did before.
114 #[serde(default)]
115 pub workspace: Option<PathBuf>,
116 /// The conversation's taint at the moment of staging. An armed snapshot
117 /// means third-party text was in context when this draft was written —
118 /// review it as possibly an attacker's words, not the assistant's.
119 #[serde(default)]
120 pub taint: Taint,
121 pub created_at: String,
122 #[serde(default)]
123 pub resolved_at: Option<String>,
124 /// Why it was rejected, when it was.
125 #[serde(default)]
126 pub reason: Option<String>,
127 /// The last release attempt's failure, if any. A failed send stays
128 /// `pending` — the draft is still good; the delivery was not.
129 #[serde(default)]
130 pub error: Option<String>,
131}
132
133impl OutboxItem {
134 pub fn edited(&self) -> bool {
135 self.args != self.args_before
136 }
137
138 /// Whether `mecha reflect` may mine this item as a **writing** correction.
139 ///
140 /// A `writing`-domain reflection can become a consolidated rule, and a rule
141 /// rides in every future run's system prompt inside the cached prefix. That
142 /// is the longest half-life anything in this project has, so what feeds it
143 /// is filtered structurally rather than by a prompt asking the reflector to
144 /// use its judgement:
145 ///
146 /// - **Sent, and edited.** An unedited release is not a correction (that it
147 /// is *positive* evidence is a separate, unread signal); a rejected one
148 /// never went out.
149 /// - **A message.** A publish's `diff(args_before, args)` is a changed
150 /// filesystem path or visibility flag. Mining it would teach voice rules
151 /// from bookkeeping — the same mistake as learning from
152 /// `"Blocked by a hook:"`, which is machine policy read as a human
153 /// correction.
154 pub fn mineable_as_writing(&self) -> bool {
155 self.kind == OutboxKind::Message && self.status == "sent" && self.edited()
156 }
157}
158
159/// What the agent loop consults per call: the store plus the routed names.
160///
161/// `session_id` is interior-mutable because the front-end learns it after the
162/// agent (and its default [`RunContext`](crate::agent::RunContext)) is built:
163/// the session is created at run start, the route at setup. Best-effort —
164/// `None` on front-ends that record no session (batch, eval).
165pub struct OutboxRoute {
166 pub store: OutboxStore,
167 routed: std::collections::BTreeSet<String>,
168 publishes: std::collections::BTreeSet<String>,
169 session_id: std::sync::Mutex<Option<String>>,
170}
171
172impl OutboxRoute {
173 pub fn new(
174 store: OutboxStore,
175 routed: impl IntoIterator<Item = String>,
176 publishes: impl IntoIterator<Item = String>,
177 ) -> Self {
178 OutboxRoute {
179 store,
180 routed: routed.into_iter().collect(),
181 publishes: publishes.into_iter().collect(),
182 session_id: std::sync::Mutex::new(None),
183 }
184 }
185
186 pub fn routes(&self, tool: &str) -> bool {
187 self.routed.contains(tool)
188 }
189
190 pub fn routed(&self) -> impl Iterator<Item = &str> {
191 self.routed.iter().map(String::as_str)
192 }
193
194 /// A tool's kind, which is config's to declare and never the tool's: the
195 /// loop must not learn what a publish is, and a third-party MCP server
196 /// cannot be trusted to say. Anything unnamed is a message, which is the
197 /// conservative default — it keeps the arguments reviewable and the item
198 /// mineable, and the cost of getting it wrong is a voice rule learned from
199 /// a path rather than a page nobody could review.
200 pub fn kind_of(&self, tool: &str) -> OutboxKind {
201 if self.publishes.contains(tool) {
202 OutboxKind::Publish
203 } else {
204 OutboxKind::Message
205 }
206 }
207
208 /// Names declared as publishes. Used at startup to warn about one that is
209 /// not routed at all — it would execute unstaged, which is the
210 /// silently-degrading-sandbox shape the routed-name warning already
211 /// catches.
212 pub fn publishes(&self) -> impl Iterator<Item = &str> {
213 self.publishes.iter().map(String::as_str)
214 }
215
216 pub fn set_session_id(&self, id: &str) {
217 if let Ok(mut slot) = self.session_id.lock() {
218 *slot = Some(id.to_string());
219 }
220 }
221
222 pub fn session_id(&self) -> Option<String> {
223 self.session_id.lock().ok().and_then(|s| s.clone())
224 }
225}
226
227pub struct OutboxStore {
228 root: PathBuf,
229}
230
231/// Holds the store's writer lock for as long as it lives.
232pub struct OutboxLock {
233 _file: std::fs::File,
234}
235
236impl OutboxStore {
237 pub fn default_root() -> Result<PathBuf> {
238 if let Ok(dir) = std::env::var("MECHA_OUTBOX_DIR") {
239 return Ok(PathBuf::from(dir));
240 }
241 Ok(crate::work::mecha_home()?.join("outbox"))
242 }
243
244 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
245 let root = root.into();
246 crate::create_private_dir(&root).with_context(|| format!("creating {}", root.display()))?;
247 Ok(OutboxStore { root })
248 }
249
250 /// Open at the default location only if it already exists — for read
251 /// paths that must not create state as a side effect.
252 pub fn open_existing_default() -> Option<Self> {
253 let root = Self::default_root().ok()?;
254 root.is_dir().then_some(OutboxStore { root })
255 }
256
257 pub fn root(&self) -> &Path {
258 &self.root
259 }
260
261 /// Stage a drafted call. No lock: the id is fresh, so there is no state
262 /// to race on, and the agent loop must never wait on a review session.
263 pub fn stage(
264 &self,
265 tool: &str,
266 kind: OutboxKind,
267 args: Value,
268 taint: Taint,
269 session_id: Option<String>,
270 workspace: Option<PathBuf>,
271 ) -> Result<OutboxItem> {
272 let item = OutboxItem {
273 id: Session::new_id(),
274 status: "pending".into(),
275 tool: tool.to_string(),
276 kind,
277 summary: summarize(tool, &args),
278 args_before: args.clone(),
279 args,
280 session_id,
281 workspace,
282 taint,
283 created_at: chrono::Utc::now().to_rfc3339(),
284 resolved_at: None,
285 reason: None,
286 error: None,
287 };
288 self.write_item(&item)?;
289 Ok(item)
290 }
291
292 /// Every item, oldest first.
293 pub fn items(&self) -> Result<Vec<OutboxItem>> {
294 let mut out = Vec::new();
295 for entry in std::fs::read_dir(&self.root)? {
296 let path = entry?.path();
297 if path.extension().and_then(|e| e.to_str()) != Some("json") {
298 continue;
299 }
300 match serde_json::from_str(&std::fs::read_to_string(&path)?) {
301 Ok(item) => out.push(item),
302 Err(e) => {
303 tracing::warn!("skipping unreadable outbox item {}: {e}", path.display())
304 }
305 }
306 }
307 out.sort_by(|a: &OutboxItem, b: &OutboxItem| a.id.cmp(&b.id));
308 Ok(out)
309 }
310
311 /// Find one item by id or unique prefix. Ambiguity is an error rather
312 /// than a guess, same as session and proposal lookup.
313 pub fn item(&self, id: &str) -> Result<OutboxItem> {
314 let all = self.items()?;
315 let matches: Vec<&OutboxItem> = all.iter().filter(|i| i.id.starts_with(id)).collect();
316 match matches.len() {
317 0 => anyhow::bail!("no outbox item matching `{id}`"),
318 1 => Ok(matches[0].clone()),
319 n => anyhow::bail!(
320 "`{id}` matches {n} outbox items: {}",
321 matches
322 .iter()
323 .map(|i| i.id.as_str())
324 .collect::<Vec<_>>()
325 .join(", ")
326 ),
327 }
328 }
329
330 /// One item by its exact store-minted id — a single file read, never a
331 /// directory scan. For the hot paths (a button press on an event loop)
332 /// that already hold the full id and must not pay `items()`'s
333 /// read-and-parse of every draft ever staged. Prefix lookup stays
334 /// [`OutboxStore::item`]'s business.
335 ///
336 /// The id is validated by shape *before* it is joined onto the store
337 /// root: ids arrive here from button payloads, and a value shaped like a
338 /// path (`../…`) must be refused, not resolved. A hostile shape is an
339 /// error; a missing item is `Ok(None)`; a torn file is an error, so a
340 /// caller that maps errors to "unreadable" keeps failing closed.
341 pub fn item_exact(&self, id: &str) -> Result<Option<OutboxItem>> {
342 anyhow::ensure!(is_item_id(id), "`{id}` is not shaped like an outbox id");
343 let path = self.root.join(format!("{id}.json"));
344 let text = match std::fs::read_to_string(&path) {
345 Ok(text) => text,
346 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
347 Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
348 };
349 Ok(Some(
350 serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?,
351 ))
352 }
353
354 /// Replace a pending item's release arguments. `args_before` is untouched
355 /// — it is the baseline the learning capture diffs against.
356 pub fn update_args(&self, id: &str, args: Value) -> Result<OutboxItem> {
357 let mut item = self.item(id)?;
358 anyhow::ensure!(
359 item.status == "pending",
360 "outbox item {} is {}, not pending",
361 item.id,
362 item.status
363 );
364 item.args = args;
365 item.summary = summarize(&item.tool, &item.args);
366 self.write_item(&item)?;
367 Ok(item)
368 }
369
370 /// Resolve a pending item as `sent` or `rejected`, in place — the file is
371 /// its own audit record, so nothing moves to an archive.
372 pub fn resolve(&self, id: &str, status: &str, reason: Option<String>) -> Result<OutboxItem> {
373 let mut item = self.item(id)?;
374 anyhow::ensure!(
375 item.status == "pending",
376 "outbox item {} is {}, not pending",
377 item.id,
378 item.status
379 );
380 item.status = status.to_string();
381 item.resolved_at = Some(chrono::Utc::now().to_rfc3339());
382 item.reason = reason;
383 item.error = None;
384 self.write_item(&item)?;
385 Ok(item)
386 }
387
388 /// Record a failed release attempt. The item stays `pending`: the draft
389 /// is still good, and the next `send` retries.
390 pub fn record_error(&self, id: &str, error: &str) -> Result<()> {
391 let mut item = self.item(id)?;
392 item.error = Some(error.to_string());
393 self.write_item(&item)
394 }
395
396 fn write_item(&self, item: &OutboxItem) -> Result<()> {
397 let path = self.root.join(format!("{}.json", item.id));
398 let tmp = path.with_extension("json.tmp");
399 std::fs::write(&tmp, serde_json::to_string_pretty(item)?)?;
400 std::fs::rename(&tmp, &path)?;
401 Ok(())
402 }
403
404 /// Writer lock for read-modify-write paths (edit, send, reject). Taken
405 /// before reading the item acted on; never held across `$EDITOR`.
406 pub fn lock(&self) -> Result<OutboxLock> {
407 use std::os::unix::io::AsRawFd;
408 let file = std::fs::OpenOptions::new()
409 .create(true)
410 .truncate(false)
411 .write(true)
412 .open(self.root.join(".lock"))?;
413 // SAFETY: flock on an fd we own, held open by the returned guard.
414 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
415 return Err(std::io::Error::last_os_error()).context("locking the outbox");
416 }
417 Ok(OutboxLock { _file: file })
418 }
419}
420
421/// The shape of a store-minted id (`Session::new_id`: a timestamp, a hyphen,
422/// a uuid fragment). Checked before an id from the outside is joined onto the
423/// store root — nothing with a separator or a dot can name a file elsewhere.
424fn is_item_id(id: &str) -> bool {
425 !id.is_empty()
426 && id.len() <= 80
427 && id
428 .chars()
429 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
430}
431
432/// A per-line diff of two argument renderings, `- `/`+ ` prefixed. Line-set
433/// based: enough to show *what* changed in a draft without a diff crate, and
434/// the full before/after always survives on the item itself. Used by both
435/// `mecha outbox show` and the reflect pass that mines edits.
436pub fn diff_args(before: &Value, after: &Value) -> String {
437 let pretty = |v: &Value| serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string());
438 let b = pretty(before);
439 let a = pretty(after);
440 let b_lines: Vec<&str> = b.lines().collect();
441 let a_lines: Vec<&str> = a.lines().collect();
442 let mut out = String::new();
443 for line in &b_lines {
444 if !a_lines.contains(line) {
445 out.push_str(&format!(" - {line}\n"));
446 }
447 }
448 for line in &a_lines {
449 if !b_lines.contains(line) {
450 out.push_str(&format!(" + {line}\n"));
451 }
452 }
453 if out.is_empty() {
454 out.push_str(" (no textual change)\n");
455 }
456 out
457}
458
459/// One line for the list view: who and what when the arguments say, the
460/// compact JSON when they do not.
461///
462/// Keyed on well-known argument *names*, never on the tool — the store stays
463/// tool-agnostic, but a queue of mail drafts whose rows all lead with
464/// `{"body_markdown":…` made every review surface start with the least
465/// informative bytes of each item. Anything without the conventional fields
466/// falls back to what it always was.
467fn summarize(tool: &str, args: &Value) -> String {
468 let text = headline(args).unwrap_or_else(|| serde_json::to_string(args).unwrap_or_default());
469 format!("{tool} {}", clip(text, 80))
470}
471
472/// "to a@x — \"subject\"", when the arguments carry the conventional names.
473fn headline(args: &Value) -> Option<String> {
474 let map = args.as_object()?;
475 let field = |key: &str| {
476 map.get(key)
477 .and_then(|v| match v {
478 Value::String(s) => Some(s.clone()),
479 // `to` is a list on some surfaces and a string on others.
480 Value::Array(a) => Some(
481 a.iter()
482 .filter_map(|x| x.as_str())
483 .collect::<Vec<_>>()
484 .join(", "),
485 ),
486 _ => None,
487 })
488 .filter(|s| !s.trim().is_empty())
489 };
490 let to = field("to");
491 let subject = field("subject").or_else(|| field("title"));
492 match (to, subject) {
493 (Some(to), Some(subject)) => Some(format!("to {to} — \"{subject}\"")),
494 (Some(to), None) => Some(format!("to {to}")),
495 (None, Some(subject)) => Some(format!("\"{subject}\"")),
496 (None, None) => None,
497 }
498}
499
500/// Truncate on a char boundary; the text can be any UTF-8.
501fn clip(mut text: String, max: usize) -> String {
502 if text.len() > max {
503 let cut = (0..=max)
504 .rev()
505 .find(|&i| text.is_char_boundary(i))
506 .unwrap_or(0);
507 text.truncate(cut);
508 text.push('…');
509 }
510 text
511}
512
513/// A staged message, shaped the way a person reads one.
514///
515/// [`OutboxKind::Publish`]'s lesson generalises: **a message's reviewable
516/// object is the message**, not the JSON carrying it. A review surface that
517/// prints `{"body_markdown": "Dear Dirk,\n\nThank you…"}` asks the reviewer to
518/// decode escape sequences to find out what would be said in their name — and
519/// "approve without reading" is the exact failure the outbox exists to
520/// prevent, so a draft that is hard to read is a security cost rather than a
521/// cosmetic one. It is also what an editor should open: editing prose inside a
522/// JSON string literal is where a real newline becomes `\n`, a stray quote
523/// becomes a parse error, and the whole edit is refused for a reason that has
524/// nothing to do with what the person meant to say.
525///
526/// Keyed on well-known argument *names*, like [`headline`] and for the same
527/// reason: the store stays tool-agnostic, so a tool nobody anticipated is
528/// still reviewable — its fields land in `other` rather than vanishing.
529///
530/// **Nothing is dropped.** Every key of the arguments appears in exactly one
531/// of `headers`, `body` or `other`, and there is a test on that, because a
532/// field the reviewer cannot see is a field they approved without reading.
533/// That is the whole difference between reshaping a draft and summarising it.
534#[derive(Debug, Default, PartialEq, Eq)]
535pub struct DraftView {
536 /// Addressing and the other short scalars, in reading order.
537 pub headers: Vec<(String, String)>,
538 /// The prose, with its real newlines.
539 pub body: Option<String>,
540 /// Which argument the prose came from, so an edit writes it back to the
541 /// same key rather than guessing a second time.
542 pub body_field: Option<String>,
543 /// Everything else, unshaped — shown after the body, never hidden.
544 pub other: Vec<(String, String)>,
545}
546
547/// Header-ish arguments, in the order a person reads them rather than the
548/// order a map hands them back.
549/// Deliberately short. `thread_id` and `message_id` address the *provider*,
550/// not a person, and a reviewer answering "would I send this?" needs them the
551/// way a letter writer needs the postcode format — which is to say later, and
552/// not above the prose. They fall through to `other`, which every surface
553/// shows below the body.
554/// `start_time`/`end_time` sit here rather than falling through to `other`
555/// for one reason: `other` is in map order, which is alphabetical, so an
556/// event read *end before start* — nonsense on a page and worse in an ear,
557/// where a listener cannot glance back to sort it out.
558const HEADER_FIELDS: [&str; 12] = [
559 "to",
560 "cc",
561 "bcc",
562 "channel",
563 "subject",
564 "title",
565 "when",
566 "start",
567 "start_time",
568 "end",
569 "end_time",
570 "account",
571];
572
573/// Arguments that carry the prose, most specific first. Exactly one wins; the
574/// runners-up are ordinary arguments and are shown as such.
575const BODY_FIELDS: [&str; 8] = [
576 "body_markdown",
577 "body_text",
578 "body_html",
579 "body",
580 "text",
581 "markdown",
582 "message",
583 "content",
584];
585
586impl DraftView {
587 pub fn of(args: &Value) -> DraftView {
588 let mut view = DraftView::default();
589 let Some(map) = args.as_object() else {
590 // Not an object: there is nothing to shape, and showing it raw is
591 // still showing all of it.
592 view.other.push(("arguments".into(), args.to_string()));
593 return view;
594 };
595 for key in HEADER_FIELDS {
596 if let Some(value) = map.get(key) {
597 view.headers.push((key.to_string(), render(value)));
598 }
599 }
600 for key in BODY_FIELDS {
601 if let Some(text) = map.get(key).and_then(Value::as_str) {
602 view.body = Some(text.to_string());
603 view.body_field = Some(key.to_string());
604 break;
605 }
606 }
607 for (key, value) in map {
608 if HEADER_FIELDS.contains(&key.as_str())
609 || view.body_field.as_deref() == Some(key.as_str())
610 {
611 continue;
612 }
613 view.other.push((key.clone(), render(value)));
614 }
615 view
616 }
617}
618
619/// A draft as it would be **read out loud** — every argument, nothing
620/// summarised.
621///
622/// The reviewable object of a message is the message, and that rule does not
623/// change when the reviewer is listening instead of looking. What changes is
624/// that a listener cannot skim: they hear it once, in order, at speaking
625/// speed, so the only safe spoken offer is one that utters the whole thing.
626/// A paraphrase read aloud is not a smaller review, it is a different
627/// document — and the field it leaves out (one more address on the `to` line)
628/// is exactly the field an injection would add.
629///
630/// So this is [`DraftView`]'s three buckets spoken in reading order, and it
631/// inherits that type's guarantee: **every argument key appears**, with a
632/// test on it. The only thing it decides is wording.
633#[derive(Debug, Default, PartialEq, Eq)]
634pub struct SpokenDraft {
635 /// The draft, in speakable lines. Concatenate with pauses between.
636 pub lines: Vec<String>,
637}
638
639impl SpokenDraft {
640 /// How much speech this is. Characters rather than words because that is
641 /// what a TTS leg is actually handed, and the two are proportional at any
642 /// rate worth caring about.
643 pub fn chars(&self) -> usize {
644 self.lines.iter().map(|l| l.chars().count() + 1).sum()
645 }
646
647 pub fn text(&self) -> String {
648 self.lines.join(" ")
649 }
650}
651
652/// An argument name as a person hears it: `body_markdown` → `Body markdown`.
653///
654/// Deliberately mechanical rather than a lookup table of nice phrasings. A
655/// table would cover the tools thought of today and quietly mis-speak the
656/// rest, and the store is tool-agnostic on purpose — an unanticipated field
657/// must arrive sounding slightly stiff, never sounding like something else.
658fn spoken_label(key: &str) -> String {
659 let words = key.replace('_', " ");
660 let mut chars = words.chars();
661 match chars.next() {
662 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
663 None => words,
664 }
665}
666
667/// A value as it should be *heard*.
668///
669/// One case, and it is the case a calendar draft is made of:
670/// `2026-08-28T14:30:00-04:00` read aloud is a run of digits nobody can check
671/// a meeting against, and being checkable is the entire purpose of reading a
672/// draft back. So a timestamp is spoken as a date and a time.
673///
674/// **Rendered in the offset the string itself carries, never in local time.**
675/// A reviewer must hear the moment the draft actually names; translating it
676/// into some other zone would be the wrong-bytes review arriving through the
677/// one door built to prevent it. Anything that does not parse is spoken
678/// unchanged — a value this does not understand must reach the listener as
679/// itself, not as a guess.
680fn spoken_value(value: &str) -> String {
681 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) {
682 let on = dt.format("%A %B %-d");
683 return if dt.format("%M").to_string() == "00" {
684 format!("{on} at {}", dt.format("%-I %p"))
685 } else {
686 format!("{on} at {}", dt.format("%-I:%M %p"))
687 };
688 }
689 // A local datetime with no offset — which is what a calendar tool sends
690 // when the zone rides in a separate `timezone` argument, spoken beside
691 // it. Formatted, never *converted*: there is no offset here to convert
692 // from, and inventing one would be the harness telling the listener a
693 // different hour than the draft says.
694 for form in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M"] {
695 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(value, form) {
696 let on = dt.format("%A %B %-d");
697 return if dt.format("%M").to_string() == "00" {
698 format!("{on} at {}", dt.format("%-I %p"))
699 } else {
700 format!("{on} at {}", dt.format("%-I:%M %p"))
701 };
702 }
703 }
704 if let Ok(date) = chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") {
705 return date.format("%A %B %-d").to_string();
706 }
707 value.to_string()
708}
709
710impl DraftView {
711 /// This draft, spoken.
712 pub fn spoken(&self) -> SpokenDraft {
713 let mut lines = Vec::new();
714 for (key, value) in &self.headers {
715 lines.push(format!("{}: {}.", spoken_label(key), spoken_value(value)));
716 }
717 if let Some(body) = &self.body {
718 // The prose is uttered as prose, with no label in front of it:
719 // "Body markdown: Dear Dirk" is a field name a listener has to
720 // parse past. Which argument carried it is `body_field`'s to
721 // report, and no surface has ever needed to hear it.
722 lines.push(body.trim().to_string());
723 }
724 for (key, value) in &self.other {
725 lines.push(format!("{}: {}.", spoken_label(key), spoken_value(value)));
726 }
727 SpokenDraft { lines }
728 }
729}
730
731/// One argument as a person should see it: a string as itself, a list of
732/// addresses joined, anything else as its JSON. An empty value says so rather
733/// than rendering as nothing — a blank `to` is a fact about the draft, and a
734/// label with nothing after it reads as a display bug instead.
735fn render(value: &Value) -> String {
736 let text = match value {
737 Value::String(s) => s.clone(),
738 Value::Array(a) if a.iter().all(Value::is_string) => a
739 .iter()
740 .filter_map(Value::as_str)
741 .collect::<Vec<_>>()
742 .join(", "),
743 other => other.to_string(),
744 };
745 if text.trim().is_empty() {
746 "(empty)".to_string()
747 } else {
748 text
749 }
750}
751
752/// The arguments that address the **provider** rather than a person.
753///
754/// The leftovers of the classification [`DraftView`] already makes: not
755/// addressing, not the prose, and a string. What remains — `thread_id`,
756/// `message_id`, a Slack `ts` — names the *object* the call acts on, which is
757/// exactly what two calls about the same object have in common. That makes it
758/// the join key from a staged draft back to the read that produced it
759/// ([`crate::outbox_source`]), and it lives here so the one decision about
760/// which argument is which is still made once.
761///
762/// `account` and the other headers are excluded on purpose, and it is the
763/// exclusion that makes the join worth anything: `{"account": "dartmouth"}`
764/// is shared by every mail call in the session and would match all of them,
765/// which is a filter that filters nothing. Provider ids are high-entropy
766/// because they have to be.
767pub fn provider_ids(args: &Value) -> Vec<(String, String)> {
768 let Some(map) = args.as_object() else {
769 return Vec::new();
770 };
771 let body = DraftView::of(args).body_field;
772 map.iter()
773 .filter(|(key, _)| !HEADER_FIELDS.contains(&key.as_str()))
774 .filter(|(key, _)| body.as_deref() != Some(key.as_str()))
775 .filter_map(|(key, value)| {
776 let text = value.as_str()?;
777 (!text.trim().is_empty()).then(|| (key.clone(), text.to_string()))
778 })
779 .collect()
780}
781
782/// Write an edited body back into the arguments it came from.
783///
784/// The inverse of [`DraftView::body_field`], and it lives here so the one
785/// decision about which key holds the prose is made once. Returns the changed
786/// arguments, or `None` when there was no body to replace — a caller must
787/// then fall back to editing the arguments themselves rather than silently
788/// changing nothing.
789pub fn with_body(args: &Value, body: &str) -> Option<Value> {
790 let field = DraftView::of(args).body_field?;
791 let mut args = args.clone();
792 args.as_object_mut()?
793 .insert(field, Value::String(body.to_string()));
794 Some(args)
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800 use serde_json::json;
801
802 fn scratch(name: &str) -> PathBuf {
803 let dir =
804 std::env::temp_dir().join(format!("mecha-outbox-test-{name}-{}", std::process::id()));
805 let _ = std::fs::remove_dir_all(&dir);
806 dir
807 }
808
809 #[test]
810 fn a_summary_leads_with_who_and_what_when_the_arguments_say() {
811 // The conventional fields, in every combination they arrive in.
812 assert_eq!(
813 summarize(
814 "mail__send",
815 &json!({"to": "a@x.org", "subject": "Tuesday?", "body_markdown": "long…"})
816 ),
817 "mail__send to a@x.org — \"Tuesday?\""
818 );
819 assert_eq!(
820 summarize(
821 "mail__send",
822 &json!({"to": ["a@x.org", "b@x.org"], "body": "hi"})
823 ),
824 "mail__send to a@x.org, b@x.org"
825 );
826 assert_eq!(
827 summarize(
828 "cal__event_create",
829 &json!({"title": "Standup", "start": "…"})
830 ),
831 "cal__event_create \"Standup\""
832 );
833
834 // Without them, the compact JSON it always was — and still bounded.
835 let plain = summarize("factory__bundle_publish", &json!({"bundle": "/tmp/x"}));
836 assert!(plain.contains("bundle"), "{plain}");
837 let long = summarize("t", &json!({"to": "x".repeat(200)}));
838 assert!(long.len() < 120, "{}", long.len());
839 assert!(long.ends_with('…'), "{long}");
840
841 // An empty `to` is absence, not an addressee.
842 assert_eq!(
843 summarize("t", &json!({"to": "", "body": "x"})),
844 r#"t {"body":"x","to":""}"#
845 );
846 }
847
848 #[test]
849 fn an_item_round_trips_and_lists_in_id_order() {
850 let root = scratch("roundtrip");
851 let store = OutboxStore::open(&root).unwrap();
852
853 let a = store
854 .stage(
855 "web__fetch",
856 OutboxKind::Message,
857 json!({"url": "https://a"}),
858 Taint::default(),
859 None,
860 None,
861 )
862 .unwrap();
863 let b = store
864 .stage(
865 "email__send",
866 OutboxKind::Message,
867 json!({"to": "x@y"}),
868 Taint {
869 private: true,
870 untrusted: true,
871 },
872 Some("sess-1".into()),
873 None,
874 )
875 .unwrap();
876
877 let items = store.items().unwrap();
878 assert_eq!(items.len(), 2);
879 // Ids sort by creation time, so listing order is staging order.
880 assert_eq!(items[0].id, a.id.min(b.id.clone()));
881
882 let loaded = store.item(&b.id).unwrap();
883 assert_eq!(loaded.tool, "email__send");
884 assert!(loaded.taint.trifecta_armed());
885 assert_eq!(loaded.session_id.as_deref(), Some("sess-1"));
886 assert_eq!(loaded.args, loaded.args_before);
887 assert!(!loaded.edited());
888
889 let _ = std::fs::remove_dir_all(&root);
890 }
891
892 #[test]
893 fn a_prefix_that_matches_two_items_is_an_error_not_a_guess() {
894 let root = scratch("prefix");
895 let store = OutboxStore::open(&root).unwrap();
896 store
897 .stage(
898 "t",
899 OutboxKind::Message,
900 json!({}),
901 Taint::default(),
902 None,
903 None,
904 )
905 .unwrap();
906 store
907 .stage(
908 "t",
909 OutboxKind::Message,
910 json!({}),
911 Taint::default(),
912 None,
913 None,
914 )
915 .unwrap();
916
917 // Both ids share the timestamp prefix of the second they were made in.
918 let err = store.item("2").unwrap_err();
919 assert!(err.to_string().contains("matches 2"), "{err}");
920
921 let _ = std::fs::remove_dir_all(&root);
922 }
923
924 #[test]
925 fn editing_replaces_args_and_never_touches_the_baseline() {
926 let root = scratch("edit");
927 let store = OutboxStore::open(&root).unwrap();
928 let item = store
929 .stage(
930 "web__fetch",
931 OutboxKind::Message,
932 json!({"url": "https://a"}),
933 Taint::default(),
934 None,
935 None,
936 )
937 .unwrap();
938
939 let edited = store
940 .update_args(&item.id, json!({"url": "https://b"}))
941 .unwrap();
942 assert!(edited.edited());
943 assert_eq!(edited.args_before, json!({"url": "https://a"}));
944 assert_eq!(edited.args, json!({"url": "https://b"}));
945
946 let _ = std::fs::remove_dir_all(&root);
947 }
948
949 /// The rule that protects every future run's system prompt. A publish's
950 /// edit diff is a changed filesystem path, and a `writing` reflection
951 /// becomes a rule in the cached prefix — the same class of mistake as
952 /// mining `"Blocked by a hook:"` as if a human had said it. Fails on the
953 /// old behaviour, which mined any sent-and-edited item.
954 #[test]
955 fn the_writing_miner_takes_edited_messages_and_never_publishes() {
956 let root = scratch("mineable");
957 let store = OutboxStore::open(&root).unwrap();
958
959 let cases = [
960 (OutboxKind::Message, "sent", true, true),
961 // A publish, edited and sent — the one the old filter accepted.
962 (OutboxKind::Publish, "sent", true, false),
963 // Unedited is not a correction; rejected never went out.
964 (OutboxKind::Message, "sent", false, false),
965 (OutboxKind::Message, "rejected", true, false),
966 (OutboxKind::Message, "pending", true, false),
967 ];
968 for (kind, status, edited, expected) in cases {
969 let mut item = store
970 .stage(
971 "x__send",
972 kind,
973 json!({"path": "/tmp/a"}),
974 Taint::default(),
975 None,
976 None,
977 )
978 .unwrap();
979 item.status = status.into();
980 if edited {
981 item.args = json!({"path": "/tmp/b"});
982 }
983 assert_eq!(
984 item.mineable_as_writing(),
985 expected,
986 "{kind:?} / {status} / edited={edited}"
987 );
988 }
989
990 let _ = std::fs::remove_dir_all(&root);
991 }
992
993 /// The kind is config's to declare, and anything unnamed stays a message —
994 /// which keeps the arguments reviewable rather than silently hiding them.
995 #[test]
996 fn a_routes_kind_comes_from_config_and_defaults_to_message() {
997 let root = scratch("kindof");
998 let store = OutboxStore::open(&root).unwrap();
999 let route = OutboxRoute::new(
1000 store,
1001 [
1002 "mail__send".to_string(),
1003 "factory__bundle_publish".to_string(),
1004 ],
1005 ["factory__bundle_publish".to_string()],
1006 );
1007 assert_eq!(
1008 route.kind_of("factory__bundle_publish"),
1009 OutboxKind::Publish
1010 );
1011 assert_eq!(route.kind_of("mail__send"), OutboxKind::Message);
1012 assert_eq!(route.kind_of("never__heard_of_it"), OutboxKind::Message);
1013
1014 let _ = std::fs::remove_dir_all(&root);
1015 }
1016
1017 /// Items written before the field existed must load as what they in fact
1018 /// were, or an upgrade would reclassify every staged email as unknown.
1019 #[test]
1020 fn an_item_recorded_before_kinds_existed_loads_as_a_message() {
1021 let item: OutboxItem = serde_json::from_value(json!({
1022 "id": "20260101-000000-abc",
1023 "status": "sent",
1024 "tool": "mail__send",
1025 "args_before": {"body": "a"},
1026 "args": {"body": "b"},
1027 "summary": "mail__send",
1028 "created_at": "2026-01-01T00:00:00Z",
1029 }))
1030 .unwrap();
1031 assert_eq!(item.kind, OutboxKind::Message);
1032 assert!(item.mineable_as_writing());
1033 // And the same for the jail it was drafted under: an older item names
1034 // none, and a release falls back to the reviewer's workspace, which is
1035 // exactly what it did before the field existed.
1036 assert_eq!(item.workspace, None);
1037 }
1038
1039 /// A staged call is a deferred tool call, and the release happens in
1040 /// another process from another directory. Without the drafting jail on
1041 /// the item, `{"bundle": "site"}` resolves against wherever the reviewer
1042 /// stands — an absolute path fails loudly, and a relative one silently
1043 /// publishes whatever `./site` happens to be there.
1044 #[test]
1045 fn a_staged_call_records_the_jail_it_was_drafted_under() {
1046 let root = scratch("workspace");
1047 let store = OutboxStore::open(&root).unwrap();
1048 let jail = PathBuf::from("/home/someone/.mecha/work/morning");
1049
1050 let item = store
1051 .stage(
1052 "factory__bundle_publish",
1053 OutboxKind::Publish,
1054 json!({"bundle": "site", "id": "brief"}),
1055 Taint::default(),
1056 None,
1057 Some(jail.clone()),
1058 )
1059 .unwrap();
1060 assert_eq!(item.workspace.as_ref(), Some(&jail));
1061
1062 // And it survives the round-trip through the file, which is the only
1063 // form the reviewing process ever sees.
1064 let loaded = store.item(&item.id).unwrap();
1065 assert_eq!(loaded.workspace.as_ref(), Some(&jail));
1066
1067 let _ = std::fs::remove_dir_all(&root);
1068 }
1069
1070 #[test]
1071 fn resolution_rewrites_in_place_and_only_pending_resolves() {
1072 let root = scratch("resolve");
1073 let store = OutboxStore::open(&root).unwrap();
1074 let item = store
1075 .stage(
1076 "t",
1077 OutboxKind::Message,
1078 json!({}),
1079 Taint::default(),
1080 None,
1081 None,
1082 )
1083 .unwrap();
1084
1085 let sent = store.resolve(&item.id, "sent", None).unwrap();
1086 assert_eq!(sent.status, "sent");
1087 assert!(sent.resolved_at.is_some());
1088 assert_eq!(
1089 store.items().unwrap().len(),
1090 1,
1091 "resolved in place, not archived"
1092 );
1093
1094 let err = store.resolve(&item.id, "rejected", None).unwrap_err();
1095 assert!(err.to_string().contains("not pending"), "{err}");
1096 let err = store.update_args(&item.id, json!({"x": 1})).unwrap_err();
1097 assert!(err.to_string().contains("not pending"), "{err}");
1098
1099 let _ = std::fs::remove_dir_all(&root);
1100 }
1101
1102 #[test]
1103 fn a_failed_release_records_the_error_and_stays_pending() {
1104 let root = scratch("error");
1105 let store = OutboxStore::open(&root).unwrap();
1106 let item = store
1107 .stage(
1108 "t",
1109 OutboxKind::Message,
1110 json!({}),
1111 Taint::default(),
1112 None,
1113 None,
1114 )
1115 .unwrap();
1116
1117 store.record_error(&item.id, "server unreachable").unwrap();
1118 let loaded = store.item(&item.id).unwrap();
1119 assert_eq!(loaded.status, "pending");
1120 assert_eq!(loaded.error.as_deref(), Some("server unreachable"));
1121
1122 // A later successful resolution clears the stale error.
1123 let sent = store.resolve(&item.id, "sent", None).unwrap();
1124 assert_eq!(sent.error, None);
1125
1126 let _ = std::fs::remove_dir_all(&root);
1127 }
1128
1129 /// The targeted read behind the hot paths: one file, found or honestly
1130 /// missing, and a value shaped like a path is refused before it can name
1131 /// a file outside the store — even one that exists and would parse.
1132 #[test]
1133 fn an_exact_lookup_reads_one_file_and_refuses_a_hostile_id() {
1134 let root = scratch("exact");
1135 let store = OutboxStore::open(&root).unwrap();
1136 let staged = store
1137 .stage(
1138 "mail__send",
1139 OutboxKind::Message,
1140 json!({"to": "a@x.org"}),
1141 Taint::default(),
1142 None,
1143 None,
1144 )
1145 .unwrap();
1146
1147 let found = store.item_exact(&staged.id).unwrap().expect("found");
1148 assert_eq!(found.id, staged.id);
1149 assert_eq!(found.tool, "mail__send");
1150
1151 // Missing is None, not an error: the caller decides what absence means.
1152 assert!(store
1153 .item_exact("20990101T000000-deadbeef")
1154 .unwrap()
1155 .is_none());
1156
1157 // A perfectly valid item file sitting *beside* the store, reachable
1158 // only by traversal — the refusal below is not vacuous.
1159 let outside = root.parent().unwrap().join("mecha-outbox-evil.json");
1160 std::fs::write(&outside, serde_json::to_string_pretty(&staged).unwrap()).unwrap();
1161 for hostile in [
1162 "../mecha-outbox-evil",
1163 "a/b",
1164 "a.b",
1165 ".",
1166 "",
1167 &"x".repeat(200),
1168 ] {
1169 assert!(
1170 store.item_exact(hostile).is_err(),
1171 "{hostile:?} must be refused, not resolved"
1172 );
1173 }
1174 let _ = std::fs::remove_file(&outside);
1175
1176 let _ = std::fs::remove_dir_all(&root);
1177 }
1178
1179 /// The invariant that separates reshaping a draft from summarising one:
1180 /// every argument reaches the reviewer somewhere. A field that falls
1181 /// between the three buckets is a field released unread.
1182 #[test]
1183 fn a_draft_view_drops_no_argument() {
1184 let args = json!({
1185 "to": ["a@x.org", "b@x.org"],
1186 "subject": "Tuesday?",
1187 "body_markdown": "Dear A,\n\nHello.\n\nLuke",
1188 "account": "dartmouth",
1189 "importance": "high",
1190 "attachments": [{"name": "f.pdf"}],
1191 });
1192 let view = DraftView::of(&args);
1193 let mut seen: Vec<String> = view
1194 .headers
1195 .iter()
1196 .map(|(k, _)| k.clone())
1197 .chain(view.body_field.clone())
1198 .chain(view.other.iter().map(|(k, _)| k.clone()))
1199 .collect();
1200 seen.sort();
1201 let mut keys: Vec<String> = args.as_object().unwrap().keys().cloned().collect();
1202 keys.sort();
1203 assert_eq!(seen, keys);
1204 assert_eq!(view.body.as_deref(), Some("Dear A,\n\nHello.\n\nLuke"));
1205 // Reading order, not map order.
1206 assert_eq!(
1207 view.headers
1208 .iter()
1209 .map(|(k, _)| k.as_str())
1210 .collect::<Vec<_>>(),
1211 ["to", "subject", "account"]
1212 );
1213 assert_eq!(view.headers[0].1, "a@x.org, b@x.org");
1214 }
1215
1216 /// The spoken form carries the same guarantee, and it is the one that
1217 /// matters most: a listener cannot skim back over the line where the
1218 /// extra recipient was. Every argument's value must be *audible* — the
1219 /// check is on values rather than keys, because the body is spoken with
1220 /// no label and a key-only check would pass on a draft that read out its
1221 /// field names and none of its content.
1222 #[test]
1223 fn a_spoken_draft_utters_every_argument() {
1224 let args = json!({
1225 "to": ["a@x.org", "b@x.org"],
1226 "subject": "Tuesday?",
1227 "body_markdown": "Dear A,\n\nHello.\n\nLuke",
1228 "account": "dartmouth",
1229 "importance": "high",
1230 });
1231 let spoken = DraftView::of(&args).spoken().text();
1232 for audible in [
1233 "a@x.org",
1234 "b@x.org",
1235 "Tuesday?",
1236 "Dear A,",
1237 "Luke",
1238 "dartmouth",
1239 "high",
1240 ] {
1241 assert!(
1242 spoken.contains(audible),
1243 "{audible} was never said: {spoken}"
1244 );
1245 }
1246 // Labels are spoken as words, and the body carries none — "Body
1247 // markdown:" is a field name a listener has to parse past.
1248 assert!(spoken.contains("Subject: Tuesday?."), "{spoken}");
1249 assert!(!spoken.contains("Body markdown"), "{spoken}");
1250 assert!(spoken.contains("Importance: high."), "{spoken}");
1251 }
1252
1253 /// A calendar draft is mostly timestamps, and a timestamp read out as
1254 /// digits is a draft nobody can check. Rendered in the offset the string
1255 /// carries — hearing a different moment than the draft names is the
1256 /// wrong-bytes review arriving through the ear.
1257 #[test]
1258 fn a_timestamp_is_spoken_as_a_time_in_its_own_offset() {
1259 let spoken = DraftView::of(&json!({
1260 "title": "Walk with Sage",
1261 "start_time": "2026-08-28T14:00:00-04:00",
1262 "end_time": "2026-08-28T14:30:00-04:00",
1263 }))
1264 .spoken()
1265 .text();
1266 assert!(spoken.contains("Friday August 28 at 2 PM"), "{spoken}");
1267 assert!(spoken.contains("Friday August 28 at 2:30 PM"), "{spoken}");
1268 assert!(!spoken.contains("T14:00"), "{spoken}");
1269 // A calendar tool that puts the zone in its own argument sends a
1270 // *naive* datetime, which is the form this missed on the first pass:
1271 // it fell through to the fallback and read out "2026-08-28T16:00:00".
1272 let naive = DraftView::of(&json!({
1273 "start_time": "2026-08-28T16:00:00",
1274 "timezone": "America/New_York",
1275 }))
1276 .spoken()
1277 .text();
1278 assert!(naive.contains("Friday August 28 at 4 PM"), "{naive}");
1279 // Start before end, whatever order the map hands them back in.
1280 let start = spoken.find("Start time").expect("start");
1281 let end = spoken.find("End time").expect("end");
1282 assert!(start < end, "an event read end-first is nonsense: {spoken}");
1283 }
1284
1285 /// A value the renderer does not understand reaches the listener as
1286 /// itself. Guessing at it would be the one thing a spoken review cannot
1287 /// afford.
1288 #[test]
1289 fn an_unparseable_value_is_spoken_unchanged() {
1290 let spoken = DraftView::of(&json!({"when": "sometime next week"}))
1291 .spoken()
1292 .text();
1293 assert_eq!(spoken, "When: sometime next week.");
1294 }
1295
1296 /// A tool nobody anticipated is still speakable, stiffly and completely.
1297 /// The alternative — a lookup table of nice phrasings — covers the tools
1298 /// thought of today and mis-speaks the rest.
1299 #[test]
1300 fn an_unanticipated_argument_is_spoken_stiffly_not_silently() {
1301 let spoken = DraftView::of(&json!({"emoji": "wave", "ts": 17}))
1302 .spoken()
1303 .text();
1304 assert_eq!(spoken, "Emoji: wave. Ts: 17.");
1305 }
1306
1307 /// A tool nobody anticipated is still reviewable: no headers, no body, and
1308 /// every argument shown.
1309 #[test]
1310 fn an_unrecognised_draft_shows_everything_as_other() {
1311 let view = DraftView::of(&json!({"emoji": "wave", "ts": 17}));
1312 assert!(view.headers.is_empty() && view.body.is_none());
1313 assert_eq!(
1314 view.other,
1315 vec![
1316 ("emoji".to_string(), "wave".to_string()),
1317 ("ts".to_string(), "17".to_string())
1318 ]
1319 );
1320 }
1321
1322 /// A blank value is shown as blank-on-purpose. The alternative is a label
1323 /// with nothing after it, which reads as a broken display rather than as
1324 /// an empty recipient list.
1325 #[test]
1326 fn an_empty_argument_says_so() {
1327 let view = DraftView::of(&json!({"to": "", "body": "hi"}));
1328 assert_eq!(
1329 view.headers,
1330 vec![("to".to_string(), "(empty)".to_string())]
1331 );
1332 }
1333
1334 /// An edit writes back to the key the body came from, and to nothing else.
1335 #[test]
1336 fn an_edited_body_returns_to_its_own_field() {
1337 let args = json!({"thread_id": "t1", "body_markdown": "old", "account": "personal"});
1338 let edited = with_body(&args, "new").unwrap();
1339 assert_eq!(edited["body_markdown"], "new");
1340 assert_eq!(edited["thread_id"], "t1");
1341 assert_eq!(edited["account"], "personal");
1342 // No prose, no body edit — the caller must fall back rather than
1343 // silently save nothing.
1344 assert!(with_body(&json!({"event_id": "e1", "response": "accept"}), "x").is_none());
1345 }
1346}