use std::collections::{HashMap, VecDeque};
use std::path::Path;
use mecha_core::outbox::{DraftView, OutboxItem, OutboxStore};
use tokio::sync::Mutex;
use crate::review_policy::{parse_answer, speakable, SpokenAnswer, SPOKEN_UNPROMPTED_CHARS};
const CHARS_PER_SECOND: usize = 15;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Pending {
pub queue: VecDeque<String>,
}
#[derive(Default)]
pub struct Confirmations(Mutex<HashMap<String, Pending>>);
impl Confirmations {
pub async fn set(&self, key: &str, pending: Pending) {
if pending.queue.is_empty() {
self.0.lock().await.remove(key);
} else {
self.0.lock().await.insert(key.to_string(), pending);
}
}
pub async fn take(&self, key: &str) -> Option<Pending> {
self.0.lock().await.remove(key)
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct Offer {
pub speech: String,
pub pending: Pending,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Reaction {
PassToModel,
Say(String),
Reread(String),
Release { acknowledge: String, id: String },
}
pub fn compose_offer(items: &[OutboxItem]) -> Option<Offer> {
let (speakable_items, unspeakable): (Vec<&OutboxItem>, Vec<&OutboxItem>) =
items.iter().partition(|i| speakable(i.kind));
let mut speech = String::new();
let mut queue: VecDeque<String> = speakable_items.iter().map(|i| i.id.clone()).collect();
if let Some(first) = speakable_items.first() {
speech.push_str(&ask_about(first));
if speakable_items.len() > 1 {
speech.push_str(&format!(
" There {} after that.",
plural_drafts(speakable_items.len() - 1)
));
}
}
if !unspeakable.is_empty() {
if !speech.is_empty() {
speech.push(' ');
}
speech.push_str(&format!(
"There {} waiting too — that needs the screen, so it is in your outbox.",
plural_publishes(unspeakable.len())
));
}
if speech.is_empty() {
return None;
}
if queue.is_empty() {
return Some(Offer {
speech,
pending: Pending::default(),
});
}
queue.make_contiguous();
Some(Offer {
speech,
pending: Pending { queue },
})
}
fn ask_about(item: &OutboxItem) -> String {
let view = DraftView::of(&item.args);
let spoken = view.spoken();
let mut out = String::new();
if spoken.chars() <= SPOKEN_UNPROMPTED_CHARS {
out.push_str(&format!("Here it is, in full. {}", spoken.text()));
out.push_str(taint_line(item));
out.push_str(" Say yes to send it, or later to leave it in your outbox.");
} else {
let headline = view
.headers
.iter()
.find(|(k, _)| k == "subject" || k == "title")
.map(|(_, v)| v.clone())
.unwrap_or_else(|| item.summary.clone());
out.push_str(&format!(
"I've drafted something longer: {headline}. It is about {} to read out.",
seconds_aloud(spoken.chars())
));
out.push_str(taint_line(item));
out.push_str(
" Say read it out to hear the whole thing, yes to send it, \
or later to leave it in your outbox.",
);
}
out
}
fn taint_line(item: &OutboxItem) -> &'static str {
if item.taint.trifecta_armed() {
" I had read outside content when I wrote this, so listen to the addressing."
} else {
""
}
}
fn seconds_aloud(chars: usize) -> String {
let secs = chars / CHARS_PER_SECOND;
if secs < 90 {
format!("{} seconds", ((secs.max(5) + 5) / 10) * 10)
} else {
format!("{} minutes", (secs + 30) / 60)
}
}
fn plural_drafts(n: usize) -> String {
if n == 1 {
"is one more draft".into()
} else {
format!("are {n} more drafts")
}
}
fn plural_publishes(n: usize) -> String {
if n == 1 {
"is also a publish".into()
} else {
format!("are also {n} publishes")
}
}
pub fn react(
utterance: &str,
pending: &Pending,
head: Option<&OutboxItem>,
next: Option<&OutboxItem>,
) -> Reaction {
let Some(id) = pending.queue.front().cloned() else {
return Reaction::PassToModel;
};
match parse_answer(utterance) {
SpokenAnswer::NotAnAnswer => Reaction::PassToModel,
SpokenAnswer::Later => {
Reaction::Say(format!("Left in your outbox.{}", next_question(next)))
}
SpokenAnswer::ReadItOut => match head {
Some(item) => Reaction::Reread(format!(
"{} Say yes to send it, or later to leave it.",
DraftView::of(&item.args).spoken().text()
)),
None => Reaction::Say(format!(
"That draft is not in the outbox any more.{}",
next_question(next)
)),
},
SpokenAnswer::Send => match head {
Some(_) => Reaction::Release {
acknowledge: "Sending it now.".into(),
id,
},
None => Reaction::Say(format!(
"That draft is not in the outbox any more, so there is nothing to send.{}",
next_question(next)
)),
},
}
}
fn next_question(next: Option<&OutboxItem>) -> String {
match next {
Some(item) => format!(" Next: {}", ask_about(item)),
None => String::new(),
}
}
pub fn item_now(root: &Path, id: &str) -> Option<OutboxItem> {
OutboxStore::open(root)
.ok()?
.item(id)
.ok()
.filter(|i| i.status == "pending")
}
pub async fn release(id: &str) -> Result<String, String> {
let output = tokio::time::timeout(
std::time::Duration::from_secs(120),
tokio::process::Command::new(crate::exe::self_exe())
.args(["outbox", "approve", id, "--yes"])
.output(),
)
.await
.map_err(|_| "that took too long — it is still in your outbox".to_string())?
.map_err(|e| format!("could not run the release: {e}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(stderr.lines().last().unwrap_or("it failed").to_string())
}
}
pub fn report_release(outcome: Result<String, String>, next: Option<&OutboxItem>) -> String {
match outcome {
Ok(_) => format!("Sent.{}", next_question(next)),
Err(why) => format!("It did not send: {why} It is still in your outbox."),
}
}
#[cfg(test)]
mod tests {
use super::*;
use mecha_core::agent::Taint;
use mecha_core::outbox::OutboxKind;
use serde_json::json;
fn item(id: &str, kind: OutboxKind, args: serde_json::Value, tainted: bool) -> OutboxItem {
OutboxItem {
id: id.into(),
status: "pending".into(),
tool: "mail__calendar_create".into(),
kind,
args_before: args.clone(),
args,
summary: "a draft".into(),
session_id: None,
workspace: None,
taint: Taint {
private: tainted,
untrusted: tainted,
},
created_at: "2026-08-25T10:00:00Z".into(),
resolved_at: None,
reason: None,
error: None,
}
}
fn event() -> OutboxItem {
item(
"a",
OutboxKind::Message,
json!({"title": "Coffee with Thea", "when": "Thursday August 27, 3pm to 3:30pm"}),
false,
)
}
#[test]
fn a_short_draft_is_read_out_whole() {
let offer = compose_offer(&[event()]).expect("an offer");
assert!(
offer.speech.contains("Coffee with Thea"),
"{}",
offer.speech
);
assert!(
offer.speech.contains("Thursday August 27, 3pm to 3:30pm"),
"{}",
offer.speech
);
assert!(offer.speech.contains("Say yes to send it"));
assert!(
offer.speech.contains("in full"),
"the listener is told the readback is verbatim: {}",
offer.speech
);
assert_eq!(offer.pending.queue.len(), 1);
}
#[test]
fn a_long_draft_is_offered_rather_than_recited() {
let long = item(
"b",
OutboxKind::Message,
json!({"subject": "Re: R01 resubmission", "body_markdown": "word ".repeat(200)}),
false,
);
let offer = compose_offer(&[long]).expect("an offer");
assert!(
offer.speech.contains("Re: R01 resubmission"),
"{}",
offer.speech
);
assert!(offer.speech.contains("read it out"), "{}", offer.speech);
assert!(
!offer.speech.contains("word word word"),
"a long draft must not be recited unasked: {}",
offer.speech
);
}
#[test]
fn a_tainted_draft_says_so_out_loud() {
let offer = compose_offer(&[item("c", OutboxKind::Message, json!({"title": "x"}), true)])
.expect("an offer");
assert!(offer.speech.contains("outside content"), "{}", offer.speech);
assert!(!compose_offer(&[event()])
.unwrap()
.speech
.contains("outside content"));
}
#[test]
fn a_publish_is_named_and_never_offered() {
let offer = compose_offer(&[item(
"p",
OutboxKind::Publish,
json!({"bundle": "site"}),
false,
)])
.expect("it is still mentioned");
assert!(
offer.speech.contains("needs the screen"),
"{}",
offer.speech
);
assert!(
offer.pending.queue.is_empty(),
"a question no word can answer must not stay open"
);
}
#[test]
fn several_drafts_are_asked_about_one_at_a_time() {
let offer = compose_offer(&[
event(),
item("b", OutboxKind::Message, json!({"title": "b"}), false),
])
.expect("an offer");
assert_eq!(offer.pending.queue.len(), 2);
assert!(offer.speech.contains("one more draft"), "{}", offer.speech);
assert!(offer.speech.contains("Coffee with Thea"));
assert!(!offer.speech.contains("Title: b."), "{}", offer.speech);
}
#[test]
fn nothing_staged_asks_nothing() {
assert_eq!(compose_offer(&[]), None);
}
#[test]
fn an_unanswered_offer_is_dropped_not_held() {
let ev = event();
let pending = compose_offer(std::slice::from_ref(&ev)).unwrap().pending;
assert_eq!(
react("actually make it four o'clock", &pending, Some(&ev), None),
Reaction::PassToModel
);
assert!(matches!(
react("yes", &pending, Some(&ev), None),
Reaction::Release { .. }
));
assert!(matches!(
react("later", &pending, Some(&ev), None),
Reaction::Say(_)
));
}
#[test]
fn reading_it_out_again_does_not_consume_the_question() {
let ev = event();
let pending = compose_offer(std::slice::from_ref(&ev)).unwrap().pending;
match react("read it out", &pending, Some(&ev), None) {
Reaction::Reread(said) => assert!(said.contains("Coffee with Thea"), "{said}"),
other => panic!("expected a re-read: {other:?}"),
}
}
#[test]
fn the_next_draft_is_asked_about_never_pointed_at() {
let ev = event();
let second = item(
"b",
OutboxKind::Message,
json!({"title": "Second thing"}),
false,
);
let pending = compose_offer(&[ev.clone(), second.clone()])
.unwrap()
.pending;
let said = match react("later", &pending, Some(&ev), Some(&second)) {
Reaction::Say(said) => said,
other => panic!("{other:?}"),
};
assert!(said.contains("Second thing"), "{said}");
assert!(said.contains("Say yes to send it"), "{said}");
assert!(!said.contains("say next"), "{said}");
}
#[test]
fn a_draft_that_vanished_is_reported_not_guessed_at() {
let ev = event();
let pending = compose_offer(std::slice::from_ref(&ev)).unwrap().pending;
match react("yes", &pending, None, None) {
Reaction::Say(said) => assert!(said.contains("not in the outbox"), "{said}"),
other => panic!("a missing draft must not be released: {other:?}"),
}
}
#[test]
fn a_failed_release_says_why_and_where_the_draft_is() {
let said = report_release(Err("token expired".into()), None);
assert!(said.contains("token expired"), "{said}");
assert!(said.contains("still in your outbox"), "{said}");
}
#[test]
fn a_length_is_spoken_roundly() {
assert_eq!(seconds_aloud(150), "10 seconds");
assert_eq!(seconds_aloud(1500), "2 minutes");
}
}