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 cache_dir = cfg.cache_dir();
517    let inv = Invocation {
518        cwd: &chat.repo,
519        prompt: &body,
520        timeout: TURN_TIMEOUT,
521        // The interviewer writes a task file into its reply, never into the
522        // repository: the competing agents do the implementation, and a
523        // repository the planner has already edited makes their diffs
524        // unjudgeable.
525        allow_write: false,
526        sessions: cfg.graph.sessions,
527        artifacts: &artifacts,
528        stem: &stem,
529        run: &chat.id,
530        node: "chat",
531        cache_dir: cache_dir.as_deref(),
532    };
533
534    let outcome = agent::invoke(spec, &mut chat.seat, &inv).await;
535    let note = |why: String| Turn {
536        who: Who::Agent,
537        body: format!("{MAGI_NOTE}{why}"),
538        at: Timestamp::now(),
539    };
540    let (reply, failure) = match outcome {
541        Err(e) => (
542            note(format!("could not run agent `{}`: {e}", chat.agent)),
543            Some(format!("could not run agent `{}`: {e}", chat.agent)),
544        ),
545        Ok(out) if out.quota_exhausted() => {
546            let reset = out
547                .quota
548                .as_ref()
549                .and_then(|q| q.reset.clone())
550                .map_or_else(String::new, |r| format!(" (resets {r})"));
551            let why = format!(
552                "agent `{}` is out of quota{reset}; your message is saved, so \
553                 say it again when the window reopens",
554                chat.agent
555            );
556            (note(why.clone()), Some(why))
557        }
558        Ok(out) if out.timed_out => {
559            let why = format!(
560                "agent `{}` did not answer within {}s; your message is saved",
561                chat.agent,
562                TURN_TIMEOUT.as_secs()
563            );
564            (note(why.clone()), Some(why))
565        }
566        Ok(out) if !out.usable() => {
567            let why = format!(
568                "agent `{}` produced no answer (exit {}); your message is saved",
569                chat.agent,
570                out.exit_code
571                    .map_or_else(|| "unknown".to_owned(), |c| c.to_string())
572            );
573            (note(why.clone()), Some(why))
574        }
575        Ok(out) => (
576            Turn {
577                who: Who::Agent,
578                body: out.text.trim().to_owned(),
579                at: Timestamp::now(),
580            },
581            None,
582        ),
583    };
584
585    // A reply carrying no fenced draft leaves the existing one alone. The agent
586    // asking one more follow-up question must not erase the task file it
587    // already wrote, which the operator may well be reading at that moment.
588    if let Some(draft) = extract_draft(&reply.body) {
589        chat.draft = Some(draft);
590    }
591    chat.turns.push(reply);
592    store.put(chat)?;
593
594    match failure {
595        Some(why) => bail!("{why}"),
596        None => Ok(()),
597    }
598}
599
600/// Everything said so far, as prose, for a CLI that cannot resume.
601///
602/// Only reached when [`agent::has_session`] says the conversation cannot be
603/// continued on the CLI's side. It is a fallback and not the design: it re-pays
604/// for the history on every turn and it is magi's rendering of the
605/// conversation rather than the model's own.
606fn transcript(chat: &Chat) -> String {
607    let mut out = String::from(
608        "You are mid-interview. This CLI cannot resume its own conversation, \
609         so here is everything said so far; answer only the last message.\n",
610    );
611    for t in &chat.turns {
612        let who = match t.who {
613            Who::Operator => "operator",
614            Who::Agent => "you",
615        };
616        out.push_str(&format!("\n## {who}\n\n{}\n", t.body.trim()));
617    }
618    out
619}
620
621/// Validate the draft with [`plan::review_draft`] and queue it.
622///
623/// Returns the queued task's id. The conversation is left on disk either way:
624/// a refused draft is a conversation to continue, not an error to recover
625/// from, and the operator's next message can ask for the missing section.
626pub fn file_draft(chat: &mut Chat, store: &Chats, queue: &Queue, priority: i32) -> Result<String> {
627    if let Err(problems) = draft_problems(chat) {
628        bail!(
629            "this draft is not fileable yet:\n- {}",
630            problems.join("\n- ")
631        );
632    }
633    let body = chat
634        .draft
635        .clone()
636        .expect("draft_problems accepted a chat with a draft");
637
638    // `title_from` rather than a title the agent was asked to supply
639    // separately: the task file's first line already is the title, and asking
640    // for it twice is how the two come to disagree.
641    let title = queue::title_from(&body, 72);
642    // `Human`, not `Agent`: the agent conducted the interview, but the change
643    // being asked for is the operator's, and "who asked for this" is the
644    // question `source` exists to answer.
645    let mut task = Task::new(title, body, chat.repo.clone(), Source::Human);
646    task.priority = priority;
647    queue.put(&mut task)?;
648
649    chat.task = Some(task.id.clone());
650    chat.status = ChatStatus::Filed;
651    store.put(chat)?;
652    Ok(task.id)
653}
654
655/// Is this conversation's draft fileable, and if not, what is wrong with it?
656///
657/// Every problem is returned, not the first: an operator about to ask the agent
658/// for a fix wants the whole list, and a validator that reveals one defect per
659/// round turns one follow-up message into three.
660///
661/// [`plan::SHORT_DRAFT`] alone does not refuse. Length is a smell, not a
662/// defect, and a genuinely small change deserves a small task file - which is
663/// exactly the judgement `magi plan` makes, so the browser path makes it too.
664/// It is still reported, because a two-line draft is usually an interview that
665/// ended early.
666pub fn draft_problems(chat: &Chat) -> Result<(), Vec<String>> {
667    let Some(body) = chat.draft.as_deref() else {
668        return Err(vec![
669            "this chat has no draft yet: the agent has not written a task file".to_owned(),
670        ]);
671    };
672    match plan::review_draft(body) {
673        Ok(()) => Ok(()),
674        Err(problems) => {
675            if problems.iter().all(|p| p == plan::SHORT_DRAFT) {
676                Ok(())
677            } else {
678                Err(problems)
679            }
680        }
681    }
682}
683
684/// The briefing the agent is opened with.
685///
686/// Pure, so the one property that matters can be asserted without an
687/// interview: it carries [`plan::TASK_FILE_SPEC`] verbatim. The spec and
688/// [`plan::review_draft`] are checked against each other by `plan`'s own tests,
689/// so including it here is what keeps this path from asking for a shape the
690/// validator will refuse - a twenty-message interview rejected for a reason the
691/// operator was never told is the worst outcome this module has.
692///
693/// The output contract is the other half. `magi plan` tells the agent to write
694/// a file, which works because that agent has a terminal and a filesystem the
695/// operator is watching. Here the reply *is* the channel: the task file comes
696/// back inside a fenced block tagged `task`, and [`extract_draft`] is the only
697/// thing that reads it.
698pub fn briefing(idea: &str, repo: &Path) -> String {
699    format!(
700        "You are the planning leader for magi, which runs a blind \
701         multi-agent implementation competition: several agents will implement \
702         the task file you write, in isolated worktrees, unaware of each other, \
703         and judges will rank the results without knowing who wrote what.\n\n\
704         Your job is not to implement anything. It is to interview the operator \
705         until the change is pinned down, and then write one task file.\n\n\
706         The operator is on a phone. Every message you send is read on a small \
707         screen, so keep it short: no preamble, no restating what they just \
708         said.\n\n\
709         # Repository\n\n{repo}\n\n\
710         Read it before you start asking. Questions the code already answers \
711         spend the operator's patience for nothing. Do not modify it: the \
712         competing agents do the implementation, and a repository you have \
713         already edited makes their diffs unjudgeable.\n\n\
714         # The idea\n\n{idea}\n\n\
715         # How to run the interview\n\n\
716         - Ask about what you cannot determine yourself: intent, scope, which \
717         of several defensible designs the operator wants, what must not \
718         change.\n\
719         - Ask about ONE thing per message and wait for the answer. This is a \
720         phone, not a form: a message with five questions in it gets one of \
721         them answered.\n\
722         - Do not produce the task file after one exchange.\n\
723         - Disagree when you have grounds. A leader that agrees with everything \
724         adds nothing to what the operator already typed.\n\
725         - Confirm the plan in your own words and get an explicit yes before \
726         writing.\n\n\
727         # How to deliver the task file\n\n\
728         When the operator agrees the plan is right, put the whole task file in \
729         your reply inside a fenced block tagged `task`, like this:\n\n\
730         ```task\n\
731         # <the task file>\n\
732         ```\n\n\
733         Nothing else goes in that block, and there is exactly one of them per \
734         message. magi extracts it and files it; a task file written to a file \
735         on disk, or pasted without the fence, is one magi cannot see. You may \
736         send a revised version later in the same conversation - the newest \
737         `task` block wins - and while you are still asking questions, send no \
738         `task` block at all.\n\n\
739         magi will refuse a task file with no completion criteria, so those are \
740         not optional.\n\n\
741         # Task file specification\n\n{spec}",
742        repo = repo.display(),
743        spec = plan::TASK_FILE_SPEC,
744    )
745}
746
747/// The interview is the operator talking, so their language matters more here
748/// than in any prompt the graph sends: an agent that answers a Japanese
749/// question in English makes the conversation slower for exactly the person
750/// magi is trying to help.
751fn language_note(language: &str) -> String {
752    if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
753        String::new()
754    } else {
755        format!("\n\nConduct the interview in {language}, and write the task file in {language}.")
756    }
757}
758
759/// Pull the task draft out of an agent reply, if it wrote one.
760///
761/// The *last* fenced `task` block, not the first. A conversation revises: an
762/// agent that rewrites the task file after one more answer sends both versions
763/// over the course of the interview, and within one message it may quote what
764/// it had before changing it. The newest block is the one the operator has been
765/// reading and the one they are about to approve.
766///
767/// Blocks tagged anything else - ```` ```rust ````, ```` ```json ```` - are
768/// ignored, so an agent illustrating its plan with code does not overwrite the
769/// draft with a snippet. An unterminated block is still taken: a reply cut off
770/// mid-draft is worth showing the operator, who can then just ask for it again.
771pub fn extract_draft(reply: &str) -> Option<String> {
772    let mut last: Option<String> = None;
773    let mut open: Option<(usize, Vec<&str>)> = None;
774    for line in reply.lines() {
775        let trimmed = line.trim_start();
776        // Backticks are one byte each, so the count is also the byte offset of
777        // the info string.
778        let ticks = trimmed.chars().take_while(|c| *c == '`').count();
779        match &mut open {
780            Some((width, body)) => {
781                if ticks >= *width && trimmed[ticks..].trim().is_empty() {
782                    last = Some(joined(body));
783                    open = None;
784                } else {
785                    body.push(line);
786                }
787            }
788            None => {
789                if ticks >= 3 && trimmed[ticks..].trim().eq_ignore_ascii_case("task") {
790                    open = Some((ticks, Vec::new()));
791                }
792            }
793        }
794    }
795    if let Some((_, body)) = open {
796        last = Some(joined(&body));
797    }
798    last.filter(|s| !s.trim().is_empty())
799}
800
801/// A fenced block's lines as one document, newline-terminated the way a file
802/// would be, because [`plan::review_draft`] reads it as a task file.
803fn joined(lines: &[&str]) -> String {
804    if lines.is_empty() {
805        return String::new();
806    }
807    let mut out = lines.join("\n");
808    out.push('\n');
809    out
810}
811
812fn read_path(path: &Path) -> Result<Chat> {
813    let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
814    serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))
815}
816
817fn short(id: &str) -> &str {
818    id.split('-').next_back().unwrap_or(id)
819}
820
821fn new_id() -> String {
822    let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
823    let seed = crate::rng::entropy();
824    format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
825}
826
827#[cfg(test)]
828mod tests {
829    use std::collections::BTreeMap;
830
831    use crate::config::{AgentKind, AgentSpec, Graph};
832
833    use super::*;
834
835    /// A store of its own, with no process-global state - which is the point of
836    /// [`Chats::at`], and why these can run in parallel.
837    fn store() -> (tempfile::TempDir, Chats) {
838        let tmp = tempfile::tempdir().expect("tempdir");
839        let chats = Chats::at(tmp.path().join("chats"));
840        (tmp, chats)
841    }
842
843    /// A task file of the shape [`plan::TASK_FILE_SPEC`] describes, long enough
844    /// that length is not one of the problems under test.
845    fn good_draft() -> String {
846        "# Report per-node durations in `magi show`\n\
847         \n\
848         ## Context\n\
849         \n\
850         `magi show` prints a run's nodes but not how long any of them took, so \
851         the operator cannot see which seat is expensive. The data is already \
852         in `run.events`.\n\
853         \n\
854         ## Change\n\
855         \n\
856         Add a duration column to the node table in `src/report.rs`.\n\
857         \n\
858         ## Constraints\n\
859         \n\
860         Do not change the JSON shape of a run record.\n\
861         \n\
862         ## Completion criteria\n\
863         \n\
864         - [ ] `magi show <run>` prints a duration for every completed node.\n\
865         - [ ] A node with no end event prints nothing rather than zero.\n\
866         \n\
867         ## Out of scope\n\
868         \n\
869         The TUI's detail pane.\n"
870            .to_owned()
871    }
872
873    /// A `kind = "command"` agent whose whole behaviour is a POSIX shell
874    /// script. No test in this module may spawn a real agent CLI: they are the
875    /// operator's paid subscriptions, they reach the network, and they are not
876    /// installed on CI.
877    fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
878        let path = dir.join("mock-chat-agent.sh");
879        std::fs::write(&path, script).expect("write mock");
880        AgentSpec {
881            id: "mock".to_owned(),
882            kind: AgentKind::Command,
883            model: None,
884            command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
885            extra_args: Vec::new(),
886            env,
887            prompt_delivery: None,
888        }
889    }
890
891    /// A config whose only agent is `spec`, with the graph left at its
892    /// defaults except for the language, so `language_note` stays out of the
893    /// prompt assertions.
894    fn config(spec: AgentSpec) -> Config {
895        Config {
896            agents: vec![spec],
897            graph: Graph {
898                language: "en".to_owned(),
899                ..Graph::default()
900            },
901            ..Config::default()
902        }
903    }
904
905    /// Echo a canned reply, ignoring the prompt on stdin.
906    const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
907
908    /// Say nothing and fail, the way a CLI that cannot start does.
909    const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
910
911    /// Reply with the prompt it was given, so a test can inspect exactly what
912    /// the leader received on stdin.
913    const ECHO: &str = "#!/bin/sh\ncat\n";
914
915    fn env(reply: &str) -> BTreeMap<String, String> {
916        BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
917    }
918
919    #[test]
920    fn the_frozen_json_field_names_round_trip_through_disk() {
921        let (tmp, chats) = store();
922        let mut chat = Chat {
923            schema: SCHEMA,
924            id: "20260903-014455-ab12".to_owned(),
925            repo: tmp.path().to_owned(),
926            from: None,
927            agent: "sonnet".to_owned(),
928            status: ChatStatus::Open,
929            turns: vec![Turn {
930                who: Who::Operator,
931                body: "rework the config loader".to_owned(),
932                at: Timestamp::now(),
933            }],
934            draft: None,
935            task: None,
936            created_at: Timestamp::now(),
937            updated_at: Timestamp::now(),
938            seat: SeatState::new(SEAT, "sonnet", 7),
939        };
940        chats.put(&mut chat).expect("put");
941
942        // Asserted literally, against the text on disk. The web UI is written
943        // against these names by hand, so a rename that only round-trips
944        // through serde would break the phone silently.
945        let raw = std::fs::read_to_string(chats.path_of(&chat.id)).expect("read back");
946        let v: serde_json::Value = serde_json::from_str(&raw).expect("parse");
947        for field in [
948            "schema",
949            "id",
950            "repo",
951            "from",
952            "agent",
953            "status",
954            "turns",
955            "draft",
956            "task",
957            "created_at",
958            "updated_at",
959        ] {
960            assert!(v.get(field).is_some(), "missing field `{field}`");
961        }
962        assert_eq!(v["schema"], 1);
963        assert_eq!(v["status"], "open");
964        assert_eq!(v["turns"][0]["who"], "operator");
965        assert_eq!(v["turns"][0]["body"], "rework the config loader");
966        assert!(v["turns"][0].get("at").is_some());
967        assert!(v["draft"].is_null());
968        assert!(v["task"].is_null());
969        assert!(v["from"].is_null());
970
971        let back = chats.get(&chat.id).expect("get");
972        assert_eq!(back.id, chat.id);
973        assert_eq!(back.turns, chat.turns);
974        assert_eq!(back.status, ChatStatus::Open);
975        assert_eq!(back.from, None);
976    }
977
978    /// A conversation recorded before `from` existed must still read: the
979    /// `#[serde(deny_unknown_fields)]` on [`Chat`] would otherwise make this
980    /// field's addition a breaking change for every chat already on disk.
981    #[test]
982    fn a_chat_recorded_without_a_from_field_still_reads() {
983        let (tmp, chats) = store();
984        let path = chats.path_of("20260903-014455-ab12");
985        std::fs::create_dir_all(chats.root()).expect("chats dir");
986        std::fs::write(
987            &path,
988            serde_json::json!({
989                "schema": SCHEMA,
990                "id": "20260903-014455-ab12",
991                "repo": tmp.path(),
992                "agent": "sonnet",
993                "status": "open",
994                "turns": [],
995                "draft": null,
996                "task": null,
997                "created_at": Timestamp::now().to_string(),
998                "updated_at": Timestamp::now().to_string(),
999                "seat": SeatState::new(SEAT, "sonnet", 7),
1000            })
1001            .to_string(),
1002        )
1003        .expect("write pre-`from` chat");
1004
1005        let chat = chats.get("20260903-014455-ab12").expect("must still read");
1006        assert_eq!(chat.from, None);
1007    }
1008
1009    #[test]
1010    fn derived_background_names_the_source_repository_and_carries_the_transcript() {
1011        let chat = Chat {
1012            schema: SCHEMA,
1013            id: "20260903-014455-ab12".to_owned(),
1014            repo: PathBuf::from("/repo/other"),
1015            from: None,
1016            agent: "sonnet".to_owned(),
1017            status: ChatStatus::Open,
1018            turns: vec![
1019                Turn {
1020                    who: Who::Operator,
1021                    body: "rework the queue drain".to_owned(),
1022                    at: Timestamp::now(),
1023                },
1024                Turn {
1025                    who: Who::Agent,
1026                    body: "which part of the drain?".to_owned(),
1027                    at: Timestamp::now(),
1028                },
1029            ],
1030            draft: None,
1031            task: None,
1032            created_at: Timestamp::now(),
1033            updated_at: Timestamp::now(),
1034            seat: SeatState::new(SEAT, "sonnet", 7),
1035        };
1036        let background = derived_background(&chat);
1037        assert!(background.contains("/repo/other"));
1038        assert!(background.contains("rework the queue drain"));
1039        assert!(background.contains("which part of the drain?"));
1040        assert!(background.contains("different"));
1041    }
1042
1043    #[tokio::test]
1044    async fn starting_a_derived_chat_carries_the_source_transcript_and_leaves_it_untouched() {
1045        let (tmp, chats) = store();
1046        let source_spec = mock_agent(tmp.path(), REPLY, env("which module?"));
1047        let source_cfg = config(source_spec);
1048        let source = start(
1049            &chats,
1050            &source_cfg,
1051            tmp.path().to_owned(),
1052            "rework the queue drain",
1053            None,
1054            None,
1055        )
1056        .await
1057        .expect("start source");
1058        let before = source.clone();
1059
1060        let other_repo = tmp.path().join("other-repo");
1061        std::fs::create_dir_all(&other_repo).expect("other repo dir");
1062        // Overwrites the script `source_spec` pointed at: the source's own
1063        // turn already ran, so only the derived chat's invocation sees this.
1064        let echo_spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
1065        let derived_cfg = config(echo_spec);
1066        let derived = start(
1067            &chats,
1068            &derived_cfg,
1069            other_repo,
1070            "same idea, different repository",
1071            None,
1072            Some(&source),
1073        )
1074        .await
1075        .expect("start derived");
1076
1077        assert_eq!(derived.from.as_deref(), Some(source.id.as_str()));
1078
1079        let prompt = &derived.turns.last().expect("agent reply").body;
1080        assert!(prompt.contains("Background: derived from another conversation"));
1081        assert!(prompt.contains(&source.repo.display().to_string()));
1082        assert!(prompt.contains("rework the queue drain"));
1083        assert!(prompt.contains("same idea, different repository"));
1084
1085        // Deriving a chat must not touch the one it came from.
1086        let reread = chats.get(&source.id).expect("source still on disk");
1087        assert_eq!(reread.status, before.status);
1088        assert_eq!(reread.turns, before.turns);
1089        assert_eq!(reread.draft, before.draft);
1090    }
1091
1092    #[test]
1093    fn extract_draft_takes_the_last_task_block_and_ignores_other_fences() {
1094        let reply = "here is a sketch\n\
1095                     \n\
1096                     ```rust\n\
1097                     fn not_the_draft() {}\n\
1098                     ```\n\
1099                     \n\
1100                     ```task\n\
1101                     # first version\n\
1102                     ```\n\
1103                     \n\
1104                     ```json\n\
1105                     {\"also\": \"not it\"}\n\
1106                     ```\n\
1107                     \n\
1108                     revised:\n\
1109                     \n\
1110                     ```task\n\
1111                     # second version\n\
1112                     ## Completion criteria\n\
1113                     ```\n";
1114        assert_eq!(
1115            extract_draft(reply).as_deref(),
1116            Some("# second version\n## Completion criteria\n")
1117        );
1118    }
1119
1120    #[test]
1121    fn extract_draft_returns_none_when_there_is_no_task_block() {
1122        assert_eq!(extract_draft("which storage backend do you want?"), None);
1123        assert_eq!(extract_draft("```rust\nfn f() {}\n```\n"), None);
1124        // An empty block is not a draft: filing it would produce a task with
1125        // nothing in it.
1126        assert_eq!(extract_draft("```task\n```\n"), None);
1127    }
1128
1129    #[tokio::test]
1130    async fn a_reply_with_no_draft_leaves_the_existing_draft_in_place() {
1131        let (tmp, chats) = store();
1132        let spec = mock_agent(tmp.path(), REPLY, env("one more thing: which module?"));
1133        let cfg = config(spec);
1134        let mut chat = start(
1135            &chats,
1136            &cfg,
1137            tmp.path().to_owned(),
1138            "add durations",
1139            None,
1140            None,
1141        )
1142        .await
1143        .expect("start");
1144        chat.draft = Some(good_draft());
1145        chats.put(&mut chat).expect("put");
1146
1147        say(&mut chat, &chats, &cfg, "the report module")
1148            .await
1149            .expect("say");
1150
1151        assert_eq!(chat.draft.as_deref(), Some(good_draft().as_str()));
1152        assert_eq!(
1153            chats.get(&chat.id).expect("get").draft.as_deref(),
1154            Some(good_draft().as_str())
1155        );
1156    }
1157
1158    #[test]
1159    fn the_briefing_carries_the_task_file_spec_and_the_task_fence() {
1160        let brief = briefing("rework the config loader", Path::new("/repo"));
1161        // The spec verbatim, so the shape asked for cannot drift from the shape
1162        // `plan::review_draft` enforces.
1163        assert!(brief.contains(plan::TASK_FILE_SPEC));
1164        assert!(brief.contains("```task"));
1165        assert!(brief.contains("rework the config loader"));
1166        assert!(brief.contains("/repo"));
1167        assert!(brief.contains("completion criteria"));
1168    }
1169
1170    #[test]
1171    fn file_draft_refuses_a_bad_draft_with_every_problem() {
1172        let (tmp, chats) = store();
1173        let queue = Queue::at(tmp.path().join("queue"));
1174        let mut chat = Chat {
1175            schema: SCHEMA,
1176            id: "20260903-014455-ab12".to_owned(),
1177            repo: tmp.path().to_owned(),
1178            from: None,
1179            agent: "mock".to_owned(),
1180            status: ChatStatus::Open,
1181            turns: Vec::new(),
1182            // Short *and* missing completion criteria: both must be reported,
1183            // or the operator asks for one fix and gets refused again.
1184            draft: Some("# do the thing\n\nsome context.\n".to_owned()),
1185            task: None,
1186            created_at: Timestamp::now(),
1187            updated_at: Timestamp::now(),
1188            seat: SeatState::new(SEAT, "mock", 7),
1189        };
1190
1191        let problems = draft_problems(&chat).expect_err("a draft with no criteria is not fileable");
1192        assert!(
1193            problems.len() >= 2,
1194            "expected every problem, got {problems:?}"
1195        );
1196        assert!(problems.iter().any(|p| p.contains("completion criteria")));
1197        assert!(problems.iter().any(|p| p == plan::SHORT_DRAFT));
1198
1199        let err = file_draft(&mut chat, &chats, &queue, 0)
1200            .expect_err("file_draft must refuse it too")
1201            .to_string();
1202        for p in &problems {
1203            assert!(err.contains(p.as_str()), "`{p}` missing from `{err}`");
1204        }
1205        assert_eq!(chat.status, ChatStatus::Open);
1206        assert!(chat.task.is_none());
1207        assert!(queue.list().is_empty());
1208    }
1209
1210    #[test]
1211    fn file_draft_queues_a_good_draft_and_records_the_task() {
1212        let (tmp, chats) = store();
1213        let queue = Queue::at(tmp.path().join("queue"));
1214        let mut chat = Chat {
1215            schema: SCHEMA,
1216            id: "20260903-014455-cd34".to_owned(),
1217            repo: tmp.path().to_owned(),
1218            from: None,
1219            agent: "mock".to_owned(),
1220            status: ChatStatus::Open,
1221            turns: Vec::new(),
1222            draft: Some(good_draft()),
1223            task: None,
1224            created_at: Timestamp::now(),
1225            updated_at: Timestamp::now(),
1226            seat: SeatState::new(SEAT, "mock", 7),
1227        };
1228
1229        let id = file_draft(&mut chat, &chats, &queue, 5).expect("file");
1230
1231        assert_eq!(chat.status, ChatStatus::Filed);
1232        assert_eq!(chat.task.as_deref(), Some(id.as_str()));
1233        assert_eq!(
1234            chats.get(&chat.id).expect("get").task.as_deref(),
1235            Some(id.as_str()),
1236            "the task id must survive on disk, or the phone shows an unfiled chat"
1237        );
1238
1239        let task = queue.get(&id).expect("queued task");
1240        assert_eq!(task.title, queue::title_from(&good_draft(), 72));
1241        assert_eq!(task.instruction, good_draft());
1242        assert_eq!(task.priority, 5);
1243        assert_eq!(task.source, Source::Human);
1244    }
1245
1246    #[tokio::test]
1247    async fn say_appends_the_operator_turn_then_the_agent_turn() {
1248        let (tmp, chats) = store();
1249        let spec = mock_agent(tmp.path(), REPLY, env("which module?"));
1250        let cfg = config(spec);
1251        let mut chat = start(
1252            &chats,
1253            &cfg,
1254            tmp.path().to_owned(),
1255            "add durations",
1256            None,
1257            None,
1258        )
1259        .await
1260        .expect("start");
1261        // start is one operator turn (the idea) plus one agent turn.
1262        assert_eq!(chat.turns.len(), 2);
1263        assert_eq!(chat.turns[0].who, Who::Operator);
1264        assert_eq!(chat.turns[1].who, Who::Agent);
1265
1266        say(&mut chat, &chats, &cfg, "the report module")
1267            .await
1268            .expect("say");
1269
1270        assert_eq!(chat.turns.len(), 4);
1271        assert_eq!(chat.turns[2].who, Who::Operator);
1272        assert_eq!(chat.turns[2].body, "the report module");
1273        assert_eq!(chat.turns[3].who, Who::Agent);
1274        assert_eq!(chat.turns[3].body, "which module?");
1275        assert_eq!(chats.get(&chat.id).expect("get").turns, chat.turns);
1276    }
1277
1278    #[tokio::test]
1279    async fn a_failed_turn_keeps_the_operator_message_and_says_what_happened() {
1280        let (tmp, chats) = store();
1281        let good = mock_agent(tmp.path(), REPLY, env("which module?"));
1282        let cfg = config(good);
1283        let mut chat = start(
1284            &chats,
1285            &cfg,
1286            tmp.path().to_owned(),
1287            "add durations",
1288            None,
1289            None,
1290        )
1291        .await
1292        .expect("start");
1293
1294        // The chat is bound to roster agent `mock`, so break what `mock`
1295        // actually runs: `mock_agent` rewrites the same script path, which is
1296        // what it looks like when that CLI stops working mid-interview.
1297        mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1298        let err = say(&mut chat, &chats, &cfg, "the report module")
1299            .await
1300            .expect_err("a turn with no answer is an error");
1301        assert!(err.to_string().contains("no answer"), "{err}");
1302
1303        let on_disk = chats.get(&chat.id).expect("get");
1304        assert_eq!(on_disk.turns.len(), 4);
1305        assert_eq!(
1306            on_disk.turns[2].body, "the report module",
1307            "the operator's message must survive the failure"
1308        );
1309        let note = &on_disk.turns[3];
1310        assert_eq!(note.who, Who::Agent);
1311        assert!(
1312            note.body.starts_with(MAGI_NOTE),
1313            "the failure must be visible in the transcript: {}",
1314            note.body
1315        );
1316        assert!(note.body.contains("your message is saved"));
1317    }
1318
1319    #[test]
1320    fn list_puts_open_chats_before_filed_ones() {
1321        let (tmp, chats) = store();
1322        let make = |id: &str, status: ChatStatus| {
1323            let mut c = Chat {
1324                schema: SCHEMA,
1325                id: id.to_owned(),
1326                repo: tmp.path().to_owned(),
1327                from: None,
1328                agent: "mock".to_owned(),
1329                status,
1330                turns: Vec::new(),
1331                draft: None,
1332                task: None,
1333                created_at: Timestamp::now(),
1334                updated_at: Timestamp::now(),
1335                seat: SeatState::new(SEAT, "mock", 7),
1336            };
1337            chats.put(&mut c).expect("put");
1338        };
1339        // The filed one is newest, so ordering by id alone would put it first.
1340        make("20260901-000000-0001", ChatStatus::Open);
1341        make("20260902-000000-0002", ChatStatus::Open);
1342        make("20260903-000000-0003", ChatStatus::Filed);
1343
1344        let ids: Vec<String> = chats.list().into_iter().map(|c| c.id).collect();
1345        assert_eq!(
1346            ids,
1347            [
1348                "20260902-000000-0002",
1349                "20260901-000000-0001",
1350                "20260903-000000-0003"
1351            ]
1352        );
1353        assert_eq!(chats.count_open(), 2);
1354    }
1355}