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/// Construct a conversation record in memory, without writing it anywhere.
323///
324/// Split out of [`open`] so a caller can claim [`crate::web::Ui::begin_turn`]
325/// on `chat.id` *before* the record is ever written to disk - `chat_post`
326/// does exactly that, because [`Chats::put`] is what makes the id visible to
327/// every other request (`GET /api/chats`, `POST /api/chats/{id}/say`), and a
328/// gap between "the file exists" and "the turn is claimed" is a window for
329/// `chat_say` to claim and record into an interview whose first turn never
330/// ran - see `chat_post`'s doc for the failure that produces.
331///
332/// `agent` is resolved by [`plan::pick`], the same policy `magi plan` uses: an
333/// explicit id wins and is an error rather than a fallback when it is not
334/// runnable, otherwise a `claude` seat, otherwise the first runnable agent in
335/// roster order. Called rather than copied, because two copies of a preference
336/// order drift and the copy that drifts is the one nobody reads.
337///
338/// `from` is the conversation this one was derived from, when the operator
339/// asked to continue an existing interview in a different repository (see
340/// [`derived_background`]). It is read, never written: the source chat's
341/// `status`, `turns` and `draft` are left exactly as they were.
342pub fn build(
343    cfg: &Config,
344    repo: PathBuf,
345    idea: &str,
346    agent: Option<&str>,
347    from: Option<&Chat>,
348) -> Result<Chat> {
349    let idea = idea.trim();
350    if idea.is_empty() {
351        bail!("an interview needs something to start from: say what you want to change");
352    }
353    // Absolute, because the daemon that eventually runs the filed task has its
354    // own working directory and a relative path would mean the wrong
355    // repository.
356    let repo = repo.canonicalize().unwrap_or(repo);
357    // The API's `agent` beats the config, the config beats the built-in order.
358    // On a phone there is no flag to pass, so `[roles] chatter` (falling back
359    // to `planner`, so an operator who never set `chatter` sees no change) is
360    // the only way an operator states who answers this conversation - kept
361    // separate from `planner` so the resident chat does not compete with a
362    // judge seat for the same account by default.
363    let want = agent
364        .or(cfg.roles.chatter.as_deref())
365        .or(cfg.roles.planner.as_deref());
366    let spec = plan::pick(&cfg.agents, want, &plan::installed)?;
367
368    let now = Timestamp::now();
369    let id = new_id();
370    Ok(Chat {
371        schema: SCHEMA,
372        id,
373        repo,
374        from: from.map(|c| c.id.clone()),
375        agent: spec.id.clone(),
376        status: ChatStatus::Open,
377        turns: vec![Turn {
378            who: Who::Operator,
379            body: idea.to_owned(),
380            at: now,
381        }],
382        draft: None,
383        task: None,
384        created_at: now,
385        updated_at: now,
386        seat: SeatState::new(SEAT, &spec.id, crate::rng::entropy()),
387    })
388}
389
390/// [`build`], then persist. No first agent turn is taken.
391///
392/// Convenient when there is nothing racing the write - [`start`] is the only
393/// caller - but `POST /api/chats` cannot use it: see [`build`]'s doc for why
394/// the claim has to land between construction and this function's own
395/// [`Chats::put`].
396pub fn open(
397    store: &Chats,
398    cfg: &Config,
399    repo: PathBuf,
400    idea: &str,
401    agent: Option<&str>,
402    from: Option<&Chat>,
403) -> Result<Chat> {
404    let mut chat = build(cfg, repo, idea, agent, from)?;
405    store.put(&mut chat)?;
406    Ok(chat)
407}
408
409/// Take the first agent turn of a conversation created by [`open`].
410///
411/// `from`, when given, must be the same source conversation `open` was called
412/// with - it is read again here rather than stashed on `chat` because the
413/// persisted record carries only the source's id, and the full record is
414/// what [`derived_background`] needs.
415pub async fn first_turn(
416    chat: &mut Chat,
417    store: &Chats,
418    cfg: &Config,
419    from: Option<&Chat>,
420) -> Result<()> {
421    let idea = chat
422        .turns
423        .first()
424        .map(|t| t.body.as_str())
425        .unwrap_or_default();
426    let mut prompt = briefing(idea, &chat.repo);
427    if let Some(source) = from {
428        // Prepended, so the leader reads what it is inheriting before it
429        // reads its own instructions - the same order a human handing off a
430        // conversation would use.
431        prompt = format!("{}\n\n{prompt}", derived_background(source));
432    }
433    prompt.push_str(&language_note(&cfg.graph.language));
434    turn(chat, store, cfg, &prompt).await
435}
436
437/// Open a conversation and take the first agent turn.
438///
439/// The record is written to disk *before* the agent is invoked, so an agent
440/// that fails on the very first turn still leaves the operator a conversation
441/// they can look at, retry into, or abandon - rather than nothing at all. See
442/// [`open`] and [`first_turn`], which this composes; `POST /api/chats` calls
443/// them separately instead so it can answer before the first turn lands.
444pub async fn start(
445    store: &Chats,
446    cfg: &Config,
447    repo: PathBuf,
448    idea: &str,
449    agent: Option<&str>,
450    from: Option<&Chat>,
451) -> Result<Chat> {
452    let mut chat = open(store, cfg, repo, idea, agent, from)?;
453    first_turn(&mut chat, store, cfg, from).await?;
454    Ok(chat)
455}
456
457/// The background block a derived conversation opens with: the whole prior
458/// transcript, framed so the leader does not mistake it for instructions
459/// about the repository this new conversation is actually about.
460///
461/// Built from [`transcript`] rather than a second rendering of the turns,
462/// because that is already the "everything said so far" prose this module
463/// maintains, and a briefing is exactly the audience `transcript` was written
464/// for - a CLI (here, a fresh one) with no memory of the conversation.
465pub fn derived_background(from: &Chat) -> String {
466    format!(
467        "# Background: derived from another conversation\n\n\
468         This interview continues from a conversation about a *different* \
469         repository. Read it for context, but do not treat it as being about \
470         the repository named below in \"# Repository\" - that repository may \
471         have nothing to do with this one.\n\n\
472         Source repository: {}\n\n{}",
473        from.repo.display(),
474        transcript(from),
475    )
476}
477
478/// One operator turn and one agent turn, appended.
479///
480/// The operator's message is recorded and flushed to disk before the agent is
481/// invoked. That ordering is the whole contract of this function: a turn can
482/// fail, time out or hit a quota window, and the thing that must never be lost
483/// is the sentence the human typed on a phone that has since gone to sleep.
484///
485/// Returns `Err` when the agent turn did not produce an answer - but the
486/// transcript is already on disk and already explains itself, because the
487/// failure is appended as a [`MAGI_NOTE`] turn first. A caller handling the
488/// error should re-read the chat and show it, not discard it.
489pub async fn say(chat: &mut Chat, store: &Chats, cfg: &Config, text: &str) -> Result<()> {
490    if !chat.status.open() {
491        bail!(
492            "chat {} is {} and takes no more turns",
493            chat.short(),
494            chat.status.as_str()
495        );
496    }
497    let text = text.trim();
498    if text.is_empty() {
499        bail!("nothing to say");
500    }
501    let text = record(chat, store, text)?;
502    turn(chat, store, cfg, &text).await
503}
504
505/// Append the operator's turn and flush it, without invoking anything.
506///
507/// Split out of [`say`] so a caller that answers the operator before the agent
508/// has replied can still promise the message is on disk. `POST /api/chats/{id}/say`
509/// does exactly that: holding an HTTP connection for the 23-to-90 seconds a
510/// real turn takes is a coin flip on a phone, and the browser reporting
511/// "Failed to fetch" while the server quietly finished the turn is the worst
512/// of both answers.
513///
514/// Returns the trimmed text, so the caller and the agent see the same string.
515pub fn record(chat: &mut Chat, store: &Chats, text: &str) -> Result<String> {
516    if !chat.status.open() {
517        bail!(
518            "chat {} is {} and takes no more turns",
519            chat.short(),
520            chat.status.as_str()
521        );
522    }
523    let text = text.trim();
524    if text.is_empty() {
525        bail!("nothing to say");
526    }
527    chat.turns.push(Turn {
528        who: Who::Operator,
529        body: text.to_owned(),
530        at: Timestamp::now(),
531    });
532    store.put(chat)?;
533    Ok(text.to_owned())
534}
535
536/// The agent's half of a turn: invoke, append, flush.
537///
538/// Pairs with [`record`]. `text` is the operator's message that this reply
539/// answers - the same string `record` returned, so the transcript and the
540/// prompt cannot disagree.
541pub async fn respond(chat: &mut Chat, store: &Chats, cfg: &Config, text: &str) -> Result<()> {
542    turn(chat, store, cfg, text).await
543}
544
545/// Invoke the interviewing agent once and append what it said.
546///
547/// `prompt` is only the new material. Whether that is enough depends on the
548/// CLI: [`agent::has_session`] answers honestly - it is `false` when sessions
549/// are switched off, before the first turn, or for a CLI that never reported an
550/// id back - and only then is the transcript prepended, because a model with no
551/// memory of the interview would otherwise answer the last sentence in a
552/// vacuum. When the CLI *can* resume, magi sends nothing extra: paying for the
553/// whole conversation on every message is the cost this design exists to avoid,
554/// and a magi-authored replay of history is also a second, divergent version of
555/// it.
556async fn turn(chat: &mut Chat, store: &Chats, cfg: &Config, prompt: &str) -> Result<()> {
557    let spec = cfg
558        .agents
559        .iter()
560        .find(|a| a.id == chat.agent)
561        .with_context(|| {
562            format!(
563                "chat {} was interviewed by agent `{}`, which is no longer in \
564                 the roster; restore it in magi.toml or start a new chat",
565                chat.short(),
566                chat.agent
567            )
568        })?;
569
570    let resuming = agent::has_session(spec.kind, &chat.seat, cfg.graph.sessions);
571    let body = if resuming {
572        prompt.to_owned()
573    } else {
574        format!("{}\n\n{prompt}", transcript(chat))
575    };
576
577    let artifacts = store.artifacts_of(&chat.id);
578    let stem = format!("turn-{}", chat.seat.turns + 1);
579    let cache_dir = cfg.cache_dir();
580    let inv = Invocation {
581        cwd: &chat.repo,
582        prompt: &body,
583        timeout: TURN_TIMEOUT,
584        // The interviewer writes a task file into its reply, never into the
585        // repository: the competing agents do the implementation, and a
586        // repository the planner has already edited makes their diffs
587        // unjudgeable.
588        allow_write: false,
589        sessions: cfg.graph.sessions,
590        artifacts: &artifacts,
591        stem: &stem,
592        run: &chat.id,
593        node: "chat",
594        cache_dir: cache_dir.as_deref(),
595    };
596
597    let outcome = agent::invoke(spec, &mut chat.seat, &inv).await;
598    let note = |why: String| Turn {
599        who: Who::Agent,
600        body: format!("{MAGI_NOTE}{why}"),
601        at: Timestamp::now(),
602    };
603    let (reply, failure) = match outcome {
604        Err(e) => (
605            note(format!("could not run agent `{}`: {e}", chat.agent)),
606            Some(format!("could not run agent `{}`: {e}", chat.agent)),
607        ),
608        Ok(out) if out.quota_exhausted() => {
609            let reset = out
610                .quota
611                .as_ref()
612                .and_then(|q| q.reset.clone())
613                .map_or_else(String::new, |r| format!(" (resets {r})"));
614            let why = format!(
615                "agent `{}` is out of quota{reset}; your message is saved, so \
616                 say it again when the window reopens",
617                chat.agent
618            );
619            (note(why.clone()), Some(why))
620        }
621        Ok(out) if out.timed_out => {
622            let why = format!(
623                "agent `{}` did not answer within {}s; your message is saved",
624                chat.agent,
625                TURN_TIMEOUT.as_secs()
626            );
627            (note(why.clone()), Some(why))
628        }
629        Ok(out) if !out.usable() => {
630            let why = format!(
631                "agent `{}` produced no answer (exit {}); your message is saved",
632                chat.agent,
633                out.exit_code
634                    .map_or_else(|| "unknown".to_owned(), |c| c.to_string())
635            );
636            (note(why.clone()), Some(why))
637        }
638        Ok(out) => (
639            Turn {
640                who: Who::Agent,
641                body: out.text.trim().to_owned(),
642                at: Timestamp::now(),
643            },
644            None,
645        ),
646    };
647
648    // A reply carrying no fenced draft leaves the existing one alone. The agent
649    // asking one more follow-up question must not erase the task file it
650    // already wrote, which the operator may well be reading at that moment.
651    if let Some(draft) = extract_draft(&reply.body) {
652        chat.draft = Some(draft);
653    }
654    chat.turns.push(reply);
655    store.put(chat)?;
656
657    match failure {
658        Some(why) => bail!("{why}"),
659        None => Ok(()),
660    }
661}
662
663/// Everything said so far, as prose, for a CLI that cannot resume.
664///
665/// Only reached when [`agent::has_session`] says the conversation cannot be
666/// continued on the CLI's side. It is a fallback and not the design: it re-pays
667/// for the history on every turn and it is magi's rendering of the
668/// conversation rather than the model's own.
669fn transcript(chat: &Chat) -> String {
670    let mut out = String::from(
671        "You are mid-interview. This CLI cannot resume its own conversation, \
672         so here is everything said so far; answer only the last message.\n",
673    );
674    for t in &chat.turns {
675        let who = match t.who {
676            Who::Operator => "operator",
677            Who::Agent => "you",
678        };
679        out.push_str(&format!("\n## {who}\n\n{}\n", t.body.trim()));
680    }
681    out
682}
683
684/// Validate the draft with [`plan::review_draft`] and queue it.
685///
686/// Returns the queued task's id. The conversation is left on disk either way:
687/// a refused draft is a conversation to continue, not an error to recover
688/// from, and the operator's next message can ask for the missing section.
689pub fn file_draft(chat: &mut Chat, store: &Chats, queue: &Queue, priority: i32) -> Result<String> {
690    if let Err(problems) = draft_problems(chat) {
691        bail!(
692            "this draft is not fileable yet:\n- {}",
693            problems.join("\n- ")
694        );
695    }
696    let body = chat
697        .draft
698        .clone()
699        .expect("draft_problems accepted a chat with a draft");
700
701    // `title_from` rather than a title the agent was asked to supply
702    // separately: the task file's first line already is the title, and asking
703    // for it twice is how the two come to disagree.
704    let title = queue::title_from(&body, 72);
705    // `Human`, not `Agent`: the agent conducted the interview, but the change
706    // being asked for is the operator's, and "who asked for this" is the
707    // question `source` exists to answer.
708    let mut task = Task::new(title, body, chat.repo.clone(), Source::Human);
709    task.priority = priority;
710    queue.put(&mut task)?;
711
712    chat.task = Some(task.id.clone());
713    chat.status = ChatStatus::Filed;
714    store.put(chat)?;
715    Ok(task.id)
716}
717
718/// Is this conversation's draft fileable, and if not, what is wrong with it?
719///
720/// Every problem is returned, not the first: an operator about to ask the agent
721/// for a fix wants the whole list, and a validator that reveals one defect per
722/// round turns one follow-up message into three.
723///
724/// [`plan::SHORT_DRAFT`] alone does not refuse. Length is a smell, not a
725/// defect, and a genuinely small change deserves a small task file - which is
726/// exactly the judgement `magi plan` makes, so the browser path makes it too.
727/// It is still reported, because a two-line draft is usually an interview that
728/// ended early.
729pub fn draft_problems(chat: &Chat) -> Result<(), Vec<String>> {
730    let Some(body) = chat.draft.as_deref() else {
731        return Err(vec![
732            "this chat has no draft yet: the agent has not written a task file".to_owned(),
733        ]);
734    };
735    match plan::review_draft(body) {
736        Ok(()) => Ok(()),
737        Err(problems) => {
738            if problems.iter().all(|p| p == plan::SHORT_DRAFT) {
739                Ok(())
740            } else {
741                Err(problems)
742            }
743        }
744    }
745}
746
747/// The briefing the agent is opened with.
748///
749/// Pure, so the one property that matters can be asserted without an
750/// interview: it carries [`plan::TASK_FILE_SPEC`] verbatim. The spec and
751/// [`plan::review_draft`] are checked against each other by `plan`'s own tests,
752/// so including it here is what keeps this path from asking for a shape the
753/// validator will refuse - a twenty-message interview rejected for a reason the
754/// operator was never told is the worst outcome this module has.
755///
756/// The output contract is the other half. `magi plan` tells the agent to write
757/// a file, which works because that agent has a terminal and a filesystem the
758/// operator is watching. Here the reply *is* the channel: the task file comes
759/// back inside a fenced block tagged `task`, and [`extract_draft`] is the only
760/// thing that reads it.
761pub fn briefing(idea: &str, repo: &Path) -> String {
762    format!(
763        "You are the planning leader for magi, which runs a blind \
764         multi-agent implementation competition: several agents will implement \
765         the task file you write, in isolated worktrees, unaware of each other, \
766         and judges will rank the results without knowing who wrote what.\n\n\
767         Your job is not to implement anything. It is to interview the operator \
768         until the change is pinned down, and then write one task file.\n\n\
769         The operator is on a phone. Every message you send is read on a small \
770         screen, so keep it short: no preamble, no restating what they just \
771         said.\n\n\
772         # Repository\n\n{repo}\n\n\
773         Read it before you start asking. Questions the code already answers \
774         spend the operator's patience for nothing. Do not modify it: the \
775         competing agents do the implementation, and a repository you have \
776         already edited makes their diffs unjudgeable.\n\n\
777         # The idea\n\n{idea}\n\n\
778         # How to run the interview\n\n\
779         - Ask about what you cannot determine yourself: intent, scope, which \
780         of several defensible designs the operator wants, what must not \
781         change.\n\
782         - Ask about ONE thing per message and wait for the answer. This is a \
783         phone, not a form: a message with five questions in it gets one of \
784         them answered.\n\
785         - Do not produce the task file after one exchange.\n\
786         - Disagree when you have grounds. A leader that agrees with everything \
787         adds nothing to what the operator already typed.\n\
788         - Confirm the plan in your own words and get an explicit yes before \
789         writing.\n\n\
790         # How to deliver the task file\n\n\
791         When the operator agrees the plan is right, put the whole task file in \
792         your reply inside a fenced block tagged `task`, like this:\n\n\
793         ```task\n\
794         # <the task file>\n\
795         ```\n\n\
796         Nothing else goes in that block, and there is exactly one of them per \
797         message. magi extracts it and files it; a task file written to a file \
798         on disk, or pasted without the fence, is one magi cannot see. You may \
799         send a revised version later in the same conversation - the newest \
800         `task` block wins - and while you are still asking questions, send no \
801         `task` block at all.\n\n\
802         magi will refuse a task file with no completion criteria, so those are \
803         not optional.\n\n\
804         # Task file specification\n\n{spec}",
805        repo = repo.display(),
806        spec = plan::TASK_FILE_SPEC,
807    )
808}
809
810/// The interview is the operator talking, so their language matters more here
811/// than in any prompt the graph sends: an agent that answers a Japanese
812/// question in English makes the conversation slower for exactly the person
813/// magi is trying to help.
814fn language_note(language: &str) -> String {
815    if language.trim().is_empty() || language.eq_ignore_ascii_case("en") {
816        String::new()
817    } else {
818        format!("\n\nConduct the interview in {language}, and write the task file in {language}.")
819    }
820}
821
822/// Pull the task draft out of an agent reply, if it wrote one.
823///
824/// The *last* fenced `task` block, not the first. A conversation revises: an
825/// agent that rewrites the task file after one more answer sends both versions
826/// over the course of the interview, and within one message it may quote what
827/// it had before changing it. The newest block is the one the operator has been
828/// reading and the one they are about to approve.
829///
830/// Blocks tagged anything else - ```` ```rust ````, ```` ```json ```` - are
831/// ignored, so an agent illustrating its plan with code does not overwrite the
832/// draft with a snippet. An unterminated block is still taken: a reply cut off
833/// mid-draft is worth showing the operator, who can then just ask for it again.
834pub fn extract_draft(reply: &str) -> Option<String> {
835    let mut last: Option<String> = None;
836    let mut open: Option<(usize, Vec<&str>)> = None;
837    for line in reply.lines() {
838        let trimmed = line.trim_start();
839        // Backticks are one byte each, so the count is also the byte offset of
840        // the info string.
841        let ticks = trimmed.chars().take_while(|c| *c == '`').count();
842        match &mut open {
843            Some((width, body)) => {
844                if ticks >= *width && trimmed[ticks..].trim().is_empty() {
845                    last = Some(joined(body));
846                    open = None;
847                } else {
848                    body.push(line);
849                }
850            }
851            None => {
852                if ticks >= 3 && trimmed[ticks..].trim().eq_ignore_ascii_case("task") {
853                    open = Some((ticks, Vec::new()));
854                }
855            }
856        }
857    }
858    if let Some((_, body)) = open {
859        last = Some(joined(&body));
860    }
861    last.filter(|s| !s.trim().is_empty())
862}
863
864/// A fenced block's lines as one document, newline-terminated the way a file
865/// would be, because [`plan::review_draft`] reads it as a task file.
866fn joined(lines: &[&str]) -> String {
867    if lines.is_empty() {
868        return String::new();
869    }
870    let mut out = lines.join("\n");
871    out.push('\n');
872    out
873}
874
875fn read_path(path: &Path) -> Result<Chat> {
876    let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
877    serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))
878}
879
880fn short(id: &str) -> &str {
881    id.split('-').next_back().unwrap_or(id)
882}
883
884fn new_id() -> String {
885    let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
886    let seed = crate::rng::entropy();
887    format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
888}
889
890#[cfg(test)]
891mod tests {
892    use std::collections::BTreeMap;
893
894    use crate::config::{AgentKind, AgentSpec, Graph};
895
896    use super::*;
897
898    /// A store of its own, with no process-global state - which is the point of
899    /// [`Chats::at`], and why these can run in parallel.
900    fn store() -> (tempfile::TempDir, Chats) {
901        let tmp = tempfile::tempdir().expect("tempdir");
902        let chats = Chats::at(tmp.path().join("chats"));
903        (tmp, chats)
904    }
905
906    /// A task file of the shape [`plan::TASK_FILE_SPEC`] describes, long enough
907    /// that length is not one of the problems under test.
908    fn good_draft() -> String {
909        "# Report per-node durations in `magi show`\n\
910         \n\
911         ## Context\n\
912         \n\
913         `magi show` prints a run's nodes but not how long any of them took, so \
914         the operator cannot see which seat is expensive. The data is already \
915         in `run.events`.\n\
916         \n\
917         ## Change\n\
918         \n\
919         Add a duration column to the node table in `src/report.rs`.\n\
920         \n\
921         ## Constraints\n\
922         \n\
923         Do not change the JSON shape of a run record.\n\
924         \n\
925         ## Completion criteria\n\
926         \n\
927         - [ ] `magi show <run>` prints a duration for every completed node.\n\
928         - [ ] A node with no end event prints nothing rather than zero.\n\
929         \n\
930         ## Out of scope\n\
931         \n\
932         The TUI's detail pane.\n"
933            .to_owned()
934    }
935
936    /// A `kind = "command"` agent whose whole behaviour is a POSIX shell
937    /// script. No test in this module may spawn a real agent CLI: they are the
938    /// operator's paid subscriptions, they reach the network, and they are not
939    /// installed on CI.
940    fn mock_agent(dir: &Path, script: &str, env: BTreeMap<String, String>) -> AgentSpec {
941        let path = dir.join("mock-chat-agent.sh");
942        std::fs::write(&path, script).expect("write mock");
943        AgentSpec {
944            id: "mock".to_owned(),
945            kind: AgentKind::Command,
946            model: None,
947            command: vec!["sh".to_owned(), path.to_string_lossy().into_owned()],
948            extra_args: Vec::new(),
949            env,
950            prompt_delivery: None,
951        }
952    }
953
954    /// A config whose only agent is `spec`, with the graph left at its
955    /// defaults except for the language, so `language_note` stays out of the
956    /// prompt assertions.
957    fn config(spec: AgentSpec) -> Config {
958        Config {
959            agents: vec![spec],
960            graph: Graph {
961                language: "en".to_owned(),
962                ..Graph::default()
963            },
964            ..Config::default()
965        }
966    }
967
968    /// Echo a canned reply, ignoring the prompt on stdin.
969    const REPLY: &str = "#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' \"$MOCK_REPLY\"\n";
970
971    /// Say nothing and fail, the way a CLI that cannot start does.
972    const BROKEN: &str = "#!/bin/sh\ncat >/dev/null\nexit 3\n";
973
974    /// Reply with the prompt it was given, so a test can inspect exactly what
975    /// the leader received on stdin.
976    const ECHO: &str = "#!/bin/sh\ncat\n";
977
978    fn env(reply: &str) -> BTreeMap<String, String> {
979        BTreeMap::from([("MOCK_REPLY".to_owned(), reply.to_owned())])
980    }
981
982    #[test]
983    fn the_frozen_json_field_names_round_trip_through_disk() {
984        let (tmp, chats) = store();
985        let mut chat = Chat {
986            schema: SCHEMA,
987            id: "20260903-014455-ab12".to_owned(),
988            repo: tmp.path().to_owned(),
989            from: None,
990            agent: "sonnet".to_owned(),
991            status: ChatStatus::Open,
992            turns: vec![Turn {
993                who: Who::Operator,
994                body: "rework the config loader".to_owned(),
995                at: Timestamp::now(),
996            }],
997            draft: None,
998            task: None,
999            created_at: Timestamp::now(),
1000            updated_at: Timestamp::now(),
1001            seat: SeatState::new(SEAT, "sonnet", 7),
1002        };
1003        chats.put(&mut chat).expect("put");
1004
1005        // Asserted literally, against the text on disk. The web UI is written
1006        // against these names by hand, so a rename that only round-trips
1007        // through serde would break the phone silently.
1008        let raw = std::fs::read_to_string(chats.path_of(&chat.id)).expect("read back");
1009        let v: serde_json::Value = serde_json::from_str(&raw).expect("parse");
1010        for field in [
1011            "schema",
1012            "id",
1013            "repo",
1014            "from",
1015            "agent",
1016            "status",
1017            "turns",
1018            "draft",
1019            "task",
1020            "created_at",
1021            "updated_at",
1022        ] {
1023            assert!(v.get(field).is_some(), "missing field `{field}`");
1024        }
1025        assert_eq!(v["schema"], 1);
1026        assert_eq!(v["status"], "open");
1027        assert_eq!(v["turns"][0]["who"], "operator");
1028        assert_eq!(v["turns"][0]["body"], "rework the config loader");
1029        assert!(v["turns"][0].get("at").is_some());
1030        assert!(v["draft"].is_null());
1031        assert!(v["task"].is_null());
1032        assert!(v["from"].is_null());
1033
1034        let back = chats.get(&chat.id).expect("get");
1035        assert_eq!(back.id, chat.id);
1036        assert_eq!(back.turns, chat.turns);
1037        assert_eq!(back.status, ChatStatus::Open);
1038        assert_eq!(back.from, None);
1039    }
1040
1041    /// A conversation recorded before `from` existed must still read: the
1042    /// `#[serde(deny_unknown_fields)]` on [`Chat`] would otherwise make this
1043    /// field's addition a breaking change for every chat already on disk.
1044    #[test]
1045    fn a_chat_recorded_without_a_from_field_still_reads() {
1046        let (tmp, chats) = store();
1047        let path = chats.path_of("20260903-014455-ab12");
1048        std::fs::create_dir_all(chats.root()).expect("chats dir");
1049        std::fs::write(
1050            &path,
1051            serde_json::json!({
1052                "schema": SCHEMA,
1053                "id": "20260903-014455-ab12",
1054                "repo": tmp.path(),
1055                "agent": "sonnet",
1056                "status": "open",
1057                "turns": [],
1058                "draft": null,
1059                "task": null,
1060                "created_at": Timestamp::now().to_string(),
1061                "updated_at": Timestamp::now().to_string(),
1062                "seat": SeatState::new(SEAT, "sonnet", 7),
1063            })
1064            .to_string(),
1065        )
1066        .expect("write pre-`from` chat");
1067
1068        let chat = chats.get("20260903-014455-ab12").expect("must still read");
1069        assert_eq!(chat.from, None);
1070    }
1071
1072    #[test]
1073    fn derived_background_names_the_source_repository_and_carries_the_transcript() {
1074        let chat = Chat {
1075            schema: SCHEMA,
1076            id: "20260903-014455-ab12".to_owned(),
1077            repo: PathBuf::from("/repo/other"),
1078            from: None,
1079            agent: "sonnet".to_owned(),
1080            status: ChatStatus::Open,
1081            turns: vec![
1082                Turn {
1083                    who: Who::Operator,
1084                    body: "rework the queue drain".to_owned(),
1085                    at: Timestamp::now(),
1086                },
1087                Turn {
1088                    who: Who::Agent,
1089                    body: "which part of the drain?".to_owned(),
1090                    at: Timestamp::now(),
1091                },
1092            ],
1093            draft: None,
1094            task: None,
1095            created_at: Timestamp::now(),
1096            updated_at: Timestamp::now(),
1097            seat: SeatState::new(SEAT, "sonnet", 7),
1098        };
1099        let background = derived_background(&chat);
1100        assert!(background.contains("/repo/other"));
1101        assert!(background.contains("rework the queue drain"));
1102        assert!(background.contains("which part of the drain?"));
1103        assert!(background.contains("different"));
1104    }
1105
1106    #[tokio::test]
1107    async fn starting_a_derived_chat_carries_the_source_transcript_and_leaves_it_untouched() {
1108        let (tmp, chats) = store();
1109        let source_spec = mock_agent(tmp.path(), REPLY, env("which module?"));
1110        let source_cfg = config(source_spec);
1111        let source = start(
1112            &chats,
1113            &source_cfg,
1114            tmp.path().to_owned(),
1115            "rework the queue drain",
1116            None,
1117            None,
1118        )
1119        .await
1120        .expect("start source");
1121        let before = source.clone();
1122
1123        let other_repo = tmp.path().join("other-repo");
1124        std::fs::create_dir_all(&other_repo).expect("other repo dir");
1125        // Overwrites the script `source_spec` pointed at: the source's own
1126        // turn already ran, so only the derived chat's invocation sees this.
1127        let echo_spec = mock_agent(tmp.path(), ECHO, BTreeMap::new());
1128        let derived_cfg = config(echo_spec);
1129        let derived = start(
1130            &chats,
1131            &derived_cfg,
1132            other_repo,
1133            "same idea, different repository",
1134            None,
1135            Some(&source),
1136        )
1137        .await
1138        .expect("start derived");
1139
1140        assert_eq!(derived.from.as_deref(), Some(source.id.as_str()));
1141
1142        let prompt = &derived.turns.last().expect("agent reply").body;
1143        assert!(prompt.contains("Background: derived from another conversation"));
1144        assert!(prompt.contains(&source.repo.display().to_string()));
1145        assert!(prompt.contains("rework the queue drain"));
1146        assert!(prompt.contains("same idea, different repository"));
1147
1148        // Deriving a chat must not touch the one it came from.
1149        let reread = chats.get(&source.id).expect("source still on disk");
1150        assert_eq!(reread.status, before.status);
1151        assert_eq!(reread.turns, before.turns);
1152        assert_eq!(reread.draft, before.draft);
1153    }
1154
1155    /// [`build`] must not write the record anywhere: `chat_post` claims
1156    /// [`crate::web::Ui::begin_turn`] on `chat.id` between calling this and
1157    /// persisting it, and that ordering only closes the race it exists for
1158    /// (see `chat_post`'s doc) if nothing observable exists yet for anyone
1159    /// else to resolve, claim or record into ahead of the claim.
1160    #[test]
1161    fn build_constructs_the_record_without_writing_it_anywhere() {
1162        let (tmp, chats) = store();
1163        let spec = mock_agent(tmp.path(), REPLY, BTreeMap::new());
1164        let cfg = config(spec);
1165
1166        let chat = build(
1167            &cfg,
1168            tmp.path().to_owned(),
1169            "rework the config loader",
1170            None,
1171            None,
1172        )
1173        .expect("build");
1174
1175        assert!(
1176            !chats.path_of(&chat.id).is_file(),
1177            "build must not touch the filesystem"
1178        );
1179        assert!(
1180            chats.list().is_empty(),
1181            "no record must be resolvable until something calls `Chats::put`"
1182        );
1183    }
1184
1185    /// `roles.chatter`, not `roles.planner`, decides who answers this
1186    /// conversation - and when `chatter` is unset, `planner` still does, so
1187    /// an operator who only ever named a planner sees no change. This is the
1188    /// distinction the resident chat's timeout under a triple-booked `opus`
1189    /// (planner, chatter, and a judge seat all at once) turned up.
1190    #[tokio::test]
1191    async fn a_chat_prefers_the_chatter_role_over_the_planner_role() {
1192        let (tmp, chats) = store();
1193        let planner_spec = mock_agent(tmp.path(), REPLY, env("from the planner"));
1194        let mut chatter_spec = mock_agent(tmp.path(), REPLY, env("from the chatter"));
1195        chatter_spec.id = "chatter-mock".to_owned();
1196
1197        let mut cfg = Config {
1198            agents: vec![planner_spec.clone(), chatter_spec.clone()],
1199            graph: Graph {
1200                language: "en".to_owned(),
1201                ..Graph::default()
1202            },
1203            ..Config::default()
1204        };
1205        cfg.roles.planner = Some(planner_spec.id.clone());
1206        cfg.roles.chatter = Some(chatter_spec.id.clone());
1207
1208        let chat = start(
1209            &chats,
1210            &cfg,
1211            tmp.path().to_owned(),
1212            "rework the drain",
1213            None,
1214            None,
1215        )
1216        .await
1217        .expect("start with chatter set");
1218        assert_eq!(chat.agent, chatter_spec.id, "chatter must win over planner");
1219
1220        cfg.roles.chatter = None;
1221        let fallback = start(
1222            &chats,
1223            &cfg,
1224            tmp.path().to_owned(),
1225            "rework the drain again",
1226            None,
1227            None,
1228        )
1229        .await
1230        .expect("start with chatter unset");
1231        assert_eq!(
1232            fallback.agent, planner_spec.id,
1233            "unset chatter must fall back to planner, unchanged from before this role existed"
1234        );
1235    }
1236
1237    #[test]
1238    fn extract_draft_takes_the_last_task_block_and_ignores_other_fences() {
1239        let reply = "here is a sketch\n\
1240                     \n\
1241                     ```rust\n\
1242                     fn not_the_draft() {}\n\
1243                     ```\n\
1244                     \n\
1245                     ```task\n\
1246                     # first version\n\
1247                     ```\n\
1248                     \n\
1249                     ```json\n\
1250                     {\"also\": \"not it\"}\n\
1251                     ```\n\
1252                     \n\
1253                     revised:\n\
1254                     \n\
1255                     ```task\n\
1256                     # second version\n\
1257                     ## Completion criteria\n\
1258                     ```\n";
1259        assert_eq!(
1260            extract_draft(reply).as_deref(),
1261            Some("# second version\n## Completion criteria\n")
1262        );
1263    }
1264
1265    #[test]
1266    fn extract_draft_returns_none_when_there_is_no_task_block() {
1267        assert_eq!(extract_draft("which storage backend do you want?"), None);
1268        assert_eq!(extract_draft("```rust\nfn f() {}\n```\n"), None);
1269        // An empty block is not a draft: filing it would produce a task with
1270        // nothing in it.
1271        assert_eq!(extract_draft("```task\n```\n"), None);
1272    }
1273
1274    #[tokio::test]
1275    async fn a_reply_with_no_draft_leaves_the_existing_draft_in_place() {
1276        let (tmp, chats) = store();
1277        let spec = mock_agent(tmp.path(), REPLY, env("one more thing: which module?"));
1278        let cfg = config(spec);
1279        let mut chat = start(
1280            &chats,
1281            &cfg,
1282            tmp.path().to_owned(),
1283            "add durations",
1284            None,
1285            None,
1286        )
1287        .await
1288        .expect("start");
1289        chat.draft = Some(good_draft());
1290        chats.put(&mut chat).expect("put");
1291
1292        say(&mut chat, &chats, &cfg, "the report module")
1293            .await
1294            .expect("say");
1295
1296        assert_eq!(chat.draft.as_deref(), Some(good_draft().as_str()));
1297        assert_eq!(
1298            chats.get(&chat.id).expect("get").draft.as_deref(),
1299            Some(good_draft().as_str())
1300        );
1301    }
1302
1303    #[test]
1304    fn the_briefing_carries_the_task_file_spec_and_the_task_fence() {
1305        let brief = briefing("rework the config loader", Path::new("/repo"));
1306        // The spec verbatim, so the shape asked for cannot drift from the shape
1307        // `plan::review_draft` enforces.
1308        assert!(brief.contains(plan::TASK_FILE_SPEC));
1309        assert!(brief.contains("```task"));
1310        assert!(brief.contains("rework the config loader"));
1311        assert!(brief.contains("/repo"));
1312        assert!(brief.contains("completion criteria"));
1313    }
1314
1315    #[test]
1316    fn file_draft_refuses_a_bad_draft_with_every_problem() {
1317        let (tmp, chats) = store();
1318        let queue = Queue::at(tmp.path().join("queue"));
1319        let mut chat = Chat {
1320            schema: SCHEMA,
1321            id: "20260903-014455-ab12".to_owned(),
1322            repo: tmp.path().to_owned(),
1323            from: None,
1324            agent: "mock".to_owned(),
1325            status: ChatStatus::Open,
1326            turns: Vec::new(),
1327            // Short *and* missing completion criteria: both must be reported,
1328            // or the operator asks for one fix and gets refused again.
1329            draft: Some("# do the thing\n\nsome context.\n".to_owned()),
1330            task: None,
1331            created_at: Timestamp::now(),
1332            updated_at: Timestamp::now(),
1333            seat: SeatState::new(SEAT, "mock", 7),
1334        };
1335
1336        let problems = draft_problems(&chat).expect_err("a draft with no criteria is not fileable");
1337        assert!(
1338            problems.len() >= 2,
1339            "expected every problem, got {problems:?}"
1340        );
1341        assert!(problems.iter().any(|p| p.contains("completion criteria")));
1342        assert!(problems.iter().any(|p| p == plan::SHORT_DRAFT));
1343
1344        let err = file_draft(&mut chat, &chats, &queue, 0)
1345            .expect_err("file_draft must refuse it too")
1346            .to_string();
1347        for p in &problems {
1348            assert!(err.contains(p.as_str()), "`{p}` missing from `{err}`");
1349        }
1350        assert_eq!(chat.status, ChatStatus::Open);
1351        assert!(chat.task.is_none());
1352        assert!(queue.list().is_empty());
1353    }
1354
1355    #[test]
1356    fn file_draft_queues_a_good_draft_and_records_the_task() {
1357        let (tmp, chats) = store();
1358        let queue = Queue::at(tmp.path().join("queue"));
1359        let mut chat = Chat {
1360            schema: SCHEMA,
1361            id: "20260903-014455-cd34".to_owned(),
1362            repo: tmp.path().to_owned(),
1363            from: None,
1364            agent: "mock".to_owned(),
1365            status: ChatStatus::Open,
1366            turns: Vec::new(),
1367            draft: Some(good_draft()),
1368            task: None,
1369            created_at: Timestamp::now(),
1370            updated_at: Timestamp::now(),
1371            seat: SeatState::new(SEAT, "mock", 7),
1372        };
1373
1374        let id = file_draft(&mut chat, &chats, &queue, 5).expect("file");
1375
1376        assert_eq!(chat.status, ChatStatus::Filed);
1377        assert_eq!(chat.task.as_deref(), Some(id.as_str()));
1378        assert_eq!(
1379            chats.get(&chat.id).expect("get").task.as_deref(),
1380            Some(id.as_str()),
1381            "the task id must survive on disk, or the phone shows an unfiled chat"
1382        );
1383
1384        let task = queue.get(&id).expect("queued task");
1385        assert_eq!(task.title, queue::title_from(&good_draft(), 72));
1386        assert_eq!(task.instruction, good_draft());
1387        assert_eq!(task.priority, 5);
1388        assert_eq!(task.source, Source::Human);
1389    }
1390
1391    #[tokio::test]
1392    async fn say_appends_the_operator_turn_then_the_agent_turn() {
1393        let (tmp, chats) = store();
1394        let spec = mock_agent(tmp.path(), REPLY, env("which module?"));
1395        let cfg = config(spec);
1396        let mut chat = start(
1397            &chats,
1398            &cfg,
1399            tmp.path().to_owned(),
1400            "add durations",
1401            None,
1402            None,
1403        )
1404        .await
1405        .expect("start");
1406        // start is one operator turn (the idea) plus one agent turn.
1407        assert_eq!(chat.turns.len(), 2);
1408        assert_eq!(chat.turns[0].who, Who::Operator);
1409        assert_eq!(chat.turns[1].who, Who::Agent);
1410
1411        say(&mut chat, &chats, &cfg, "the report module")
1412            .await
1413            .expect("say");
1414
1415        assert_eq!(chat.turns.len(), 4);
1416        assert_eq!(chat.turns[2].who, Who::Operator);
1417        assert_eq!(chat.turns[2].body, "the report module");
1418        assert_eq!(chat.turns[3].who, Who::Agent);
1419        assert_eq!(chat.turns[3].body, "which module?");
1420        assert_eq!(chats.get(&chat.id).expect("get").turns, chat.turns);
1421    }
1422
1423    #[tokio::test]
1424    async fn a_failed_turn_keeps_the_operator_message_and_says_what_happened() {
1425        let (tmp, chats) = store();
1426        let good = mock_agent(tmp.path(), REPLY, env("which module?"));
1427        let cfg = config(good);
1428        let mut chat = start(
1429            &chats,
1430            &cfg,
1431            tmp.path().to_owned(),
1432            "add durations",
1433            None,
1434            None,
1435        )
1436        .await
1437        .expect("start");
1438
1439        // The chat is bound to roster agent `mock`, so break what `mock`
1440        // actually runs: `mock_agent` rewrites the same script path, which is
1441        // what it looks like when that CLI stops working mid-interview.
1442        mock_agent(tmp.path(), BROKEN, BTreeMap::new());
1443        let err = say(&mut chat, &chats, &cfg, "the report module")
1444            .await
1445            .expect_err("a turn with no answer is an error");
1446        assert!(err.to_string().contains("no answer"), "{err}");
1447
1448        let on_disk = chats.get(&chat.id).expect("get");
1449        assert_eq!(on_disk.turns.len(), 4);
1450        assert_eq!(
1451            on_disk.turns[2].body, "the report module",
1452            "the operator's message must survive the failure"
1453        );
1454        let note = &on_disk.turns[3];
1455        assert_eq!(note.who, Who::Agent);
1456        assert!(
1457            note.body.starts_with(MAGI_NOTE),
1458            "the failure must be visible in the transcript: {}",
1459            note.body
1460        );
1461        assert!(note.body.contains("your message is saved"));
1462    }
1463
1464    #[test]
1465    fn list_puts_open_chats_before_filed_ones() {
1466        let (tmp, chats) = store();
1467        let make = |id: &str, status: ChatStatus| {
1468            let mut c = Chat {
1469                schema: SCHEMA,
1470                id: id.to_owned(),
1471                repo: tmp.path().to_owned(),
1472                from: None,
1473                agent: "mock".to_owned(),
1474                status,
1475                turns: Vec::new(),
1476                draft: None,
1477                task: None,
1478                created_at: Timestamp::now(),
1479                updated_at: Timestamp::now(),
1480                seat: SeatState::new(SEAT, "mock", 7),
1481            };
1482            chats.put(&mut c).expect("put");
1483        };
1484        // The filed one is newest, so ordering by id alone would put it first.
1485        make("20260901-000000-0001", ChatStatus::Open);
1486        make("20260902-000000-0002", ChatStatus::Open);
1487        make("20260903-000000-0003", ChatStatus::Filed);
1488
1489        let ids: Vec<String> = chats.list().into_iter().map(|c| c.id).collect();
1490        assert_eq!(
1491            ids,
1492            [
1493                "20260902-000000-0002",
1494                "20260901-000000-0001",
1495                "20260903-000000-0003"
1496            ]
1497        );
1498        assert_eq!(chats.count_open(), 2);
1499    }
1500}