Skip to main content

magi/
chat.rs

1//! The browser interview: `magi plan` for somebody holding a phone.
2//!
3//! [`crate::plan`] is an interview that works by *handing over the terminal* -
4//! stdin, stdout and stderr inherited, the agent's own UI in front of the
5//! operator, no timeout. That is the right design and it is not changing. It is
6//! also unavailable to the operator who is away from the machine, which is most
7//! of the time this repository's operator wants to plan something: there is no
8//! terminal in a browser to hand over.
9//!
10//! So this module is the same interview, arrived at from the other side. magi
11//! does host the conversation here, because there is nothing else that can:
12//! each operator message is one *headless* [`crate::agent::invoke`], and the
13//! transcript lives in a JSON file the phone reads. The end state is identical
14//! to `magi plan`'s - a task file checked by [`plan::review_draft`] and filed
15//! in [`crate::queue`] - which is deliberate. Two planning paths that accept
16//! different task files would be two products.
17//!
18//! # A turn is cheap because the CLI remembers
19//!
20//! The thing that makes a turn-per-request affordable is [`SeatState`]: a
21//! second [`crate::agent::invoke`] with the same seat resumes the CLI's own
22//! conversation (`claude --resume`, `opencode run -s`, `agy --conversation`),
23//! so a turn sends the operator's new sentence and nothing else. The model
24//! already has the repository it read and the questions it asked. magi does
25//! *not* re-send the transcript when the CLI can resume - that would pay for
26//! the whole conversation again on every message, and it would let magi's idea
27//! of the history drift from the model's. [`transcript`] exists only for the
28//! case where resuming is genuinely impossible, and [`turn`] says when.
29//!
30//! # Shape
31//!
32//! The same split as [`crate::queue`] and [`crate::ask`]: [`Chat`] is data plus
33//! pure helpers, [`Chats`] owns all I/O and is constructed with its root, so
34//! every test below drives a real store in a temp directory and none of them
35//! touch the operator's home. One conversation is one JSON file, written
36//! atomically, because `magi web` and a future `magi chat` are separate
37//! processes and a rename is the only cross-process atomic write that needs no
38//! coordination between them.
39
40use std::path::{Path, PathBuf};
41use std::time::Duration;
42
43use anyhow::{Context, Result, bail};
44use jiff::Timestamp;
45use serde::{Deserialize, Serialize};
46
47use crate::agent::{self, Invocation, SeatState};
48use crate::config::Config;
49use crate::plan;
50use crate::queue::{self, Queue, Source, Task};
51
52/// On-disk format for a conversation. Bumped when a field's meaning changes.
53///
54/// The web UI is written against this shape by hand, so a field that changes
55/// meaning without a bump here is a front end that lies silently.
56pub const SCHEMA: u32 = 1;
57
58/// Wall-clock limit for one agent turn.
59///
60/// Five minutes, and the number is borrowed rather than invented: it is `agy`'s
61/// own default `--print-timeout`, the one place a CLI vendor has published an
62/// opinion about how long a single non-interactive answer should take. It fits
63/// what a turn actually is - read a few files, ask one question - and it is far
64/// below an implementation node's budget, which is correct: nobody is watching
65/// an implementer, whereas here an operator is holding a phone with a spinner
66/// on it. A turn that has not answered in five minutes is a wedged CLI, not a
67/// thinking one, and the operator needs to be told that while they are still
68/// looking at the screen.
69const TURN_TIMEOUT: Duration = Duration::from_secs(300);
70
71/// Seat name for the interviewing agent.
72///
73/// One seat per conversation, so the CLI-side conversation is scoped to this
74/// chat and nothing else - the same rule [`crate::agent`] applies to judges.
75const SEAT: &str = "plan";
76
77/// Prefix on an agent turn that magi wrote rather than an agent.
78///
79/// A failed turn has to be *visible*, and the transcript is the only surface
80/// the phone renders, so the failure goes in as an agent turn carrying this
81/// marker. Two turn authors is what the wire shape allows (`operator` /
82/// `agent`), and inventing a third would break every client written against
83/// it; a stable prefix the UI can key on costs nothing and loses no
84/// information.
85pub const MAGI_NOTE: &str = "magi: ";
86
87/// Who said something.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum Who {
91    /// The person magi is planning for.
92    Operator,
93    /// The interviewing agent - or magi itself, reporting that the agent
94    /// failed. See [`MAGI_NOTE`].
95    Agent,
96}
97
98/// One message in the conversation.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct Turn {
102    /// Who wrote it.
103    pub who: Who,
104    /// What they said.
105    pub body: String,
106    /// When it was said.
107    pub at: Timestamp,
108}
109
110/// Where a conversation is in its life.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "lowercase")]
113pub enum ChatStatus {
114    /// Still being talked through.
115    Open,
116    /// A task was filed from its draft.
117    Filed,
118    /// Given up on. Kept on disk, because an abandoned interview is still the
119    /// record of a decision the operator made.
120    Abandoned,
121}
122
123impl ChatStatus {
124    /// Is this conversation still live?
125    pub fn open(self) -> bool {
126        matches!(self, Self::Open)
127    }
128
129    /// Wire form, for the phone and for logs.
130    pub fn as_str(self) -> &'static str {
131        match self {
132            Self::Open => "open",
133            Self::Filed => "filed",
134            Self::Abandoned => "abandoned",
135        }
136    }
137}
138
139/// One planning conversation.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct Chat {
143    /// On-disk format version.
144    pub schema: u32,
145    /// Conversation id, e.g. `20260903-014455-ab12`.
146    pub id: String,
147    /// Repository the task will be filed against.
148    pub repo: PathBuf,
149    /// The chat this one was derived from, when it began as a fork into a
150    /// different repository. See [`derived_background`]. `#[serde(default)]`
151    /// so a conversation recorded before this field existed still reads.
152    #[serde(default)]
153    pub from: Option<String>,
154    /// Roster agent id doing the interviewing.
155    pub agent: String,
156    /// Current state.
157    pub status: ChatStatus,
158    /// Everything said, oldest first.
159    pub turns: Vec<Turn>,
160    /// The task file, once the agent has written one.
161    pub draft: Option<String>,
162    /// Queue task id, once filed.
163    pub task: Option<String>,
164    /// When the conversation was opened.
165    pub created_at: Timestamp,
166    /// Last change to this file.
167    pub updated_at: Timestamp,
168    /// The CLI-side conversation, which is what makes turn N+1 cost one
169    /// sentence instead of the whole transcript.
170    ///
171    /// Not `pub`: it is magi's bookkeeping, not part of the interview, and a
172    /// caller that edited it would silently detach the record from the
173    /// conversation the model is actually holding. It is still serialized,
174    /// because a chat that survives a restart without its session id resumes
175    /// nothing.
176    seat: SeatState,
177}
178
179impl Chat {
180    /// Short form used in lists and notifications, matching a run's short id.
181    pub fn short(&self) -> &str {
182        short(&self.id)
183    }
184
185    /// How many turns the interviewing agent has actually taken.
186    ///
187    /// Read off the seat rather than counted from [`Chat::turns`], because a
188    /// failed turn appends a [`MAGI_NOTE`] message that no agent wrote. The
189    /// number names artifacts, so it has to match what was invoked.
190    pub fn agent_turns(&self) -> usize {
191        self.seat.turns
192    }
193}
194
195/// A conversation store on disk.
196#[derive(Debug, Clone)]
197pub struct Chats {
198    root: PathBuf,
199}
200
201impl Chats {
202    /// The operator's conversations, `<home>/chats`.
203    pub fn open() -> Self {
204        Self::at(crate::run::home().join("chats"))
205    }
206
207    /// A store at an explicit root. Tests use this, which is why none of them
208    /// need the operator's real home.
209    pub fn at(root: PathBuf) -> Self {
210        Self { root }
211    }
212
213    /// Directory holding the conversation files.
214    pub fn root(&self) -> &Path {
215        &self.root
216    }
217
218    /// Path for one conversation id.
219    pub fn path_of(&self, id: &str) -> PathBuf {
220        self.root.join(format!("{id}.json"))
221    }
222
223    /// Where one conversation's prompts and CLI output are kept.
224    ///
225    /// Beside the record rather than inside it, with the same stem convention a
226    /// run's nodes use, so a conversation that went wrong can be read back
227    /// turn by turn - which is the only way to tell "the agent said nothing"
228    /// apart from "magi never asked it".
229    pub fn artifacts_of(&self, id: &str) -> PathBuf {
230        self.root.join(format!("{id}.artifacts"))
231    }
232
233    /// Write a conversation, atomically, so a process killed mid-write leaves
234    /// the previous state readable rather than a truncated file that would lose
235    /// the whole interview.
236    pub fn put(&self, c: &mut Chat) -> Result<()> {
237        std::fs::create_dir_all(&self.root)
238            .with_context(|| format!("create {}", self.root.display()))?;
239        c.updated_at = Timestamp::now();
240        let body = serde_json::to_string_pretty(c).context("serialize chat")?;
241        let path = self.path_of(&c.id);
242        let tmp = path.with_extension("json.tmp");
243        std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
244        std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
245        Ok(())
246    }
247
248    /// Load a conversation by id or unambiguous id prefix.
249    pub fn get(&self, id: &str) -> Result<Chat> {
250        let resolved = self.resolve_id(id)?;
251        read_path(&self.path_of(&resolved))
252    }
253
254    /// Every conversation on disk: open first, then newest first.
255    ///
256    /// Open first because that ordering is the product - the list exists to
257    /// show the operator what is still being talked through, and a filed
258    /// interview is history underneath it. Unreadable files are skipped rather
259    /// than fatal: one corrupt record must not take the web UI down, and must
260    /// certainly not hide the open conversation the operator came back for.
261    pub fn list(&self) -> Vec<Chat> {
262        let mut all: Vec<Chat> = std::fs::read_dir(&self.root)
263            .into_iter()
264            .flatten()
265            .flatten()
266            .map(|e| e.path())
267            .filter(|p| p.extension().is_some_and(|x| x == "json"))
268            .filter_map(|p| read_path(&p).ok())
269            .collect();
270        all.sort_unstable_by(|a, b| {
271            let rank = |c: &Chat| u8::from(!c.status.open());
272            rank(a).cmp(&rank(b)).then_with(|| b.id.cmp(&a.id))
273        });
274        all
275    }
276
277    /// Expand an id prefix to exactly one conversation id. The short id the
278    /// phone shows is a suffix, so that is accepted too.
279    pub fn resolve_id(&self, prefix: &str) -> Result<String> {
280        if self.path_of(prefix).is_file() {
281            return Ok(prefix.to_owned());
282        }
283        let hits: Vec<String> = self
284            .list()
285            .into_iter()
286            .map(|c| c.id)
287            .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
288            .collect();
289        match hits.len() {
290            1 => Ok(hits.into_iter().next().expect("exactly one hit")),
291            0 => bail!("no chat matches `{prefix}`"),
292            _ => bail!(
293                "`{prefix}` matches {} chats: {}",
294                hits.len(),
295                hits.join(", ")
296            ),
297        }
298    }
299
300    /// Newest modification time in the store, in milliseconds, for change
301    /// detection. The web UI compares this instead of re-reading every
302    /// conversation, so an idle phone on a slow link costs one `stat` per file.
303    pub fn revision(&self) -> u64 {
304        std::fs::read_dir(&self.root)
305            .into_iter()
306            .flatten()
307            .flatten()
308            .filter_map(|e| e.metadata().ok())
309            .filter_map(|m| m.modified().ok())
310            .filter_map(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
311            .map(|d| d.as_millis() as u64)
312            .max()
313            .unwrap_or(0)
314    }
315
316    /// How many conversations are still open. The badge on the phone.
317    pub fn count_open(&self) -> usize {
318        self.list().iter().filter(|c| c.status.open()).count()
319    }
320}
321
322/// Open a conversation and take the first agent turn.
323///
324/// The record is written to disk *before* the agent is invoked, so an agent
325/// that fails on the very first turn still leaves the operator a conversation
326/// they can look at, retry into, or abandon - rather than nothing at all.
327///
328/// `agent` is resolved by [`plan::pick`], the same policy `magi plan` uses: an
329/// explicit id wins and is an error rather than a fallback when it is not
330/// runnable, otherwise a `claude` seat, otherwise the first runnable agent in
331/// roster order. Called rather than copied, because two copies of a preference
332/// order drift and the copy that drifts is the one nobody reads.
333///
334/// `from` is the conversation this one was derived from, when the operator
335/// asked to continue an existing interview in a different repository (see
336/// [`derived_background`]). It is read, never written: the source chat's
337/// `status`, `turns` and `draft` are left exactly as they were.
338pub async fn start(
339    store: &Chats,
340    cfg: &Config,
341    repo: PathBuf,
342    idea: &str,
343    agent: Option<&str>,
344    from: Option<&Chat>,
345) -> Result<Chat> {
346    let idea = idea.trim();
347    if idea.is_empty() {
348        bail!("an interview needs something to start from: say what you want to change");
349    }
350    // Absolute, because the daemon that eventually runs the filed task has its
351    // own working directory and a relative path would mean the wrong
352    // repository.
353    let repo = repo.canonicalize().unwrap_or(repo);
354    // The API's `agent` beats the config, the config beats the built-in order.
355    // On a phone there is no flag to pass, so `[roles] planner` is the only
356    // way an operator states who they want to be interviewed by.
357    let want = agent.or(cfg.roles.planner.as_deref());
358    let spec = plan::pick(&cfg.agents, want, &plan::installed)?;
359
360    let now = Timestamp::now();
361    let id = new_id();
362    let mut chat = Chat {
363        schema: SCHEMA,
364        id,
365        repo,
366        from: from.map(|c| c.id.clone()),
367        agent: spec.id.clone(),
368        status: ChatStatus::Open,
369        turns: vec![Turn {
370            who: Who::Operator,
371            body: idea.to_owned(),
372            at: now,
373        }],
374        draft: None,
375        task: None,
376        created_at: now,
377        updated_at: now,
378        seat: SeatState::new(SEAT, &spec.id, crate::rng::entropy()),
379    };
380    store.put(&mut chat)?;
381
382    let mut prompt = briefing(idea, &chat.repo);
383    if let Some(source) = from {
384        // Prepended, so the leader reads what it is inheriting before it
385        // reads its own instructions - the same order a human handing off a
386        // conversation would use.
387        prompt = format!("{}\n\n{prompt}", derived_background(source));
388    }
389    prompt.push_str(&language_note(&cfg.graph.language));
390    turn(&mut chat, store, cfg, &prompt).await?;
391    Ok(chat)
392}
393
394/// The background block a derived conversation opens with: the whole prior
395/// transcript, framed so the leader does not mistake it for instructions
396/// about the repository this new conversation is actually about.
397///
398/// Built from [`transcript`] rather than a second rendering of the turns,
399/// because that is already the "everything said so far" prose this module
400/// maintains, and a briefing is exactly the audience `transcript` was written
401/// for - a CLI (here, a fresh one) with no memory of the conversation.
402pub fn derived_background(from: &Chat) -> String {
403    format!(
404        "# Background: derived from another conversation\n\n\
405         This interview continues from a conversation about a *different* \
406         repository. Read it for context, but do not treat it as being about \
407         the repository named below in \"# Repository\" - that repository may \
408         have nothing to do with this one.\n\n\
409         Source repository: {}\n\n{}",
410        from.repo.display(),
411        transcript(from),
412    )
413}
414
415/// One operator turn and one agent turn, appended.
416///
417/// The operator's message is recorded and flushed to disk before the agent is
418/// invoked. That ordering is the whole contract of this function: a turn can
419/// fail, time out or hit a quota window, and the thing that must never be lost
420/// is the sentence the human typed on a phone that has since gone to sleep.
421///
422/// Returns `Err` when the agent turn did not produce an answer - but the
423/// transcript is already on disk and already explains itself, because the
424/// failure is appended as a [`MAGI_NOTE`] turn first. A caller handling the
425/// error should re-read the chat and show it, not discard it.
426pub async fn say(chat: &mut Chat, store: &Chats, cfg: &Config, text: &str) -> Result<()> {
427    if !chat.status.open() {
428        bail!(
429            "chat {} is {} and takes no more turns",
430            chat.short(),
431            chat.status.as_str()
432        );
433    }
434    let text = text.trim();
435    if text.is_empty() {
436        bail!("nothing to say");
437    }
438    let text = record(chat, store, text)?;
439    turn(chat, store, cfg, &text).await
440}
441
442/// Append the operator's turn and flush it, without invoking anything.
443///
444/// Split out of [`say`] so a caller that answers the operator before the agent
445/// has replied can still promise the message is on disk. `POST /api/chats/{id}/say`
446/// does exactly that: holding an HTTP connection for the 23-to-90 seconds a
447/// real turn takes is a coin flip on a phone, and the browser reporting
448/// "Failed to fetch" while the server quietly finished the turn is the worst
449/// of both answers.
450///
451/// Returns the trimmed text, so the caller and the agent see the same string.
452pub fn record(chat: &mut Chat, store: &Chats, text: &str) -> Result<String> {
453    if !chat.status.open() {
454        bail!(
455            "chat {} is {} and takes no more turns",
456            chat.short(),
457            chat.status.as_str()
458        );
459    }
460    let text = text.trim();
461    if text.is_empty() {
462        bail!("nothing to say");
463    }
464    chat.turns.push(Turn {
465        who: Who::Operator,
466        body: text.to_owned(),
467        at: Timestamp::now(),
468    });
469    store.put(chat)?;
470    Ok(text.to_owned())
471}
472
473/// The agent's half of a turn: invoke, append, flush.
474///
475/// Pairs with [`record`]. `text` is the operator's message that this reply
476/// answers - the same string `record` returned, so the transcript and the
477/// prompt cannot disagree.
478pub async fn respond(chat: &mut Chat, store: &Chats, cfg: &Config, text: &str) -> Result<()> {
479    turn(chat, store, cfg, text).await
480}
481
482/// Invoke the interviewing agent once and append what it said.
483///
484/// `prompt` is only the new material. Whether that is enough depends on the
485/// CLI: [`agent::has_session`] answers honestly - it is `false` when sessions
486/// are switched off, before the first turn, or for a CLI that never reported an
487/// id back - and only then is the transcript prepended, because a model with no
488/// memory of the interview would otherwise answer the last sentence in a
489/// vacuum. When the CLI *can* resume, magi sends nothing extra: paying for the
490/// whole conversation on every message is the cost this design exists to avoid,
491/// and a magi-authored replay of history is also a second, divergent version of
492/// it.
493async fn turn(chat: &mut Chat, store: &Chats, cfg: &Config, prompt: &str) -> Result<()> {
494    let spec = cfg
495        .agents
496        .iter()
497        .find(|a| a.id == chat.agent)
498        .with_context(|| {
499            format!(
500                "chat {} was interviewed by agent `{}`, which is no longer in \
501                 the roster; restore it in magi.toml or start a new chat",
502                chat.short(),
503                chat.agent
504            )
505        })?;
506
507    let resuming = agent::has_session(spec.kind, &chat.seat, cfg.graph.sessions);
508    let body = if resuming {
509        prompt.to_owned()
510    } else {
511        format!("{}\n\n{prompt}", transcript(chat))
512    };
513
514    let artifacts = store.artifacts_of(&chat.id);
515    let stem = format!("turn-{}", chat.seat.turns + 1);
516    let inv = Invocation {
517        cwd: &chat.repo,
518        prompt: &body,
519        timeout: TURN_TIMEOUT,
520        // The interviewer writes a task file into its reply, never into the
521        // repository: the competing agents do the implementation, and a
522        // repository the planner has already edited makes their diffs
523        // unjudgeable.
524        allow_write: false,
525        sessions: cfg.graph.sessions,
526        artifacts: &artifacts,
527        stem: &stem,
528        run: &chat.id,
529        node: "chat",
530    };
531
532    let outcome = agent::invoke(spec, &mut chat.seat, &inv).await;
533    let note = |why: String| Turn {
534        who: Who::Agent,
535        body: format!("{MAGI_NOTE}{why}"),
536        at: Timestamp::now(),
537    };
538    let (reply, failure) = match outcome {
539        Err(e) => (
540            note(format!("could not run agent `{}`: {e}", chat.agent)),
541            Some(format!("could not run agent `{}`: {e}", chat.agent)),
542        ),
543        Ok(out) if out.quota_exhausted() => {
544            let reset = out
545                .quota
546                .as_ref()
547                .and_then(|q| q.reset.clone())
548                .map_or_else(String::new, |r| format!(" (resets {r})"));
549            let why = format!(
550                "agent `{}` is out of quota{reset}; your message is saved, so \
551                 say it again when the window reopens",
552                chat.agent
553            );
554            (note(why.clone()), Some(why))
555        }
556        Ok(out) if out.timed_out => {
557            let why = format!(
558                "agent `{}` did not answer within {}s; your message is saved",
559                chat.agent,
560                TURN_TIMEOUT.as_secs()
561            );
562            (note(why.clone()), Some(why))
563        }
564        Ok(out) if !out.usable() => {
565            let why = format!(
566                "agent `{}` produced no answer (exit {}); your message is saved",
567                chat.agent,
568                out.exit_code
569                    .map_or_else(|| "unknown".to_owned(), |c| c.to_string())
570            );
571            (note(why.clone()), Some(why))
572        }
573        Ok(out) => (
574            Turn {
575                who: Who::Agent,
576                body: out.text.trim().to_owned(),
577                at: Timestamp::now(),
578            },
579            None,
580        ),
581    };
582
583    // A reply carrying no fenced draft leaves the existing one alone. The agent
584    // asking one more follow-up question must not erase the task file it
585    // already wrote, which the operator may well be reading at that moment.
586    if let Some(draft) = extract_draft(&reply.body) {
587        chat.draft = Some(draft);
588    }
589    chat.turns.push(reply);
590    store.put(chat)?;
591
592    match failure {
593        Some(why) => bail!("{why}"),
594        None => Ok(()),
595    }
596}
597
598/// Everything said so far, as prose, for a CLI that cannot resume.
599///
600/// Only reached when [`agent::has_session`] says the conversation cannot be
601/// continued on the CLI's side. It is a fallback and not the design: it re-pays
602/// for the history on every turn and it is magi's rendering of the
603/// conversation rather than the model's own.
604fn transcript(chat: &Chat) -> String {
605    let mut out = String::from(
606        "You are mid-interview. This CLI cannot resume its own conversation, \
607         so here is everything said so far; answer only the last message.\n",
608    );
609    for t in &chat.turns {
610        let who = match t.who {
611            Who::Operator => "operator",
612            Who::Agent => "you",
613        };
614        out.push_str(&format!("\n## {who}\n\n{}\n", t.body.trim()));
615    }
616    out
617}
618
619/// Validate the draft with [`plan::review_draft`] and queue it.
620///
621/// Returns the queued task's id. The conversation is left on disk either way:
622/// a refused draft is a conversation to continue, not an error to recover
623/// from, and the operator's next message can ask for the missing section.
624pub fn file_draft(chat: &mut Chat, store: &Chats, queue: &Queue, priority: i32) -> Result<String> {
625    if let Err(problems) = draft_problems(chat) {
626        bail!(
627            "this draft is not fileable yet:\n- {}",
628            problems.join("\n- ")
629        );
630    }
631    let body = chat
632        .draft
633        .clone()
634        .expect("draft_problems accepted a chat with a draft");
635
636    // `title_from` rather than a title the agent was asked to supply
637    // separately: the task file's first line already is the title, and asking
638    // for it twice is how the two come to disagree.
639    let title = queue::title_from(&body, 72);
640    // `Human`, not `Agent`: the agent conducted the interview, but the change
641    // being asked for is the operator's, and "who asked for this" is the
642    // question `source` exists to answer.
643    let mut task = Task::new(title, body, chat.repo.clone(), Source::Human);
644    task.priority = priority;
645    queue.put(&mut task)?;
646
647    chat.task = Some(task.id.clone());
648    chat.status = ChatStatus::Filed;
649    store.put(chat)?;
650    Ok(task.id)
651}
652
653/// Is this conversation's draft fileable, and if not, what is wrong with it?
654///
655/// Every problem is returned, not the first: an operator about to ask the agent
656/// for a fix wants the whole list, and a validator that reveals one defect per
657/// round turns one follow-up message into three.
658///
659/// [`plan::SHORT_DRAFT`] alone does not refuse. Length is a smell, not a
660/// defect, and a genuinely small change deserves a small task file - which is
661/// exactly the judgement `magi plan` makes, so the browser path makes it too.
662/// It is still reported, because a two-line draft is usually an interview that
663/// ended early.
664pub fn draft_problems(chat: &Chat) -> Result<(), Vec<String>> {
665    let Some(body) = chat.draft.as_deref() else {
666        return Err(vec![
667            "this chat has no draft yet: the agent has not written a task file".to_owned(),
668        ]);
669    };
670    match plan::review_draft(body) {
671        Ok(()) => Ok(()),
672        Err(problems) => {
673            if problems.iter().all(|p| p == plan::SHORT_DRAFT) {
674                Ok(())
675            } else {
676                Err(problems)
677            }
678        }
679    }
680}
681
682/// The briefing the agent is opened with.
683///
684/// Pure, so the one property that matters can be asserted without an
685/// interview: it carries [`plan::TASK_FILE_SPEC`] verbatim. The spec and
686/// [`plan::review_draft`] are checked against each other by `plan`'s own tests,
687/// so including it here is what keeps this path from asking for a shape the
688/// validator will refuse - a twenty-message interview rejected for a reason the
689/// operator was never told is the worst outcome this module has.
690///
691/// The output contract is the other half. `magi plan` tells the agent to write
692/// a file, which works because that agent has a terminal and a filesystem the
693/// operator is watching. Here the reply *is* the channel: the task file comes
694/// back inside a fenced block tagged `task`, and [`extract_draft`] is the only
695/// thing that reads it.
696pub fn briefing(idea: &str, repo: &Path) -> String {
697    format!(
698        "You are the planning leader for magi, which runs a blind \
699         multi-agent implementation competition: several agents will implement \
700         the task file you write, in isolated worktrees, unaware of each other, \
701         and judges will rank the results without knowing who wrote what.\n\n\
702         Your job is not to implement anything. It is to interview the operator \
703         until the change is pinned down, and then write one task file.\n\n\
704         The operator is on a phone. Every message you send is read on a small \
705         screen, so keep it short: no preamble, no restating what they just \
706         said.\n\n\
707         # Repository\n\n{repo}\n\n\
708         Read it before you start asking. Questions the code already answers \
709         spend the operator's patience for nothing. Do not modify it: the \
710         competing agents do the implementation, and a repository you have \
711         already edited makes their diffs unjudgeable.\n\n\
712         # The idea\n\n{idea}\n\n\
713         # How to run the interview\n\n\
714         - Ask about what you cannot determine yourself: intent, scope, which \
715         of several defensible designs the operator wants, what must not \
716         change.\n\
717         - Ask about ONE thing per message and wait for the answer. This is a \
718         phone, not a form: a message with five questions in it gets one of \
719         them answered.\n\
720         - Do not produce the task file after one exchange.\n\
721         - Disagree when you have grounds. A leader that agrees with everything \
722         adds nothing to what the operator already typed.\n\
723         - Confirm the plan in your own words and get an explicit yes before \
724         writing.\n\n\
725         # How to deliver the task file\n\n\
726         When the operator agrees the plan is right, put the whole task file in \
727         your reply inside a fenced block tagged `task`, like this:\n\n\
728         ```task\n\
729         # <the task file>\n\
730         ```\n\n\
731         Nothing else goes in that block, and there is exactly one of them per \
732         message. magi extracts it and files it; a task file written to a file \
733         on disk, or pasted without the fence, is one magi cannot see. You may \
734         send a revised version later in the same conversation - the newest \
735         `task` block wins - and while you are still asking questions, send no \
736         `task` block at all.\n\n\
737         magi will refuse a task file with no completion criteria, so those are \
738         not optional.\n\n\
739         # Task file specification\n\n{spec}",
740        repo = repo.display(),
741        spec = plan::TASK_FILE_SPEC,
742    )
743}
744
745/// The interview is the operator talking, so their language matters more here
746/// than in any prompt the graph sends: an agent that answers a Japanese
747/// question in English makes the conversation slower for exactly the person
748/// magi is trying to help.
749fn language_note(language: &str) -> String {
750    if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
751        String::new()
752    } else {
753        format!("\n\nConduct the interview in {language}, and write the task file in {language}.")
754    }
755}
756
757/// Pull the task draft out of an agent reply, if it wrote one.
758///
759/// The *last* fenced `task` block, not the first. A conversation revises: an
760/// agent that rewrites the task file after one more answer sends both versions
761/// over the course of the interview, and within one message it may quote what
762/// it had before changing it. The newest block is the one the operator has been
763/// reading and the one they are about to approve.
764///
765/// Blocks tagged anything else - ```` ```rust ````, ```` ```json ```` - are
766/// ignored, so an agent illustrating its plan with code does not overwrite the
767/// draft with a snippet. An unterminated block is still taken: a reply cut off
768/// mid-draft is worth showing the operator, who can then just ask for it again.
769pub fn extract_draft(reply: &str) -> Option<String> {
770    let mut last: Option<String> = None;
771    let mut open: Option<(usize, Vec<&str>)> = None;
772    for line in reply.lines() {
773        let trimmed = line.trim_start();
774        // Backticks are one byte each, so the count is also the byte offset of
775        // the info string.
776        let ticks = trimmed.chars().take_while(|c| *c == '`').count();
777        match &mut open {
778            Some((width, body)) => {
779                if ticks >= *width && trimmed[ticks..].trim().is_empty() {
780                    last = Some(joined(body));
781                    open = None;
782                } else {
783                    body.push(line);
784                }
785            }
786            None => {
787                if ticks >= 3 && trimmed[ticks..].trim().eq_ignore_ascii_case("task") {
788                    open = Some((ticks, Vec::new()));
789                }
790            }
791        }
792    }
793    if let Some((_, body)) = open {
794        last = Some(joined(&body));
795    }
796    last.filter(|s| !s.trim().is_empty())
797}
798
799/// A fenced block's lines as one document, newline-terminated the way a file
800/// would be, because [`plan::review_draft`] reads it as a task file.
801fn joined(lines: &[&str]) -> String {
802    if lines.is_empty() {
803        return String::new();
804    }
805    let mut out = lines.join("\n");
806    out.push('\n');
807    out
808}
809
810fn read_path(path: &Path) -> Result<Chat> {
811    let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
812    serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))
813}
814
815fn short(id: &str) -> &str {
816    id.split('-').next_back().unwrap_or(id)
817}
818
819fn new_id() -> String {
820    let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
821    let seed = crate::rng::entropy();
822    format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
823}
824
825#[cfg(test)]
826mod tests {
827    use std::collections::BTreeMap;
828
829    use crate::config::{AgentKind, AgentSpec, Graph};
830
831    use super::*;
832
833    /// A store of its own, with no process-global state - which is the point of
834    /// [`Chats::at`], and why these can run in parallel.
835    fn store() -> (tempfile::TempDir, Chats) {
836        let tmp = tempfile::tempdir().expect("tempdir");
837        let chats = Chats::at(tmp.path().join("chats"));
838        (tmp, chats)
839    }
840
841    /// A task file of the shape [`plan::TASK_FILE_SPEC`] describes, long enough
842    /// that length is not one of the problems under test.
843    fn good_draft() -> String {
844        "# Report per-node durations in `magi show`\n\
845         \n\
846         ## Context\n\
847         \n\
848         `magi show` prints a run's nodes but not how long any of them took, so \
849         the operator cannot see which seat is expensive. The data is already \
850         in `run.events`.\n\
851         \n\
852         ## Change\n\
853         \n\
854         Add a duration column to the node table in `src/report.rs`.\n\
855         \n\
856         ## Constraints\n\
857         \n\
858         Do not change the JSON shape of a run record.\n\
859         \n\
860         ## Completion criteria\n\
861         \n\
862         - [ ] `magi show <run>` prints a duration for every completed node.\n\
863         - [ ] A node with no end event prints nothing rather than zero.\n\
864         \n\
865         ## Out of scope\n\
866         \n\
867         The TUI's detail pane.\n"
868            .to_owned()
869    }
870
871    /// A `kind = "command"` agent whose whole behaviour is a POSIX shell
872    /// script. No test in this module may spawn a real agent CLI: they are the
873    /// operator's paid subscriptions, they reach the network, and they are not
874    /// installed on CI.
875    fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
876        let path = dir.join("mock-chat-agent.sh");
877        std::fs::write(&path, script).expect("write mock");
878        AgentSpec {
879            id: "mock".to_owned(),
880            kind: AgentKind::Command,
881            model: None,
882            command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
883            extra_args: Vec::new(),
884            env,
885            prompt_delivery: None,
886        }
887    }
888
889    /// A config whose only agent is `spec`, with the graph left at its
890    /// defaults except for the language, so `language_note` stays out of the
891    /// prompt assertions.
892    fn config(spec: AgentSpec) -> Config {
893        Config {
894            agents: vec![spec],
895            graph: Graph {
896                language: "en".to_owned(),
897                ..Graph::default()
898            },
899            ..Config::default()
900        }
901    }
902
903    /// Echo a canned reply, ignoring the prompt on stdin.
904    const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
905
906    /// Say nothing and fail, the way a CLI that cannot start does.
907    const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
908
909    /// Reply with the prompt it was given, so a test can inspect exactly what
910    /// the leader received on stdin.
911    const ECHO: &str = "#!/bin/sh\ncat\n";
912
913    fn env(reply: &str) -> BTreeMap<String, String> {
914        BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
915    }
916
917    #[test]
918    fn the_frozen_json_field_names_round_trip_through_disk() {
919        let (tmp, chats) = store();
920        let mut chat = Chat {
921            schema: SCHEMA,
922            id: "20260903-014455-ab12".to_owned(),
923            repo: tmp.path().to_owned(),
924            from: None,
925            agent: "sonnet".to_owned(),
926            status: ChatStatus::Open,
927            turns: vec![Turn {
928                who: Who::Operator,
929                body: "rework the config loader".to_owned(),
930                at: Timestamp::now(),
931            }],
932            draft: None,
933            task: None,
934            created_at: Timestamp::now(),
935            updated_at: Timestamp::now(),
936            seat: SeatState::new(SEAT, "sonnet", 7),
937        };
938        chats.put(&mut chat).expect("put");
939
940        // Asserted literally, against the text on disk. The web UI is written
941        // against these names by hand, so a rename that only round-trips
942        // through serde would break the phone silently.
943        let raw = std::fs::read_to_string(chats.path_of(&chat.id)).expect("read back");
944        let v: serde_json::Value = serde_json::from_str(&raw).expect("parse");
945        for field in [
946            "schema",
947            "id",
948            "repo",
949            "from",
950            "agent",
951            "status",
952            "turns",
953            "draft",
954            "task",
955            "created_at",
956            "updated_at",
957        ] {
958            assert!(v.get(field).is_some(), "missing field `{field}`");
959        }
960        assert_eq!(v["schema"], 1);
961        assert_eq!(v["status"], "open");
962        assert_eq!(v["turns"][0]["who"], "operator");
963        assert_eq!(v["turns"][0]["body"], "rework the config loader");
964        assert!(v["turns"][0].get("at").is_some());
965        assert!(v["draft"].is_null());
966        assert!(v["task"].is_null());
967        assert!(v["from"].is_null());
968
969        let back = chats.get(&chat.id).expect("get");
970        assert_eq!(back.id, chat.id);
971        assert_eq!(back.turns, chat.turns);
972        assert_eq!(back.status, ChatStatus::Open);
973        assert_eq!(back.from, None);
974    }
975
976    /// A conversation recorded before `from` existed must still read: the
977    /// `#[serde(deny_unknown_fields)]` on [`Chat`] would otherwise make this
978    /// field's addition a breaking change for every chat already on disk.
979    #[test]
980    fn a_chat_recorded_without_a_from_field_still_reads() {
981        let (tmp, chats) = store();
982        let path = chats.path_of("20260903-014455-ab12");
983        std::fs::create_dir_all(chats.root()).expect("chats dir");
984        std::fs::write(
985            &path,
986            serde_json::json!({
987                "schema": SCHEMA,
988                "id": "20260903-014455-ab12",
989                "repo": tmp.path(),
990                "agent": "sonnet",
991                "status": "open",
992                "turns": [],
993                "draft": null,
994                "task": null,
995                "created_at": Timestamp::now().to_string(),
996                "updated_at": Timestamp::now().to_string(),
997                "seat": SeatState::new(SEAT, "sonnet", 7),
998            })
999            .to_string(),
1000        )
1001        .expect("write pre-`from` chat");
1002
1003        let chat = chats.get("20260903-014455-ab12").expect("must still read");
1004        assert_eq!(chat.from, None);
1005    }
1006
1007    #[test]
1008    fn derived_background_names_the_source_repository_and_carries_the_transcript() {
1009        let chat = Chat {
1010            schema: SCHEMA,
1011            id: "20260903-014455-ab12".to_owned(),
1012            repo: PathBuf::from("/repo/other"),
1013            from: None,
1014            agent: "sonnet".to_owned(),
1015            status: ChatStatus::Open,
1016            turns: vec![
1017                Turn {
1018                    who: Who::Operator,
1019                    body: "rework the queue drain".to_owned(),
1020                    at: Timestamp::now(),
1021                },
1022                Turn {
1023                    who: Who::Agent,
1024                    body: "which part of the drain?".to_owned(),
1025                    at: Timestamp::now(),
1026                },
1027            ],
1028            draft: None,
1029            task: None,
1030            created_at: Timestamp::now(),
1031            updated_at: Timestamp::now(),
1032            seat: SeatState::new(SEAT, "sonnet", 7),
1033        };
1034        let background = derived_background(&chat);
1035        assert!(background.contains("/repo/other"));
1036        assert!(background.contains("rework the queue drain"));
1037        assert!(background.contains("which part of the drain?"));
1038        assert!(background.contains("different"));
1039    }
1040
1041    #[tokio::test]
1042    async fn starting_a_derived_chat_carries_the_source_transcript_and_leaves_it_untouched() {
1043        let (tmp, chats) = store();
1044        let source_spec = mock_agent(tmp.path(), REPLY, env("which module?"));
1045        let source_cfg = config(source_spec);
1046        let source = start(
1047            &chats,
1048            &source_cfg,
1049            tmp.path().to_owned(),
1050            "rework the queue drain",
1051            None,
1052            None,
1053        )
1054        .await
1055        .expect("start source");
1056        let before = source.clone();
1057
1058        let other_repo = tmp.path().join("other-repo");
1059        std::fs::create_dir_all(&other_repo).expect("other repo dir");
1060        // Overwrites the script `source_spec` pointed at: the source's own
1061        // turn already ran, so only the derived chat's invocation sees this.
1062        let echo_spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
1063        let derived_cfg = config(echo_spec);
1064        let derived = start(
1065            &chats,
1066            &derived_cfg,
1067            other_repo,
1068            "same idea, different repository",
1069            None,
1070            Some(&source),
1071        )
1072        .await
1073        .expect("start derived");
1074
1075        assert_eq!(derived.from.as_deref(), Some(source.id.as_str()));
1076
1077        let prompt = &derived.turns.last().expect("agent reply").body;
1078        assert!(prompt.contains("Background: derived from another conversation"));
1079        assert!(prompt.contains(&source.repo.display().to_string()));
1080        assert!(prompt.contains("rework the queue drain"));
1081        assert!(prompt.contains("same idea, different repository"));
1082
1083        // Deriving a chat must not touch the one it came from.
1084        let reread = chats.get(&source.id).expect("source still on disk");
1085        assert_eq!(reread.status, before.status);
1086        assert_eq!(reread.turns, before.turns);
1087        assert_eq!(reread.draft, before.draft);
1088    }
1089
1090    #[test]
1091    fn extract_draft_takes_the_last_task_block_and_ignores_other_fences() {
1092        let reply = "here is a sketch\n\
1093                     \n\
1094                     ```rust\n\
1095                     fn not_the_draft() {}\n\
1096                     ```\n\
1097                     \n\
1098                     ```task\n\
1099                     # first version\n\
1100                     ```\n\
1101                     \n\
1102                     ```json\n\
1103                     {\"also\": \"not it\"}\n\
1104                     ```\n\
1105                     \n\
1106                     revised:\n\
1107                     \n\
1108                     ```task\n\
1109                     # second version\n\
1110                     ## Completion criteria\n\
1111                     ```\n";
1112        assert_eq!(
1113            extract_draft(reply).as_deref(),
1114            Some("# second version\n## Completion criteria\n")
1115        );
1116    }
1117
1118    #[test]
1119    fn extract_draft_returns_none_when_there_is_no_task_block() {
1120        assert_eq!(extract_draft("which storage backend do you want?"), None);
1121        assert_eq!(extract_draft("```rust\nfn f() {}\n```\n"), None);
1122        // An empty block is not a draft: filing it would produce a task with
1123        // nothing in it.
1124        assert_eq!(extract_draft("```task\n```\n"), None);
1125    }
1126
1127    #[tokio::test]
1128    async fn a_reply_with_no_draft_leaves_the_existing_draft_in_place() {
1129        let (tmp, chats) = store();
1130        let spec = mock_agent(tmp.path(), REPLY, env("one more thing: which module?"));
1131        let cfg = config(spec);
1132        let mut chat = start(
1133            &chats,
1134            &cfg,
1135            tmp.path().to_owned(),
1136            "add durations",
1137            None,
1138            None,
1139        )
1140        .await
1141        .expect("start");
1142        chat.draft = Some(good_draft());
1143        chats.put(&mut chat).expect("put");
1144
1145        say(&mut chat, &chats, &cfg, "the report module")
1146            .await
1147            .expect("say");
1148
1149        assert_eq!(chat.draft.as_deref(), Some(good_draft().as_str()));
1150        assert_eq!(
1151            chats.get(&chat.id).expect("get").draft.as_deref(),
1152            Some(good_draft().as_str())
1153        );
1154    }
1155
1156    #[test]
1157    fn the_briefing_carries_the_task_file_spec_and_the_task_fence() {
1158        let brief = briefing("rework the config loader", Path::new("/repo"));
1159        // The spec verbatim, so the shape asked for cannot drift from the shape
1160        // `plan::review_draft` enforces.
1161        assert!(brief.contains(plan::TASK_FILE_SPEC));
1162        assert!(brief.contains("```task"));
1163        assert!(brief.contains("rework the config loader"));
1164        assert!(brief.contains("/repo"));
1165        assert!(brief.contains("completion criteria"));
1166    }
1167
1168    #[test]
1169    fn file_draft_refuses_a_bad_draft_with_every_problem() {
1170        let (tmp, chats) = store();
1171        let queue = Queue::at(tmp.path().join("queue"));
1172        let mut chat = Chat {
1173            schema: SCHEMA,
1174            id: "20260903-014455-ab12".to_owned(),
1175            repo: tmp.path().to_owned(),
1176            from: None,
1177            agent: "mock".to_owned(),
1178            status: ChatStatus::Open,
1179            turns: Vec::new(),
1180            // Short *and* missing completion criteria: both must be reported,
1181            // or the operator asks for one fix and gets refused again.
1182            draft: Some("# do the thing\n\nsome context.\n".to_owned()),
1183            task: None,
1184            created_at: Timestamp::now(),
1185            updated_at: Timestamp::now(),
1186            seat: SeatState::new(SEAT, "mock", 7),
1187        };
1188
1189        let problems = draft_problems(&chat).expect_err("a draft with no criteria is not fileable");
1190        assert!(
1191            problems.len() >= 2,
1192            "expected every problem, got {problems:?}"
1193        );
1194        assert!(problems.iter().any(|p| p.contains("completion criteria")));
1195        assert!(problems.iter().any(|p| p == plan::SHORT_DRAFT));
1196
1197        let err = file_draft(&mut chat, &chats, &queue, 0)
1198            .expect_err("file_draft must refuse it too")
1199            .to_string();
1200        for p in &problems {
1201            assert!(err.contains(p.as_str()), "`{p}` missing from `{err}`");
1202        }
1203        assert_eq!(chat.status, ChatStatus::Open);
1204        assert!(chat.task.is_none());
1205        assert!(queue.list().is_empty());
1206    }
1207
1208    #[test]
1209    fn file_draft_queues_a_good_draft_and_records_the_task() {
1210        let (tmp, chats) = store();
1211        let queue = Queue::at(tmp.path().join("queue"));
1212        let mut chat = Chat {
1213            schema: SCHEMA,
1214            id: "20260903-014455-cd34".to_owned(),
1215            repo: tmp.path().to_owned(),
1216            from: None,
1217            agent: "mock".to_owned(),
1218            status: ChatStatus::Open,
1219            turns: Vec::new(),
1220            draft: Some(good_draft()),
1221            task: None,
1222            created_at: Timestamp::now(),
1223            updated_at: Timestamp::now(),
1224            seat: SeatState::new(SEAT, "mock", 7),
1225        };
1226
1227        let id = file_draft(&mut chat, &chats, &queue, 5).expect("file");
1228
1229        assert_eq!(chat.status, ChatStatus::Filed);
1230        assert_eq!(chat.task.as_deref(), Some(id.as_str()));
1231        assert_eq!(
1232            chats.get(&chat.id).expect("get").task.as_deref(),
1233            Some(id.as_str()),
1234            "the task id must survive on disk, or the phone shows an unfiled chat"
1235        );
1236
1237        let task = queue.get(&id).expect("queued task");
1238        assert_eq!(task.title, queue::title_from(&good_draft(), 72));
1239        assert_eq!(task.instruction, good_draft());
1240        assert_eq!(task.priority, 5);
1241        assert_eq!(task.source, Source::Human);
1242    }
1243
1244    #[tokio::test]
1245    async fn say_appends_the_operator_turn_then_the_agent_turn() {
1246        let (tmp, chats) = store();
1247        let spec = mock_agent(tmp.path(), REPLY, env("which module?"));
1248        let cfg = config(spec);
1249        let mut chat = start(
1250            &chats,
1251            &cfg,
1252            tmp.path().to_owned(),
1253            "add durations",
1254            None,
1255            None,
1256        )
1257        .await
1258        .expect("start");
1259        // start is one operator turn (the idea) plus one agent turn.
1260        assert_eq!(chat.turns.len(), 2);
1261        assert_eq!(chat.turns[0].who, Who::Operator);
1262        assert_eq!(chat.turns[1].who, Who::Agent);
1263
1264        say(&mut chat, &chats, &cfg, "the report module")
1265            .await
1266            .expect("say");
1267
1268        assert_eq!(chat.turns.len(), 4);
1269        assert_eq!(chat.turns[2].who, Who::Operator);
1270        assert_eq!(chat.turns[2].body, "the report module");
1271        assert_eq!(chat.turns[3].who, Who::Agent);
1272        assert_eq!(chat.turns[3].body, "which module?");
1273        assert_eq!(chats.get(&chat.id).expect("get").turns, chat.turns);
1274    }
1275
1276    #[tokio::test]
1277    async fn a_failed_turn_keeps_the_operator_message_and_says_what_happened() {
1278        let (tmp, chats) = store();
1279        let good = mock_agent(tmp.path(), REPLY, env("which module?"));
1280        let cfg = config(good);
1281        let mut chat = start(
1282            &chats,
1283            &cfg,
1284            tmp.path().to_owned(),
1285            "add durations",
1286            None,
1287            None,
1288        )
1289        .await
1290        .expect("start");
1291
1292        // The chat is bound to roster agent `mock`, so break what `mock`
1293        // actually runs: `mock_agent` rewrites the same script path, which is
1294        // what it looks like when that CLI stops working mid-interview.
1295        mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1296        let err = say(&mut chat, &chats, &cfg, "the report module")
1297            .await
1298            .expect_err("a turn with no answer is an error");
1299        assert!(err.to_string().contains("no answer"), "{err}");
1300
1301        let on_disk = chats.get(&chat.id).expect("get");
1302        assert_eq!(on_disk.turns.len(), 4);
1303        assert_eq!(
1304            on_disk.turns[2].body, "the report module",
1305            "the operator's message must survive the failure"
1306        );
1307        let note = &on_disk.turns[3];
1308        assert_eq!(note.who, Who::Agent);
1309        assert!(
1310            note.body.starts_with(MAGI_NOTE),
1311            "the failure must be visible in the transcript: {}",
1312            note.body
1313        );
1314        assert!(note.body.contains("your message is saved"));
1315    }
1316
1317    #[test]
1318    fn list_puts_open_chats_before_filed_ones() {
1319        let (tmp, chats) = store();
1320        let make = |id: &str, status: ChatStatus| {
1321            let mut c = Chat {
1322                schema: SCHEMA,
1323                id: id.to_owned(),
1324                repo: tmp.path().to_owned(),
1325                from: None,
1326                agent: "mock".to_owned(),
1327                status,
1328                turns: Vec::new(),
1329                draft: None,
1330                task: None,
1331                created_at: Timestamp::now(),
1332                updated_at: Timestamp::now(),
1333                seat: SeatState::new(SEAT, "mock", 7),
1334            };
1335            chats.put(&mut c).expect("put");
1336        };
1337        // The filed one is newest, so ordering by id alone would put it first.
1338        make("20260901-000000-0001", ChatStatus::Open);
1339        make("20260902-000000-0002", ChatStatus::Open);
1340        make("20260903-000000-0003", ChatStatus::Filed);
1341
1342        let ids: Vec<String> = chats.list().into_iter().map(|c| c.id).collect();
1343        assert_eq!(
1344            ids,
1345            [
1346                "20260902-000000-0002",
1347                "20260901-000000-0001",
1348                "20260903-000000-0003"
1349            ]
1350        );
1351        assert_eq!(chats.count_open(), 2);
1352    }
1353}