use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use crate::agent::{self, Invocation, SeatState};
use crate::config::Config;
use crate::plan;
use crate::queue::{self, Queue, Source, Task};
pub const SCHEMA: u32 = 1;
fn turn_timeout(cfg: &Config) -> Duration {
Duration::from_secs(cfg.graph.timeout_chat)
}
const SEAT: &str = "plan";
pub const MAGI_NOTE: &str = "magi: ";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Who {
Operator,
Agent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Attachment {
pub id: String,
pub name: String,
pub mime: String,
pub bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Turn {
pub who: Who,
pub body: String,
pub at: Timestamp,
#[serde(default)]
pub attachments: Vec<Attachment>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChatStatus {
Open,
Filed,
Abandoned,
}
impl ChatStatus {
pub fn open(self) -> bool {
matches!(self, Self::Open)
}
pub fn as_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::Filed => "filed",
Self::Abandoned => "abandoned",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Chat {
pub schema: u32,
pub id: String,
pub repo: PathBuf,
#[serde(default)]
pub from: Option<String>,
pub agent: String,
pub status: ChatStatus,
pub turns: Vec<Turn>,
pub draft: Option<String>,
pub task: Option<String>,
pub created_at: Timestamp,
pub updated_at: Timestamp,
seat: SeatState,
}
impl Chat {
pub fn short(&self) -> &str {
short(&self.id)
}
pub fn agent_turns(&self) -> usize {
self.seat.turns
}
}
#[derive(Debug, Clone)]
pub struct Chats {
root: PathBuf,
lock: Arc<Mutex<()>>,
}
impl Chats {
pub fn open() -> Self {
Self::at(crate::run::home().join("chats"))
}
pub fn at(root: PathBuf) -> Self {
Self {
root,
lock: Arc::new(Mutex::new(())),
}
}
fn guard(&self) -> MutexGuard<'_, ()> {
self.lock.lock().unwrap_or_else(PoisonError::into_inner)
}
fn lock_path(&self, id: &str) -> PathBuf {
self.root.join(format!("{id}.lock"))
}
fn claim(&self, id: &str) -> Result<ChatClaim> {
std::fs::create_dir_all(&self.root)
.with_context(|| format!("create {}", self.root.display()))?;
let path = self.lock_path(id);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut f) => {
use std::io::Write as _;
let _ = writeln!(f, "{}", std::process::id());
Ok(ChatClaim { path })
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
bail!("chat {id} is claimed by another process right now")
}
Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn path_of(&self, id: &str) -> PathBuf {
self.root.join(format!("{id}.json"))
}
pub fn artifacts_of(&self, id: &str) -> PathBuf {
self.root.join(format!("{id}.artifacts"))
}
pub fn attachments_dir(&self, id: &str) -> PathBuf {
self.artifacts_of(id).join("attachments")
}
pub fn put_attachment(
&self,
id: &str,
mime: &str,
name: &str,
data: &[u8],
) -> Result<Attachment> {
let dir = self.attachments_dir(id);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let ext = attachment_ext(mime).with_context(|| format!("unsupported mime `{mime}`"))?;
let att = Attachment {
id: new_attachment_id(),
name: name.to_owned(),
mime: mime.to_owned(),
bytes: data.len() as u64,
};
std::fs::write(dir.join(format!("{}.{ext}", att.id)), data)
.with_context(|| format!("write attachment {}", att.id))?;
std::fs::write(
dir.join(format!("{}.json", att.id)),
serde_json::to_string(&att).context("serialize attachment")?,
)
.with_context(|| format!("write attachment metadata {}", att.id))?;
Ok(att)
}
pub fn attachment_meta(&self, id: &str, att_id: &str) -> Result<Option<Attachment>> {
if !valid_attachment_id(att_id) {
return Ok(None);
}
let meta_path = self.attachments_dir(id).join(format!("{att_id}.json"));
if !meta_path.is_file() {
return Ok(None);
}
let att = serde_json::from_str(
&std::fs::read_to_string(&meta_path)
.with_context(|| format!("read {}", meta_path.display()))?,
)
.with_context(|| format!("parse {}", meta_path.display()))?;
Ok(Some(att))
}
pub fn read_attachment(&self, id: &str, att_id: &str) -> Result<Option<(Attachment, Vec<u8>)>> {
let Some(att) = self.attachment_meta(id, att_id)? else {
return Ok(None);
};
let ext = attachment_ext(&att.mime).with_context(|| {
format!("attachment {att_id} has an unsupported mime `{}`", att.mime)
})?;
let data_path = self.attachments_dir(id).join(format!("{att_id}.{ext}"));
let data =
std::fs::read(&data_path).with_context(|| format!("read {}", data_path.display()))?;
Ok(Some((att, data)))
}
fn attachment_path(&self, id: &str, att: &Attachment) -> Option<PathBuf> {
let ext = attachment_ext(&att.mime)?;
let path = self.attachments_dir(id).join(format!("{}.{ext}", att.id));
std::path::absolute(&path).ok()
}
pub fn put(&self, c: &mut Chat) -> Result<()> {
std::fs::create_dir_all(&self.root)
.with_context(|| format!("create {}", self.root.display()))?;
c.updated_at = Timestamp::now();
let body = serde_json::to_string_pretty(c).context("serialize chat")?;
let path = self.path_of(&c.id);
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
Ok(())
}
pub fn get(&self, id: &str) -> Result<Chat> {
let resolved = self.resolve_id(id)?;
read_path(&self.path_of(&resolved))
}
pub fn list(&self) -> Vec<Chat> {
let mut all: Vec<Chat> = std::fs::read_dir(&self.root)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.filter_map(|p| read_path(&p).ok())
.collect();
all.sort_unstable_by(|a, b| {
let rank = |c: &Chat| u8::from(!c.status.open());
rank(a).cmp(&rank(b)).then_with(|| b.id.cmp(&a.id))
});
all
}
pub fn resolve_id(&self, prefix: &str) -> Result<String> {
if self.path_of(prefix).is_file() {
return Ok(prefix.to_owned());
}
let hits: Vec<String> = self
.list()
.into_iter()
.map(|c| c.id)
.filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
.collect();
match hits.len() {
1 => Ok(hits.into_iter().next().expect("exactly one hit")),
0 => bail!("no chat matches `{prefix}`"),
_ => bail!(
"`{prefix}` matches {} chats: {}",
hits.len(),
hits.join(", ")
),
}
}
pub fn revision(&self) -> u64 {
std::fs::read_dir(&self.root)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| e.metadata().ok())
.filter_map(|m| m.modified().ok())
.filter_map(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.max()
.unwrap_or(0)
}
pub fn count_open(&self) -> usize {
self.list().iter().filter(|c| c.status.open()).count()
}
}
#[derive(Debug)]
struct ChatClaim {
path: PathBuf,
}
impl Drop for ChatClaim {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
pub fn build(
cfg: &Config,
repo: PathBuf,
idea: &str,
agent: Option<&str>,
from: Option<&Chat>,
) -> Result<Chat> {
let idea = idea.trim();
if idea.is_empty() {
bail!("an interview needs something to start from: say what you want to change");
}
let repo = repo.canonicalize().unwrap_or(repo);
let want = agent
.or(cfg.roles.chatter.as_deref())
.or(cfg.roles.planner.as_deref());
let spec = plan::pick(&cfg.agents, want, &plan::installed)?;
let now = Timestamp::now();
let id = new_id();
Ok(Chat {
schema: SCHEMA,
id,
repo,
from: from.map(|c| c.id.clone()),
agent: spec.id.clone(),
status: ChatStatus::Open,
turns: vec![Turn {
who: Who::Operator,
body: idea.to_owned(),
at: now,
attachments: Vec::new(),
}],
draft: None,
task: None,
created_at: now,
updated_at: now,
seat: SeatState::new(SEAT, &spec.id, crate::rng::entropy()),
})
}
pub fn open(
store: &Chats,
cfg: &Config,
repo: PathBuf,
idea: &str,
agent: Option<&str>,
from: Option<&Chat>,
) -> Result<Chat> {
let mut chat = build(cfg, repo, idea, agent, from)?;
store.put(&mut chat)?;
Ok(chat)
}
pub async fn first_turn(
chat: &mut Chat,
store: &Chats,
cfg: &Config,
from: Option<&Chat>,
) -> Result<()> {
let idea = chat
.turns
.first()
.map(|t| t.body.as_str())
.unwrap_or_default();
let mut prompt = briefing(idea, &chat.repo);
let mut inherited_attachments: Vec<PathBuf> = Vec::new();
if let Some(source) = from {
prompt = format!("{}\n\n{prompt}", derived_background(source, store));
inherited_attachments = source
.turns
.iter()
.flat_map(|t| t.attachments.iter())
.filter_map(|a| store.attachment_path(&source.id, a))
.collect();
}
prompt.push_str(&language_note(&cfg.graph.language));
turn(chat, store, cfg, &prompt, &inherited_attachments).await
}
pub async fn start(
store: &Chats,
cfg: &Config,
repo: PathBuf,
idea: &str,
agent: Option<&str>,
from: Option<&Chat>,
) -> Result<Chat> {
let mut chat = open(store, cfg, repo, idea, agent, from)?;
first_turn(&mut chat, store, cfg, from).await?;
Ok(chat)
}
pub fn derived_background(from: &Chat, store: &Chats) -> String {
format!(
"# Background: derived from another conversation\n\n\
This interview continues from a conversation about a *different* \
repository. Read it for context, but do not treat it as being about \
the repository named below in \"# Repository\" - that repository may \
have nothing to do with this one.\n\n\
Source repository: {}\n\n{}",
from.repo.display(),
transcript(from, store),
)
}
pub async fn say(
chat: &mut Chat,
store: &Chats,
cfg: &Config,
text: &str,
attachments: Vec<Attachment>,
) -> Result<()> {
if !chat.status.open() {
bail!(
"chat {} is {} and takes no more turns",
chat.short(),
chat.status.as_str()
);
}
let text = text.trim();
if text.is_empty() && attachments.is_empty() {
bail!("nothing to say");
}
let text = record(chat, store, text, attachments)?;
turn(chat, store, cfg, &text, &[]).await
}
pub fn record(
chat: &mut Chat,
store: &Chats,
text: &str,
attachments: Vec<Attachment>,
) -> Result<String> {
let _guard = store.guard();
let _claim = store.claim(&chat.id)?;
let fresh = store
.get(&chat.id)
.with_context(|| format!("chat {} could not be re-read", chat.short()))?;
chat.status = fresh.status;
if !chat.status.open() {
bail!(
"chat {} is {} and takes no more turns",
chat.short(),
chat.status.as_str()
);
}
let text = text.trim();
if text.is_empty() && attachments.is_empty() {
bail!("nothing to say");
}
chat.turns.push(Turn {
who: Who::Operator,
body: text.to_owned(),
at: Timestamp::now(),
attachments,
});
store.put(chat)?;
Ok(text.to_owned())
}
pub async fn respond(chat: &mut Chat, store: &Chats, cfg: &Config, text: &str) -> Result<()> {
turn(chat, store, cfg, text, &[]).await
}
async fn turn(
chat: &mut Chat,
store: &Chats,
cfg: &Config,
prompt: &str,
inherited_attachments: &[PathBuf],
) -> Result<()> {
let spec = cfg
.agents
.iter()
.find(|a| a.id == chat.agent)
.with_context(|| {
format!(
"chat {} was interviewed by agent `{}`, which is no longer in \
the roster; restore it in magi.toml or start a new chat",
chat.short(),
chat.agent
)
})?;
let resuming = agent::has_session(spec.kind, &chat.seat, cfg.graph.sessions);
let last_note = attachment_note(
store,
&chat.id,
chat.turns
.last()
.map_or(&[][..], |t| t.attachments.as_slice()),
);
let body = if resuming {
format!("{prompt}{last_note}")
} else {
format!("{}\n\n{prompt}{last_note}", transcript(chat, store))
};
let attachment_paths: Vec<PathBuf> = chat
.turns
.iter()
.flat_map(|t| t.attachments.iter())
.filter_map(|a| store.attachment_path(&chat.id, a))
.chain(inherited_attachments.iter().cloned())
.collect();
let artifacts = store.artifacts_of(&chat.id);
let stem = format!("turn-{}", chat.seat.turns + 1);
let cache_dir = cfg.cache_dir();
let inv = Invocation {
cwd: &chat.repo,
prompt: &body,
timeout: turn_timeout(cfg),
allow_write: false,
sessions: cfg.graph.sessions,
artifacts: &artifacts,
stem: &stem,
run: &chat.id,
node: "chat",
cache_dir: cache_dir.as_deref(),
attachments: &attachment_paths,
};
let outcome = agent::invoke(spec, &mut chat.seat, &inv).await;
let note = |why: String| Turn {
who: Who::Agent,
body: format!("{MAGI_NOTE}{why}"),
at: Timestamp::now(),
attachments: Vec::new(),
};
let (reply, failure) = match outcome {
Err(e) => (
note(format!("could not run agent `{}`: {e}", chat.agent)),
Some(format!("could not run agent `{}`: {e}", chat.agent)),
),
Ok(out) if out.quota_exhausted() => {
let reset = out
.quota
.as_ref()
.and_then(|q| q.reset.clone())
.map_or_else(String::new, |r| format!(" (resets {r})"));
let why = format!(
"agent `{}` is out of quota{reset}; your message is saved, so \
say it again when the window reopens",
chat.agent
);
(note(why.clone()), Some(why))
}
Ok(out) if out.timed_out => {
let why = format!(
"agent `{}` did not answer within {}s; your message is saved",
chat.agent,
turn_timeout(cfg).as_secs()
);
(note(why.clone()), Some(why))
}
Ok(out) if !out.usable() => {
let why = format!(
"agent `{}` produced no answer (exit {}); your message is saved",
chat.agent,
out.exit_code
.map_or_else(|| "unknown".to_owned(), |c| c.to_string())
);
(note(why.clone()), Some(why))
}
Ok(out) => (
Turn {
who: Who::Agent,
body: out.text.trim().to_owned(),
at: Timestamp::now(),
attachments: Vec::new(),
},
None,
),
};
if let Some(draft) = extract_draft(&reply.body) {
chat.draft = Some(draft);
}
let _guard = store.guard();
let _claim = store.claim(&chat.id)?;
let fresh = store
.get(&chat.id)
.with_context(|| format!("chat {} could not be re-read", chat.short()))?;
chat.status = fresh.status;
chat.task = fresh.task;
chat.turns.push(reply);
store.put(chat)?;
match failure {
Some(why) => bail!("{why}"),
None => Ok(()),
}
}
fn transcript(chat: &Chat, store: &Chats) -> String {
let mut out = String::from(
"You are mid-interview. This CLI cannot resume its own conversation, \
so here is everything said so far; answer only the last message.\n",
);
for t in &chat.turns {
let who = match t.who {
Who::Operator => "operator",
Who::Agent => "you",
};
out.push_str(&format!("\n## {who}\n\n{}\n", t.body.trim()));
out.push_str(&attachment_note(store, &chat.id, &t.attachments));
}
out
}
fn attachment_note(store: &Chats, chat_id: &str, attachments: &[Attachment]) -> String {
if attachments.is_empty() {
return String::new();
}
let mut out = String::from(
"\n\nThe operator attached the image(s) below to this message. Open \
and look at each one before you answer.\n",
);
for att in attachments {
if let Some(path) = store.attachment_path(chat_id, att) {
out.push_str(&format!("\n- {} ({})", path.display(), att.mime));
}
}
out.push('\n');
out
}
pub fn file_draft(chat: &mut Chat, store: &Chats, queue: &Queue, priority: i32) -> Result<String> {
let _guard = store.guard();
let _claim = store.claim(&chat.id)?;
let mut fresh = store
.get(&chat.id)
.with_context(|| format!("chat {} could not be re-read", chat.short()))?;
if !fresh.status.open() {
bail!(
"chat {} is {} and takes no more turns",
fresh.short(),
fresh.status.as_str()
);
}
if let Err(problems) = draft_problems(&fresh) {
bail!(
"this draft is not fileable yet:\n- {}",
problems.join("\n- ")
);
}
let body = fresh
.draft
.clone()
.expect("draft_problems accepted a chat with a draft");
let title = queue::title_from(&body, 72);
let mut task = Task::new(title, body, fresh.repo.clone(), Source::Human);
task.priority = priority;
queue.put(&mut task)?;
fresh.task = Some(task.id.clone());
fresh.status = ChatStatus::Filed;
store.put(&mut fresh)?;
*chat = fresh;
Ok(task.id)
}
pub fn abandon(chat: &mut Chat, store: &Chats) -> Result<()> {
let _guard = store.guard();
let _claim = store.claim(&chat.id)?;
let mut fresh = store
.get(&chat.id)
.with_context(|| format!("chat {} could not be re-read", chat.short()))?;
match fresh.status {
ChatStatus::Open => {
fresh.status = ChatStatus::Abandoned;
store.put(&mut fresh)?;
}
ChatStatus::Abandoned => {}
ChatStatus::Filed => bail!(
"chat {} is {} and takes no more turns",
fresh.short(),
fresh.status.as_str()
),
}
*chat = fresh;
Ok(())
}
pub fn draft_problems(chat: &Chat) -> Result<(), Vec<String>> {
let Some(body) = chat.draft.as_deref() else {
return Err(vec![
"this chat has no draft yet: the agent has not written a task file".to_owned(),
]);
};
match plan::review_draft(body) {
Ok(()) => Ok(()),
Err(problems) => {
if problems.iter().all(|p| p == plan::SHORT_DRAFT) {
Ok(())
} else {
Err(problems)
}
}
}
}
pub fn briefing(idea: &str, repo: &Path) -> String {
format!(
"You are the planning leader for magi, which runs a blind \
multi-agent implementation competition: several agents will implement \
the task file you write, in isolated worktrees, unaware of each other, \
and judges will rank the results without knowing who wrote what.\n\n\
Your job is not to implement anything. It is to interview the operator \
until the change is pinned down, and then write one task file.\n\n\
The operator is on a phone. Every message you send is read on a small \
screen, so keep it short: no preamble, no restating what they just \
said.\n\n\
# Repository\n\n{repo}\n\n\
Read it before you start asking. Questions the code already answers \
spend the operator's patience for nothing. Do not modify it: the \
competing agents do the implementation, and a repository you have \
already edited makes their diffs unjudgeable.\n\n\
# The idea\n\n{idea}\n\n\
# How to run the interview\n\n\
- Ask about what you cannot determine yourself: intent, scope, which \
of several defensible designs the operator wants, what must not \
change.\n\
- Ask about ONE thing per message and wait for the answer. This is a \
phone, not a form: a message with five questions in it gets one of \
them answered.\n\
- Do not produce the task file after one exchange.\n\
- Disagree when you have grounds. A leader that agrees with everything \
adds nothing to what the operator already typed.\n\
- Confirm the plan in your own words and get an explicit yes before \
writing.\n\n\
# How to deliver the task file\n\n\
When the operator agrees the plan is right, put the whole task file in \
your reply inside a fenced block tagged `task`, like this:\n\n\
```task\n\
# <the task file>\n\
```\n\n\
Nothing else goes in that block, and there is exactly one of them per \
message. magi extracts it and files it; a task file written to a file \
on disk, or pasted without the fence, is one magi cannot see. You may \
send a revised version later in the same conversation - the newest \
`task` block wins - and while you are still asking questions, send no \
`task` block at all.\n\n\
magi will refuse a task file with no completion criteria, so those are \
not optional.\n\n\
# Task file specification\n\n{spec}",
repo = repo.display(),
spec = plan::TASK_FILE_SPEC,
)
}
fn language_note(language: &str) -> String {
if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
String::new()
} else {
format!("\n\nConduct the interview in {language}, and write the task file in {language}.")
}
}
pub fn extract_draft(reply: &str) -> Option<String> {
let mut last: Option<String> = None;
let mut open: Option<(usize, Vec<&str>)> = None;
for line in reply.lines() {
let trimmed = line.trim_start();
let ticks = trimmed.chars().take_while(|c| *c == '`').count();
match &mut open {
Some((width, body)) => {
if ticks >= *width && trimmed[ticks..].trim().is_empty() {
last = Some(joined(body));
open = None;
} else {
body.push(line);
}
}
None => {
if ticks >= 3 && trimmed[ticks..].trim().eq_ignore_ascii_case("task") {
open = Some((ticks, Vec::new()));
}
}
}
}
if let Some((_, body)) = open {
last = Some(joined(&body));
}
last.filter(|s| !s.trim().is_empty())
}
fn joined(lines: &[&str]) -> String {
if lines.is_empty() {
return String::new();
}
let mut out = lines.join("\n");
out.push('\n');
out
}
fn read_path(path: &Path) -> Result<Chat> {
let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))
}
fn short(id: &str) -> &str {
id.split('-').next_back().unwrap_or(id)
}
fn new_id() -> String {
let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
let seed = crate::rng::entropy();
format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
}
fn attachment_ext(mime: &str) -> Option<&'static str> {
match mime {
"image/png" => Some("png"),
"image/jpeg" => Some("jpg"),
"image/gif" => Some("gif"),
"image/webp" => Some("webp"),
_ => None,
}
}
pub fn valid_attachment_id(id: &str) -> bool {
id.len() == 32
&& id
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn new_attachment_id() -> String {
let mut r = crate::rng::SplitMix64::new(crate::rng::entropy());
format!("{:016x}{:016x}", r.next_u64(), r.next_u64())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use crate::config::{AgentKind, AgentSpec, Graph};
use super::*;
fn store() -> (tempfile::TempDir, Chats) {
let tmp = tempfile::tempdir().expect("tempdir");
let chats = Chats::at(tmp.path().join("chats"));
(tmp, chats)
}
fn good_draft() -> String {
"# Report per-node durations in `magi show`\n\
\n\
## Context\n\
\n\
`magi show` prints a run's nodes but not how long any of them took, so \
the operator cannot see which seat is expensive. The data is already \
in `run.events`.\n\
\n\
## Change\n\
\n\
Add a duration column to the node table in `src/report.rs`.\n\
\n\
## Constraints\n\
\n\
Do not change the JSON shape of a run record.\n\
\n\
## Completion criteria\n\
\n\
- [ ] `magi show <run>` prints a duration for every completed node.\n\
- [ ] A node with no end event prints nothing rather than zero.\n\
\n\
## Out of scope\n\
\n\
The TUI's detail pane.\n"
.to_owned()
}
fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
let path = dir.join("mock-chat-agent.sh");
std::fs::write(&path, script).expect("write mock");
AgentSpec {
id: "mock".to_owned(),
kind: AgentKind::Command,
model: None,
command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
extra_args: Vec::new(),
env,
prompt_delivery: None,
}
}
fn config(spec: AgentSpec) -> Config {
Config {
agents: vec![spec],
graph: Graph {
language: "en".to_owned(),
..Graph::default()
},
..Config::default()
}
}
const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
const ECHO: &str = "#!/bin/sh\ncat\n";
fn env(reply: &str) -> BTreeMap<String, String> {
BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
}
#[test]
fn the_frozen_json_field_names_round_trip_through_disk() {
let (tmp, chats) = store();
let mut chat = Chat {
schema: SCHEMA,
id: "20260903-014455-ab12".to_owned(),
repo: tmp.path().to_owned(),
from: None,
agent: "sonnet".to_owned(),
status: ChatStatus::Open,
turns: vec![Turn {
who: Who::Operator,
body: "rework the config loader".to_owned(),
at: Timestamp::now(),
attachments: Vec::new(),
}],
draft: None,
task: None,
created_at: Timestamp::now(),
updated_at: Timestamp::now(),
seat: SeatState::new(SEAT, "sonnet", 7),
};
chats.put(&mut chat).expect("put");
let raw = std::fs::read_to_string(chats.path_of(&chat.id)).expect("read back");
let v: serde_json::Value = serde_json::from_str(&raw).expect("parse");
for field in [
"schema",
"id",
"repo",
"from",
"agent",
"status",
"turns",
"draft",
"task",
"created_at",
"updated_at",
] {
assert!(v.get(field).is_some(), "missing field `{field}`");
}
assert_eq!(v["schema"], 1);
assert_eq!(v["status"], "open");
assert_eq!(v["turns"][0]["who"], "operator");
assert_eq!(v["turns"][0]["body"], "rework the config loader");
assert!(v["turns"][0].get("at").is_some());
assert!(v["turns"][0].get("attachments").is_some());
assert!(v["draft"].is_null());
assert!(v["task"].is_null());
assert!(v["from"].is_null());
let back = chats.get(&chat.id).expect("get");
assert_eq!(back.id, chat.id);
assert_eq!(back.turns, chat.turns);
assert_eq!(back.status, ChatStatus::Open);
assert_eq!(back.from, None);
}
#[test]
fn a_chat_recorded_without_a_from_field_still_reads() {
let (tmp, chats) = store();
let path = chats.path_of("20260903-014455-ab12");
std::fs::create_dir_all(chats.root()).expect("chats dir");
std::fs::write(
&path,
serde_json::json!({
"schema": SCHEMA,
"id": "20260903-014455-ab12",
"repo": tmp.path(),
"agent": "sonnet",
"status": "open",
"turns": [],
"draft": null,
"task": null,
"created_at": Timestamp::now().to_string(),
"updated_at": Timestamp::now().to_string(),
"seat": SeatState::new(SEAT, "sonnet", 7),
})
.to_string(),
)
.expect("write pre-`from` chat");
let chat = chats.get("20260903-014455-ab12").expect("must still read");
assert_eq!(chat.from, None);
}
#[test]
fn a_chat_recorded_without_attachments_still_reads() {
let (tmp, chats) = store();
let path = chats.path_of("20260903-014455-ab12");
std::fs::create_dir_all(chats.root()).expect("chats dir");
std::fs::write(
&path,
serde_json::json!({
"schema": 1,
"id": "20260903-014455-ab12",
"repo": tmp.path(),
"agent": "sonnet",
"status": "open",
"turns": [
{ "who": "operator", "body": "rework the config loader",
"at": Timestamp::now().to_string() },
],
"draft": null,
"task": null,
"created_at": Timestamp::now().to_string(),
"updated_at": Timestamp::now().to_string(),
"seat": SeatState::new(SEAT, "sonnet", 7),
})
.to_string(),
)
.expect("write pre-attachments chat");
let chat = chats.get("20260903-014455-ab12").expect("must still read");
assert!(chat.turns[0].attachments.is_empty());
}
#[test]
fn derived_background_names_the_source_repository_and_carries_the_transcript() {
let (_tmp, chats) = store();
let chat = Chat {
schema: SCHEMA,
id: "20260903-014455-ab12".to_owned(),
repo: PathBuf::from("/repo/other"),
from: None,
agent: "sonnet".to_owned(),
status: ChatStatus::Open,
turns: vec![
Turn {
who: Who::Operator,
body: "rework the queue drain".to_owned(),
at: Timestamp::now(),
attachments: Vec::new(),
},
Turn {
who: Who::Agent,
body: "which part of the drain?".to_owned(),
at: Timestamp::now(),
attachments: Vec::new(),
},
],
draft: None,
task: None,
created_at: Timestamp::now(),
updated_at: Timestamp::now(),
seat: SeatState::new(SEAT, "sonnet", 7),
};
let background = derived_background(&chat, &chats);
assert!(background.contains("/repo/other"));
assert!(background.contains("rework the queue drain"));
assert!(background.contains("which part of the drain?"));
assert!(background.contains("different"));
}
#[tokio::test]
async fn starting_a_derived_chat_carries_the_source_transcript_and_leaves_it_untouched() {
let (tmp, chats) = store();
let source_spec = mock_agent(tmp.path(), REPLY, env("which module?"));
let source_cfg = config(source_spec);
let source = start(
&chats,
&source_cfg,
tmp.path().to_owned(),
"rework the queue drain",
None,
None,
)
.await
.expect("start source");
let before = source.clone();
let other_repo = tmp.path().join("other-repo");
std::fs::create_dir_all(&other_repo).expect("other repo dir");
let echo_spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
let derived_cfg = config(echo_spec);
let derived = start(
&chats,
&derived_cfg,
other_repo,
"same idea, different repository",
None,
Some(&source),
)
.await
.expect("start derived");
assert_eq!(derived.from.as_deref(), Some(source.id.as_str()));
let prompt = &derived.turns.last().expect("agent reply").body;
assert!(prompt.contains("Background: derived from another conversation"));
assert!(prompt.contains(&source.repo.display().to_string()));
assert!(prompt.contains("rework the queue drain"));
assert!(prompt.contains("same idea, different repository"));
let reread = chats.get(&source.id).expect("source still on disk");
assert_eq!(reread.status, before.status);
assert_eq!(reread.turns, before.turns);
assert_eq!(reread.draft, before.draft);
}
#[test]
fn build_constructs_the_record_without_writing_it_anywhere() {
let (tmp, chats) = store();
let spec = mock_agent(tmp.path(), REPLY, BTreeMap::new());
let cfg = config(spec);
let chat = build(
&cfg,
tmp.path().to_owned(),
"rework the config loader",
None,
None,
)
.expect("build");
assert!(
!chats.path_of(&chat.id).is_file(),
"build must not touch the filesystem"
);
assert!(
chats.list().is_empty(),
"no record must be resolvable until something calls `Chats::put`"
);
}
#[tokio::test]
async fn a_chat_prefers_the_chatter_role_over_the_planner_role() {
let (tmp, chats) = store();
let planner_spec = mock_agent(tmp.path(), REPLY, env("from the planner"));
let mut chatter_spec = mock_agent(tmp.path(), REPLY, env("from the chatter"));
chatter_spec.id = "chatter-mock".to_owned();
let mut cfg = Config {
agents: vec![planner_spec.clone(), chatter_spec.clone()],
graph: Graph {
language: "en".to_owned(),
..Graph::default()
},
..Config::default()
};
cfg.roles.planner = Some(planner_spec.id.clone());
cfg.roles.chatter = Some(chatter_spec.id.clone());
let chat = start(
&chats,
&cfg,
tmp.path().to_owned(),
"rework the drain",
None,
None,
)
.await
.expect("start with chatter set");
assert_eq!(chat.agent, chatter_spec.id, "chatter must win over planner");
cfg.roles.chatter = None;
let fallback = start(
&chats,
&cfg,
tmp.path().to_owned(),
"rework the drain again",
None,
None,
)
.await
.expect("start with chatter unset");
assert_eq!(
fallback.agent, planner_spec.id,
"unset chatter must fall back to planner, unchanged from before this role existed"
);
}
#[test]
fn extract_draft_takes_the_last_task_block_and_ignores_other_fences() {
let reply = "here is a sketch\n\
\n\
```rust\n\
fn not_the_draft() {}\n\
```\n\
\n\
```task\n\
# first version\n\
```\n\
\n\
```json\n\
{\"also\": \"not it\"}\n\
```\n\
\n\
revised:\n\
\n\
```task\n\
# second version\n\
## Completion criteria\n\
```\n";
assert_eq!(
extract_draft(reply).as_deref(),
Some("# second version\n## Completion criteria\n")
);
}
#[test]
fn extract_draft_returns_none_when_there_is_no_task_block() {
assert_eq!(extract_draft("which storage backend do you want?"), None);
assert_eq!(extract_draft("```rust\nfn f() {}\n```\n"), None);
assert_eq!(extract_draft("```task\n```\n"), None);
}
#[tokio::test]
async fn a_reply_with_no_draft_leaves_the_existing_draft_in_place() {
let (tmp, chats) = store();
let spec = mock_agent(tmp.path(), REPLY, env("one more thing: which module?"));
let cfg = config(spec);
let mut chat = start(
&chats,
&cfg,
tmp.path().to_owned(),
"add durations",
None,
None,
)
.await
.expect("start");
chat.draft = Some(good_draft());
chats.put(&mut chat).expect("put");
say(&mut chat, &chats, &cfg, "the report module", Vec::new())
.await
.expect("say");
assert_eq!(chat.draft.as_deref(), Some(good_draft().as_str()));
assert_eq!(
chats.get(&chat.id).expect("get").draft.as_deref(),
Some(good_draft().as_str())
);
}
#[test]
fn the_briefing_carries_the_task_file_spec_and_the_task_fence() {
let brief = briefing("rework the config loader", Path::new("/repo"));
assert!(brief.contains(plan::TASK_FILE_SPEC));
assert!(brief.contains("```task"));
assert!(brief.contains("rework the config loader"));
assert!(brief.contains("/repo"));
assert!(brief.contains("completion criteria"));
}
#[test]
fn file_draft_refuses_a_bad_draft_with_every_problem() {
let (tmp, chats) = store();
let queue = Queue::at(tmp.path().join("queue"));
let mut chat = Chat {
schema: SCHEMA,
id: "20260903-014455-ab12".to_owned(),
repo: tmp.path().to_owned(),
from: None,
agent: "mock".to_owned(),
status: ChatStatus::Open,
turns: Vec::new(),
draft: Some("# do the thing\n\nsome context.\n".to_owned()),
task: None,
created_at: Timestamp::now(),
updated_at: Timestamp::now(),
seat: SeatState::new(SEAT, "mock", 7),
};
chats.put(&mut chat).expect("put");
let problems = draft_problems(&chat).expect_err("a draft with no criteria is not fileable");
assert!(
problems.len() >= 2,
"expected every problem, got {problems:?}"
);
assert!(problems.iter().any(|p| p.contains("completion criteria")));
assert!(problems.iter().any(|p| p == plan::SHORT_DRAFT));
let err = file_draft(&mut chat, &chats, &queue, 0)
.expect_err("file_draft must refuse it too")
.to_string();
for p in &problems {
assert!(err.contains(p.as_str()), "`{p}` missing from `{err}`");
}
assert_eq!(chat.status, ChatStatus::Open);
assert!(chat.task.is_none());
assert!(queue.list().is_empty());
}
#[test]
fn file_draft_queues_a_good_draft_and_records_the_task() {
let (tmp, chats) = store();
let queue = Queue::at(tmp.path().join("queue"));
let mut chat = Chat {
schema: SCHEMA,
id: "20260903-014455-cd34".to_owned(),
repo: tmp.path().to_owned(),
from: None,
agent: "mock".to_owned(),
status: ChatStatus::Open,
turns: Vec::new(),
draft: Some(good_draft()),
task: None,
created_at: Timestamp::now(),
updated_at: Timestamp::now(),
seat: SeatState::new(SEAT, "mock", 7),
};
chats.put(&mut chat).expect("put");
let id = file_draft(&mut chat, &chats, &queue, 5).expect("file");
assert_eq!(chat.status, ChatStatus::Filed);
assert_eq!(chat.task.as_deref(), Some(id.as_str()));
assert_eq!(
chats.get(&chat.id).expect("get").task.as_deref(),
Some(id.as_str()),
"the task id must survive on disk, or the phone shows an unfiled chat"
);
let task = queue.get(&id).expect("queued task");
assert_eq!(task.title, queue::title_from(&good_draft(), 72));
assert_eq!(task.instruction, good_draft());
assert_eq!(task.priority, 5);
assert_eq!(task.source, Source::Human);
}
#[test]
fn abandon_moves_an_open_chat_to_abandoned() {
let (tmp, chats) = store();
let mut chat = Chat {
schema: SCHEMA,
id: "20260903-014455-ab12".to_owned(),
repo: tmp.path().to_owned(),
from: None,
agent: "mock".to_owned(),
status: ChatStatus::Open,
turns: Vec::new(),
draft: None,
task: None,
created_at: Timestamp::now(),
updated_at: Timestamp::now(),
seat: SeatState::new(SEAT, "mock", 7),
};
chats.put(&mut chat).expect("put");
abandon(&mut chat, &chats).expect("abandon");
assert_eq!(chat.status, ChatStatus::Abandoned);
assert_eq!(
chats.get(&chat.id).expect("get").status,
ChatStatus::Abandoned
);
}
#[test]
fn abandoning_an_already_abandoned_chat_is_not_an_error() {
let (tmp, chats) = store();
let mut chat = Chat {
schema: SCHEMA,
id: "20260903-014455-ab13".to_owned(),
repo: tmp.path().to_owned(),
from: None,
agent: "mock".to_owned(),
status: ChatStatus::Abandoned,
turns: Vec::new(),
draft: None,
task: None,
created_at: Timestamp::now(),
updated_at: Timestamp::now(),
seat: SeatState::new(SEAT, "mock", 7),
};
chats.put(&mut chat).expect("put");
abandon(&mut chat, &chats).expect("abandoning twice is not an error");
assert_eq!(chat.status, ChatStatus::Abandoned);
assert_eq!(
chats.get(&chat.id).expect("get").status,
ChatStatus::Abandoned
);
}
#[test]
fn abandon_refuses_a_filed_chat_and_leaves_it_filed() {
let (tmp, chats) = store();
let mut chat = Chat {
schema: SCHEMA,
id: "20260903-014455-ab14".to_owned(),
repo: tmp.path().to_owned(),
from: None,
agent: "mock".to_owned(),
status: ChatStatus::Filed,
turns: Vec::new(),
draft: None,
task: Some("some-task-id".to_owned()),
created_at: Timestamp::now(),
updated_at: Timestamp::now(),
seat: SeatState::new(SEAT, "mock", 7),
};
chats.put(&mut chat).expect("put");
let err = abandon(&mut chat, &chats).expect_err("a filed chat refuses abandon");
assert!(err.to_string().contains("filed"), "{err}");
assert_eq!(
chats.get(&chat.id).expect("get").status,
ChatStatus::Filed,
"a refused abandon must not touch the on-disk status"
);
}
#[tokio::test]
async fn an_abandon_that_lands_while_a_turn_is_in_flight_is_not_undone_by_the_reply() {
let (tmp, chats) = store();
let spec = mock_agent(tmp.path(), REPLY, env("here you go"));
let cfg = config(spec);
let mut in_flight = open(
&chats,
&cfg,
tmp.path().to_owned(),
"add durations",
None,
None,
)
.expect("open");
let mut abandoned_elsewhere = chats.get(&in_flight.id).expect("reread");
abandon(&mut abandoned_elsewhere, &chats).expect("abandon");
assert_eq!(
chats.get(&in_flight.id).expect("reread").status,
ChatStatus::Abandoned,
"the abandon landed on disk before the turn finished"
);
assert_eq!(in_flight.status, ChatStatus::Open);
first_turn(&mut in_flight, &chats, &cfg, None)
.await
.expect("the turn itself still completes");
let on_disk = chats.get(&in_flight.id).expect("reread");
assert_eq!(
on_disk.status,
ChatStatus::Abandoned,
"an abandon must stick even when a turn that started before it finishes after it"
);
assert!(
on_disk.turns.iter().any(|t| t.body == "here you go"),
"the in-flight turn's own reply is still recorded: {:?}",
on_disk.turns
);
}
#[test]
fn abandon_blocks_on_the_shared_guard_rather_than_interleaving_with_a_racing_writer() {
let (tmp, chats) = store();
let queue = Queue::at(tmp.path().join("queue"));
let mut chat = Chat {
schema: SCHEMA,
id: "20260903-014455-ee15".to_owned(),
repo: tmp.path().to_owned(),
from: None,
agent: "mock".to_owned(),
status: ChatStatus::Open,
turns: Vec::new(),
draft: Some(good_draft()),
task: None,
created_at: Timestamp::now(),
updated_at: Timestamp::now(),
seat: SeatState::new(SEAT, "mock", 7),
};
chats.put(&mut chat).expect("put");
let held = chats.guard();
let chats2 = chats.clone();
let id = chat.id.clone();
let abandoning = std::thread::spawn(move || {
let mut chat = chats2.get(&id).expect("get");
abandon(&mut chat, &chats2).expect("abandon");
});
std::thread::sleep(Duration::from_millis(50));
assert!(
!abandoning.is_finished(),
"abandon must wait for the guard, not read and write while it is held - \
a re-read alone narrows this window without closing it"
);
drop(held);
abandoning.join().expect("abandon thread panicked");
assert_eq!(
chats.get(&chat.id).expect("reread").status,
ChatStatus::Abandoned,
"once the guard is free, abandon still lands"
);
assert!(queue.list().is_empty(), "file_draft never ran in this test");
}
#[test]
fn abandon_is_refused_while_another_process_holds_the_chats_claim() {
let (tmp, chats) = store();
let mut chat = Chat {
schema: SCHEMA,
id: "20260903-014455-ee16".to_owned(),
repo: tmp.path().to_owned(),
from: None,
agent: "mock".to_owned(),
status: ChatStatus::Open,
turns: Vec::new(),
draft: None,
task: None,
created_at: Timestamp::now(),
updated_at: Timestamp::now(),
seat: SeatState::new(SEAT, "mock", 7),
};
chats.put(&mut chat).expect("put");
let held = chats.claim(&chat.id).expect("claim");
let err = abandon(&mut chat, &chats).expect_err("a claimed chat refuses abandon");
assert!(err.to_string().contains("claimed"), "{err}");
assert_eq!(
chats.get(&chat.id).expect("reread").status,
ChatStatus::Open,
"a refused abandon must not touch the on-disk status"
);
drop(held);
abandon(&mut chat, &chats).expect("abandon succeeds once the claim is released");
assert_eq!(chat.status, ChatStatus::Abandoned);
}
#[tokio::test]
async fn say_appends_the_operator_turn_then_the_agent_turn() {
let (tmp, chats) = store();
let spec = mock_agent(tmp.path(), REPLY, env("which module?"));
let cfg = config(spec);
let mut chat = start(
&chats,
&cfg,
tmp.path().to_owned(),
"add durations",
None,
None,
)
.await
.expect("start");
assert_eq!(chat.turns.len(), 2);
assert_eq!(chat.turns[0].who, Who::Operator);
assert_eq!(chat.turns[1].who, Who::Agent);
say(&mut chat, &chats, &cfg, "the report module", Vec::new())
.await
.expect("say");
assert_eq!(chat.turns.len(), 4);
assert_eq!(chat.turns[2].who, Who::Operator);
assert_eq!(chat.turns[2].body, "the report module");
assert_eq!(chat.turns[3].who, Who::Agent);
assert_eq!(chat.turns[3].body, "which module?");
assert_eq!(chats.get(&chat.id).expect("get").turns, chat.turns);
}
#[tokio::test]
async fn a_failed_turn_keeps_the_operator_message_and_says_what_happened() {
let (tmp, chats) = store();
let good = mock_agent(tmp.path(), REPLY, env("which module?"));
let cfg = config(good);
let mut chat = start(
&chats,
&cfg,
tmp.path().to_owned(),
"add durations",
None,
None,
)
.await
.expect("start");
mock_agent(tmp.path(), BROKEN, BTreeMap::new());
let err = say(&mut chat, &chats, &cfg, "the report module", Vec::new())
.await
.expect_err("a turn with no answer is an error");
assert!(err.to_string().contains("no answer"), "{err}");
let on_disk = chats.get(&chat.id).expect("get");
assert_eq!(on_disk.turns.len(), 4);
assert_eq!(
on_disk.turns[2].body, "the report module",
"the operator's message must survive the failure"
);
let note = &on_disk.turns[3];
assert_eq!(note.who, Who::Agent);
assert!(
note.body.starts_with(MAGI_NOTE),
"the failure must be visible in the transcript: {}",
note.body
);
assert!(note.body.contains("your message is saved"));
}
#[tokio::test]
async fn attachments_reach_the_prompt_and_an_empty_body_is_still_a_turn() {
let (tmp, chats) = store();
let spec = mock_agent(tmp.path(), REPLY, env("first reply"));
let cfg = config(spec);
let mut chat = start(
&chats,
&cfg,
tmp.path().to_owned(),
"add durations",
None,
None,
)
.await
.expect("start");
let att = chats
.put_attachment(
&chat.id,
"image/png",
"screenshot.png",
b"pretend-png-bytes",
)
.expect("put attachment");
mock_agent(tmp.path(), ECHO, BTreeMap::new());
say(&mut chat, &chats, &cfg, "", vec![att.clone()])
.await
.expect("an empty body with an attachment is still a turn");
let operator_turn = &chat.turns[chat.turns.len() - 2];
assert_eq!(operator_turn.who, Who::Operator);
assert_eq!(operator_turn.body, "");
assert_eq!(operator_turn.attachments, vec![att.clone()]);
let prompt = &chat.turns.last().expect("agent reply").body;
let expected_path = chats
.attachments_dir(&chat.id)
.join(format!("{}.png", att.id));
assert!(
prompt.contains(&expected_path.display().to_string()),
"the agent must be told the attachment's absolute path: {prompt}"
);
assert!(prompt.contains("image/png"), "and its mime: {prompt}");
}
#[test]
fn attachment_path_is_absolute_even_when_the_store_root_is_relative() {
let chats = Chats::at(PathBuf::from("relative-chats-root-for-this-test"));
let att = Attachment {
id: "0".repeat(32),
name: "shot.png".to_owned(),
mime: "image/png".to_owned(),
bytes: 3,
};
let path = chats
.attachment_path("some-chat-id", &att)
.expect("a supported mime always yields a path");
assert!(
path.is_absolute(),
"must be absolute even off a relative store root: {}",
path.display()
);
}
#[tokio::test]
async fn a_turn_past_the_configured_chat_timeout_is_reported_with_that_timeout() {
let (tmp, chats) = store();
let slow = mock_agent(
tmp.path(),
"#!/bin/sh\ncat >/dev/null\nsleep 2\n",
BTreeMap::new(),
);
let mut cfg = config(slow);
cfg.graph.timeout_chat = 1;
let err = start(
&chats,
&cfg,
tmp.path().to_owned(),
"add durations",
None,
None,
)
.await
.expect_err("a turn that never answers is an error");
assert!(
err.to_string().contains("did not answer within 1s"),
"{err}"
);
let on_disk = chats.list();
let chat = &on_disk[0];
let note = chat.turns.last().expect("a note turn was recorded");
assert!(
note.body.contains("did not answer within 1s"),
"the transcript must show the configured timeout: {}",
note.body
);
}
#[test]
fn list_puts_open_chats_before_filed_ones() {
let (tmp, chats) = store();
let make = |id: &str, status: ChatStatus| {
let mut c = Chat {
schema: SCHEMA,
id: id.to_owned(),
repo: tmp.path().to_owned(),
from: None,
agent: "mock".to_owned(),
status,
turns: Vec::new(),
draft: None,
task: None,
created_at: Timestamp::now(),
updated_at: Timestamp::now(),
seat: SeatState::new(SEAT, "mock", 7),
};
chats.put(&mut c).expect("put");
};
make("20260901-000000-0001", ChatStatus::Open);
make("20260902-000000-0002", ChatStatus::Open);
make("20260903-000000-0003", ChatStatus::Filed);
let ids: Vec<String> = chats.list().into_iter().map(|c| c.id).collect();
assert_eq!(
ids,
[
"20260902-000000-0002",
"20260901-000000-0001",
"20260903-000000-0003"
]
);
assert_eq!(chats.count_open(), 2);
}
}